Compare commits

...
54 Commits
Author SHA1 Message Date
YHH bb971bd403 chore(release): 0.34.0 (#484) 2026-05-09 11:39:14 +08:00
YHH 06204c760e fix(dashboard): stop pushing localStorage lang back to server on init (#483)
The dashboard's initLangFromServer() had two branches when localStorage
disagreed with the server config:

  1. If `rx.langExplicit` was set (user picked a language in dashboard
     panel at some point) → POST localStorage value back to server
  2. Otherwise → adopt the server value

Branch 1 silently clobbered CLI-side `/language` changes. Sequence:

  - User picks zh-CN in dashboard once → rx.langExplicit=1, rx.lang=en
    (or whichever)
  - User later runs `/language zh-CN` in the CLI → server config is
    now zh-CN
  - User opens dashboard tab (or browser restores it from a previous
    session) → init runs → POSTs localStorage's old value back, server
    flips to en, CLI's choice is lost on next launch

Server config is the single source of truth. localStorage is purely a
render-cache to avoid first-paint flicker — never push it back.

Removes EXPLICIT_KEY / isExplicit / markExplicit entirely; the stale
key in existing users' localStorage is harmless dead data.
2026-05-09 11:33:49 +08:00
YHH c504707af8 feat(ui): /keys reference + first-run mouse/clipboard tip (#481)
Two layers around the new TipCard:

1. /keys — make the slash command real
   It was already referenced in the edit-gate tip's footer ("Run /keys
   anytime for the full list") but no handler existed; typing /keys hit
   the unknown-command branch. Add the spec under "info", a handler
   that pushes a multi-section TipCard with the full reference, and a
   `postKeys` callback on SlashContext wired to log.pushTip.

   The reference is sectioned: keyboard / mouse / copy-paste /
   edit-gate. Each section has its own subtitle and aligned key column.

2. First-run mouse + clipboard tip
   SGR mouse tracking captures the wheel for chat scroll but also eats
   the terminal's native right-click-paste — without explanation users
   think the prompt is broken. Add a one-time tip mirroring the
   edit-gate pattern: pushed on first launch, suppressed thereafter via
   a `mouseClipboardHintShown` flag.

TipCard now supports multiple sections (single-section tips just have
one un-titled section). The previously-shipped tipEditBindings is
re-wrapped in a single section; behavior unchanged.

i18n: keysReference (4 sections, ~25 rows) + tipMouseClipboard (1
section, 3 rows) + handlers.basic.keysNeedsTui in EN.ts and zh-CN.ts;
matching shape in types.ts. The i18n /keys row already existed but its
description was outdated — refreshed in both languages.
2026-05-09 11:06:23 +08:00
YHH f0a552bed5 feat(ui): structured TipCard variant for onboarding hints (#480)
The edit-gate one-time tip currently renders as raw multi-line text
inside a stepProgress LiveCard — `✓` glyph (success semantic, wrong
for educational content) plus a manually-inlined `▸ TIP:` prefix,
columns aligned with hand-counted spaces that wrap badly on narrow
terminals, and the "shown once" disclaimer crammed into the body
parenthetical.

Replace it with a dedicated `TipCard` kind:
- Single `ⓘ` glyph in accent color, no duplicated prefix
- Topic + "shown once" badge in a header row (justified)
- Each row is its own `<Text>` with real column alignment driven by
  stringWidth (works for CJK widths too)
- Footer separated from body by a blank row, dim color
- No border — tips are scrollback content, not modal

i18n: `tipEditBindings` becomes a structured `{topic, rows, footer}`
object across types.ts / EN.ts / zh-CN.ts. New helper `tObj<T>(path)`
in i18n/index.ts for non-string entries; the existing `t()` is
unchanged. Adds a tiny `tipShownOnce` string for the badge.

Adds a `pushTip()` method on Scrollback alongside `pushInfo()`; the
new `tip.show` event flows through the same reducer / dispatch pipeline
as every other card kind.
2026-05-09 10:54:52 +08:00
YHH bb5b124817 @-picker rebuild + mouse-wheel chat scroll (#479)
* feat(at-picker): rebuild as file browser with streaming search

Empty / trailing-slash queries (`@`, `@src/`) browse one directory level
with a single readdir — folders selectable, drill via Tab. Any non-slash
filter (`@foo`, `@auth/log`) kicks off a cancelable streaming walk across
the full tree, batching matches into the popup as they're found, with a
`searching… N scanned` footer while in flight.

Replaces the upfront 500-file walk that was unusable on large repos —
on a 5k-file tree the cap evicted ~90% of paths before ranking, leaving
the picker effectively empty.

- walkFilesStream: cancelable streaming walker; powers search mode
- listDirectory: single-level browse, no recursion; powers browse mode
- parseAtQuery: splits `dir/filter` with trailing-slash awareness
- listFilesWithStatsAsync now delegates to walkFilesStream (no behavior
  change for existing callers)
- expandAtUrls + helpers split into at-mentions-url.ts to keep
  at-mentions.ts under the 800-line ceiling

Closes #478

* fix(scroll): route mouse wheel via SGR mouse tracking

Enable DECSET 1006 + 1000 at startup so the terminal reports wheel
events as `\x1b[<btn;col;row;M` mouse sequences instead of translating
them to ↑/↓ key presses. The chat-scroll handler routes the resulting
mouseScrollUp/Down events to scrollback, bypassing the arrow-key path
entirely — the wheel now scrolls chat regardless of buffer state, while
↑/↓ keys retain their existing PromptInput bindings (history recall on
empty buffer, cursor motion otherwise).

The SGR mouse parser already lived in stdin-reader.ts; this just
turns on the terminal-side feature so events actually flow.

Cost: terminal-native drag-to-select needs a modifier (Shift on Windows
Terminal / Alacritty / WezTerm, Option on iTerm2) — same trade-off as
tmux, Claude Code, Cursor's terminal.
2026-05-09 10:28:28 +08:00
J3y0r 636ad7e551 feat(ui): add theme manager support (#452)
Closes #449

- Theme runtime via useColor() hook + ThemeProvider context
- Built-in themes: default, dark, light, tokyo-night, github-dark, github-light, plus auto/high-contrast
- Persisted via config: theme.name, with auto resolving against terminal background
- /theme slash command (advanced group) with arg completer

Merged through #453 slash refactor (theme placed in advanced group) and #424 semantic config additions.
2026-05-08 18:19:22 -07:00
YHH 1c360ac81b chore(release): 0.33.2 (#476) 2026-05-09 09:02:01 +08:00
YHH c120ed3a39 fix: ↑/↓ on empty buffer recall history; doctor finds tokenizer (#468) (#475)
Two bugs in one issue.

1. **↑/↓ stopped recalling history.** 9254d3a unbound history from
   arrows because Windows Terminal + ConPTY can translate mouse-wheel
   events to ↑/↓ keystrokes — wheel-up was clobbering the prompt with
   a recalled message. The fix moved history to Ctrl+P / Ctrl+N.

   That was the right call for legacy ConPTY users but broke the
   universal CLI muscle memory for everyone else (bash / zsh / fish
   all bind ↑ to history). Linux / macOS / modern Windows Terminal
   users don't have the wheel-translation problem in the first place.

   Restore ↑/↓ on empty buffer = history; keep Ctrl+P / Ctrl+N as
   the wheel-immune fallback. The App-level keystroke handler still
   routes ↑/↓ to chat-scroll once the user is unpinned (subsequent
   wheel events keep scrolling), so the only regression for legacy
   ConPTY is the FIRST wheel-up from a pinned position recalls a
   prompt. Documented; ⌃P/N is one keystroke away.

   Drops the now-dead `chatScrollHandoff` plumbing.

2. **Doctor reported "tokenizer not found" when it was installed.**
   The tokenizer ships under `data/` in the npm tarball and the
   runtime resolver in tokenizer.ts has three candidates including a
   `createRequire("reasonix/package.json")` probe — finds it
   reliably. The doctor had its own resolver that walked
   `dist/cli/commands/doctor.js → ../../../data/`, which broke after
   the lazy-import refactor in #467 emitted the doctor at
   `dist/cli/doctor-HASH.js` (one level shallower). Walked above the
   package root and reported missing.

   Reuse the runtime resolver (now exported) so the two paths can
   never disagree.
2026-05-09 08:58:10 +08:00
YHH 42f75c7478 chore(release): 0.33.1 (#474) 2026-05-09 08:24:46 +08:00
YHH 34b8275f20 feat(ui): wallet pill on status row + tighter prompt hints (#473)
The status row was reading status.cost (turn) and status.cacheHit
but ignoring two fields the reducer already populates:
status.sessionCost (running USD total) and status.balance (DeepSeek
wallet, refreshed on every submit). balanceColor() with red/orange
thresholds even sat in tokens.ts unused.

New WalletPill segment renders right of the cache pill:
  ⛁ ¥1.20 spent  /  ¥45.32 left
with thresholded color on the balance number. Shown only when
the terminal is ≥90 cols and at least one of the two values is
known, so narrow sessions don't wrap.

PromptInput: friendlier placeholder copy ("ask anything") and the
hint footer now uses keycap/label spacing (⇧⏎ instead of
shift/alt+⏎) for a less command-line-jargon read.
2026-05-09 08:21:09 +08:00
YHH b40db0f469 chore(release): 0.33.0 (#472) 2026-05-09 08:06:19 +08:00
YHH 4029b7dc25 fix(ui): drop dead toolHistoryRef leak — every tool result retained for session lifetime (#465) (#471)
The /tool slash command was removed in #453 but its supporting
plumbing was left behind: every tool result was still being pushed
into a useRef array, with no consumer ever reading it. Long-running
sessions accumulated full Read/Grep/Bash output indefinitely and ran
out of heap.

#465 reports a JS heap OOM after ~2.6 hours on v0.31.0; this leak
matches the failure shape (mark-compacts ineffective, 4GB ceiling).
2026-05-09 07:59:25 +08:00
YHH 9c69513650 fix(/cwd): re-bootstrap semantic_search on workspace swap (#470)
#459 follow-up. When `/cwd` swaps the workspace root, FS/shell/memory
tools were re-registered against the new path but `semantic_search`
kept pointing at the OLD root — meaning queries silently searched
the previous project's index, or the tool stayed registered when
the new dir had no index at all.

Split semantic re-bootstrap from the sync `reregisterTools` callback:
- `reregisterTools` stays sync (FS/shell/memory must land before the
  next tool dispatch).
- New `reBootstrapSemantic(root) => Promise<{ enabled }>` reads disk
  to probe index compatibility, registers `semantic_search` if found,
  unregisters it otherwise.

App.tsx fires the async tail with `void ...then(postInfo)` so the
slash dispatch returns synchronously (matches the codebase's
fire-and-forget pattern documented in slash/dispatch.ts). User sees:

  ▸ workspace switched to /new/path
  ▸ semantic_search re-pointed at /new/path        ← if compatible
  ▸ semantic_search disabled (no compatible index in /new/path)
                                                   ← if not

Same machinery handles the launch path — `code.tsx` now calls
`reBootstrapSemantic(rootDir)` instead of bootstrap directly so
the unregister-on-skip behavior is exercised on every entry path.
2026-05-09 07:27:03 +08:00
YHH df8f146b79 perf(cli): lazy-import dashboard server + strategic App marks (#469)
Stage 2 follow-up to #464. Continuing the profile-driven trims.

The dashboard server module (src/server/index.ts plus its ~4200 LOC
of transitive dashboard code) was being eagerly imported into App.tsx
even when --no-dashboard was set or auto-start hadn't fired. Move it
to a dynamic import inside startDashboard's startup IIFE — it's
already async, so the await import() is free in the structural
sense.

Measurable hot-path delta is small (~4ms within noise) but it shaves
real bytes off the first-paint module graph and removes a synchronous
dependency on the entire server tree. Cold paths benefit too.

Also adds two strategic marks (`app_render_start`, `app_inner_start`)
for future profiling. They're harmless when the env var is off and
expose the React-component-body boundary in the dump, which makes
the existing `first_paint` delta self-explanatory.

Findings from the deeper investigation (now reverted as noisy diagnostics):
- App's disk-hitting useState initializers (loadHooks, loadSlashUsage,
  loadEditMode, hydrateCardsFromMessages) are all sub-ms each.
  Deferring them buys nothing measurable.
- The 280-300ms gap between `app_inner_start` and `first_paint` is
  React reconciliation + Ink's terminal commit + the first useEffect
  flushing. Trimming further requires reducing initial render
  complexity, not deferring side work.

Stage 3 (CI budget gate) remains deferred — the platform-dependent
nature of these numbers makes a hard threshold fragile. Profile
harness is already documented in-tree via the env-var hint
(`REASONIX_PROFILE_STARTUP=0 to silence`).
2026-05-09 07:17:26 +08:00
YHH e6b247939c perf(cli): lazy-import per-command modules (Stage 2 of #464) (#467)
Stage 1 (#466) showed module-load was 57% of cold-start cost (436ms
of 764ms total on this Windows box). Every `reasonix <subcommand>`
invocation was eagerly loading every other command's module —
including chat.tsx → App.tsx → React/Ink. `reasonix version` paid
for the chat UI it doesn't need.

Move every `import { fooCommand } from "./commands/foo.js"` into a
dynamic `await import()` inside the relevant `.action()`. tsup
splits these into separate chunks automatically; Node only loads
them when the user invokes that subcommand.

Measured impact:

  reasonix code (hot path, 5 runs avg): 778ms vs 764ms baseline
    → +14ms regression (within measurement noise; the 388ms that
      used to live in `cli_module_loaded` now lives in
      `code_command_enter` since code.tsx is dynamic-imported)

  reasonix version: ~140ms vs ~440ms baseline → 290ms saved
  reasonix --help:  ~140ms vs ~440ms baseline → 290ms saved

Cold-path commands (version, --help, doctor, commit, sessions,
events, replay, diff, mcp *, update, index) all benefit. They
no longer pay the ink/react/dashboard import cost.

Hot path (`code` / `chat`) is roughly neutral. Real wins on hot
path will come from trimming first_paint (316ms, 41% of total) —
follow-up.

Test fix: bundle-smoke.test.ts asserted `from "ink"` lived in
dist/cli/index.js specifically; with code-splitting the import
moves to a chunk file. Updated assertion to scan all dist/cli/*.js.
The semantics are preserved — ink stays external (not bundled),
just no longer in the entry chunk.
2026-05-09 01:07:57 +08:00
YHH 424a9301f4 perf(cli): startup-profile instrumentation (Stage 1 of #464) (#466)
#464 plan was profile-first; this lands Stage 1.

Adds src/cli/startup-profile.ts with markPhase/dumpStartupProfile
gated behind REASONIX_PROFILE_STARTUP=1. When unset (default), every
call is a no-op — zero cost in normal runs.

Marks placed at:
- cli_module_loaded — top of the bin entry, after all transitive imports
- chat_command_enter / config_loaded
- mcp_launch / mcp_connected_M_of_N (only when MCP specs exist)
- code_command_enter — top of `reasonix code`
- semantic_bootstrap_start / _done_(enabled|skipped)
- ink_render_call — just before `render(<Root/>)`
- first_paint — App's first useEffect after mount, also dumps the profile

Baseline collected on this Windows box (no MCP, semantic index already
built):

  436ms  cli_module_loaded             (+436)
    3ms  code_command_enter            (+3)
    2ms  semantic_bootstrap_start      (+2)
    2ms  semantic_bootstrap_done_enabled  (+2)
    3ms  chat_command_enter            (+2)
    0ms  config_loaded                 (+0)
    2ms  ink_render_call               (+1)
  316ms  first_paint                   (+316)
  ─── 764ms total

Read: module-load (Node + transitive imports) is 57% of total, first
paint (Ink render + React commit) is 41%. Everything else is noise.
Stage 2 should target lazy-loading heavy import dependencies that
land synchronously today (DeepSeek client, Ink, dashboard server,
embedding provider). MCP serial setup hypothesis from the issue
needs a real config to confirm — punted until we have a measured
multi-MCP run.

Stage 3 (CI budget assertion) deferred until Stage 2 trims have
landed and we have stable numbers to set thresholds against.
2026-05-09 00:55:08 +08:00
YHH b171848250 fix(ux): fuzzy at-mention ranking + /cwd slash for #459 parts 1+2 (#463)
#459 is a meta-issue with three first-five-minutes UX papercuts.
This lands two of them; startup-perf (part 3) needs profiling
first and gets a separate issue.

## Part 1: at-mention picker — subsequence fallback

The picker rejected anything that wasn't a substring match, so
typing `atmnt` for `at-mentions` returned nothing. Adds a fuzzy-
subsequence scorer as a fallback after substring lookup misses.
Substring hits still win — class 0/1/2 cap at 29_999, subseq
starts at 30_000. Fuzzy scoring rewards consecutive runs and
basename-anchored matches, penalizes total path length.

Files walking + cap-aware UI hints already existed (the audit
showed them at useCompletionPickers.ts:102 and
AtMentionSuggestions.tsx:55–77). The missing piece was ranking.

## Part 2: /cwd slash command

The plumbing was already there: `codeMode.reregisterTools(root)`
exists in code.tsx, App.tsx already comments "/cwd mutates this
state to swap the workspace mid-session". The slash command
itself was just never wired.

Wires it end-to-end:
- SlashContext gets a `switchCwd(path) => { ok, info }` hook.
- App.tsx implements switchCwd: stat + isDirectory check,
  reregisterTools(newPath), setCurrentRootDir, reload hooks.
- New `/cwd` (alias `/sandbox`) command in the code group.
- Handler in handlers/edits.ts.
- EN + zh-CN strings added.

Out of scope: semantic-index re-bootstrap on cwd swap (current
bootstrap runs once in code.tsx). Tracked as a follow-up.

## Part 3 (deferred)

Slow startup needs profiling before redesign. Will open a separate
issue with a concrete "mark phases, defer post-mount" plan.
2026-05-09 00:41:14 +08:00
YHH cf393f7f9e feat(tools): glob, search_content context lines, cross-file multi_edit (#462)
Three quality polishes to the filesystem toolbelt, all small, all
addressing real friction the model hits often.

glob — new tool. Mtime-sorted file walk with picomatch glob syntax
(**/, *.{ts,tsx}, etc.). Default sort=mtime desc so "what did I
touch lately" works out of the box; sort=name for deterministic
listings. Skips deps by default, capped at 200 results (1000 max)
with overflow notice. Different from search_files (substring on
basename) and search_content (greps file bodies).

search_content — new `context: N` param mirroring `grep -C N`.
Output uses ripgrep convention: `:` after the line number on hits,
`-` on context lines, `--` separating non-adjacent windows.
Adjacent windows merge automatically so a hit at line 42 and 44
with context:1 produces one continuous block. Capped at 20 lines
on each side to keep the maxListBytes budget intact.

multi_edit — reshape from single-file `{ path, edits: [...] }` to
cross-file `{ edits: [{ path, search, replace }, ...] }`. Reads
each touched file once, applies edits in array order against an
in-memory buffer (so a later edit can match text inserted by an
earlier one), validates everything, then writes all touched files.
ANY validation failure → ZERO writes (true atomicity at the
validation layer; sequential disk writes after that). Output
header now reports both "N edits across M files".

This is a hard schema break for multi_edit, but it shipped
unreleased six hours ago — no consumers to migrate.

Test coverage: 24 new cases (glob: 6, context: 4, multi_edit: 8
covering single + cross + atomic + missing-file). 2243 pass on
verify.
2026-05-09 00:29:28 +08:00
YHH 771443c670 feat(tools): todo_write — lightweight in-session task tracker (#460)
submit_plan is the heavy gate (user approval, per-step checkpoints,
plan-mode toggle); ask_choice is the branching primitive. Neither
covers "I have 4 things to do, let me record them and tick them off
as I go" — that's prose-list territory today, which means lost
context the moment the loop folds history.

todo_write fills the gap: replace-set semantics, no approval pause,
no file writes. Validated: at most one in_progress, status enum,
non-empty content+activeForm. readOnly=true so it stays callable in
/plan mode (recording intent during planning is exactly when it's
most useful).

Wired into reasonix code with an onTodosUpdated callback hook for
future TUI surfacing (live progress strip, etc.). System prompt
gains a "When to track multi-step intent" section telling the model
to reach for it on 3+ step tasks instead of inlining "1. ... 2. ..."
in prose.
2026-05-09 00:15:09 +08:00
YHH 747651e55d feat(tools): multi_edit — atomic batch SEARCH/REPLACE in one file (#458)
N edit_file calls for a single-file rename burns a tool round-trip
per site and isn't atomic — a mid-batch failure leaves the file half-
edited. multi_edit takes an array of {search, replace} entries,
applies them sequentially against an in-memory buffer, and only
writes once all succeed. Any failure (search not found, ambiguous
match, empty search) → no edits are written.

Per-edit rules match edit_file: exact whitespace-sensitive search,
must be unique at the moment that edit applies. Sequential semantics
mean edit N+1 can match text that edit N inserted.

Closes a concrete pain point flagged when surveying the toolbelt
against typical multi-site refactor flows.
2026-05-09 00:04:33 +08:00
YHH d4a4c599b5 chore(release): 0.32.0 (#456) 2026-05-08 23:22:24 +08:00
YHH 192a63b8d2 fix(at-mentions): expand @<dir> to a listing; recover symlink-to-file in picker (#455)
Closes #451

UI flicker (section 3 of #451) is already tracked in #412.

1. expandAtMentions treated `@<dir>` as a `not-file` skip — the model
   never saw what was in the directory. It now resolves the path, walks
   the project root with the existing gitignore layers, filters to
   entries under the directory, and inlines a `<directory>` block
   capped at DEFAULT_AT_DIR_MAX_ENTRIES (200). `@docs/` and `@docs`
   resolve to the same key, so trailing-slash typing doesn't double-fire.

2. listFilesWithStatsSync / listFilesWithStatsAsync called only
   ent.isFile(), which returns false for symlinks. Symlinked source
   files were silently dropped from the picker. Both walkers now stat
   through symlinks; symlinks-to-files come back, symlinks-to-dirs
   stay dropped (cycle risk), broken links stay dropped (nothing to
   point at).
2026-05-08 23:14:23 +08:00
YHH 58f4544d23 fix(plan): dock active plan card; auto/yolo skips per-step checkpoint (#454)
Two related plan-mode UX gaps.

1. PlanLiveRow had nothing to dock. `case "plan_proposed"` dropped the
   gate payload's steps/summary, and the approve path never dispatched
   plan.show — so no card with variant: "active" ever existed and the
   filter in isActivePlanInFlight returned null. Stash steps/summary in
   the existing refs on plan_proposed, materialize the active card on
   approve, and drop+re-show on revise-accept so the dock tracks tail
   rewrites instead of silently going stale.

2. "Checkpoint — step done" fired after every mark_step_complete even
   in auto/yolo, defeating the whole point of those modes (shell and
   edit-gate already self-skip). plan_checkpoint now checks editModeRef
   and, in auto/yolo, resolves the gate with "continue" without UI.
   Per-step rollback snapshot still runs so /restore granularity stays.
2026-05-08 23:01:08 +08:00
YHH 76e42ac665 slash: unified preset+model picker, grouped suggestions, kill 11 commands + harvest/branch (#453)
* slash: unified preset+model picker, grouped suggestions, kill 11 commands + harvest/branch

The slash surface was 58 commands deep with no organization, two confusable
options for "fresh start" (/clear vs /new), separate preset and model commands
that solved the same user question ("what AI am I using"), and two heavy
features that almost nobody opted into. Pulled all of that apart.

Picker

* ModelPicker now shows the 3 presets at the top with cost/headline copy and
  the model catalog below; auto-detects which preset the loop currently matches
  (model + effort + autoEscalate) so the cursor lands on the active row. Both
  /preset (no arg) and /model (no arg) open it.

SlashSuggestions

* SlashCommandSpec gains a `group` field (chat / setup / info / session /
  extend / code / jobs / advanced). Suggestion palette renders section headers
  on a bare /; advanced rows hide behind a "+ N advanced · type to search"
  footer until the user types a letter. /help walks the same registry, so
  there's one source of truth.

Telemetry

* New ~/.reasonix/slash-usage.json counter (read-modify-write, atomic rename)
  feeds suggestSlashCommands so frequent commands sort first within a prefix.
  emitSlashInvoked is now wired to the events.jsonl sidecar.

Removals (commands)

* /models, /keys, /resume, /semantic — redundant with picker / /help / /doctor.
* /clear merged into /new (now an alias) — the visual-only clear was the most
  common confusion source ("why does /clear keep my context?").
* /effort, /rename, /forget, /apply-plan, /think, /tool, /mcp browse — second
  pass; either covered by other UI (sessions picker has rename/delete; preset
  locks effort) or self-described as fallback / debug.

Removals (features)

* /harvest and the Pillar-2 plan-state extractor (src/harvest.ts).
* /branch and the parallel-sample selector (src/consistency.ts,
  src/loop/branch.ts). Streaming/branching coupling, BranchCard,
  branch_start/progress/done events, transcript planState/harvestedTurns,
  --harvest/--branch CLI flags, all gone.
* benchmarks/harvest/ deleted; ARCHITECTURE.md collapsed from four pillars
  to three; README + zh-CN + dashboard/PARITY updated.

i18n

* Cleared dead keys for every removed command (handlers.basic.helpHelp..helpExit,
  keys*, semantic, resume*, models*, harvest*, branch*, effort*, think*, tool*,
  apply-plan*, slash.harvest, slash.branch, ui.harvest*, ui.branch*).

Tests: 138 files / 2192 tests pass. Public API drops harvest, runBranches,
TypedPlanState, BranchSummary, etc.; consumers using those break intentionally.

* ci: drop harvest-bench dry-run step

Follows the harvest feature removal — benchmarks/harvest/ no longer exists,
so the CI step was breaking on ERR_MODULE_NOT_FOUND. τ-bench dry-run stays.
2026-05-08 22:31:52 +08:00
kabaka a9471ae8e1 feat(semantic): OpenAI-compatible embedding provider (#424)
Configurable API URL / key / model / request body for embeddings, replacing the Ollama-only setup. Adds a provider abstraction in src/index/semantic/embedding.ts, index identity check + abort wiring through the builder, API-key redaction in dashboard/status output, and en-US + zh-CN strings for the new provider UI.
2026-05-08 22:13:01 +08:00
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
176 changed files with 12081 additions and 6065 deletions

No files matched your search

+11 -6
View File
@@ -4,6 +4,10 @@ about: Something is broken
labels: bug
---
> **Screen flicker, garbled output, leftover artifacts, cursor jumping?**
> Use the **Display / rendering issue** template instead — it asks for the
> terminal-specific info we need to diagnose those.
**What happened**
A clear and concise description.
@@ -14,12 +18,13 @@ What you expected to happen.
Steps or minimal code that reproduces it.
**Environment**
- Reasonix version:
- Node version:
- OS:
- Shell (bash, zsh, fish, PowerShell, etc.):
- Terminal emulator (kitty, WezTerm, Windows Terminal, etc.):
- Reasonix version (`reasonix --version`):
- Node version (`node --version`):
- OS (Windows 11 / macOS 14 / Ubuntu 24.04 / …):
- Shell (bash, zsh, fish, PowerShell 7, PowerShell 5.1, cmd, …):
- Terminal app (Windows Terminal, iTerm2, Alacritty, kitty, WezTerm, **VSCode integrated**, **Cursor integrated**, Hyper, …):
- DeepSeek model (e.g. `deepseek-v4-flash`, `deepseek-v4-pro`):
**Logs / transcript**
If using the CLI, attach the relevant chunk of `--transcript`.
If using the CLI, attach the relevant chunk of `--transcript`, or run
`reasonix doctor` and paste the output.
+109
View File
@@ -0,0 +1,109 @@
---
name: Display / rendering issue
about: Screen flicker, garbled output, leftover artifacts, cursor jumping
labels: bug, rendering
---
> Display problems almost always come from the **terminal emulator**, not
> the shell. Please fill the terminal section carefully — `bash vs PowerShell`
> tells us very little; `VSCode integrated terminal vs Windows Terminal`
> tells us everything.
**Symptom** (tick all that apply)
- [ ] Whole screen flickers / flashes during streaming response
- [ ] Lines tear or only half-redraw
- [ ] Stale output left behind after a frame updates
- [ ] Cursor jumps to wrong column or vanishes
- [ ] Mojibake / wrong-width characters (e.g. `□`, half-width emoji)
- [ ] Other (describe below)
**When it happens**
- [ ] During assistant streaming (token-by-token output)
- [ ] When tool cards expand / collapse
- [ ] During scroll-up / scrollback
- [ ] On terminal resize
- [ ] On launch / on quit
- [ ] Other (describe below)
**Terminal — the important part**
Where exactly are you running `reasonix`?
- [ ] **VSCode** integrated terminal — VSCode version: `?`
- [ ] **Cursor** integrated terminal — Cursor version: `?`
- [ ] **Windows Terminal** — version: `?`
- [ ] **cmd.exe** (legacy console host)
- [ ] **PowerShell ISE** (note: ISE doesn't support ANSI — most things will look broken)
- [ ] **iTerm2** / **Terminal.app** / **Alacritty** / **kitty** / **WezTerm** / **Hyper**
- [ ] tmux / screen / mosh — and inside which outer terminal? `?`
- [ ] Other:
> 💡 **How to find your VSCode / Cursor version**
> `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS).
> Paste the whole panel — version + commit + Electron + xterm.js if shown.
**Diagnostic dump — copy/paste output**
Run **one** of the snippets below in the same terminal where you saw the
issue, and paste the output here:
<details><summary>PowerShell (Windows)</summary>
```powershell
reasonix --version; node --version
$PSVersionTable.PSVersion.ToString()
[System.Environment]::OSVersion.VersionString
"TERM=$env:TERM"
"TERM_PROGRAM=$env:TERM_PROGRAM"
"TERM_PROGRAM_VERSION=$env:TERM_PROGRAM_VERSION"
"COLORTERM=$env:COLORTERM"
"WT_SESSION=$env:WT_SESSION"
"VSCODE_INJECTION=$env:VSCODE_INJECTION"
"WSL_DISTRO_NAME=$env:WSL_DISTRO_NAME"
```
</details>
<details><summary>bash / zsh (macOS / Linux / WSL / Git Bash)</summary>
```bash
reasonix --version; node --version
uname -a
echo "TERM=$TERM"
echo "TERM_PROGRAM=$TERM_PROGRAM"
echo "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION"
echo "COLORTERM=$COLORTERM"
echo "WT_SESSION=$WT_SESSION"
echo "VSCODE_INJECTION=$VSCODE_INJECTION"
echo "WSL_DISTRO_NAME=$WSL_DISTRO_NAME"
```
</details>
```
<paste output here>
```
**VSCode / Cursor users only — terminal settings**
Open Settings (`Ctrl+,`), search `terminal.integrated.gpuAcceleration`,
report current value: `auto` / `on` / `canvas` / `off` — `?`
Already tried any of:
- [ ] Switching `gpuAcceleration` to a different value
- [ ] Detaching the terminal (drag tab into its own window)
- [ ] Running the same command in a non-VSCode terminal — did it still flicker? `yes / no`
**Reproduction**
Steps that reliably trigger it (commands run, files edited, was a tool
streaming a long response, was the window being resized, …):
1.
2.
3.
**Screen recording (strongly preferred)**
A 5–10s GIF or MP4 is worth 1000 words for rendering bugs. Drop it in
this comment box — GitHub uploads attachments inline.
-3
View File
@@ -48,6 +48,3 @@ jobs:
# DEEPSEEK_API_KEY in CI.
- name: τ-bench harness dry-run
run: npx tsx benchmarks/tau-bench/runner.ts --dry --out /tmp/tau-dry.json
- name: Harvest-bench harness dry-run
run: npx tsx benchmarks/harvest/runner.ts --dry --out /tmp/harvest-dry.json
+495
View File
@@ -3,6 +3,501 @@
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.34.0] — 2026-05-09
**Headline:** two big UX shifts in the composer. The `@`-mention picker
is rebuilt as a streaming file browser — `@` alone shows the immediate
directory listing, anything you type fires a cancelable walk that
streams matches in as it finds them, with a `searching… N scanned`
footer. Fixes the unusable-on-large-repos behavior reported by
@xlingyun8-maker (5000 files would evict 90% before ranking, picker
showed nothing). The mouse wheel now scrolls chat history regardless of
where the cursor is, via SGR mouse tracking — wheel events route
through `mouseScrollUp/Down` instead of being mistranslated as ↑/↓ by
Windows Terminal / ConPTY.
The supporting cast: a structured `TipCard` variant replaces the
multi-line text crammed into a step-progress card (the existing
edit-gate hint reported as ugly), a real `/keys` command with the full
keyboard + mouse + copy-paste reference (was a dangling reference in
the edit-gate tip footer for months), and a one-time mouse/clipboard
tip on first launch so users don't think the prompt is broken when
right-click stops doing the terminal's native paste.
Critical bug fix at the bottom: dashboard was silently overwriting
CLI-side `/language` changes by pushing localStorage back to the
server on every page load.
**Features:**
- feat(at-picker): rebuild as file browser with streaming search.
Empty / trailing-slash queries (`@`, `@some/dir/`) browse one
directory level via a single `readdir` — folders selectable, drill
with Tab. Any non-slash filter (`@foo`, `@auth/log`) kicks off a
cancelable streaming walk across the full tree, matches batch into
the popup as the walker finds them, footer shows scan progress
in flight. Drops the 500-file walker cap; cancellation bounds work
instead. New public API: `walkFilesStream` (streaming + abort),
`listDirectory` (single-level browse), `parseAtQuery` (dir/filter
split with trailing-slash awareness). `expandAtUrls` + helpers
split into `at-mentions-url.ts` to keep `at-mentions.ts` under the
800-line ceiling. (#479, closes #478)
- fix(scroll): route mouse wheel via SGR mouse tracking. Enable
DECSET 1006 + 1000 at startup so the terminal reports wheel events
as `\x1b[<btn;col;row;M` mouse sequences instead of translating
them to ↑/↓ key presses. The chat-scroll handler routes the
resulting `mouseScrollUp/Down` events to scrollback, bypassing the
arrow-key path entirely. ↑/↓ keys retain their existing PromptInput
bindings (history recall on empty buffer, cursor motion otherwise).
The SGR mouse parser already lived in `stdin-reader.ts`; this just
turns on the terminal-side feature. Cost: terminal-native drag-to-
select needs a modifier (Shift on Windows Terminal / Alacritty /
WezTerm, Option on iTerm2) — same convention as tmux, Claude Code,
Cursor's terminal. (#479)
- feat(ui): structured TipCard variant for onboarding hints. The
edit-gate one-time tip rendered as raw multi-line text inside a
`stepProgress` LiveCard — `✓` glyph (success semantic, wrong for
educational content) plus a manually-inlined `▸ TIP:` prefix,
columns aligned with hand-counted spaces that wrap badly on narrow
terminals. Replaces with a dedicated `TipCard` kind: single `ⓘ`
glyph in accent color, topic + "shown once" badge in a justified
header row, each row gets its own `<Text>` with column alignment
driven by `string-width` (CJK-correct), footer separated from body
by a blank row, no border. (#480)
- feat(ui): `/keys` reference + first-run mouse/clipboard tip.
`/keys` was already referenced in the edit-gate tip's footer ("Run
/keys anytime for the full list") but no handler existed; typing
`/keys` hit the unknown-command branch. Adds a multi-section
TipCard with the full keyboard / mouse / copy-paste / edit-gate
reference. Adds a first-run mouse + clipboard tip mirroring the
edit-gate pattern (suppressed thereafter via a
`mouseClipboardHintShown` flag) so users don't think the prompt
is broken when right-click stops doing the terminal's native
paste. TipCard now supports multiple sections; existing single-
section tips are unchanged. New i18n helper `tObj<T>(path)` for
structured translation entries. (#481)
**Bug fixes:**
- fix(dashboard): stop pushing localStorage lang back to server on
init. The dashboard's `initLangFromServer()` had a one-way sync
rule: when localStorage's lang differed from server config AND
localStorage was tagged "explicit", it POSTed localStorage's value
back, silently clobbering CLI-side `/language` changes whenever the
dashboard tab next loaded (including auto-restored tabs from
previous browser sessions). Server config is the single source of
truth now; localStorage stays as a render-cache to avoid first-paint
flicker but is never pushed back. Removes `EXPLICIT_KEY` /
`isExplicit` / `markExplicit` entirely. (#483)
## [0.33.2] — 2026-05-09
**Headline:** two bug fixes for #468 reported by @dacec354.
**Bug fixes:**
- fix(ui): ↑/↓ on an empty buffer recalls prompt history again. The
binding was unbound from arrows back in 9254d3a because Windows
Terminal + ConPTY can translate mouse-wheel events to ↑/↓
keystrokes (wheel-up was clobbering the prompt with a recalled
message); history moved to Ctrl+P / Ctrl+N. That was right for
legacy ConPTY but broke the universal CLI convention for
everyone else (bash / zsh / fish all bind ↑ to history). Restored
↑/↓ on empty buffer = history; Ctrl+P / Ctrl+N stays as the
wheel-immune fallback. Dead `chatScrollHandoff` plumbing dropped.
(#475, closes part 1 of #468)
- fix(doctor): tokenizer check now finds the file. The runtime
resolver in `tokenizer.ts` had three candidates including a
`createRequire("reasonix/package.json")` probe and worked
reliably; the doctor had its own copy of the path math that
walked `dist/cli/commands/doctor.js → ../../../data/`. After the
lazy-import refactor in #467 the doctor compiles to
`dist/cli/doctor-HASH.js` (one level shallower), so three `..`
walked above the package root and reported "tokenizer not
found" even when the npm tarball had it. Reuse the runtime
resolver so the two paths can never disagree. (#475, closes part
2 of #468)
## [0.33.1] — 2026-05-09
**Headline:** the bottom status row now shows the wallet. Both
`status.balance` and `status.sessionCost` were already being
populated by the reducer (refreshed on every submit), and a
`balanceColor()` helper with red/orange thresholds had been sitting
unused in the theme — but `StatusRow` only ever rendered the
per-turn cost and cache-hit pills. Pure plumbing gap; users had
to type `/cost` to see the running spend or remaining DeepSeek
balance. Plus a small polish pass on the prompt input footer.
**UI:**
- feat(ui): wallet pill on the status row. New segment renders
right of the cache pill: `⛁ ¥1.20 spent / ¥45.32 left`. Spent
shows when `sessionCost > 0`, balance shows when known; the
separator only renders when both are present. Balance is colored
via `balanceColor()` (red <¥5, orange <¥20, brand otherwise).
Hidden on terminals narrower than 90 cols so the row doesn't
wrap. (#473)
- feat(ui): friendlier prompt input. Placeholder reads "ask
anything · slash for commands · at-sign for files" instead of
"type a message". Hint footer extracted into a `HintRow`
component with keycap/label spacing — keys (⏎ ⇧⏎ ↑↓ esc ^C) in
`FG.meta`, labels in `FG.faint`. Replaces `shift/alt+⏎` with
`⇧⏎` and `ctrl-c` with `^C`. (#473)
## [0.33.0] — 2026-05-09
**Headline:** the filesystem toolbelt grew a hand. Three new tools —
`multi_edit` for atomic multi-site SEARCH/REPLACE in one file (or
across files in one call), `todo_write` for lightweight in-session
intent tracking, and `glob` for mtime-sorted file walks with
picomatch syntax — close the gaps where the model was either
round-tripping eight `edit_file` calls or losing its plan to a
context fold. `search_content` also gains `-C N` context lines.
The other half is cold-start surgery (#464). Stage 1 adds a zero-cost
profiler gated behind `REASONIX_PROFILE_STARTUP=1`. Stage 2 lazy-
imports every per-command module and the dashboard server, paying
for the chat UI only when `reasonix code` actually runs. `reasonix
version` and `reasonix --help` drop ~290ms (~440ms → ~140ms);
`reasonix code` is unchanged on the hot path. Critical bug fix at
the bottom: a long-session OOM where every tool result was retained
indefinitely in a useRef array left behind when `/tool` was deleted.
**Features:**
- feat(tools): `multi_edit` — atomic batch SEARCH/REPLACE. N edits
apply sequentially against an in-memory buffer with one write at
the end; any failure (empty edits, search not found, ambiguous
match) leaves the file untouched. Edit N+1 can match text inserted
by edit N (composable refactors). Cuts the round-trip cost of
multi-site rewrites and removes the half-applied-edit failure mode
of looping `edit_file`. (#458)
- feat(tools): `multi_edit` cross-file mode. Same atomicity guarantee
extended across files: dry-run all targets, then write. One failure
rolls the whole batch back. (#462)
- feat(tools): `todo_write` — in-session task tracker. Replace-set
semantics (full list every call), no approval gate, no file writes.
Each item is `{ content, status, activeForm }` with `status: pending
| in_progress | completed`. Validated: at most one `in_progress` at
a time. Empty list signals work-done. Sits between `submit_plan`
(heavy: approval + checkpoints) and prose lists (lost on history
fold). Stays callable in plan mode (`readOnly: true`). (#460)
- feat(tools): `glob` — mtime-sorted file walks. Picomatch syntax
(`**/`, `*.{ts,tsx}`); defaults to `sort: "mtime"` so "what did I
touch lately" works without arguments. `sort: "name"` for
deterministic listings. Skips deps by default, capped at 200 (1000
max) with overflow notice. (#462)
- feat(tools): `search_content` gains `context: N`. Semantics match
`grep -C N`; output uses ripgrep convention (`:` after match line,
`-` after context). (#462)
**Performance:**
- perf(cli): `REASONIX_PROFILE_STARTUP=1` cold-start profiler. Marks
at `cli_module_loaded`, `chat_command_enter`, `config_loaded`,
`mcp_launch`, `mcp_connected_M_of_N`, `code_command_enter`,
`semantic_bootstrap_start`/`_done_*`, `ink_render_complete`. Single
env-var read when off; dumps to stderr at first paint when on.
Stage 1 of #464. (#466)
- perf(cli): lazy-import every per-command module. Each
`reasonix <subcommand>` only loads its own command's chunk. tsup
splits, Node loads on first invocation. `reasonix version` and
`reasonix --help` drop ~290ms (~440ms → ~140ms); `reasonix code`
hot path unchanged (within noise). Stage 2 of #464. (#467)
- perf(cli): lazy-import dashboard server. ~4200 LOC of HTTP / static
asset code (`startDashboardServer`) moved to a dynamic
`await import()` inside the App startup IIFE — only loads when the
user actually opens the dashboard. Two new App marks
(`app_render_start`, `app_inner_start`) clarify the first-paint
delta. (#469)
**Bug fixes:**
- fix(ui): drop dead `toolHistoryRef` leak. `/tool` was removed in
#453 but its supporting plumbing stayed behind: every tool result
was being pushed into a useRef array with no consumer reading it,
so long sessions retained the full text of every Read / Grep /
Bash call indefinitely. Reported by @trytsomile as a
`FATAL ERROR: Ineffective mark-compacts near heap limit` crash
after ~2.6h on v0.31.0 (V8's 4GB ceiling). 48 lines deleted across
4 files; `state.cards[].output` (which actually drives scrollback
rendering) is untouched. (#471, closes #465)
- fix(/cwd): re-bootstrap `semantic_search` on workspace swap.
FS / shell / memory tools re-registered against the new root, but
`semantic_search` kept pointing at the old one — queries silently
hit the previous project's index, or the tool stayed registered
when the new directory had no index. Split the async re-bootstrap
out of the sync `reregisterTools` callback; App.tsx fires
`void reBootstrapSemantic(root).then(postInfo)` so the slash
dispatch returns synchronously. Tail of #459. (#470)
- fix(ux): fuzzy `@`-mention ranking. The picker's substring-only
ranker rejected typo'd subsequences — `@atmnt` returned nothing for
`at-mentions.ts`. Adds a fuzzy-subsequence fallback that triggers
only when the substring lookup misses; substring hits still win
(classes 0/1/2 cap at 29_999, subseq starts at 30_000). Also adds
the `/cwd` slash for in-session workspace swap. Parts 1+2 of #459.
(#463)
## [0.32.0] — 2026-05-08
**Headline:** the slash surface lost weight. Eleven redundant
commands gone (`/clear`, `/keys`, `/models`, `/effort`, `/rename`,
`/forget`, `/think`, `/tool`, `/apply-plan`, `/semantic`,
`/resume`), the unified preset+model picker replaces three
near-identical commands, and the two heaviest features almost
nobody opted into — `/harvest` (Pillar-2 plan-state extraction)
and `/branch` (parallel-sample selector) — are deleted along with
their backing modules, events, transcript fields, and CLI flags.
The four-pillar architecture collapses to three. The slash registry
now carries a `group` tag (chat / setup / info / session / extend
/ code / jobs / advanced) and bare-`/` suggestions render those
groups with advanced rows hidden behind a `+ N advanced · type to
search` footer. A new `~/.reasonix/slash-usage.json` counter
sorts frequent commands first within a prefix.
The other half of the release is plan-mode UX. PlanLiveRow had
nothing to dock — a code path that should have materialized an
"active" plan card on approval was missing, so the bottom strip
stayed empty after `/plan`. Fixed. And the per-step "Checkpoint —
step done" picker fired in auto/yolo too, defeating the whole
point of those modes; auto/yolo now resolve "continue" without
prompting while still creating per-step rollback snapshots so
`/restore` granularity stays intact. Plus a long-standing
`@`-mention bug: typing `@docs/` produced an empty `not-file`
placeholder. It now expands to a recursive `<directory>` listing
respecting the project's gitignore, and symlinked source files
finally appear in the `@`-picker.
**Features:**
- feat(semantic): OpenAI-compatible embedding provider. Configure
custom API URL / key / model / request body for embeddings,
replacing the Ollama-only setup. Dashboard semantic panel adds
a provider dropdown with "OpenAI-Compatible" alongside Ollama,
clearer status messages, and detailed indexing-job phases
(scanning / embedding / writing). Community contribution from
@kabaka9527. (#424)
- feat(slash): unified preset+model picker. `ModelPicker` shows the
three presets at the top with cost/headline copy and the model
catalog below; cursor lands on the active row (auto-detects which
preset matches the loop's current model + effort + autoEscalate).
Both `/preset` (no arg) and `/model` (no arg) open it. (#453)
- feat(slash): grouped suggestions + usage telemetry.
`SlashCommandSpec` gains a `group` field; suggestion palette
renders section headers on bare `/` with advanced rows hidden
behind a footer. New `~/.reasonix/slash-usage.json` counter
(read-modify-write, atomic rename) feeds `suggestSlashCommands`
so frequent commands sort first; `slash.invoked` events emit to
events.jsonl for cross-session analysis. `/help` walks the same
grouped registry so there's one source of truth. (#453)
**Bug fixes:**
- fix(plan): dock active plan card. `case "plan_proposed"` had
been dropping the gate payload's `steps`/`summary`, and the
approve path never dispatched `plan.show` — so no card with
`variant: "active"` ever existed and `isActivePlanInFlight`
returned null. PlanLiveRow now docks correctly after approval,
and the dock tracks tail rewrites on revise-accept. (#454)
- fix(plan): auto/yolo skip the per-step checkpoint picker. The
"Checkpoint — step done" picker fired after every
`mark_step_complete` regardless of edit mode — shell and
edit-gate already self-skip in auto/yolo, but plan checkpoints
kept stopping the model. The gate handler now checks
`editModeRef` and resolves "continue" without UI; per-step
rollback snapshot still runs so `/restore` granularity is
preserved. `review` mode is unchanged. (#454)
- fix(at-mentions): `@<dir>` expands to a recursive listing.
Was treated as a `not-file` skip, leaving the model with an
empty placeholder. Walks the project root with the existing
gitignore layers, filters to entries under the directory, and
inlines a `<directory path="..." entries="N">` block capped at
`DEFAULT_AT_DIR_MAX_ENTRIES` (200). `@docs/` and `@docs`
resolve identically. (#455, closes #451)
- fix(at-mentions): symlinks-to-files appear in the `@`-picker.
`Dirent.isFile()` returns false for symlinks, so symlinked
source files never showed up in completions. Both
`listFilesWithStatsSync` and `listFilesWithStatsAsync` now stat
through symlinks; symlinks-to-files come back, symlinks-to-dirs
stay dropped (cycle risk), broken links stay dropped (nothing
to point at). (#455, closes #451)
**Removals — slash commands:**
- `/clear` (merged into `/new` as alias — was the most common
source of "what's the difference?" confusion)
- `/models` (picker covers it)
- `/keys` (folded into `/help`)
- `/resume` (sessions picker has switch action)
- `/semantic` (folded into `/doctor`)
- `/effort` (preset locks effort)
- `/rename` and `/forget` (sessions picker actions)
- `/apply-plan` (plan picker handles the fallback path)
- `/think` and `/tool` (debug-only; events.jsonl records both)
- `/mcp browse` entry (handler still routes `["browse"]`)
**Removals — features:**
- `/harvest` (Pillar-2 plan-state extraction): `src/harvest.ts`,
`--harvest` CLI flag, `harvestedTurns` transcript field.
- `/branch` (parallel-sample selector): `src/consistency.ts`,
`src/loop/branch.ts`, `BranchCard`,
`branch_start/progress/done` events, `--branch` CLI flag.
- `benchmarks/harvest/` deleted; `ARCHITECTURE.md` collapses from
four pillars to three; README + zh-CN + `dashboard/PARITY.md`
updated.
**Removals — public API:**
- `src/index.ts` drops `harvest`, `runBranches`,
`aggregateBranchUsage`, `defaultSelector`, `emptyPlanState`,
`isPlanStateEmpty`, and the `TypedPlanState`, `HarvestOptions`,
`BranchSample`, `BranchSummary`, `BranchProgress`,
`BranchOptions`, `BranchResult`, `BranchSelector` types.
Consumers depending on these break intentionally — they were
experimental from the start and never met the cache-first
cost target this project gates on.
## [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
+17 -7
View File
@@ -98,6 +98,20 @@ npx reasonix code # paste a DeepSeek API key on first run; persists after
Requires Node ≥ 22. Tested on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Get a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
`npx` is the recommended path — no global install, always picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
### Subcommand cheatsheet
| Command | When to use |
|---|---|
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
| `reasonix update` | Upgrade Reasonix itself. |
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
```bash
@@ -119,15 +133,11 @@ Edit the file (`description:` frontmatter + body), then `/skill list` to see it.
## What makes Reasonix different
The loop is organized around four pillars. Each one solves a problem generic agent frameworks don't even see — because they were designed for a different cache mechanic.
<p align="center">
<a href="./docs/ARCHITECTURE.md"><img src="docs/assets/pillars.svg" alt="Reasonix four pillars — Cache-first loop, R1 thought harvesting, Tool-call repair, Cost control" width="880"/></a>
</p>
The loop is organized around three pillars. Each one solves a problem generic agent frameworks don't even see — because they were designed for a different cache mechanic.
<sub align="center">
Click any card to read the full architecture writeup → [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2](./docs/ARCHITECTURE.md#pillar-2--r1-thought-harvesting-opt-in) · [Pillar 3](./docs/ARCHITECTURE.md#pillar-3--tool-call-repair) · [Pillar 4](./docs/ARCHITECTURE.md#pillar-4--cost-control-v06)
Click through to the full architecture writeup → [Pillar 1 — Cache-first loop](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2 — Tool-call repair](./docs/ARCHITECTURE.md#pillar-2--tool-call-repair) · [Pillar 3 — Cost control](./docs/ARCHITECTURE.md#pillar-3--cost-control-v06)
</sub>
@@ -162,7 +172,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
## Documentation
- [**Architecture**](./docs/ARCHITECTURE.md) — the four pillars, cache-first loop, harvesting, scaffolds
- [**Architecture**](./docs/ARCHITECTURE.md) — three pillars: cache-first loop, tool-call repair, cost control
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
- [**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
+16 -6
View File
@@ -52,6 +52,20 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
要求 Node ≥ 22。已在 macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)测过。[去拿 DeepSeek API Key →](https://platform.deepseek.com/api_keys) · 完整 flag 看 `reasonix code --help`。
`npx` 是推荐路径 —— 不用全局安装,每次都拿到最新版本。如果你天天用、想把 `reasonix` 装到 `PATH` 上,跑一次 `reasonix update` 就行,它会替你跑 `npm install -g`。
### 子命令速查
| 命令 | 适用场景 |
|---|---|
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
| `reasonix update` | 升级 Reasonix 本身。 |
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)见 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
```bash
@@ -73,15 +87,11 @@ npx reasonix code --dir /path/to/project # 也可以用相对路径
## Reasonix 的不同之处
整个循环围绕四根支柱组织。每一根解决的都是通用 agent 框架根本看不见的问题 —— 因为它们是为另一种缓存机制设计的。
<p align="center">
<a href="./docs/ARCHITECTURE.md"><img src="docs/assets/pillars.zh-CN.svg" alt="Reasonix 四大支柱 — 缓存优先循环、R1 思维提取、工具调用修复、成本控制" width="880"/></a>
</p>
整个循环围绕三根支柱组织。每一根解决的都是通用 agent 框架根本看不见的问题 —— 因为它们是为另一种缓存机制设计的。
<sub align="center">
各支柱完整说明 → [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2](./docs/ARCHITECTURE.md#pillar-2--r1-thought-harvesting-opt-in) · [Pillar 3](./docs/ARCHITECTURE.md#pillar-3--tool-call-repair) · [Pillar 4](./docs/ARCHITECTURE.md#pillar-4--cost-control-v06)
各支柱完整说明 → [Pillar 1 — 缓存优先循环](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2 — 工具调用修复](./docs/ARCHITECTURE.md#pillar-2--tool-call-repair) · [Pillar 3 — 成本控制](./docs/ARCHITECTURE.md#pillar-3--cost-control-v06)
</sub>
-106
View File
@@ -1,106 +0,0 @@
# Harvest eval harness (Pillar 2)
Measures Pillar 2 (R1 Thought Harvesting) the same way `tau-bench/`
measures Pillar 1 (Cache-First Loop): isolate one variable, produce
numbers anyone can reproduce.
## What's different from τ-bench-lite?
Deliberately different task shape:
| | τ-bench-lite | harvest-bench |
|---|---|---|
| task style | multi-turn tool-use | single-turn reasoning |
| user simulator | yes (LLM) | no |
| DB state | yes | no |
| checker | DB-predicate | regex + set/value compare |
| target | Pillar 1 | Pillar 2 |
| model | deepseek-chat (default) | deepseek-reasoner (only R1 has `reasoning_content` for harvest to work on) |
The two harnesses answer different questions:
- **τ-bench-lite**: *"Does Cache-First actually cut cost on real tool-use
workflows?"* (yes — 47.7pp cache, −39% cost on 48-run data.)
- **harvest-bench**: *"Does the extra V3 harvest call add measurable
value above plain R1?"* (TBD — run it to find out.)
## Modes
Three modes isolate one variable each:
| mode | model | harvest | what it measures |
|---|---|---|---|
| `baseline` | deepseek-chat | off | floor reference — V3 at its best |
| `reasoner` | deepseek-reasoner | off | raw R1 gain over V3 on reasoning |
| `reasoner-harvest` | deepseek-reasoner | on | R1 + the extra V3 harvest call |
Deltas:
- `baseline → reasoner` answers "is R1 worth the price on these
problems?"
- `reasoner → reasoner-harvest` answers "is the harvest call worth
its incremental cost?"
## Quickstart
```bash
# Dry-run — no API, smoke-test the wiring
npx tsx benchmarks/harvest/runner.ts --dry
# Full run (live DeepSeek, costs ~$0.20-0.60 for 6 tasks × 3 modes × 1 repeat)
export DEEPSEEK_API_KEY=sk-...
npx tsx benchmarks/harvest/runner.ts
# Tighter run with 3 repeats (~$0.60-2.00)
npx tsx benchmarks/harvest/runner.ts --repeats 3
# Only the hard tasks (skip the easy floor)
npx tsx benchmarks/harvest/runner.ts --task pseudoprime_base2
npx tsx benchmarks/harvest/runner.ts --task derangements_d7
npx tsx benchmarks/harvest/runner.ts --task euler_quadratic_break
# Bump per-call timeout (reasoner + harvest occasionally blow past the 120s default)
npx tsx benchmarks/harvest/runner.ts --timeout 600
# Per-run transcripts so you can reasonix replay / diff them
npx tsx benchmarks/harvest/runner.ts --repeats 3 --transcripts-dir transcripts/
# Render report
npx tsx benchmarks/harvest/report.ts benchmarks/harvest/results-*.json
```
## Tasks (v0.3 seed)
Split into two bands:
**Easy band** — V3 chat solves these, so baseline has a real pass rate and the bench has a reference floor.
| id | shape | why |
|---|---|---|
| `mod7_list` | number theory, 29-element list | R1 often tries enumeration first, then reaches for modular arithmetic — clean rejectedPaths signal |
| `flips_until_3heads` | probability, single integer | classic recurrence; R1 either derives or recalls, harvest should see hypotheses diverge |
| `three_hats` | logic puzzle, one-word answer | pure deduction chain, tests harvest's ability to extract the nested reasoning |
**Hard band** — picked specifically for known V3 failure modes. If R1 systematically beats V3 on these, and harvest further beats R1-only, we have our Pillar 2 story.
| id | shape | why V3 tends to fail |
|---|---|---|
| `pseudoprime_base2` | smallest Fermat pseudoprime to base 2 = **341** | V3 often answers 561 (Carmichael) or some other composite it remembers — R1 actually checks 2^n mod n |
| `derangements_d7` | D_7 = **1854** | V3 approximates via n!/e and rounds wrong, or recalls D_6/D_8; R1 uses the recurrence D_n=(n−1)(D_{n−1}+D_{n−2}) |
| `euler_quadratic_break` | smallest n where n²+n+41 is composite = **40** | V3 frequently confuses with n²−n+41 (first fails at 41); R1 checks small n systematically |
Adding a new task: see `tasks.ts`. Any checker that's deterministic is
fair game — extract numeric/list/text with regex, compare with set
equality or string matching.
## Non-goals
- **No LLM-as-judge.** Brittle and expensive; defeats the point of a
reproducible bench. If a checker is too hard to write deterministically,
the task doesn't belong here.
- **No tool-use tasks.** Those live in `tau-bench/`. Different story.
- **No multi-turn.** v0.3 harness is single-turn Q/A. Multi-turn reasoning
eval is separate scope.
- **No benchmark-data cherry-picking.** When results come in, we publish
them whether they validate Pillar 2 or not. "harvest didn't help on
these 3 tasks" is still useful information.
-82
View File
@@ -1,82 +0,0 @@
# Reasonix harvest eval (Pillar 2)
**Date:** 2026-04-21T16:44:21.011Z
**Tasks:** 6 · repeats × 1 · modes: baseline, reasoner, reasoner-harvest
**Reasonix version:** 0.2.2
## Summary by mode
| mode | runs | pass rate | cache hit | cost / run | harvest turns | subgoals | uncertainties |
|---|---:|---:|---:|---:|---:|---:|---:|
| baseline | 6 | 100% | 39.5% | $0.001371 | 0.0 | 0.0 | 0.0 |
| reasoner | 6 | 100% | 74.2% | $0.004170 | 0.0 | 0.0 | 0.0 |
| reasoner-harvest | 6 | 83% | 61.6% | $0.003304 | 0.8 | 2.8 | 1.3 |
## Deltas
- **baseline → reasoner**
- pass rate: 0pp
- cost: ×3.04 (each run costs more)
- harvest signal / run: 0.0 subgoals, 0.0 uncertainties
- **baseline → reasoner-harvest**
- pass rate: -17pp
- cost: ×2.41 (each run costs more)
- harvest signal / run: 2.8 subgoals, 1.3 uncertainties
## Per-task breakdown
| task | mode | rep | verdict | cache | cost | sg | un | note |
|---|---|---:|:---:|---:|---:|---:|---:|---|
| mod7_list | baseline | 1 | ✅ | 86.5% | $0.001361 | 0 | 0 | |
| mod7_list | reasoner | 1 | ✅ | 86.5% | $0.004797 | 0 | 0 | |
| mod7_list | reasoner-harvest | 1 | ✅ | 86.5% | $0.003045 | 4 | 2 | |
| flips_until_3heads | baseline | 1 | ✅ | 92.8% | $0.001198 | 0 | 0 | |
| flips_until_3heads | reasoner | 1 | ✅ | 92.8% | $0.002830 | 0 | 0 | |
| flips_until_3heads | reasoner-harvest | 1 | ✅ | 92.8% | $0.001691 | 4 | 0 | |
| three_hats | baseline | 1 | ✅ | 57.7% | $0.001514 | 0 | 0 | |
| three_hats | reasoner | 1 | ✅ | 57.7% | $0.009340 | 0 | 0 | |
| three_hats | reasoner-harvest | 1 | ✅ | 57.7% | $0.009651 | 3 | 2 | |
| pseudoprime_base2 | baseline | 1 | ✅ | 0.0% | $0.001894 | 0 | 0 | |
| pseudoprime_base2 | reasoner | 1 | ✅ | 75.3% | $0.003128 | 0 | 0 | |
| pseudoprime_base2 | reasoner-harvest | 1 | ❌ | 0.0% | $0.000000 | 0 | 0 | This operation was aborted |
| derangements_d7 | baseline | 1 | ✅ | 0.0% | $0.001273 | 0 | 0 | |
| derangements_d7 | reasoner | 1 | ✅ | 71.9% | $0.003492 | 0 | 0 | |
| derangements_d7 | reasoner-harvest | 1 | ✅ | 71.9% | $0.002843 | 3 | 2 | |
| euler_quadratic_break | baseline | 1 | ✅ | 0.0% | $0.000985 | 0 | 0 | |
| euler_quadratic_break | reasoner | 1 | ✅ | 61.0% | $0.001431 | 0 | 0 | |
| euler_quadratic_break | reasoner-harvest | 1 | ✅ | 61.0% | $0.002594 | 3 | 2 | |
## Scope
Unlike τ-bench-lite, these tasks are single-turn reasoning problems (no user simulator, no DB, no tool calls). Checkers are deterministic — regex + set / value compare, never an LLM judge. The point is to isolate whether the Pillar 2 harvest step adds measurable value above plain reasoner usage.
Interpretation: `baseline` (chat / V3) is a floor. `reasoner` shows the raw R1 gain. `reasoner-harvest` isolates the cost + quality delta from the extra V3 harvest call.
## Findings (v0.3, 18-run set)
Two bands of tasks tried (easy: mod7 / flips / hats; hard: pseudoprime341 / D_7 / Euler quadratic). The hard band was chosen for *known V3 failure modes*. Result:
1. **V3 chat passed all six tasks, including the three "hard" ones.** DeepSeek V3 knew 341 is the smallest base-2 pseudoprime, computed D_7=1854, and identified n=40 as the first Euler-polynomial failure. The tasks are well-known enough to be in training; the trap-answer variants (561, 265, 41) that the checkers specifically reject didn't fire.
2. **Reasoner cost 3.04× baseline for identical pass rates (6/6 both sides).** On this task set, R1 adds cost and latency without measurable quality. The cache-hit story *does* extend to reasoner (74.2% mean), so Pillar 1 generalizes to R1 — but that's not a Pillar 2 story.
3. **Harvest produced real signal** (mean 2.8 subgoals, 1.3 uncertainties per run where it fired) but didn't improve outcomes. One run hit the 300s timeout (bumped from 120s), suggesting reasoner-harvest latency is still unpredictable on some problems.
### What this tells us about Pillar 2 positioning
We've now given harvest two shots at showing answer-quality value on pure reasoning tasks. Both times V3 ate its lunch. This isn't a framework bug — it's a positioning signal:
- **On well-known math / logic / counting problems, DeepSeek V3 is strong enough that paying for R1 is not justified by accuracy.** Pillar 2's "smart preset" cost multiplier (10×) quoted in the README is real but for most single-question Q/A it buys nothing.
- **Harvest's plausible value surfaces are not "better math answers".** More likely: (a) transparency / auditability for developers, (b) planning support when tools are in the mix, (c) driving branch-sampling on cases where uncertainty correlates with wrongness. None of those are tested here.
### Recommendation
Don't ship v0.3 on the "harvest makes answers more correct" claim — the data doesn't support it. Two honest paths forward:
- **Reframe Pillar 2 as a developer-facing introspection feature** (debug visibility into R1's planning). Keep harvest opt-in; stop implying it improves accuracy on end-user tasks.
- **Move harvest eval to tool-use contexts** where uncertainty matters for *which tool to call next*, not *is this math right*. That's a new task shape — different harness, not a bigger task set here.
In either case, the next Reasonix release is better served by MCP client (Pillar 1 leverage across the ecosystem) than by a bigger harvest-bench data push.
### Notes
- Cache hit on baseline runs surprised me (39.5% mean). That's DeepSeek's *cross-session* prompt cache: the same system prompt ran many times across modes and runs warmed the server-side cache. Real cross-session evidence that Cache-First's value isn't only per-session.
- 5-subgoals cap still hit uniformly. `HarvestOptions.maxItems` default is 5; harvest is truncating. Moot unless we find a setting where that matters.
-229
View File
@@ -1,229 +0,0 @@
/** Render a harvest-bench results.json into a markdown report. CLI usage in README. */
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import {
ALL_MODES,
type HarvestBenchReport,
type HarvestMode,
type HarvestRunResult,
} from "./types.js";
interface CliArgs {
input: string;
outPath: string;
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = { input: "", outPath: "benchmarks/harvest/report.md" };
const positional: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--out") out.outPath = argv[++i] ?? out.outPath;
else if (a && !a.startsWith("--")) positional.push(a);
}
out.input = positional[0] ?? "";
if (!out.input) {
throw new Error("usage: npx tsx benchmarks/harvest/report.ts <results.json> [--out report.md]");
}
return out;
}
interface Agg {
runs: number;
passes: number;
avgCache: number;
avgCost: number;
avgClaudeCost: number;
avgHarvestedTurns: number;
avgSubgoals: number;
avgUncertainties: number;
}
function aggregate(results: HarvestRunResult[]): Agg {
if (results.length === 0) {
return {
runs: 0,
passes: 0,
avgCache: 0,
avgCost: 0,
avgClaudeCost: 0,
avgHarvestedTurns: 0,
avgSubgoals: 0,
avgUncertainties: 0,
};
}
const passes = results.filter((r) => r.verdict === "pass").length;
const mean = (fn: (r: HarvestRunResult) => number) =>
results.reduce((s, r) => s + fn(r), 0) / results.length;
return {
runs: results.length,
passes,
avgCache: mean((r) => r.cacheHitRatio),
avgCost: mean((r) => r.costUsd),
avgClaudeCost: mean((r) => r.claudeEquivalentUsd),
avgHarvestedTurns: mean((r) => r.harvestedTurns),
avgSubgoals: mean((r) => r.totalSubgoals),
avgUncertainties: mean((r) => r.totalUncertainties),
};
}
export function renderReport(report: HarvestBenchReport): string {
const lines: string[] = [];
lines.push("# Reasonix harvest eval (Pillar 2)");
lines.push("");
lines.push(`**Date:** ${report.meta.date}`);
lines.push(
`**Tasks:** ${report.meta.taskCount} · repeats × ${report.meta.repeatsPerTask} · modes: ${report.meta.modesRun.join(", ")}`,
);
lines.push(`**Reasonix version:** ${report.meta.reasonixVersion}`);
lines.push("");
// Per-mode summary
lines.push("## Summary by mode");
lines.push("");
lines.push(
"| mode | runs | pass rate | cache hit | cost / run | harvest turns | subgoals | uncertainties |",
);
lines.push("|---|---:|---:|---:|---:|---:|---:|---:|");
for (const mode of ALL_MODES) {
const rs = report.results.filter((r) => r.mode === mode);
const a = aggregate(rs);
if (a.runs === 0) continue;
lines.push(
`| ${mode} | ${a.runs} | ${pct(a.passes, a.runs)} | ${pct1(a.avgCache)} | $${a.avgCost.toFixed(6)} | ${a.avgHarvestedTurns.toFixed(1)} | ${a.avgSubgoals.toFixed(1)} | ${a.avgUncertainties.toFixed(1)} |`,
);
}
lines.push("");
// Pairwise deltas that actually answer the question
lines.push("## Deltas");
lines.push("");
const modes = ALL_MODES.filter((m) => report.results.some((r) => r.mode === m));
if (modes.length >= 2) {
const baseline = aggregate(report.results.filter((r) => r.mode === modes[0]));
for (let i = 1; i < modes.length; i++) {
const mode = modes[i]!;
const agg = aggregate(report.results.filter((r) => r.mode === mode));
const passDelta =
agg.passes / Math.max(agg.runs, 1) - baseline.passes / Math.max(baseline.runs, 1);
const costRatio = baseline.avgCost > 0 ? agg.avgCost / baseline.avgCost : 0;
lines.push(`- **${modes[0]} → ${mode}**`);
lines.push(` - pass rate: ${(passDelta * 100).toFixed(0)}pp`);
lines.push(
` - cost: ×${costRatio.toFixed(2)} (each run costs ${costRatio > 1 ? "more" : "less"})`,
);
lines.push(
` - harvest signal / run: ${agg.avgSubgoals.toFixed(1)} subgoals, ${agg.avgUncertainties.toFixed(1)} uncertainties`,
);
lines.push("");
}
}
// Per-task breakdown
lines.push("## Per-task breakdown");
lines.push("");
const byTask = new Map<string, HarvestRunResult[]>();
for (const r of report.results) {
const list = byTask.get(r.taskId) ?? [];
list.push(r);
byTask.set(r.taskId, list);
}
lines.push("| task | mode | rep | verdict | cache | cost | sg | un | note |");
lines.push("|---|---|---:|:---:|---:|---:|---:|---:|---|");
for (const [taskId, runs] of byTask) {
for (const r of runs) {
const mark = r.verdict === "pass" ? "✅" : r.verdict === "fail" ? "❌" : "•";
lines.push(
`| ${taskId} | ${r.mode} | ${r.repeat} | ${mark} | ${pct1(r.cacheHitRatio)} | $${r.costUsd.toFixed(6)} | ${r.totalSubgoals} | ${r.totalUncertainties} | ${truncate(r.checkNote ?? "", 40)} |`,
);
}
}
lines.push("");
lines.push("## Scope");
lines.push("");
lines.push(
"Unlike τ-bench-lite, these tasks are single-turn reasoning problems (no user simulator, no DB, no tool calls). Checkers are deterministic — regex + set / value compare, never an LLM judge. The point is to isolate whether the Pillar 2 harvest step adds measurable value above plain reasoner usage.",
);
lines.push("");
lines.push(
"Interpretation: `baseline` (chat / V3) is a floor. `reasoner` shows the raw R1 gain. `reasoner-harvest` isolates the cost + quality delta from the extra V3 harvest call.",
);
return lines.join("\n");
}
/** Findings prose lives in report.md (hand-written after rendering); this stub keeps the function shape. */
export function renderFindings(_report: HarvestBenchReport): string {
return [
"",
"## Findings (v0.3, 18-run set)",
"",
"Two bands of tasks tried (easy: mod7 / flips / hats; hard: pseudoprime341 / D_7 / Euler quadratic). The hard band was chosen for *known V3 failure modes*. Result:",
"",
'1. **V3 chat passed all six tasks, including the three "hard" ones.** DeepSeek V3 knew 341 is the smallest base-2 pseudoprime, computed D_7=1854, and identified n=40 as the first Euler-polynomial failure. The tasks are well-known enough to be in training; the trap-answer variants (561, 265, 41) that the checkers specifically reject didn\'t fire.',
"2. **Reasoner cost 3.04× baseline for identical pass rates (6/6 both sides).** On this task set, R1 adds cost and latency without measurable quality. The cache-hit story *does* extend to reasoner (74.2% mean), so Pillar 1 generalizes to R1 — but that's not a Pillar 2 story.",
"3. **Harvest produced real signal** (mean 2.8 subgoals, 1.3 uncertainties per run where it fired) but didn't improve outcomes. One run hit the 300s timeout (bumped from 120s), suggesting reasoner-harvest latency is still unpredictable on some problems.",
"",
"### What this tells us about Pillar 2 positioning",
"",
"We've now given harvest two shots at showing answer-quality value on pure reasoning tasks. Both times V3 ate its lunch. This isn't a framework bug — it's a positioning signal:",
"",
'- **On well-known math / logic / counting problems, DeepSeek V3 is strong enough that paying for R1 is not justified by accuracy.** Pillar 2\'s "smart preset" cost multiplier (10×) quoted in the README is real but for most single-question Q/A it buys nothing.',
'- **Harvest\'s plausible value surfaces are not "better math answers".** More likely: (a) transparency / auditability for developers, (b) planning support when tools are in the mix, (c) driving branch-sampling on cases where uncertainty correlates with wrongness. None of those are tested here.',
"",
"### Recommendation",
"",
"Don't ship v0.3 on the \"harvest makes answers more correct\" claim — the data doesn't support it. Two honest paths forward:",
"",
"- **Reframe Pillar 2 as a developer-facing introspection feature** (debug visibility into R1's planning). Keep harvest opt-in; stop implying it improves accuracy on end-user tasks.",
"- **Move harvest eval to tool-use contexts** where uncertainty matters for *which tool to call next*, not *is this math right*. That's a new task shape — different harness, not a bigger task set here.",
"",
"In either case, the next Reasonix release is better served by MCP client (Pillar 1 leverage across the ecosystem) than by a bigger harvest-bench data push.",
"",
"### Notes",
"",
"- Cache hit on baseline runs surprised me (39.5% mean). That's DeepSeek's *cross-session* prompt cache: the same system prompt ran many times across modes and runs warmed the server-side cache. Real cross-session evidence that Cache-First's value isn't only per-session.",
"- 5-subgoals cap still hit uniformly. `HarvestOptions.maxItems` default is 5; harvest is truncating. Moot unless we find a setting where that matters.",
"",
].join("\n");
}
function pct(num: number, denom: number): string {
if (denom === 0) return "—";
return `${((num / denom) * 100).toFixed(0)}%`;
}
function pct1(x: number): string {
return `${(x * 100).toFixed(1)}%`;
}
function truncate(s: string, n: number): string {
return s.length > n ? `${s.slice(0, n)}…` : s;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const raw = readFileSync(args.input, "utf8");
const report = JSON.parse(raw) as HarvestBenchReport;
const md = renderReport(report) + renderFindings(report);
writeFileSync(args.outPath, md, "utf8");
console.log(`wrote ${args.outPath} (${report.results.length} runs)`);
}
function isMain(): boolean {
const entry = process.argv[1];
if (!entry) return false;
return import.meta.url === pathToFileURL(entry).href;
}
if (isMain()) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
// Suppress unused import warning — kept in scope so ALL_MODES order matches renderer iteration.
export type { HarvestMode };
-305
View File
@@ -1,305 +0,0 @@
{
"meta": {
"date": "2026-04-21T16:44:21.011Z",
"repeatsPerTask": 1,
"taskCount": 6,
"modesRun": [
"baseline",
"reasoner",
"reasoner-harvest"
],
"reasonixVersion": "0.2.2"
},
"results": [
{
"taskId": "mod7_list",
"mode": "baseline",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "Alright, let's go step-by-step.\n\n---\n\n## **Step 1: Problem restatement**\nWe want positive integers \\( n \\leq 100 \\) such that \n\\[\nn^2 + n + 1 \\equiv 0 \\pmod{7}.\n\\]\n\n---\n\n## **Step 2: Try residues modulo 7**\nLet \\( n \\equiv r \\pmod{7} \\), where \\( r \\in \\{0, 1, 2, 3, 4, 5, 6\\} \\).\n\nWe compute \\( r^2 + r + 1 \\mod 7 \\):\n\n- \\( r = 0 \\): \\( 0 + 0 + 1 = 1 \\) → not divisible by 7.\n- \\( r = 1 \\): \\( 1 + 1 + 1 = 3 \\) → no.\n- \\( r = 2 \\): \\( 4 + 2 + 1 = 7 \\equiv 0 \\) → yes.\n- \\( r = 3 \\): \\( 9 + 3 + 1 = 13 \\equiv 6 \\) → no.\n- \\( r = 4 \\): \\( 16 + 4 + 1 = 21 \\equiv 0 \\) → yes.\n- \\( r = 5 \\): \\( 25 + 5 + 1 = 31 \\equiv 3 \\) → no.\n- \\( r = 6 \\): \\( 36 + 6 + 1 = 43 \\equiv 1 \\) → no.\n\nSo \\( n \\equiv 2 \\pmod{7} \\) or \\( n \\equiv 4 \\pmod{7} \\).\n\n---\n\n## **Step 3: General form**\n\\[\nn \\equiv 2 \\pmod{7} \\quad \\text{or} \\quad n \\equiv 4 \\pmod{7}.\n\\]\n\nThat means \\( n = 7k + 2 \\) or \\( n = 7k + 4 \\) for some integer \\( k \\ge 0 \\).\n\n---\n\n## **Step 4: Positive integers \\( n \\leq 100 \\)**\n**Case 1:** \\( n = 7k + 2 \\), \\( n \\leq 100 \\), \\( k \\ge 0 \\).\n\n\\( 7k + 2 \\leq 100 \\) → \\( 7k \\leq 98 \\) → \\( k \\leq 14 \\).\n\nSo \\( k = 0, 1, 2, \\dots, 14 \\) gives \\( n = 2, 9, 16, \\dots, 100 \\).\n\nCheck \\( n=100 \\): \\( 7 \\times 14 + 2 = 98 + 2 = 100 \\) okay.\n\n---\n\n**Case 2:** \\( n = 7k + 4 \\), \\( n \\leq 100 \\), \\( k \\ge 0 \\).\n\n\\( 7k + 4 \\leq 100 \\) → \\( 7k \\leq 96 \\) → \\( k \\leq 13 \\) (since \\( 7 \\times 13 = 91 \\), \\( 91+4=95 \\), \\( 7\\times14=98 \\) → \\( 98+4=102>100 \\)).\n\nSo \\( k = 0, 1, 2, \\dots, 13 \\) gives \\( n = 4, 11, 18, \\dots, 95 \\).\n\n---\n\n## **Step 5: List all such \\( n \\leq 100 \\)**\nFrom case 1: \\( 2, 9, 16, 23, 30, 37, 44, 51, 58, 65, 72, 79, 86, 93, 100 \\).\n\nFrom case 2: \\( 4, 11, 18, 25, 32, 39, 46, 53, 60, 67, 74, 81, 88, 95 \\).\n\nCombine and sort:\n\n\\[\n2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100.\n\\]\n\n---\n\n## **Step 6: Check count**\nEach sequence has 15 and 14 terms respectively, total \\( 15 + 14 = 29 \\) numbers.\n\nCheck: \\( n=2 \\) works? \\( 4+2+1=7 \\) yes. \\( n=4 \\) works? \\( 16+4+1=21 \\) yes. \\( n=100 \\) works? \\( 10000+100+1=10101 \\), divide by 7: \\( 10101/7 = 1443 \\) yes.\n\n---\n\n**Final answer:**\n\\[\n\\boxed{2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100}\n\\]\n\n---\n\nAnswer: 2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100",
"turns": 1,
"cacheHitRatio": 0.8648648648648649,
"costUsd": 0.0013612800000000003,
"claudeEquivalentUsd": 0.018687,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "mod7_list",
"mode": "reasoner",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The condition \\(n^2 + n + 1\\) divisible by 7 is equivalent to \\(n^2 + n + 1 \\equiv 0 \\pmod{7}\\). Testing residues modulo 7:\n\n- \\(n \\equiv 0: 0+0+1 \\equiv 1\\) \n- \\(n \\equiv 1: 1+1+1 \\equiv 3\\) \n- \\(n \\equiv 2: 4+2+1 \\equiv 7 \\equiv 0\\) \n- \\(n \\equiv 3: 9+3+1 \\equiv 13 \\equiv 6\\) \n- \\(n \\equiv 4: 16+4+1 \\equiv 21 \\equiv 0\\) \n- \\(n \\equiv 5: 25+5+1 \\equiv 31 \\equiv 3\\) \n- \\(n \\equiv 6: 36+6+1 \\equiv 43 \\equiv 1\\)\n\nThus, \\(n \\equiv 2\\) or \\(n \\equiv 4 \\pmod{7}\\) are the solutions modulo 7. For positive integers \\(n \\leq 100\\), we list all numbers congruent to 2 or 4 modulo 7 in increasing order.\n\nAnswer: 2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100",
"turns": 1,
"cacheHitRatio": 0.8648648648648649,
"costUsd": 0.00479742,
"claudeEquivalentUsd": 0.032982,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "mod7_list",
"mode": "reasoner-harvest",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The congruence \\(n^2 + n + 1 \\equiv 0 \\pmod{7}\\) is solved by testing residues modulo 7. The solutions are \\(n \\equiv 2\\) and \\(n \\equiv 4 \\pmod{7}\\). For positive integers \\(n \\le 100\\), these yield \\(n = 7k + 2\\) with \\(k = 0, 1, \\dots, 14\\) and \\(n = 7k + 4\\) with \\(k = 0, 1, \\dots, 13\\).\n\nAnswer: 2, 4, 9, 11, 16, 18, 23, 25, 30, 32, 37, 39, 44, 46, 51, 53, 58, 60, 65, 67, 72, 74, 79, 81, 86, 88, 93, 95, 100",
"turns": 1,
"cacheHitRatio": 0.8648648648648649,
"costUsd": 0.00304542,
"claudeEquivalentUsd": 0.020982,
"harvestedTurns": 1,
"totalSubgoals": 4,
"totalUncertainties": 2,
"totalHypotheses": 2,
"totalRejectedPaths": 2
},
{
"taskId": "flips_until_3heads",
"mode": "baseline",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "Alright, let's reason through this step-by-step.\n\n---\n\n## **Step 1: Define states**\nLet’s define states based on how many consecutive heads we have at the moment.\n\n- \\( E_0 \\): Expected flips to reach 3 heads in a row starting from 0 consecutive heads (or just starting, or after a tail).\n- \\( E_1 \\): Expected flips to reach 3 heads in a row starting from 1 consecutive head.\n- \\( E_2 \\): Expected flips to reach 3 heads in a row starting from 2 consecutive heads.\n\nWe want \\( E_0 \\).\n\n---\n\n## **Step 2: Transition equations**\nFrom \\( E_0 \\):\n- Flip H (prob \\( \\frac12 \\)) → go to state \\( E_1 \\), 1 flip used.\n- Flip T (prob \\( \\frac12 \\)) → stay in \\( E_0 \\), 1 flip used.\n\nSo:\n\\[\nE_0 = \\frac12 (1 + E_1) + \\frac12 (1 + E_0)\n\\]\n\n---\n\nFrom \\( E_1 \\) (already have 1 head):\n- Flip H (prob \\( \\frac12 \\)) → go to state \\( E_2 \\), 1 flip used.\n- Flip T (prob \\( \\frac12 \\)) → go to state \\( E_0 \\), 1 flip used.\n\nSo:\n\\[\nE_1 = \\frac12 (1 + E_2) + \\frac12 (1 + E_0)\n\\]\n\n---\n\nFrom \\( E_2 \\) (already have 2 heads):\n- Flip H (prob \\( \\frac12 \\)) → done (3 heads in a row), 1 flip used.\n- Flip T (prob \\( \\frac12 \\)) → go to state \\( E_0 \\), 1 flip used.\n\nSo:\n\\[\nE_2 = \\frac12 (1 + 0) + \\frac12 (1 + E_0)\n\\]\n\\[\nE_2 = \\frac12 \\cdot 1 + \\frac12 (1 + E_0) \\quad\\text{(Wait, careful:)}\n\\]\nActually: \nIf H: \\( 1 \\) flip and done. \nIf T: \\( 1 \\) flip and then \\( E_0 \\) more.\n\nSo:\n\\[\nE_2 = \\frac12 \\cdot 1 + \\frac12 (1 + E_0)\n\\]\nCheck: \nH: 1 flip → total 1, done. Contribution: \\( \\frac12 \\times 1 \\). \nT: 1 flip + \\( E_0 \\) more → total \\( 1 + E_0 \\). Contribution: \\( \\frac12 \\times (1 + E_0) \\).\n\nYes:\n\\[\nE_2 = \\frac12 \\cdot 1 + \\frac12 (1 + E_0) = \\frac12 + \\frac12 + \\frac12 E_0 = 1 + \\frac{E_0}{2}.\n\\]\n\n---\n\n## **Step 3: Solve system**\nWe have:\n\n(1) \\( E_0 = \\frac12 (1 + E_1) + \\frac12 (1 + E_0) \\) \nSimplify: \\( E_0 = 1 + \\frac12 E_1 + \\frac12 E_0 \\) \nMultiply by 2: \\( 2E_0 = 2 + E_1 + E_0 \\) \nSo \\( E_0 - E_1 = 2 \\) … (Eq A)\n\n---\n\n(2) \\( E_1 = \\frac12 (1 + E_2) + \\frac12 (1 + E_0) \\) \nSimplify: \\( E_1 = 1 + \\frac12 E_2 + \\frac12 E_0 \\) … (Eq B)\n\n---\n\n(3) \\( E_2 = 1 + \\frac12 E_0 \\) … (Eq C)\n\n---\n\nFrom Eq C into Eq B:\n\\[\nE_1 = 1 + \\frac12 \\left( 1 + \\frac12 E_0 \\right) + \\frac12 E_0\n\\]\n\\[\nE_1 = 1 + \\frac12 + \\frac14 E_0 + \\frac12 E_0\n\\]\n\\[\nE_1 = \\frac32 + \\frac34 E_0\n\\]\n\n---\n\nFrom Eq A: \\( E_0 - E_1 = 2 \\) \nSubstitute \\( E_1 \\):\n\\[\nE_0 - \\left( \\frac32 + \\frac34 E_0 \\right) = 2\n\\]\n\\[\nE_0 - \\frac32 - \\frac34 E_0 = 2\n\\]\n\\[\n\\frac14 E_0 - \\frac32 = 2\n\\]\n\\[\n\\frac14 E_0 = 2 + \\frac32 = \\frac72\n\\]\n\\[\nE_0 = 14\n\\]\n\n---\n\n## **Step 4: Conclusion**\nThe expected number of flips until we get 3 heads in a row is \\( 14 \\).\n\n---\n\n**Answer:** 14",
"turns": 1,
"cacheHitRatio": 0.927536231884058,
"costUsd": 0.00119823,
"claudeEquivalentUsd": 0.016467,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "flips_until_3heads",
"mode": "reasoner",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The expected number of flips required to get three consecutive heads is 14, derived from solving the system of linear equations for the Markov chain states representing the current streak of heads.\n\nAnswer: 14",
"turns": 1,
"cacheHitRatio": 0.927536231884058,
"costUsd": 0.00283024,
"claudeEquivalentUsd": 0.019512,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "flips_until_3heads",
"mode": "reasoner-harvest",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The expected number of flips until three consecutive heads appear is 14.\n\nAnswer: 14",
"turns": 1,
"cacheHitRatio": 0.927536231884058,
"costUsd": 0.00169144,
"claudeEquivalentUsd": 0.011712,
"harvestedTurns": 1,
"totalSubgoals": 4,
"totalUncertainties": 0,
"totalHypotheses": 2,
"totalRejectedPaths": 0
},
{
"taskId": "three_hats",
"mode": "baseline",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "Let's break this down step by step.\n\n---\n\n**Step 1: Understanding the setup** \nThree people: A (first), B (second), C (third). \nEach hat is red (R) or blue (B). \nThey know: at least one hat is red. \nEach sees the other two hats, not their own. \nThey are asked in order: \"Do you know your hat color?\" \nA answers no, B answers no, C answers yes. \nWe want C’s hat color.\n\n---\n\n**Step 2: From A’s perspective when asked** \nA sees B and C. \nIf A had seen two blue hats, then A would know: \"At least one red hat means my hat must be red.\" \nSo A says \"no\" → A did **not** see two blue hats. \nThus A sees at least one red hat on {B, C}.\n\nPossible cases for what A sees (B, C): \n(R, R), (R, B), (B, R) — all have at least one red.\n\n---\n\n**Step 3: B’s reasoning when asked** \nB hears A say \"no\". \nB sees A and C. \n\nLet’s consider B’s possible deductions from A’s \"no\": \nB knows: If A had seen two blues, A would have said yes. Since A said no, A saw at least one red among B and C. \nBut B sees A and C. \n\nFrom B’s viewpoint: \n- If B saw C with blue and A with blue, then B would know: \"A saw me and C both blue → impossible because A said no.\" But B doesn’t see that because if B saw A blue and C blue, then B would know his own hat must be red (since at least one red total). But then B would answer yes. \nSo B’s \"no\" means B did **not** see A and C both blue. \n\nAlso, B considers: Could my hat be blue? Let’s check: \nSuppose B sees A red, C blue. Then B thinks: \"If my hat were blue, then A would have seen (B blue, C blue) → A would have said yes. But A said no, so my hat cannot be blue.\" \nSo if B saw (A red, C blue), B would deduce: \"My hat must be red.\" Then B would answer yes. \nBut B said no → so B did **not** see (A red, C blue).\n\nSimilarly, if B saw (A blue, C red): \nThen B thinks: \"If my hat were blue, then A would have seen (B blue, C red) → that’s fine (A sees one red on C), so A would still say no. If my hat were red, then A would see (B red, C red) → A would still say no. So I don’t know.\" That’s consistent with B saying no. \n\nIf B saw (A red, C red): \nThen B thinks: \"If my hat were blue, A sees (B blue, C red) → A says no. If my hat were red, A sees (B red, C red) → A says no. So I don’t know.\" That’s also consistent with B saying no.\n\nSo B says no in cases: \nB sees (A blue, C red) or (A red, C red). \nB would say yes if B saw (A red, C blue) — but that didn’t happen. \n\n---\n\n**Step 4: C’s reasoning** \nC hears A says no, B says no. \nC sees A and B. \n\nFrom C’s viewpoint: \nLet’s list all possible (A, B, C) hat colorings with at least one red, consistent with A’s \"no\" (which we already incorporated: A sees at least one red on B/C) and B’s \"no\" (meaning B didn’t see A red, C blue — because that would make B know).\n\nPossible cases for (A, B) that C could see: \n\n1. C sees (A blue, B red): \n Then C thinks: \n - If C were blue: (A blue, B red, C blue) → A sees (B red, C blue) → A says no. B sees (A blue, C blue) → B sees two blues? Wait, B sees A blue, C blue → then B knows his own hat is red (since at least one red total) → B would say yes. But B said no → contradiction. So C cannot be blue. \n So C must be red. \n\n2. C sees (A red, B blue): \n Then C thinks: \n - If C were blue: (A red, B blue, C blue) → A sees (B blue, C blue) → A would say yes (since at least one red total means A’s hat must be red). But A said no → contradiction. So C cannot be blue. \n So C must be red. \n\n3. C sees (A red, B red): \n Then C thinks: \n - If C were blue: (A red, B red, C blue) → A sees (B red, C blue) → A says no. B sees (A red, C blue) → B sees A red, C blue → earlier we said: If B sees A red, C blue, then B would deduce his own hat is red (reasoning: if B were blue, A would have seen two blues and said yes, but A said no, so B can’t be blue) → so B would say yes. But B actually said no → contradiction. So C cannot be blue. \n - If C were red: (A red, B red, C red) → A sees (B red, C red) → A says no. B sees (A red, C red) → B says no. Works fine. \n\nSo in all cases C sees for (A, B), C can deduce C’s hat is **red** because assuming C is blue leads to contradiction with B’s \"no\" (in cases 1 and 3) or with A’s \"no\" (in case 2).\n\n---\n\n**Step 5: Conclusion** \nC answers yes, and C’s hat must be red.\n\n---\n\nAnswer: red",
"turns": 1,
"cacheHitRatio": 0.5765765765765766,
"costUsd": 0.0015142700000000003,
"claudeEquivalentUsd": 0.020748,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "three_hats",
"mode": "reasoner",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "Answer: red",
"turns": 1,
"cacheHitRatio": 0.5765765765765766,
"costUsd": 0.009340119999999999,
"claudeEquivalentUsd": 0.064068,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "three_hats",
"mode": "reasoner-harvest",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The third person deduces their hat color based on the previous responses. Since the first two answer \"no,\" it implies they each see at least one red hat and cannot determine their own color. Specifically, the second person's \"no\" indicates they see the third person's hat as red; otherwise, they would have known their own hat color. Therefore, the third person's hat must be red.\n\nAnswer: red",
"turns": 1,
"cacheHitRatio": 0.5765765765765766,
"costUsd": 0.0096511,
"claudeEquivalentUsd": 0.066198,
"harvestedTurns": 1,
"totalSubgoals": 3,
"totalUncertainties": 2,
"totalHypotheses": 3,
"totalRejectedPaths": 3
},
{
"taskId": "pseudoprime_base2",
"mode": "baseline",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "Alright, let's go step-by-step.\n\n---\n\n**Step 1: Understanding the problem**\n\nWe want the smallest composite integer \\( n > 1 \\) such that\n\n\\[\n2^n \\equiv 2 \\pmod{n}.\n\\]\n\nEquivalently, \\( 2^{n-1} \\equiv 1 \\pmod{n} \\) for composite \\( n \\).\n\nThis means \\( n \\) is a **Fermat pseudoprime to base 2**.\n\nWe check composite numbers starting from the smallest.\n\n---\n\n**Step 2: Check small composites**\n\n- \\( n = 4 \\): composite. \n\\( 2^4 = 16 \\), \\( 16 \\mod 4 = 0 \\), not \\( 2 \\). So \\( 2^4 \\equiv 0 \\not\\equiv 2 \\pmod{4} \\). Fails.\n\n- \\( n = 6 \\): composite. \n\\( 2^6 = 64 \\), \\( 64 \\mod 6 = 4 \\), not \\( 2 \\). Fails.\n\n- \\( n = 8 \\): composite. \n\\( 2^8 = 256 \\), \\( 256 \\mod 8 = 0 \\), not \\( 2 \\). Fails.\n\n- \\( n = 9 \\): composite. \n\\( 2^9 = 512 \\), \\( 512 \\mod 9 \\): \n\\( 512 \\div 9 \\), \\( 9 \\times 56 = 504 \\), remainder \\( 8 \\), not \\( 2 \\). Fails.\n\n- \\( n = 10 \\): composite. \n\\( 2^{10} = 1024 \\), \\( 1024 \\mod 10 = 4 \\), not \\( 2 \\). Fails.\n\n---\n\n**Step 3: Try n = 15**\n\n\\( n = 15 \\): composite. \n\\( 2^{15} = 32768 \\). \nCheck mod 15: \nWe can use mod arithmetic: \n\\( 2^4 = 16 \\equiv 1 \\pmod{15} \\)?? Wait, check: \\( 16 \\mod 15 = 1 \\), yes. \nSo \\( 2^{15} = (2^4)^3 \\times 2^3 = 1^3 \\times 8 = 8 \\pmod{15} \\). \nSo \\( 2^{15} \\equiv 8 \\not\\equiv 2 \\pmod{15} \\). Fails.\n\n---\n\n**Step 4: Try n = 21**\n\n\\( n = 21 \\): composite. \nMod 21: \n\\( 2^6 = 64 \\equiv 1 \\pmod{21} \\)? Check: \\( 64 \\div 21 = 3 \\times 21 = 63 \\), remainder 1, yes. \nSo \\( 2^{21} = (2^6)^3 \\times 2^3 \\equiv 1^3 \\times 8 = 8 \\pmod{21} \\). Not 2. Fails.\n\n---\n\n**Step 5: Try n = 22**\n\n\\( n = 22 \\): composite. \nUse mod 22: \nEuler’s totient not needed, just check order: \n\\( 2^5 = 32 \\equiv 10 \\pmod{22} \\) \n\\( 2^{10} = (2^5)^2 \\equiv 10^2 = 100 \\equiv 12 \\pmod{22} \\) \n\\( 2^{11} \\equiv 24 \\equiv 2 \\pmod{22} \\) \nSo \\( 2^{22} = (2^{11})^2 \\equiv 2^2 = 4 \\pmod{22} \\). Not 2. Fails.\n\n---\n\n**Step 6: Try n = 25**\n\n\\( n = 25 \\): composite. \nCheck mod 25: \n\\( 2^{10} = 1024 \\equiv 24 \\pmod{25} \\) (since \\( 1000 \\equiv 0 \\), \\( 24 \\equiv -1 \\)) \nActually check: \\( 1024 - 1000 = 24 \\), yes \\( 24 \\equiv -1 \\). \nSo \\( 2^{20} \\equiv 1 \\pmod{25} \\). \nThus \\( 2^{25} = 2^{20} \\times 2^5 \\equiv 1 \\times 32 = 7 \\pmod{25} \\). Not 2. Fails.\n\n---\n\n**Step 7: Known smallest base-2 Fermat pseudoprime**\n\nFrom known number theory facts: \nThe smallest Fermat pseudoprime to base 2 is \\( n = 341 \\). \nBut let's check smaller ones just in case I’m skipping one.\n\n---\n\n**Step 8: Try n = 91**\n\n91 = 7 × 13. \nCheck mod 7: \\( 2^{91} \\equiv (2^6)^{15} \\times 2^1 \\equiv 1^{15} \\times 2 = 2 \\pmod{7} \\). \nCheck mod 13: \\( 2^{12} \\equiv 1 \\pmod{13} \\), \n\\( 91 = 12 \\times 7 + 7 \\), so \\( 2^{91} \\equiv 2^7 = 128 \\equiv 11 \\pmod{13} \\). \nFor \\( 2^{91} \\equiv 2 \\pmod{91} \\), we need \\( 2^{91} \\equiv 2 \\) mod 7 and mod 13. \nMod 13 we got 11, not 2. So fails.\n\n---\n\n**Step 9: Try n = 341**\n\n341 = 11 × 31. \nCheck mod 11: \\( 2^{10} \\equiv 1 \\pmod{11} \\), \n\\( 341 = 10 \\times 34 + 1 \\), so \\( 2^{341} \\equiv 2^1 = 2 \\pmod{11} \\). Good. \nCheck mod 31: \\( 2^5 = 32 \\equiv 1 \\pmod{31} \\), \n\\( 341 = 5 \\times 68 + 1 \\), so \\( 2^{341} \\equiv 2^1 = 2 \\pmod{31} \\). \nSo \\( 2^{341} \\equiv 2 \\) mod 11 and mod 31, hence mod 341. \nThus \\( 341 \\) works.\n\n---\n\n**Step 10: Check smaller than 341**\n\nKnown list: 341 is smallest. Let’s check a candidate like 121 = 11^2: \n\\( 2^{121} \\mod 121 \\): \nMod 11: \\( 2^{10} \\equiv 1 \\), \\( 121 = 10 \\times 12 + 1 \\), so \\( 2^{121} \\equiv 2 \\pmod{11} \\). \nBut for modulus 121, need more: \nActually, \\( 2^{110} \\equiv 1 \\pmod{121} \\) by Euler’s theorem? Not exactly, but known: 121 is not a pseudoprime base 2. Quick check: \n\\( 2^{121} - 2 \\) divisible by 121? Probably not. Let’s skip detailed check; known fact is 341 is smallest.\n\n---\n\n**Step 11: Verify no smaller composite works**\n\nFrom known sequence A001567: \nFirst few base-2 Fermat pseudoprimes: 341, 561, 645, 1105, … \nSo smallest is 341.\n\n---\n\n**Step 12: Conclusion**\n\nThe smallest composite \\( n > 1 \\) with \\( 2^n \\equiv 2 \\pmod{n} \\) is \\( n = 341 \\).\n\n---\n\n**Answer:** 341",
"turns": 1,
"cacheHitRatio": 0,
"costUsd": 0.0018940500000000002,
"claudeEquivalentUsd": 0.02577,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "pseudoprime_base2",
"mode": "reasoner",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The smallest composite integer \\(n > 1\\) satisfying \\(2^n \\equiv 2 \\pmod{n}\\) is \\(n = 341\\). This is composite (\\(341 = 11 \\times 31\\)), and it satisfies the congruence because \\(2^{341} \\equiv 2 \\pmod{11}\\) and \\(2^{341} \\equiv 2 \\pmod{31}\\) by Fermat's little theorem, implying \\(2^{341} \\equiv 2 \\pmod{341}\\). All smaller composite integers (e.g., 4, 6, 8, 9, 10, 15, 21) fail to satisfy the condition.\n\nAnswer: 341",
"turns": 1,
"cacheHitRatio": 0.7529411764705882,
"costUsd": 0.0031281200000000003,
"claudeEquivalentUsd": 0.02154,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "pseudoprime_base2",
"mode": "reasoner-harvest",
"repeat": 1,
"verdict": "fail",
"checkNote": "This operation was aborted",
"finalAgentMessage": "",
"turns": 0,
"cacheHitRatio": 0,
"costUsd": 0,
"claudeEquivalentUsd": 0,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0,
"errorMessage": "This operation was aborted"
},
{
"taskId": "derangements_d7",
"mode": "baseline",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "Alright, let's go step-by-step.\n\n---\n\n**Step 1: Understanding the problem**\n\nWe want the number of permutations of \\(\\{1, 2, 3, 4, 5, 6, 7\\}\\) with **no fixed points**. \nA fixed point means \\(\\pi(i) = i\\) for some \\(i\\). \nA permutation with no fixed points is called a **derangement**. \nThe number of derangements of \\(n\\) elements is denoted \\(D_n\\) or \\(!n\\).\n\n---\n\n**Step 2: Formula for derangements**\n\nThe formula for \\(D_n\\) is:\n\n\\[\nD_n = n! \\sum_{k=0}^{n} \\frac{(-1)^k}{k!}\n\\]\n\nFor \\(n=7\\):\n\n\\[\nD_7 = 7! \\left( \\frac{1}{0!} - \\frac{1}{1!} + \\frac{1}{2!} - \\frac{1}{3!} + \\frac{1}{4!} - \\frac{1}{5!} + \\frac{1}{6!} - \\frac{1}{7!} \\right)\n\\]\n\n---\n\n**Step 3: Compute step-by-step**\n\nFirst, \\(7! = 5040\\).\n\nNow compute the sum:\n\n\\[\n\\sum_{k=0}^{7} \\frac{(-1)^k}{k!} \n= 1 - 1 + \\frac{1}{2} - \\frac{1}{6} + \\frac{1}{24} - \\frac{1}{120} + \\frac{1}{720} - \\frac{1}{5040}\n\\]\n\nLet's compute term-by-term:\n\n- \\(1 - 1 = 0\\)\n- \\(0 + 0.5 = 0.5\\)\n- \\(0.5 - \\frac{1}{6} \\approx 0.5 - 0.1666667 = 0.3333333\\)\n- \\(0.3333333 + \\frac{1}{24} \\approx 0.3333333 + 0.0416667 = 0.375\\)\n- \\(0.375 - \\frac{1}{120} \\approx 0.375 - 0.0083333 = 0.3666667\\)\n- \\(0.3666667 + \\frac{1}{720} \\approx 0.3666667 + 0.0013889 = 0.3680556\\)\n- \\(0.3680556 - \\frac{1}{5040} \\approx 0.3680556 - 0.0001984 = 0.3678572\\)\n\nSo the sum is approximately \\(0.367857142857...\\)\n\n---\n\n**Step 4: Multiply by \\(7!\\)**\n\n\\[\nD_7 = 5040 \\times 0.367857142857\\ldots\n\\]\n\nLet's compute exactly using fractions:\n\n\\[\n\\sum_{k=0}^{7} \\frac{(-1)^k}{k!} \n= \\frac{1}{2!} - \\frac{1}{3!} + \\frac{1}{4!} - \\frac{1}{5!} + \\frac{1}{6!} - \\frac{1}{7!}\n\\]\n\nWrite with common denominator \\(7! = 5040\\):\n\n- \\(\\frac{1}{2!} = \\frac{2520}{5040}\\)\n- \\(\\frac{1}{3!} = \\frac{840}{5040}\\)\n- \\(\\frac{1}{4!} = \\frac{210}{5040}\\)\n- \\(\\frac{1}{5!} = \\frac{42}{5040}\\)\n- \\(\\frac{1}{6!} = \\frac{7}{5040}\\)\n- \\(\\frac{1}{7!} = \\frac{1}{5040}\\)\n\nNow alternate signs:\n\n\\[\n\\left( \\frac{2520}{5040} - \\frac{840}{5040} \\right) \n= \\frac{1680}{5040}\n\\]\n\\[\n\\frac{1680}{5040} + \\frac{210}{5040} = \\frac{1890}{5040}\n\\]\n\\[\n\\frac{1890}{5040} - \\frac{42}{5040} = \\frac{1848}{5040}\n\\]\n\\[\n\\frac{1848}{5040} + \\frac{7}{5040} = \\frac{1855}{5040}\n\\]\n\\[\n\\frac{1855}{5040} - \\frac{1}{5040} = \\frac{1854}{5040}\n\\]\n\nSo the sum is \\(\\frac{1854}{5040}\\).\n\n---\n\n**Step 5: Multiply by \\(7!\\)**\n\n\\[\nD_7 = 5040 \\times \\frac{1854}{5040} = 1854\n\\]\n\n---\n\n**Step 6: Quick check with known derangement numbers**\n\nKnown small derangements: \n\\(D_1 = 0, D_2 = 1, D_3 = 2, D_4 = 9, D_5 = 44, D_6 = 265, D_7 = 1854\\). \nYes, matches known sequence.\n\n---\n\n**Final answer:**\n\nAnswer: 1854",
"turns": 1,
"cacheHitRatio": 0,
"costUsd": 0.00127253,
"claudeEquivalentUsd": 0.017292,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "derangements_d7",
"mode": "reasoner",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The number of derangements of \\(n\\) distinct objects, denoted \\(D_n\\), counts permutations with no fixed points. For \\(n = 7\\), this can be computed using the recurrence \\(D_n = (n-1)(D_{n-1} + D_{n-2})\\) with initial values \\(D_1 = 0\\) and \\(D_2 = 1\\). \nThus: \n\\(D_3 = 2(1+0) = 2\\), \n\\(D_4 = 3(2+1) = 9\\), \n\\(D_5 = 4(9+2) = 44\\), \n\\(D_6 = 5(44+9) = 265\\), \n\\(D_7 = 6(265+44) = 1854\\).\n\nAnswer: 1854",
"turns": 1,
"cacheHitRatio": 0.7191011235955056,
"costUsd": 0.00349167,
"claudeEquivalentUsd": 0.024027,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "derangements_d7",
"mode": "reasoner-harvest",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The number of derangements of \\(n\\) elements, denoted \\(D_n\\), counts permutations with no fixed points. For \\(n=7\\), this can be computed using the recurrence \\(D_n = (n-1)(D_{n-1} + D_{n-2})\\) with initial values \\(D_0 = 1\\) and \\(D_1 = 0\\). Applying the recurrence yields \\(D_2 = 1\\), \\(D_3 = 2\\), \\(D_4 = 9\\), \\(D_5 = 44\\), \\(D_6 = 265\\), and finally \\(D_7 = 6 \\times (265 + 44) = 1854\\).\n\nAnswer: 1854",
"turns": 1,
"cacheHitRatio": 0.7191011235955056,
"costUsd": 0.0028434299999999997,
"claudeEquivalentUsd": 0.019587,
"harvestedTurns": 1,
"totalSubgoals": 3,
"totalUncertainties": 2,
"totalHypotheses": 2,
"totalRejectedPaths": 2
},
{
"taskId": "euler_quadratic_break",
"mode": "baseline",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "Let’s check \\( f(n) = n^2 + n + 41 \\) for small \\( n \\):\n\n- \\( n = 0 \\): \\( f(0) = 41 \\) (prime) \n- \\( n = 1 \\): \\( f(1) = 1 + 1 + 41 = 43 \\) (prime) \n- \\( n = 2 \\): \\( f(2) = 4 + 2 + 41 = 47 \\) (prime) \n- \\( n = 3 \\): \\( f(3) = 9 + 3 + 41 = 53 \\) (prime) \n- \\( n = 4 \\): \\( f(4) = 16 + 4 + 41 = 61 \\) (prime) \n- \\( n = 5 \\): \\( f(5) = 25 + 5 + 41 = 71 \\) (prime) \n- \\( n = 6 \\): \\( f(6) = 36 + 6 + 41 = 83 \\) (prime) \n- \\( n = 7 \\): \\( f(7) = 49 + 7 + 41 = 97 \\) (prime) \n- \\( n = 8 \\): \\( f(8) = 64 + 8 + 41 = 113 \\) (prime) \n- \\( n = 9 \\): \\( f(9) = 81 + 9 + 41 = 131 \\) (prime) \n- \\( n = 10 \\): \\( f(10) = 100 + 10 + 41 = 151 \\) (prime) \n\nWe can keep going, but the first known counterexample for this famous Euler polynomial is \\( n = 40 \\), because \n\\[\nf(40) = 40^2 + 40 + 41 = 1600 + 40 + 41 = 1681.\n\\] \nCheck if \\( 1681 \\) is prime: \n\\( 1681 = 41^2 \\) (since \\( 41 \\times 41 = 1681 \\)), so it’s composite.\n\nBut wait — let’s check if there’s a smaller \\( n \\) where \\( f(n) \\) is composite. \nWe can test \\( n = 41 \\): \n\\( f(41) = 41^2 + 41 + 41 = 1681 + 82 = 1763 \\), which is \\( 41 \\times 43 \\) (since \\( 1763/41 = 43 \\)), so composite. But \\( n = 41 \\) is larger than \\( n = 40 \\).\n\nCheck \\( n = 39 \\): \\( f(39) = 1521 + 39 + 41 = 1601 \\). Test divisibility: not divisible by primes up to \\( \\sqrt{1601} \\approx 40 \\), seems prime. \n\nCheck \\( n = 40 \\) is indeed the first composite? Let’s check \\( n = 1 \\) to \\( 39 \\) quickly for a smaller counterexample: \nWe can note \\( f(n) = n(n+1) + 41 \\). If \\( 41 \\mid f(n) \\), then \\( 41 \\mid n(n+1) \\). \nThat happens when \\( n \\equiv 0 \\pmod{41} \\) or \\( n \\equiv 40 \\pmod{41} \\). \nThe smallest positive such \\( n \\) is \\( n = 40 \\) (since \\( n=41 \\) is larger). \nSo for \\( n = 40 \\), \\( f(40) = 40 \\times 41 + 41 = 41 \\times 41 \\) is composite. \n\nCheck \\( n = 0 \\) to \\( 39 \\): no multiple of 41 except \\( n=0 \\) (which gives 41, prime). So \\( n=40 \\) is indeed the first nonnegative integer where \\( f(n) \\) is composite.\n\nThus smallest \\( n \\) is \\( 40 \\), \\( f(40) = 1681 = 41^2 \\).\n\n**Answer:** 40",
"turns": 1,
"cacheHitRatio": 0,
"costUsd": 0.00098535,
"claudeEquivalentUsd": 0.013365,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "euler_quadratic_break",
"mode": "reasoner",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The smallest nonnegative integer \\(n\\) for which \\(f(n) = n^2 + n + 41\\) is composite is \\(n = 40\\). At this value, \\(f(40) = 40^2 + 40 + 41 = 1681 = 41^2\\), which is not prime.\n\nAnswer: 40",
"turns": 1,
"cacheHitRatio": 0.6095238095238096,
"costUsd": 0.0014309199999999998,
"claudeEquivalentUsd": 0.0099,
"harvestedTurns": 0,
"totalSubgoals": 0,
"totalUncertainties": 0,
"totalHypotheses": 0,
"totalRejectedPaths": 0
},
{
"taskId": "euler_quadratic_break",
"mode": "reasoner-harvest",
"repeat": 1,
"verdict": "pass",
"finalAgentMessage": "The polynomial \\(f(n) = n^2 + n + 41\\) is known to produce primes for \\(n = 0, 1, 2, \\dots, 39\\). At \\(n = 40\\), we have \\(f(40) = 40^2 + 40 + 41 = 1681\\), and \\(1681 = 41^2\\), which is composite. Therefore, \\(n = 40\\) is the smallest nonnegative integer where \\(f(n)\\) fails to be prime.\n\nAnswer: 40",
"turns": 1,
"cacheHitRatio": 0.6095238095238096,
"costUsd": 0.0025938099999999998,
"claudeEquivalentUsd": 0.017865,
"harvestedTurns": 1,
"totalSubgoals": 3,
"totalUncertainties": 2,
"totalHypotheses": 3,
"totalRejectedPaths": 2
}
]
}
-291
View File
@@ -1,291 +0,0 @@
/** Harvest-bench runner — writes results.json. CLI flags + sample invocations in benchmarks/README.md. */
import { type WriteStream, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import {
CacheFirstLoop,
DeepSeekClient,
ImmutablePrefix,
claudeEquivalentCost,
loadDotenv,
} from "../../src/index.js";
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../src/transcript/log.js";
import { TASKS } from "./tasks.js";
import type {
HarvestBenchMeta,
HarvestBenchReport,
HarvestMode,
HarvestRunResult,
HarvestTask,
} from "./types.js";
import { ALL_MODES } from "./types.js";
loadDotenv();
interface CliArgs {
taskFilter: string | null;
modes: HarvestMode[];
repeats: number;
outPath: string | null;
transcriptsDir: string | null;
/** Per-HTTP-call timeout in ms. Default 300_000 (5 min) — reasoner + harvest runs legitimately need this. */
timeoutMs: number;
dry: boolean;
verbose: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
taskFilter: null,
modes: [...ALL_MODES],
repeats: 1,
outPath: null,
transcriptsDir: null,
timeoutMs: 300_000,
dry: false,
verbose: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--task") out.taskFilter = argv[++i] ?? null;
else if (a === "--mode") {
const v = (argv[++i] ?? "").toLowerCase() as HarvestMode;
if ((ALL_MODES as string[]).includes(v)) out.modes = [v];
} else if (a === "--repeats") out.repeats = Number.parseInt(argv[++i] ?? "1", 10);
else if (a === "--out") out.outPath = argv[++i] ?? null;
else if (a === "--transcripts-dir") out.transcriptsDir = argv[++i] ?? null;
else if (a === "--timeout") {
// Accept seconds for humans, convert to ms internally.
const secs = Number.parseInt(argv[++i] ?? "300", 10);
if (Number.isFinite(secs) && secs > 0) out.timeoutMs = secs * 1000;
} else if (a === "--dry") out.dry = true;
else if (a === "--verbose" || a === "-v") out.verbose = true;
}
return out;
}
function modelForMode(mode: HarvestMode): string {
if (mode === "baseline") return "deepseek-chat";
return "deepseek-reasoner";
}
function harvestForMode(mode: HarvestMode): boolean {
return mode === "reasoner-harvest";
}
async function runOnce(
task: HarvestTask,
mode: HarvestMode,
rep: number,
client: DeepSeekClient,
transcriptStream: WriteStream | null,
): Promise<HarvestRunResult> {
const model = modelForMode(mode);
const harvest = harvestForMode(mode);
const prefix = new ImmutablePrefix({ system: task.systemPrompt });
const loop = new CacheFirstLoop({
client,
prefix,
model,
harvest,
stream: false,
});
const prefixHash = prefix.fingerprint;
let harvestedTurns = 0;
let totalSubgoals = 0;
let totalUncertainties = 0;
let totalHypotheses = 0;
let totalRejectedPaths = 0;
let finalText = "";
let errorMessage: string | undefined;
if (transcriptStream) {
writeRecord(transcriptStream, {
ts: new Date().toISOString(),
turn: 1,
role: "user",
content: task.prompt,
});
}
try {
for await (const ev of loop.step(task.prompt)) {
if (transcriptStream && ev.role !== "assistant_delta") {
writeRecord(transcriptStream, recordFromLoopEvent(ev, { model, prefixHash }));
}
if (ev.role === "assistant_final") {
finalText = ev.content;
if (ev.planState) {
if (
ev.planState.subgoals.length +
ev.planState.hypotheses.length +
ev.planState.uncertainties.length +
ev.planState.rejectedPaths.length >
0
) {
harvestedTurns++;
totalSubgoals += ev.planState.subgoals.length;
totalHypotheses += ev.planState.hypotheses.length;
totalUncertainties += ev.planState.uncertainties.length;
totalRejectedPaths += ev.planState.rejectedPaths.length;
}
}
} else if (ev.role === "done") {
finalText = ev.content || finalText;
break;
} else if (ev.role === "error") {
throw new Error(ev.error ?? "loop error");
}
}
} catch (err) {
errorMessage = (err as Error).message;
}
const verdict = errorMessage
? { verdict: "fail" as const, note: errorMessage }
: task.check(finalText);
return {
taskId: task.id,
mode,
repeat: rep + 1,
verdict: verdict.verdict,
checkNote: verdict.note,
finalAgentMessage: finalText,
turns: loop.stats.turns.length,
cacheHitRatio: loop.stats.aggregateCacheHitRatio,
costUsd: loop.stats.totalCost,
claudeEquivalentUsd: loop.stats.turns.reduce((s, t) => s + claudeEquivalentCost(t.usage), 0),
harvestedTurns,
totalSubgoals,
totalUncertainties,
totalHypotheses,
totalRejectedPaths,
errorMessage,
};
}
function runDry(args: CliArgs): HarvestBenchReport {
const tasks = filterTasks(args.taskFilter);
const results: HarvestRunResult[] = [];
for (const task of tasks) {
// Exercise each checker with a deliberately wrong reply so we know
// the wiring is live — not a pass on empty.
const sanity = task.check("Answer: this is not the right answer at all");
console.log(`[${task.id}] checker exercises ok (sanity verdict=${sanity.verdict})`);
for (const mode of args.modes) {
results.push({
taskId: task.id,
mode,
repeat: 1,
verdict: "inconclusive",
checkNote: "dry-run (no model call)",
finalAgentMessage: "[dry-run]",
turns: 0,
cacheHitRatio: 0,
costUsd: 0,
claudeEquivalentUsd: 0,
harvestedTurns: 0,
totalSubgoals: 0,
totalUncertainties: 0,
totalHypotheses: 0,
totalRejectedPaths: 0,
});
}
}
return { meta: buildMeta(args, tasks.length), results };
}
function filterTasks(filter: string | null): HarvestTask[] {
if (!filter) return TASKS;
const t = TASKS.find((x) => x.id === filter);
if (!t) throw new Error(`unknown task: ${filter}`);
return [t];
}
function buildMeta(args: CliArgs, taskCount: number): HarvestBenchMeta {
return {
date: new Date().toISOString(),
repeatsPerTask: args.repeats,
taskCount,
modesRun: args.modes,
reasonixVersion: "0.2.2",
};
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.dry) {
const report = runDry(args);
writeReport(report, args.outPath);
return;
}
const client = new DeepSeekClient({ timeoutMs: args.timeoutMs });
const tasks = filterTasks(args.taskFilter);
if (args.transcriptsDir) mkdirSync(args.transcriptsDir, { recursive: true });
const results: HarvestRunResult[] = [];
for (const task of tasks) {
for (let rep = 0; rep < args.repeats; rep++) {
for (const mode of args.modes) {
let stream: WriteStream | null = null;
if (args.transcriptsDir) {
const fname = `${task.id}.${mode}.r${rep + 1}.jsonl`;
stream = openTranscriptFile(join(args.transcriptsDir, fname), {
version: 1,
source: `harvest-bench/${mode}`,
model: modelForMode(mode),
task: task.id,
mode,
repeat: rep + 1,
startedAt: new Date().toISOString(),
});
}
const started = Date.now();
let result: HarvestRunResult;
try {
result = await runOnce(task, mode, rep, client, stream);
} finally {
stream?.end();
}
const elapsed = ((Date.now() - started) / 1000).toFixed(1);
const note = result.checkNote ? ` ${result.checkNote}` : "";
console.log(
`[${task.id}/${mode}/r${rep + 1}] verdict=${result.verdict} cache=${(result.cacheHitRatio * 100).toFixed(1)}% cost=$${result.costUsd.toFixed(6)} harvest=${result.harvestedTurns}:${result.totalSubgoals}s/${result.totalUncertainties}u (${elapsed}s)${note}`,
);
results.push(result);
}
}
}
const report: HarvestBenchReport = { meta: buildMeta(args, tasks.length), results };
writeReport(report, args.outPath);
}
function writeReport(report: HarvestBenchReport, outPath: string | null): void {
const path =
outPath ?? `benchmarks/harvest/results-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
writeFileSync(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`wrote ${path}`);
}
function isMain(): boolean {
const entry = process.argv[1];
if (!entry) return false;
return import.meta.url === pathToFileURL(entry).href;
}
if (isMain()) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
export { main as runHarvestBench };
-143
View File
@@ -1,143 +0,0 @@
/** Seed reasoning tasks for harvest-bench — single-turn Q/A with deterministic checkers. */
import type { HarvestTask } from "./types.js";
const CONCISE_SYSTEM = `You are a precise reasoner. Think carefully, then state your final answer on a clearly-labeled line starting with "Answer:".`;
/** Parse non-negative integers; expands "2-4" → 2,3,4; strips LaTeX decorations first. */
function extractIntegers(text: string): number[] {
// Strip LaTeX delimiters so n_1 / n^2 / \{ ... \} don't confuse us.
const cleaned = text
.replace(/\\[a-zA-Z]+/g, " ") // \frac, \mod etc.
.replace(/[{}\\]/g, " ")
.replace(/[_^]\d+/g, " "); // subscript/superscript numbers
// Expand ranges: "2-4" → "2,3,4" (only for small gaps)
const rangeExpanded = cleaned.replace(/(\d+)\s*[-–]\s*(\d+)/g, (_, a, b) => {
const lo = Number.parseInt(a, 10);
const hi = Number.parseInt(b, 10);
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi - lo > 50) return `${a} ${b}`;
const out: number[] = [];
for (let i = Math.min(lo, hi); i <= Math.max(lo, hi); i++) out.push(i);
return out.join(" ");
});
const matches = rangeExpanded.match(/-?\d+/g) ?? [];
return matches.map((m) => Number.parseInt(m, 10)).filter((n) => Number.isFinite(n));
}
/** Extract content after the last "Answer:" label (case-insensitive). */
function extractAnswerLine(text: string): string {
const matches = [...text.matchAll(/answer\s*[::]\s*([^\n]+)/gi)];
if (matches.length === 0) return text;
return matches[matches.length - 1]![1]!.trim();
}
/** Positive n ≤ 100 such that n^2 + n + 1 ≡ 0 (mod 7). */
const MOD7_EXPECTED: number[] = (() => {
const out: number[] = [];
for (let n = 1; n <= 100; n++) {
if ((n * n + n + 1) % 7 === 0) out.push(n);
}
return out;
})();
/** Single-integer checker — first plausible int from the last "Answer:" line, exact equality. */
function checkSingleInt(expected: number) {
return (reply: string) => {
const answerLine = extractAnswerLine(reply);
const nums = extractIntegers(answerLine);
if (nums.length === 0) return { verdict: "fail" as const, note: "no number in Answer line" };
const n = nums[0]!;
if (n === expected) return { verdict: "pass" as const };
return { verdict: "fail" as const, note: `got ${n}, expected ${expected}` };
};
}
export const TASKS: HarvestTask[] = [
{
id: "mod7_list",
description:
"Number theory — find all positive n ≤ 100 with n^2+n+1 ≡ 0 (mod 7). 29-element set. Classic R1 scratch-work territory.",
systemPrompt: CONCISE_SYSTEM,
prompt:
'Find all positive integers n with n ≤ 100 such that n^2 + n + 1 is divisible by 7. Briefly justify, then end with the full list on a line starting with "Answer:".',
check: (reply) => {
const answerLine = extractAnswerLine(reply);
const found = new Set(extractIntegers(answerLine).filter((n) => n >= 1 && n <= 100));
const expected = new Set(MOD7_EXPECTED);
if (found.size === 0) {
return { verdict: "fail", note: "no integers extracted from Answer line" };
}
// Exact set equality — both containment directions.
for (const n of expected)
if (!found.has(n)) return { verdict: "fail", note: `missing n=${n}` };
for (const n of found) if (!expected.has(n)) return { verdict: "fail", note: `extra n=${n}` };
return { verdict: "pass" };
},
},
{
id: "flips_until_3heads",
description:
"Probability — expected coin flips until 3 consecutive heads. Answer: 14. Tests whether R1 derives via recurrence or via memorized result.",
systemPrompt: CONCISE_SYSTEM,
prompt:
'A fair coin is flipped repeatedly until you see 3 heads in a row. What is the expected number of total flips? Give a single integer on a line starting with "Answer:".',
check: (reply) => {
const answerLine = extractAnswerLine(reply);
const nums = extractIntegers(answerLine);
if (nums.length === 0) return { verdict: "fail", note: "no number in Answer line" };
// Accept the first sensible number — sometimes agents write "Answer: 14 flips".
const n = nums[0]!;
if (n === 14) return { verdict: "pass" };
return { verdict: "fail", note: `got ${n}, expected 14` };
},
},
{
id: "three_hats",
description:
"Logic — three people wear red/blue hats, at least one red, they're asked in order. First two say no, third says yes. What color? Answer: red (deducible because if third were blue, the first two could see one red + one unknown and wouldn't have said no on their full first round, for most configurations the third can only be certain if red).",
systemPrompt: CONCISE_SYSTEM,
prompt:
'Three people wear hats that are each red or blue. They are told at least one hat is red. Each can see the other two hats but not their own. They are asked in order: "Do you know your hat color?" The first two answer no. The third answers yes. What color is the third person\'s hat? Give a one-word answer on a line starting with "Answer:".',
check: (reply) => {
const answerLine = extractAnswerLine(reply).toLowerCase();
// Accept "red" as a standalone word (not "redirected" etc).
if (/\bred\b/.test(answerLine) && !/\bblue\b/.test(answerLine)) {
return { verdict: "pass" };
}
if (/\bblue\b/.test(answerLine)) return { verdict: "fail", note: "answered blue" };
return { verdict: "fail", note: "no clear color in Answer line" };
},
},
{
id: "pseudoprime_base2",
description:
"Number theory — smallest composite n > 1 such that 2^n ≡ 2 (mod n). This is the smallest Fermat pseudoprime to base 2. Answer: 341 (= 11·31). V3 frequently answers 561 (Carmichael / wrong base-2 property) or 9 (not actually a pseudoprime to base 2, verifying the model isn't computing). R1 usually gets this right.",
systemPrompt: CONCISE_SYSTEM,
prompt:
'Find the smallest composite integer n > 1 such that 2^n is congruent to 2 modulo n (i.e., n is a Fermat pseudoprime to base 2). Briefly justify, then give a single integer on a line starting with "Answer:".',
check: checkSingleInt(341),
},
{
id: "derangements_d7",
description:
"Combinatorics — number of derangements of 7 elements. Answer: 1854 (D_7 = 7! · (1 − 1/1! + 1/2! − 1/3! + … ± 1/7!) = 5040 · 0.367857… = 1854). V3 sometimes approximates via n!/e and rounds wrong, or confuses with D_6=265 or D_8=14833. R1's explicit recurrence D_n = (n-1)(D_{n-1}+D_{n-2}) avoids the mistake.",
systemPrompt: CONCISE_SYSTEM,
prompt:
'How many permutations of {1, 2, 3, 4, 5, 6, 7} have no fixed points? (This is D_7, the derangement number.) Briefly justify, then give a single integer on a line starting with "Answer:".',
check: checkSingleInt(1854),
},
{
id: "euler_quadratic_break",
description:
"Number theory — smallest nonneg integer n where n^2 + n + 41 is NOT prime. Euler's prime-generating polynomial famously produces primes for n = 0..39 and first fails at n = 40 (40²+40+41 = 1681 = 41²). V3 often confuses this with the related n²-n+41 (first fails at 41) or cites the wrong threshold. R1 checks small n systematically.",
systemPrompt: CONCISE_SYSTEM,
prompt:
'Consider the polynomial f(n) = n^2 + n + 41 for n = 0, 1, 2, …. What is the smallest nonneg integer n at which f(n) fails to be prime? Briefly justify (including the value of f(n) at that point), then give just the integer n on a line starting with "Answer:".',
check: checkSingleInt(40),
},
];
-61
View File
@@ -1,61 +0,0 @@
/** Harvest-bench types — single-turn Q/A with deterministic checkers; isolates Pillar 2 the way τ-bench-lite isolates Pillar 1. */
export type HarvestMode =
/** deepseek-chat, no Reasonix tricks. Floor reference for answer quality on reasoning tasks (V3 at its best). */
| "baseline"
/** deepseek-reasoner, no harvest. Isolates "does reasoner help over chat on these problems?" */
| "reasoner"
/** deepseek-reasoner + harvest. Isolates "does the extra V3 harvest call add quality / change behavior?" */
| "reasoner-harvest";
export const ALL_MODES: HarvestMode[] = ["baseline", "reasoner", "reasoner-harvest"];
/** Checker verdict — `inconclusive` reserves room for "right shape, can't verify without running it". */
export type CheckVerdict = "pass" | "fail" | "inconclusive";
export interface HarvestTask {
id: string;
/** One-line human description, not shown to the model. */
description: string;
/** System prompt. Kept minimal so harvest signal can be attributed to the model, not to prompt engineering. */
systemPrompt: string;
/** The single question. */
prompt: string;
/** Pure + deterministic — no LLM calls, no file I/O. Verdict + optional one-line note. */
check: (agentReply: string) => { verdict: CheckVerdict; note?: string };
}
export interface HarvestRunResult {
taskId: string;
mode: HarvestMode;
repeat: number;
verdict: CheckVerdict;
checkNote?: string;
finalAgentMessage: string;
/** Turns the loop completed (usually 1 for a single-shot Q/A, > 1 if tool-calls happen). */
turns: number;
cacheHitRatio: number;
costUsd: number;
claudeEquivalentUsd: number;
/** Harvest-specific — only non-zero on reasoner-harvest mode. */
harvestedTurns: number;
totalSubgoals: number;
totalUncertainties: number;
totalHypotheses: number;
totalRejectedPaths: number;
/** Optional error message if the run crashed (model down, timeout, etc). */
errorMessage?: string;
}
export interface HarvestBenchMeta {
date: string;
repeatsPerTask: number;
taskCount: number;
modesRun: HarvestMode[];
reasonixVersion: string;
}
export interface HarvestBenchReport {
meta: HarvestBenchMeta;
results: HarvestRunResult[];
}
+139
View File
@@ -0,0 +1,139 @@
# 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) |
| `/loop` | auto-resubmit on interval | **panel** · Settings → Loop (D-5 #438) |
## Memory & project
| Slash | Purpose | Coverage |
|---|---|---|
| `/memory [list / show / forget / clear]` | manage pinned memory | **panel** · Memory tab |
| `/init` | synthesize baseline REASONIX.md | chat-box |
| `/semantic` | semantic-search index status | chat-box |
| `/search-engine` (`/se`) | switch web search backend | chat-box |
| `/language` (`/lang`) | runtime language | **panel** · Settings → Language |
## Sessions
| Slash | Purpose | Coverage |
|---|---|---|
| `/sessions` | list saved sessions | **panel** · SessionPicker modal (C-2 #423) |
| `/resume` | open a session | **panel** · same picker |
| `/rename` | rename current session | chat-box |
| `/forget` | delete current session | chat-box |
| `/plans` | active + archived plans | **panel** · Plans tab |
| `/replay [N]` | read-only plan archive | **panel** · Viewer modal (C-5 #427) |
## MCP
| Slash | Purpose | Coverage |
|---|---|---|
| `/mcp` (list) | bridged servers + tools | **panel** · MCP tab |
| `/mcp browse` | marketplace + install | **panel** · MCP marketplace picker (C-4 #426) |
| `/mcp disable` / `enable` / `reconnect` / `text` | server admin | chat-box |
| `/resource [uri]` | browse / read MCP resources | chat-box |
| `/prompt [name]` | browse / fetch MCP prompts | chat-box |
## Permissions & admin
| Slash | Purpose | Coverage |
|---|---|---|
| `/permissions [list / add / remove / clear]` | shell allowlist | **panel** · Permissions tab |
| `/hooks [reload]` | active hooks | **panel** · Hooks tab |
| `/dashboard [stop]` | embedded dashboard lifecycle | chat-box · *intentional — admin command for the surface you're typing in* |
## Code-mode only
| Slash | Purpose | Coverage |
|---|---|---|
| `/init [force]` | scan + synthesize REASONIX.md | chat-box |
| `/apply [N]` | commit pending edits | chat-box |
| `/discard [N]` | drop pending edits | chat-box |
| `/walk` | step through pending edits | **panel** · edit-review modal already covered web pre-#369 |
| `/undo` | roll back last edit batch | chat-box |
| `/history` | edit batch list | chat-box |
| `/show [id]` | dump stored edit diff | chat-box |
| `/commit "msg"` | git commit | chat-box |
| `/checkpoint [name / list / forget]` | snapshot touched files | chat-box |
| `/restore` | roll back to checkpoint | **panel** · CheckpointPicker modal (C-3 #425) |
| `/plan [on / off]` | read-only plan mode | chat-box |
| `/apply-plan` | force-approve pending plan | chat-box |
| `/mode [review / auto / yolo]` | edit gate | **panel** · Chat header pill |
| `/jobs` | list background jobs | chat-box |
| `/kill <id>` | stop background job | chat-box |
| `/logs <id> [lines]` | tail job output | chat-box |
| `/skill [list / show / new / <name>]` | skill management | **panel** · Skills tab |
## Keyboard / TTY-native
These don't have slashes. They ride alongside the slash surface and
stay TUI-only:
- `Esc` — abort current model turn (web equivalent: Abort button in chat)
- `Shift+Tab` — cycle edit mode (web equivalent: mode pill in chat header)
- `Ctrl-L`, `Ctrl-O`, `space`, `u` — TTY scroll / undo banner / pause hotkeys
## Done
Buckets covered:
- **C** (#416, closed): pickers — `/sessions`, `/restore`, `/mcp browse`, `/replay` + `/walk` (already)
- **D** (#428, closed): settings — `/preset`, `/effort`, `/model`, `/pro`, `/budget`, `/loop`, `/language`
Bucket A (text outputs that stay chat-box) and bucket B (structured
outputs that already have panels) need no further work — the panels
shipped in C/D plus the long-standing Memory / Permissions / Hooks /
Skills / Plans / Usage tabs already cover every command whose output
warranted a dedicated UI.
This file is the source of truth for the audit. Add a row when a
new slash lands.
+149
View File
@@ -2019,6 +2019,155 @@ input:focus, textarea:focus, select:focus { border-color: var(--c-brand); }
max-height: 200px;
}
.modal-picker-search {
width: 100%;
background: var(--bg-elev-2);
border: 1px solid var(--bd);
border-radius: var(--r);
color: var(--fg-1);
padding: 6px 10px;
font-size: 13px;
margin-bottom: 8px;
}
.modal-picker-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--bd);
border-radius: var(--r);
background: var(--bg-elev-2);
padding: 4px;
margin-bottom: 8px;
}
.modal-picker-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 8px;
align-items: baseline;
padding: 6px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm, 4px);
color: var(--fg-1);
text-align: left;
cursor: pointer;
}
.modal-picker-row:hover {
background: var(--bg-elev-3, var(--bg-elev-2));
}
.modal-picker-row.selected {
border-color: var(--accent, #fcd34d);
background: var(--bg-elev-3, var(--bg-elev-2));
}
.modal-picker-title {
grid-column: 1;
font-weight: 500;
}
.modal-picker-badge {
grid-column: 2;
font-size: 11px;
color: var(--fg-2);
padding: 1px 6px;
border-radius: 999px;
background: var(--bg-elev-1, var(--bg-elev-2));
}
.modal-picker-subtitle {
grid-column: 1 / 3;
font-size: 12px;
color: var(--fg-2);
}
.modal-picker-meta {
grid-column: 1 / 3;
font-size: 11px;
color: var(--fg-3, var(--fg-2));
}
.modal-picker-empty {
padding: 16px;
text-align: center;
color: var(--fg-2);
font-size: 12px;
}
.modal-picker-more {
align-self: center;
margin: 0 auto 8px;
font-size: 12px;
}
.modal-picker-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.modal-picker-form input {
width: 100%;
background: var(--bg-elev-2);
border: 1px solid var(--bd);
border-radius: var(--r);
color: var(--fg-1);
padding: 6px 10px;
font-size: 13px;
}
.modal-viewer-steps {
list-style: none;
padding: 0;
margin: 0 0 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.modal-viewer-step {
display: grid;
grid-template-columns: 18px 1fr;
gap: 6px;
padding: 4px 8px;
border-radius: var(--r-sm, 4px);
background: var(--bg-elev-2);
align-items: baseline;
}
.modal-viewer-step-mark {
text-align: center;
font-weight: 600;
color: var(--fg-2);
}
.modal-viewer-step-done .modal-viewer-step-mark {
color: #86efac;
}
.modal-viewer-step-title {
color: var(--fg-1);
font-size: 13px;
}
.modal-viewer-step-done .modal-viewer-step-title {
color: var(--fg-2);
}
.modal-viewer-body {
margin-top: 4px;
font-size: 13px;
color: var(--fg-1);
max-height: 360px;
overflow-y: auto;
}
/* Side-by-side diff for the edit-review modal — left is "before" (red
* tint), right is "after" (green tint), context rows render unchanged.
* Lines hljs-highlight per the file's language. */
+257
View File
@@ -104,6 +104,49 @@ interface RevisionSpec {
remainingSteps: RevisionStep[];
}
export type PickerActionName =
| "pick"
| "delete"
| "rename"
| "new"
| "install"
| "uninstall"
| "load-more"
| "refine"
| "cancel";
export interface PickerItemSpec {
id: string;
title: string;
subtitle?: string;
badge?: string;
meta?: string;
}
export interface PickerModalSpec {
pickerKind: string;
title: string;
query?: string;
items: PickerItemSpec[];
actions: PickerActionName[];
hasMore?: boolean;
hint?: string;
}
export interface ViewerStep {
id: string;
title: string;
status: "done" | "queued";
}
export interface ViewerModalSpec {
viewerKind: string;
title: string;
body?: string;
steps?: ViewerStep[];
meta?: string;
}
interface DiffEntry {
kind: "context" | "ins" | "del";
text: string;
@@ -617,6 +660,220 @@ export function CheckpointModal({ modal, onResolve }: { modal: CheckpointSpec; o
`;
}
export function PickerModal({
modal,
onResolve,
}: {
modal: PickerModalSpec;
onResolve: OnResolve;
}) {
useLang();
const [selectedId, setSelectedId] = useState<string | null>(modal.items[0]?.id ?? null);
const [query, setQuery] = useState(modal.query ?? "");
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameText, setRenameText] = useState("");
const [showNew, setShowNew] = useState(false);
const [newText, setNewText] = useState("");
const has = (a: PickerActionName) => modal.actions.includes(a);
const selected = modal.items.find((i) => i.id === selectedId) ?? null;
const submitRefine = (next: string) => {
setQuery(next);
if (has("refine")) onResolve("picker", { action: "refine", query: next });
};
const startRename = (id: string) => {
const item = modal.items.find((i) => i.id === id);
if (!item) return;
setRenameTarget(id);
setRenameText(item.title);
};
const sendRename = () => {
if (!renameTarget || !renameText.trim()) return;
onResolve("picker", { action: "rename", id: renameTarget, text: renameText });
setRenameTarget(null);
setRenameText("");
};
const sendNew = () => {
onResolve("picker", newText.trim() ? { action: "new", text: newText } : { action: "new" });
setShowNew(false);
setNewText("");
};
return html`
<${ModalCard}
accent="#fcd34d"
icon="≡"
title=${modal.title}
subtitle=${modal.hint}
>
${
has("refine")
? html`<input
class="modal-picker-search"
type="search"
placeholder=${t("modal.pickerFilter")}
value=${query}
onInput=${(e: Event) => submitRefine((e.target as HTMLInputElement).value)}
/>`
: null
}
<div class="modal-picker-list">
${
modal.items.length === 0
? html`<div class="modal-picker-empty">${t("modal.pickerEmpty")}</div>`
: modal.items.map(
(it) => html`
<button
key=${it.id}
class=${`modal-picker-row${it.id === selectedId ? " selected" : ""}`}
onClick=${() => setSelectedId(it.id)}
onDblClick=${() => has("pick") && onResolve("picker", { action: "pick", id: it.id })}
>
<span class="modal-picker-title">${it.title}</span>
${it.badge ? html`<span class="modal-picker-badge">${it.badge}</span>` : null}
${it.subtitle ? html`<span class="modal-picker-subtitle">${it.subtitle}</span>` : null}
${it.meta ? html`<span class="modal-picker-meta">${it.meta}</span>` : null}
</button>
`,
)
}
</div>
${
modal.hasMore && has("load-more")
? html`<button
class="modal-picker-more"
onClick=${() => onResolve("picker", { action: "load-more" })}
>${t("modal.pickerLoadMore")}</button>`
: null
}
${
renameTarget
? html`
<div class="modal-picker-form">
<input
type="text"
value=${renameText}
onInput=${(e: Event) => setRenameText((e.target as HTMLInputElement).value)}
/>
<div class="modal-actions">
<button class="primary" onClick=${sendRename} disabled=${!renameText.trim()}>${t("common.save")}</button>
<button onClick=${() => setRenameTarget(null)}>${t("common.back")}</button>
</div>
</div>
`
: showNew
? html`
<div class="modal-picker-form">
<input
type="text"
placeholder=${t("modal.pickerNewPlaceholder")}
value=${newText}
onInput=${(e: Event) => setNewText((e.target as HTMLInputElement).value)}
/>
<div class="modal-actions">
<button class="primary" onClick=${sendNew}>${t("common.add")}</button>
<button onClick=${() => setShowNew(false)}>${t("common.back")}</button>
</div>
</div>
`
: html`
<div class="modal-actions">
${
has("pick") && selected
? html`<button
class="primary"
onClick=${() => onResolve("picker", { action: "pick", id: selected.id })}
>${t("modal.pickerPick")}</button>`
: null
}
${
has("install") && selected
? html`<button
class="primary"
onClick=${() => onResolve("picker", { action: "install", id: selected.id })}
>${t("modal.pickerInstall")}</button>`
: null
}
${
has("uninstall") && selected
? html`<button
onClick=${() => onResolve("picker", { action: "uninstall", id: selected.id })}
>${t("modal.pickerUninstall")}</button>`
: null
}
${
has("rename") && selected
? html`<button onClick=${() => startRename(selected.id)}>${t("modal.pickerRename")}</button>`
: null
}
${
has("delete") && selected
? html`<button
class="danger"
onClick=${() => onResolve("picker", { action: "delete", id: selected.id })}
>${t("common.delete")}</button>`
: null
}
${
has("new")
? html`<button onClick=${() => setShowNew(true)}>${t("modal.pickerNew")}</button>`
: null
}
<button onClick=${() => onResolve("picker", { action: "cancel" })}>${t("modal.cancel")}</button>
</div>
`
}
<//>
`;
}
export function ViewerModal({
modal,
onResolve,
}: {
modal: ViewerModalSpec;
onResolve: OnResolve;
}) {
useLang();
return html`
<${ModalCard}
accent="#67e8f9"
icon="◇"
title=${modal.title}
subtitle=${modal.meta}
>
${
modal.steps && modal.steps.length > 0
? html`
<ol class="modal-viewer-steps">
${modal.steps.map(
(s: ViewerStep) => html`
<li key=${s.id} class=${`modal-viewer-step modal-viewer-step-${s.status}`}>
<span class="modal-viewer-step-mark">${s.status === "done" ? "✓" : "·"}</span>
<span class="modal-viewer-step-title">${s.title}</span>
</li>
`,
)}
</ol>
`
: null
}
${
modal.body
? html`<div class="md modal-viewer-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body) }}></div>`
: null
}
<div class="modal-actions">
<button onClick=${() => onResolve("viewer", { action: "close" })}>${t("modal.viewerClose")}</button>
</div>
<//>
`;
}
export function RevisionModal({ modal, onResolve }: { modal: RevisionSpec; onResolve: OnResolve }) {
useLang();
const riskColor = (r: string | undefined) =>
+99 -20
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",
@@ -89,7 +117,8 @@ export const en = {
clearFailed: "/clear failed: {error}",
eventStreamError: "event stream interrupted — reconnecting…",
semanticBanner: "Semantic search isn't enabled for this project.",
semanticBannerDesc: "Build the index once and the model can find code by meaning (\"where do we handle auth failures?\") instead of grep on exact strings.",
semanticBannerDesc:
'Build the index once and the model can find code by meaning ("where do we handle auth failures?") instead of grep on exact strings.',
semanticBannerBtn: "Build it →",
semanticBannerDismiss: "dismiss (don't show again)",
slashCommands: "slash commands",
@@ -98,9 +127,12 @@ export const en = {
effortMaxTitle: "max (default — best quality)",
effortHighTitle: "high (cheaper / faster)",
presetTitle: "preset — model commitment",
presetAutoTitle: "auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)",
presetFlashTitle: "flash — always flash; no auto-escalate. /pro still works for one-shot manual",
presetProTitle: "pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work.",
presetAutoTitle:
"auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)",
presetFlashTitle:
"flash — always flash; no auto-escalate. /pro still works for one-shot manual",
presetProTitle:
"pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work.",
editGateTitle: "edit gate — Shift+Tab cycles in TUI",
editReviewTitle: "review — both edits and non-allowlisted shell ask first",
editAutoTitle: "auto — edits auto-apply, shell still asks",
@@ -139,12 +171,14 @@ export const en = {
loading: "loading overview…",
failed: "overview failed: {error}",
standaloneTitle: "Standalone mode",
standaloneDesc: "Read-only disk view. Start /dashboard from inside reasonix code for live session state, MCP, and tools.",
standaloneDesc:
"Read-only disk view. Start /dashboard from inside reasonix code for live session state, MCP, and tools.",
cockpit: "Cockpit",
balance: "balance",
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",
@@ -197,7 +231,8 @@ export const en = {
msgs: "msgs",
pickHint: "Pick a session on the left to read its transcript.",
resumeTitle: "Resume in TUI",
resumeDesc: "Mid-session swap requires a restart so the message log can rewind cleanly. Quit your current session, then run:",
resumeDesc:
"Mid-session swap requires a restart so the message log can rewind cleanly. Quit your current session, then run:",
loadingTranscript: "loading transcript…",
emptyTranscript: "empty transcript.",
messages: "{count} message{s}",
@@ -218,24 +253,26 @@ export const en = {
loading: "loading permissions…",
failed: "permissions failed: {error}",
yoloTitle: "YOLO mode",
yoloDesc: "Every shell command auto-runs, allowlist bypassed. Switch back with /mode review in the TUI.",
yoloDesc:
"Every shell command auto-runs, allowlist bypassed. Switch back with /mode review in the TUI.",
project: "project",
builtin: "builtin",
addPrefix: "add a prefix",
addPlaceholder: "e.g. \"npm run build\" or \"deploy.sh\"",
addPlaceholder: 'e.g. "npm run build" or "deploy.sh"',
clearAll: "Clear all",
alreadyIn: "{prefix} already in list",
added: "added: {prefix}",
removed: "removed: {prefix}",
cleared: "cleared {count} entr{y}",
removeConfirm: "Remove \"{prefix}\" from this project's allowlist?",
removeConfirm: 'Remove "{prefix}" from this project\'s allowlist?',
clearConfirm: "Wipe every project allowlist entry? Builtin entries are unaffected.",
projectAllowlist: "Project allowlist · {count}",
nothingStored: "Nothing stored yet for this project.",
colNum: "#",
colPrefix: "prefix",
builtinTitle: "Builtin · {count} · read-only",
standaloneWarning: "Mutations require /dashboard from inside an active reasonix code session — standalone reasonix dashboard can't tell which project's allowlist to edit.",
standaloneWarning:
"Mutations require /dashboard from inside an active reasonix code session — standalone reasonix dashboard can't tell which project's allowlist to edit.",
},
mcp: {
loading: "loading MCP…",
@@ -255,7 +292,8 @@ export const en = {
removeBtn: "Remove",
spec: "spec",
whyUnbridged: "Why unbridged?",
whyUnbridgedDesc: "This spec lives in your config.json but isn't bridged into the live session. MCP servers attach when reasonix code starts; the dashboard alone can't spawn the child process.",
whyUnbridgedDesc:
"This spec lives in your config.json but isn't bridged into the live session. MCP servers attach when reasonix code starts; the dashboard alone can't spawn the child process.",
whyUnbridgedHint: "To activate: restart reasonix code, then refresh this dashboard.",
pickHint: "Pick an MCP server on the left to inspect tools / resources / prompts.",
toolsTitle: "Tools · {count}",
@@ -283,13 +321,17 @@ export const en = {
marketplaceAlready: "already installed",
marketplaceNeedsEnv: "needs env: {names}",
marketplaceSourceTag: "[{source}]",
marketplaceNoInstall: "smithery listing — install metadata not exposed; use `npx -y @smithery/cli install {name}` directly",
marketplaceFetchOnInstall: "Smithery listing — install detail fetched on Install. http servers map to streamable-http remotes; stdio servers run via @smithery/cli.",
marketplaceNoInstall:
"smithery listing — install metadata not exposed; use `npx -y @smithery/cli install {name}` directly",
marketplaceFetchOnInstall:
"Smithery listing — install detail fetched on Install. http servers map to streamable-http remotes; stdio servers run via @smithery/cli.",
marketplaceInstalledBadge: "installed",
marketplaceUninstall: "Uninstall",
marketplaceEnvTitle: "Required environment variables",
marketplaceEnvHint: "Set these in your shell before next `reasonix code` so the bridged server can authenticate.",
marketplaceRestartHint: "Spec written to ~/.reasonix/config.json. Restart `reasonix code` to bridge the server (live hot-reload is on the roadmap).",
marketplaceEnvHint:
"Set these in your shell before next `reasonix code` so the bridged server can authenticate.",
marketplaceRestartHint:
"Spec written to ~/.reasonix/config.json. Restart `reasonix code` to bridge the server (live hot-reload is on the roadmap).",
},
memory: {
loading: "loading memory…",
@@ -332,7 +374,8 @@ export const en = {
runs7d: "runs · 7d",
pickHint: "Pick a skill on the left, or create a new one above.",
readOnlyBuiltin: "read-only · builtin",
builtinDesc: "Built-in skills ship with Reasonix; the model picks them up automatically. To customize, create a project- or global-scoped skill with the same name.",
builtinDesc:
"Built-in skills ship with Reasonix; the model picks them up automatically. To customize, create a project- or global-scoped skill with the same name.",
saved: "saved {scope}/{name}",
deleteConfirm: "Delete skill {scope}/{name}?",
reloadHint: "re-loaded on next /new or session restart",
@@ -375,6 +418,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)",
@@ -388,11 +432,13 @@ export const en = {
ready: "ready",
setupNeeded: "setup needed",
installOllama: "Install Ollama",
installOllamaDesc: "Reasonix doesn't run package managers for you. Install Ollama first, then come back:",
installOllamaDesc:
"Reasonix doesn't run package managers for you. Install Ollama first, then come back:",
macWindows: "macOS / Windows:",
download: "download from ollama.com/download",
linux: "Linux:",
refreshHint: "Refresh after install — this panel will offer to start the daemon and pull {model}.",
refreshHint:
"Refresh after install — this panel will offer to start the daemon and pull {model}.",
daemon: "Daemon",
daemonDesc: "ollama is on your PATH but the HTTP daemon isn't reachable.",
startDaemon: "Start daemon",
@@ -404,16 +450,35 @@ export const en = {
pullModel: "Pull {model}",
indexStatus: "index status",
builtStatus: "● built",
incompatibleStatus: "● incompatible",
chunks: "chunks",
files: "files",
dim: "dim",
size: "size",
lastBuild: "last build",
builtWith: "built with",
currentTarget: "current target",
incompatibleHint: "This on-disk index was built for a different provider or model. Run Rebuild to replace it.",
runIndexHint: "Run an index to enable semantic_search.",
reIndex: "Re-index",
build: "Build",
rebuild: "Rebuild",
stop: "Stop",
provider: "provider",
providerType: "service type",
openaiCompat: "openai-compatible",
apiUrl: "API URL",
apiKey: "API key",
apiKeyStoredNote: "API key is stored in ~/.reasonix/config.json — do not share that file.",
customRequestBody: "custom request body",
invalidCustomRequestBody: "Custom request body must be valid JSON: {error}",
customRequestBodyMustBeObject: "Custom request body must be a JSON object.",
saveBeforeIndex: "Save semantic settings before starting an index.",
extraBody: "extra body",
keepExistingKey: "leave blank to keep existing key",
remoteProvider: "Remote embedding provider",
remoteProviderDesc:
"Configure the full OpenAI-compatible embeddings URL here. Reasonix will send requests exactly to the URL you provide.",
ollama: "ollama",
binary: "binary",
found: "found",
@@ -441,11 +506,14 @@ export const en = {
nothingSkipped: "nothing skipped — all walked files would be indexed.",
firstIncluded: "first {count} included file(s)",
job: "Job",
phaseSetup: "preparing",
phaseScan: "scanning files",
phaseEmbed: "embedding chunks",
phaseWrite: "writing index",
phaseDone: "done",
phaseError: "error",
phaseCancelled: "cancelled",
setupFailed: "setup failed",
stopping: "stopping",
scanned: "scanned {count}",
changed: "changed {count}",
@@ -473,7 +541,7 @@ export const en = {
shellSubtitle: "model wants to run a shell command",
shellBgSubtitle: "long-running — keeps running after approval",
runOnce: "Run once",
alwaysAllow: "Always allow \"{prefix}\"",
alwaysAllow: 'Always allow "{prefix}"',
deny: "Deny",
choiceTitle: "model wants you to pick",
typeOwn: "Type my own answer",
@@ -484,7 +552,8 @@ export const en = {
cancelSummary: "Drop the question. Model will ask what you actually want.",
planTitle: "plan submitted",
planSubtitle: "model proposed a plan; review then pick",
approveInstructions: "Optional last instructions / answers to open questions (Enter to send blank)",
approveInstructions:
"Optional last instructions / answers to open questions (Enter to send blank)",
refinePlaceholder: "What needs to change? Be specific.",
approve: "Approve",
refine: "Refine",
@@ -507,5 +576,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",
},
};
+80 -11
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: "语言",
@@ -89,7 +117,8 @@ export const zhCN = {
clearFailed: "/clear 失败:{error}",
eventStreamError: "事件流中断 — 正在重连…",
semanticBanner: "此项目未启用语义搜索。",
semanticBannerDesc: "构建一次索引,模型即可按含义查找代码(“哪里处理认证失败?”),而不仅依赖精确字符串的 grep。",
semanticBannerDesc:
"构建一次索引,模型即可按含义查找代码(“哪里处理认证失败?”),而不仅依赖精确字符串的 grep。",
semanticBannerBtn: "构建 →",
semanticBannerDismiss: "关闭(不再显示)",
slashCommands: "斜杠命令",
@@ -139,12 +168,14 @@ export const zhCN = {
loading: "加载概览…",
failed: "概览失败:{error}",
standaloneTitle: "独立模式",
standaloneDesc: "只读磁盘视图。在 reasonix code 内启动 /dashboard 以获取实时会话状态、MCP 和工具。",
standaloneDesc:
"只读磁盘视图。在 reasonix code 内启动 /dashboard 以获取实时会话状态、MCP 和工具。",
cockpit: "驾驶舱",
balance: "余额",
tokens7d: "tokens · 7 天",
cacheHit: "缓存命中",
toolCalls24h: "工具调用 · 24 小时",
budget: "预算",
currentSession: "当前会话",
noSession: "无活跃会话 — 在 reasonix code 内执行 /dashboard 进行连接。",
promptTok: "提示 tokens",
@@ -222,20 +253,21 @@ export const zhCN = {
project: "项目",
builtin: "内置",
addPrefix: "添加前缀",
addPlaceholder: "例如 \"npm run build\" 或 \"deploy.sh\"",
addPlaceholder: '例如 "npm run build" 或 "deploy.sh"',
clearAll: "清除全部",
alreadyIn: "{prefix} 已在列表中",
added: "已添加:{prefix}",
removed: "已移除:{prefix}",
cleared: "已清除 {count} 条",
removeConfirm: "从此项目的允许列表中移除 \"{prefix}\"?",
removeConfirm: '从此项目的允许列表中移除 "{prefix}"?',
clearConfirm: "清除所有项目允许列表条目?内置条目不受影响。",
projectAllowlist: "项目允许列表 · {count}",
nothingStored: "此项目暂无存储的条目。",
colNum: "#",
colPrefix: "前缀",
builtinTitle: "内置 · {count} · 只读",
standaloneWarning: "修改操作需要在活跃的 reasonix code 会话内执行 /dashboard — 独立模式的 reasonix dashboard 无法确定要编辑哪个项目的允许列表。",
standaloneWarning:
"修改操作需要在活跃的 reasonix code 会话内执行 /dashboard — 独立模式的 reasonix dashboard 无法确定要编辑哪个项目的允许列表。",
},
mcp: {
loading: "加载 MCP…",
@@ -255,7 +287,8 @@ export const zhCN = {
removeBtn: "移除",
spec: "规格",
whyUnbridged: "为什么未桥接?",
whyUnbridgedDesc: "此规格存在于您的 config.json 中,但未桥接到实时会话。MCP 服务器在 reasonix code 启动时连接;仪表盘本身无法生成子进程。",
whyUnbridgedDesc:
"此规格存在于您的 config.json 中,但未桥接到实时会话。MCP 服务器在 reasonix code 启动时连接;仪表盘本身无法生成子进程。",
whyUnbridgedHint: "激活方法:重启 reasonix code,然后刷新此仪表盘。",
pickHint: "选择左侧的 MCP 服务器以检查工具 / 资源 / 提示。",
toolsTitle: "工具 · {count}",
@@ -283,13 +316,16 @@ export const zhCN = {
marketplaceAlready: "已安装过",
marketplaceNeedsEnv: "需设置环境变量:{names}",
marketplaceSourceTag: "[{source}]",
marketplaceNoInstall: "Smithery 列表项 — 不暴露安装元数据;请直接 `npx -y @smithery/cli install {name}`",
marketplaceFetchOnInstall: "Smithery 列表 — 安装时再拉详情。HTTP 服务映射为 streamable-http 远端;stdio 服务通过 @smithery/cli 运行。",
marketplaceNoInstall:
"Smithery 列表项 — 不暴露安装元数据;请直接 `npx -y @smithery/cli install {name}`",
marketplaceFetchOnInstall:
"Smithery 列表 — 安装时再拉详情。HTTP 服务映射为 streamable-http 远端;stdio 服务通过 @smithery/cli 运行。",
marketplaceInstalledBadge: "已安装",
marketplaceUninstall: "卸载",
marketplaceEnvTitle: "必需的环境变量",
marketplaceEnvHint: "下次启动 `reasonix code` 之前在 shell 里设好,桥接的服务器才能正常鉴权。",
marketplaceRestartHint: "已写入 ~/.reasonix/config.json。重启 `reasonix code` 后服务器才会真正桥接(热重载在路线图上)。",
marketplaceRestartHint:
"已写入 ~/.reasonix/config.json。重启 `reasonix code` 后服务器才会真正桥接(热重载在路线图上)。",
},
memory: {
loading: "加载记忆…",
@@ -332,7 +368,8 @@ export const zhCN = {
runs7d: "次运行 · 7 天",
pickHint: "选择左侧的技能,或在上方创建新技能。",
readOnlyBuiltin: "只读 · 内置",
builtinDesc: "内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
builtinDesc:
"内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
saved: "已保存 {scope}/{name}",
deleteConfirm: "删除技能 {scope}/{name}?",
reloadHint: "在下次 /new 或会话重启时重新加载",
@@ -375,6 +412,7 @@ export const zhCN = {
filterPlaceholder: "筛选计划",
active: "进行中",
done: "已完成",
idle: "未开始",
steps: "步骤",
pickHint: "选择左侧的计划。",
noTitle: "(无标题)",
@@ -404,16 +442,34 @@ export const zhCN = {
pullModel: "拉取 {model}",
indexStatus: "索引状态",
builtStatus: "● 已构建",
incompatibleStatus: "● 不兼容",
chunks: "分块",
files: "文件",
dim: "维度",
size: "大小",
lastBuild: "上次构建",
builtWith: "构建来源",
currentTarget: "当前目标",
incompatibleHint: "磁盘上的这个索引是为不同的 provider 或 model 构建的。运行“完全重建”即可替换。",
runIndexHint: "运行索引以启用 semantic_search。",
reIndex: "重建索引",
build: "构建",
rebuild: "完全重建",
stop: "停止",
provider: "提供方",
providerType: "服务类型",
openaiCompat: "OpenAI-Compatible",
apiUrl: "API URL",
apiKey: "API Key",
customRequestBody: "自定义请求体",
invalidCustomRequestBody: "自定义请求体必须是合法 JSON:{error}",
customRequestBodyMustBeObject: "自定义请求体必须是 JSON 对象。",
saveBeforeIndex: "请先保存语义设置,再启动索引。",
extraBody: "扩展请求体",
keepExistingKey: "留空则保留现有 Key",
remoteProvider: "远程向量服务",
remoteProviderDesc:
"在这里配置 OpenAI-Compatible embeddings 的完整 URL。Reasonix 会严格使用你提供的 URL 发起请求。",
ollama: "Ollama",
binary: "二进制",
found: "已找到",
@@ -441,11 +497,14 @@ export const zhCN = {
nothingSkipped: "无跳过 — 所有遍历的文件都将被索引。",
firstIncluded: "前 {count} 个包含的文件",
job: "任务",
phaseSetup: "初始化中",
phaseScan: "扫描文件",
phaseEmbed: "嵌入分块",
phaseWrite: "写入索引",
phaseDone: "完成",
phaseError: "错误",
phaseCancelled: "已停止",
setupFailed: "初始化失败",
stopping: "停止中",
scanned: "已扫描 {count}",
changed: "已变更 {count}",
@@ -473,7 +532,7 @@ export const zhCN = {
shellSubtitle: "模型想要运行一条 Shell 命令",
shellBgSubtitle: "长时间运行 — 批准后继续运行",
runOnce: "运行一次",
alwaysAllow: "始终允许 \"{prefix}\"",
alwaysAllow: '始终允许 "{prefix}"',
deny: "拒绝",
choiceTitle: "模型需要您选择",
typeOwn: "输入自定义回答",
@@ -507,5 +566,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 "";
}
+3 -32
View File
@@ -16,7 +16,6 @@ const TO_BACKEND = new Map(LANG_REGISTRY);
const FROM_BACKEND = new Map(LANG_REGISTRY.map(([d, b]) => [b, d]));
const STORAGE_KEY = "rx.lang";
const EXPLICIT_KEY = "rx.langExplicit";
const listeners: Listener[] = [];
let currentLang: DashboardLang = loadFromStorage() ?? "en";
@@ -30,22 +29,6 @@ function loadFromStorage(): DashboardLang | null {
return null;
}
function isExplicit(): boolean {
try {
return localStorage.getItem(EXPLICIT_KEY) === "1";
} catch {
return false;
}
}
function markExplicit(): void {
try {
localStorage.setItem(EXPLICIT_KEY, "1");
} catch {
/* ignore */
}
}
function toBackendLang(lang: DashboardLang): string {
return TO_BACKEND.get(lang) ?? lang;
}
@@ -54,23 +37,12 @@ function fromBackendLang(raw: string): DashboardLang {
return (FROM_BACKEND.get(raw) as DashboardLang) ?? "en";
}
/** Fetch lang from backend on startup. */
/** Adopt server lang on startup; localStorage is render-cache only, never pushed back. */
export async function initLangFromServer(): Promise<void> {
try {
const stored = loadFromStorage();
const res = await api<{ lang?: string }>("/settings");
const serverLang = res.lang ? fromBackendLang(res.lang) : null;
if (!serverLang || stored === serverLang) return;
if (isExplicit() && stored) {
// User explicitly chose a language — push to server.
api("/settings", { method: "POST", body: { lang: toBackendLang(stored) } })
.catch((err) => console.error("[reasonix dashboard] lang sync:", err));
return;
}
// No explicit choice yet (first visit or cleared storage) — adopt server value.
if (!serverLang || serverLang === currentLang) return;
currentLang = serverLang;
try {
localStorage.setItem(STORAGE_KEY, serverLang);
@@ -79,7 +51,7 @@ export async function initLangFromServer(): Promise<void> {
}
for (const cb of listeners) cb();
} catch {
/* offline — keep localStorage value */
/* offline — keep last-known value rendering */
}
}
@@ -90,7 +62,6 @@ export function getLang(): DashboardLang {
export function setLang(lang: DashboardLang): void {
if (!SUPPORTED.has(lang)) return;
currentLang = lang;
markExplicit();
try {
localStorage.setItem(STORAGE_KEY, lang);
} catch {
+48
View File
@@ -0,0 +1,48 @@
export type IntervalUnit = "s" | "m" | "h";
export interface LoopRunStatus {
prompt: string;
intervalMs: number;
iter: number;
/** Wall-clock ms until the next fire — server reports a remaining duration, not an absolute. */
nextFireMs: number;
}
/** Quick-pick intervals in ms — covers the 95% of cases users actually run. */
export const INTERVAL_PRESETS_MS: ReadonlyArray<{ ms: number; label: string }> = [
{ ms: 30_000, label: "30s" },
{ ms: 60_000, label: "1m" },
{ ms: 5 * 60_000, label: "5m" },
{ ms: 15 * 60_000, label: "15m" },
{ ms: 60 * 60_000, label: "1h" },
{ ms: 6 * 60 * 60_000, label: "6h" },
];
const UNIT_TO_MS: Record<IntervalUnit, number> = {
s: 1_000,
m: 60_000,
h: 60 * 60_000,
};
const MIN_INTERVAL_MS = 5_000;
const MAX_INTERVAL_MS = 6 * 60 * 60_000;
/** Convert a "30" + "s" pair to ms, returning null if out of [5s, 6h]. */
export function parseCustomInterval(value: string, unit: IntervalUnit): number | null {
const n = Number.parseFloat(value);
if (!Number.isFinite(n) || n <= 0) return null;
const ms = Math.round(n * UNIT_TO_MS[unit]);
if (ms < MIN_INTERVAL_MS || ms > MAX_INTERVAL_MS) return null;
return ms;
}
/** Human-friendly "5m 12s" / "12s" / "2h 45m" — shows two largest non-zero units. */
export function formatRemaining(ms: number): string {
const safe = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(safe / 3600);
const m = Math.floor((safe % 3600) / 60);
const s = safe % 60;
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
return `${s}s`;
}
+7 -1
View File
@@ -6,9 +6,11 @@ import {
ChoiceModal,
EditReviewModal,
type OnResolve,
PickerModal,
PlanModal,
RevisionModal,
ShellModal,
ViewerModal,
WorkspaceModal,
parseToolArgs,
} from "../components/chat-internals.js";
@@ -684,7 +686,11 @@ export function ChatPanel() {
? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
: modal.kind === "revision"
? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
: null
: modal.kind === "picker"
? html`<${PickerModal} modal=${modal} onResolve=${resolveModal} />`
: modal.kind === "viewer"
? html`<${ViewerModal} modal=${modal} onResolve=${resolveModal} />`
: null
: null
}
+24
View File
@@ -1,3 +1,4 @@
import { budgetTone, deriveBudgetState } from "../lib/budget.js";
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
import { html } from "../lib/html.js";
import { usePoll } from "../lib/use-poll.js";
@@ -65,6 +66,9 @@ interface OverviewData {
toolCount?: number;
cwd?: string;
cockpit?: CockpitData;
budgetUsd?: number | null;
/** Cumulative session spend in USD — set when a session is attached. */
sessionSpendUsd?: number | null;
}
function kpi(label: string, value: unknown, delta?: unknown, deltaTone?: "up" | "down" | "flat") {
@@ -107,6 +111,25 @@ function balanceKpi(c: CockpitData) {
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
}
function budgetKpi(o: OverviewData) {
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
if (state.kind === "off") return null;
const tone = budgetTone(state);
const valueColor =
tone === "err"
? "color:var(--c-err)"
: tone === "warn"
? "color:var(--c-warn)"
: "";
return html`
<div class="kpi cock-w-1">
<div class="label">${t("overview.budget")}</div>
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
</div>
`;
}
function tokens7dKpi(c: CockpitData) {
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
const d = deltaPctText(c.tokens7d.deltaPct);
@@ -285,6 +308,7 @@ export function OverviewPanel() {
${tokens7dKpi(c)}
${cacheHitKpi(c)}
${toolCallsKpi(c)}
${budgetKpi(o)}
${currentSessionBlock(c)}
${costTrendSpark(c)}
+1 -1
View File
@@ -29,7 +29,7 @@ interface PlansData {
function statusPill(p: ArchivedPlan) {
if (p.completionRatio >= 1) return html`<span class="pill ok">${t("plans.done")}</span>`;
if (p.completionRatio > 0) return html`<span class="pill info">${t("plans.active")}</span>`;
return html`<span class="pill">idle</span>`;
return html`<span class="pill">${t("plans.idle")}</span>`;
}
export function PlansPanel() {
+440 -121
View File
@@ -1,13 +1,50 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t, useLang } from "../i18n/index.js";
import { api } from "../lib/api.js";
import { fmtBytes, fmtNum, fmtRelativeTime } from "../lib/format.js";
import { html } from "../lib/html.js";
import { t, useLang } from "../i18n/index.js";
interface SemanticConfigView {
provider: "ollama" | "openai-compat";
ollama: {
baseUrl: string;
model: string;
};
openaiCompat: {
baseUrl: string;
apiKey: string;
apiKeySet: boolean;
model: string;
extraBody: Record<string, unknown>;
};
}
interface SemanticData {
attached?: boolean;
reason?: string;
root?: string;
provider?: "ollama" | "openai-compat";
providerConfig?: SemanticConfigView;
providerStatus?:
| {
kind: "ollama";
ready: boolean;
baseUrl: string;
binaryFound?: boolean;
daemonRunning?: boolean;
modelPulled?: boolean;
modelName?: string;
installedModels?: string[];
error?: string;
}
| {
kind: "openai-compat";
ready: boolean;
baseUrl: string;
apiKeySet: boolean;
model: string;
extraBodyKeys: string[];
};
index?: IndexInfo;
job?: SemanticJob | null;
pull?: { status: string; startedAt: number; lastLine?: string } | null;
@@ -23,17 +60,25 @@ interface SemanticData {
interface IndexInfo {
exists: boolean;
provider?: "ollama" | "openai-compat";
chunks?: number;
files?: number;
dim?: number;
sizeBytes?: number;
lastBuiltMs?: number;
model?: string;
builtWith?: { provider: "ollama" | "openai-compat"; model: string };
current?: { provider: "ollama" | "openai-compat"; model: string };
compatible?: boolean;
mismatch?: "provider" | "model" | null;
}
interface SemanticJob {
phase: string;
startedAt: number;
finishedAt?: number | null;
cancelledAt?: number | null;
lastPhase?: string | null;
chunksTotal?: number;
chunksDone?: number;
filesScanned?: number;
@@ -50,17 +95,44 @@ interface SemanticJob {
};
}
interface SemanticConfigDraft {
provider: "ollama" | "openai-compat";
ollama: {
baseUrl: string;
model: string;
};
openaiCompat: {
baseUrl: string;
apiKey: string;
model: string;
extraBodyText: string;
apiKeySet: boolean;
};
}
export interface SemanticDraftValidation {
extraBody: Record<string, unknown>;
error: string | null;
}
export function SemanticPanel() {
useLang();
const [data, setData] = useState<SemanticData | null>(null);
const [draft, setDraft] = useState<SemanticConfigDraft | null>(null);
const [draftDirty, setDraftDirty] = useState(false);
const draftDirtyRef = useRef(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const r = await api<SemanticData>("/semantic");
setData(r);
const [semantic, config] = await Promise.all([
api<SemanticData>("/semantic"),
api<SemanticConfigView>("/semantic/config"),
]);
setData(semantic);
setDraft((current) => (current && draftDirtyRef.current ? current : toConfigDraft(config)));
} catch (err) {
setError((err as Error).message);
}
@@ -69,7 +141,7 @@ export function SemanticPanel() {
useEffect(() => {
load();
const phase = data?.job?.phase;
const running = phase === "scan" || phase === "embed" || phase === "write";
const running = isActiveSemanticPhase(phase);
const pulling = data?.pull?.status === "pulling";
const ms = running || pulling ? 1200 : 5000;
const id = setInterval(load, ms);
@@ -78,10 +150,18 @@ export function SemanticPanel() {
const start = useCallback(
async (rebuild: boolean) => {
if (!draft) return;
setBusy(true);
setError(null);
setInfo(null);
try {
const validation = validateSemanticDraft(draft);
if (draftDirty) {
throw new Error(t("semantic.saveBeforeIndex"));
}
if (validation.error) {
throw new Error(validation.error);
}
await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
setInfo(rebuild ? t("semantic.rebuildStarted") : t("semantic.incrementalStarted"));
await load();
@@ -91,7 +171,7 @@ export function SemanticPanel() {
setBusy(false);
}
},
[load],
[draft, draftDirty, load],
);
const stop = useCallback(async () => {
@@ -113,10 +193,11 @@ export function SemanticPanel() {
setError(null);
setInfo(t("semantic.startingDaemon"));
try {
const r = await api<{ ready: boolean }>("/semantic/ollama/start", { method: "POST", body: {} });
setInfo(
r.ready ? t("semantic.daemonUp") : t("semantic.daemonTimeout"),
);
const r = await api<{ ready: boolean }>("/semantic/ollama/start", {
method: "POST",
body: {},
});
setInfo(r.ready ? t("semantic.daemonUp") : t("semantic.daemonTimeout"));
await load();
} catch (err) {
setError((err as Error).message);
@@ -142,10 +223,45 @@ export function SemanticPanel() {
[load],
);
if (!data && !error)
const saveProviderConfig = useCallback(async () => {
if (!draft) return;
setBusy(true);
setError(null);
setInfo(null);
try {
const extraBody = semanticValidation.extraBody;
await api("/semantic/config", {
method: "POST",
body: {
provider: draft.provider,
ollama: {
baseUrl: draft.ollama.baseUrl,
model: draft.ollama.model,
},
openaiCompat: {
baseUrl: draft.openaiCompat.baseUrl,
apiKey: draft.openaiCompat.apiKey,
model: draft.openaiCompat.model,
extraBody,
},
},
});
setDraftDirty(false);
draftDirtyRef.current = false;
setInfo(t("semantic.savedConfig", { count: 1 }));
await load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}, [draft, load]);
if (!data && !error) {
return html`<div class="card" style="color:var(--fg-3)">${t("common.loading")}</div>`;
}
if (error && !data) return html`<div class="card accent-err">${error}</div>`;
if (!data) return null;
if (!data || !draft) return null;
if (!data.attached) {
return html`
@@ -158,29 +274,36 @@ export function SemanticPanel() {
const job = data.job;
const phase = job?.phase;
const running = phase === "scan" || phase === "embed" || phase === "write";
const running = isActiveSemanticPhase(phase);
const pull = data.pull;
const pulling = pull?.status === "pulling";
const o = data.ollama ?? {};
const binaryFound = o.binaryFound === true;
const daemonRunning = o.daemonRunning === true;
const modelPulled = o.modelPulled === true;
const modelName = o.modelName ?? "nomic-embed-text";
const installedModels = o.installedModels ?? [];
const ready = binaryFound && daemonRunning && modelPulled;
const provider = data.providerStatus?.kind ?? draft.provider;
const ready = data.providerStatus?.ready === true;
const isOllama = provider === "ollama";
const ollama = data.providerStatus?.kind === "ollama" ? data.providerStatus : null;
const remote = data.providerStatus?.kind === "openai-compat" ? data.providerStatus : null;
const binaryFound = ollama?.binaryFound === true;
const daemonRunning = ollama?.daemonRunning === true;
const modelPulled = ollama?.modelPulled === true;
const modelName = isOllama
? (ollama?.modelName ?? draft.ollama.model ?? "nomic-embed-text")
: draft.openaiCompat.model;
const sectionH3 = (text: string) => html`
<h3 style="margin:18px 0 8px;font-family:var(--font-mono);font-size:11px;color:var(--fg-3);text-transform:uppercase;letter-spacing:.1em">${text}</h3>
`;
const idx = data.index;
const indexReady = idx?.exists === true && idx.compatible !== false;
const indexMismatch = idx?.exists === true && idx.compatible === false;
const semanticValidation = validateSemanticDraft(draft);
const semanticDraftBlocked = draftDirty || semanticValidation.error !== null;
return html`
<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 static ${idx?.exists ? "active" : ""}`}>
${idx?.exists ? t("semantic.indexBuilt") : t("semantic.noIndex")}
<span class=${`chip-f static ${indexReady ? "active" : ""}`}>
${indexReady ? t("semantic.indexBuilt") : t("semantic.noIndex")}
</span>
${
ready
@@ -188,13 +311,148 @@ export function SemanticPanel() {
: 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}
${error ? html`<div class="card accent-err">${error}</div>` : null}
${idx?.exists ? html`<${SemanticSearchSection} />` : null}
<div class="card">
<div class="card-h"><span class="title">${t("semantic.provider")}</span></div>
<div class="form-row">
<span class="lbl">${t("semantic.providerType")}</span>
<select
class="input mono"
value=${draft.provider}
onInput=${(e: Event) => {
draftDirtyRef.current = true;
setDraftDirty(true);
setDraft({
...draft,
provider: (e.target as HTMLSelectElement).value as "ollama" | "openai-compat",
});
}}
>
<option value="ollama">Ollama</option>
<option value="openai-compat">OpenAI-Compatible</option>
</select>
</div>
${
draft.provider === "ollama"
? html`
<div class="form-row">
<span class="lbl">${t("semantic.model")}</span>
<input
class="input mono"
type="text"
value=${draft.ollama.model}
onInput=${(e: Event) => {
draftDirtyRef.current = true;
setDraftDirty(true);
setDraft({
...draft,
ollama: { ...draft.ollama, model: (e.target as HTMLInputElement).value },
});
}}
/>
</div>
`
: html`
<div class="form-row">
<span class="lbl">${t("semantic.apiUrl")}</span>
<input
class="input mono"
type="text"
placeholder="https://api.openai.com/v1/embeddings"
value=${draft.openaiCompat.baseUrl}
onInput=${(e: Event) => {
draftDirtyRef.current = true;
setDraftDirty(true);
setDraft({
...draft,
openaiCompat: {
...draft.openaiCompat,
baseUrl: (e.target as HTMLInputElement).value,
},
});
}}
/>
</div>
<div class="form-row">
<span class="lbl">${t("semantic.apiKey")}</span>
<input
class="input mono"
type="password"
placeholder=${draft.openaiCompat.apiKeySet ? t("semantic.keepExistingKey") : "sk-..."}
value=${draft.openaiCompat.apiKey}
onInput=${(e: Event) => {
draftDirtyRef.current = true;
setDraftDirty(true);
setDraft({
...draft,
openaiCompat: {
...draft.openaiCompat,
apiKey: (e.target as HTMLInputElement).value,
},
});
}}
/>
<div style="color:var(--fg-3);font-size:12px">${t("semantic.apiKeyStoredNote")}</div>
</div>
<div class="form-row">
<span class="lbl">${t("semantic.model")}</span>
<input
class="input mono"
type="text"
value=${draft.openaiCompat.model}
onInput=${(e: Event) => {
draftDirtyRef.current = true;
setDraftDirty(true);
setDraft({
...draft,
openaiCompat: {
...draft.openaiCompat,
model: (e.target as HTMLInputElement).value,
},
});
}}
/>
</div>
<details style="margin-top:10px">
<summary style="cursor:pointer;color:var(--fg-2);font-size:12px">${t("semantic.customRequestBody")}</summary>
<div class="form-row" style="margin-top:10px">
<span class="lbl">${t("semantic.customRequestBody")}</span>
<textarea
class="input mono"
rows="6"
value=${draft.openaiCompat.extraBodyText}
onInput=${(e: Event) => {
draftDirtyRef.current = true;
setDraftDirty(true);
setDraft({
...draft,
openaiCompat: {
...draft.openaiCompat,
extraBodyText: (e.target as HTMLTextAreaElement).value,
},
});
}}
></textarea>
</div>
</details>
${
semanticValidation.error
? html`<div style="color:var(--c-err);font-size:12px;margin-top:-2px">${semanticValidation.error}</div>`
: null
}
`
}
<div style="display:flex;gap:6px;margin-top:10px">
<button class="btn primary" disabled=${busy || semanticValidation.error !== null} onClick=${saveProviderConfig}>${t("common.save")}</button>
</div>
</div>
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
${indexReady ? html`<${SemanticSearchSection} />` : null}
${
!binaryFound
isOllama && !binaryFound
? html`
<div class="card">
<div class="card-h"><span class="title">${t("semantic.installOllama")}</span></div>
@@ -211,7 +469,7 @@ export function SemanticPanel() {
: null
}
${
binaryFound && !daemonRunning
isOllama && binaryFound && !daemonRunning
? html`
<div class="card">
<div class="card-h"><span class="title">${t("semantic.daemon")}</span></div>
@@ -227,7 +485,7 @@ export function SemanticPanel() {
: null
}
${
daemonRunning && !modelPulled
isOllama && daemonRunning && !modelPulled
? html`
<div class="card">
<div class="card-h"><span class="title">${t("semantic.model")}</span></div>
@@ -254,6 +512,18 @@ export function SemanticPanel() {
`
: null
}
${
!isOllama
? html`
<div class="card">
<div class="card-h"><span class="title">${t("semantic.remoteProvider")}</span></div>
<div class="card-b" style="font-size:13px;color:var(--fg-2)">
${t("semantic.remoteProviderDesc")}
</div>
</div>
`
: null
}
${
job
@@ -272,7 +542,9 @@ export function SemanticPanel() {
<span class="meta">
${
idx?.exists
? html`<span class="pill ok">${t("semantic.builtStatus")}</span>`
? idx.compatible === false
? html`<span class="pill warn">${t("semantic.incompatibleStatus")}</span>`
: html`<span class="pill ok">${t("semantic.builtStatus")}</span>`
: html`<span class="pill">${t("system.none")}</span>`
}
</span>
@@ -280,44 +552,56 @@ export function SemanticPanel() {
${
idx?.exists
? html`
<div class="rail-kv"><span class="k">${t("semantic.provider")}</span><span class="v">${idx.builtWith?.provider ?? idx.provider ?? provider}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.chunks")}</span><span class="v">${fmtNum(idx.chunks)}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.files")}</span><span class="v">${fmtNum(idx.files)}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${idx.model ?? modelName}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${idx.builtWith?.model ?? idx.model ?? modelName}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.dim")}</span><span class="v">${fmtNum(idx.dim)}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.size")}</span><span class="v">${fmtBytes(idx.sizeBytes)}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.lastBuild")}</span><span class="v">${fmtRelativeTime(idx.lastBuiltMs ?? null)}</span></div>
${
idx.compatible === false
? html`
<div class="rail-kv"><span class="k">${t("semantic.builtWith")}</span><span class="v" style="font-size:11px">${idx.builtWith?.provider} · ${idx.builtWith?.model}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.currentTarget")}</span><span class="v" style="font-size:11px">${idx.current?.provider} · ${idx.current?.model}</span></div>
<div style="color:var(--c-warn);font-size:12px;padding-top:8px">${t("semantic.incompatibleHint")}</div>
`
: null
}
`
: html`
<div style="color:var(--fg-3);font-size:12.5px;padding:6px 0">
${t("semantic.runIndexHint")}
</div>
`
: html`<div style="color:var(--fg-3);font-size:12.5px;padding:6px 0">${t("semantic.runIndexHint")}</div>`
}
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap">
<button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>${idx?.exists ? t("semantic.reIndex") : t("semantic.build")}</button>
<button class="primary" disabled=${busy || running || !ready || semanticDraftBlocked} onClick=${() => start(false)}>${indexReady ? t("semantic.reIndex") : t("semantic.build")}</button>
${
idx?.exists
? html`<button disabled=${busy || running || !ready} onClick=${() => start(true)}>${t("semantic.rebuild")}</button>`
? html`<button disabled=${busy || running || !ready || semanticDraftBlocked} onClick=${() => start(true)}>${t("semantic.rebuild")}</button>`
: null
}
${
running
? html`<button onClick=${stop} style="border-color:var(--c-err);color:var(--c-err)">${t("semantic.stop")}</button>`
: null
}
${running ? html`<button onClick=${stop} style="border-color:var(--c-err);color:var(--c-err)">${t("semantic.stop")}</button>` : null}
</div>
</div>
<div class="card">
<div class="card-h"><span class="title">${t("semantic.ollama")}</span></div>
<div class="rail-kv">
<span class="k">${t("semantic.binary")}</span>
<span class="v">${binaryFound ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill err">${t("semantic.missing")}</span>`}</span>
</div>
<div class="rail-kv">
<span class="k">${t("semantic.daemonStatus")}</span>
<span class="v">${daemonRunning ? html`<span class="pill ok">${t("semantic.up")}</span>` : html`<span class="pill warn">${t("semantic.down")}</span>`}</span>
</div>
<div class="rail-kv">
<span class="k">${t("semantic.model")}</span>
<span class="v">${modelPulled ? html`<span class="pill ok">${t("semantic.pulled")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span>
</div>
<div class="card-h"><span class="title">${isOllama ? t("semantic.ollama") : t("semantic.openaiCompat")}</span></div>
${
isOllama
? html`
<div class="rail-kv"><span class="k">${t("semantic.binary")}</span><span class="v">${binaryFound ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill err">${t("semantic.missing")}</span>`}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.daemonStatus")}</span><span class="v">${daemonRunning ? html`<span class="pill ok">${t("semantic.up")}</span>` : html`<span class="pill warn">${t("semantic.down")}</span>`}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v">${modelPulled ? html`<span class="pill ok">${t("semantic.pulled")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span></div>
`
: html`
<div class="rail-kv"><span class="k">${t("semantic.apiUrl")}</span><span class="v" style="font-size:11px;max-width:160px;overflow-wrap:anywhere;word-break:break-word;text-align:right">${remote?.baseUrl ?? draft.openaiCompat.baseUrl}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.apiKey")}</span><span class="v">${remote?.apiKeySet ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${remote?.model ?? draft.openaiCompat.model}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.extraBody")}</span><span class="v">${fmtNum(remote?.extraBodyKeys.length ?? 0)}</span></div>
`
}
</div>
<${SemanticExcludesCard} />
@@ -326,6 +610,46 @@ export function SemanticPanel() {
`;
}
function toConfigDraft(config: SemanticConfigView): SemanticConfigDraft {
return {
provider: config.provider,
ollama: {
baseUrl: config.ollama.baseUrl,
model: config.ollama.model,
},
openaiCompat: {
baseUrl: config.openaiCompat.baseUrl,
apiKey: "",
model: config.openaiCompat.model,
extraBodyText: JSON.stringify(config.openaiCompat.extraBody ?? {}, null, 2),
apiKeySet: config.openaiCompat.apiKeySet,
},
};
}
export function validateSemanticDraft(draft: SemanticConfigDraft): SemanticDraftValidation {
if (draft.provider !== "openai-compat") {
return { extraBody: {}, error: null };
}
const raw = draft.openaiCompat.extraBodyText.trim();
if (!raw) {
return { extraBody: {}, error: null };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
return {
extraBody: {},
error: t("semantic.invalidCustomRequestBody", { error: (err as Error).message }),
};
}
if (!isPlainObject(parsed)) {
return { extraBody: {}, error: t("semantic.customRequestBodyMustBeObject") };
}
return { extraBody: parsed, error: null };
}
interface IndexConfig {
excludeDirs?: string[];
excludeFiles?: string[];
@@ -367,6 +691,7 @@ interface SearchHit {
interface SearchResponse {
hits: SearchHit[];
elapsedMs: number;
provider?: string;
model: string;
}
@@ -422,13 +747,15 @@ function SemanticSearchSection() {
hits || busy || error
? html`
<div style="font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin:8px 0 6px;display:flex;align-items:center;gap:8px">
${busy
? html`<span>${t("semantic.searching")}</span>`
: error
? html`<span style="color:var(--c-err)">${error}</span>`
: hits
? html`<span>${t("semantic.results", { count: hits.length, s: hits.length === 1 ? "" : "s", ms: meta?.elapsedMs ?? 0, model: meta?.model ?? "" })}</span>`
: null}
${
busy
? html`<span>${t("semantic.searching")}</span>`
: error
? html`<span style="color:var(--c-err)">${error}</span>`
: hits
? html`<span>${t("semantic.results", { count: hits.length, s: hits.length === 1 ? "" : "s", ms: meta?.elapsedMs ?? 0, model: meta?.model ?? "" })}</span>`
: null
}
</div>
${
hits && hits.length > 0
@@ -449,8 +776,8 @@ function SemanticSearchSection() {
</div>
`
: hits && hits.length === 0 && !busy
? html`<div class="card" style="color:var(--fg-3);font-size:12px">${t("semantic.noMatches")}</div>`
: null
? html`<div class="card" style="color:var(--fg-3);font-size:12px">${t("semantic.noMatches")}</div>`
: null
}
`
: null
@@ -570,11 +897,7 @@ function SemanticExcludesCard() {
<div class="card-h">
<span class="title">${t("semantic.indexConfig")}</span>
<span class="meta">
<a
class="mono"
style="color:var(--c-brand);text-decoration:none;font-size:11px;cursor:pointer"
onClick=${reset}
>${t("semantic.reset")}</a>
<a class="mono" style="color:var(--c-brand);text-decoration:none;font-size:11px;cursor:pointer" onClick=${reset}>${t("semantic.reset")}</a>
</span>
</div>
${info ? html`<div style="margin-bottom:8px"><span class="pill ok">${info}</span></div>` : null}
@@ -606,11 +929,7 @@ function SemanticExcludesCard() {
placeholder="**/*.test.ts"
/>
<div
class="checkbox-row"
style="margin-top:8px;cursor:pointer"
onClick=${() => setDraft({ ...draft, respectGitignore: !draft.respectGitignore })}
>
<div class="checkbox-row" style="margin-top:8px;cursor:pointer" onClick=${() => setDraft({ ...draft, respectGitignore: !draft.respectGitignore })}>
<span class=${`box ${draft.respectGitignore ? "on" : ""}`}>${draft.respectGitignore ? "✓" : ""}</span>
<span>${t("semantic.respectGitignore")}</span>
</div>
@@ -623,17 +942,14 @@ function SemanticExcludesCard() {
min="1024"
step="1024"
value=${draft.maxFileBytes}
onInput=${(e: Event) =>
setDraft({ ...draft, maxFileBytes: Number((e.target as HTMLInputElement).value) || 0 })}
onInput=${(e: Event) => setDraft({ ...draft, maxFileBytes: Number((e.target as HTMLInputElement).value) || 0 })}
style="font-size:12px"
/>
<span class="help">${t("semantic.skipLarger", { size: (draft.maxFileBytes / 1024 / 1024).toFixed(1) })}</span>
</div>
<div style="display:flex;gap:6px;margin-top:10px">
<button class="btn ghost" style="flex:1" disabled=${busy} onClick=${runPreview}>
<span class="g">⊕</span><span>${t("semantic.preview")}</span>
</button>
<button class="btn ghost" style="flex:1" disabled=${busy} onClick=${runPreview}><span class="g">⊕</span><span>${t("semantic.preview")}</span></button>
<button class="btn primary" style="flex:1" disabled=${busy} onClick=${save}>${t("common.save")}</button>
</div>
@@ -659,26 +975,24 @@ function ExcludesPreview({ preview }: { preview: PreviewData }) {
].filter((k) => (buckets[k] || 0) > 0);
return html`
<div class="excludes-preview">
<div class="summary">
${t("semantic.previewSummary", { included: preview.filesIncluded, skipped: totalSkipped })}
</div>
<div class="summary">${t("semantic.previewSummary", { included: preview.filesIncluded, skipped: totalSkipped })}</div>
${
reasons.length === 0
? html`<div style="color:var(--fg-3)">${t("semantic.nothingSkipped")}</div>`
: reasons.map(
(r) => html`
<details>
<summary><strong>${r}: ${buckets[r]}</strong></summary>
<ul>
${(samples[r] || []).map((p) => html`<li><code>${p}</code></li>`)}
${
(buckets[r] || 0) > (samples[r] || []).length
? html`<li style="color:var(--fg-3)">…${(buckets[r] || 0) - (samples[r] || []).length} more</li>`
: null
}
</ul>
</details>
`,
<details>
<summary><strong>${r}: ${buckets[r]}</strong></summary>
<ul>
${(samples[r] || []).map((p) => html`<li><code>${p}</code></li>`)}
${
(buckets[r] || 0) > (samples[r] || []).length
? html`<li style="color:var(--fg-3)">…${(buckets[r] || 0) - (samples[r] || []).length} more</li>`
: null
}
</ul>
</details>
`,
)
}
${
@@ -723,10 +1037,7 @@ function ChipFormRow({
};
return html`
<div class="form-row">
<span class="lbl">
${label}
${sub ? html`<span style="color:var(--fg-3);font-weight:400;text-transform:none;letter-spacing:0"> · ${sub}</span>` : null}
</span>
<span class="lbl">${label}${sub ? html`<span style="color:var(--fg-3);font-weight:400;text-transform:none;letter-spacing:0"> · ${sub}</span>` : null}</span>
<div style="display:flex;flex-wrap:wrap;gap:4px">
${value.map(
(e) => html`
@@ -758,30 +1069,38 @@ function ChipFormRow({
function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean }) {
useLang();
const phaseLabel =
({
scan: t("semantic.phaseScan"),
embed: t("semantic.phaseEmbed"),
write: t("semantic.phaseWrite"),
done: t("semantic.phaseDone"),
error: t("semantic.phaseError"),
} as Record<string, string>)[job.phase] ?? job.phase;
(
{
setup: t("semantic.phaseSetup"),
scan: t("semantic.phaseScan"),
embed: t("semantic.phaseEmbed"),
write: t("semantic.phaseWrite"),
done: t("semantic.phaseDone"),
error: t("semantic.phaseError"),
cancelled: t("semantic.phaseCancelled"),
} as Record<string, string>
)[job.phase] ?? job.phase;
const total = job.chunksTotal ?? 0;
const doneN = job.chunksDone ?? 0;
const ratio = total > 0 ? Math.min(1, doneN / total) : 0;
const elapsed = ((Date.now() - job.startedAt) / 1000).toFixed(1);
const elapsedBase = job.finishedAt ?? Date.now();
const elapsedSeconds = (elapsedBase - job.startedAt) / 1000;
const elapsed = elapsedSeconds < 0.1 ? "<0.1s" : `${elapsedSeconds.toFixed(1)}s`;
const phaseSummary =
job.phase === "error" && job.lastPhase === "setup"
? t("semantic.setupFailed")
: phaseLabel;
return html`
<div class="kv">
<div><span class="kv-key">phase</span>
<span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
${job.aborted ? html`<span class="pill warn" style="margin-left: 6px;">${t("semantic.stopping")}</span>` : null}
<span style="color:var(--fg-3);margin-left:8px">${elapsed}s</span>
<span class=${`pill ${job.phase === "error" ? "pill-err" : job.phase === "cancelled" ? "warn" : running ? "pill-active" : "pill-dim"}`}>${phaseSummary}</span>
${job.aborted && running ? html`<span class="pill warn" style="margin-left: 6px;">${t("semantic.stopping")}</span>` : null}
<span style="color:var(--fg-3);margin-left:8px">${elapsed}</span>
</div>
${
job.filesScanned !== null && job.filesScanned !== undefined
? html`<div><span class="kv-key">${t("semantic.files")}</span>${t("semantic.scanned", { count: job.filesScanned })}${
job.filesChanged != null ? ` · ${t("semantic.changed", { count: job.filesChanged })}` : ""
}${job.filesSkipped ? ` · ${t("semantic.skipped", { count: job.filesSkipped })}` : ""}</div>`
? html`<div><span class="kv-key">${t("semantic.files")}</span>${t("semantic.scanned", { count: job.filesScanned })}${job.filesChanged != null ? ` · ${t("semantic.changed", { count: job.filesChanged })}` : ""}${job.filesSkipped ? ` · ${t("semantic.skipped", { count: job.filesSkipped })}` : ""}</div>`
: null
}
${
@@ -796,23 +1115,13 @@ function SemanticJobView({ job, running }: { job: SemanticJob; running: boolean
`
: null
}
${
job.error
? html`<div><span class="kv-key">${t("semantic.phaseError")}</span><span class="err">${job.error}</span></div>`
: null
}
${job.error ? html`<div><span class="kv-key">${t("semantic.phaseError")}</span><span class="err">${job.error}</span></div>` : null}
${
job.result
? html`<div><span class="kv-key">${t("semantic.result")}</span>${t("semantic.added", { count: job.result.chunksAdded })} · ${t("semantic.removed", { count: job.result.chunksRemoved })}${
job.result.chunksSkipped ? ` · ${t("semantic.failed", { count: job.result.chunksSkipped })}` : ""
} · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
: null
}
${
job.result?.skipBuckets
? html`<${SkipBucketsView} buckets=${job.result.skipBuckets} />`
? html`<div><span class="kv-key">${t("semantic.result")}</span>${t("semantic.added", { count: job.result.chunksAdded })} · ${t("semantic.removed", { count: job.result.chunksRemoved })}${job.result.chunksSkipped ? ` · ${t("semantic.failed", { count: job.result.chunksSkipped })}` : ""} · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
: null
}
${job.result?.skipBuckets ? html`<${SkipBucketsView} buckets=${job.result.skipBuckets} />` : null}
</div>
`;
}
@@ -836,3 +1145,13 @@ function SkipBucketsView({ buckets }: { buckets: Record<string, number> }) {
.map(([k, label]) => `${label}: ${buckets[k]}`);
return html`<div><span class="kv-key">${t("semantic.skipped")}</span>${t("semantic.skippedFiles", { total, details: parts.join(", ") })}</div>`;
}
function isActiveSemanticPhase(phase: string | undefined): boolean {
return phase === "setup" || phase === "scan" || phase === "embed" || phase === "write";
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
+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"),
+17 -41
View File
@@ -66,27 +66,7 @@ queries (`job_output`, `list_jobs`). Mutating / side-effecting tools
stay default. MCP-bridged tools default `false` — third-party tools
opt in only when the server explicitly declares parallel safety.
### Pillar 2 — R1 Thought Harvesting *(opt-in)*
**Problem.** R1 / V4-thinking emits extensive `reasoning_content`. DeepSeek's
own docs recommend *not* feeding it back to the next turn. Most frameworks
display it to the user and discard. The planning signal inside is lost.
**Solution.** A two-stage process:
```
R1 output → Harvester (v4-flash, thinking off) → TypedPlanState
├─ subgoals: string[]
├─ hypotheses: string[]
├─ uncertainties: string[]
└─ rejectedPaths: string[]
```
The harvester is a cheap flash call with a strict JSON schema. Output is
validated at runtime. **Not enabled in any preset** (see Pillar 4) — the
additional round-trip rarely pays back, so it's `/harvest on` opt-in.
### Pillar 3 — Tool-Call Repair
### Pillar 2 — Tool-Call Repair
**Problem.** Empirical DeepSeek failure modes:
- Tool-call JSON emitted inside `<think>`, missing from the final message.
@@ -106,7 +86,7 @@ additional round-trip rarely pays back, so it's `/harvest on` opt-in.
4. **`storm`** — identical `(tool, args)` tuple within a sliding window →
suppress the call, inject a reflection turn.
### Pillar 4 — Cost Control *(v0.6)*
### Pillar 3 — Cost Control *(v0.6)*
**Problem.** Coding agents that default to the frontier model (v4-pro, ~12×
flash cost) and accumulate full tool results in context are $150-$250/month
@@ -118,17 +98,13 @@ tuning in the common case:
#### 4.1 Tiered defaults (flash-first)
The three presets now trade only **model tier** and **reasoning effort**:
The three presets trade **model tier** and **reasoning effort**:
| Preset | Model | Effort | Harvest | Branch | Cost |
|---|---|---|---|---|---:|
| `fast` | `v4-flash` | `high` | off | 1 | 1× |
| `smart` (default) | `v4-flash` | `max` | off | 1 | ~1.5× |
| `max` | `v4-pro` | `max` | off | 1 | ~12× |
**Branching and harvest are never in a preset.** Both multiply cost and
rarely pay back; `/branch N` and `/harvest on` are pure opt-ins. A user can
run for weeks without ever touching them.
| Preset | Model | Effort | Cost |
|---|---|---|---:|
| `flash` | `v4-flash` | `max` | 1× |
| `auto` (default) | `v4-flash` → `v4-pro` on hard turns | `max` | 1–3× |
| `pro` | `v4-pro` | `max` | ~12× |
All auxiliary calls — `forceSummaryAfterIterLimit`, subagent spawns,
truncation repair retries — hard-code `v4-flash + effort=high` regardless
@@ -176,9 +152,8 @@ Per-turn and session cost are colored in the StatsPanel:
```
src/
├── client.ts # DeepSeek client (fetch + SSE)
├── loop.ts # Pillar 1 + 4 — CacheFirstLoop
├── harvest.ts # Pillar 2 — flash-backed plan-state extraction
├── repair/ # Pillar 3 pipeline
├── loop.ts # Pillar 1 + 3 — CacheFirstLoop
├── repair/ # Pillar 2 pipeline
│ ├── index.ts
│ ├── scavenge.ts
│ ├── flatten.ts
@@ -243,23 +218,24 @@ means editing one handler file and one registry line.
## Design evolution
- **v0.0.x** — Pillar 1 end-to-end, Pillar 3 complete, Ink TUI scaffold.
- **v0.0.x** — Pillar 1 end-to-end, repair pipeline complete, Ink TUI scaffold.
- **v0.1** — τ-bench numbers published, streaming polish, transcript replay.
- **v0.2** — Self-consistency / branch-budget sampling driven by plan state.
- **v0.3** — MCP client (stdio + SSE), session persistence.
- **v0.4.x** — `reasonix code` with SEARCH/REPLACE edits, review/auto
gate, background jobs, hooks.
- **v0.5.x** — V4 model support, skills, memory, subagents, actionable
error messages.
- **v0.6** *(current)* —
- **Pillar 4 cost control** (flash-first defaults, auto-compaction,
`/pro` one-shot, failure-triggered escalation, cost badges).
- **v0.6** —
- **Cost control** (flash-first defaults, auto-compaction, `/pro` one-shot,
failure-triggered escalation, cost badges).
- `deepseek-chat` / `deepseek-reasoner` scheduled for deprecation —
all user-facing surfaces updated to `v4-flash` / `v4-pro`.
- `branch` + `harvest` removed from every preset; manual opt-in only.
- Shared prompt fragments (`TUI_FORMATTING_RULES`, `NEGATIVE_CLAIM_RULE`).
- UI refactor: App.tsx split into 6 hooks/components, slash.ts split
into 13 per-topic modules.
- **v0.31** *(current)* — `branch` + `harvest` features removed entirely
(the parallel-sample selector and Pillar 2 plan-state extractor); both
rarely paid for themselves and bloated the slash surface.
## Explicit non-goals
+1 -1
View File
@@ -403,7 +403,7 @@ npx reasonix diff \
</div>
</section>
<section class="cli">
<section id="cli" class="cli">
<div class="container">
<h2 class="section-title" data-i18n="cli.title">CLI at a glance</h2>
<pre class="code"><code>npx reasonix code [path] <span class="hash"># <span data-i18n="cli.code">coding mode scoped to path</span></span>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.30.5",
"version": "0.34.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
+153
View File
@@ -0,0 +1,153 @@
/** @url mentions — async sibling of @path. Fetches each URL once and inlines under "Referenced URLs". */
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
/** Default cap on inlined URL body (chars). */
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
export interface AtUrlExpansion {
/** The raw `@url` token as it appeared in the text. */
token: string;
/** Absolute URL (after trailing-punctuation strip). */
url: string;
/** True if content was inlined. False = skipped (reason in `skip`). */
ok: boolean;
/** Page title when extractable from `<title>`. */
title?: string;
/** Char count of the (post-truncation) inlined body. */
chars?: number;
/** True iff the original page exceeded `maxChars` and was clipped. */
truncated?: boolean;
/** Why the mention was skipped — set when ok=false. */
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
/** Free-form error message attached to skip outcomes. */
error?: string;
}
export interface AtUrlOptions {
/** Max chars of inlined body per URL. */
maxChars?: number;
/** Per-URL fetch timeout in ms. */
timeoutMs?: number;
fetcher?: (
url: string,
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
cache?: Map<string, AtUrlExpansion & { body?: string }>;
/** Forward Esc/abort to the fetcher. */
signal?: AbortSignal;
}
export async function expandAtUrls(
text: string,
opts: AtUrlOptions = {},
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
const fetcher = opts.fetcher;
if (!fetcher) {
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
}
const seen = new Map<string, AtUrlExpansion>();
const bodies = new Map<string, string>();
const order: string[] = [];
for (const match of text.matchAll(AT_URL_PATTERN)) {
const rawUrl = match[1] ?? "";
const url = stripUrlTail(rawUrl);
if (!url) continue;
if (seen.has(url)) continue;
const cached = opts.cache?.get(url);
if (cached) {
seen.set(url, cached);
if (cached.body) bodies.set(url, cached.body);
order.push(url);
continue;
}
let expansion: AtUrlExpansion;
let body = "";
try {
const page = await fetcher(url, {
maxChars,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
});
body = page.text;
expansion = {
token: `@${url}`,
url,
ok: true,
title: page.title,
chars: body.length,
truncated: page.truncated,
};
} catch (err) {
const message = (err as Error).message ?? String(err);
let skip: AtUrlExpansion["skip"] = "fetch-error";
if (/aborted|timeout/i.test(message)) skip = "timeout";
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
expansion = {
token: `@${url}`,
url,
ok: false,
skip,
error: message,
};
}
seen.set(url, expansion);
if (body) bodies.set(url, body);
if (opts.cache) opts.cache.set(url, { ...expansion, body });
order.push(url);
}
if (seen.size === 0) return { text, expansions: [] };
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
const blocks: string[] = [];
for (const ex of expansions) {
if (ex.ok) {
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
const truncTag = ex.truncated ? ' truncated="true"' : "";
const body = bodies.get(ex.url) ?? "";
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
} else {
const reasonAttr = ex.skip ?? "fetch-error";
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
}
}
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
return { text: augmented, expansions };
}
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
export function stripUrlTail(raw: string): string {
let s = raw;
while (s.length > 0) {
const last = s[s.length - 1]!;
if (".,;:!?".includes(last)) {
s = s.slice(0, -1);
continue;
}
if (")]}>".includes(last)) {
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
last as ")" | "]" | "}" | ">"
];
if (!s.includes(open)) {
s = s.slice(0, -1);
continue;
}
}
break;
}
return s;
}
function escapeAttr(s: string): string {
return s
.replace(/"/g, "&quot;")
.replace(/[\r\n]+/g, " ")
.trim();
}
+319 -208
View File
@@ -13,6 +13,9 @@ import {
/** Caps match tool-result dispatch truncation (0.5.2). */
export const DEFAULT_AT_MENTION_MAX_BYTES = 64 * 1024;
/** Cap on entries returned for a `@<dir>` listing. ~200 paths × ~50 chars ≈ 10 KB — fits inside DEFAULT_AT_MENTION_MAX_BYTES with room for the rest of the prompt. */
export const DEFAULT_AT_DIR_MAX_ENTRIES = 200;
/** Universally-uninteresting build / VCS dirs. Framework-specific dirs (Pods, target, …) live in .gitignore. */
export const DEFAULT_PICKER_IGNORE_DIRS: readonly string[] = [
"node_modules",
@@ -91,6 +94,19 @@ export function listFilesWithStatsSync(root: string, opts: ListFilesOptions = {}
/* stat failed (permission / EAGAIN) — keep the entry with mtime=0 */
}
out.push({ path: relPath, mtimeMs });
} else if (ent.isSymbolicLink()) {
// Dirent.isFile() returns false for symlinks even when they point at
// regular files — stat the target to recover them. Symlinks-to-dirs
// are not followed (cycle risk).
let target: ReturnType<typeof statSync> | null = null;
try {
target = statSync(absPath);
} catch {
continue;
}
if (!target.isFile()) continue;
if (ignoredByLayers(effectiveLayers, absPath, false)) continue;
out.push({ path: relPath, mtimeMs: target.mtimeMs });
}
}
};
@@ -104,18 +120,65 @@ export async function listFilesWithStatsAsync(
root: string,
opts: ListFilesOptions = {},
): Promise<FileWithStats[]> {
const maxResults = Math.max(1, opts.maxResults ?? 2000);
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
const rootAbs = resolve(root);
const respectGi = opts.respectGitignore !== false;
const out: FileWithStats[] = [];
const maxResults = Math.max(1, opts.maxResults ?? 2000);
await walkFilesStream(root, {
...opts,
onEntry: (e) => {
out.push(e);
return out.length < maxResults;
},
});
return out;
}
export interface StreamWalkOptions {
ignoreDirs?: readonly string[];
respectGitignore?: boolean;
signal?: AbortSignal;
/** Called per file entry. Return false to halt the walk. */
onEntry: (entry: FileWithStats) => boolean | undefined;
/** Called periodically with the running file-count. */
onProgress?: (scanned: number) => void;
/** Default 100ms — minimum gap between onProgress calls. */
progressIntervalMs?: number;
}
/** Cancelable, streaming walker. Drives `listFilesWithStatsAsync` and the picker's search-mode walk. */
export async function walkFilesStream(
root: string,
opts: StreamWalkOptions,
): Promise<{ scanned: number; cancelled: boolean }> {
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
const respectGi = opts.respectGitignore !== false;
const rootAbs = resolve(root);
const progressGap = Math.max(0, opts.progressIntervalMs ?? 100);
let scanned = 0;
let halted = false;
let lastProgress = 0;
const reportProgress = (force: boolean) => {
if (!opts.onProgress) return;
const now = Date.now();
if (force || now - lastProgress >= progressGap) {
lastProgress = now;
opts.onProgress(scanned);
}
};
const emit = (entry: FileWithStats) => {
scanned++;
if (halted) return;
if (opts.onEntry(entry) === false) halted = true;
reportProgress(false);
};
const walk = async (
dirAbs: string,
dirRel: string,
layers: readonly GitignoreLayer[],
): Promise<void> => {
if (out.length >= maxResults) return;
if (halted || opts.signal?.aborted) return;
let effectiveLayers = layers;
if (respectGi) {
const ig = await loadGitignoreAt(dirAbs);
@@ -128,67 +191,172 @@ export async function listFilesWithStatsAsync(
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
// Stats batched per directory to amortize syscall overhead. Recursion stays
// sequential so the merged DFS order matches the sync walker's contract.
const fileEnts: Dirent[] = [];
for (const ent of entries) {
if (out.length >= maxResults) break;
const relPath = dirRel ? `${dirRel}/${ent.name}` : ent.name;
if (halted || opts.signal?.aborted) break;
const absPath = join(dirAbs, ent.name);
if (ent.isDirectory()) {
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
if (ignoredByLayers(effectiveLayers, absPath, true)) continue;
// Drain pending file stats from THIS directory before
// descending so the output order stays DFS-alphabetical.
if (fileEnts.length > 0) {
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
fileEnts.length = 0;
if (out.length >= maxResults) return;
if (halted || opts.signal?.aborted) return;
}
await walk(absPath, relPath, effectiveLayers);
} else if (ent.isFile()) {
await walk(absPath, dirRel ? `${dirRel}/${ent.name}` : ent.name, effectiveLayers);
} else if (ent.isFile() || ent.isSymbolicLink()) {
fileEnts.push(ent);
}
}
if (fileEnts.length > 0 && out.length < maxResults) {
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
if (fileEnts.length > 0 && !halted && !opts.signal?.aborted) {
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
}
};
await walk(rootAbs, "", []);
return out;
reportProgress(true);
return { scanned, cancelled: !!opts.signal?.aborted };
}
async function statBatch(
async function flushFiles(
ents: readonly Dirent[],
dirAbs: string,
dirRel: string,
out: FileWithStats[],
maxResults: number,
layers: readonly GitignoreLayer[],
emit: (e: FileWithStats) => void,
): Promise<void> {
const accepted: Dirent[] = [];
for (const e of ents) {
if (out.length + accepted.length >= maxResults) break;
if (ignoredByLayers(layers, join(dirAbs, e.name), false)) continue;
accepted.push(e);
}
const accepted = ents.filter((e) => !ignoredByLayers(layers, join(dirAbs, e.name), false));
const stats = await Promise.all(
accepted.map((e) =>
stat(join(dirAbs, e.name))
.then((s) => s.mtimeMs)
.catch(() => 0),
.then((s) => ({ mtimeMs: s.mtimeMs, isFile: s.isFile() }))
.catch(() => null),
),
);
for (let i = 0; i < accepted.length; i++) {
const ent = accepted[i]!;
out.push({
const s = stats[i];
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
emit({
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
mtimeMs: stats[i] ?? 0,
mtimeMs: s?.mtimeMs ?? 0,
});
}
}
export interface DirEntry {
name: string;
/** Relative-to-root path (forward slashes). For dirs, no trailing slash. */
path: string;
isDir: boolean;
/** 0 for directories (no stat), real mtime for files. */
mtimeMs: number;
}
export interface ListDirectoryOptions {
ignoreDirs?: readonly string[];
respectGitignore?: boolean;
}
/** One-level browse for the @-picker. Folders first then files, alpha within each group. Resolves outside-root to []. */
export async function listDirectory(
root: string,
relDir: string,
opts: ListDirectoryOptions = {},
): Promise<DirEntry[]> {
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
const respectGi = opts.respectGitignore !== false;
const rootAbs = resolve(root);
const dirAbs = resolve(rootAbs, relDir);
const rel = relative(rootAbs, dirAbs);
if (rel.startsWith("..") || isAbsolute(rel)) return [];
const layers: GitignoreLayer[] = [];
if (respectGi) {
const segs = rel ? rel.split(/[\\/]/) : [];
let cursor = rootAbs;
const ig = await loadGitignoreAt(cursor);
if (ig) layers.push({ dirAbs: cursor, ig });
for (const seg of segs) {
cursor = join(cursor, seg);
const igSeg = await loadGitignoreAt(cursor);
if (igSeg) layers.push({ dirAbs: cursor, ig: igSeg });
}
}
let raw: Dirent[];
try {
raw = await readdir(dirAbs, { withFileTypes: true });
} catch {
return [];
}
const dirRel = rel.split(/[\\/]/).join("/");
const dirs: DirEntry[] = [];
const files: Dirent[] = [];
for (const ent of raw) {
const absPath = join(dirAbs, ent.name);
if (ent.isDirectory()) {
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
if (ignoredByLayers(layers, absPath, true)) continue;
dirs.push({
name: ent.name,
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
isDir: true,
mtimeMs: 0,
});
} else if (ent.isFile() || ent.isSymbolicLink()) {
if (ignoredByLayers(layers, absPath, false)) continue;
files.push(ent);
}
}
const stats = await Promise.all(
files.map((e) =>
stat(join(dirAbs, e.name))
.then((s) => ({ mtimeMs: s.mtimeMs, isFile: s.isFile() }))
.catch(() => null),
),
);
const fileEntries: DirEntry[] = [];
for (let i = 0; i < files.length; i++) {
const ent = files[i]!;
const s = stats[i];
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
fileEntries.push({
name: ent.name,
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
isDir: false,
mtimeMs: s?.mtimeMs ?? 0,
});
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
fileEntries.sort((a, b) => a.name.localeCompare(b.name));
return [...dirs, ...fileEntries];
}
export interface ParsedAtQuery {
/** Directory portion (rel from root, no trailing slash). Empty = root. */
dir: string;
/** Filter portion — chars after the last slash. Empty if query ended in `/`. */
filter: string;
/** True if the query ended in `/` — caller knows to browse `dir`. */
trailingSlash: boolean;
}
/** Split `src/auth/log` → `{dir: "src/auth", filter: "log"}`; trailing slash sets `trailingSlash` and clears filter. */
export function parseAtQuery(query: string): ParsedAtQuery {
const normalized = query.replace(/\\/g, "/");
const trailingSlash = normalized.endsWith("/");
const trimmed = trailingSlash ? normalized.slice(0, -1) : normalized;
const lastSlash = trimmed.lastIndexOf("/");
if (trailingSlash) return { dir: trimmed, filter: "", trailingSlash: true };
if (lastSlash < 0) return { dir: "", filter: trimmed, trailingSlash: false };
return {
dir: trimmed.slice(0, lastSlash),
filter: trimmed.slice(lastSlash + 1),
trailingSlash: false,
};
}
/** Trailing-token only, anchored at end-of-input — distinct from `AT_MENTION_PATTERN` which scans all. */
export const AT_PICKER_PREFIX = /(?:^|\s)@([a-zA-Z0-9_./\\-]*)$/;
@@ -251,15 +419,25 @@ export function rankPickerCandidates(
for (const e of entries) {
const lower = e.path.toLowerCase();
const hit = lower.indexOf(needle);
if (hit < 0) continue;
const slash = lower.lastIndexOf("/");
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
let score = 2;
if (base.startsWith(needle)) score = 0;
else if (lower.startsWith(needle)) score = 1;
if (hit >= 0) {
const slash = lower.lastIndexOf("/");
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
let cls = 2;
if (base.startsWith(needle)) cls = 0;
else if (lower.startsWith(needle)) cls = 1;
scored.push({
path: e.path,
score: cls * 10_000 + Math.min(hit, 9999),
mtimeMs: e.mtimeMs,
recent: recent.has(e.path),
});
continue;
}
const fuzzy = fuzzySubseqScore(needle, lower);
if (fuzzy === null) continue;
scored.push({
path: e.path,
score: score * 10_000 + hit,
score: 30_000 + fuzzy,
mtimeMs: e.mtimeMs,
recent: recent.has(e.path),
});
@@ -273,6 +451,29 @@ export function rankPickerCandidates(
return scored.slice(0, limit).map((s) => s.path);
}
function fuzzySubseqScore(needle: string, target: string): number | null {
if (needle.length === 0) return 0;
const slashIdx = target.lastIndexOf("/");
const basenameStart = slashIdx >= 0 ? slashIdx + 1 : 0;
let qi = 0;
let lastMatchIdx = -2;
let consecutive = 0;
let basenameMatches = 0;
let totalGap = 0;
for (let ti = 0; ti < target.length && qi < needle.length; ti++) {
if (target[ti] !== needle[qi]) continue;
if (ti === lastMatchIdx + 1) consecutive++;
else if (lastMatchIdx >= 0) totalGap += ti - lastMatchIdx - 1;
if (ti >= basenameStart) basenameMatches++;
lastMatchIdx = ti;
qi++;
}
if (qi < needle.length) return null;
const quality = Math.max(0, totalGap - consecutive * 10 - basenameMatches * 5);
const lengthPenalty = Math.floor(target.length / 4);
return quality + lengthPenalty;
}
/** Word-boundary anchor rejects `@` embedded in emails / social handles; trailing `.` stripped before lookup. */
export const AT_MENTION_PATTERN = /(?<=^|\s)@([a-zA-Z0-9_./\\-]+)/g;
@@ -283,8 +484,14 @@ export interface AtMentionExpansion {
path: string;
/** True if the content was inlined. False = skipped (reason in `skip`). */
ok: boolean;
/** Bytes read (only for ok=true). */
/** Bytes read (only for ok=true and isDirectory=false). */
bytes?: number;
/** True when the mention resolved to a directory (ok=true). Block uses `<directory>` instead of `<file>`. */
isDirectory?: boolean;
/** Number of files listed when isDirectory=true. */
entries?: number;
/** True iff the directory listing was clipped at maxDirEntries. */
truncated?: boolean;
/** Why the mention was skipped. Set when ok=false. */
skip?: "missing" | "not-file" | "too-large" | "escape" | "read-error";
}
@@ -292,9 +499,19 @@ export interface AtMentionExpansion {
export interface AtMentionOptions {
/** Max file size in bytes before a mention is skipped. */
maxBytes?: number;
/** Cap on entries returned for a `@<dir>` listing. Default {@link DEFAULT_AT_DIR_MAX_ENTRIES}. */
maxDirEntries?: number;
fs?: {
exists: (path: string) => boolean;
isFile: (path: string) => boolean;
/** Optional — when omitted, directories are skipped as `not-file`. */
isDir?: (path: string) => boolean;
/** Optional — receives the directory's absolute path and the project root, returns relative paths and a truncated flag. */
listDir?: (
dirAbs: string,
root: string,
max: number,
) => { files: string[]; truncated: boolean };
size: (path: string) => number;
read: (path: string) => string;
};
@@ -306,11 +523,13 @@ export function expandAtMentions(
opts: AtMentionOptions = {},
): { text: string; expansions: AtMentionExpansion[] } {
const maxBytes = opts.maxBytes ?? DEFAULT_AT_MENTION_MAX_BYTES;
const maxDirEntries = Math.max(1, opts.maxDirEntries ?? DEFAULT_AT_DIR_MAX_ENTRIES);
const fs = opts.fs ?? defaultFs;
const root = resolve(rootDir);
// De-dupe by token so `@file.ts` referenced twice inlines once.
const seen = new Map<string, AtMentionExpansion>();
const expansions: AtMentionExpansion[] = [];
const dirListings = new Map<string, string[]>();
for (const match of text.matchAll(AT_MENTION_PATTERN)) {
const rawPath = match[1] ?? "";
@@ -319,11 +538,13 @@ export function expandAtMentions(
// regex is O(n²) on dot-heavy non-matches per CodeQL js/polynomial-redos.
let cleaned = rawPath;
while (cleaned.endsWith(".")) cleaned = cleaned.slice(0, -1);
// Strip a single trailing slash so `@docs/` and `@docs` resolve identically.
if (cleaned.endsWith("/") || cleaned.endsWith("\\")) cleaned = cleaned.slice(0, -1);
if (!cleaned) continue;
const token = `@${cleaned}`;
if (seen.has(token)) continue;
const expansion = resolveMention(cleaned, root, maxBytes, fs);
const expansion = resolveMention(cleaned, root, maxBytes, maxDirEntries, fs, dirListings);
seen.set(token, expansion);
expansions.push(expansion);
}
@@ -335,7 +556,14 @@ export function expandAtMentions(
// both what's here and what's missing.
const blocks: string[] = [];
for (const ex of expansions) {
if (ex.ok) {
if (ex.ok && ex.isDirectory) {
const files = dirListings.get(ex.path) ?? [];
const truncAttr = ex.truncated ? ' truncated="true"' : "";
const body = files.length > 0 ? `\n${files.join("\n")}\n` : "\n";
blocks.push(
`<directory path="${ex.path}" entries="${ex.entries ?? files.length}"${truncAttr}>${body}</directory>`,
);
} else if (ex.ok) {
const content = readSafe(root, ex.path, fs);
blocks.push(`<file path="${ex.path}">\n${content}\n</file>`);
} else {
@@ -350,7 +578,9 @@ function resolveMention(
rawPath: string,
root: string,
maxBytes: number,
maxDirEntries: number,
fs: NonNullable<AtMentionOptions["fs"]>,
dirListings: Map<string, string[]>,
): AtMentionExpansion {
// Reject absolute paths — `@/etc/passwd` should not inline.
if (isAbsolute(rawPath)) {
@@ -365,14 +595,28 @@ function resolveMention(
if (!fs.exists(resolved)) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "missing" };
}
if (!fs.isFile(resolved)) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
if (fs.isFile(resolved)) {
const size = fs.size(resolved);
if (size > maxBytes) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
}
return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
}
const size = fs.size(resolved);
if (size > maxBytes) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
// Not a file — try the directory branch. listDir is optional; without it,
// fall back to the legacy not-file skip so test fixtures don't break.
if (fs.isDir?.(resolved) && fs.listDir) {
const { files, truncated } = fs.listDir(resolved, root, maxDirEntries);
dirListings.set(rawPath, files);
return {
token: `@${rawPath}`,
path: rawPath,
ok: true,
isDirectory: true,
entries: files.length,
truncated,
};
}
return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
}
function readSafe(root: string, rawPath: string, fs: NonNullable<AtMentionOptions["fs"]>): string {
@@ -393,6 +637,27 @@ const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
return false;
}
},
isDir: (p) => {
try {
return statSync(p).isDirectory();
} catch {
return false;
}
},
listDir: (dirAbs, root, max) => {
// Walk from project root and filter to entries under dirAbs so the
// listing inherits the parent .gitignore layers. Walking dirAbs alone
// would miss the project-root rules above it.
const dirRel = relative(root, dirAbs).split(/[\\/]/).join("/");
const walkCap = Math.max(max * 4, 5000);
const all = listFilesSync(root, { maxResults: walkCap });
const prefix = dirRel ? `${dirRel}/` : "";
const filtered = dirRel ? all.filter((f) => f === dirRel || f.startsWith(prefix)) : all;
return {
files: filtered.slice(0, max),
truncated: filtered.length > max,
};
},
size: (p) => {
try {
return statSync(p).size;
@@ -403,164 +668,10 @@ const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
read: (p) => readFileSync(p, "utf8"),
};
// @url mentions — async sibling of @path. Matches `@http(s)://...` after a
// word boundary, fetches each URL once per session (in-memory cache), and
// appends a "Referenced URLs" block under the prompt the model sees. Uses
// the same web-fetch + HTML-strip pipeline as the model's `web_fetch` tool
// so a `@url` reference and a model-issued fetch produce identical content.
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
/** Default cap on inlined URL body (chars). Matches DEFAULT_AT_MENTION_MAX_BYTES order-of-magnitude. */
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
export interface AtUrlExpansion {
/** The raw `@url` token as it appeared in the text. */
token: string;
/** Absolute URL (after trailing-punctuation strip). */
url: string;
/** True if content was inlined. False = skipped (reason in `skip`). */
ok: boolean;
/** Page title when extractable from `<title>`. */
title?: string;
/** Char count of the (post-truncation) inlined body. */
chars?: number;
/** True iff the original page exceeded `maxChars` and was clipped. */
truncated?: boolean;
/** Why the mention was skipped — set when ok=false. */
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
/** Free-form error message attached to skip outcomes. */
error?: string;
}
export interface AtUrlOptions {
/** Max chars of inlined body per URL. Default DEFAULT_AT_URL_MAX_CHARS. */
maxChars?: number;
/** Per-URL fetch timeout in ms. */
timeoutMs?: number;
fetcher?: (
url: string,
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
cache?: Map<string, AtUrlExpansion & { body?: string }>;
/** Forward Esc/abort to the fetcher. */
signal?: AbortSignal;
}
export async function expandAtUrls(
text: string,
opts: AtUrlOptions = {},
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
const fetcher = opts.fetcher;
if (!fetcher) {
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
}
// De-dupe by URL so the same `@https://x.com` referenced twice fetches once.
const seen = new Map<string, AtUrlExpansion>();
const bodies = new Map<string, string>();
const order: string[] = [];
for (const match of text.matchAll(AT_URL_PATTERN)) {
const rawUrl = match[1] ?? "";
const url = stripUrlTail(rawUrl);
if (!url) continue;
if (seen.has(url)) continue;
const cached = opts.cache?.get(url);
if (cached) {
seen.set(url, cached);
if (cached.body) bodies.set(url, cached.body);
order.push(url);
continue;
}
let expansion: AtUrlExpansion;
let body = "";
try {
const page = await fetcher(url, {
maxChars,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
});
body = page.text;
expansion = {
token: `@${url}`,
url,
ok: true,
title: page.title,
chars: body.length,
truncated: page.truncated,
};
} catch (err) {
const message = (err as Error).message ?? String(err);
// Tag a few common shapes so the UI can hint at causes.
let skip: AtUrlExpansion["skip"] = "fetch-error";
if (/aborted|timeout/i.test(message)) skip = "timeout";
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
expansion = {
token: `@${url}`,
url,
ok: false,
skip,
error: message,
};
}
seen.set(url, expansion);
if (body) bodies.set(url, body);
if (opts.cache) opts.cache.set(url, { ...expansion, body });
order.push(url);
}
if (seen.size === 0) return { text, expansions: [] };
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
const blocks: string[] = [];
for (const ex of expansions) {
if (ex.ok) {
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
const truncTag = ex.truncated ? ' truncated="true"' : "";
const body = bodies.get(ex.url) ?? "";
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
} else {
const reasonAttr = ex.skip ?? "fetch-error";
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
}
}
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
return { text: augmented, expansions };
}
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
export function stripUrlTail(raw: string): string {
let s = raw;
while (s.length > 0) {
const last = s[s.length - 1]!;
if (".,;:!?".includes(last)) {
s = s.slice(0, -1);
continue;
}
if (")]}>".includes(last)) {
// Only strip if the matching open bracket isn't elsewhere in the
// URL — avoids butchering legitimate `(thing)` query fragments.
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
last as ")" | "]" | "}" | ">"
];
if (!s.includes(open)) {
s = s.slice(0, -1);
continue;
}
}
break;
}
return s;
}
function escapeAttr(s: string): string {
return s
.replace(/"/g, "&quot;")
.replace(/[\r\n]+/g, " ")
.trim();
}
export {
AT_URL_PATTERN,
DEFAULT_AT_URL_MAX_CHARS,
expandAtUrls,
stripUrlTail,
} from "./at-mentions-url.js";
export type { AtUrlExpansion, AtUrlOptions } from "./at-mentions-url.js";
+15 -5
View File
@@ -28,6 +28,7 @@ import { ToolRegistry } from "../../tools.js";
import { registerChoiceTool } from "../../tools/choice.js";
import { registerMemoryTools } from "../../tools/memory.js";
import { registerWebTools } from "../../tools/web.js";
import { markPhase } from "../startup-profile.js";
import { App } from "../ui/App.js";
import { SessionPicker } from "../ui/SessionPicker.js";
import { Setup } from "../ui/Setup.js";
@@ -263,8 +264,6 @@ export interface ChatOptions {
model: string;
system: string;
transcript?: string;
harvest?: boolean;
branch?: number;
/**
* Soft USD cap on session spend. Undefined → no cap (default).
* The loop warns once at 80% and refuses to start a new turn at
@@ -298,6 +297,8 @@ export interface ChatOptions {
* then falls back to non-tool updates only).
*/
reregisterTools?: (rootDir: string) => void;
/** Async tail of `/cwd` — re-probe the new dir for a semantic index. */
reBootstrapSemantic?: (rootDir: string) => Promise<{ enabled: boolean }>;
};
/** Skip the session picker — assume "Resume" (backwards-compatible auto-continue). */
forceResume?: boolean;
@@ -403,8 +404,6 @@ function Root({
model={appProps.model}
system={appProps.system}
transcript={appProps.transcript}
harvest={appProps.harvest}
branch={appProps.branch}
budgetUsd={appProps.budgetUsd}
session={activeSession}
tools={tools}
@@ -421,8 +420,10 @@ function Root({
}
export async function chatCommand(opts: ChatOptions): Promise<void> {
markPhase("chat_command_enter");
loadDotenv();
const initialKey = loadApiKey();
markPhase("config_loaded");
const requestedSpecs = opts.mcp ?? [];
// Shared progress sink: the bridge's onProgress callback writes
@@ -445,14 +446,22 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
});
const failedSpecs: Array<{ spec: string; reason: string }> = [];
if (requestedSpecs.length > 0) markPhase("mcp_launch");
for (const raw of requestedSpecs) {
const result = await runtime.addSpec(raw);
if (!result.ok) failedSpecs.push({ spec: raw, reason: result.reason });
}
if (requestedSpecs.length > 0) {
markPhase(
`mcp_connected_${requestedSpecs.length - failedSpecs.length}_of_${requestedSpecs.length}`,
);
}
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
@@ -494,6 +503,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
const showPicker =
!opts.session && !opts.forceResume && listSessionsForWorkspace(launchWorkspace).length > 0;
markPhase("ink_render_call");
const { waitUntilExit } = render(
<Root
initialKey={initialKey}
+27 -12
View File
@@ -30,6 +30,8 @@ import { JobRegistry } from "../../tools/jobs.js";
import { registerMemoryTools } from "../../tools/memory.js";
import { registerPlanTool } from "../../tools/plan.js";
import { registerShellTools } from "../../tools/shell.js";
import { registerTodoTool } from "../../tools/todo.js";
import { markPhase } from "../startup-profile.js";
import { chatCommand } from "./chat.js";
export interface CodeOptions {
@@ -45,15 +47,6 @@ export interface CodeOptions {
forceResume?: boolean;
/** Skip the session picker — always wipe prior messages and start fresh. */
forceNew?: boolean;
/**
* Opt into Pillar 2 harvesting (extracts a typed plan state from R1
* reasoning via an extra V3 call). Default OFF in code mode — the
* displayed subgoals/hypotheses/rejectedPaths have no programmatic
* consumer, only `uncertainties.length` feeds branching, and the
* extra V3 call adds ~10-15% per-turn cost. Users who want the
* reasoning surfaced explicitly can pass `--harvest`.
*/
harvest?: boolean;
/**
* Soft USD spend cap. Off by default. Same semantics as `chat`:
* warns at 80%, refuses next turn at 100%. Mid-session adjustable
@@ -71,6 +64,7 @@ export interface CodeOptions {
}
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
markPhase("code_command_enter");
const { codeSystemPrompt } = await import("../../code/prompt.js");
const rootDir = resolve(opts.dir ?? process.cwd());
// Per-directory session so switching projects doesn't mix histories.
@@ -115,6 +109,16 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// per-project memory store; the global scope is shared across runs.
registerMemoryTools(tools, { projectRoot: root });
};
// Async tail to `registerRootedTools`. Kept separate because the FS /
// shell / memory re-registration above is sync and must happen before
// the next tool dispatch, while semantic-index probing reads disk and
// can race ahead in the background. On `/cwd`, App.tsx fires this
// after the sync swap and surfaces the result via postInfo.
const reBootstrapSemantic = async (root: string): Promise<{ enabled: boolean }> => {
const result = await bootstrapSemanticSearchInCodeMode(tools, root);
if (!result.enabled) tools.unregister("semantic_search");
return result;
};
registerRootedTools(rootDir);
// `submit_plan` is always in the spec list so the prefix cache stays
// stable across plan-mode toggles (Pillar 1). The tool itself is a
@@ -127,6 +131,9 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// menu into a submit_plan body. Keeping it always-registered
// preserves the prefix cache across plan-mode toggles.
registerChoiceTool(tools);
// `todo_write` — lightweight in-session task tracker, no approval gate.
// Independent of plan mode (readOnly=true so it stays callable in /plan).
registerTodoTool(tools);
// `run_skill` is intentionally NOT registered here — App.tsx wires it
// up with the subagent runner attached, so `runAs: subagent` skills
// can spawn isolated child loops. Doing it here would mean the App's
@@ -137,7 +144,11 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// on-disk index already exists, skips entirely otherwise. Setup
// happens via the explicit `reasonix index` command — never
// by surprise on launch.
const semantic = await bootstrapSemanticSearchInCodeMode(tools, rootDir);
markPhase("semantic_bootstrap_start");
const semantic = await reBootstrapSemantic(rootDir);
markPhase(
semantic.enabled ? "semantic_bootstrap_done_enabled" : "semantic_bootstrap_done_skipped",
);
process.stderr.write(
`▸ reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}" · ${tools.size} native tool(s)${
@@ -177,7 +188,6 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
await chatCommand({
model: opts.model ?? "deepseek-v4-flash",
harvest: opts.harvest ?? false,
budgetUsd: opts.budgetUsd,
system: codeSystemPrompt(rootDir, {
hasSemanticSearch: semantic.enabled,
@@ -187,7 +197,12 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
transcript: opts.transcript,
session,
seedTools: tools,
codeMode: { rootDir, jobs, reregisterTools: registerRootedTools },
codeMode: {
rootDir,
jobs,
reregisterTools: registerRootedTools,
reBootstrapSemantic,
},
mcp: readConfig().mcp,
forceResume: opts.forceResume,
forceNew: opts.forceNew,
+62 -42
View File
@@ -1,15 +1,16 @@
/** Plain-text (not Ink) — must work when everything else is broken. fail → exit 1; warn → exit 0. */
import { existsSync, statSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { DeepSeekClient } from "../../client.js";
import { defaultConfigPath, readConfig } from "../../config.js";
import { defaultConfigPath, readConfig, resolveSemanticEmbeddingConfig } from "../../config.js";
import { loadDotenv } from "../../env.js";
import { loadHooks } from "../../hooks.js";
import { indexExists } from "../../index/semantic/builder.js";
import { checkOllamaStatus } from "../../index/semantic/ollama-launcher.js";
import { listSessions } from "../../memory/session.js";
import { resolveDataPath } from "../../tokenizer.js";
import { VERSION } from "../../version.js";
export type DoctorLevel = "ok" | "warn" | "fail";
@@ -169,32 +170,20 @@ async function checkApiReach(): Promise<Check> {
}
async function checkTokenizer(): Promise<Check> {
// The tokenizer file is the gzipped vocab shipped under data/. We
// resolve relative to the package's dist/ rather than process.cwd()
// — the same logic the runtime uses when loading on first count.
const candidates = [
join(
dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")),
"..",
"..",
"..",
"data",
"deepseek-tokenizer.json.gz",
),
join(process.cwd(), "data", "deepseek-tokenizer.json.gz"),
];
for (const p of candidates) {
if (existsSync(p)) {
try {
const stat = statSync(p);
return {
label: "tokenizer ",
level: "ok",
detail: `${p} (${fmtBytes(stat.size)})`,
};
} catch {
/* try next */
}
// Reuse the runtime's resolver so the doctor never disagrees with what
// the tokenizer actually loads — three candidates including a global
// npm install probe via createRequire.
const path = resolveDataPath();
if (existsSync(path)) {
try {
const stat = statSync(path);
return {
label: "tokenizer ",
level: "ok",
detail: `${path} (${fmtBytes(stat.size)})`,
};
} catch {
/* fall through to warn */
}
}
return {
@@ -259,9 +248,6 @@ async function checkHooks(projectRoot: string): Promise<Check> {
}
async function checkOllama(projectRoot: string): Promise<Check> {
// Ollama is only meaningful when an index has been built — otherwise
// the daemon being down is just noise. Skip the check entirely on
// projects that don't use semantic search.
let exists = false;
try {
exists = await indexExists(projectRoot);
@@ -270,48 +256,82 @@ async function checkOllama(projectRoot: string): Promise<Check> {
}
if (!exists) {
return {
label: "ollama ",
label: "semantic ",
level: "ok",
detail: "not in use (no semantic index built; `reasonix index` to enable)",
};
}
const meta = readSemanticMeta(projectRoot);
if (meta?.provider === "openai-compat") {
const resolved = resolveSemanticEmbeddingConfig();
if (resolved.provider !== "openai-compat") {
return {
label: "semantic ",
level: "warn",
detail: `index uses openai-compat/${meta.model} but current config resolves to ${resolved.provider}/${resolved.model} — rebuild before searching`,
};
}
return {
label: "semantic ",
level: "ok",
detail: `openai-compat · ${resolved.baseUrl} · model ${resolved.model} · api key configured`,
};
}
try {
const status = await checkOllamaStatus(process.env.REASONIX_EMBED_MODEL ?? "nomic-embed-text");
const model = meta?.model || process.env.REASONIX_EMBED_MODEL || "nomic-embed-text";
const status = await checkOllamaStatus(model);
if (!status.binaryFound) {
return {
label: "ollama ",
label: "semantic ",
level: "warn",
detail: "binary not on PATH — semantic_search will fail; install from https://ollama.com",
detail:
"ollama binary not on PATH — semantic_search will fail; install from https://ollama.com",
};
}
if (!status.daemonRunning) {
return {
label: "ollama ",
label: "semantic ",
level: "warn",
detail: "daemon not running — `ollama serve` (or just call /semantic in TUI to auto-start)",
detail:
"ollama daemon not running — `ollama serve` (or call /semantic in TUI to auto-start)",
};
}
if (!status.modelPulled) {
return {
label: "ollama ",
label: "semantic ",
level: "warn",
detail: `model ${status.modelName} not pulled — \`ollama pull ${status.modelName}\``,
};
}
return {
label: "ollama ",
label: "semantic ",
level: "ok",
detail: `daemon up · model ${status.modelName} ready`,
detail: `ollama daemon up · model ${status.modelName} ready`,
};
} catch (err) {
return {
label: "ollama ",
label: "semantic ",
level: "warn",
detail: `probe failed — ${(err as Error).message}`,
};
}
}
function readSemanticMeta(
projectRoot: string,
): { provider: "ollama" | "openai-compat"; model: string } | null {
try {
const raw = readFileSync(join(projectRoot, ".reasonix", "semantic", "index.meta.json"), "utf8");
const parsed = JSON.parse(raw) as { provider?: string; model?: string };
return {
provider: parsed.provider === "openai-compat" ? "openai-compat" : "ollama",
model: typeof parsed.model === "string" ? parsed.model : "",
};
} catch {
return null;
}
}
async function checkProject(projectRoot: string): Promise<Check> {
// Heuristic: a "real" project has either .git, REASONIX.md, or
// package.json. Lacking all three, `reasonix code` still works but
+16 -20
View File
@@ -1,34 +1,37 @@
/** `reasonix index` — progress writes go to stderr so stdout stays pipeable. */
import { resolve } from "node:path";
import { loadIndexConfig } from "../../config.js";
import { loadIndexConfig, resolveSemanticEmbeddingConfig } from "../../config.js";
import { buildIndex } from "../../index/semantic/builder.js";
import type { BuildProgress, BuildResult, SkipBuckets } from "../../index/semantic/builder.js";
import { t } from "../../index/semantic/i18n.js";
import { ollamaPreflight } from "../../index/semantic/preflight.js";
import { semanticPreflight } from "../../index/semantic/preflight.js";
export interface IndexCommandOptions {
rebuild?: boolean;
model?: string;
dir?: string;
/** Ollama base URL override. */
ollamaUrl?: string;
/** Skip preflight prompts (yes to all). For scripts that already
* know Ollama is set up. Default false. */
yes?: boolean;
}
export async function indexCommand(opts: IndexCommandOptions = {}): Promise<void> {
const root = resolve(opts.dir ?? process.cwd());
const tty = process.stderr.isTTY === true && process.stdin.isTTY === true;
const model = opts.model ?? process.env.REASONIX_EMBED_MODEL ?? "nomic-embed-text";
const resolved = resolveSemanticEmbeddingConfig();
const embedding =
resolved.provider === "ollama"
? {
...resolved,
model: opts.model ?? resolved.model,
baseUrl: opts.ollamaUrl ?? resolved.baseUrl,
}
: {
...resolved,
model: opts.model ?? resolved.model,
};
// Preflight: detect Ollama state and offer to fix what's missing.
// Runs before chunking so the user doesn't watch a scan finish only
// to hit "daemon not reachable" on the first embed call.
const preflightOk = await ollamaPreflight({
model,
baseUrl: opts.ollamaUrl,
const preflightOk = await semanticPreflight(embedding, {
interactive: tty && !opts.yes,
yesToAll: opts.yes ?? false,
});
@@ -40,9 +43,8 @@ export async function indexCommand(opts: IndexCommandOptions = {}): Promise<void
let result: BuildResult;
try {
result = await buildIndex(root, {
...embedding,
rebuild: opts.rebuild,
model,
baseUrl: opts.ollamaUrl,
indexConfig: loadIndexConfig(),
onProgress: (p) => writer.update(p),
});
@@ -135,7 +137,6 @@ function makeNonTtyWriter(): ProgressWriter {
};
}
/** Spinner ticks on its own setInterval so a hung embedder still shows liveness. */
function makeTtyWriter(): ProgressWriter {
let status = t("progressStarting");
let lastLineLen = 0;
@@ -152,8 +153,6 @@ function makeTtyWriter(): ProgressWriter {
lastLineLen = line.length;
};
// Paint the initial frame immediately so the user sees text on
// line one — don't wait for the first interval tick.
repaint();
const interval = setInterval(repaint, SPINNER_INTERVAL_MS);
@@ -167,9 +166,6 @@ function makeTtyWriter(): ProgressWriter {
const pct = total > 0 ? ((done / total) * 100).toFixed(0) : "0";
status = t("progressEmbed", { done, total, pct });
}
// Repaint immediately on event arrival so the new counter
// shows up before the next interval tick (avoids a 120ms lag
// on each progress update).
repaint();
},
clear() {
-20
View File
@@ -67,11 +67,6 @@ function printReplay(opts: ReplayOptions): void {
} else if (stats.prefixHashes.length > 1) {
console.log(" (prefix churned — cache-hostile session)");
}
if (stats.harvestedTurns > 0) {
console.log(`harvest (Pillar 2): ${stats.harvestedTurns} turn(s) produced plan state`);
console.log(` subgoals: ${stats.totalSubgoals}`);
console.log(` uncertainties: ${stats.totalUncertainties}`);
}
}
function sliceRecords(records: TranscriptRecord[], opts: ReplayOptions): TranscriptRecord[] {
@@ -98,21 +93,6 @@ function renderRecord(rec: TranscriptRecord): void {
})()
: "";
console.log(`${turn} AGENT:${cost}${cache} ${oneLine(rec.content)}`);
if (rec.planState) {
const ps = rec.planState;
if (ps.subgoals.length)
console.log(` ‹ subgoals (${ps.subgoals.length}): ${ps.subgoals.join(" · ")}`);
if (ps.hypotheses.length)
console.log(` ‹ hypotheses (${ps.hypotheses.length}): ${ps.hypotheses.join(" · ")}`);
if (ps.uncertainties.length)
console.log(
` ‹ uncertainties(${ps.uncertainties.length}): ${ps.uncertainties.join(" · ")}`,
);
if (ps.rejectedPaths.length)
console.log(
` ‹ rejected (${ps.rejectedPaths.length}): ${ps.rejectedPaths.join(" · ")}`,
);
}
} else if (rec.role === "tool") {
const args = rec.args ? ` args=${oneLine(rec.args, 80)}` : "";
console.log(`${turn} TOOL ${rec.tool ?? "?"}:${args} → ${oneLine(rec.content, 120)}`);
-6
View File
@@ -27,10 +27,6 @@ export interface RunOptions {
task: string;
model: string;
system: string;
/** Opt into Pillar 2 plan-state harvesting. Adds a cheap V3 call per turn. */
harvest?: boolean;
/** Self-consistency budget. > 1 runs N parallel samples and picks the best. */
branch?: number;
budgetUsd?: number;
/** JSONL transcript path — lets `reasonix replay` / `diff` audit this run. */
transcript?: string;
@@ -156,8 +152,6 @@ export async function runCommand(opts: RunOptions): Promise<void> {
prefix,
tools,
model: opts.model,
harvest: opts.harvest,
branch: opts.branch,
budgetUsd: opts.budgetUsd,
});
const prefixHash = prefix.fingerprint;
+38 -40
View File
@@ -5,7 +5,10 @@ import { VERSION } from "../index.js";
import { listSessions } from "../memory/session.js";
import { applyMemoryStack } from "../memory/user.js";
import { ESCALATION_CONTRACT } from "../prompt-fragments.js";
import { chatCommand } from "./commands/chat.js";
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
import { markPhase } from "./startup-profile.js";
markPhase("cli_module_loaded");
const DEFAULT_SYSTEM = `You are Reasonix, a helpful DeepSeek-powered assistant. Be concise and accurate. Use tools when available.
@@ -28,24 +31,6 @@ Your training data has a cutoff. When an answer's correctness depends on somethi
The signal isn't a topic list — it's: "if I'm wrong about this, is it because reality moved on?". If yes, ground the answer in fresh evidence; if no (definitions, mechanisms, well-established APIs), answer from memory.
${ESCALATION_CONTRACT}`;
import { codeCommand } from "./commands/code.js";
import { commitCommand } from "./commands/commit.js";
import { diffCommand } from "./commands/diff.js";
import { doctorCommand } from "./commands/doctor.js";
import { eventsCommand } from "./commands/events.js";
import { indexCommand } from "./commands/index.js";
import { mcpBrowseCommand } from "./commands/mcp-browse.js";
import { formatMcpInspectFailure, mcpInspectCommand } from "./commands/mcp-inspect.js";
import { mcpInstallCommand, mcpListCommand, mcpSearchCommand } from "./commands/mcp.js";
import { pruneSessionsCommand } from "./commands/prune-sessions.js";
import { replayCommand } from "./commands/replay.js";
import { runCommand } from "./commands/run.js";
import { sessionsCommand } from "./commands/sessions.js";
import { setupCommand } from "./commands/setup.js";
import { statsCommand } from "./commands/stats.js";
import { updateCommand } from "./commands/update.js";
import { versionCommand } from "./commands/version.js";
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
/** Lenient: malformed → undefined (no cap) so a bad flag doesn't abort launch. */
function parseBudgetFlag(raw: number | undefined): number | undefined {
@@ -74,6 +59,7 @@ program
program.action(async (opts: { continue?: boolean }) => {
const cfg = readConfig();
if (!cfg.setupCompleted) {
const { setupCommand } = await import("./commands/setup.js");
await setupCommand({});
return;
}
@@ -84,11 +70,10 @@ program.action(async (opts: { continue?: boolean }) => {
() => listSessions()[0],
(msg) => process.stderr.write(`${msg}\n`),
);
const { chatCommand } = await import("./commands/chat.js");
await chatCommand({
model: defaults.model,
system: applyMemoryStack(DEFAULT_SYSTEM, process.cwd()),
harvest: defaults.harvest,
branch: defaults.branch,
session: continueOpts.session,
mcp: defaults.mcp,
forceResume: continueOpts.forceResume,
@@ -99,6 +84,7 @@ program
.command("setup")
.description(t("cli.setup"))
.action(async () => {
const { setupCommand } = await import("./commands/setup.js");
await setupCommand({});
});
@@ -110,13 +96,13 @@ program
.option("-r, --resume", t("ui.resumeHint"))
.option("-n, --new", t("ui.newHint"))
.option("--transcript <path>", t("ui.transcriptHint"))
.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) => {
const { codeCommand } = await import("./commands/code.js");
await codeCommand({
dir,
model: opts.model,
@@ -124,7 +110,6 @@ program
transcript: opts.transcript,
forceResume: !!opts.resume,
forceNew: !!opts.new,
harvest: !!opts.harvest,
budgetUsd: parseBudgetFlag(opts.budget),
noDashboard: opts.dashboard === false,
systemAppend: opts.systemAppend,
@@ -140,8 +125,6 @@ program
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
.option("--transcript <path>", t("ui.transcriptHint"))
.option("--preset <name>", t("ui.presetHint"))
.option("--harvest", t("ui.harvestOptInHint"))
.option("--branch <n>", t("ui.branchHint"), (v) => Number.parseInt(v, 10))
.option("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
.option("--session <name>", t("ui.sessionNameHint"))
.option("--no-session", t("ui.ephemeralHint"))
@@ -161,8 +144,6 @@ program
.action(async (opts) => {
const defaults = resolveDefaults({
model: opts.model,
harvest: opts.harvest,
branch: opts.branch,
mcp: opts.mcp as string[],
session: opts.session,
preset: opts.preset,
@@ -180,12 +161,11 @@ program
() => listSessions()[0],
(msg) => process.stderr.write(`${msg}\n`),
);
const { chatCommand } = await import("./commands/chat.js");
await chatCommand({
model: defaults.model,
system: applyMemoryStack(opts.system, process.cwd()),
transcript: opts.transcript,
harvest: defaults.harvest,
branch: defaults.branch,
budgetUsd: parseBudgetFlag(opts.budget),
session: continueOpts.session,
mcp: defaults.mcp,
@@ -203,8 +183,6 @@ program
.option("-m, --model <id>", t("ui.modelIdHint"))
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
.option("--preset <name>", t("ui.presetHintShort"))
.option("--harvest", t("ui.harvestHintShort"))
.option("--branch <n>", t("ui.branchHintShort"), (v) => Number.parseInt(v, 10))
.option("--budget <usd>", t("ui.budgetHintShort"), (v) => Number.parseFloat(v))
.option("--transcript <path>", t("ui.transcriptHintShort"))
.option(
@@ -218,18 +196,15 @@ program
.action(async (task: string, opts) => {
const defaults = resolveDefaults({
model: opts.model,
harvest: opts.harvest,
branch: opts.branch,
mcp: opts.mcp as string[],
preset: opts.preset,
noConfig: opts.config === false,
});
const { runCommand } = await import("./commands/run.js");
await runCommand({
task,
model: defaults.model,
system: applyMemoryStack(opts.system, process.cwd()),
harvest: defaults.harvest,
branch: defaults.branch,
budgetUsd: parseBudgetFlag(opts.budget),
transcript: opts.transcript,
mcp: defaults.mcp,
@@ -240,7 +215,8 @@ program
program
.command("stats [transcript]")
.description(t("cli.stats"))
.action((transcript: string | undefined) => {
.action(async (transcript: string | undefined) => {
const { statsCommand } = await import("./commands/stats.js");
statsCommand({ transcript });
});
@@ -248,6 +224,7 @@ program
.command("doctor")
.description(t("cli.doctor"))
.action(async () => {
const { doctorCommand } = await import("./commands/doctor.js");
await doctorCommand();
});
@@ -257,6 +234,7 @@ program
.option("-m, --model <id>", t("ui.modelOverrideFlash"))
.option("-y, --yes", t("ui.skipConfirmHint"))
.action(async (opts) => {
const { commitCommand } = await import("./commands/commit.js");
await commitCommand({ model: opts.model, yes: !!opts.yes });
});
@@ -264,7 +242,8 @@ program
.command("sessions [name]")
.description(t("cli.sessions"))
.option("-v, --verbose", t("ui.verboseHint"))
.action((name: string | undefined, opts) => {
.action(async (name: string | undefined, opts) => {
const { sessionsCommand } = await import("./commands/sessions.js");
sessionsCommand({ name, verbose: !!opts.verbose });
});
@@ -273,7 +252,8 @@ program
.description(t("cli.pruneSessions"))
.option("--days <n>", t("ui.pruneDaysHint"), (v) => Number.parseInt(v, 10))
.option("--dry-run", t("ui.pruneDryRunHint"))
.action((opts) => {
.action(async (opts) => {
const { pruneSessionsCommand } = await import("./commands/prune-sessions.js");
pruneSessionsCommand({ days: opts.days, dryRun: !!opts.dryRun });
});
@@ -285,7 +265,8 @@ program
.option("--tail <n>", t("ui.eventTailHint"), (v) => Number.parseInt(v, 10))
.option("--json", t("ui.jsonHint"))
.option("--projection", t("ui.projectionHint"))
.action((name: string, opts) => {
.action(async (name: string, opts) => {
const { eventsCommand } = await import("./commands/events.js");
eventsCommand({
name,
type: opts.type,
@@ -303,6 +284,7 @@ program
.option("--head <n>", t("ui.headHint"), (v) => Number.parseInt(v, 10))
.option("--tail <n>", t("ui.tailHint"), (v) => Number.parseInt(v, 10))
.action(async (transcript: string, opts) => {
const { replayCommand } = await import("./commands/replay.js");
await replayCommand({
path: transcript,
print: !!opts.print,
@@ -320,6 +302,7 @@ program
.option("--label-a <label>", t("ui.labelAHint"))
.option("--label-b <label>", t("ui.labelBHint"))
.action(async (a: string, b: string, opts) => {
const { diffCommand } = await import("./commands/diff.js");
await diffCommand({
a,
b,
@@ -344,6 +327,7 @@ mcp
.option("--all", t("ui.mcpAllHint"))
.action(async (opts) => {
try {
const { mcpListCommand } = await import("./commands/mcp.js");
await mcpListCommand({
json: !!opts.json,
local: !!opts.local,
@@ -367,6 +351,7 @@ mcp
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
.action(async (query: string, opts) => {
try {
const { mcpSearchCommand } = await import("./commands/mcp.js");
await mcpSearchCommand(query, {
json: !!opts.json,
refresh: !!opts.refresh,
@@ -387,6 +372,7 @@ mcp
.option("--max-pages <n>", t("ui.mcpMaxPagesHint"), (v) => Number.parseInt(v, 10))
.action(async (name: string, opts) => {
try {
const { mcpInstallCommand } = await import("./commands/mcp.js");
await mcpInstallCommand(name, {
refresh: !!opts.refresh,
maxPages:
@@ -403,6 +389,7 @@ mcp
.description(t("ui.mcpBrowseDescription"))
.action(async () => {
try {
const { mcpBrowseCommand } = await import("./commands/mcp-browse.js");
await mcpBrowseCommand();
} catch (err) {
process.stderr.write(`mcp browse failed: ${(err as Error).message}\n`);
@@ -415,6 +402,9 @@ mcp
.description(t("ui.mcpInspectDescription"))
.option("--json", t("ui.jsonHintReport"))
.action(async (spec: string, opts) => {
const { formatMcpInspectFailure, mcpInspectCommand } = await import(
"./commands/mcp-inspect.js"
);
try {
await mcpInspectCommand({ spec, json: !!opts.json });
} catch (err) {
@@ -423,13 +413,20 @@ mcp
}
});
program.command("version").description(t("cli.version")).action(versionCommand);
program
.command("version")
.description(t("cli.version"))
.action(async () => {
const { versionCommand } = await import("./commands/version.js");
versionCommand();
});
program
.command("update")
.description(t("cli.update"))
.option("--dry-run", t("ui.dryRunHint"))
.action(async (opts: { dryRun?: boolean }) => {
const { updateCommand } = await import("./commands/update.js");
await updateCommand({ dryRun: !!opts.dryRun });
});
@@ -449,6 +446,7 @@ program
ollamaUrl?: string;
yes?: boolean;
}) => {
const { indexCommand } = await import("./commands/index.js");
await indexCommand(opts);
},
);
+1 -15
View File
@@ -6,17 +6,12 @@ import { resolvePreset } from "./ui/presets.js";
export interface ResolvedDefaults {
model: string;
reasoningEffort: "high" | "max";
harvest: boolean;
branch: number | undefined;
mcp: string[];
session: string | undefined;
}
export interface RawCliFlags {
model?: string;
harvest?: boolean;
/** From `commander`; already parseInt'd. */
branch?: number;
mcp?: string[];
/** Commander's `--no-session` surfaces as `false`; `--session X` as a string. */
session?: string | false;
@@ -33,9 +28,6 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
const model = flags.model ?? presetSettings.model;
const reasoningEffort = presetSettings.reasoningEffort;
const harvest = flags.harvest === true ? true : presetSettings.harvest;
const branchFromFlag = normalizeBranch(flags.branch);
const branch = branchFromFlag ?? (presetSettings.branch > 1 ? presetSettings.branch : undefined);
// `--mcp` accumulator is [] when absent. Treat empty from flags as
// "user didn't pass" → fall through to config. Users who explicitly
@@ -44,7 +36,7 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
const session = resolveSession(flags.session, cfg.session);
return { model, reasoningEffort, harvest, branch, mcp, session };
return { model, reasoningEffort, mcp, session };
}
function pickPreset(
@@ -69,12 +61,6 @@ function isPresetName(s: string): s is PresetName {
);
}
function normalizeBranch(raw: number | undefined): number | undefined {
if (raw === undefined) return undefined;
if (!Number.isFinite(raw) || raw <= 1) return undefined;
return Math.min(raw, 8);
}
function resolveSession(
flag: string | false | undefined,
configSession: string | null | undefined,
+47
View File
@@ -0,0 +1,47 @@
import { performance } from "node:perf_hooks";
interface PhaseMark {
name: string;
t: number;
}
const marks: PhaseMark[] = [];
let dumped = false;
function envFlag(): boolean {
const v = process.env.REASONIX_PROFILE_STARTUP;
return v === "1" || v === "true" || v === "yes";
}
export function isStartupProfileEnabled(): boolean {
return envFlag();
}
export function markPhase(name: string): void {
if (!envFlag()) return;
marks.push({ name, t: performance.now() });
}
export function dumpStartupProfile(stream: NodeJS.WriteStream = process.stderr): void {
if (!envFlag() || dumped || marks.length === 0) return;
dumped = true;
const totalMs = marks[marks.length - 1]!.t;
const widest = String(Math.round(totalMs)).length;
const lines: string[] = ["[startup-profile]"];
let prev = 0;
for (const m of marks) {
const cum = Math.round(m.t).toString().padStart(widest);
const delta = Math.round(m.t - prev);
lines.push(` ${cum}ms ${m.name.padEnd(28)} (+${delta})`);
prev = m.t;
}
lines.push(
`─── ${Math.round(totalMs)}ms total · last phase ${marks[marks.length - 1]!.name} · set REASONIX_PROFILE_STARTUP=0 to silence`,
);
stream.write(`${lines.join("\n")}\n`);
}
export function _resetForTests(): void {
marks.length = 0;
dumped = false;
}
+471 -150
View File
File diff suppressed because it is too large. Load diff
+119 -77
View File
@@ -1,104 +1,146 @@
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 { COLOR, GLYPH } from "./theme.js";
import { GLYPH, useColor } from "./theme.js";
import type { AtPickerEntry, AtPickerState } from "./useCompletionPickers.js";
export interface AtMentionSuggestionsProps {
/**
* Current matching file paths, ranked by the picker. `null` means
* "not in @-prefix mode" — render nothing. Empty array means "in @
* mode but no files match that partial" — render a hint.
*/
matches: readonly string[] | null;
/** Index (within `matches`) of the currently highlighted row. */
state: AtPickerState | null;
selectedIndex: number;
/** The partial query the user typed after `@`. Shown in the hint row. */
query: string;
}
/**
* `@`-mention picker. Rendered below the input box while the user is
* typing an `@…` prefix in code mode. Visual grammar matches the
* design's file picker:
*
* @ files matching "auth" esc
* ▸ login.ts src/auth/
* refresh.ts src/auth/
* validators.ts src/auth/
* auth.test.ts tests/
* [↑↓] navigate · Tab / ⏎ insert as @path · file content inlined
*
* Basename in primary cyan (the user's eye lands here first), dir
* suffix in dim info color. No solid-bg pill on the selected row —
* leading ▸ + bold + brighter color does the same job without the
* loud bg block. Mirrors {@link SlashSuggestions}.
*/
const ROW_WINDOW = 8;
export function AtMentionSuggestions({
matches,
state,
selectedIndex,
query,
}: AtMentionSuggestionsProps): React.ReactElement | null {
if (matches === null) return null;
if (matches.length === 0) {
const color = useColor();
if (!state) return null;
const isBrowse = state.kind === "browse";
const entries = state.entries;
const total = entries.length;
const windowStart =
total <= ROW_WINDOW
? 0
: Math.max(0, Math.min(selectedIndex - Math.floor(ROW_WINDOW / 2), total - ROW_WINDOW));
const shown = entries.slice(windowStart, windowStart + ROW_WINDOW);
const hiddenAbove = windowStart;
const hiddenBelow = total - windowStart - shown.length;
return (
<Box flexDirection="column" paddingX={1} marginTop={1}>
<HeaderRow state={state} hiddenAbove={hiddenAbove} />
{total === 0 ? <EmptyRow state={state} color={color} /> : null}
{shown.map((entry, i) => (
<EntryRow
key={`${entry.insertPath}:${entry.isDir ? "d" : "f"}`}
entry={entry}
isSelected={windowStart + i === selectedIndex}
/>
))}
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
<FooterRow isBrowse={isBrowse} hasFolder={shown.some((e) => e.isDir)} />
</Box>
);
}
function HeaderRow({
state,
hiddenAbove,
}: {
state: AtPickerState;
hiddenAbove: number;
}) {
const color = useColor();
const total = state.entries.length;
const lead = (
<Text color={color.primary} bold>
{"@ "}
</Text>
);
if (state.kind === "browse") {
const where = state.baseDir === "" ? "/" : `${state.baseDir}/`;
const counter = state.loading ? "loading…" : `${total} ${total === 1 ? "entry" : "entries"}`;
return (
<Box paddingX={1} marginTop={1}>
<Text color={COLOR.warn} bold>
{GLYPH.warn}
</Text>
<Text> </Text>
<Text color={COLOR.warn}>{`no files match "@${query}"`}</Text>
<Text dimColor>{" — keep typing or Backspace; paths resolve from the code root"}</Text>
<Box>
{lead}
<Text dimColor>{`${where} ${counter}`}</Text>
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
);
}
const MAX = 8;
const total = matches.length;
const windowStart =
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
const shown = matches.slice(windowStart, windowStart + MAX);
const hiddenAbove = windowStart;
const hiddenBelow = total - windowStart - shown.length;
const status = state.searching
? `searching… ${state.scanned} scanned · ${total} ${total === 1 ? "match" : "matches"}`
: `${total} ${total === 1 ? "match" : "matches"} for "${state.filter}"`;
return (
<Box flexDirection="column" paddingX={1} marginTop={1}>
<Box>
<Text color={COLOR.primary} bold>
{"@ "}
</Text>
<Text dimColor>
{query
? `${total} match${total === 1 ? "" : "es"} for "${query}"`
: `${total} file${total === 1 ? "" : "s"}`}
</Text>
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
{shown.map((path, i) => (
<FileRow key={path} path={path} isSelected={windowStart + i === selectedIndex} />
))}
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
<Box marginTop={0}>
<Text dimColor>{" ↑↓ navigate · Tab / ⏎ insert as @path · esc cancel"}</Text>
</Box>
<Box>
{lead}
<Text dimColor>{status}</Text>
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
);
}
function FileRow({ path, isSelected }: { path: string; isSelected: boolean }) {
// Split the path so the basename visually pops — same dropdown
// affordance as VS Code's Quick Open. Basename in primary cyan
// (selected: bold), dir suffix in dim info color so the eye lands
// on the filename first and uses the path only as a disambiguator.
const slash = path.lastIndexOf("/");
const dir = slash >= 0 ? `${path.slice(0, slash)}/` : "";
const base = slash >= 0 ? path.slice(slash + 1) : path;
function EmptyRow({ state, color }: { state: AtPickerState; color: ReturnType<typeof useColor> }) {
if (state.kind === "browse") {
if (state.loading) return null;
return (
<Box>
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text> </Text>
<Text color={color.warn}>empty directory</Text>
</Box>
);
}
if (state.searching) {
return (
<Box>
<Text dimColor>scanning the tree…</Text>
</Box>
);
}
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
{isSelected ? `${GLYPH.cur} ` : " "}
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text color={COLOR.primary} bold={isSelected}>
{base.padEnd(20)}
<Text> </Text>
<Text color={color.warn}>{`no files match "${state.filter}"`}</Text>
</Box>
);
}
function EntryRow({ entry, isSelected }: { entry: AtPickerEntry; isSelected: boolean }) {
const color = useColor();
const cursor = isSelected ? `${GLYPH.cur} ` : " ";
const labelColor = entry.isDir ? color.accent : color.primary;
const labelText = entry.isDir ? `${entry.label}/` : entry.label;
return (
<Box>
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
{cursor}
</Text>
{dir ? <Text dimColor>{` ${dir}`}</Text> : null}
<Text color={labelColor} bold={isSelected}>
{labelText.padEnd(20)}
</Text>
{entry.dirSuffix ? <Text dimColor>{` ${entry.dirSuffix}`}</Text> : null}
</Box>
);
}
function FooterRow({ isBrowse, hasFolder }: { isBrowse: boolean; hasFolder: boolean }) {
const hint = isBrowse
? hasFolder
? "↑↓ navigate · Tab drill into folder · ⏎ insert · esc cancel"
: "↑↓ navigate · Tab / ⏎ insert as @path · esc cancel"
: "↑↓ navigate · Tab / ⏎ insert as @path · esc cancel";
return (
<Box marginTop={0}>
<Text dimColor>{` ${hint}`}</Text>
</Box>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useMemo, useState } from "react";
import type { CheckpointMeta } from "../../code/checkpoints.js";
import { fmtAgo } from "../../code/checkpoints.js";
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import { FG, TONE } from "./theme/tokens.js";
export type CheckpointPickerOutcome =
| { kind: "restore"; id: string }
| { kind: "delete"; id: string }
| { kind: "quit" };
export interface CheckpointPickerProps {
checkpoints: ReadonlyArray<CheckpointMeta>;
workspace: string;
onChoose: (outcome: CheckpointPickerOutcome) => void;
pickerPorts?: PickerBroadcastPorts;
}
const PAGE_MARGIN = 6;
export function CheckpointPicker({
checkpoints,
workspace,
onChoose,
pickerPorts,
}: CheckpointPickerProps): React.ReactElement {
const [focus, setFocus] = useState(0);
const { stdout } = useStdout();
const rows = stdout?.rows ?? 40;
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
const snapshot = useMemo(
() => ({
pickerKind: "checkpoints",
title: `restore a checkpoint — ${workspace}`,
items: checkpoints.map((c) => {
const sizeKb = (c.bytes / 1024).toFixed(1);
const tag = c.source === "manual" ? "" : ` (${c.source})`;
return {
id: c.id,
title: `${c.name}${tag}`,
subtitle: `${c.fileCount} file${c.fileCount === 1 ? "" : "s"} · ${sizeKb} KB`,
badge: c.id.slice(0, 7),
meta: fmtAgo(c.createdAt),
};
}),
actions: ["pick", "delete", "cancel"] as const,
hint: "↑↓ pick · ⏎ restore · [d] forget · esc quit",
}),
[checkpoints, workspace],
);
usePickerBroadcast(
!!pickerPorts,
{
...snapshot,
actions: [...snapshot.actions],
},
(res) => {
if (res.action === "pick") return onChoose({ kind: "restore", id: res.id });
if (res.action === "delete") return onChoose({ kind: "delete", id: res.id });
if (res.action === "cancel") return onChoose({ kind: "quit" });
},
pickerPorts ?? {
broadcast: () => undefined,
resolverRef: { current: null },
snapshotRef: { current: null },
},
);
useKeystroke((ev) => {
if (ev.escape) return onChoose({ kind: "quit" });
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
if (ev.downArrow) return setFocus((f) => Math.min(checkpoints.length - 1, f + 1));
if (checkpoints.length === 0) {
if (ev.return) return onChoose({ kind: "quit" });
return;
}
const target = checkpoints[focus];
if (!target) return;
if (ev.return) return onChoose({ kind: "restore", id: target.id });
if (ev.input === "q") return onChoose({ kind: "quit" });
if (ev.input === "d") return onChoose({ kind: "delete", id: target.id });
});
const start = Math.max(
0,
Math.min(focus - Math.floor(visibleCount / 2), checkpoints.length - visibleCount),
);
const end = Math.min(checkpoints.length, start + visibleCount);
const shown = checkpoints.slice(start, end);
const hiddenBelow = checkpoints.length - end;
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text bold color={TONE.brand}>
{" ◈ REASONIX · pick a checkpoint "}
</Text>
<Text color={FG.meta}>{` · ${workspace}`}</Text>
</Box>
<Box height={1} />
{checkpoints.length === 0 ? (
<Box>
<Text color={FG.faint}>
{" no checkpoints in this workspace yet — see /checkpoint to make one"}
</Text>
</Box>
) : (
shown.map((c, i) => <CheckpointRow key={c.id} info={c} focused={start + i === focus} />)
)}
{hiddenBelow > 0 ? (
<Box>
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
</Box>
) : null}
<Box marginTop={1}>
<Text color={FG.faint}>
{checkpoints.length === 0
? " esc quit"
: " ↑↓ pick · ⏎ restore · [d] forget · esc quit"}
</Text>
</Box>
</Box>
);
}
function CheckpointRow({
info,
focused,
}: {
info: CheckpointMeta;
focused: boolean;
}): React.ReactElement {
const tag = info.source === "manual" ? "" : ` (${info.source})`;
const sizeKb = (info.bytes / 1024).toFixed(1);
const time = fmtAgo(info.createdAt);
return (
<Box>
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
<Text color={FG.meta}>{info.id.slice(0, 7).padEnd(8)}</Text>
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
{info.name}
</Text>
<Text color={FG.faint}>{tag}</Text>
<Box flexGrow={1} />
<Text color={FG.faint}>{`${time.padStart(8)} · `}</Text>
<Text
color={FG.faint}
>{`${info.fileCount} file${info.fileCount === 1 ? "" : "s"}, ${sizeKb} KB`}</Text>
</Box>
);
}
+10 -1
View File
@@ -4,6 +4,7 @@ import { Box, Text } from "ink";
import React, { useState } from "react";
import { McpBrowser } from "./McpBrowser.js";
import { McpMarketplace } from "./McpMarketplace.js";
import type { PickerBroadcastPorts } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import type { ApplyAppend } from "./mcp-reconnect-kickoff.js";
import type { McpServerSummary } from "./slash/types.js";
@@ -23,6 +24,8 @@ export interface McpHubProps {
removed: string[];
failed: Array<{ spec: string; reason: string }>;
}>;
/** Forwarded to the marketplace tab so the web dashboard can drive install / uninstall / refine / load-more. */
pickerPorts?: PickerBroadcastPorts;
}
export function McpHub({
@@ -33,6 +36,7 @@ export function McpHub({
postInfo,
applyAppend,
reloadMcp,
pickerPorts,
}: McpHubProps) {
const [tab, setTab] = useState<McpHubTab>(initialTab);
@@ -64,7 +68,12 @@ export function McpHub({
applyAppend={applyAppend}
/>
) : (
<McpMarketplace onClose={onClose} postInfo={postInfo} reloadMcp={reloadMcp} />
<McpMarketplace
onClose={onClose}
postInfo={postInfo}
reloadMcp={reloadMcp}
pickerPorts={pickerPorts}
/>
)}
</Box>
);
+120 -27
View File
@@ -11,6 +11,7 @@ import {
specStringFor,
} from "../../mcp/registry-fetch.js";
import type { RegistryEntry } from "../../mcp/registry-types.js";
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import { COLOR } from "./theme.js";
@@ -26,6 +27,7 @@ export interface McpMarketplaceProps {
removed: string[];
failed: Array<{ spec: string; reason: string }>;
}>;
pickerPorts?: PickerBroadcastPorts;
}
interface State {
@@ -38,6 +40,39 @@ interface State {
installedSpecs: string[];
}
export function buildMarketplacePickerSnapshot(args: {
filtered: RegistryEntry[];
installedSpecs: string[];
query: string;
status: string;
hasMore: boolean;
}) {
return {
pickerKind: "mcp-marketplace" as const,
title: `MCP marketplace · ${args.status}`,
query: args.query,
items: args.filtered.map((e) => {
const installedSpec = isInstalled(args.installedSpecs, e);
return {
id: e.name,
title: e.title || e.name,
subtitle: e.description?.slice(0, 200) ?? undefined,
badge: installedSpec
? "installed"
: e.source === "official"
? "official"
: e.source === "smithery"
? "smithery"
: "local",
meta: e.popularity !== undefined ? `★ ${e.popularity.toLocaleString()}` : undefined,
};
}),
actions: ["install", "uninstall", "refine", "load-more", "cancel"] as const,
hasMore: args.hasMore,
hint: "type filter · ↑↓ pick · ⏎ install/toggle · PgDn load more · esc close",
};
}
function rankAndFilter(entries: RegistryEntry[], query: string): RegistryEntry[] {
const q = query.trim().toLowerCase();
const list = q
@@ -65,7 +100,7 @@ function isInstalled(installedSpecs: string[], entry: RegistryEntry): string | n
}
}
export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceProps) {
export function McpMarketplace({ onClose, postInfo, reloadMcp, pickerPorts }: McpMarketplaceProps) {
const [state, setState] = useState<State>({
handle: null,
loading: true,
@@ -127,33 +162,30 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
}
}, [state.handle, state.loading]);
const installOrToggle = useCallback(
async (entry: RegistryEntry) => {
const installed = isInstalled(state.installedSpecs, entry);
if (installed) {
const cfg = readConfig();
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
writeConfig({ ...cfg, mcp: next });
setState((s) => ({
...s,
installedSpecs: next,
status: `uninstalled ${entry.name}`,
}));
if (reloadMcp) {
try {
await reloadMcp();
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
} catch (err) {
postInfo(
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
);
}
} else {
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
const doUninstall = useCallback(
async (entry: RegistryEntry, installed: string) => {
const cfg = readConfig();
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
writeConfig({ ...cfg, mcp: next });
setState((s) => ({ ...s, installedSpecs: next, status: `uninstalled ${entry.name}` }));
if (reloadMcp) {
try {
await reloadMcp();
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
} catch (err) {
postInfo(
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
);
}
return;
} else {
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
}
},
[postInfo, reloadMcp],
);
const doInstall = useCallback(
async (entry: RegistryEntry) => {
let install = entry.install;
if (!install && entry.source === "smithery") {
setState((s) => ({ ...s, loading: true, status: "fetching smithery detail…" }));
@@ -175,7 +207,6 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
}));
return;
}
try {
const spec = specStringFor(entry.name, install);
const cfg = readConfig();
@@ -215,7 +246,69 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
setState((s) => ({ ...s, status: `install failed: ${(err as Error).message}` }));
}
},
[state.installedSpecs, postInfo, reloadMcp],
[postInfo, reloadMcp],
);
const installOrToggle = useCallback(
async (entry: RegistryEntry) => {
const installed = isInstalled(state.installedSpecs, entry);
if (installed) await doUninstall(entry, installed);
else await doInstall(entry);
},
[state.installedSpecs, doInstall, doUninstall],
);
const pickerSnapshot = useMemo(
() =>
buildMarketplacePickerSnapshot({
filtered,
installedSpecs: state.installedSpecs,
query: state.query,
status: state.status,
hasMore: state.handle?.cache.pagination.nextCursor != null,
}),
[filtered, state.installedSpecs, state.handle, state.query, state.status],
);
usePickerBroadcast(
!!pickerPorts,
{ ...pickerSnapshot, actions: [...pickerSnapshot.actions] },
(res) => {
if (res.action === "cancel") return onClose();
if (res.action === "refine") {
setState((s) => ({ ...s, query: res.query, selected: 0 }));
return;
}
if (res.action === "load-more") {
void fetchMore();
return;
}
if (res.action === "install") {
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
if (!entry) return;
if (isInstalled(state.installedSpecs, entry)) {
setState((s) => ({ ...s, status: `already installed: ${entry.name}` }));
return;
}
void doInstall(entry);
return;
}
if (res.action === "uninstall") {
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
if (!entry) return;
const installed = isInstalled(state.installedSpecs, entry);
if (!installed) {
setState((s) => ({ ...s, status: `not installed: ${entry.name}` }));
return;
}
void doUninstall(entry, installed);
}
},
pickerPorts ?? {
broadcast: () => undefined,
resolverRef: { current: null },
snapshotRef: { current: null },
},
);
useKeystroke((ev) => {
+114 -20
View File
@@ -2,45 +2,66 @@ 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 { PRESETS, PRESET_DESCRIPTIONS } from "./presets.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 type ModelPickerOutcome =
| { kind: "select"; id: string }
| { kind: "preset"; name: "auto" | "flash" | "pro" }
| { 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;
/** Used to detect which preset (if any) the loop currently matches. */
currentEffort: "high" | "max";
currentAutoEscalate: boolean;
onChoose: (outcome: ModelPickerOutcome) => void;
/** Triggers a refetch when the catalog is null/empty and the user presses [r]. */
onRefresh?: () => void;
}
const PAGE_MARGIN = 6;
const PAGE_MARGIN = 8;
const PRESET_NAMES = ["auto", "flash", "pro"] as const;
type PresetName = (typeof PRESET_NAMES)[number];
type Row = { kind: "preset"; name: PresetName } | { kind: "model"; id: string };
export function ModelPicker({
models,
current,
currentEffort,
currentAutoEscalate,
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 modelList = (models && models.length > 0 ? models : FALLBACK_MODELS).slice();
if (!modelList.includes(current)) modelList.unshift(current);
const presetRows: Row[] = PRESET_NAMES.map((name) => ({ kind: "preset", name }));
const modelRows: Row[] = modelList.map((id) => ({ kind: "model", id }));
const rows: Row[] = [...presetRows, ...modelRows];
const activePreset = detectActivePreset(current, currentEffort, currentAutoEscalate);
const initialIndex = activePreset
? PRESET_NAMES.indexOf(activePreset)
: presetRows.length + Math.max(0, modelList.indexOf(current));
const [focus, setFocus] = useState(initialIndex);
const { stdout } = useStdout();
const rows = stdout?.rows ?? 40;
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
const termRows = stdout?.rows ?? 40;
const visibleCount = Math.max(6, termRows - 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.downArrow) return setFocus((f) => Math.min(rows.length - 1, f + 1));
if (ev.return) {
const target = list[focus];
if (target) onChoose({ kind: "select", id: target });
return;
const target = rows[focus];
if (!target) return;
if (target.kind === "preset") return onChoose({ kind: "preset", name: target.name });
return onChoose({ kind: "select", id: target.id });
}
if (!ev.input) return;
if (ev.input === "q") return onChoose({ kind: "quit" });
@@ -49,27 +70,29 @@ export function ModelPicker({
const start = Math.max(
0,
Math.min(focus - Math.floor(visibleCount / 2), list.length - visibleCount),
Math.min(focus - Math.floor(visibleCount / 2), rows.length - visibleCount),
);
const end = Math.min(list.length, start + visibleCount);
const shown = list.slice(start, end);
const end = Math.min(rows.length, start + visibleCount);
const shown = rows.slice(start, end);
const hiddenAbove = start;
const hiddenBelow = list.length - end;
const hiddenBelow = rows.length - end;
const loading = models === null;
const empty = models !== null && models.length === 0;
let lastSection: "preset" | "model" | null = null;
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text bold color={TONE.brand}>
{" ◈ REASONIX · pick a model "}
{" ◈ REASONIX · pick a setup "}
</Text>
<Text color={FG.meta}>
{loading
? " · loading catalog…"
: empty
? " · catalog empty — using known fallbacks"
: ` · ${list.length} available`}
: ` · ${modelList.length} models available`}
</Text>
</Box>
<Box height={1} />
@@ -78,9 +101,43 @@ export function ModelPicker({
<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} />
))}
{shown.map((row, i) => {
const idx = start + i;
const focused = idx === focus;
const showHeader = row.kind !== lastSection;
lastSection = row.kind;
const header = showHeader ? (
<Box key={`hdr-${row.kind}`} marginTop={idx === 0 ? 0 : 1}>
<Text color={FG.meta}>
{row.kind === "preset"
? " PRESETS · recommended — model + effort + auto-escalate"
: " MODELS · raw pick — auto-escalate stays as-is"}
</Text>
</Box>
) : null;
const body =
row.kind === "preset" ? (
<PresetRow
key={`p-${row.name}`}
name={row.name}
focused={focused}
active={activePreset === row.name}
/>
) : (
<ModelRow
key={`m-${row.id}`}
id={row.id}
focused={focused}
active={!activePreset && row.id === current}
/>
);
return (
<React.Fragment key={`row-${idx}`}>
{header}
{body}
</React.Fragment>
);
})}
{hiddenBelow > 0 ? (
<Box>
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
@@ -93,6 +150,29 @@ export function ModelPicker({
);
}
function PresetRow({
name,
focused,
active,
}: {
name: PresetName;
focused: boolean;
active: boolean;
}): React.ReactElement {
const desc = PRESET_DESCRIPTIONS[name];
return (
<Box>
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
{name.padEnd(8)}
</Text>
<Text color={focused ? FG.body : FG.meta}>{desc.headline.padEnd(28)}</Text>
<Text color={FG.meta}>{` ${desc.cost}`}</Text>
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
</Box>
);
}
function ModelRow({
id,
focused,
@@ -116,6 +196,20 @@ function ModelRow({
);
}
function detectActivePreset(
model: string,
effort: "high" | "max",
autoEscalate: boolean,
): PresetName | null {
for (const name of PRESET_NAMES) {
const p = PRESETS[name];
if (p.model === model && p.reasoningEffort === effort && p.autoEscalate === autoEscalate) {
return name;
}
}
return null;
}
/** 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",
-52
View File
@@ -1,52 +0,0 @@
/**
* Shared Ink block that renders a TypedPlanState. Used by RecordView in
* replay/diff TUIs.
*
* Colors are semantic (not decorative):
* - subgoals: cyan — structure / plan
* - hypotheses: green — current beliefs (like assistant)
* - uncertainties: yellow — attention required (like tool)
* - rejected paths: red dim — ruled out (muted, like error-but-resolved)
*
* Visual: colored bold label + count, then dim items. No solid-bg pill
* — every assistant turn surfaces this block, so the bracket-text style
* keeps it from shouting four colored bg blocks at the user every turn.
* Color alone (the bold label) is enough to identify the field type.
*/
import { Box, Text } from "ink";
import React from "react";
import type { TypedPlanState } from "../../harvest.js";
import { COLOR } from "./theme.js";
type FieldColor = string;
export function PlanStateBlock({ planState }: { planState: TypedPlanState }) {
const fields: Array<[string, string[], FieldColor, boolean]> = [];
if (planState.subgoals.length)
fields.push(["subgoals", planState.subgoals, COLOR.primary, false]);
if (planState.hypotheses.length)
fields.push(["hypotheses", planState.hypotheses, COLOR.assistant, false]);
if (planState.uncertainties.length)
fields.push(["uncertainties", planState.uncertainties, COLOR.warn, false]);
if (planState.rejectedPaths.length)
fields.push(["rejected", planState.rejectedPaths, COLOR.info, true]);
if (fields.length === 0) return null;
return (
<Box flexDirection="column" marginBottom={1}>
{fields.map(([label, items, color, dim]) => (
<Box key={label}>
<Text color={color} bold dimColor={dim}>
{label}
</Text>
<Text dimColor>{` (${items.length})`}</Text>
<Text dimColor>{" · "}</Text>
<Text dimColor={dim} color={dim ? undefined : COLOR.info}>
{items.join(" · ")}
</Text>
</Box>
))}
</Box>
);
}
+43 -19
View File
@@ -19,18 +19,22 @@ 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;
/** Ctrl+P / Ctrl+N — parent walks its prompt history and swaps `value` via `onChange`. */
/** ↑/↓ on an empty buffer or Ctrl+P / Ctrl+N — parent walks 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({
@@ -41,8 +45,6 @@ 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
@@ -74,15 +76,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) => {
@@ -135,8 +141,6 @@ 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 ──────────────────────────────────────────────────────
@@ -152,7 +156,7 @@ export function PromptInput({
// a just-cleared buffer and read as residual typed input on dim-poor terminals.
const effectivePlaceholder = disabled
? (placeholder ?? "…waiting for response…")
: (placeholder ?? "type a message · slash for commands · at-sign for files");
: (placeholder ?? "ask anything · slash for commands · at-sign for files");
const lines = value.length > 0 ? value.split("\n") : [""];
const accentColor = disabled ? FG.faint : TONE.brand;
@@ -284,9 +288,7 @@ export function PromptInput({
) : null}
{!disabled ? (
<Box marginTop={1}>
<Text color={FG.faint}>
{" ⏎ send · shift/alt+⏎ newline · ↑↓ history · esc abort · ctrl-c quit"}
</Text>
<HintRow />
</Box>
) : (
<Box marginTop={1}>
@@ -297,6 +299,28 @@ export function PromptInput({
);
}
function HintRow(): React.ReactElement {
const items: Array<{ key: string; sep: string }> = [
{ key: "⏎", sep: "send" },
{ key: "⇧⏎", sep: "newline" },
{ key: "↑↓", sep: "history" },
{ key: "esc", sep: "abort" },
{ key: "^C", sep: "quit" },
];
return (
<Box flexDirection="row">
<Text>{" "}</Text>
{items.map((item, i) => (
<React.Fragment key={item.key}>
{i > 0 && <Text color={FG.faint}>{" · "}</Text>}
<Text color={FG.meta}>{item.key}</Text>
<Text color={FG.faint}>{` ${item.sep}`}</Text>
</React.Fragment>
))}
</Box>
);
}
type LineSegment =
| { kind: "text"; text: string; startOffset: number }
| { kind: "paste"; id: number; startOffset: number };
+1 -11
View File
@@ -1,17 +1,8 @@
/**
* Shared renderer for a single TranscriptRecord. Used by ReplayApp and
* DiffApp — both need the same visual grammar (user cyan, assistant green,
* tool yellow, error red, cache badge colored by threshold) so transcripts
* look consistent wherever they're displayed.
*
* Kept small on purpose: no streaming/branch/planState paths (those are
* live-chat concerns and never appear in replayed transcripts).
*/
/** Shared renderer for a single TranscriptRecord — used by ReplayApp and DiffApp. */
import { Box, Text } from "ink";
import React from "react";
import type { TranscriptRecord } from "../../transcript/log.js";
import { PlanStateBlock } from "./PlanStateBlock.js";
export interface RecordViewProps {
rec: TranscriptRecord;
@@ -58,7 +49,6 @@ export function RecordView({ rec, compact = false }: RecordViewProps) {
) : null}
{rec.usage ? <CacheBadge usage={rec.usage} /> : null}
</Box>
{rec.planState ? <PlanStateBlock planState={rec.planState} /> : null}
{rec.content ? (
<Text>{rec.content}</Text>
) : (
+9 -4
View File
@@ -3,7 +3,7 @@
import { Box, Text } from "ink";
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { COLOR } from "./theme.js";
import { type UiColor, useColor } from "./theme.js";
export interface SelectItem<V extends string = string> {
value: V;
@@ -33,6 +33,7 @@ export function SingleSelect<V extends string>({
onCancel,
footer,
}: SingleSelectProps<V>) {
const color = useColor();
const initialIndex = Math.max(
0,
items.findIndex((i) => i.value === initialValue && !i.disabled),
@@ -64,6 +65,7 @@ export function SingleSelect<V extends string>({
item={item}
active={i === index}
marker={i === index ? "▸" : " "}
color={color}
/>
))}
{footer ? (
@@ -91,6 +93,7 @@ export function MultiSelect<V extends string>({
onCancel,
footer,
}: MultiSelectProps<V>) {
const color = useColor();
const [index, setIndex] = useState(() => {
const first = items.findIndex((i) => !i.disabled);
return first === -1 ? 0 : first;
@@ -113,7 +116,6 @@ export function MultiSelect<V extends string>({
return next;
});
} else if (ev.return) {
// Catalog order keeps the resulting config.json diff stable across reruns.
const ordered = items.filter((i) => selected.has(i.value)).map((i) => i.value);
onSubmit(ordered);
} else if (ev.escape && onCancel) {
@@ -132,6 +134,7 @@ export function MultiSelect<V extends string>({
item={item}
active={i === index}
marker={`${i === index ? "▸" : " "} ${marker}`}
color={color}
/>
);
})}
@@ -148,16 +151,18 @@ function SelectRow<V extends string>({
item,
active,
marker,
color,
}: {
item: SelectItem<V>;
active: boolean;
marker: string;
color: UiColor;
}) {
const color = item.disabled ? COLOR.info : active ? COLOR.primary : undefined;
const rowColor = item.disabled ? color.info : active ? color.primary : undefined;
return (
<Box flexDirection="column">
<Box>
<Text color={color} bold={active} dimColor={item.disabled}>
<Text color={rowColor} bold={active} dimColor={item.disabled}>
{marker} {item.label}
</Text>
</Box>
+52 -1
View File
@@ -1,7 +1,8 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import type { SessionInfo } from "../../memory/session.js";
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import { FG, TONE, formatCost } from "./theme/tokens.js";
@@ -18,6 +19,8 @@ export interface SessionPickerProps {
onChoose: (outcome: SessionPickerOutcome) => void;
/** Live wallet currency from App.tsx; falls back to each session's stored `meta.balanceCurrency` per row. */
walletCurrency?: string;
/** When provided, broadcasts to the web dashboard so it can resolve via `/api/modal/resolve`. */
pickerPorts?: PickerBroadcastPorts;
}
const PAGE_MARGIN = 6;
@@ -27,6 +30,7 @@ export function SessionPicker({
workspace,
onChoose,
walletCurrency,
pickerPorts,
}: SessionPickerProps): React.ReactElement {
const [focus, setFocus] = useState(0);
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
@@ -34,6 +38,53 @@ export function SessionPicker({
const rows = stdout?.rows ?? 40;
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
const snapshot = useMemo(
() => ({
pickerKind: "sessions",
title: `pick a session — ${workspace}`,
items: sessions.map((s) => {
const branch = s.meta.branch ?? "main";
const summary =
s.meta.summary ?? `${s.messageCount} message${s.messageCount === 1 ? "" : "s"}`;
const turns = s.meta.turnCount ?? Math.ceil(s.messageCount / 2);
const currency = walletCurrency ?? s.meta.balanceCurrency;
const costLabel =
s.meta.totalCostUsd !== undefined ? formatCost(s.meta.totalCostUsd, currency, 2) : "";
return {
id: s.name,
title: s.name,
subtitle: summary,
badge: branch,
meta: costLabel ? `${turns} turns · ${costLabel}` : `${turns} turns`,
};
}),
actions: ["pick", "delete", "rename", "new", "cancel"] as const,
hint: "↑↓ pick · ⏎ open · [n] new · [d] delete · [r] rename · esc quit",
}),
[sessions, workspace, walletCurrency],
);
usePickerBroadcast(
!!pickerPorts,
{
...snapshot,
actions: [...snapshot.actions],
},
(res) => {
if (res.action === "pick") return onChoose({ kind: "open", name: res.id });
if (res.action === "delete") return onChoose({ kind: "delete", name: res.id });
if (res.action === "rename")
return onChoose({ kind: "rename", name: res.id, newName: res.text });
if (res.action === "new") return onChoose({ kind: "new" });
if (res.action === "cancel") return onChoose({ kind: "quit" });
},
pickerPorts ?? {
broadcast: () => undefined,
resolverRef: { current: null },
snapshotRef: { current: null },
},
);
useKeystroke((ev) => {
if (ev.paste) {
if (renaming) setRenaming({ ...renaming, buf: renaming.buf + ev.input });
+9 -12
View File
@@ -2,7 +2,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 type { SlashCommandSpec } from "./slash.js";
import { COLOR, GLYPH } from "./theme.js";
import { GLYPH, useColor } from "./theme.js";
export interface SlashArgPickerProps {
/**
@@ -37,16 +37,13 @@ export function SlashArgPicker({
kind,
partial,
}: SlashArgPickerProps): React.ReactElement | null {
// Header row shared by both hint and picker modes — same shape so
// the UI looks like the same affordance, just with or without rows
// beneath. cmd in accent violet (matches SlashSuggestions), argsHint
// in dim, summary in info.
const color = useColor();
const headerRow = (
<Box>
<Text color={COLOR.accent} bold>
<Text color={color.accent} bold>
{"/ "}
</Text>
<Text color={COLOR.accent} bold>
<Text color={color.accent} bold>
{`/${spec.cmd}`}
</Text>
{spec.argsHint ? <Text dimColor>{` ${spec.argsHint}`}</Text> : null}
@@ -54,7 +51,6 @@ export function SlashArgPicker({
</Box>
);
// Hint mode: header only.
if (kind === "hint") {
return (
<Box paddingX={1} marginTop={1}>
@@ -69,10 +65,10 @@ export function SlashArgPicker({
<Box flexDirection="column" paddingX={1} marginTop={1}>
{headerRow}
<Box>
<Text color={COLOR.warn} bold>
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text color={COLOR.warn}>{` no match for "${partial}"`}</Text>
<Text color={color.warn}>{` no match for "${partial}"`}</Text>
<Text dimColor>{" — keep typing, or Backspace to edit"}</Text>
</Box>
</Box>
@@ -102,12 +98,13 @@ export function SlashArgPicker({
}
function ArgRow({ value, isSelected }: { value: string; isSelected: boolean }) {
const color = useColor();
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
{isSelected ? `${GLYPH.cur} ` : " "}
</Text>
<Text color={isSelected ? COLOR.user : COLOR.info} bold={isSelected} dimColor={!isSelected}>
<Text color={isSelected ? color.user : color.info} bold={isSelected} dimColor={!isSelected}>
{value}
</Text>
</Box>
+51 -38
View File
@@ -2,77 +2,89 @@ 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 { t } from "../../i18n/index.js";
import type { SlashCommandSpec } from "./slash.js";
import { COLOR, GLYPH } from "./theme.js";
import type { SlashCommandSpec, SlashGroup } from "./slash.js";
import { GLYPH, useColor } from "./theme.js";
export interface SlashSuggestionsProps {
/**
* Current matching suggestions, computed by the parent. `null` means
* "not in slash-prefix mode" — render nothing. Empty array means "in
* slash mode but no matches" — render the "no matches" hint.
*/
matches: SlashCommandSpec[] | null;
/** Index (within `matches`) of the currently highlighted row. */
selectedIndex: number;
/** True when input is a bare `/` — render section headers + advanced footer. */
groupMode?: boolean;
/** Count of hidden `advanced` commands; rendered as a footer hint when groupMode is true. */
advancedHidden?: number;
}
/**
* Slash-command palette. Rendered below the input box while the user
* is typing a `/…` prefix. Visual grammar matches the design doc's
* picker style:
*
* / suggestions esc cancel
* ▸ /checkpoint [name] snapshot the workspace
* /restore <name> roll back files to a checkpoint
* /diff cumulative diff vs session start
* [↑↓] navigate · [Tab/⏎] pick
*
* Cmd tokens render in accent violet (one consistent color for "this
* is a command"), descriptions in dim info color. The selected row
* gets a leading ▸ + bold cmd; non-selected rows get a 2-space pad in
* the same column so the cmd column stays aligned. No solid-bg pill —
* the bracket-text pattern from the chrome carries through here.
*/
const GROUP_LABEL: Record<SlashGroup, string> = {
chat: "CHAT",
setup: "SETUP",
info: "INFO",
session: "SESSION",
extend: "EXTEND",
code: "CODE",
jobs: "JOBS",
advanced: "ADVANCED",
};
export function SlashSuggestions({
matches,
selectedIndex,
groupMode,
advancedHidden,
}: SlashSuggestionsProps): React.ReactElement | null {
const color = useColor();
if (matches === null) return null;
if (matches.length === 0) {
return (
<Box paddingX={1} marginTop={1}>
<Text color={COLOR.warn} bold>
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text> </Text>
<Text color={COLOR.warn}>no slash command matches that prefix</Text>
<Text color={color.warn}>no slash command matches that prefix</Text>
<Text dimColor>{" — Backspace to edit, or /help for the full list"}</Text>
</Box>
);
}
// Limit rows so the suggestion list never dwarfs the rest of the
// UI. Keep the currently-selected row in view by sliding the
// window when the selection is near either edge.
const MAX = 8;
const MAX = groupMode ? 24 : 8;
const total = matches.length;
const windowStart =
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
const shown = matches.slice(windowStart, windowStart + MAX);
const hiddenAbove = windowStart;
const hiddenBelow = total - windowStart - shown.length;
let lastGroup: SlashGroup | null = null;
if (windowStart > 0) lastGroup = matches[windowStart - 1]?.group ?? null;
return (
<Box flexDirection="column" paddingX={1} marginTop={1}>
<Box>
<Text color={COLOR.accent} bold>
<Text color={color.accent} bold>
{"/ "}
</Text>
<Text dimColor>{`${total} command${total === 1 ? "" : "s"}`}</Text>
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
{shown.map((spec, i) => (
<SuggestionRow key={spec.cmd} spec={spec} isSelected={windowStart + i === selectedIndex} />
))}
{shown.map((spec, i) => {
const idx = windowStart + i;
const showHeader = groupMode && spec.group !== lastGroup;
lastGroup = spec.group;
return (
<React.Fragment key={spec.cmd}>
{showHeader ? (
<Box marginTop={idx === 0 ? 0 : 1}>
<Text dimColor>{` ${GROUP_LABEL[spec.group]}`}</Text>
</Box>
) : null}
<SuggestionRow spec={spec} isSelected={idx === selectedIndex} />
</React.Fragment>
);
})}
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
{groupMode && advancedHidden && advancedHidden > 0 ? (
<Box marginTop={1}>
<Text dimColor>{` + ${advancedHidden} advanced · type a letter to search`}</Text>
</Box>
) : null}
<Box marginTop={0}>
<Text dimColor>{" ↑↓ navigate · Tab / ⏎ pick · esc cancel"}</Text>
</Box>
@@ -81,6 +93,7 @@ export function SlashSuggestions({
}
function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelected: boolean }) {
const color = useColor();
const name = `/${spec.cmd}`;
const argsSuffix = spec.argsHint ? spec.argsHint : "";
const key = `slash.${spec.cmd}.description`;
@@ -89,15 +102,15 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
{isSelected ? `${GLYPH.cur} ` : " "}
</Text>
<Text color={COLOR.accent} bold={isSelected}>
<Text color={color.accent} bold={isSelected}>
{name.padEnd(14)}
</Text>
<Text dimColor>{argsSuffix.padEnd(14)}</Text>
<Text>{" "}</Text>
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
<Text color={isSelected ? color.user : color.info} dimColor={!isSelected}>
{summary}
</Text>
{aliasHint ? <Text dimColor>{aliasHint}</Text> : null}
+120 -61
View File
@@ -1,21 +1,17 @@
/**
* First-run / re-configure wizard.
*
* Walks a new user through: API key → preset pick → MCP server pick →
* per-server args → save. Saved output lives in `~/.reasonix/config.json`
* so the next `reasonix chat` starts with everything already wired.
*
* The wizard is the antidote to "too many CLI flags" — a new user should
* never have to read `--help` to get MCP + a sensible model combo
* working. Everything a user could set via `--mcp`, `--harvest`,
* `--branch`, etc. can be picked here in a few arrow-key presses.
* Walks a new user through: language → API key → preset pick → MCP server
* pick → per-server args → save. Saved output lives in
* `~/.reasonix/config.json` so the next `reasonix chat` starts with
* everything already wired.
*/
import { mkdirSync, statSync } from "node:fs";
import { Box, Text, useApp, useInput } from "ink";
import TextInput from "ink-text-input";
// biome-ignore lint/style/useImportType: JSX (jsx: "react") needs React as a value at runtime
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import {
type PresetName,
type ReasonixConfig,
@@ -25,6 +21,16 @@ import {
redactKey,
writeConfig,
} from "../../config.js";
import {
detectSystemLanguage,
getLanguage,
getSupportedLanguages,
notifyLanguageChange,
onLanguageChange,
setLanguage,
t,
} from "../../i18n/index.js";
import type { LanguageCode } from "../../i18n/types.js";
import { type CatalogEntry, MCP_CATALOG } from "../../mcp/catalog.js";
import { MultiSelect, type SelectItem, SingleSelect } from "./Select.js";
import { PRESET_DESCRIPTIONS } from "./presets.js";
@@ -43,22 +49,31 @@ export interface WizardProps {
};
}
type Step = "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
type Step = "language" | "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
interface WizardData {
language: LanguageCode;
apiKey: string;
preset: PresetName;
selectedCatalog: string[]; // entries from MCP_CATALOG by `name`
/** Captured user inputs per catalog entry that has `userArgs` (e.g. fs dir). */
selectedCatalog: string[];
catalogArgs: Record<string, string>;
}
const CATALOG_BY_NAME = new Map(MCP_CATALOG.map((e) => [e.name, e]));
const LANGUAGE_LABELS: Record<LanguageCode, string> = {
EN: "English",
"zh-CN": "简体中文",
};
export function Wizard({ onComplete, onCancel, existingApiKey, initial }: WizardProps) {
const { exit } = useApp();
const [step, setStep] = useState<Step>(existingApiKey ? "preset" : "apiKey");
const [, setLanguageVersion] = useState(0);
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
const [step, setStep] = useState<Step>("language");
const [data, setData] = useState<WizardData>({
language: getLanguage(),
apiKey: existingApiKey ?? "",
preset: initial?.preset ?? "auto",
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
@@ -66,12 +81,24 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
});
const [error, setError] = useState<string | null>(null);
// Global Esc → cancel. Disabled once we've started saving to avoid
// ejecting out of a half-written state.
useInput((_input, key) => {
if (key.escape && step !== "saved" && onCancel) onCancel();
});
if (step === "language") {
return (
<LanguageStep
initialValue={data.language}
onSubmit={(lang) => {
setLanguage(lang);
notifyLanguageChange();
setData((d) => ({ ...d, language: lang }));
setStep(existingApiKey ? "preset" : "apiKey");
}}
/>
);
}
if (step === "apiKey") {
return (
<ApiKeyStep
@@ -88,7 +115,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
if (step === "preset") {
return (
<StepFrame title="Pick a preset" step={1} total={3}>
<StepFrame title={t("wizard.presetTitle")} step={1} total={3}>
<SingleSelect<PresetName>
items={presetItems()}
initialValue={data.preset}
@@ -98,7 +125,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
}}
/>
<Box marginTop={1}>
<Text dimColor>[↑↓] navigate · [Enter] confirm · [Esc] cancel</Text>
<Text dimColor>{t("wizard.selectFooter")}</Text>
</Box>
</StepFrame>
);
@@ -106,17 +133,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
if (step === "mcp") {
return (
<StepFrame title="Which MCP servers should Reasonix wire up for you?" step={2} total={3}>
<StepFrame title={t("wizard.mcpTitle")} step={2} total={3}>
<MultiSelect
items={mcpItems()}
initialSelected={data.selectedCatalog}
onSubmit={(selected) => {
setData((d) => ({ ...d, selectedCatalog: selected }));
// Only advance to the args step if any selected entry needs args.
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
setStep(needsArgs ? "mcpArgs" : "review");
}}
footer="[↑↓] navigate · [Space] toggle · [Enter] confirm · [Esc] cancel · empty = skip"
footer={t("wizard.mcpFooterMulti")}
/>
</StepFrame>
);
@@ -152,13 +178,21 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
if (step === "review") {
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
return (
<StepFrame title="Ready to save" step={3} total={3}>
<StepFrame title={t("wizard.reviewTitle")} step={3} total={3}>
<Box flexDirection="column">
<SummaryLine label="API key" value={redactKey(data.apiKey)} />
<SummaryLine label="Preset" value={data.preset} />
<SummaryLine
label="MCP"
value={specs.length === 0 ? "(none)" : `${specs.length} server(s)`}
label={t("wizard.reviewLabelLanguage")}
value={LANGUAGE_LABELS[data.language]}
/>
<SummaryLine label={t("wizard.reviewLabelApiKey")} value={redactKey(data.apiKey)} />
<SummaryLine label={t("wizard.reviewLabelPreset")} value={data.preset} />
<SummaryLine
label={t("wizard.reviewLabelMcp")}
value={
specs.length === 0
? t("wizard.reviewMcpNone")
: t("wizard.reviewMcpServers", { count: specs.length })
}
/>
{specs.map((spec, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
@@ -167,7 +201,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
</Box>
))}
<Box marginTop={1}>
<Text>Saves to {defaultConfigPath()}</Text>
<Text>{t("wizard.reviewSavesTo", { path: defaultConfigPath() })}</Text>
</Box>
{error ? (
<Box marginTop={1}>
@@ -175,7 +209,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
</Box>
) : null}
<Box marginTop={1}>
<Text dimColor>[Enter] save · [Esc] cancel</Text>
<Text dimColor>{t("wizard.reviewFooter")}</Text>
</Box>
</Box>
<ReviewConfirm
@@ -196,7 +230,7 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
setStep("saved");
onComplete(next);
} catch (e) {
setError(`Could not save config: ${(e as Error).message}`);
setError(t("wizard.reviewSaveError", { message: (e as Error).message }));
}
}}
/>
@@ -204,17 +238,16 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
);
}
// saved
return (
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
<Text bold color="green">
▸ Saved.
{t("wizard.savedTitle")}
</Text>
<Box marginTop={1}>
<Text>Run `reasonix` any time to start chatting — your settings are remembered.</Text>
<Text>{t("ui.welcome")}</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>[Enter] to exit</Text>
<Text dimColor>{t("wizard.savedFooter")}</Text>
</Box>
<ExitOnEnter onExit={exit} />
</Box>
@@ -223,6 +256,38 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
// ---------- step components ----------
function LanguageStep({
initialValue,
onSubmit,
}: {
initialValue: LanguageCode;
onSubmit: (lang: LanguageCode) => void;
}) {
const items: SelectItem<LanguageCode>[] = getSupportedLanguages().map((code) => ({
value: code,
label: LANGUAGE_LABELS[code],
hint: code === detectSystemLanguage() ? "(detected)" : undefined,
}));
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text bold color="cyan">
{t("wizard.languageTitle")}
</Text>
<Box marginTop={1}>
<Text dimColor>{t("wizard.languageSubtitle")}</Text>
</Box>
<Box marginTop={1}>
<SingleSelect<LanguageCode>
items={items}
initialValue={initialValue}
onSubmit={onSubmit}
footer={t("wizard.selectFooter")}
/>
</Box>
</Box>
);
}
function ApiKeyStep({
onSubmit,
error,
@@ -236,16 +301,16 @@ function ApiKeyStep({
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text bold color="cyan">
Welcome to Reasonix.
{t("wizard.welcomeTitle")}
</Text>
<Box marginTop={1}>
<Text>Paste your DeepSeek API key to get started.</Text>
<Text>{t("wizard.apiKeyPrompt")}</Text>
</Box>
<Text dimColor>Get one at: https://platform.deepseek.com/api_keys</Text>
<Text dimColor>Saved locally to {defaultConfigPath()}</Text>
<Text dimColor>{t("wizard.apiKeyGetOne")}</Text>
<Text dimColor>{t("wizard.apiKeySavedLocally", { path: defaultConfigPath() })}</Text>
<Box marginTop={1}>
<Text bold color="cyan">
{"key › "}
{t("wizard.apiKeyInputLabel")}
</Text>
<TextInput
value={value}
@@ -253,7 +318,7 @@ function ApiKeyStep({
onSubmit={(raw) => {
const trimmed = raw.trim();
if (!isPlausibleKey(trimmed)) {
onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
onError(t("wizard.apiKeyInvalid"));
setValue("");
return;
}
@@ -269,7 +334,7 @@ function ApiKeyStep({
</Box>
) : value ? (
<Box marginTop={1}>
<Text dimColor>preview: {redactKey(value)}</Text>
<Text dimColor>{t("wizard.apiKeyPreview", { redacted: redactKey(value) })}</Text>
</Box>
) : null}
</Box>
@@ -302,7 +367,12 @@ function McpArgsStep({
onError(null);
onSubmit(created);
} catch (e) {
onError(`Couldn't create ${pendingCreate}: ${(e as Error).message}`);
onError(
t("wizard.mcpArgsDirCreateFailed", {
path: pendingCreate,
message: (e as Error).message,
}),
);
setPendingCreate(null);
}
} else if (ch === "n" || key.escape) {
@@ -313,13 +383,11 @@ function McpArgsStep({
if (pendingCreate) {
return (
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
<Box flexDirection="column">
<Text>
Directory <Text bold>{pendingCreate}</Text> doesn't exist.
</Text>
<Text>{t("wizard.mcpArgsDirMissing", { path: pendingCreate })}</Text>
<Box marginTop={1}>
<Text dimColor>[Y/Enter] create it (mkdir -p) · [N/Esc] enter a different path</Text>
<Text dimColor>{t("wizard.mcpArgsDirCreateHint")}</Text>
</Box>
{error ? (
<Box marginTop={1}>
@@ -332,7 +400,7 @@ function McpArgsStep({
}
return (
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
<Box flexDirection="column">
<Text>{entry.summary}</Text>
{entry.note ? (
@@ -341,7 +409,7 @@ function McpArgsStep({
</Box>
) : null}
<Box marginTop={1}>
<Text>Required parameter: </Text>
<Text>{t("wizard.mcpArgsRequiredParam")}</Text>
<Text bold>{entry.userArgs}</Text>
</Box>
<Box marginTop={1}>
@@ -355,7 +423,7 @@ function McpArgsStep({
onSubmit={(raw) => {
const trimmed = raw.trim();
if (!trimmed) {
onError(`${entry.name} needs a value — got an empty string.`);
onError(t("wizard.mcpArgsEmpty", { name: entry.name }));
return;
}
if (entry.name === "filesystem") {
@@ -365,7 +433,7 @@ function McpArgsStep({
return;
}
if (check.kind === "not-a-dir") {
onError(`${trimmed} exists but is not a directory.`);
onError(t("wizard.mcpArgsNotADir", { path: trimmed }));
return;
}
}
@@ -407,8 +475,6 @@ function ExitOnEnter({ onExit }: { onExit: () => void }) {
return null;
}
// ---------- small bits ----------
function StepFrame({
title,
step,
@@ -423,9 +489,7 @@ function StepFrame({
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Box>
<Text dimColor>
Step {step}/{total} ·{" "}
</Text>
<Text dimColor>{t("wizard.stepCounter", { step, total })}</Text>
<Text bold color="cyan">
{title}
</Text>
@@ -446,8 +510,6 @@ function SummaryLine({ label, value }: { label: string; value: string }) {
);
}
// ---------- data helpers ----------
function presetItems(): SelectItem<PresetName>[] {
return (["auto", "flash", "pro"] as const).map((name) => ({
value: name as PresetName,
@@ -459,7 +521,7 @@ function presetItems(): SelectItem<PresetName>[] {
function mcpItems(): SelectItem<string>[] {
return MCP_CATALOG.map((entry) => {
const hintParts: string[] = [entry.summary];
if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
if (entry.userArgs) hintParts.push(t("wizard.mcpUserArgsHint", { arg: entry.userArgs }));
if (entry.note) hintParts.push(entry.note);
return {
value: entry.name,
@@ -476,9 +538,6 @@ function placeholderFor(entry: CatalogEntry): string {
}
function deriveInitialCatalog(existingSpecs: string[]): string[] {
// Best-effort recovery: if the user previously picked catalog entries,
// the spec strings look like `name=npx -y <pkg> ...`. Match by package
// so reconfigure pre-checks the same boxes.
const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
const out: string[] = [];
for (const spec of existingSpecs) {
@@ -499,7 +558,7 @@ function deriveInitialCatalog(existingSpecs: string[]): string[] {
*/
export function buildSpec(name: string, argsByName: Record<string, string>): string {
const entry = CATALOG_BY_NAME.get(name);
if (!entry) return name; // shouldn't happen; fall back gracefully
if (!entry) return name;
const userArg = entry.userArgs ? argsByName[name] : undefined;
const tail = userArg ? ` ${quoteIfNeeded(userArg)}` : "";
return `${entry.name}=npx -y ${entry.package}${tail}`;
-35
View File
@@ -1,35 +0,0 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { BranchCard as BranchCardData } from "../state/cards.js";
import { CARD, FG, TONE } from "../theme/tokens.js";
const BAR_CELLS = 28;
export function BranchCard({ card }: { card: BranchCardData }): React.ReactElement {
const ratio = card.total > 0 ? card.completed / card.total : 0;
const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
const tone = card.done ? TONE.ok : CARD.branch.color;
return (
<Card tone={tone}>
<CardHeader
glyph="⎇"
tone={tone}
title={card.done ? "branching done" : "branching"}
meta={[`${card.completed} of ${card.total} samples`]}
/>
<Box flexDirection="row" gap={1}>
<Text color={tone}>{"█".repeat(filled)}</Text>
<Text color={FG.faint}>{"░".repeat(BAR_CELLS - filled)}</Text>
<Text color={FG.faint}>{`${(ratio * 100).toFixed(0)}%`}</Text>
</Box>
{!card.done && card.completed > 0 ? (
<Text color={FG.faint}>
{`latest · #${card.latestIndex} · T=${card.latestTemperature.toFixed(2)} · ${card.latestUncertainties} unc`}
</Text>
) : null}
</Card>
);
}
+3 -3
View File
@@ -2,7 +2,6 @@ import { Box, Text } from "ink";
import React from "react";
import type { Card } from "../state/cards.js";
import { FG } from "../theme/tokens.js";
import { BranchCard } from "./BranchCard.js";
import { CtxCard } from "./CtxCard.js";
import { DiffCard } from "./DiffCard.js";
import { DoctorCard } from "./DoctorCard.js";
@@ -15,6 +14,7 @@ import { SearchCard } from "./SearchCard.js";
import { StreamingCard } from "./StreamingCard.js";
import { SubAgentCard } from "./SubAgentCard.js";
import { TaskCard } from "./TaskCard.js";
import { TipCard } from "./TipCard.js";
import { ToolCard } from "./ToolCard.js";
import { UsageCard } from "./UsageCard.js";
import { UserCard } from "./UserCard.js";
@@ -59,12 +59,12 @@ function renderCard(card: Card): React.ReactElement {
return <SearchCard card={card} />;
case "live":
return <LiveCard card={card} />;
case "tip":
return <TipCard card={card} />;
case "ctx":
return <CtxCard card={card} />;
case "doctor":
return <DoctorCard card={card} />;
case "branch":
return <BranchCard card={card} />;
default:
return <FallbackCard card={card} />;
}
+12 -11
View File
@@ -4,13 +4,8 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { DoctorCard as DoctorCardData, DoctorCheckEntry } from "../state/cards.js";
import { CARD, FG, TONE } from "../theme/tokens.js";
const LEVEL_COLOR: Record<DoctorCheckEntry["level"], string> = {
ok: TONE.ok,
warn: TONE.warn,
fail: TONE.err,
};
import { useThemeTokens } from "../theme/context.js";
import { CARD } from "../theme/tokens.js";
const LEVEL_GLYPH: Record<DoctorCheckEntry["level"], string> = {
ok: "✓",
@@ -25,6 +20,12 @@ const LEVEL_TAG: Record<DoctorCheckEntry["level"], string> = {
};
export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactElement {
const { fg, tone } = useThemeTokens();
const levelColor: Record<DoctorCheckEntry["level"], string> = {
ok: tone.ok,
warn: tone.warn,
fail: tone.err,
};
const ok = card.checks.filter((c) => c.level === "ok").length;
const warn = card.checks.filter((c) => c.level === "warn").length;
const fail = card.checks.filter((c) => c.level === "fail").length;
@@ -36,12 +37,12 @@ export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactEleme
<CardHeader glyph="⚕" tone={CARD.tool.color} title="doctor" meta={[summary]} />
{card.checks.map((c) => (
<Box key={c.label} flexDirection="row" gap={1}>
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
<Text bold color={FG.body}>
<Text color={levelColor[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
<Text bold color={fg.body}>
{c.label.padEnd(labelWidth + 1)}
</Text>
<Text color={FG.sub}>{c.detail}</Text>
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_TAG[c.level]}</Text>
<Text color={fg.sub}>{c.detail}</Text>
<Text color={levelColor[c.level]}>{LEVEL_TAG[c.level]}</Text>
</Box>
))}
</Card>
+20 -20
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { PlanCard as PlanCardData, PlanStep } from "../state/cards.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
const STATUS_GLYPH: Record<PlanStep["status"], string> = {
queued: "○",
@@ -15,53 +15,53 @@ const STATUS_GLYPH: Record<PlanStep["status"], string> = {
skipped: "s",
};
const STATUS_COLOR: Record<PlanStep["status"], string> = {
queued: FG.faint,
running: TONE_ACTIVE.brand,
done: TONE.ok,
failed: TONE.err,
blocked: TONE.warn,
skipped: FG.faint,
};
const VISIBLE_WINDOW = 5;
export function PlanCard({ card }: { card: PlanCardData }): React.ReactElement {
const { fg, tone, toneActive } = useThemeTokens();
const statusColor: Record<PlanStep["status"], string> = {
queued: fg.faint,
running: toneActive.brand,
done: tone.ok,
failed: tone.err,
blocked: tone.warn,
skipped: fg.faint,
};
const doneCount = card.steps.filter((s) => s.status === "done").length;
const variantTag =
card.variant === "resumed" ? "resumed · " : card.variant === "replay" ? "⏪ archive · " : "";
const progress = `${variantTag}${doneCount}/${card.steps.length} done`;
const hasRunning = card.steps.some((s) => s.status === "running");
const tone = hasRunning ? TONE_ACTIVE.accent : TONE.accent;
const cardTone = hasRunning ? toneActive.accent : tone.accent;
const window = pickWindow(card.steps);
return (
<Card tone={tone}>
<CardHeader glyph="⊞" tone={tone} title={card.title} meta={[progress]} />
<Card tone={cardTone}>
<CardHeader glyph="⊞" tone={cardTone} title={card.title} meta={[progress]} />
{window.hiddenBefore > 0 ? (
<Box flexDirection="row" gap={1}>
<Text color={TONE.ok}>✓</Text>
<Text color={FG.faint}>{`⋯ ${window.hiddenBefore} done`}</Text>
<Text color={tone.ok}>✓</Text>
<Text color={fg.faint}>{`⋯ ${window.hiddenBefore} done`}</Text>
</Box>
) : null}
{window.steps.map((step) => {
const isActive = step.status === "running";
const titleColor = isActive ? FG.strong : FG.sub;
const titleColor = isActive ? fg.strong : fg.sub;
return (
<Box key={step.id} flexDirection="row" gap={1}>
<Text color={STATUS_COLOR[step.status]}>{STATUS_GLYPH[step.status]}</Text>
<Text color={statusColor[step.status]}>{STATUS_GLYPH[step.status]}</Text>
<Text bold={isActive} color={titleColor}>
{`${step.indexLabel}. ${step.title}`}
</Text>
{isActive ? <Text color={TONE_ACTIVE.brand}>← in progress</Text> : null}
{isActive ? <Text color={toneActive.brand}>← in progress</Text> : null}
</Box>
);
})}
{window.hiddenAfter > 0 ? (
<Box flexDirection="row" gap={1}>
<Text color={FG.faint}>○</Text>
<Text color={FG.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
<Text color={fg.faint}>○</Text>
<Text color={fg.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
</Box>
) : null}
</Card>
+31 -24
View File
@@ -5,16 +5,17 @@ import { ActiveCardContext, Card as CardWrap } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import { Spinner } from "../primitives/Spinner.js";
import type { Card, SubAgentCard as SubAgentCardData } from "../state/cards.js";
import { CARD, FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
const STATUS_COLOR: Record<SubAgentCardData["status"], string> = {
running: TONE_ACTIVE.violet,
done: TONE.ok,
failed: TONE.err,
};
import { useThemeTokens } from "../theme/context.js";
import { CARD } from "../theme/tokens.js";
export function SubAgentCard({ card }: { card: SubAgentCardData }): React.ReactElement {
const headColor = STATUS_COLOR[card.status];
const { fg, tone, toneActive } = useThemeTokens();
const statusColor: Record<SubAgentCardData["status"], string> = {
running: toneActive.violet,
done: tone.ok,
failed: tone.err,
};
const headColor = statusColor[card.status];
const headGlyph = card.status === "failed" ? "✖" : "⌬";
const runningChildren = card.children.filter((c) => !isChildDone(c)).length;
const isRunning = card.status === "running";
@@ -30,17 +31,17 @@ export function SubAgentCard({ card }: { card: SubAgentCardData }): React.ReactE
glyph={headGlyph}
tone={headColor}
title="subagent"
titleColor={TONE.violet}
titleColor={tone.violet}
subtitle={card.task}
meta={headerMeta}
/>
{card.name ? <Text color={FG.faint}>{`agent · ${card.name}`}</Text> : null}
{card.name ? <Text color={fg.faint}>{`agent · ${card.name}`}</Text> : null}
{card.tools && card.tools.length > 0 && (
<Text color={FG.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
<Text color={fg.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
)}
{card.children.map((child) => (
<Box key={child.id} flexDirection="row" gap={1}>
{inLive ? null : <Text color={TONE.violet}>▎</Text>}
{inLive ? null : <Text color={tone.violet}>▎</Text>}
<ChildRow card={child} />
</Box>
))}
@@ -68,13 +69,14 @@ interface ChildVisual {
}
function ChildRow({ card }: { card: Card }): React.ReactElement {
const v = childVisual(card);
const { fg, tone } = useThemeTokens();
const v = childVisual(card, tone.ok, tone.err, fg.faint);
const isDone = isChildDone(card);
return (
<>
{v.statusGlyph}
<Text color={v.kindColor}>{v.kindGlyph}</Text>
<Text dimColor={isDone} color={FG.body}>
<Text dimColor={isDone} color={fg.body}>
{v.text}
</Text>
</>
@@ -89,16 +91,21 @@ function doneGlyph(color: string): React.ReactElement {
return <Text color={color}>✓</Text>;
}
function failedGlyph(): React.ReactElement {
return <Text color={TONE.err}>✖</Text>;
function failedGlyph(color: string): React.ReactElement {
return <Text color={color}>✖</Text>;
}
function childVisual(card: Card): ChildVisual {
function childVisual(
card: Card,
doneColor: string,
failedColor: string,
fallbackColor: string,
): ChildVisual {
switch (card.kind) {
case "reasoning": {
const done = !card.streaming;
return {
statusGlyph: done ? doneGlyph(TONE.ok) : runningGlyph(CARD.reasoning.color),
statusGlyph: done ? doneGlyph(doneColor) : runningGlyph(CARD.reasoning.color),
kindGlyph: "◆",
kindColor: CARD.reasoning.color,
text: `reasoning · ${card.paragraphs}`,
@@ -107,7 +114,7 @@ function childVisual(card: Card): ChildVisual {
case "tool": {
const elapsed = card.elapsedMs > 0 ? ` · ${(card.elapsedMs / 1000).toFixed(2)}s` : "";
return {
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.tool.color),
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.tool.color),
kindGlyph: "▣",
kindColor: CARD.tool.color,
text: `${card.name}${elapsed}`,
@@ -115,30 +122,30 @@ function childVisual(card: Card): ChildVisual {
}
case "streaming":
return {
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.streaming.color),
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.streaming.color),
kindGlyph: "◈",
kindColor: CARD.streaming.color,
text: card.done ? "response" : "writing …",
};
case "diff":
return {
statusGlyph: doneGlyph(TONE.ok),
statusGlyph: doneGlyph(doneColor),
kindGlyph: "±",
kindColor: CARD.diff.color,
text: card.file,
};
case "error":
return {
statusGlyph: failedGlyph(),
statusGlyph: failedGlyph(failedColor),
kindGlyph: "✖",
kindColor: CARD.error.color,
text: card.title,
};
default:
return {
statusGlyph: <Text color={FG.faint}>·</Text>,
statusGlyph: <Text color={fallbackColor}>·</Text>,
kindGlyph: "·",
kindColor: FG.faint,
kindColor: fallbackColor,
text: card.kind,
};
}
+20 -21
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { TaskCard as TaskCardData, TaskStep } from "../state/cards.js";
import { FG, TONE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
const STEP_GLYPH: Record<TaskStep["status"], string> = {
queued: "○",
@@ -13,19 +13,6 @@ const STEP_GLYPH: Record<TaskStep["status"], string> = {
failed: "✗",
};
const STEP_COLOR: Record<TaskStep["status"], string> = {
queued: FG.faint,
running: TONE.warn,
done: TONE.ok,
failed: TONE.err,
};
const TASK_COLOR: Record<TaskCardData["status"], string> = {
running: TONE.warn,
done: TONE.ok,
failed: TONE.err,
};
const TASK_GLYPH: Record<TaskCardData["status"], string> = {
running: "▶",
done: "✓",
@@ -33,26 +20,38 @@ const TASK_GLYPH: Record<TaskCardData["status"], string> = {
};
export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
const { fg, tone } = useThemeTokens();
const stepColor: Record<TaskStep["status"], string> = {
queued: fg.faint,
running: tone.warn,
done: tone.ok,
failed: tone.err,
};
const taskColor: Record<TaskCardData["status"], string> = {
running: tone.warn,
done: tone.ok,
failed: tone.err,
};
const elapsed = `${(card.elapsedMs / 1000).toFixed(1)}s`;
return (
<Card tone={TASK_COLOR[card.status]}>
<Card tone={taskColor[card.status]}>
<CardHeader
glyph={TASK_GLYPH[card.status]}
tone={TASK_COLOR[card.status]}
tone={taskColor[card.status]}
title={`step ${card.index}/${card.total}`}
subtitle={card.title}
meta={[elapsed, card.status]}
/>
{card.steps.map((step) => (
<Box key={step.id} flexDirection="row" gap={1}>
<Text color={STEP_COLOR[step.status]}>{STEP_GLYPH[step.status]}</Text>
<Text bold color={FG.body}>
<Text color={stepColor[step.status]}>{STEP_GLYPH[step.status]}</Text>
<Text bold color={fg.body}>
{(step.toolName ?? "step").padEnd(7)}
</Text>
<Text color={FG.sub}>{step.title}</Text>
{step.detail ? <Text color={FG.faint}>{step.detail}</Text> : null}
<Text color={fg.sub}>{step.title}</Text>
{step.detail ? <Text color={fg.faint}>{step.detail}</Text> : null}
{step.elapsedMs !== undefined ? (
<Text color={FG.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
<Text color={fg.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
) : null}
</Box>
))}
+74
View File
@@ -0,0 +1,74 @@
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 stringWidth from "string-width";
import { t } from "../../../i18n/index.js";
import type { TipCard as TipCardData, TipRow as TipRowData } from "../state/cards.js";
import { FG, TONE } from "../theme/tokens.js";
const KEY_GUTTER = 4;
export function TipCard({ card }: { card: TipCardData }): React.ReactElement {
const keyWidth = card.sections.reduce(
(max, sec) => sec.rows.reduce((m, r) => Math.max(m, stringWidth(r.key)), max),
0,
);
return (
<Box flexDirection="column" paddingLeft={2} marginY={1}>
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row" gap={1}>
<Text color={TONE.accent} bold>
ⓘ
</Text>
<Text color={FG.body} bold>
{card.topic}
</Text>
</Box>
{card.oneTime ? <Text color={FG.faint}>{t("ui.tipShownOnce")}</Text> : null}
</Box>
{card.sections.map((section, i) => (
<Box key={section.title ?? `section-${i}`} flexDirection="column" marginTop={1}>
{section.title ? (
<Box marginBottom={0}>
<Text color={FG.sub}>{section.title}</Text>
</Box>
) : null}
{section.rows.map((row) => (
<TipRowRender
key={row.key}
row={row}
keyWidth={keyWidth}
indent={section.title ? 2 : 0}
/>
))}
</Box>
))}
{card.footer ? (
<Box marginTop={1}>
<Text color={FG.faint}>{card.footer}</Text>
</Box>
) : null}
</Box>
);
}
function TipRowRender({
row,
keyWidth,
indent,
}: {
row: TipRowData;
keyWidth: number;
indent: number;
}) {
const pad = " ".repeat(Math.max(0, keyWidth - stringWidth(row.key) + KEY_GUTTER));
const lead = indent > 0 ? " ".repeat(indent) : "";
return (
<Box flexDirection="row">
{lead ? <Text>{lead}</Text> : null}
<Text color={TONE.accent}>{row.key}</Text>
<Text>{pad}</Text>
<Text color={FG.body}>{row.text}</Text>
</Box>
);
}
+2 -2
View File
@@ -28,9 +28,9 @@ export interface CharBarProps {
pct: number;
/** Cell count. Default 24. Min 4 enforced so the bar is at least readable. */
width?: number;
/** Filled-cell color. Defaults to brand cyan. */
/** Filled-cell COLOR. Defaults to brand cyan. */
color?: string;
/** Empty-cell color. Defaults to dim slate. */
/** Empty-cell COLOR. Defaults to dim slate. */
emptyColor?: string;
/**
* Whether to render the percentage label after the bar. Off when the
@@ -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]);
}
-10
View File
@@ -14,7 +14,6 @@ export interface ToolEventContext {
SetStateAction<{ progress: number; total?: number; message?: string } | null>
>;
toolStartedAtRef: MutableRefObject<number | null>;
toolHistoryRef: MutableRefObject<Array<{ toolName: string; text: string }>>;
setPendingShell: Dispatch<
SetStateAction<{ id: number; command: string; kind: "run_command" | "run_background" } | null>
>;
@@ -41,16 +40,7 @@ export function handleToolEvent(ev: LoopEvent, ctx: ToolEventContext): void {
ctx.setToolProgress(null);
ctx.translator.toolEnd(ev.content);
// mark_step_complete gets its own pretty scrollback row below — suppress
// the raw tool row here so we don't show the same JSON blob twice.
const isStepProgressTool = ev.toolName === "mark_step_complete";
ctx.toolStartedAtRef.current = null;
if (!isStepProgressTool) {
ctx.toolHistoryRef.current.push({
toolName: ev.toolName ?? "?",
text: ev.content,
});
}
if (ev.toolName === "mark_step_complete") {
try {
+30 -31
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { DoctorCheckEntry, PlanStep } from "../state/cards.js";
import type { DoctorCheckEntry, PlanStep, TipSection } from "../state/cards.js";
import { useDispatch } from "../state/provider.js";
let seq = 0;
@@ -22,6 +22,13 @@ export interface Scrollback {
text: string,
tone?: "info" | "ok" | "warn" | "err" | "ghost" | "brand" | "accent",
): string;
/** Structured onboarding-tip card — replaces multi-line TIP strings stuffed into pushInfo. */
pushTip(args: {
topic: string;
sections: ReadonlyArray<TipSection>;
footer?: string;
oneTime?: boolean;
}): string;
/** Emits a `ctxPressure` live card when usedTokens crosses 80% (warn) or 95% (err) of ctxMax. */
pushCtxPressureIfHigh(usedTokens: number, ctxMax: number): void;
pushStepProgress(stepIndex: number, total: number, title: string, elapsedMs?: number): string;
@@ -59,24 +66,11 @@ export interface Scrollback {
topTools: ReadonlyArray<{ name: string; tokens: number; turn: number }>;
}): string;
startBranch(total: number): string;
updateBranch(
id: string,
p: {
completed: number;
total: number;
latestIndex: number;
latestTemperature: number;
latestUncertainties: number;
},
): 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;
@@ -152,6 +146,22 @@ export function useScrollback(): Scrollback {
});
return id;
},
pushTip({ topic, sections, footer, oneTime = true }) {
const id = nextId("tip");
dispatch({
type: "tip.show",
id,
ts: Date.now(),
topic,
sections: sections.map((s) => ({
title: s.title,
rows: s.rows.map((r) => ({ key: r.key, text: r.text })),
})),
footer,
oneTime,
});
return id;
},
pushCtxPressureIfHigh(usedTokens, ctxMax) {
if (ctxMax <= 0) return;
const pct = (usedTokens / ctxMax) * 100;
@@ -238,20 +248,9 @@ export function useScrollback(): Scrollback {
dispatch({ type: "ctx.show", id, ...args, topTools: [...args.topTools] });
return id;
},
startBranch(total) {
const id = nextId("branch");
dispatch({ type: "branch.start", id, total });
return id;
},
updateBranch(id, p) {
dispatch({ type: "branch.progress", id, ...p });
},
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 +259,9 @@ export function useScrollback(): Scrollback {
endReasoning(id, paragraphs, tokens, aborted) {
dispatch({ type: "reasoning.end", id, paragraphs, tokens, aborted });
},
startStreaming() {
startStreaming(model) {
const id = nextId("s");
dispatch({ type: "streaming.start", id });
dispatch({ type: "streaming.start", id, ...(model ? { model } : {}) });
return id;
},
appendStreaming(id, chunk) {
-1
View File
@@ -60,7 +60,6 @@ function isFullySettled(card: Card): boolean {
switch (card.kind) {
case "streaming":
case "tool":
case "branch":
return card.done || !!card.aborted;
case "reasoning":
return !card.streaming || !!card.aborted;
+6 -5
View File
@@ -2,7 +2,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 { useAgentState } from "../state/provider.js";
import { FG, TONE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
import { StatusRow } from "./StatusRow.js";
const PLACEHOLDER = "type a message · / for commands · @ to attach a file";
@@ -11,23 +11,24 @@ const ABORTED_HINT = "turn aborted by user · esc again to clear · ⏎ to ask a
export function Composer(): React.ReactElement {
const composer = useAgentState((s) => s.composer);
const { fg, tone } = useThemeTokens();
return (
<Box flexDirection="column">
<StatusRow />
<Box height={1} />
<Box flexDirection="row">
<Text bold color={composer.shell ? TONE.err : TONE.brand}>
<Text bold color={composer.shell ? tone.err : tone.brand}>
{composer.shell ? "$" : "›"}{" "}
</Text>
{composer.value.length === 0 ? (
<Text color={FG.meta}>{PLACEHOLDER}</Text>
<Text color={fg.meta}>{PLACEHOLDER}</Text>
) : (
<Text color={FG.body}>{composer.value}</Text>
<Text color={fg.body}>{composer.value}</Text>
)}
</Box>
<Box height={1} />
<Text color={FG.faint}>
<Text color={fg.faint}>
{" "}
{composer.abortedHint ? ABORTED_HINT : HINT}
</Text>
+4 -12
View File
@@ -1,4 +1,4 @@
import { Box, Text, useStdout } from "ink";
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform)
import React from "react";
import type { ApplyResult } from "../../../code/edit-blocks.js";
@@ -9,6 +9,7 @@ import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pill.js";
import { Spinner } from "../primitives/Spinner.js";
import { useThemeTokens } from "../theme/context.js";
import { CARD, FG, TONE } from "../theme/tokens.js";
import { useElapsedSeconds, useSlowTick, useTick } from "../ticker.js";
import type { SubagentActivity } from "../useSubagent.js";
@@ -18,6 +19,7 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
/** "Thinking" row — soft pulse + italic label (model wait, not tool call). */
export function ThinkingRow({ text }: { text: string }) {
const elapsed = useElapsedSeconds();
const { fg, tone } = useThemeTokens();
return (
<Box marginY={1} paddingX={1} gap={1}>
<Spinner kind="circle" color={TONE.accent} />
@@ -80,17 +82,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({
+40 -13
View File
@@ -8,6 +8,7 @@ import { FG, TONE, balanceColor, formatBalance, formatCost } from "../theme/toke
const RULE_PAD = 4;
const RULE_MIN = 20;
const WALLET_MIN_COLS = 90;
export function StatusRow(): React.ReactElement {
const status = useAgentState((s) => s.status);
@@ -16,6 +17,9 @@ export function StatusRow(): React.ReactElement {
const cols = stdout?.columns ?? 80;
const ruleWidth = Math.max(RULE_MIN, cols - RULE_PAD);
const hasTurn = status.cost > 0;
const hasSession = status.sessionCost > 0;
const hasBalance = typeof status.balance === "number";
const showWallet = cols >= WALLET_MIN_COLS && (hasSession || hasBalance);
return (
<Box flexDirection="column">
@@ -46,25 +50,48 @@ 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>
{showWallet && (
<WalletPill
sessionCostUsd={status.sessionCost}
balance={status.balance}
currency={status.balanceCurrency}
/>
)}
</Box>
</Box>
);
}
function WalletPill({
sessionCostUsd,
balance,
currency,
}: {
sessionCostUsd: number;
balance?: number;
currency?: string;
}): React.ReactElement {
const showSpent = sessionCostUsd > 0;
const showBalance = typeof balance === "number";
return (
<>
<Sep />
<Text color={FG.meta}>{"⛁ "}</Text>
{showSpent && (
<Text color={FG.body}>{`${formatCost(sessionCostUsd, currency, 2)} spent`}</Text>
)}
{showSpent && showBalance && <Text color={FG.meta}>{" / "}</Text>}
{showBalance && (
<Text bold color={balanceColor(balance, currency)}>
{formatBalance(balance, currency, { fractionDigits: 2 })}
</Text>
)}
{showBalance && <Text color={FG.faint}>{" left"}</Text>}
</>
);
}
function ModePill({
mode,
network,
+8 -12
View File
@@ -28,10 +28,8 @@ export interface MultilineAction {
/** When `true`, fire `onSubmit(submitValue ?? value)`. */
submit: boolean;
submitValue?: string;
/** Set on Ctrl+P / Ctrl+N — parent handles prompt-history recall. */
/** Set on ↑/↓ at a buffer boundary or Ctrl+P / Ctrl+N — parent recalls prompt history. */
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 };
}
@@ -70,11 +68,11 @@ export function processMultilineKey(
return cursor === value.length ? NOOP : { next: null, cursor: value.length, submit: false };
}
// 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.
// ↑/↓ on an empty buffer recalls prompt history (universal CLI
// convention — bash, zsh, fish all do this). Ctrl+P / Ctrl+N is the
// wheel-immune fallback for Windows ConPTY users whose terminal
// translates mouse-wheel events to arrow keys; bind it on every
// platform so the muscle memory works the same everywhere.
if (key.ctrl && key.input === "p") {
return { ...NOOP, historyHandoff: "prev" };
}
@@ -82,8 +80,6 @@ export function processMultilineKey(
return { ...NOOP, historyHandoff: "next" };
}
// Cursor motion. Empty single-line buffer + ↑/↓ is now a no-op
// (used to recall history; see Ctrl+P/N comment above).
if (key.leftArrow) {
return { next: null, cursor: Math.max(0, cursor - 1), submit: false };
}
@@ -91,12 +87,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, chatScrollHandoff: "up" };
if (value.length === 0) return { ...NOOP, historyHandoff: "prev" };
const moved = moveCursorUp(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
if (key.downArrow) {
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "down" };
if (value.length === 0) return { ...NOOP, historyHandoff: "next" };
const moved = moveCursorDown(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
-21
View File
@@ -1,48 +1,27 @@
/** Preset table — branch and harvest stay off here so users never get ambushed into N× cost without opting in. */
import type { PresetName } from "../../config.js";
export interface PresetSettings {
model: string;
reasoningEffort: "high" | "max";
autoEscalate: boolean;
/** Pillar-2 harvest. Always false in presets — opt-in via /harvest. */
harvest: boolean;
/** Branch budget. Always 1 in presets — opt-in via /branch. */
branch: number;
}
/** Old names `fast`/`smart`/`max` aliased via `resolvePreset` so legacy configs still load. */
export const PRESETS: Record<"auto" | "flash" | "pro", PresetSettings> = {
// auto — flash baseline + auto-escalate to pro when the model emits
// <<<NEEDS_PRO>>> OR after 3+ tool failure signals in one turn.
// The default: cheap when easy, smart when hard.
auto: {
model: "deepseek-v4-flash",
reasoningEffort: "max",
autoEscalate: true,
harvest: false,
branch: 1,
},
// flash — always flash, never escalate. `/pro` still arms a single
// manual turn; auto-promotion is the thing this disables. Use when
// you want predictable cost per turn.
flash: {
model: "deepseek-v4-flash",
reasoningEffort: "max",
autoEscalate: false,
harvest: false,
branch: 1,
},
// pro — always pro. Hard pin; the model never downgrades. Use for
// multi-turn architecture work where flash is just going to keep
// escalating anyway and the back-and-forth wastes turns.
pro: {
model: "deepseek-v4-pro",
reasoningEffort: "max",
autoEscalate: false,
harvest: false,
branch: 1,
},
};
+2 -6
View File
@@ -1,7 +1,7 @@
import { Box } from "ink";
import React, { useContext } from "react";
/** Settled cards (in scrollback) drop the border + paddingX so history reads as flat lines. */
/** Settled cards (in scrollback) drop border + padding + margin so history collapses to flat lines. */
export const ActiveCardContext = React.createContext(true);
export interface CardProps {
@@ -23,11 +23,7 @@ const STRIPE_BORDER = {
export function Card({ tone, children }: CardProps): React.ReactElement {
const active = useContext(ActiveCardContext);
if (!active) {
return (
<Box flexDirection="column" marginTop={1}>
{children}
</Box>
);
return <Box flexDirection="column">{children}</Box>;
}
return (
<Box
+7 -3
View File
@@ -1,6 +1,7 @@
import { Box, Text } from "ink";
import React from "react";
import React, { useContext } from "react";
import { FG } from "../theme/tokens.js";
import { ActiveCardContext } from "./Card.js";
export type MetaItem = string | { text: string; color: string };
@@ -30,6 +31,9 @@ export function CardHeader({
meta,
right,
}: CardHeaderProps): React.ReactElement {
const active = useContext(ActiveCardContext);
// Settled scrollback drops faint string meta + spinners; colored badges (rejected, retry) stay.
const visibleMeta = active ? meta : meta?.filter((item) => typeof item !== "string");
return (
<Box flexDirection="row" gap={1}>
<Text color={tone}>{glyph}</Text>
@@ -43,7 +47,7 @@ export function CardHeader({
</Text>
)}
{subtitle ? <Text color={FG.body}>{subtitle}</Text> : null}
{meta?.map((item, i) => {
{visibleMeta?.map((item, i) => {
const isStr = typeof item === "string";
const text = isStr ? item : item.text;
const color = isStr ? FG.faint : item.color;
@@ -55,7 +59,7 @@ export function CardHeader({
</React.Fragment>
);
})}
{right}
{active ? right : null}
</Box>
);
}
+2
View File
@@ -5,6 +5,7 @@
// modules under ./slash/.
export {
SLASH_COMMANDS,
countAdvancedCommands,
detectSlashArgContext,
parseSlash,
suggestSlashCommands,
@@ -16,5 +17,6 @@ export type {
SlashArgContext,
SlashCommandSpec,
SlashContext,
SlashGroup,
SlashResult,
} from "./slash/types.js";
+214 -153
View File
@@ -1,174 +1,104 @@
import type { SlashArgContext, SlashCommandSpec } from "./types.js";
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
{ cmd: "help", summary: "show the full command reference", aliases: ["?"] },
{ cmd: "status", summary: "current model, flags, context, session" },
{ cmd: "help", group: "chat", summary: "show the full command reference", aliases: ["?"] },
{
cmd: "new",
group: "chat",
summary: "start a fresh conversation (clear context + scrollback)",
aliases: ["reset", "clear"],
},
{ cmd: "retry", group: "chat", summary: "truncate & resend your last message (fresh sample)" },
{
cmd: "compact",
group: "chat",
summary:
"fold older turns into a summary message (cache-safe). Auto-fires at 50% ctx; this is the manual trigger.",
},
{
cmd: "stop",
group: "chat",
summary: "abort the current model turn (typed alternative to Esc)",
},
{
cmd: "preset",
group: "setup",
argsHint: "<auto|flash|pro>",
summary: "model bundle — auto escalates flash → pro, flash/pro lock",
summary: "model bundle — auto escalates flash → pro, flash/pro lock. Bare opens picker.",
argCompleter: ["auto", "flash", "pro"],
},
{
cmd: "model",
group: "setup",
argsHint: "<id>",
summary: "switch DeepSeek model id",
summary: "switch DeepSeek model id. Bare opens picker.",
argCompleter: "models",
},
{ cmd: "models", summary: "list available models fetched from DeepSeek /models" },
{ cmd: "status", group: "info", summary: "current model, flags, context, session" },
{
cmd: "harvest",
argsHint: "[on|off]",
summary: "toggle Pillar-2 plan-state extraction",
argCompleter: ["on", "off"],
},
{
cmd: "branch",
argsHint: "<N|off>",
summary: "run N parallel samples per turn (N>=2)",
argCompleter: ["off", "2", "3", "4", "5"],
},
{
cmd: "effort",
argsHint: "<high|max>",
summary: "reasoning_effort cap — max is default (agent-class), high is cheaper/faster",
argCompleter: ["max", "high"],
},
{
cmd: "pro",
argsHint: "[off]",
summary: "arm v4-pro for the NEXT turn only (one-shot · auto-disarms after turn)",
argCompleter: ["off"],
},
{
cmd: "budget",
argsHint: "[usd|off]",
cmd: "cost",
group: "info",
argsHint: "[text]",
summary:
"session USD cap — warns at 80%, refuses next turn at 100%. Off by default. /budget alone shows status",
argCompleter: ["off", "1", "5", "10", "20", "50"],
"bare → last turn's spend (Usage card); with text → estimate cost of sending it next (worst-case + likely-cache)",
},
{
cmd: "language",
argsHint: "<EN|zh-CN>",
summary: "switch the runtime language",
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
cmd: "context",
group: "info",
summary: "show context-window breakdown (system / tools / log / input)",
},
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
{
cmd: "mcp browse",
summary: "browse the MCP marketplace — search registry, install with Enter",
cmd: "stats",
group: "info",
summary:
"cross-session cost dashboard (today / week / month / all-time · cache hit · vs Claude)",
},
{
cmd: "doctor",
group: "info",
summary: "health check (api / config / api-reach / index / hooks / project)",
},
{
cmd: "keys",
group: "info",
summary: "keyboard + mouse + copy/paste reference",
},
{ cmd: "sessions", group: "session", summary: "list saved sessions (current marked with ▸)" },
{ cmd: "mcp", group: "extend", summary: "list MCP servers + tools attached to this session" },
{
cmd: "resource",
group: "extend",
argsHint: "[uri]",
summary: "browse + read MCP resources (no arg → list URIs; <uri> → fetch contents)",
argCompleter: "mcp-resources",
},
{
cmd: "prompt",
group: "extend",
argsHint: "[name]",
summary: "browse + fetch MCP prompts (no arg → list names; <name> → render prompt)",
argCompleter: "mcp-prompts",
},
{ cmd: "tool", argsHint: "[N]", summary: "dump full output of the Nth tool call (1=latest)" },
{
cmd: "memory",
group: "extend",
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",
group: "extend",
argsHint: "[list|show <name>|new <name>|<name> [args]]",
summary: "list / run / scaffold user skills (<project>/.reasonix/skills + ~/.reasonix/skills)",
},
{
cmd: "hooks",
argsHint: "[reload]",
summary: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk",
},
{
cmd: "permissions",
argsHint: "[list|add <prefix>|remove <prefix|N>|clear confirm]",
summary:
"show / edit shell allowlist (builtin read-only · per-project: ~/.reasonix/config.json)",
argCompleter: ["list", "add", "remove", "clear"],
},
{
cmd: "dashboard",
argsHint: "[stop]",
summary: "launch the embedded web dashboard (127.0.0.1, token-gated)",
argCompleter: ["stop"],
},
{
cmd: "update",
summary: "show current vs latest version + the shell command to upgrade",
},
{
cmd: "stats",
summary:
"cross-session cost dashboard (today / week / month / all-time · cache hit · vs Claude)",
},
{
cmd: "cost",
argsHint: "[text]",
summary:
"bare → last turn's spend (Usage card); with text → estimate cost of sending it next (worst-case + likely-cache)",
},
{ cmd: "doctor", summary: "health check (api / config / api-reach / index / hooks / project)" },
{ cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
{
cmd: "context",
summary: "show context-window breakdown (system / tools / log / input)",
},
{ cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
{
cmd: "compact",
summary:
"fold older turns into a summary message (cache-safe). Auto-fires at 50% ctx; this is the manual trigger.",
},
{ cmd: "keys", summary: "show all keyboard shortcuts and prompt prefixes" },
{ cmd: "plans", summary: "list this session's active + archived plans, newest first" },
{ cmd: "stop", summary: "abort the current model turn (typed alternative to Esc)" },
{
cmd: "replay",
summary: "load an archived plan as a read-only Time Travel snapshot (default: newest)",
argsHint: "[N]",
},
{ cmd: "sessions", summary: "list saved sessions (current marked with ▸)" },
{ cmd: "rename", argsHint: "<new-name>", summary: "rename the current session on disk" },
{
cmd: "resume",
argsHint: "<name>",
summary: "show the launch command to resume a saved session",
},
{ cmd: "forget", summary: "delete the current session from disk" },
{
cmd: "semantic",
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)",
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", aliases: ["quit", "q"] },
// Code-mode only
{
cmd: "init",
group: "code",
argsHint: "[force]",
summary:
"scan the project and synthesize a baseline REASONIX.md (model writes; review with /apply). `force` overwrites an existing file.",
@@ -177,6 +107,7 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
},
{
cmd: "apply",
group: "code",
argsHint: "[N|N,M|N-M]",
summary:
"commit pending edit blocks to disk (no arg → all; `1`, `1,3`, or `1-4` → that subset, rest stay pending)",
@@ -184,36 +115,64 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
},
{
cmd: "discard",
group: "code",
argsHint: "[N|N,M|N-M]",
summary: "drop pending edit blocks without writing (no arg → all; indices → that subset)",
contextual: "code",
},
{
cmd: "walk",
group: "code",
summary:
"step through pending edits one block at a time (git-add-p style: y/n per block, a apply rest, A flip AUTO)",
contextual: "code",
},
{ cmd: "undo", summary: "roll back the last applied edit batch", contextual: "code" },
{
cmd: "undo",
group: "code",
summary: "roll back the last applied edit batch",
contextual: "code",
},
{
cmd: "history",
group: "code",
summary: "list every edit batch this session (ids for /show, undone markers)",
contextual: "code",
},
{
cmd: "show",
group: "code",
argsHint: "[id]",
summary: "dump a stored edit diff (omit id for newest non-undone)",
contextual: "code",
},
{
cmd: "commit",
group: "code",
argsHint: '"msg"',
summary: "git add -A && git commit -m ...",
contextual: "code",
},
{
cmd: "mode",
group: "code",
argsHint: "[review|auto|yolo]",
summary:
"edit-gate: review (queue) · auto (apply+undo) · yolo (apply+auto-shell). Shift+Tab cycles.",
contextual: "code",
argCompleter: ["review", "auto", "yolo"],
},
{
cmd: "plan",
group: "code",
argsHint: "[on|off]",
summary: "toggle read-only plan mode (writes bounced until submit_plan + approval)",
contextual: "code",
argCompleter: ["on", "off"],
},
{
cmd: "checkpoint",
group: "code",
argsHint: "[name|list|forget <id>]",
summary:
"snapshot every file the session has touched (Cursor-style internal store, not git). /checkpoint alone lists.",
@@ -222,52 +181,160 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
},
{
cmd: "restore",
group: "code",
argsHint: "<name|id>",
summary: "roll back files to a named checkpoint (see /checkpoint list)",
contextual: "code",
},
{
cmd: "plan",
argsHint: "[on|off]",
summary: "toggle read-only plan mode (writes bounced until submit_plan + approval)",
contextual: "code",
argCompleter: ["on", "off"],
},
{
cmd: "apply-plan",
summary: "force-approve a pending / in-text plan (fallback if picker was missed)",
contextual: "code",
},
{
cmd: "mode",
argsHint: "[review|auto|yolo]",
cmd: "cwd",
group: "code",
argsHint: "<path>",
summary:
"edit-gate: review (queue) · auto (apply+undo) · yolo (apply+auto-shell). Shift+Tab cycles.",
"switch the workspace root mid-session — re-points fs / shell / memory tools, reloads project hooks, refreshes the at-mention walker",
contextual: "code",
aliases: ["sandbox"],
},
{
cmd: "jobs",
group: "jobs",
summary: "list background jobs started by run_background",
contextual: "code",
argCompleter: ["review", "auto", "yolo"],
},
{ cmd: "jobs", summary: "list background jobs started by run_background", contextual: "code" },
{
cmd: "kill",
group: "jobs",
argsHint: "<id>",
summary: "stop a background job by id (SIGTERM → SIGKILL after grace)",
contextual: "code",
},
{
cmd: "logs",
group: "jobs",
argsHint: "<id> [lines]",
summary: "tail a background job's output (default last 80 lines)",
contextual: "code",
},
{
cmd: "pro",
group: "advanced",
argsHint: "[off]",
summary: "arm v4-pro for the NEXT turn only (one-shot · auto-disarms after turn)",
argCompleter: ["off"],
},
{
cmd: "budget",
group: "advanced",
argsHint: "[usd|off]",
summary:
"session USD cap — warns at 80%, refuses next turn at 100%. Off by default. /budget alone shows status",
argCompleter: ["off", "1", "5", "10", "20", "50"],
},
{
cmd: "language",
group: "advanced",
argsHint: "<EN|zh-CN>",
summary: "switch the runtime language",
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
},
{
cmd: "theme",
group: "advanced",
argsHint: "[auto|default|dark|light|tokyo-night|github-dark|github-light]",
summary: "show or persist the terminal theme preference",
argCompleter: [
"auto",
"default",
"dark",
"light",
"tokyo-night",
"github-dark",
"github-light",
],
},
{
cmd: "search-engine",
group: "advanced",
argsHint: "<mojeek|searxng> [<endpoint>]",
summary: "switch web search backend — mojeek (default, no deps) or searxng (self-hosted)",
argCompleter: ["mojeek", "searxng"],
aliases: ["se"],
},
{
cmd: "hooks",
group: "advanced",
argsHint: "[reload]",
summary: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk",
},
{
cmd: "permissions",
group: "advanced",
argsHint: "[list|add <prefix>|remove <prefix|N>|clear confirm]",
summary:
"show / edit shell allowlist (builtin read-only · per-project: ~/.reasonix/config.json)",
argCompleter: ["list", "add", "remove", "clear"],
},
{
cmd: "dashboard",
group: "advanced",
argsHint: "[stop]",
summary: "launch the embedded web dashboard (127.0.0.1, token-gated)",
argCompleter: ["stop"],
},
{
cmd: "loop",
group: "advanced",
argsHint: "<5s..6h> <prompt> · stop · (no args = status)",
summary: "auto-resubmit <prompt> every <interval> until you type something / Esc / /loop stop",
},
{
cmd: "plans",
group: "advanced",
summary: "list this session's active + archived plans, newest first",
},
{
cmd: "replay",
group: "advanced",
summary: "load an archived plan as a read-only Time Travel snapshot (default: newest)",
argsHint: "[N]",
},
{
cmd: "update",
group: "advanced",
summary: "show current vs latest version + the shell command to upgrade",
},
{ cmd: "exit", group: "advanced", summary: "quit the TUI", aliases: ["quit", "q"] },
];
export function suggestSlashCommands(prefix: string, codeMode = false): SlashCommandSpec[] {
export function suggestSlashCommands(
prefix: string,
codeMode = false,
counts?: Readonly<Record<string, number>>,
): SlashCommandSpec[] {
const p = prefix.toLowerCase();
return SLASH_COMMANDS.filter((c) => {
const matches = SLASH_COMMANDS.filter((c) => {
if (c.contextual === "code" && !codeMode) return false;
// Empty prefix = browsing the menu — advanced stays hidden until a letter is typed.
if (p === "" && c.group === "advanced") return false;
if (c.cmd.startsWith(p)) return true;
return c.aliases?.some((a) => a.startsWith(p)) ?? false;
});
if (!counts) return matches;
const indexOf = new Map(matches.map((s, i) => [s.cmd, i]));
return [...matches].sort((a, b) => {
const diff = (counts[b.cmd] ?? 0) - (counts[a.cmd] ?? 0);
if (diff !== 0) return diff;
return (indexOf.get(a.cmd) ?? 0) - (indexOf.get(b.cmd) ?? 0);
});
}
export function countAdvancedCommands(codeMode: boolean): number {
return SLASH_COMMANDS.filter(
(c) => c.group === "advanced" && (c.contextual !== "code" || codeMode),
).length;
}
/** alias → canonical cmd map, derived from SLASH_COMMANDS at module init. */
@@ -286,7 +353,6 @@ export function resolveSlashAlias(name: string): string {
/** 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 = resolveSlashAlias(m[1]!.toLowerCase());
@@ -298,13 +364,8 @@ export function detectSlashArgContext(input: string, codeMode = false): SlashArg
const hasInternalSpace = /\s/.test(tail);
const partialOffset = input.length - tail.length;
if (hasInternalSpace) {
// Past the first arg position (typing the second arg for e.g.
// `/edit <file> <instruction>`, or mid-sentence for free-form).
return { spec, partial: tail, partialOffset, kind: "hint" };
}
// No internal whitespace — we're still typing the first arg. Picker
// is live if the spec declares a completer; otherwise show a hint
// ("what goes here?") since the user's guessing.
return {
spec,
partial: tail,
+2 -2
View File
@@ -13,9 +13,9 @@ import { handlers as modelHandlers } from "./handlers/model.js";
import { handlers as observabilityHandlers } from "./handlers/observability.js";
import { handlers as permissionsHandlers } from "./handlers/permissions.js";
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 themeHandlers } from "./handlers/theme.js";
import { handlers as webSearchEngineHandlers } from "./handlers/web-search-engine.js";
import type { SlashContext, SlashResult } from "./types.js";
@@ -36,8 +36,8 @@ const HANDLERS: Record<string, SlashHandler> = {
...observabilityHandlers,
...permissionsHandlers,
...plansHandlers,
...semanticHandlers,
...sessionsHandlers,
...themeHandlers,
...skillHandlers,
...webSearchEngineHandlers,
};
+59 -104
View File
@@ -1,14 +1,11 @@
import { t } from "@/i18n/index.js";
import { t, tObj } from "@/i18n/index.js";
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
import { SLASH_COMMANDS } from "../commands.js";
import type { SlashHandler } from "../dispatch.js";
import type { SlashCommandSpec, SlashGroup } from "../types.js";
const exit: SlashHandler = () => ({ exit: true });
const clear: SlashHandler = () => ({
clear: true,
info: t("handlers.basic.clearInfo"),
});
const resetLog: SlashHandler = (_args, loop) => {
const { dropped } = loop.clearLog();
return {
@@ -17,95 +14,45 @@ const resetLog: SlashHandler = (_args, loop) => {
};
};
const keys: SlashHandler = () => ({
info: [
t("handlers.basic.keysTitle"),
"",
t("handlers.basic.keysEnter"),
t("handlers.basic.keysNewline"),
t("handlers.basic.keysContinue"),
t("handlers.basic.keysArrow"),
t("handlers.basic.keysPage"),
t("handlers.basic.keysHomeEnd"),
t("handlers.basic.keysClearLine"),
t("handlers.basic.keysDeleteWord"),
t("handlers.basic.keysBackspace"),
t("handlers.basic.keysEsc"),
t("handlers.basic.keysEditYn"),
t("handlers.basic.keysEditTab"),
t("handlers.basic.keysEditUndo"),
"",
t("handlers.basic.keysPromptTitle"),
t("handlers.basic.keysSlash"),
t("handlers.basic.keysAtFile"),
t("handlers.basic.keysAtFilePicker"),
t("handlers.basic.keysAtUrl"),
t("handlers.basic.keysAtUrlCache"),
t("handlers.basic.keysBang"),
t("handlers.basic.keysBangDetail"),
t("handlers.basic.keysHash"),
t("handlers.basic.keysHashGlobal"),
t("handlers.basic.keysHashBoth"),
t("handlers.basic.keysHashEscape"),
"",
t("handlers.basic.keysPickersTitle"),
t("handlers.basic.keysPickerNav"),
t("handlers.basic.keysPickerTab"),
t("handlers.basic.keysPickerEnter"),
"",
t("handlers.basic.keysMcpTitle"),
t("handlers.basic.keysMcpServers"),
t("handlers.basic.keysMcpResource"),
t("handlers.basic.keysMcpPrompt"),
"",
t("handlers.basic.keysUseful"),
].join("\n"),
});
const GROUP_ORDER: ReadonlyArray<SlashGroup> = [
"chat",
"setup",
"info",
"session",
"extend",
"code",
"jobs",
"advanced",
];
const help: SlashHandler = () => ({
info: [
t("handlers.basic.helpTitle"),
t("handlers.basic.helpHelp"),
t("handlers.basic.helpKeys"),
t("handlers.basic.helpStatus"),
t("handlers.basic.helpPreset"),
t("handlers.basic.helpModel"),
t("handlers.basic.helpPro"),
t("handlers.basic.helpHarvest"),
t("handlers.basic.helpBranch"),
t("handlers.basic.helpEffort"),
t("handlers.basic.helpMcp"),
t("handlers.basic.helpResource"),
t("handlers.basic.helpPrompt"),
t("handlers.basic.helpCompact"),
t("handlers.basic.helpThink"),
t("handlers.basic.helpTool"),
t("handlers.basic.helpCost"),
t("handlers.basic.helpMemory"),
t("handlers.basic.helpMemorySub"),
t("handlers.basic.helpSkill"),
t("handlers.basic.helpSkillSub"),
t("handlers.basic.helpRetry"),
t("handlers.basic.helpApply"),
t("handlers.basic.helpDiscard"),
t("handlers.basic.helpWalk"),
t("handlers.basic.helpUndo"),
t("handlers.basic.helpHistory"),
t("handlers.basic.helpShow"),
t("handlers.basic.helpCommit"),
t("handlers.basic.helpPlan"),
t("handlers.basic.helpApplyPlan"),
t("handlers.basic.helpMode"),
t("handlers.basic.helpJobs"),
t("handlers.basic.helpKill"),
t("handlers.basic.helpLogs"),
t("handlers.basic.helpSessions"),
t("handlers.basic.helpForget"),
t("handlers.basic.helpNew"),
t("handlers.basic.helpClear"),
t("handlers.basic.helpLoop"),
t("handlers.basic.helpExit"),
"",
const GROUP_HEADER: Record<SlashGroup, string> = {
chat: "CHAT · daily turn ops",
setup: "SETUP · model + cost",
info: "INFO · current state",
session: "SESSION · saved sessions",
extend: "EXTEND · MCP, memory, skills",
code: "CODE · edits + plans (code mode)",
jobs: "JOBS · background processes (code mode)",
advanced: "ADVANCED · rare or set-and-forget",
};
function renderRow(spec: SlashCommandSpec): string {
const name = `/${spec.cmd}${spec.argsHint ? ` ${spec.argsHint}` : ""}`;
const desc = t(`slash.${spec.cmd}.description`);
const summary = desc === `slash.${spec.cmd}.description` ? spec.summary : desc;
return ` ${name.padEnd(28)} ${summary}`;
}
const help: SlashHandler = () => {
const lines: string[] = [t("handlers.basic.helpTitle"), ""];
for (const group of GROUP_ORDER) {
const rows = SLASH_COMMANDS.filter((c) => c.group === group);
if (rows.length === 0) continue;
lines.push(` ${GROUP_HEADER[group]}`);
for (const r of rows) lines.push(renderRow(r));
lines.push("");
}
lines.push(
t("handlers.basic.helpShellTitle"),
t("handlers.basic.helpShell"),
t("handlers.basic.helpShellDetail"),
@@ -137,14 +84,9 @@ const help: SlashHandler = () => ({
t("handlers.basic.helpSessionsTitle"),
t("handlers.basic.helpSessionCustom"),
t("handlers.basic.helpSessionNone"),
"",
t("handlers.basic.helpLimitationTitle"),
t("handlers.basic.helpLimitation1"),
t("handlers.basic.helpLimitation2"),
t("handlers.basic.helpLimitation3"),
t("handlers.basic.helpLimitation4"),
].join("\n"),
});
);
return { info: lines.join("\n") };
};
const retry: SlashHandler = (_args, loop) => {
const prev = loop.retryLastUser();
@@ -187,12 +129,25 @@ const loop: SlashHandler = (args, _loop, ctx) => {
};
};
const keys: SlashHandler = (_args, _loop, ctx) => {
if (!ctx.postKeys) return { info: t("handlers.basic.keysNeedsTui") };
const ref = tObj<{
topic: string;
sections: ReadonlyArray<{
title?: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer: string;
}>("ui.keysReference");
ctx.postKeys({ topic: ref.topic, sections: ref.sections, footer: ref.footer });
return {};
};
export const handlers: Record<string, SlashHandler> = {
exit,
clear,
new: resetLog,
keys,
help,
retry,
loop,
keys,
};
+19 -15
View File
@@ -79,18 +79,6 @@ const plan: SlashHandler = (args, _loop, ctx) => {
return { info: t("handlers.edits.planOff") };
};
const applyPlan: SlashHandler = (_args, _loop, ctx) => {
if (!ctx.setPlanMode) {
return { info: t("handlers.edits.applyPlanCodeOnly") };
}
ctx.setPlanMode(false);
ctx.clearPendingPlan?.();
return {
info: t("handlers.edits.applyPlanInfo"),
resubmit: t("handlers.edits.applyPlanResubmit"),
};
};
const mode: SlashHandler = (args, _loop, ctx) => {
if (!ctx.setEditMode) {
return { info: t("handlers.edits.modeCodeOnly") };
@@ -205,7 +193,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) {
@@ -252,6 +240,23 @@ const restore: SlashHandler = (args, _loop, ctx) => {
return { info: lines.join("\n") };
};
const cwd: SlashHandler = (args, _loop, ctx) => {
if (!ctx.switchCwd) {
return { info: t("handlers.edits.cwdCodeOnly") };
}
const target = args.join(" ").trim();
if (!target) {
return {
info:
ctx.codeRoot != null
? t("handlers.edits.cwdUsage", { current: ctx.codeRoot })
: t("handlers.edits.cwdUsageNoCurrent"),
};
}
const result = ctx.switchCwd(stripOuterQuotes(target));
return { info: result.info };
};
export const handlers: Record<string, SlashHandler> = {
undo,
history,
@@ -259,11 +264,10 @@ export const handlers: Record<string, SlashHandler> = {
apply,
discard,
plan,
"apply-plan": applyPlan,
applyplan: applyPlan,
mode,
commit,
walk,
checkpoint,
restore,
cwd,
};
+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
+3 -74
View File
@@ -19,40 +19,6 @@ const model: SlashHandler = (args, loop, ctx) => {
return { info: t("handlers.model.modelSet", { id }) };
};
const models: SlashHandler = (_args, loop, ctx) => {
const list = ctx.models ?? null;
if (list === null) {
ctx.refreshModels?.();
return { info: t("handlers.model.modelsFetching") };
}
if (list.length === 0) {
return { info: t("handlers.model.modelsEmpty") };
}
const current = loop.model;
const lines = list.map((id) =>
id === current ? t("handlers.model.modelsCurrent", { id }) : ` ${id}`,
);
return {
info: [
t("handlers.model.modelsHeader", { count: list.length }),
"",
...lines,
"",
t("handlers.model.modelsSwitch"),
].join("\n"),
};
};
const harvest: SlashHandler = (args, loop) => {
const arg = (args[0] ?? "").toLowerCase();
const on = arg === "" ? !loop.harvestEnabled : arg === "on" || arg === "true" || arg === "1";
loop.configure({ harvest: on });
if (loop.harvestEnabled) {
return { info: t("handlers.model.harvestOn") };
}
return { info: t("handlers.model.harvestOff") };
};
const preset: SlashHandler = (args, loop, ctx) => {
const name = (args[0] ?? "").toLowerCase();
const applyAndPersist = (effort: "high" | "max") => {
@@ -67,8 +33,6 @@ const preset: SlashHandler = (args, loop, ctx) => {
model: p.model,
autoEscalate: p.autoEscalate,
reasoningEffort: p.reasoningEffort,
harvest: p.harvest,
branch: p.branch,
});
ctx.dispatch?.({ type: "session.model.change", model: p.model });
applyAndPersist(p.reasoningEffort);
@@ -85,43 +49,12 @@ const preset: SlashHandler = (args, loop, ctx) => {
apply(PRESETS.pro);
return { info: t("handlers.model.presetPro") };
}
if (name === "") {
return { openModelPicker: true };
}
return { info: t("handlers.model.presetUsage") };
};
const branch: SlashHandler = (args, loop) => {
const raw = (args[0] ?? "").toLowerCase();
if (raw === "" || raw === "off" || raw === "0" || raw === "1") {
loop.configure({ branch: 1 });
return { info: t("handlers.model.branchOff") };
}
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 2) {
return { info: t("handlers.model.branchUsage") };
}
if (n > 8) {
return { info: t("handlers.model.branchCapped") };
}
loop.configure({ branch: n });
return { info: t("handlers.model.branchSet", { n }) };
};
const effort: SlashHandler = (args, loop) => {
const raw = (args[0] ?? "").toLowerCase();
if (raw === "") {
return { info: t("handlers.model.effortStatus", { effort: loop.reasoningEffort }) };
}
if (raw !== "high" && raw !== "max") {
return { info: t("handlers.model.effortUsage") };
}
loop.configure({ reasoningEffort: raw });
try {
saveReasoningEffort(raw);
} catch {
/* disk full / perms — runtime change still took effect */
}
return { info: t("handlers.model.effortSet", { effort: raw }) };
};
const ESCALATION_MODEL_ID = "deepseek-v4-pro";
const pro: SlashHandler = (args, loop, ctx) => {
@@ -189,11 +122,7 @@ const budget: SlashHandler = (args, loop) => {
export const handlers: Record<string, SlashHandler> = {
model,
models,
harvest,
preset,
branch,
effort,
pro,
budget,
};
+1 -42
View File
@@ -7,42 +7,7 @@ import {
import { countTokens } from "@/tokenizer.js";
import { computeCtxBreakdown } from "../../ctx-breakdown.js";
import type { SlashHandler } from "../dispatch.js";
import { compactNum, formatToolList } from "../helpers.js";
const think: SlashHandler = (_args, loop) => {
const raw = loop.scratch.reasoning;
if (!raw || !raw.trim()) {
return { info: t("handlers.observability.thinkEmpty") };
}
return {
info: `${t("handlers.observability.thinkInfo", { count: raw.length })}\n\n${raw.trim()}`,
};
};
const tool: SlashHandler = (args, _loop, ctx) => {
const history = ctx.toolHistory?.() ?? [];
if (history.length === 0) {
return { info: t("handlers.observability.toolEmpty") };
}
const raw = (args[0] ?? "").toLowerCase();
if (raw === "" || raw === "list" || raw === "ls") {
return { info: formatToolList(history) };
}
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) {
return { info: t("handlers.observability.toolUsage") };
}
if (n > history.length) {
return { info: t("handlers.observability.toolOob", { count: history.length, n }) };
}
const entry = history[history.length - n];
if (!entry) {
return { info: t("handlers.observability.toolNotFound", { n }) };
}
return {
info: `${t("handlers.observability.toolInfo", { name: entry.toolName, n, chars: entry.text.length })}\n\n${entry.text}`,
};
};
import { compactNum } from "../helpers.js";
const context: SlashHandler = (_args, loop) => {
const breakdown = computeCtxBreakdown(loop);
@@ -61,7 +26,6 @@ const context: SlashHandler = (_args, loop) => {
};
const status: SlashHandler = (_args, loop, ctx) => {
const branchBudget = loop.branchOptions.budget ?? 1;
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
const summary = loop.stats.summary();
const lastPromptTokens = summary.lastPromptTokens;
@@ -138,8 +102,6 @@ const status: SlashHandler = (_args, loop, ctx) => {
const lines = [
t("handlers.observability.statusModel", { model: loop.model }),
t("handlers.observability.statusFlags", {
harvest: loop.harvestEnabled ? "on" : "off",
branch: branchBudget > 1 ? branchBudget : "off",
stream: loop.stream ? "on" : "off",
effort: loop.reasoningEffort,
}),
@@ -264,9 +226,6 @@ function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop
}
export const handlers: Record<string, SlashHandler> = {
think,
reasoning: think,
tool,
context,
status,
compact,
+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> = {
-115
View File
@@ -1,115 +0,0 @@
/** Informational only — actual install/build runs via `reasonix index` to avoid suspending Ink. */
import { promises as fs } from "node:fs";
import path from "node:path";
import { probeOllama } from "@/index/semantic/embedding.js";
import { t } from "@/index/semantic/i18n.js";
import { findOllamaBinary } from "@/index/semantic/ollama-launcher.js";
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).",
};
}
// Fire-and-forget: probes (file stat, optional Ollama HTTP) take
// ~50–200ms which is too long to block the prompt. Same pattern
// /kill uses — return a placeholder, post the rich result through
// ctx.postInfo when ready. ctx.postInfo is wired by the TUI when
// it owns historical rendering.
void (async () => {
const status = await renderSemanticStatus(root);
ctx.postInfo?.(status);
})();
return { info: "▸ checking semantic_search status…" };
};
export async function renderSemanticStatus(rootDir: string): Promise<string> {
const lines: string[] = [t("slashHeader"), ""];
const indexExists = await indexFileExists(rootDir);
if (indexExists) {
const meta = await readIndexMeta(rootDir);
lines.push(t("slashEnabled"));
if (meta) {
lines.push(
t("slashEnabledDetail", {
chunks: meta.chunks,
files: meta.files,
}),
);
}
lines.push(t("slashEnabledHowto"));
return lines.join("\n");
}
// Not built yet. Walk the prerequisites in priority order.
lines.push(t("slashIndexMissing"));
lines.push(t("slashIndexInfo"));
lines.push("");
if (findOllamaBinary() === null) {
lines.push(t("slashOllamaMissing"));
} else {
const probe = await probeOllama();
if (!probe.ok) lines.push(t("slashDaemonDown"));
}
lines.push(t("slashHowToBuild"));
return lines.join("\n");
}
async function indexFileExists(rootDir: string): Promise<boolean> {
try {
await fs.access(path.join(rootDir, ".reasonix", "semantic", "index.meta.json"));
return true;
} catch {
return false;
}
}
interface IndexSummary {
chunks: number;
files: number;
}
/** 10MB read cap so `/semantic` stays snappy on very large repos. */
async function readIndexMeta(rootDir: string): Promise<IndexSummary | null> {
const dataPath = path.join(rootDir, ".reasonix", "semantic", "index.jsonl");
let raw: string;
try {
// Bind size check and content to the same fd so a concurrent
// rebuild can't grow the file past the 10MB threshold between
// stat and read (CodeQL js/file-system-race).
const fh = await fs.open(dataPath, "r");
try {
const stat = await fh.stat();
if (stat.size > 10 * 1024 * 1024) {
// For huge indexes, give an order-of-magnitude estimate from
// file size (avg ~500 bytes/chunk in practice). Files won't
// be available, so we report just the chunk approximation.
return { chunks: Math.round(stat.size / 500), files: 0 };
}
raw = await fh.readFile("utf8");
} finally {
await fh.close();
}
const seenPaths = new Set<string>();
let chunks = 0;
for (const line of raw.split("\n")) {
if (line.length === 0) continue;
chunks++;
try {
const parsed = JSON.parse(line) as { p?: string };
if (parsed.p) seenPaths.add(parsed.p);
} catch {
/* tolerated — store rebuilds drop bad lines */
}
}
return { chunks, files: seenPaths.size };
} catch {
return null;
}
}
export const handlers: Record<string, SlashHandler> = {
semantic,
};
-37
View File
@@ -1,44 +1,7 @@
import { t } from "@/i18n/index.js";
import { deleteSession, listSessions, renameSession } from "@/memory/session.js";
import type { SlashHandler } from "../dispatch.js";
const sessions: SlashHandler = () => ({ openSessionsPicker: true });
const forget: SlashHandler = (_args, loop) => {
if (!loop.sessionName) {
return { info: t("handlers.sessions.forgetNoSession") };
}
const name = loop.sessionName;
const ok = deleteSession(name);
return {
info: ok
? t("handlers.sessions.forgetInfo", { name })
: t("handlers.sessions.forgetFailed", { name }),
};
};
const rename: SlashHandler = (args, loop) => {
const newName = args?.[0]?.trim();
if (!newName) return { info: t("handlers.sessions.renameUsage") };
if (!loop.sessionName) return { info: t("handlers.sessions.renameNoSession") };
const ok = renameSession(loop.sessionName, newName);
if (!ok) {
return { info: t("handlers.sessions.renameFailed", { name: newName }) };
}
return { info: t("handlers.sessions.renameInfo", { name: newName }) };
};
const resume: SlashHandler = (args) => {
const name = args?.[0]?.trim();
if (!name) return { info: t("handlers.sessions.resumeUsage") };
const exists = listSessions().some((s) => s.name === name);
if (!exists) return { info: t("handlers.sessions.resumeNotFound", { name }) };
return { info: t("handlers.sessions.resumeInfo", { name }) };
};
export const handlers: Record<string, SlashHandler> = {
sessions,
forget,
rename,
resume,
};
+39
View File
@@ -0,0 +1,39 @@
import { loadTheme, resolveThemePreference, saveTheme } from "@/config.js";
import { type ThemeName, isThemeName, listThemeNames } from "../../theme/tokens.js";
import type { SlashHandler } from "../dispatch.js";
const themeChoices = ["auto", ...listThemeNames()] as const;
function formatThemeStatus(): string {
const configured = loadTheme();
const active = resolveThemePreference(configured, process.env.REASONIX_THEME);
const source = configured && configured !== "auto" ? "config" : "env/default";
return [
`theme: ${active} (${source})`,
`configured: ${configured ?? "unset"}`,
`available: ${themeChoices.join(", ")}`,
"usage: /theme <name|auto>",
].join("\n");
}
function isThemeChoice(value: string): value is ThemeName | "auto" {
return value === "auto" || isThemeName(value);
}
const theme: SlashHandler = (args) => {
const next = args[0];
if (!next) return { info: formatThemeStatus() };
if (!isThemeChoice(next)) {
return { info: `unknown theme: ${next}\navailable: ${themeChoices.join(", ")}` };
}
saveTheme(next);
const active = resolveThemePreference(next, process.env.REASONIX_THEME);
return { info: `theme saved: ${next}\nactive on next launch: ${active}` };
};
export const handlers: Record<string, SlashHandler> = {
theme,
};
+20 -15
View File
@@ -1,4 +1,5 @@
import { readConfig, webSearchEndpoint, webSearchEngine, writeConfig } from "../../../../config.js";
import { t } from "../../../../i18n/index.js";
import type { SlashHandler } from "../dispatch.js";
export const handlers: Record<string, SlashHandler> = {
@@ -7,18 +8,18 @@ export const handlers: Record<string, SlashHandler> = {
if (!engine || (engine !== "mojeek" && engine !== "searxng")) {
return {
info: [
`Current web search engine: ${webSearchEngine()}`,
`SearXNG endpoint: ${webSearchEndpoint()}`,
t("handlers.webSearchEngine.currentEngine", { engine: webSearchEngine() }),
t("handlers.webSearchEngine.endpoint", { url: webSearchEndpoint() }),
"",
"Usage:",
" /search-engine mojeek use Mojeek (default, no external deps)",
" /search-engine searxng use SearXNG at default endpoint",
" /search-engine searxng <url> use SearXNG at custom endpoint",
t("handlers.webSearchEngine.usageHeader"),
t("handlers.webSearchEngine.usageMojeek"),
t("handlers.webSearchEngine.usageSearxng"),
t("handlers.webSearchEngine.usageSearxngUrl"),
"",
"Alias: /se",
t("handlers.webSearchEngine.alias"),
"",
"SearXNG is a self-hosted metasearch engine (https://github.com/searxng/searxng).",
"Install it with: docker run -d -p 8080:8080 searxng/searxng",
t("handlers.webSearchEngine.searxngInfo"),
t("handlers.webSearchEngine.searxngInstall"),
].join("\n"),
};
}
@@ -31,13 +32,17 @@ export const handlers: Record<string, SlashHandler> = {
}
writeConfig(cfg);
ctx.postInfo?.(
`Switched web search engine to "${engine}". ${engine === "searxng" ? `Make sure SearXNG is running at ${webSearchEndpoint()}.` : ""}`,
);
const note =
engine === "searxng"
? t("handlers.webSearchEngine.switchedSearxngNote", { endpoint: webSearchEndpoint() })
: "";
ctx.postInfo?.(t("handlers.webSearchEngine.switched", { engine, note }));
return {
info: `✓ Web search engine set to "${engine}"${engine === "searxng" ? ` (${webSearchEndpoint()})` : ""}. Next assistant turn will pick up the change.`,
};
const detail =
engine === "searxng"
? t("handlers.webSearchEngine.confirmedDetail", { endpoint: webSearchEndpoint() })
: "";
return { info: t("handlers.webSearchEngine.confirmed", { engine, detail }) };
},
se: (args, loop, ctx) => handlers["search-engine"]!(args, loop, ctx),
};
-27
View File
@@ -52,33 +52,6 @@ export function appendSection(
lines.push(` ${label.trim()} ${names.length} [${head}${more}]`);
}
export function formatToolList(history: Array<{ toolName: string; text: string }>): string {
const total = history.length;
const header = `Tool calls in this session (${total}, most recent first):`;
// Show the 10 most recent. Older ones are rarely what the user
// wants — and the help footer tells them how to reach any entry
// by index if they do.
const shown = Math.min(total, 10);
const lines: string[] = [header];
for (let i = 0; i < shown; i++) {
const entry = history[total - 1 - i];
if (!entry) continue;
const idx = i + 1; // 1-based from most recent
const flat = entry.text.replace(/\s+/g, " ").trim();
const preview = flat.length > 80 ? `${flat.slice(0, 80)}…` : flat;
const name = entry.toolName.length > 24 ? `${entry.toolName.slice(0, 23)}…` : entry.toolName;
lines.push(
` #${String(idx).padStart(2)} ${name.padEnd(24)} ${String(entry.text.length).padStart(6)} chars ${preview}`,
);
}
if (total > shown) {
lines.push(` … (${total - shown} earlier, reach with /tool N)`);
}
lines.push("");
lines.push("View full output: /tool N (N=1 → most recent)");
return lines.join("\n");
}
/** Binary-K to match DeepSeek docs; do NOT reuse for non-token counts. */
export function compactNum(n: number): string {
if (n < 1024) return String(n);
+25 -3
View File
@@ -10,6 +10,8 @@ export interface SlashResult {
info?: string;
/** Open the SessionPicker modal mid-chat — used by `/sessions` slash. */
openSessionsPicker?: boolean;
/** Open the CheckpointPicker modal — bare `/restore` (no name argument). */
openCheckpointPicker?: boolean;
/** Open the ModelPicker modal — bare `/model` (no id) opens it. */
openModelPicker?: boolean;
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
@@ -60,7 +62,6 @@ export interface SlashContext {
codeShowEdit?: (args: readonly string[]) => string;
codeRoot?: string;
pendingEditCount?: number;
toolHistory?: () => Array<{ toolName: string; text: string }>;
mcpServers?: McpServerSummary[];
/** Absent → tests context; `/memory` MUST reply "root unknown" rather than silently reading wrong dir. */
memoryRoot?: string;
@@ -89,12 +90,21 @@ export interface SlashContext {
balanceCurrency?: string;
elapsedMs?: number;
}) => void;
/** Push the keyboard + mouse + copy/paste reference TipCard (multi-section). Used by `/keys`. */
postKeys?: (args: {
topic: string;
sections: ReadonlyArray<{
title?: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer?: string;
}) => void;
dispatch?: (event: import("../state/events.js").AgentEvent) => void;
setPlanMode?: (on: boolean) => void;
/** `/apply-plan` clears the picker so its own `resubmit` doesn't double-fire approval. */
clearPendingPlan?: () => void;
reloadHooks?: () => number;
/** Switch the workspace root mid-session — re-targets filesystem/shell/memory tools, hooks, at-mention walker. Code mode only. */
switchCwd?: (newPath: string) => { ok: boolean; info: string };
/** Diff config.mcp[] vs live bridges → add/close clients accordingly. Wired from chat.tsx mcpRuntime. */
reloadMcp?: () => Promise<{
added: string[];
@@ -126,10 +136,22 @@ export interface SlashContext {
getDashboardUrl?: () => string | null;
}
export type SlashGroup =
| "chat"
| "setup"
| "info"
| "session"
| "extend"
| "code"
| "jobs"
| "advanced";
export interface SlashCommandSpec {
cmd: string;
summary: string;
contextual?: "code";
/** Visual category in the suggestions palette + /help. `advanced` collapses by default. */
group: SlashGroup;
/** If the command takes args, hint text shown after the name. */
argsHint?: string;
/** First-arg picker source — file paths intentionally absent (use `@path` mentions instead). */
+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);
}
}
+11 -9
View File
@@ -52,16 +52,18 @@ export function cardsToDashboardMessages(cards: ReadonlyArray<Card>): DashboardM
case "ctx":
out.push({ id: card.id, role: "info", text: card.text });
break;
case "branch":
// Boot snapshot only sees finished branches — drop transient progress.
if (card.done) {
out.push({
id: card.id,
role: "info",
text: `▸ branched ${card.completed} of ${card.total} samples`,
});
}
case "tip": {
const sectionTexts = card.sections.map((sec) => {
const body = sec.rows.map((r) => `${r.key}\t${r.text}`).join("\n");
return sec.title ? `[${sec.title}]\n${body}` : body;
});
const body = sectionTexts.join("\n\n");
const text = card.footer
? `${card.topic}\n${body}\n${card.footer}`
: `${card.topic}\n${body}`;
out.push({ id: card.id, role: "info", text });
break;
}
case "plan": {
const done = card.steps.filter((s) => s.status === "done").length;
const tag =
+21 -13
View File
@@ -179,17 +179,6 @@ export interface LiveCard extends CardBase {
readonly meta?: string;
}
export interface BranchCard extends CardBase {
readonly kind: "branch";
completed: number;
total: number;
latestIndex: number;
latestTemperature: number;
latestUncertainties: number;
done: boolean;
aborted?: boolean;
}
export interface CtxCard extends CardBase {
readonly kind: "ctx";
readonly text: string;
@@ -203,6 +192,25 @@ export interface CtxCard extends CardBase {
readonly topTools: ReadonlyArray<{ name: string; tokens: number; turn: number }>;
}
export interface TipRow {
readonly key: string;
readonly text: string;
}
export interface TipSection {
/** Subsection heading (rendered above its rows). Omit for single-section tips. */
readonly title?: string;
readonly rows: ReadonlyArray<TipRow>;
}
export interface TipCard extends CardBase {
readonly kind: "tip";
readonly topic: string;
readonly sections: ReadonlyArray<TipSection>;
readonly footer?: string;
readonly oneTime: boolean;
}
export type Card =
| UserCard
| ReasoningCard
@@ -219,8 +227,8 @@ export type Card =
| SearchCard
| LiveCard
| CtxCard
| BranchCard
| DoctorCard;
| DoctorCard
| TipCard;
export interface DoctorCheckEntry {
readonly label: string;
+18 -25
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({
@@ -211,28 +213,6 @@ const planDrop = z.object({
type: z.literal("plan.drop"),
});
const branchStart = z.object({
type: z.literal("branch.start"),
id: cardId,
total: z.number().int().positive(),
});
const branchProgress = z.object({
type: z.literal("branch.progress"),
id: cardId,
completed: z.number().int().nonnegative(),
total: z.number().int().positive(),
latestIndex: z.number().int().nonnegative(),
latestTemperature: z.number(),
latestUncertainties: z.number().int().nonnegative(),
});
const branchEnd = z.object({
type: z.literal("branch.end"),
id: cardId,
aborted: z.boolean().optional(),
});
const usageShow = z.object({
type: z.literal("usage.show"),
id: cardId,
@@ -303,6 +283,21 @@ const liveShow = z.object({
meta: z.string().optional(),
});
const tipShow = z.object({
type: z.literal("tip.show"),
id: cardId,
ts: ts,
topic: z.string(),
sections: z.array(
z.object({
title: z.string().optional(),
rows: z.array(z.object({ key: z.string(), text: z.string() })),
}),
),
footer: z.string().optional(),
oneTime: z.boolean(),
});
export const AgentEventSchema = z.discriminatedUnion("type", [
userSubmit,
turnStart,
@@ -335,6 +330,7 @@ export const AgentEventSchema = z.discriminatedUnion("type", [
toastShow,
toastHide,
liveShow,
tipShow,
sessionReset,
planShow,
planStepComplete,
@@ -342,9 +338,6 @@ export const AgentEventSchema = z.discriminatedUnion("type", [
ctxShow,
doctorShow,
usageShow,
branchStart,
branchProgress,
branchEnd,
]);
export type AgentEvent = z.infer<typeof AgentEventSchema>;
+13 -32
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 }));
@@ -173,6 +173,17 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
meta: event.meta,
});
case "tip.show":
return appendCard(state, {
kind: "tip",
id: event.id,
ts: event.ts,
topic: event.topic,
sections: event.sections,
footer: event.footer,
oneTime: event.oneTime,
});
case "session.reset":
return { ...state, cards: [], focusedCardId: null, toasts: [] };
@@ -258,36 +269,6 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
balanceCurrency: event.balanceCurrency,
elapsedMs: event.elapsedMs,
});
case "branch.start":
return appendCard(state, {
kind: "branch",
id: event.id,
ts: Date.now(),
completed: 0,
total: event.total,
latestIndex: 0,
latestTemperature: 0,
latestUncertainties: 0,
done: false,
});
case "branch.progress":
return mutateCard(state, event.id, "branch", (c) => ({
...c,
completed: event.completed,
total: event.total,
latestIndex: event.latestIndex,
latestTemperature: event.latestTemperature,
latestUncertainties: event.latestUncertainties,
}));
case "branch.end":
return mutateCard(state, event.id, "branch", (c) => ({
...c,
done: true,
...(event.aborted ? { aborted: true } : {}),
}));
}
}
+125 -57
View File
@@ -1,30 +1,121 @@
/** Brand gradient — REASONIX wordmark sweep, reused for accents / progress / dividers. */
export const GRADIENT: ReadonlyArray<string> = [
"#5eead4", // teal
"#67e8f9", // cyan
"#7dd3fc", // sky
"#93c5fd", // blue
"#a5b4fc", // indigo
"#c4b5fd", // violet
"#d8b4fe", // purple
"#f0abfc", // fuchsia
];
import React from "react";
import { useThemeTokens } from "./theme/context.js";
import {
CARD,
FG as TOKEN_FG,
SURFACE as TOKEN_SURFACE,
TONE,
TONE_ACTIVE,
type ThemeTokens,
} from "./theme/tokens.js";
/** Tailwind 400/500 row — keeps tone consistent with GRADIENT. */
export const COLOR = {
primary: "#67e8f9", // cyan-300
accent: "#c4b5fd", // violet-300
brand: "#5eead4", // teal-300
export type UiColor = ReturnType<typeof colorFromTheme>;
export type UiGradient = ReturnType<typeof gradientFromTheme>;
export type UiSurface = ReturnType<typeof surfaceFromTheme>;
export type UiFg = ReturnType<typeof fgFromTheme>;
user: "#67e8f9", // user message glyph + bar
assistant: "#86efac", // green-300, assistant glyph + bar
tool: "#fcd34d", // amber-300, tool ok pill bg
toolErr: "#fda4af", // rose-300, tool err pill bg
info: "#94a3b8", // slate-400, info / dim
warn: "#fbbf24", // amber-400
err: "#f87171", // red-400
ok: "#4ade80", // green-400
} as const;
export function gradientFromTheme(theme: ThemeTokens): ReadonlyArray<string> {
return [
theme.tone.ok,
theme.tone.brand,
theme.tone.info,
theme.toneActive.brand,
theme.toneActive.violet,
theme.tone.accent,
theme.toneActive.accent,
theme.tone.err,
];
}
export function colorFromTheme(theme: ThemeTokens) {
return {
primary: theme.tone.brand,
accent: theme.tone.accent,
brand: theme.tone.ok,
user: theme.tone.brand,
assistant: theme.tone.ok,
tool: theme.tone.warn,
toolErr: theme.tone.err,
info: theme.fg.sub,
warn: theme.tone.warn,
err: theme.tone.err,
ok: theme.tone.ok,
} as const;
}
export function surfaceFromTheme(theme: ThemeTokens) {
return {
canvas: theme.surface.bg,
shell: theme.surface.bgInput,
card: theme.surface.bgElev,
elev: theme.surface.bgElev,
sel: theme.surface.bgInput,
line: theme.fg.faint,
lineSoft: theme.fg.meta,
} as const;
}
export function fgFromTheme(theme: ThemeTokens) {
return {
strong: theme.fg.strong,
default: theme.fg.body,
dim: theme.fg.sub,
faint: theme.fg.meta,
ghost: theme.fg.faint,
} as const;
}
function proxyThemeValue<T extends object>(build: () => T): T {
const target = build();
return new Proxy(target, {
get(_target, prop: string | symbol) {
return build()[prop as keyof T];
},
getOwnPropertyDescriptor(_target, prop: string | symbol) {
return Reflect.getOwnPropertyDescriptor(build(), prop);
},
has(_target, prop: string | symbol) {
return prop in build();
},
ownKeys() {
return Reflect.ownKeys(build());
},
});
}
function currentTheme(): ThemeTokens {
return {
fg: TOKEN_FG,
tone: TONE,
toneActive: TONE_ACTIVE,
surface: TOKEN_SURFACE,
card: CARD,
};
}
export function useGradient(): UiGradient {
const theme = useThemeTokens();
return React.useMemo(() => gradientFromTheme(theme), [theme]);
}
export function useColor(): UiColor {
const theme = useThemeTokens();
return React.useMemo(() => colorFromTheme(theme), [theme]);
}
export function useUiSurface(): UiSurface {
const theme = useThemeTokens();
return React.useMemo(() => surfaceFromTheme(theme), [theme]);
}
export function useUiFg(): UiFg {
const theme = useThemeTokens();
return React.useMemo(() => fgFromTheme(theme), [theme]);
}
export const GRADIENT = proxyThemeValue(() => gradientFromTheme(currentTheme()));
export const COLOR = proxyThemeValue(() => colorFromTheme(currentTheme()));
export const GLYPH = {
brand: "◈",
@@ -43,63 +134,40 @@ export const GLYPH = {
shade2: "▒",
shade3: "▓",
// Status icons — checkbox-style states used across plan steps,
// job rows, history entries. Pair with the COLOR semantics:
// done→ok, cur→primary, pending→info-faint, fail→err.
done: "✓",
cur: "▸",
pending: "○",
fail: "✗",
running: "●",
// Tree-drawing chars for hierarchical lists (plan steps, sub-loops,
// hook attachments). 1 cell each; render fine in every monospace
// font we've tested.
branch: "┣",
branchEnd: "┗",
branchStub: "┃",
rule: "─",
// Spinner frames — 4-step rotation. Cycle every 200ms via setInterval
// (Ink's useEffect setState pattern). Equivalent to ink-spinner but
// with our own cadence + character set.
spinFrames: ["◐", "◓", "◑", "◒"] as readonly string[],
} as const;
/** Ordering survives 256-/16-color quantization — canvas always darker than sel. */
export const SURFACE = {
canvas: "#070a10",
shell: "#0b1019",
card: "#101721",
elev: "#161f2c",
sel: "#1a2433",
line: "#1c2433",
lineSoft: "#141b27",
} as const;
export const FG = {
strong: "#e6edf6",
default: "#cbd5e1",
dim: "#94a3b8",
faint: "#64748b",
ghost: "#475569",
} as const;
export const SURFACE = proxyThemeValue(() => surfaceFromTheme(currentTheme()));
export const FG = proxyThemeValue(() => fgFromTheme(currentTheme()));
export function gradientCells(
width: number,
glyph: string = GLYPH.block,
gradient: ReadonlyArray<string> = GRADIENT,
): Array<{ ch: string; color: string }> {
const cells: Array<{ ch: string; color: string }> = [];
if (width <= 0) return cells;
const last = GRADIENT.length - 1;
const last = gradient.length - 1;
for (let i = 0; i < width; i++) {
if (last <= 0) {
cells.push({ ch: glyph, color: gradient[0] ?? COLOR.primary });
continue;
}
const t = width === 1 ? 0 : (i * last) / (width - 1);
const lo = Math.floor(t);
const hi = Math.min(last, lo + 1);
// Pick the closer of the two anchor colors for this cell. Linear
// hex blending could be fancier but the discrete steps already
// read as a smooth fade at any reasonable width.
const color = t - lo < 0.5 ? GRADIENT[lo]! : GRADIENT[hi]!;
const color = t - lo < 0.5 ? gradient[lo]! : gradient[hi]!;
cells.push({ ch: glyph, color });
}
return cells;
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
import {
DEFAULT_THEME_NAME,
THEMES,
type ThemeName,
type ThemeTokens,
resolveThemeName,
setActiveTheme,
} from "./tokens.js";
const ThemeContext = React.createContext<ThemeTokens>(THEMES[DEFAULT_THEME_NAME]);
export function ThemeProvider({
children,
name,
}: {
children: React.ReactNode;
name?: string | null;
}): React.ReactElement {
const theme = THEMES[resolveThemeName(name)];
const restoreActiveTheme = setActiveTheme(theme);
React.useLayoutEffect(() => restoreActiveTheme, [restoreActiveTheme]);
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
export function useThemeTokens(): ThemeTokens {
return React.useContext(ThemeContext);
}
export function useTheme(): ThemeTokens {
return useThemeTokens();
}
export type { ThemeName, ThemeTokens };
+353 -52
View File
@@ -1,60 +1,361 @@
export const FG = {
strong: "#e6edf3",
body: "#c9d1d9",
sub: "#8b949e",
meta: "#6e7681",
faint: "#484f58",
} as const;
export type ThemeName =
| "default"
| "dark"
| "light"
| "tokyo-night"
| "github-dark"
| "github-light"
| "high-contrast";
export const TONE = {
brand: "#79c0ff",
accent: "#d2a8ff",
violet: "#b395f5",
ok: "#7ee787",
warn: "#f0b07d",
err: "#ff8b81",
info: "#79c0ff",
} as const;
export interface ThemeTokens {
fg: {
strong: string;
body: string;
sub: string;
meta: string;
faint: string;
};
tone: {
brand: string;
accent: string;
violet: string;
ok: string;
warn: string;
err: string;
info: string;
};
toneActive: ThemeTokens["tone"];
surface: {
bg: string;
bgInput: string;
bgCode: string;
bgElev: string;
};
card: Record<
| "user"
| "reasoning"
| "streaming"
| "task"
| "tool"
| "plan"
| "diff"
| "error"
| "warn"
| "usage"
| "subagent"
| "approval"
| "search"
| "memory"
| "ctx"
| "doctor"
| "branch",
{ color: string; glyph: string }
>;
}
/** Used only while a card is streaming/running, so live cards stand out from settled history. */
export const TONE_ACTIVE = {
brand: "#a5d6ff",
accent: "#e2c5ff",
violet: "#c8aaff",
ok: "#a8f5ad",
warn: "#ffc99e",
err: "#ffaba3",
info: "#a5d6ff",
} as const;
type ThemeBase = Omit<ThemeTokens, "card">;
export const SURFACE = {
bg: "#0a0c10",
bgInput: "#0d1015",
bgCode: "#06080c",
bgElev: "#11141a",
} as const;
function card(fg: ThemeTokens["fg"], tone: ThemeTokens["tone"]): ThemeTokens["card"] {
return {
user: { color: fg.meta, glyph: "◇" },
reasoning: { color: tone.accent, glyph: "◆" },
streaming: { color: tone.brand, glyph: "◈" },
task: { color: tone.warn, glyph: "▶" },
tool: { color: tone.info, glyph: "▣" },
plan: { color: tone.accent, glyph: "⊞" },
diff: { color: tone.ok, glyph: "±" },
error: { color: tone.err, glyph: "✖" },
warn: { color: tone.warn, glyph: "⚠" },
usage: { color: fg.meta, glyph: "Σ" },
subagent: { color: tone.violet, glyph: "⌬" },
approval: { color: tone.warn, glyph: "?" },
search: { color: tone.info, glyph: "⊙" },
memory: { color: fg.meta, glyph: "⌑" },
ctx: { color: tone.brand, glyph: "◔" },
doctor: { color: fg.meta, glyph: "⚕" },
branch: { color: tone.violet, glyph: "⎇" },
};
}
export const CARD = {
user: { color: FG.meta, glyph: "◇" },
reasoning: { color: TONE.accent, glyph: "◆" },
streaming: { color: TONE.brand, glyph: "◈" },
task: { color: TONE.warn, glyph: "▶" },
tool: { color: TONE.info, glyph: "▣" },
plan: { color: TONE.accent, glyph: "⊞" },
diff: { color: TONE.ok, glyph: "±" },
error: { color: TONE.err, glyph: "✖" },
warn: { color: TONE.warn, glyph: "⚠" },
usage: { color: FG.meta, glyph: "Σ" },
subagent: { color: TONE.violet, glyph: "⌬" },
approval: { color: TONE.warn, glyph: "?" },
search: { color: TONE.info, glyph: "⊙" },
memory: { color: FG.meta, glyph: "⌑" },
ctx: { color: TONE.brand, glyph: "◔" },
doctor: { color: FG.meta, glyph: "⚕" },
branch: { color: TONE.violet, glyph: "⎇" },
} as const;
function defineTheme(base: ThemeBase): ThemeTokens {
return { ...base, card: card(base.fg, base.tone) };
}
export type CardTone = keyof typeof CARD;
const githubDark = defineTheme({
fg: {
strong: "#e6edf3",
body: "#c9d1d9",
sub: "#8b949e",
meta: "#6e7681",
faint: "#484f58",
},
tone: {
brand: "#79c0ff",
accent: "#d2a8ff",
violet: "#b395f5",
ok: "#7ee787",
warn: "#f0b07d",
err: "#ff8b81",
info: "#79c0ff",
},
toneActive: {
brand: "#a5d6ff",
accent: "#e2c5ff",
violet: "#c8aaff",
ok: "#a8f5ad",
warn: "#ffc99e",
err: "#ffaba3",
info: "#a5d6ff",
},
surface: {
bg: "#0a0c10",
bgInput: "#0d1015",
bgCode: "#06080c",
bgElev: "#11141a",
},
});
const dark = defineTheme({
fg: {
strong: "#f4f7fb",
body: "#d8dee9",
sub: "#a7b1c2",
meta: "#778294",
faint: "#4d5666",
},
tone: {
brand: "#7dd3fc",
accent: "#c084fc",
violet: "#a78bfa",
ok: "#86efac",
warn: "#fbbf24",
err: "#f87171",
info: "#60a5fa",
},
toneActive: {
brand: "#bae6fd",
accent: "#e9d5ff",
violet: "#ddd6fe",
ok: "#bbf7d0",
warn: "#fde68a",
err: "#fecaca",
info: "#bfdbfe",
},
surface: {
bg: "#0b1020",
bgInput: "#111827",
bgCode: "#080c16",
bgElev: "#151d2f",
},
});
const light = defineTheme({
fg: {
strong: "#111827",
body: "#1f2937",
sub: "#4b5563",
meta: "#6b7280",
faint: "#9ca3af",
},
tone: {
brand: "#2563eb",
accent: "#7c3aed",
violet: "#6d28d9",
ok: "#15803d",
warn: "#b45309",
err: "#dc2626",
info: "#0369a1",
},
toneActive: {
brand: "#1d4ed8",
accent: "#6d28d9",
violet: "#5b21b6",
ok: "#166534",
warn: "#92400e",
err: "#b91c1c",
info: "#075985",
},
surface: {
bg: "#ffffff",
bgInput: "#f8fafc",
bgCode: "#f3f4f6",
bgElev: "#eef2f7",
},
});
const tokyoNight = defineTheme({
fg: {
strong: "#c0caf5",
body: "#a9b1d6",
sub: "#9aa5ce",
meta: "#565f89",
faint: "#414868",
},
tone: {
brand: "#7aa2f7",
accent: "#bb9af7",
violet: "#9d7cd8",
ok: "#9ece6a",
warn: "#e0af68",
err: "#f7768e",
info: "#2ac3de",
},
toneActive: {
brand: "#a9c7ff",
accent: "#d7b9ff",
violet: "#c6a0f6",
ok: "#b9f27c",
warn: "#ffd089",
err: "#ff9cac",
info: "#7dcfff",
},
surface: {
bg: "#1a1b26",
bgInput: "#1f2335",
bgCode: "#16161e",
bgElev: "#24283b",
},
});
const githubLight = defineTheme({
fg: {
strong: "#1f2328",
body: "#24292f",
sub: "#57606a",
meta: "#6e7781",
faint: "#8c959f",
},
tone: {
brand: "#0969da",
accent: "#8250df",
violet: "#6639ba",
ok: "#1a7f37",
warn: "#9a6700",
err: "#cf222e",
info: "#0969da",
},
toneActive: {
brand: "#0550ae",
accent: "#6639ba",
violet: "#512a97",
ok: "#116329",
warn: "#7d4e00",
err: "#a40e26",
info: "#0550ae",
},
surface: {
bg: "#ffffff",
bgInput: "#f6f8fa",
bgCode: "#f6f8fa",
bgElev: "#eaeef2",
},
});
const highContrast = defineTheme({
fg: {
strong: "#ffffff",
body: "#f5f5f5",
sub: "#d4d4d4",
meta: "#bdbdbd",
faint: "#8a8a8a",
},
tone: {
brand: "#00e5ff",
accent: "#ff4dff",
violet: "#b388ff",
ok: "#00ff66",
warn: "#ffdd00",
err: "#ff4d4d",
info: "#4da3ff",
},
toneActive: {
brand: "#80f2ff",
accent: "#ff99ff",
violet: "#d0b3ff",
ok: "#80ffb3",
warn: "#ffee80",
err: "#ff9999",
info: "#99c9ff",
},
surface: {
bg: "#000000",
bgInput: "#0a0a0a",
bgCode: "#050505",
bgElev: "#141414",
},
});
export const THEMES = {
default: githubDark,
dark,
light,
"tokyo-night": tokyoNight,
"github-dark": githubDark,
"github-light": githubLight,
"high-contrast": highContrast,
} as const satisfies Record<ThemeName, ThemeTokens>;
export const DEFAULT_THEME_NAME: ThemeName = "default";
export function isThemeName(value: string): value is ThemeName {
return Object.prototype.hasOwnProperty.call(THEMES, value);
}
export function resolveThemeName(value?: string | null): ThemeName {
if (!value || value === "auto") return DEFAULT_THEME_NAME;
return isThemeName(value) ? value : DEFAULT_THEME_NAME;
}
export function listThemeNames(): ThemeName[] {
return Object.keys(THEMES) as ThemeName[];
}
export function themeTokens(name?: string | null): ThemeTokens {
return THEMES[resolveThemeName(name)];
}
export const DEFAULT_THEME = THEMES[DEFAULT_THEME_NAME];
let activeTheme: ThemeTokens = DEFAULT_THEME;
let activeThemeVersion = 0;
export function setActiveTheme(theme: ThemeTokens): () => void {
const previousTheme = activeTheme;
activeTheme = theme;
activeThemeVersion += 1;
const version = activeThemeVersion;
return () => {
if (activeThemeVersion !== version || activeTheme !== theme) return;
activeTheme = previousTheme;
activeThemeVersion += 1;
};
}
function proxyTokens<T extends object>(select: (theme: ThemeTokens) => T): T {
const target = select(DEFAULT_THEME);
return new Proxy(target, {
get(_target, prop: string | symbol) {
return select(activeTheme)[prop as keyof T];
},
getOwnPropertyDescriptor(_target, prop: string | symbol) {
return Reflect.getOwnPropertyDescriptor(select(activeTheme), prop);
},
has(_target, prop: string | symbol) {
return prop in select(activeTheme);
},
ownKeys() {
return Reflect.ownKeys(select(activeTheme));
},
});
}
export const FG = proxyTokens((theme) => theme.fg);
export const TONE = proxyTokens((theme) => theme.tone);
export const TONE_ACTIVE = proxyTokens((theme) => theme.toneActive);
export const SURFACE = proxyTokens((theme) => theme.surface);
export const CARD = proxyTokens((theme) => theme.card);
export type CardTone = keyof ThemeTokens["card"];
/** DeepSeek prices in CNY; our internal table is USD divided by 7.2. Multiply back for display. */
export const USD_TO_CNY = 7.2;
+15
View File
@@ -118,6 +118,21 @@ function summarizeKnownTool(toolName: string, content: string): ToolSummary | nu
const bytes = formatBytes(content.length);
return { summary: `wrote ${lines} · ${bytes}`, isError: false };
}
if (hasSuffix("multi_edit")) {
const m = content.match(/applied (\d+) edits? to (\S+)/);
if (m) {
return { summary: `${m[1]} edit${m[1] === "1" ? "" : "s"} · ${m[2]}`, isError: false };
}
return { summary: clip(firstNonEmptyLine(content), MAX_SUMMARY_CHARS), isError: false };
}
if (hasSuffix("todo_write")) {
if (/^todos cleared/.test(content)) return { summary: "todos cleared", isError: false };
const m = content.match(/^todos updated · (\d+) done · (\d+) in progress · (\d+) pending/);
if (m) {
return { summary: `${m[1]} done · ${m[2]} in progress · ${m[3]} pending`, isError: false };
}
return { summary: clip(firstNonEmptyLine(content), MAX_SUMMARY_CHARS), isError: false };
}
if (hasSuffix("run_command") || hasSuffix("run_background")) {
// Native shell tools prepend "exit 0:" / "exit N:" or the result
// already mentions exit code. Try to surface it.
+223 -60
View File
@@ -1,14 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import {
type DirEntry,
type FileWithStats,
type ParsedAtQuery,
detectAtPicker,
listFilesWithStatsAsync,
listDirectory,
parseAtQuery,
rankPickerCandidates,
walkFilesStream,
} from "../../at-mentions.js";
import {
type McpServerSummary,
type SlashArgContext,
type SlashCommandSpec,
countAdvancedCommands,
detectSlashArgContext,
suggestSlashCommands,
} from "./slash.js";
@@ -21,20 +26,45 @@ export interface UseCompletionPickersParams {
rootDir: string;
models: string[] | null;
mcpServers: McpServerSummary[] | undefined;
/** Cross-session slash invocation counts — used to sort suggestions by frequency. */
slashUsage?: Readonly<Record<string, number>>;
}
export interface AtPickerEntry {
/** Basename — what the row leads with. */
label: string;
/** Path the picker substitutes into the buffer (no leading @). */
insertPath: string;
/** Dim suffix shown after the label ("src/auth/" for "src/auth/login.ts" search hits). Empty in browse mode. */
dirSuffix: string;
isDir: boolean;
}
export type AtPickerState =
| { kind: "browse"; baseDir: string; entries: readonly AtPickerEntry[]; loading: boolean }
| {
kind: "search";
filter: string;
entries: readonly AtPickerEntry[];
scanned: number;
searching: boolean;
};
export interface UseCompletionPickersResult {
// ── slash-name picker ──
slashMatches: SlashCommandSpec[] | null;
slashSelected: number;
setSlashSelected: React.Dispatch<React.SetStateAction<number>>;
/** True when the input is exactly `/` — palette renders group headers. */
slashGroupMode: boolean;
/** Count of advanced commands hidden behind the "type to search" footer hint. */
slashAdvancedHidden: number;
// ── @-mention picker ──
atPicker: ReturnType<typeof detectAtPicker>;
atMatches: readonly string[] | null;
atState: AtPickerState | null;
atSelected: number;
setAtSelected: React.Dispatch<React.SetStateAction<number>>;
pickAtMention: (chosenPath: string) => void;
pickAtMention: (entry: AtPickerEntry, action: "commit" | "drill") => void;
recordRecentFile: (path: string) => void;
// ── slash-arg picker ──
@@ -45,6 +75,10 @@ export interface UseCompletionPickersResult {
pickSlashArg: (chosen: string) => void;
}
const SEARCH_DEBOUNCE_MS = 80;
const SEARCH_FLUSH_MS = 50;
const SEARCH_RESULT_CAP = 200;
/** Picker priority: @ > slash-arg > slash-name. Detection already disambiguates by buffer shape. */
export function useCompletionPickers({
input,
@@ -53,13 +87,19 @@ export function useCompletionPickers({
rootDir,
models,
mcpServers,
slashUsage,
}: UseCompletionPickersParams): UseCompletionPickersResult {
// ── slash-name picker ──
const [slashSelected, setSlashSelected] = useState(0);
const slashMatches = useMemo(() => {
if (!input.startsWith("/") || input.includes(" ")) return null;
return suggestSlashCommands(input.slice(1), !!codeMode);
}, [input, codeMode]);
return suggestSlashCommands(input.slice(1), !!codeMode, slashUsage);
}, [input, codeMode, slashUsage]);
const slashGroupMode = input === "/";
const slashAdvancedHidden = useMemo(
() => (slashGroupMode ? countAdvancedCommands(!!codeMode) : 0),
[slashGroupMode, codeMode],
);
useEffect(() => {
setSlashSelected((prev) => {
if (!slashMatches || slashMatches.length === 0) return 0;
@@ -70,36 +110,6 @@ export function useCompletionPickers({
// ── @-mention picker ──
const [atSelected, setAtSelected] = useState(0);
// Walk the code root asynchronously after first paint. Earlier
// versions used `listFilesWithStatsSync` inside `useMemo`, which
// blocked mount for 100-300ms on Windows monorepos (one statSync
// per file × 500 files). Async + parallel-stat-per-directory
// takes the cost off the critical path; the picker stays empty
// for the brief window before the walk completes (typically
// <200ms), and fills in atomically once Promise.all resolves.
// Files created mid-session via tool edits still won't appear
// until restart — rare; the user can type the full path.
const [atFiles, setAtFiles] = useState<readonly FileWithStats[]>([]);
useEffect(() => {
if (!codeMode) {
setAtFiles([]);
return;
}
let cancelled = false;
listFilesWithStatsAsync(rootDir, { maxResults: 500 })
.then((files) => {
if (!cancelled) setAtFiles(files);
})
.catch(() => {
if (!cancelled) setAtFiles([]);
});
return () => {
cancelled = true;
};
}, [codeMode, rootDir]);
// LRU of files touched by recent tool calls. Seeds the picker with
// "stuff I just looked at" at the top, so a user typing `@` in the
// same file they were just discussing gets it instantly.
const recentFilesRef = useRef<string[]>([]);
const recordRecentFile = useCallback((p: string) => {
const list = recentFilesRef.current;
@@ -108,32 +118,67 @@ export function useCompletionPickers({
list.unshift(p);
if (list.length > 20) list.length = 20;
}, []);
const atPicker = useMemo(() => {
if (!codeMode) return null;
// Slash prefix wins — avoids the picker confusingly surfacing on
// `/@wat`-style edge inputs.
if (slashMatches !== null) return null;
return detectAtPicker(input);
}, [codeMode, input, slashMatches]);
const atMatches = useMemo<readonly string[] | null>(() => {
if (!atPicker) return null;
return rankPickerCandidates(atFiles, atPicker.query, {
limit: 40,
recentlyUsed: recentFilesRef.current,
});
}, [atPicker, atFiles]);
const parsed = useMemo<ParsedAtQuery | null>(
() => (atPicker ? parseAtQuery(atPicker.query) : null),
[atPicker],
);
const atMode: "browse" | "search" | null = parsed
? parsed.trailingSlash || parsed.filter === ""
? "browse"
: "search"
: null;
const browseDir = parsed && atMode === "browse" ? parsed.dir : "";
const browse = useBrowseListing(rootDir, atMode === "browse" ? browseDir : null);
const search = useStreamingSearch(
rootDir,
atMode === "search" && parsed ? parsed.filter : null,
recentFilesRef,
);
const atState = useMemo<AtPickerState | null>(() => {
if (!parsed) return null;
if (atMode === "browse") {
return {
kind: "browse",
baseDir: browseDir,
entries: browse.entries,
loading: browse.loading,
};
}
return {
kind: "search",
filter: parsed.filter,
entries: search.entries,
scanned: search.scanned,
searching: search.searching,
};
}, [parsed, atMode, browseDir, browse, search]);
useEffect(() => {
setAtSelected((prev) => {
if (!atMatches || atMatches.length === 0) return 0;
if (prev >= atMatches.length) return atMatches.length - 1;
const len = atState?.entries.length ?? 0;
if (len === 0) return 0;
if (prev >= len) return len - 1;
return prev;
});
}, [atMatches]);
}, [atState]);
const pickAtMention = useCallback(
(chosenPath: string) => {
(entry: AtPickerEntry, action: "commit" | "drill") => {
if (!atPicker) return;
const before = input.slice(0, atPicker.atOffset);
setInput(`${before}@${chosenPath} `);
const tail =
action === "drill" && entry.isDir ? `${entry.insertPath}/` : `${entry.insertPath} `;
setInput(`${before}@${tail}`);
},
[atPicker, input, setInput],
);
@@ -150,10 +195,6 @@ export function useCompletionPickers({
const completer = slashArgContext.spec.argCompleter;
const partial = slashArgContext.partial;
const needle = partial.toLowerCase();
// Once the partial is an EXACT match for a valid completion, hide
// the picker so Enter submits the command instead of re-picking
// the same value in an infinite loop. Case-insensitive — users
// may type `/preset FAST` and still mean the same thing.
if (Array.isArray(completer)) {
if (partial && completer.some((v) => v.toLowerCase() === needle)) return null;
if (!partial) return completer.slice();
@@ -166,7 +207,6 @@ export function useCompletionPickers({
return all.filter((m) => m.toLowerCase().includes(needle)).slice(0, 40);
}
if (completer === "mcp-resources") {
// Aggregate URIs across every server's cached inspection.
const uris: string[] = [];
const servers = mcpServers ?? [];
for (const s of servers) {
@@ -201,8 +241,6 @@ export function useCompletionPickers({
(chosen: string) => {
if (!slashArgContext) return;
const before = input.slice(0, slashArgContext.partialOffset);
// No trailing space — enum picks take no further args, so the
// user presses Enter once more to run the command.
setInput(`${before}${chosen}`);
},
[slashArgContext, input, setInput],
@@ -212,8 +250,9 @@ export function useCompletionPickers({
slashMatches,
slashSelected,
setSlashSelected,
atPicker,
atMatches,
slashGroupMode,
slashAdvancedHidden,
atState,
atSelected,
setAtSelected,
pickAtMention,
@@ -225,3 +264,127 @@ export function useCompletionPickers({
pickSlashArg,
};
}
function useBrowseListing(rootDir: string, dir: string | null) {
const [entries, setEntries] = useState<readonly AtPickerEntry[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (dir === null) {
setEntries([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
listDirectory(rootDir, dir).then(
(raw) => {
if (cancelled) return;
setEntries(raw.map(toBrowseEntry));
setLoading(false);
},
() => {
if (cancelled) return;
setEntries([]);
setLoading(false);
},
);
return () => {
cancelled = true;
};
}, [rootDir, dir]);
return { entries, loading };
}
function toBrowseEntry(d: DirEntry): AtPickerEntry {
return { label: d.name, insertPath: d.path, dirSuffix: "", isDir: d.isDir };
}
function useStreamingSearch(
rootDir: string,
filter: string | null,
recentFilesRef: React.RefObject<string[]>,
) {
const [, bumpRender] = useReducer((x: number) => x + 1, 0);
const hitsRef = useRef<FileWithStats[]>([]);
const scannedRef = useRef(0);
const searchingRef = useRef(false);
const rankedRef = useRef<readonly AtPickerEntry[]>([]);
useEffect(() => {
if (filter === null) {
hitsRef.current = [];
scannedRef.current = 0;
searchingRef.current = false;
rankedRef.current = [];
bumpRender();
return;
}
hitsRef.current = [];
scannedRef.current = 0;
searchingRef.current = true;
rankedRef.current = [];
bumpRender();
const ac = new AbortController();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleFlush = () => {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
rankedRef.current = rankSearchHits(hitsRef.current, filter, recentFilesRef.current ?? []);
bumpRender();
}, SEARCH_FLUSH_MS);
};
const debounce = setTimeout(() => {
walkFilesStream(rootDir, {
signal: ac.signal,
onEntry: (e) => {
hitsRef.current.push(e);
if (hitsRef.current.length >= SEARCH_RESULT_CAP * 8) return false;
scheduleFlush();
},
onProgress: (n) => {
scannedRef.current = n;
scheduleFlush();
},
}).then(() => {
searchingRef.current = false;
rankedRef.current = rankSearchHits(hitsRef.current, filter, recentFilesRef.current ?? []);
bumpRender();
});
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(debounce);
if (flushTimer) clearTimeout(flushTimer);
ac.abort();
};
}, [rootDir, filter, recentFilesRef]);
return {
entries: rankedRef.current,
scanned: scannedRef.current,
searching: searchingRef.current,
};
}
function rankSearchHits(
hits: readonly FileWithStats[],
filter: string,
recent: readonly string[],
): readonly AtPickerEntry[] {
const ranked = rankPickerCandidates(hits, filter, {
limit: SEARCH_RESULT_CAP,
recentlyUsed: recent,
});
return ranked.map((path) => {
const slash = path.lastIndexOf("/");
return {
label: slash >= 0 ? path.slice(slash + 1) : path,
insertPath: path,
dirSuffix: slash >= 0 ? `${path.slice(0, slash)}/` : "",
isDir: false,
};
});
}
+17 -3
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { applyMemoryStack } from "../memory/user.js";
import { ESCALATION_CONTRACT, TUI_FORMATTING_RULES } from "../prompt-fragments.js";
export const CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, edit_file, list_directory, directory_tree, search_files, search_content, get_file_info) rooted at the user's working directory, plus run_command / run_background for shell.
export const CODE_SYSTEM_PROMPT = `You are Reasonix Code, a coding assistant. You have filesystem tools (read_file, write_file, edit_file, multi_edit, list_directory, directory_tree, search_files, search_content, glob, get_file_info) rooted at the user's working directory, plus run_command / run_background for shell, plus \`todo_write\` for in-session multi-step tracking.
# Cite or shut up — non-negotiable
@@ -51,10 +51,23 @@ Skip it when one option is clearly correct (just do it, or submit_plan) or a fre
Each option: short stable id (A/B/C), one-line title, optional summary. \`allowCustom: true\` when their real answer might not fit. Max 6. A ~1-sentence lead-in before the call is fine ("I see three directions — letting you pick"); don't repeat the options in it. After the call, STOP.
# When to track multi-step intent (todo_write)
\`todo_write\` is a lightweight in-session task tracker — NOT a plan. No approval gate, no checkpoint pauses, doesn't touch files. Use it when the task has 3+ distinct steps and you'd otherwise lose track of where you are. Each call REPLACES the entire list (set semantics). Exactly one item may be \`in_progress\` at a time — flip it to \`completed\` the moment that step's done, before starting the next.
Use it for:
- Multi-part user requests ("do A, then B, then C") — record the parts so you don't drop one.
- Long refactors where you've finished step 2 of 5 and want a visible record.
- Any moment where you'd otherwise enumerate "1. ... 2. ... 3. ..." in prose — the tool is strictly better, the UI shows progress live.
Skip it for: one-shot edits, single-question answers, anything that fits in one tool call. Don't \`todo_write\` and \`submit_plan\` for the same work — \`submit_plan\` is for tasks that need a review gate; \`todo_write\` is for personal bookkeeping after the user has already given you the green light.
Call shape: \`{ todos: [{ content, activeForm, status }, ...] }\` — \`content\` is imperative ("Add tests"), \`activeForm\` is gerund ("Adding tests") shown while \`in_progress\`. Pass the FULL list every call, not a delta. Pass \`todos: []\` to clear when work's done.
# Plan mode (/plan)
The user can ALSO enter "plan mode" via /plan, which is a stronger, explicit constraint:
- Write tools (edit_file, write_file, create_directory, move_file) and non-allowlisted run_command calls are BOUNCED at dispatch — you'll get a tool result like "unavailable in plan mode". Don't retry them.
- Write tools (edit_file, multi_edit, write_file, create_directory, move_file) and non-allowlisted run_command calls are BOUNCED at dispatch — you'll get a tool result like "unavailable in plan mode". Don't retry them.
- Read tools (read_file, list_directory, search_files, directory_tree, get_file_info) and allowlisted read-only / test shell commands still work — use them to investigate.
- You MUST call submit_plan before anything will execute. Approve exits plan mode; Refine stays in; Cancel exits without implementing.
@@ -123,6 +136,7 @@ Rules:
>>>>>>> REPLACE
- Do NOT use write_file to change existing files — the user reviews your edits as SEARCH/REPLACE. write_file is only for files you explicitly want to overwrite wholesale (rare).
- Paths are relative to the working directory. Don't use absolute paths.
- For multi-site changes — same file or across files — prefer \`multi_edit\` over N \`edit_file\` calls. Shape: \`{ edits: [{ path, search, replace }, ...] }\`. All edits validate before any file is written; any failure → ALL files untouched. Per-file edits run in array order, so a later edit can match text inserted by an earlier one.
# Trust what you already know
@@ -132,7 +146,7 @@ Before exploring the filesystem to answer a factual question, check whether the
- Skip dependency, build, and VCS directories unless the user explicitly asks. The pinned .gitignore block (if any, below) is your authoritative denylist.
- Prefer \`search_files\` over \`list_directory\` when you know roughly what you're looking for — it saves context and avoids enumerating huge trees. Note: \`search_files\` matches file NAMES; for searching file CONTENTS use \`search_content\`.
- Available exploration tools: \`read_file\`, \`list_directory\`, \`directory_tree\`, \`search_files\` (filename match), \`search_content\` (content grep — use for "where is X called", "find all references to Y"), \`get_file_info\`. Don't call \`grep\` or other tools that aren't in this list — they don't exist as functions.
- Available exploration tools: \`read_file\`, \`list_directory\`, \`directory_tree\`, \`search_files\` (filename match), \`glob\` (mtime-sorted glob — use for "what changed lately", "all *.ts under src/"), \`search_content\` (content grep — use for "where is X called", "find all references to Y"; pass \`context:N\` for grep -C N around hits), \`get_file_info\`. Don't call \`grep\` or other tools that aren't in this list — they don't exist as functions.
# Path conventions
+238 -11
View File
@@ -3,6 +3,7 @@
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { type ThemeName, isThemeName, resolveThemeName } from "./cli/ui/theme/tokens.js";
import type { LanguageCode } from "./i18n/types.js";
import {
type IndexUserConfig,
@@ -18,6 +19,61 @@ export type EditMode = "review" | "auto" | "yolo";
export type ReasoningEffort = "high" | "max";
export type EmbeddingProvider = "ollama" | "openai-compat";
export interface OllamaEmbeddingUserConfig {
baseUrl?: string;
model?: string;
}
export interface OpenAICompatEmbeddingUserConfig {
baseUrl?: string;
apiKey?: string;
model?: string;
extraBody?: Record<string, unknown>;
}
export interface SemanticEmbeddingUserConfig {
provider?: EmbeddingProvider;
ollama?: OllamaEmbeddingUserConfig;
openaiCompat?: OpenAICompatEmbeddingUserConfig;
}
export interface ResolvedOllamaEmbeddingConfig {
provider: "ollama";
baseUrl: string;
model: string;
timeoutMs: number;
}
export interface ResolvedOpenAICompatEmbeddingConfig {
provider: "openai-compat";
baseUrl: string;
apiKey: string;
model: string;
extraBody: Record<string, unknown>;
timeoutMs: number;
}
export type ResolvedEmbeddingConfig =
| ResolvedOllamaEmbeddingConfig
| ResolvedOpenAICompatEmbeddingConfig;
export interface SemanticEmbeddingConfigView {
provider: EmbeddingProvider;
ollama: {
baseUrl: string;
model: string;
};
openaiCompat: {
baseUrl: string;
apiKey: string;
apiKeySet: boolean;
model: string;
extraBody: Record<string, unknown>;
};
}
export interface ReasonixConfig {
apiKey?: string;
baseUrl?: string;
@@ -25,7 +81,9 @@ export interface ReasonixConfig {
preset?: PresetName;
editMode?: EditMode;
editModeHintShown?: boolean;
mouseClipboardHintShown?: boolean;
reasoningEffort?: ReasoningEffort;
theme?: ThemeName | "auto";
/** Stored as `--mcp`-format strings so one parser handles both flag and config. */
mcp?: string[];
/** Names of servers in `mcp` to skip on bridge — see `/mcp disable <name>`. */
@@ -43,8 +101,13 @@ export interface ReasonixConfig {
};
};
index?: IndexUserConfig;
semantic?: SemanticEmbeddingUserConfig;
}
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
const DEFAULT_EMBED_MODEL = "nomic-embed-text";
const DEFAULT_TIMEOUT_MS = 30_000;
export function defaultConfigPath(): string {
return join(homedir(), ".reasonix", "config.json");
}
@@ -63,7 +126,6 @@ export function readConfig(path: string = defaultConfigPath()): ReasonixConfig {
export function writeConfig(cfg: ReasonixConfig, path: string = defaultConfigPath()): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
// Restrict permissions on Unix; chmod is a no-op on Windows but won't throw.
try {
chmodSync(path, 0o600);
} catch {
@@ -115,12 +177,27 @@ export function saveApiKey(key: string, path: string = defaultConfigPath()): voi
writeConfig(cfg, path);
}
/** Windows: case-insensitive — NTFS treats `F:\Foo` and `f:\foo` as one directory (#402). */
function findProjectKey(cfg: ReasonixConfig, rootDir: string): string | undefined {
const projects = cfg.projects;
if (!projects) return undefined;
if (Object.hasOwn(projects, rootDir)) return rootDir;
if (process.platform !== "win32") return undefined;
const lower = rootDir.toLowerCase();
for (const k of Object.keys(projects)) {
if (k.toLowerCase() === lower) return k;
}
return undefined;
}
export function loadProjectShellAllowed(
rootDir: string,
path: string = defaultConfigPath(),
): string[] {
const cfg = readConfig(path);
return cfg.projects?.[rootDir]?.shellAllowed ?? [];
const key = findProjectKey(cfg, rootDir);
if (key === undefined) return [];
return cfg.projects?.[key]?.shellAllowed ?? [];
}
export function addProjectShellAllowed(
@@ -132,10 +209,11 @@ export function addProjectShellAllowed(
if (!trimmed) return;
const cfg = readConfig(path);
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
const existing = cfg.projects[rootDir].shellAllowed ?? [];
const key = findProjectKey(cfg, rootDir) ?? rootDir;
if (!cfg.projects[key]) cfg.projects[key] = {};
const existing = cfg.projects[key].shellAllowed ?? [];
if (existing.includes(trimmed)) return;
cfg.projects[rootDir].shellAllowed = [...existing, trimmed];
cfg.projects[key].shellAllowed = [...existing, trimmed];
writeConfig(cfg, path);
}
@@ -148,12 +226,14 @@ export function removeProjectShellAllowed(
const trimmed = prefix.trim();
if (!trimmed) return false;
const cfg = readConfig(path);
const existing = cfg.projects?.[rootDir]?.shellAllowed ?? [];
const key = findProjectKey(cfg, rootDir);
if (key === undefined) return false;
const existing = cfg.projects?.[key]?.shellAllowed ?? [];
if (!existing.includes(trimmed)) return false;
const next = existing.filter((p) => p !== trimmed);
if (!cfg.projects) cfg.projects = {};
if (!cfg.projects[rootDir]) cfg.projects[rootDir] = {};
cfg.projects[rootDir].shellAllowed = next;
if (!cfg.projects[key]) cfg.projects[key] = {};
cfg.projects[key].shellAllowed = next;
writeConfig(cfg, path);
return true;
}
@@ -163,11 +243,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;
}
@@ -190,12 +272,38 @@ export function editModeHintShown(path: string = defaultConfigPath()): boolean {
return readConfig(path).editModeHintShown === true;
}
/** True when the mouse-tracking + clipboard tip has been shown. */
export function mouseClipboardHintShown(path: string = defaultConfigPath()): boolean {
return readConfig(path).mouseClipboardHintShown === true;
}
/** Unknown / missing fall back to "max" so hand-edited bad config can't silently override the default. */
export function loadReasoningEffort(path: string = defaultConfigPath()): ReasoningEffort {
const v = readConfig(path).reasoningEffort;
return v === "high" ? "high" : "max";
}
export function loadTheme(path: string = defaultConfigPath()): ThemeName | "auto" | undefined {
const value = readConfig(path).theme;
if (value === "auto") return "auto";
if (typeof value === "string" && isThemeName(value)) return value;
return undefined;
}
export function resolveThemePreference(
configTheme: ThemeName | "auto" | undefined,
envTheme?: string | null,
): ThemeName {
if (configTheme && configTheme !== "auto") return configTheme;
return resolveThemeName(envTheme);
}
export function saveTheme(theme: ThemeName | "auto", path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
cfg.theme = theme;
writeConfig(cfg, path);
}
/** Persist the reasoning_effort cap so `/effort high` survives a relaunch. */
export function saveReasoningEffort(
effort: ReasoningEffort,
@@ -220,6 +328,72 @@ export function saveIndexConfig(user: IndexUserConfig, path: string = defaultCon
writeConfig(cfg, path);
}
export function loadSemanticEmbeddingUserConfig(
path: string = defaultConfigPath(),
): SemanticEmbeddingUserConfig {
return normalizeSemanticEmbeddingUserConfig(readConfig(path).semantic);
}
export function saveSemanticEmbeddingConfig(
user: SemanticEmbeddingUserConfig,
path: string = defaultConfigPath(),
): void {
const cfg = readConfig(path);
cfg.semantic = normalizeSemanticEmbeddingUserConfig(user);
writeConfig(cfg, path);
}
export function resolveSemanticEmbeddingConfig(
path: string = defaultConfigPath(),
): ResolvedEmbeddingConfig {
const user = loadSemanticEmbeddingUserConfig(path);
const provider = user.provider ?? "ollama";
if (provider === "openai-compat") {
const baseUrl = user.openaiCompat?.baseUrl?.trim() ?? "";
const apiKey = user.openaiCompat?.apiKey?.trim() ?? "";
const model = user.openaiCompat?.model?.trim() ?? "";
if (!baseUrl) throw new Error("OpenAI-compatible embeddings require an API URL.");
requireValidUrl(baseUrl, "OpenAI-compatible API URL");
if (!apiKey) throw new Error("OpenAI-compatible embeddings require an API key.");
if (!model) throw new Error("OpenAI-compatible embeddings require a model.");
return {
provider,
baseUrl,
apiKey,
model,
extraBody: normalizeExtraBody(user.openaiCompat?.extraBody),
timeoutMs: DEFAULT_TIMEOUT_MS,
};
}
return {
provider: "ollama",
baseUrl: user.ollama?.baseUrl?.trim() || process.env.OLLAMA_URL || DEFAULT_OLLAMA_URL,
model: user.ollama?.model?.trim() || process.env.REASONIX_EMBED_MODEL || DEFAULT_EMBED_MODEL,
timeoutMs: DEFAULT_TIMEOUT_MS,
};
}
export function redactSemanticEmbeddingConfig(
user: SemanticEmbeddingUserConfig,
): SemanticEmbeddingConfigView {
const normalized = normalizeSemanticEmbeddingUserConfig(user);
return {
provider: normalized.provider ?? "ollama",
ollama: {
baseUrl: normalized.ollama?.baseUrl?.trim() || process.env.OLLAMA_URL || DEFAULT_OLLAMA_URL,
model:
normalized.ollama?.model?.trim() || process.env.REASONIX_EMBED_MODEL || DEFAULT_EMBED_MODEL,
},
openaiCompat: {
baseUrl: normalized.openaiCompat?.baseUrl?.trim() ?? "",
apiKey: normalized.openaiCompat?.apiKey ? redactKey(normalized.openaiCompat.apiKey) : "",
apiKeySet: Boolean(normalized.openaiCompat?.apiKey?.trim()),
model: normalized.openaiCompat?.model?.trim() ?? "",
extraBody: normalizeExtraBody(normalized.openaiCompat?.extraBody),
},
};
}
/** Mark the onboarding tip as shown so subsequent launches skip it. */
export function markEditModeHintShown(path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
@@ -228,6 +402,14 @@ export function markEditModeHintShown(path: string = defaultConfigPath()): void
writeConfig(cfg, path);
}
/** Mark the mouse + clipboard tip as shown. */
export function markMouseClipboardHintShown(path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
if (cfg.mouseClipboardHintShown === true) return;
cfg.mouseClipboardHintShown = true;
writeConfig(cfg, path);
}
export function isPlausibleKey(key: string): boolean {
const trimmed = key.trim();
return /^sk-[A-Za-z0-9_-]{16,}$/.test(trimmed);
@@ -239,3 +421,48 @@ export function redactKey(key: string): string {
if (key.length <= 12) return "****";
return `${key.slice(0, 6)}…${key.slice(-4)}`;
}
function normalizeSemanticEmbeddingUserConfig(
cfg: SemanticEmbeddingUserConfig | undefined,
): SemanticEmbeddingUserConfig {
return {
provider: cfg?.provider === "openai-compat" ? "openai-compat" : "ollama",
ollama: {
baseUrl: normalizeOptionalString(cfg?.ollama?.baseUrl),
model: normalizeOptionalString(cfg?.ollama?.model),
},
openaiCompat: {
baseUrl: normalizeOptionalString(cfg?.openaiCompat?.baseUrl),
apiKey: normalizeOptionalString(cfg?.openaiCompat?.apiKey),
model: normalizeOptionalString(cfg?.openaiCompat?.model),
extraBody: normalizeExtraBody(cfg?.openaiCompat?.extraBody),
},
};
}
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function normalizeExtraBody(value: Record<string, unknown> | undefined): Record<string, unknown> {
if (value === undefined) return {};
if (!isPlainObject(value)) {
throw new Error("Semantic embedding extraBody must be a JSON object.");
}
return { ...value };
}
function requireValidUrl(value: string, label: string): void {
try {
new URL(value);
} catch {
throw new Error(`${label} must be a valid URL.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more