Compare commits

...
198 Commits
Author SHA1 Message Date
YHH 736e615c8e docs(changelog): add WelcomeBanner overlay fix to 0.37.0 entry (#595)
Rolling #594 into 0.37.0 since the version was bumped but not yet
tagged or published to npm. Tag will land on this commit so the
published 0.37.0 contains the fix, no 0.37.1 needed.
2026-05-10 14:55:15 +08:00
YHH 447a31cc3a fix(ui): hide WelcomeBanner when slash menu is open (#594)
The empty-state guard checked !hasConversation && !busy && !isStreaming
but missed the slash-suggestions overlay. Pressing `/` from the home
screen left the bordered welcome card mounted in the flex column while
SlashSuggestions rendered below, producing interleaved frame output
where the card border drew through the menu rows.
2026-05-10 14:51:00 +08:00
YHH 87eb8f168f chore(release): 0.37.0 (#593) 2026-05-10 14:46:28 +08:00
YHH 2af4cb3fdd fix(prompt): escalation contract names the actual session model (#592)
ESCALATION_CONTRACT was a const with `deepseek-v4-flash` baked in,
interpolated into three system prompts at module load. A pro session
got told it was running on flash, so when the user asked "which model
are you?", pro answered "flash" — accurately reading what the prompt
told it.

- Convert ESCALATION_CONTRACT → escalationContract(modelId): pro tier
  gets a short "you are the escalation tier" note (no <<<NEEDS_PRO>>>
  ladder, since pro can't escalate to itself); other tiers get the
  contract with the actual model id interpolated, plus an explicit
  "if asked which model you are, answer `<id>`" line.
- DEFAULT_SYSTEM (cli/index.ts) → defaultSystemPrompt(modelId), called
  per chat / run / bare-reasonix invocation against the resolved model.
- CODE_SYSTEM_PROMPT split: the public-API const stays at the
  historical flash phrasing for backward compat; codeSystemPrompt now
  takes opts.modelId and the code command threads in opts.model.
- DEFAULT_SUBAGENT_SYSTEM split into a memory-stable base + per-spawn
  contract appended against the spawn's resolved model id.

Closes #582
2026-05-10 14:35:42 +08:00
YHH 2813af8329 fix(skills): hard-fail install + surface skills missing description (#591)
A skill whose frontmatter omits `description:` was silently dropped
from the prefix index, but `/skill <name>` still ran it from disk —
so it worked in the install session and vanished in the next one,
with no diagnostic.

- POST /api/skills/<scope>/<name> now validates frontmatter via
  validateSkillFrontmatter() and returns 400 instead of writing a
  skill the model will never see.
- applySkillsIndex no longer filters out blank-description skills;
  it injects them with a placeholder line that names them and tells
  the model to flag the missing frontmatter to the user.

Closes #583
2026-05-10 14:22:46 +08:00
YHH 79da127680 fix(loop): /new archives prior transcript instead of destroying it (#590)
clearLog() truncated the live session jsonl in place, so users running
/new lost every previous turn — the dashboard's Sessions tab faithfully
reported one row because there was one file. Rotate the live jsonl plus
sidecars to <name>__archive_<ts> so the prior conversation survives in
Sessions. sessionName is unchanged, so the prefix cache stays warm.
The __archive_ infix sits outside the ${name}- resume-prefix matcher,
so archives don't get auto-resumed on next launch.

Closes #587
2026-05-10 14:12:57 +08:00
paradoxSCH 8fab388608 fix(dashboard): list flat-format skills (#589) 2026-05-10 14:04:37 +08:00
YHH 484c40a02c perf(boot): defer MCP bridging to App.tsx mount (#585)
chatCommand used to `await runtime.addSpec(raw)` once per server
before render() — every handshake (100ms-2s) blocked the alt-screen
from coming up, so users with MCP configured saw a black screen
on startup.

Now mcpRuntime exposes a setLifecycleSink(sink) hook. chatCommand
hands the empty mcpServers array off to App immediately; App.tsx
swaps in a log-routing sink on mount and fires runtime.addSpec
(spec, loop) for each requested spec in the background. As each
server bridges, hot-add pushes its tools into loop.prefix and
setLiveMcpServers refreshes the /mcp panel live. Lifecycle events
(handshake / connected / failed / slow) flow into the in-app log
instead of stderr — stderr writes during alt-screen would corrupt
the UI.

UI now paints immediately even with N MCP servers configured.
Servers that fail to connect just stay unavailable; their tools
never bridge — same end state as before, just without the wait.
2026-05-10 13:36:56 +08:00
YHH 54dd2ea8b4 feat(ui): boot splash with REASONIX logo + animated whale (#588)
Adds src/cli/ui/BootSplash.tsx — a full-screen splash that AppInner
returns from before its first real paint, gated by a 1.4s timer
(one whale-spout cycle). Two purposes:

- Hides the brief alt-screen-blank gap on cold launch (heavy
  AppInner first-paint cost stays under the splash).
- Brand mark always lands clean instead of flashing raw mount
  state.

Splash content:
- ANSI Shadow REASONIX wordmark (6-row block letters, brand color)
- Three-tone shaded whale silhouette (░ outline / ▒ midtone /
  ▓ inner) with eye, mouth, tail flukes
- 7-frame spout animation cycling at 200ms (idle → rising →
  peak with droplets → dispersing)
- Wave line below shifting one column per frame
- 'loading...' indicator with cycling dots

App.tsx wires bootReady state + setTimeout, returns <BootSplash />
until ready.
2026-05-10 13:34:05 +08:00
Evan-Pycraft 07bddea41c feat(i18n): localize status bar, input prompt, and edit mode hints with zh-CN (#584) 2026-05-10 13:31:34 +08:00
YHH 08b7ced357 refactor(ui): extract useLanguageReload from App.tsx (#581)
useLanguageReload() returns a counter that increments on
runtime language changes — same shape as before, just owns its
own useState + onLanguageChange subscription. Lets App.tsx drop
the onLanguageChange import.

Step 7/7 of #565 phase 1. App.tsx LOC unchanged (one import line
in, two hook lines out).
2026-05-10 12:48:38 +08:00
YHH ffcc0443f1 refactor(ui): extract usePresetMode from App.tsx (#580)
usePresetMode(model) bundles the three model-mode states:
- preset: "auto" | "flash" | "pro" (initial seeded from model)
- proArmed: /pro pending for next turn
- turnOnPro: current turn is on v4-pro (arming consumed or
  ⇧ pro auto-escalation fired)

App.tsx loses three useState calls plus the inline narratives.
The states and their setters are destructured back so handleSubmit
+ startDashboard's handlers stay structurally identical.

Step 6/7 of #565 phase 1. App.tsx 3701 -> 3695.
2026-05-10 12:45:13 +08:00
YHH 3426f5a126 refactor(ui): extract useHookList from App.tsx (#579)
useHookList(initialProjectRoot) returns hookList + a reloadHooks
helper that bundles loadHooks() + state replacement. The two
slash callbacks (`/hooks reload`, `/cwd`) and the dashboard
mutation surface now call reloadHooks(projectRoot) instead of
duplicating the loadHooks + setHookList pattern.

App.tsx drops the loadHooks + ResolvedHook imports along with
the inline initial-load comment and the three duplicate
load-and-set blocks.

Step 5/7 of #565 phase 1. App.tsx 3715 -> 3701.
2026-05-10 12:38:17 +08:00
YHH 39685d0ab3 refactor(ui): extract useWorkspaceRoot from App.tsx (#578)
useWorkspaceRoot(launchRoot) returns currentRootDir +
setCurrentRootDir + currentRootDirRef and runs the ref-mirror
effect. App.tsx loses one useState, one useRef, one useEffect,
and the long inline narrative explaining what each
rootDir-dependent surface reads.

The /cwd slash handler still composes the swap (re-register
tools + reload hooks + re-bootstrap semantic) — only the state
+ ref ownership moves into the hook.

Step 4/7 of #565 phase 1. App.tsx 3723 -> 3715.
2026-05-10 12:32:40 +08:00
YHH 6e723e2e60 refactor(ui): extract useEditGate from App.tsx (#577)
Bundles pendingEdits ref + pendingCount mirror + pendingTick +
syncPendingCount + editMode + editModeRef + saveEditMode
persistence + the 1.2s mode-flash effect into one hook in
src/cli/ui/hooks/. App.tsx loses three useState calls, two refs,
two effects, and several long inline narratives — those are now
single-line field docs on the EditGate interface.

Note: the tool interceptor stays in App.tsx for now — it's tangled
with review-modal state (pendingEditReview / editReviewResolveRef /
turnEditPolicyRef), which want their own hook in a follow-up.

Step 3/7 of #565 phase 1. App.tsx 3760 -> 3723.
2026-05-10 12:27:44 +08:00
YHH 59babbb604 refactor(ui): extract useToolProgressDisplay from App.tsx (#576)
Bundles ongoingTool / toolProgress / statusLine state + the
progressSink wiring + the turn-end clear() into one hook in
src/cli/ui/hooks/. Long inline narratives explaining the
pulse-spinner row / progress frame / silent-phase status line
move out of App.tsx; the hook's name carries the role.

Step 2/7 of #565 phase 1. App.tsx 3785 -> 3760.
2026-05-10 12:18:58 +08:00
YHH bcc64ff822 refactor(ui): extract useTerminalSetup from App.tsx (#575)
DECSET 2004 / modifyOtherKeys / DECSET 1007 setup+cleanup moves
to its own hook in src/cli/ui/hooks/. App.tsx no longer owns the
escape-sequence dance; the long inline narrative collapses to one
line documenting the only non-obvious choice (1007 over full
mouse tracking).

Step 1/7 of #565 phase 1. App.tsx 3816 -> 3785.
2026-05-10 12:12:44 +08:00
YHH 9a43aa214b perf(ui): card virtualization — only lay out visible cards (#574)
After A+B (#573) the App-level render cost on scroll was gone, but
Yoga still had to lay out every card in CardStream's inner Box on
every frame — for a 50-card history, that's hundreds of rows
re-measured per scroll tick.

This PR adds card-level virtualization. Each card is wrapped in a
MeasuredCard that captures its row height via useBoxMetrics and
reports it to the chat-scroll store. CardStream then walks the cards
once per render, computes the cumulative offset of each, and decides:

- live render the card (it's inside the viewport ± 30-row buffer, OR
  its height isn't cached yet so we need to measure it), or
- collapse it into a spacer Box of the cached height.

Adjacent collapsed cards merge into a single spacer, so the off-screen
ranges add up to one Yoga node above and one below the visible cards.
Total inner-Box height stays exact, so marginTop={-scrollRows} still
lands on the right rows and maxScroll is correct.

Edge cases:
- New cards (id not in heights map) always render live so the first
  measurement happens. Once cached, they collapse next frame.
- Streaming cards re-measure on every render — their height changes
  and the cache stays fresh through the same path.
- pruneCardHeights drops heights for cards that disappear from the
  list (resumed sessions, /clear), so the map can't grow unbounded.

For a session with 50 cards and ~600 row total height, only the ~5-10
cards under the viewport go through Yoga per scroll. The off-screen
work that was making long sessions feel sluggish is now gone.
2026-05-10 12:04:31 +08:00
YHH d61d6806c8 perf(ui): isolate chat scroll state from App.tsx + scroll polish (#573)
Wheel/arrow ticks were re-rendering all of AppInner — 3,800 lines and
122 hook calls — even though the actual UI delta was a single
marginTop change in CardStream. Each scroll paid the full hook-eval
tax before Yoga layout and Ink commit could even start.

The fix has two parts:

1. New chat-scroll store under src/cli/ui/state/. Same useSyncExternalStore
   pattern as the agent store — selectors subscribe per-slice, actions
   are stable refs. CardStream now reads scrollRows directly from it;
   App.tsx only takes the action callbacks (which never change identity).
   The "reading history" branch that depended on `pinned` moves into
   InputAreaWithHistoryHint, a leaf that subscribes to pinned in
   isolation. Net: scroll ticks no longer re-render App.tsx at all.

2. Visual polish.
   - Wheel/PgUp/PgDn step from 8 to 5 rows. Smaller jumps read as
     smoother continuous motion in a cell-based TUI.
   - The "↑ earlier" line is now a live position indicator
     ("↑ N / M rows above — K more · PgUp / wheel / ↑").
   - The indicator briefly switches to the accent color on every
     applied delta (scrollVersion bump), so the user gets visual
     confirmation the wheel registered before the new frame paints.

Removes the old src/cli/ui/hooks/useChatScroll.ts; nothing else used it.
2026-05-10 11:54:51 +08:00
YHH 5a4754ffc6 fix(ui): compress scroll-schedule comment to satisfy 3-line cap (#572)
Hotfix: the squash merge of #571 landed with a 5-line block comment
that violates tests/comment-policy.test.ts (cap is 3 lines). The
fixup commit was pushed to the branch after the auto-merge had
already fired, so it never made it onto main.
2026-05-10 11:35:18 +08:00
YHH a9314cf23a fix(ui): leading-edge scroll + chunkier wheel/page step (#571)
Wheel-up felt laggy because every tick paid a full 16 ms wait before
any visual feedback — schedule() was trailing-edge: first call started
a setTimeout, the actual setScrollRows didn't run until the timer
fired. With Ink's reconcile + Yoga layout on top of that, a single
wheel tick took 30-50 ms to land visibly.

Two changes:

1. Leading-edge schedule. First call applies the delta immediately
   (instant feedback on first wheel tick), opens a 16 ms window during
   which subsequent calls accumulate into pendingDelta, and lands one
   trailing flush. Fast scrolls still produce only one re-render per
   window, but the user no longer waits for a slow tick to feel.

2. Split arrow step from page/wheel step. SCROLL_ARROW_ROWS stays at
   3 (fine-grained ↑/↓). New SCROLL_PAGE_ROWS = 8 covers PgUp/PgDn
   and the wheel-via-DECSET-1007 path. Each wheel tick now travels
   roughly a third of a typical viewport instead of three lines.
2026-05-10 11:27:02 +08:00
YHH 26d2c527db fix(ui): default flush to 50ms, drop REASONIX_UI=plain escape hatch (#570)
Vertical-jitter reports were tracing back to the 16ms (60Hz) flush
default — on winpty/MINTTY/ConEmu/tmux/high-latency-SSH the cursor-up
repaint can't atomically swap the frame at that rate, so users see
the old frame's bottom rows briefly through every redraw. 50ms (20Hz)
is still continuous-feeling for streaming text and stops the bobbing
on every terminal we've seen affected. Override via REASONIX_FLUSH_MS
when running on a terminal with atomic frame swap.

REASONIX_UI=plain is removed: it was the nuclear escape hatch for the
same problem, but it suppresses every live row (streaming text, tool
spinner, plan progress, undo banner, subagent stack, status line) —
i.e., most of what makes the TUI useful. The slower default flush
addresses the same terminals without that tradeoff.
2026-05-10 11:15:19 +08:00
YHH 054d3989ed docs(site): bilingual config guide, README restructure, SEO + perf cleanup (#569)
Adds a dedicated configuration guide page (docs/configuration.html)
covering MCP, skills, memory, hooks, permissions, web search, and
the semantic index — bilingual (en/zh) with scrollspy TOC.

Landing page: hero gets a real-metric strip (counts up once when
scrolled into view), orbital SVG decoration, status pill. The
"Configure in five minutes" teaser block links into the guide.

README cleanup: pulls Configuration into prime real estate, collapses
the chat-vs-code / working-folder / first-skill detail into a
<details> block, drops the standalone Web Search section (now
covered by the guide), tightens install copy. Mirrored in zh-CN.

SEO: correct og:url (was pointing at the wrong repo path), canonical,
hreflang en/zh-CN/x-default, JSON-LD SoftwareApplication + WebSite
on landing, TechArticle + BreadcrumbList on guide, Twitter card
metadata. Adds docs/robots.txt and docs/sitemap.xml.

Perf: stripped scroll-reveal, card spotlight + 3D tilt, capability
marquee, backdrop-filter on metrics row, fractal-noise overlay,
content-visibility, gradient-text shimmer, and section-beam
animations after they made the page feel laggy. Background is now
fully static — dot grid, three glow blobs, horizon hairline.
Only one-shot animation left is the metric count-up; the brand
mark, status dot, and terminal caret keep their tiny scoped pulses.
2026-05-10 10:54:23 +08:00
YHH bcfb7e586d chore(release): 0.36.2 (#568) 2026-05-10 09:38:03 +08:00
YHH 122d2e0fc9 feat(ui): add /theme picker (#543) (#567)
Surfaces a SingleSelect modal for the terminal theme preference. Bare
\`/theme\` opens the picker; \`/theme <name>\` keeps its existing
"persist and report" behaviour. Moves the slash entry from the
advanced group to setup and adds high-contrast to the arg completer.

Adapted from J3y0r's PR — repointed onto current main (which had
since reformatted App.tsx around the InflightProvider wrap from
#566) and the count test in tests/ui-slash-suggestions.test.tsx
updated from 12 → 11 advanced commands now that /theme has moved
out of the advanced bucket.

Thanks @J3y0r for the contribution.
2026-05-10 09:31:51 +08:00
YHH cf122c357d fix(loop): derive tool-card running state from finally-guarded inflight set (#566)
Closes #557

Tool spinners occasionally kept spinning after the underlying work had
finished. The cause: status was set imperatively from paired tool_start
/ tool events, and every code path that exits a dispatch had to
remember to emit the closing event — normal return, throw, abort,
storm-breaker, network drop. Miss one and the card was stuck.

Switch the running flag to a derived view:

- New InflightSet (src/core/inflight.ts) with add / delete / has /
  subscribe + notify-on-mutation. Parallel finally-driven cleanup is
  the contract: the language guarantees finally on every exit path,
  so there's no closing event to forget.
- CacheFirstLoop holds one. runOneToolCall adds the call's id at
  function entry and deletes it in finally; the chunk-loop pre-adds
  before yielding tool_start so the very first card render already
  reflects the running state. clearLog drains the set on /new.
- LoopEvent.tool_start / tool now carry callId — same id used as the
  inflight key. TurnTranslator threads it into Scrollback.startTool,
  which uses it as the card id so the card and the inflight key match.
- InflightProvider + useIsInflight (src/cli/ui/state/inflight-context.tsx)
  expose the set as a React context backed by useSyncExternalStore.
  ToolCard's toolStatus consults the hook for "running"; card.done is
  no longer the spinner discriminator, only terminal flavor (ok /
  rejected / aborted / error).

Tests:
- tests/inflight.test.ts — InflightSet contract: idempotent add,
  notify-only-on-change, listener errors don't break the gate, clear,
  finally-on-throw, finally-on-abort, parallel allSettled.
- tests/loop-inflight.test.ts — runOneToolCall finally clears the
  set on success and on tool throw; tool_start carries callId
  matching the inflight key; clearLog drains.

Out of scope (follow-up): subagent runId in the same set; deriving
streaming/reasoning running flags. Both are mentioned in the issue
but live behind different leak surfaces; the tool-card path is the
one users see most.
2026-05-10 09:12:12 +08:00
YHH de4b342199 fix(dashboard): kill chat-tab freeze under long sessions / concurrent jobs (#560)
Chrome's "Page not responding" dialog fires on the dashboard chat tab
once a session accumulates enough messages. Repro condition: long
streaming turn or several concurrent jobs broadcasting at once.

Two cooperating bugs on the chat feed's hot path:

1. Every assistant_delta event hit setState synchronously. The model
   streams ~20 deltas/sec; each one re-rendered ChatPanel, which
   .map'd over every historical ChatMessage with no memoization, so
   each one re-ran marked.parse + (for tool messages) hljs syntax
   highlight on UNCHANGED content. Long sessions = N grows = main
   thread starves.
2. The `streaming` prop fed to historical ChatMessage flipped between
   `null` (no streaming) and `false` (streaming-but-not-this-message)
   across turns, so even with memo, `null !== false` would force
   every historical message to re-render every time streaming
   started or stopped.

Fix:

- Wrap ChatMessage in `memo` from preact/compat so historical messages
  with stable `msg` refs short-circuit re-renders.
- Cast the per-row streaming prop to a boolean so historical
  messages get a stable `false` and memo's shallow compare actually
  bails out across streaming-on/off transitions.
- Coalesce assistant_delta into a single rAF-batched setStreaming.
  Multiple deltas in the same frame collapse into one state commit;
  the streaming bubble's re-render rate is capped at the display
  refresh rate. assistant_final + SSE reconnect + unmount each
  cancel the pending flush so the buffer can't leak across turns.

Closes #551
2026-05-10 08:36:35 +08:00
YHH 6761a31697 fix(prompt): identity guard against foreign agent-platform workspaces (#550) (#555)
When the sandbox root contained another product's data dir (Hermes
Agent's SOUL.md, skills/, memories/, etc.), the model would browse
those files and infer it was a sub-profile or runtime layered on top of
the host product. The system prompt defines the assistant as a
standalone coding assistant; the workspace contents shouldn't override
that.

Two layers:

- Top-of-prompt identity section names the failure mode explicitly:
  workspace files describe the user's project, never what Reasonix is.
  Calls out SOUL.md / AGENT.md / PERSONA.md / foreign skills+memories
  trees / a foreign REASONIX.md as not-the-spec, and tells the model to
  answer "who are you?" from the prompt instead of running ls.
- detectForeignAgentPlatform(rootDir) flags the workspace at launch if
  one of those markers sits at the root, and the code-mode banner adds
  a one-line warning suggesting --dir <real-project>. Conservative
  signal: lone skills/ doesn't trigger (common in coding repos);
  skills/ + memories/ pair does.

Closes #550
2026-05-10 08:36:31 +08:00
YHH 94dc1926f2 fix(tui): flush pending modals + cancel pauseGate on Esc and /new (#552)
When a tool awaits pauseGate.ask (plan_checkpoint, plan_proposed, etc.),
the gate's promise ignores AbortSignal. Esc-during-busy fires
loop.abort(), but the awaiting tool fn never returns, so:

  - busy stays true forever — PromptInput stays disabled.
  - The pending modal (or its plan card) can't be dismissed.
  - /new is silently dropped by handleSubmit's `if (busy) return`,
    so the user can't even wipe the session to start over.

Add pauseGate.cancelAll() that resolves every outstanding request with
its kind's safe-cancel verdict (deny / cancel / stop / cancelled). Wire
it into a resetPendingModals helper that also clears every pending*
modal state, and call it from both Esc-during-busy and apply-slash-
result's clear branch. The awaiting tool fn now returns or throws
cleanly, busy clears, and the user can recover.
2026-05-10 08:08:51 +08:00
YHH 98183baf99 fix(tui): break scrollRows ↔ outer.height feedback that crashed CardStream (#549)
The "↑ earlier" hint was rendered conditionally on scrollRows > 0, as a
sibling of the measured outer Box. That tied outer.height to scrollRows:
toggling the hint changed outer.height by one row, which changed
maxScroll, which pinned-mode wrote back as scrollRows, which toggled
the hint again. With streaming content compounding inside the same
React batch, the chain reached MAX_NESTED_UPDATES = 50 and aborted with
"Maximum update depth exceeded" inside ink's useBoxMetrics.

Reserve the hint row unconditionally so outer.height is independent of
scrollRows. Trade is one row of permanent reservation; the loop is
gone.
2026-05-10 07:42:50 +08:00
YHH 614236eb03 chore(release): 0.36.1 (#548) 2026-05-10 00:25:02 +08:00
YHH b1efcfe091 fix(slash): use frequency-sorted list when picking by index on Enter (#547)
The picker UI sorts suggestions by usage count (slashUsage) but the
Enter-time substitution recomputed the list without it, so the list
the user navigated and the list dispatch indexed into were ordered
differently. The shared slashSelected index then dereferenced a
different command than the highlighted row.

Pass slashUsage through so both calls produce identical orderings.
2026-05-10 00:22:26 +08:00
YHH c2966f5c0e feat(tui): phase-aware label for the busy-fallback activity row (#546)
Refs #367
2026-05-10 00:17:14 +08:00
YHH 4dbb03ae36 docs(readme): explain when to pick chat over code (#545)
Closes #509
2026-05-09 23:55:30 +08:00
paradoxSCH 05491f16c6 fix(tui): clear ongoing tool on errors (#542)
Closes #535
2026-05-09 23:44:57 +08:00
YHH e2b3de7246 docs(changelog): add slash hook-order fix to 0.36.0 entry (#539)
Rolling PR #538 into 0.36.0 since the version was tagged but not yet
published to npm. Tag will be moved to this commit so the published
0.36.0 contains the fix, no 0.36.1 needed.
2026-05-09 23:09:01 +08:00
YHH d9cfeffd50 fix(slash): hoist hooks above early returns to stop "Rendered more hooks" crash (#538)
SlashSuggestions called useColor / useStdout / useState before two
early-return branches (matches === null, matches.length === 0), then
useEffect afterward. When matches flipped from non-null/non-empty to
either of the early-return states and back, React saw 4 hooks one
render and 3 the next — "Rendered more hooks than during the previous
render" crash, killing the whole TUI.

Hoist windowStart computation + the useEffect above the early
returns. Compute against `matches ?? []` so the math is well-defined
when matches is null (windowStart resolves to 0, the effect no-ops
because state already equals 0). The early-return JSX branches stay
unchanged below.

Regression test rerenders the component through
non-empty → null → empty → non-empty and asserts no throw.
2026-05-09 23:05:53 +08:00
YHH 67a1d6194b chore(release): 0.36.0 (#537)
* chore(release): 0.36.0

* test(status-row): pin status-line version assertion to live VERSION import

Hardcoded "v0.35.0" string broke on every release bump. Reading from
src/version.js means future releases don't need a coordinated test
edit alongside the package.json bump.

* test(mcp): bump startup-summary timeout to 15s — chronic flake under suite load

captureStartupState dynamically imports chat.js + tools.js inside the
test, which can blow past Vitest's 5s default when the worker pool is
saturated (148 test files, lots of cold cache). Passes in isolation,
flakes under `npm test` parallel load. 15s adds headroom without
slowing the happy path.
2026-05-09 22:58:29 +08:00
YHH 0a1c65d9db fix(tui): rescue unbracketed pastes so multi-line content stops firing N submits (#536)
Bracketed-paste markers (DECSET 2004) don't arrive on every host —
multiplexers strip them, some web-SSH gateways drop them, certain
Windows pipes never forward them. When they're missing, a multi-line
paste arrives as raw bytes; each \r in the chunk gets dispatched as
an Enter event; PromptInput submits the partial buffer; the loop
fires a fresh agent call per line. Reporter saw multi-line paste
trigger multiple submits in a row.

Heuristic rescue at the parser entry point: when a chunk in idle
state contains 2+ line breaks, OR a single line break with non-empty
text on both sides, AND no ESC bytes (would indicate a real key
sequence) AND no existing paste markers, wrap it in synthetic
\x1b[200~ … \x1b[201~ before processing. Routes through the existing
paste accumulator so it lands as ONE paste event with the whole blob.

Asymmetric tradeoff is intentional — a false-positive paste makes the
user re-press Enter; a false-negative paste fires N prompts and burns
N agent calls. The detector errs toward paste.

Bare \r and \r\n stay typed-Enter (terminal-converted CRLF is one
keystroke, not two breaks). "abc\r" stays typed-then-Enter, so a
fast typist's batched chunk still submits.

Updated the printable-coalescer split test to use \t since \r/\n now
route through the heuristic rescue when surrounded by text.

Closes #522
2026-05-09 22:46:25 +08:00
YHH ef2b50d873 fix(plan): pipe user feedback through the refine / approve / cancel gate (#534)
When the user picked Refine on PlanConfirm and typed their suggestion,
the model never saw the text. `handleStagedInputSubmit` built a rich
`synthetic` string with the feedback embedded but only resolved the
PauseGate with `{ type: "refine" }` — no payload. submit_plan threw
the bare `Error("user requested refinement")`, the model received
that as the tool result with zero context, and proposed a near-
identical plan, which fired PlanConfirm again. From the user's
perspective the suggestion was silently dropped.

The PlanVerdict union didn't even allow a feedback field, so the
omission was structural. The CheckpointVerdict pattern (already
shipping for revise-at-step) had the right shape; submit_plan just
never adopted it.

Pipe feedback through PlanVerdict for all three outcomes (refine /
approve / cancel) and surface it in the tool result string so the
model sees the user's exact words. Drop the dead `synthetic` that was
being built and discarded; keep `marker` and emit it via `pushInfo`
so the user gets a visible confirmation in chat scrollback right
after they hit Enter.

Closes #533
2026-05-09 22:32:46 +08:00
YHH 6cde945a01 fix(dashboard): resync canonical state on SSE reconnect (#532)
The /api/events endpoint snapshots only `busy-change` on (re)connect.
When the SSE connection drops mid-task — proxy timeout, browser
background-tab throttle, or Node event loop blocked past the 25s ping
window during heavy work — every assistant_delta / assistant_final /
tool / modal-up event fired during the disconnect window is lost.

EventSource auto-reconnects, but the new stream only carries new
events. If the disconnect happened before the end-of-turn
busy-change(false), the UI is wedged on "busy" forever and the
completed reply never appears. Reopening the dashboard URL was the
only escape, since fresh page load fetches /api/messages.

On reconnect (every onopen after the first), refetch /api/messages
and /api/modal to recover canonical state. Streaming + active-tool
slots reset since they're transient — the next delta will repopulate
streaming, the next tool_start will repopulate active-tool. First
open is skipped because the boot effect already fetched canonical
state.

Closes #521
2026-05-09 22:17:34 +08:00
YHH faf59cee4a fix(tui): wheel scroll on cloud/web terminals + drop render ghosting (#529)
* fix(tui): enable DECSET 1007 alternate-scroll so wheel scrolls on cloud/web terminals

Previously we relied on the implicit "terminal translates wheel→↑/↓ in
alt-screen" behavior. That only holds on local desktop terminals
(xterm/iTerm/Windows Terminal/Alacritty) where alt-scroll is on by
default. Web/cloud/SSH terminals (xterm.js, code-server, Cloud Shell,
mobile SSH clients, tmux without `mouse on`) don't ship with that
default, leaving the wheel as a dead key — the most common
"can't scroll" report.

Explicitly request the behavior via DECSET 1007 (alternate-scroll) on
mount, reset on unmount. Falls back silently on terminals that don't
recognize 1007. Native drag-select and right-click are untouched —
no Shift bypass needed.

`--no-mouse` opts out for the rare user who'd rather suppress the
sequence entirely.

* fix(tui): disable Ink incrementalRendering to drop render ghosting

Incremental render diffs against an internal "what's on screen"
snapshot. The diff drifts when:
  - string-width misjudges CJK / emoji ZWJ cell width and Ink
    miscomputes the affected region
  - async terminal-event bytes (DA replies, mouse, focus) interleave
    between Ink's cursor-positioning writes
  - terminal resize races a flush

When the diff drifts, stale rows survive the redraw — the residual
"ghost" class users have been reporting on cloud/web terminals.

Full-frame redraws sidestep the snapshot. Cost: more stdout bytes per
flush. At 60Hz default flush, local terminals are unaffected; remote
SSH only feels it during sustained high-rate streaming, where the
existing REASONIX_FLUSH_MS escape hatch can drop to 30Hz.
2026-05-09 21:57:53 +08:00
Evan-Pycraft 1e7be37a55 feat(i18n): localize card component labels with zh-CN (#526)
Replace hardcoded English strings in 8 card components (Ctx, Error, Reasoning, Search, Streaming, SubAgent, Tool, Usage) with t() calls. Pluralization handled at the call site via singular/plural key pairs (Chinese maps both to one form). Adds cardTitles + cardLabels namespaces to EN.ts / zh-CN.ts / types.ts.
2026-05-09 21:53:32 +08:00
zerone0x 7f54ae0678 feat(ui): suggest nearest slash commands on typos (#302)
Levenshtein-rank registered command names against unknown slash input; surface up to three closest candidates inline in the dispatch response. Falls back to the existing "(try /help)" hint when nothing is within distance threshold. Closes #17.
2026-05-09 21:08:53 +08:00
paradoxSCH bdf8faeb4e fix(shell): normalize Windows PATH env before spawn (#525)
Collapse case-variant PATH / PATHEXT keys (Path / PATH / path) into one normalized entry before passing env to child_process.spawn on Windows, and read PATH / PATHEXT case-insensitively in resolveExecutable so the spread env (which is a plain object, not the case-insensitive process.env Proxy) still resolves bins. Refs #520 — does not auto-close; awaiting reporter verification that Machine PATH inheritance is restored.
2026-05-09 20:34:37 +08:00
GuGuGaGa a9c407853a feat(wizard): add theme-picker step with live preview (#518)
Slot a theme step between language and apiKey so the rest of the wizard renders in the chosen theme. Live preview updates as the user navigates the list; default highlight lands on the currently-resolved theme; commit writes through writeConfig. Sample tile exercises tone.accent / tone.info / tone.ok / tone.warn / tone.err + fg.meta. Closes #492.
2026-05-09 20:26:34 +08:00
kabaka 959e9ffefd fix(slash): stabilize suggestions windowing + isolate status row layout (#516)
Rework SlashSuggestions windowing so grouped headers are budgeted into the visible row count and selection moves through commands without skipping group rows. Wrap StatusRow children with flexShrink/height/wrap=truncate so the status bar no longer collapses under the suggestion popup. Surface /language as a visible setup command. Filter order in suggestSlashCommands now also exposes code-group commands in bare browsing — flagged for follow-up if undesired.
2026-05-09 20:18:40 +08:00
YHH 012757567e perf(tui): raise streaming flush to 60Hz default for continuous text feel (#517) 2026-05-09 16:51:36 +08:00
YHH 453a3bc9d3 perf(tui): lower streaming flush from 30Hz to 20Hz default (#515)
We write to stdout at the streaming flush rate, and the terminal has
to interleave those writes with its own native drag-select highlight
repaints. At 30Hz (33ms) the terminal can't keep up — selection
feedback visibly lags during model output. Now that mouse mode is
off (#514) and users actually use drag-to-select again, this is the
load-bearing perf knob.

50ms (20Hz) still feels smooth on every modern terminal we test on
(WT, WezTerm, iTerm2, Alacritty, Ghostty) and cuts the write rate by
33%, leaving terminal repaint enough budget to keep selection
highlights snappy. `REASONIX_FLUSH_MS=33` brings the old behavior
back for anyone who actively prefers it.
2026-05-09 16:27:31 +08:00
YHH dbafd1632c fix(tui): drop xterm mouse tracking — restore native copy/paste, rebind keys (#514)
* fix(tui): drop xterm mouse tracking; ↑/↓ scroll chat, Ctrl+P/N owns prompt

Multiple users reported they can't copy text and can't scroll. Both
trace to the same root cause: we enabled DECSET 1000 + 1006 mouse
tracking so the wheel could route through `mouseScrollUp/Down`. The
side effect is that every terminal we know about then refuses to
start a native drag-select on click, blocks right-click, and on
ConPTY / multiplexer setups can drop the wheel events entirely (so
neither path scrolls).

Architecture flip:

- Don't enable mouse tracking. Bracketed paste + modifyOtherKeys stay.
- ↑/↓ at the App level always scroll chat, no buffer-state gating.
  Terminals translate the wheel to ↑/↓ in alt-screen mode, so the
  wheel still scrolls chat — for free, in every terminal.
- Ctrl+P / Ctrl+N take over what ↑/↓ used to do in PromptInput:
  cursor up/down inside a multi-line draft, falling back to prompt
  history when no cursor move is available (readline parity).
- /keys + tipMouseClipboard rewritten to reflect the new bindings;
  the old "hold Shift to drag-select" workaround is gone — drag
  works directly now.

Cost: power users who liked the wheel-scroll-while-typing affordance
have to switch to PgUp/PgDn or empty their prompt. Worth it given
how many users hit the copy-paste wall.

* fix(tui): update prompt hint row + stale comments to reflect ↑↓ scroll / Ctrl+P/N history binding

* fix(tui): don't scroll chat when a picker owns ↑/↓ (slash / @-mention / slash-arg / shell-confirm)
2026-05-09 16:09:28 +08:00
YHH 1aca86b424 fix(config): honor config.baseUrl + accept self-hosted key formats (#513)
Two compounding bugs sent self-hosted DeepSeek users back to the
official endpoint regardless of how they configured the client:

1. config.baseUrl was a dead field. Every `new DeepSeekClient()` call
   site (App.tsx, commit.ts, doctor.ts, run.ts) constructed the
   client without passing baseUrl, so it always fell back to the
   `DEEPSEEK_BASE_URL` env var or `https://api.deepseek.com`. The
   dashboard settings panel saved the value but nothing read it.

2. isPlausibleKey enforced the `sk-` prefix, so the wizard / setup /
   `/feedback` settings rejected any token that didn't look like an
   official DeepSeek key — even when the user had pointed baseUrl at
   their own deployment.

Adds `loadBaseUrl` (env > config > undefined) + `saveBaseUrl`, plumbs
the loader into all four client construction sites. Relaxes
isPlausibleKey to a generic typo guard (16+ chars, no whitespace) so
self-hosted token formats work; the real auth check is the first API
call against baseUrl.

Closes #502
2026-05-09 15:30:51 +08:00
YHH 5ce642aeeb fix(dashboard): use semver compare for up-to-date check (#512)
The dashboard health card, overview cockpit, and /feedback diagnostic
all decided "up to date" via `latest === installed` string equality.
That fires false-positive "needs update" / "(latest: 0.31.0)" pills
whenever the cached latest is older than the installed version — for
example after publishing 0.35.0 while the 24h cache still holds the
prior latest.

Switch all three sites to `compareVersions(installed, latest) >= 0`.
Add a tiny dashboard/src/lib/version.ts so the browser bundle has the
same comparator the CLI uses (no cross-package import).

Closes #510
2026-05-09 15:20:11 +08:00
YHH c888ab33d7 feat(update): respect install source instead of forcing npm global (#511)
`reasonix update` previously always emitted `npm install -g`, which
clobbered bun/pnpm/yarn installs and was unpredictable under nvm/fnm
where ambient npm may resolve to a different node runtime than the one
running the binary.

Detection (`detectInstallSource`) reads argv[1] and matches each
manager's unique global path segment:

  - `_npx/`, `.pnpm/...dlx/`, `npx/` UA → npx
  - `.bun/`, `bun/install/`              → bun
  - `pnpm/global/`                       → pnpm
  - `yarn/global/`, `.yarn/global/`      → yarn
  - `node_modules/reasonix/`             → npm
  - otherwise                            → unknown

Plan emits the matching command per source. For npm we additionally
pin `--prefix=<extracted-prefix>` so nvm/fnm runtime switches don't
redirect the install elsewhere. For unknown source we refuse with a
manual hint listing all four commands and exit 1 — no silent npm
fallback.

Closes #506
2026-05-09 14:58:31 +08:00
YHH 88514595ac fix(semantic): unblock Build when daemon is up but binary lookup fails (#507)
PATH refreshes per-shell on Windows, so a dashboard process started
before the Ollama install (or before a Terminal restart) inherits a
stale PATH and `where ollama` returns nothing — even when the daemon
the user launched from the tray icon is fully serving requests.

Two changes:

- findOllamaBinary falls back to %LOCALAPPDATA%\Programs\Ollama\
  ollama.exe when `where` fails. spawn() for `serve` / `pull` now uses
  the resolved path so those buttons survive a stale PATH too.

- ready no longer requires binaryFound. The embedding pipeline only
  talks to the HTTP API; the binary is only needed for daemon-start
  and model-pull, which have their own UI guards.

Closes #504
2026-05-09 14:38:04 +08:00
YHH 29020327fe chore(release): 0.35.0 (#505)
* chore(format): reuse fmtPct for cache-hit display; keep cachePct numeric

Drop the duplicate `formatCacheHitPct` helper added in #503 — `fmtPct`
in `dashboard/src/lib/format.ts` already does the same `(n*100).toFixed(1) + "%"`
shape and is the canonical formatter for the rest of the panels.

In `/status` (`observability.ts`), keep `cachePct` as a number through
`renderTinyBar` and only `.toFixed(1)` at the template-string boundary,
so we don't round-trip through string + `Number()`.

* chore(release): 0.35.0
2026-05-09 14:21:33 +08:00
kabaka 7642ad6ba2 fix(ui): improve cache hit percentage display and localization in dashboard (#503) 2026-05-09 14:11:13 +08:00
YHH e205c59775 feat(ui): /feedback + version badge in status row (#501)
* feat(ui): version badge in status row + /feedback opens issue with diagnostic clipboard

When a user hits a bug, the first question back is "what version" — and
the only way to find out today is to drop to the shell. Filing the
issue itself means leaving the TUI, finding the repo URL, then either
remembering or re-collecting version / OS / terminal / Node / locale /
model. Both papercuts are easy to fix from the prompt-input surface.

- StatusRow shows a `v<VERSION>` pill on the right cluster, gated to
  cols ≥ 70 so it doesn't crowd narrow terminals.
- New `/feedback` slash: gathers version + platform + os release +
  TERM_PROGRAM/TERM + Node + locale + model + sessionId, writes a
  paste-ready Markdown block to the clipboard via the existing
  `writeClipboard` helper, then opens the GitHub new-issue URL in the
  default browser via a new cross-platform `openUrl` (win32 cmd /c
  start, darwin open, linux xdg-open). No-ops under CI / when
  REASONIX_NO_OPEN is set.
- Diagnostic body contains exactly the seven advertised fields and
  nothing else — `tests/feedback.test.ts` pins the field set and asserts
  no API keys / file paths / transcript content can leak through.

Closes #499

* feat(ui): pre-fill /feedback issue body via URL + surface chip in status row

Two follow-ups so the affordance actually gets discovered and used:

- The issue page now opens with the diagnostic block already in the
  textarea via `?body=<urlencoded>`. Most users won't notice we copied
  to clipboard; pre-filling the URL is the obvious path. Clipboard
  stays as belt-and-suspenders for when the URL fails to open.
- StatusRow shows a `⚑ /feedback` hint after the version pill at
  cols ≥ 100 so users see the command exists. Narrower terminals (70–99
  cols) still get the version, just without the hint.
- Body length is capped at 6000 chars before encoding so a runaway
  diagnostic can't blow past the URL limit.

* feat(ui): expand /feedback diagnostic with flicker-relevant fields

The original block had version + platform + TERM_PROGRAM + Node + locale
+ model + session — fine for "agent ran the wrong tool" reports, but
useless for the rendering / flicker reports we actually field. Adds the
fields that come up first when triaging those:

- terminal size (cols×rows) — lots of repaint bugs only repro at
  specific widths
- env markers that change the rendering pipeline: COLORTERM,
  WT_SESSION, TMUX, SSH_TTY, WSL_DISTRO_NAME — TERM_PROGRAM alone can't
  distinguish cmd / WSL / SSH-tunnelled terminals
- theme name — different themes use different background fills, flicker
  is often theme-dependent
- edit / plan mode — decides which surfaces are mounted, changes
  repaint cadence
- latest version compare — surfaces "you're on an old build" without a
  follow-up question
- reasoning effort + MCP server count — incidentally cheap, occasionally
  load-bearing for cost / latency reports

Still no transcripts, paths, workspace name, or env outside the
hand-picked safe list. New tests pin the exact field set so future
fields can't sneak in unannounced.
2026-05-09 14:07:51 +08:00
YHH 874f0d1cc1 fix(dashboard): bump doc-chrome typography; widen sidebar column (#500)
The design-doc page chrome (`.toc` / `.section` / `.subsec`) was tuned
on a larger display and reads as too tight on a 13" laptop at 100%
zoom. Sidebar section headers at 10px with 0.14em tracking are the
worst offender — section labels also wrap mid-word in the 240px column.

- `.page` sidebar column: 240px → 260px so 2–3 word section labels fit.
- `.toc h1` 14 → 15px; `.toc .sub` 11 → 12px.
- `.toc-section` 10 → 12px and tracking 0.14em → 0.08em (lower so the
  size bump doesn't fight the letter-spacing).
- `.toc li a` 12.5 → 14px with `line-height: 1.4` and
  `overflow-wrap: anywhere` for graceful wraps on longer labels.
- `.section > .lede` 13 → 15px; `.subsec > p` 12.5 → 15px (line-height
  1.6 already applied).
- `.subsec > h3 .desc` 12 → 13px.
- `.swatch .hex` 10.5 → 11.5px; `.scale-row .lbl` 10.5 → 11.5px (and
  width 70 → 76px to keep the new monospace digits aligned).

Mirrored verbatim into `docs/design/agent-dashboard.html` per the
"keep CSS in lockstep with the mockup" comment at the top of app.css.

Closes #461
2026-05-09 13:17:56 +08:00
YHH 2462b1c278 feat(tools): create_skill + add_mcp_server — let the model scaffold from chat (#498)
Two new agent-facing tools so "add a skill that does X" / "wire up a
postgres MCP server" work as normal chat requests instead of forcing the
user out to slash commands:

- `create_skill` — pre-fills frontmatter (description / runAs /
  allowed-tools / model) from structured args and writes through a new
  SkillStore.createWithContent that reuses the same path resolution +
  collision check as `/skill new`.
- `add_mcp_server` — builds a `name=...` spec for stdio / sse /
  streamable-http transports, optionally hydrates from MCP_CATALOG,
  runs the existing preflight (filesystem sandbox-dir check), and
  appends to cfg.mcp via writeConfig. Refuses name collisions.
  Active on next launch — no live MCP client churn this session.

Both are registered alongside the existing native tools in
`reasonix code` so they're available wherever filesystem / shell are.

Closes #494
2026-05-09 13:06:29 +08:00
YHH d421970a27 fix(plan): surface open-questions block under the banner; i18n the plan flow (#497)
The banner already detected `Open Questions` / `Risks` / `Unknowns` headings
but the questions themselves were never shown — PlanConfirm rendered either
a step list or a 24-line body cap, both of which can hide the block. Refine
mode then reopened a blank input with no echo of the questions, so the user
either invented an answer or hit Enter and we asked the model to re-derive
its own questions on a follow-up turn.

- Extract the matching block (heading down to next same-or-shallower heading)
  via `extractOpenQuestionsSection` and render it under the banner regardless
  of steps / body cap.
- Forward the extracted block to `PlanRefineInput`, which now renders the
  questions above the input on `mode === "refine"`.
- Replace the blank-refine synthetic that asks the model to re-derive
  questions with one that tells it to pick safe defaults and call them out.
- Move all plan-flow strings (PlanConfirm / PlanRefineInput /
  PlanCheckpointConfirm / PlanStepList counter line) through `t()` under a
  new `planFlow` namespace in EN + zh-CN.

Closes #477
2026-05-09 12:56:47 +08:00
Prantik Medhi d1f491b29f fix(ui): render spawn_subagent output as markdown (#496) 2026-05-09 12:52:30 +08:00
YHH 3f6fb51b08 feat(search): per-file cap + histogram fallback for search_content (#495)
A single popular symbol could match 200+ lines in a big file (App.tsx,
loop.ts) and eat the entire byte budget before the walk reached any
other file. Truncation marker fired, every other matching file got
zero coverage, and the caller saw a wall of one file's hits with no
hint that distribution was wider than that.

Three knobs:

1. MAX_HITS_PER_FILE = 30. Beyond that, the per-file output ends with
   "[rel: N more matches in this file — re-grep with a tighter
   pattern or use read_file to see them]". One bullhorn file can no
   longer dominate the byte budget.

2. After each file completes, if printed bytes have crossed 80% of
   the byte budget, remaining files switch to histogram form
   ("rel: N matches"). A one-line notice marks the flip so the
   caller knows distribution from this point on is summary-only.

3. New `summary_only:true` arg skips line content entirely and
   returns just the histogram. Useful for "where does this exist at
   all" before drilling in with a targeted read_file.

Closes #489
2026-05-09 12:50:55 +08:00
YHH c63239ddb8 feat(subagent): tell the child its iter budget, warn near the cap (#493)
The child loop's tool-iter cap was enforced from the outside only;
the subagent's system prompt never said "you have N tool calls", and
the child had no way to know which iteration it was on. The visible
failure mode was an explore subagent burning all 20 iters mapping the
territory, then hitting the cap mid-thought and emitting a partial
summary the parent had to re-dispatch from.

Two pieces:

1. spawnSubagent now appends a one-paragraph budget notice to the
   child's system prompt — literal "Tool budget: you have N tool calls"
   with the resolved cap baked in. Applies to default, typed
   (explore/verify), and caller-supplied system prompts uniformly.

2. ToolRegistry gains setResultAugmenter(), a final-stage post-hook
   on dispatch. spawnSubagent installs one that counts dispatches and
   appends "[budget: K of N tool calls left — wrap up soon]" once the
   child is within 3 iters of the cap, escalating to "0 left —
   finalize NOW" on the last call.

Static prompt + dynamic countdown together: prompt sets expectation,
countdown forces reckoning when the budget is actually tight.

Closes #488
2026-05-09 12:50:28 +08:00
YHH ec5bfabde2 feat(tools): add top-level export outline to read_file auto-preview (#490)
When the auto-preview path fires (file > 200 lines, no head/tail/range
given), the head 80 + tail 40 slice rarely covers the full export list
of a real file. Callers had to follow up with a search_content grep to
locate "where is function AppInner defined" before they could pick a
useful range.

Now the auto-preview embeds a top-level outline between the head slice
and the omitted-marker — function / class / const / interface / type /
enum names with their line numbers, capped at 30 entries (first 25 +
last 5 + an elide marker). One round-trip, not three.

Closes #487
2026-05-09 12:43:01 +08:00
YHH a525182aff chore(release): 0.34.1 (#486) 2026-05-09 12:05:20 +08:00
YHH f795583cea perf(ui): coalesce chat-scroll events into one render per frame (#485)
Each PgUp / PgDn / arrow / wheel tick used to call setScrollRows
synchronously, so a single mouse-wheel gesture (10-30 events) triggered
10-30 full Yoga layout passes over the entire card tree. Layout cost
scales linearly with history length, which is what made long sessions
feel laggy.

Accumulate the delta into a ref and flush once per ~16ms. Net effect:
one setState per scroll burst regardless of event volume. jumpToBottom
cancels any pending delta so End still feels instant.

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

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

Adds the missing creation step:

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

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

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

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

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

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

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

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

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

Three onboarding surfaces now make it discoverable:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two coupled bugs in the markdown file-ref linkifier:

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

The fix:

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

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

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

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

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

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

Real filters wired:

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

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

CSS: new `.chip-f.static` + `.chip-f.static:hover` rules disable the
interactive affordances without duplicating the chip styling.
2026-05-06 08:58:58 -07:00
YHH 3efa4cc374 chore(release): 0.29.0 (#329) 2026-05-06 08:35:38 -07:00
YHH 26ca84dd8f docs(architecture): parallel tool dispatch + env knobs (#325) (#328)
Document the chunked-parallel dispatcher and the two knobs that govern
it (REASONIX_PARALLEL_MAX, REASONIX_TOOL_DISPATCH=serial), plus the
list of built-in tools that opt in via parallelSafe.

Closes the docs portion of #325. Observability-event work (chunk-start
in events.jsonl) is deferred — the existing test suite already validates
the timing, ordering, and barrier guarantees, and the kernel event log
is a heavier surface that can wait until a real ops need surfaces.
2026-05-06 08:18:59 -07:00
YHH e91d136dbc feat(loop): chunked parallel tool dispatch + multi-subagent UI (#325) (#327)
Replace the serial `for...of + await` tool dispatch with chunked
parallel scheduling: consecutive `parallelSafe` calls race together;
unsafe calls form serial barriers. Tool yields and history append
preserve declared order regardless of which call settles first.

- Loop dispatch chunks consecutive parallelSafe calls; unsafe call
  ends the current chunk and runs alone
- runOneToolCall extracts per-call lifecycle (PreToolUse + dispatch +
  PostToolUse) so chunks can fan out via Promise.allSettled while the
  loop body keeps yielding events in declared order
- New env knobs: REASONIX_PARALLEL_MAX (cap, default 3, hard max 16)
  and REASONIX_TOOL_DISPATCH=serial (escape hatch)
- SubagentEvent gains a stable runId per spawn; useSubagent keeps an
  array of in-flight activities; SubagentLiveStack renders 1 → rich
  card, 2..max → compact rows, >max → "+N more" fold

Tests cover the four guarantees: parallel timing, serial barrier on
mixed safe/unsafe, declared-order yields under racey completion, and
both env-knob overrides.
2026-05-06 08:15:24 -07:00
YHH 12a3dab7e9 feat(tools): parallelSafe annotation on ToolDefinition (#325) (#326)
Plumbing for chunked parallel dispatch. No behavior change.

- ToolDefinition.parallelSafe?: boolean (default false; opt-in only)
- ToolRegistry.isParallelSafe(name): unknown / unannotated → false
- Built-in opt-ins: read-only filesystem (read_file, list_directory,
  directory_tree, search_files, search_content, get_file_info), web
  (web_search, web_fetch), memory recall, semantic_search, isolated
  child loops (run_skill, spawn_subagent), in-memory job queries
  (job_output, list_jobs)
- Mutating / side-effecting tools stay default (write_file, edit_file,
  shell, plan state, memory writes, ask_choice)
- MCP-bridged tools default false; safe surface only ships when an MCP
  server explicitly declares its tools parallel-safe
2026-05-06 08:07:04 -07:00
YHH deefafed10 chore(release): 0.28.0 (#323) 2026-05-06 03:16:17 -07:00
YHH 882a7ad2d9 feat(subagent): built-in subagent types (explore, verify) via the type arg (#322)
Two task shapes repeat often enough to warrant first-class support
without needing a skill:

- explore — wide-net read-only investigation (20-iter budget)
- verify — narrow yes/no check with evidence (8-iter budget)

Add an optional `type` field to the spawn_subagent schema. When set,
the type supplies the system prompt and default iter budget. Caller-
supplied `system` / `max_iters` override the type's defaults.

Prompts live in src/tools/subagent-types.ts so subagent.ts stays under
the 500-line target.

Closes #319
2026-05-06 01:59:01 -07:00
YHH b248a87455 feat(subagent): expose max_iters on spawn_subagent tool schema (#321)
Subagents have always had a configurable maxToolIters internally, but
the public spawn_subagent schema exposed only task/system/model. Every
spawn paid the 16-iter default, even narrow verify-style tasks that
want fewer or broad explore tasks that want more.

Add an optional `max_iters` integer field to the schema, clamp at the
boundary to 1-32 so a stray prompt can't request thousands, and pipe
through to spawnSubagent. Non-numeric / missing values fall back to
the registration-time default (still 16).

Closes #318
2026-05-06 01:55:12 -07:00
YHH ff858ed744 feat(subagent): honor skill allowed-tools frontmatter when forking child registry (#320)
Skill frontmatter `allowed-tools` was parsed but ignored — every subagent
inherited the parent's full registry minus spawn_subagent / submit_plan,
which gave skill authors no way to scope a child to read-only or
task-specific tools.

Plumb the parsed list through SpawnSubagentOptions; when present, fork
via new forkRegistryWithAllowList helper instead of forkRegistryExcluding.
NEVER_INHERITED still wins so spawn_subagent stays out even if the
skill's allow-list names it. Validate at spawn time — if the allow-list
names a tool the parent doesn't have, return a structured error result
naming the missing tool, no API call burned.

Direct (non-skill) spawn_subagent calls still see full inheritance —
the tool schema is unchanged.

Closes #317
2026-05-06 01:50:08 -07:00
YHH a5adca41ac chore(release): 0.27.3 (#315) 2026-05-06 01:12:27 -07:00
YHH aed4d431cb chore(ui): remove orphan ChromeBar.tsx (#314)
No live render path — `App.tsx` mounts `StatsPanel`, which has its own
`ChromeRow` that diverged from `ChromeBar` and is the bar users actually
see. Only references are a CHANGELOG history line and one test
description string ("'w \$0.91' style").

The two formatter helpers `ChromeBar` once owned (`formatBalance` /
`formatBalanceLabel`) already live in `theme/tokens.ts` after #272 / #313.
2026-05-06 01:06:20 -07:00
YHH 0acfcefb89 fix(ui): top bar cost + subagent end-cost follow wallet currency (#313)
After #272 fixed StatusRow / UsageCard / ChromeBar BalancePill, three
display sites still hardcoded `$` and ignored the wallet currency:

- StatsPanel.ChromeRow (the live top bar) — cost label `[$0.0058]`
- StatsPanel.ChromeRow — balance label inline-formatted, bypassing the
  shared formatBalance helper from #272
- useSubagent end-event summary — ` · $0.0123` cost suffix

Now all three route through formatCost / formatBalance and follow the
live wallet symbol. Render tests added in ui-stats-panel-currency.test.tsx
covering USD / CNY / no-wallet variants.

useSubagent gets a `getWalletCurrency` thunk (not a snapshot value)
because subagent runs can outlive a balance refresh — the thunk reads
the latest currency at end-event time.

Out of scope: ChromeBar.tsx (orphan, no live render path), DiffApp.tsx
(dev tool comparing historical USD numbers), BudgetRow (USD by definition,
user sets `--budget` in USD). SessionPicker tracked separately in #312.
2026-05-06 00:58:47 -07:00
Wesley Vianaandesengine 9cb0bdbc5f Fix: usd currency on status bar (#272)
* fix: Currency for USD wrong afeter TUI redesign.

* red: tests for the currency bug

* Fix implementation

* fixup: collapse tokens helpers + fix UsageCard cost bug

Addresses the two review comments on this PR:

1. Undefined-currency fallback now defaults to CNY for all three helpers
   (formatBalance, formatCost, balanceColor) — matches pre-fix unconditional ¥.
2. Seven helpers (formatCNY, formatBalance, formatBalanceLabel,
   formatWalletDisplay, formatCost, balanceColorCny, balanceColorForBalance)
   collapsed to three: formatBalance, formatCost, balanceColor.

Also fixes a separate bug surfaced while collapsing: UsageCard turn cost
(header) and session cost (body) used formatCNY, which was unconditional ¥
regardless of wallet currency. They now use formatCost(…, balanceCurrency)
and follow the wallet symbol like StatusRow does.

Render tests added for UsageCard turn/session cost in USD wallet mode.

---------

Co-authored-by: esengine <[email protected]>
2026-05-06 00:44:35 -07:00
YHH 2ca403670d refactor(loop): extract message builders, failure tracker, forced-summary helper (#311)
loop.ts 1331 → 1219 (−112). Three sibling files under src/loop/:
- messages.ts: pure ChatMessage builders (assistant + synthetic)
- turn-failure-tracker.ts: per-turn count/types + threshold tipping
- force-summary.ts: forced-summary generator behind a small DI context

Drops two now-unused imports (errorLabelFor, reasonPrefixFor) from loop.ts.
2026-05-06 00:06:31 -07:00
YHH c6e5bf4b02 refactor(loop): move LoopEvent / BranchSummary types + hook helpers out (#309)
PR2 under #307. Three more pieces that didn't depend on `this`:

- new `src/loop/types.ts` — `EventRole`, `BranchSummary`, `BranchProgress`,
  `LoopEvent`. The public-API re-export from `loop.ts` is preserved
  (`export type { ... } from "./loop/types.js"`), so consumers importing
  from `reasonix` see no change.
- new `src/loop/branch.ts` — `summarizeBranch` lives next to the types
  it produces.
- new `src/loop/hook-events.ts` — `safeParseToolArgs` + `hookWarnings`.
  These format hook outcomes as `LoopEvent`s and were the only callers
  of `formatHookOutcomeMessage` / `HookOutcome` from `./hooks.js`, so
  those imports leave `loop.ts` too.

`loop.ts`: 1414 → 1332 lines. No public API change. `npm run verify`
green (2336 pass / 1 skip).
2026-05-05 21:07:20 -07:00
YHH 08c7f2be81 refactor(loop): extract escalation marker + summary-reason helpers (#308)
PR1 under #307. Six functions and three constants that don't touch
`this` had no business living inside the `CacheFirstLoop` class file.

- new `src/loop/escalation.ts` — `parseEscalationMarker`,
  `isEscalationRequest`, `looksLikePartialEscalationMarker` plus
  `NEEDS_PRO_MARKER_PREFIX` / `NEEDS_PRO_MARKER_RE` /
  `NEEDS_PRO_BUFFER_CHARS`. The streaming chunk consumer + post-call
  retry path call these as plain functions now.
- `src/loop/errors.ts` gains `reasonPrefixFor` + `errorLabelFor` next
  to `formatLoopError`. They share the same `"budget" | "aborted" |
  "context-guard" | "stuck"` reason union and were the only callers.

`loop.ts`: 1463 → 1414 lines. No public API change — these were all
private / file-local. `npm run verify` green (2336 pass / 1 skip).
2026-05-05 21:02:53 -07:00
YHH 8befb44e5e fix(web): rewrite htmlToText with a real HTML parser (#306)
The chain-of-regex approach was tripping CodeQL across three rules
(bad-tag-filter, incomplete-multi-character-sanitization,
polynomial-redos) on the same set of patterns:

- `<tag[\s\S]*?</tag>` misses `</tag >` (whitespace before `>`) and
  re-exposes `<tag` after one removal pass on `<scr<tag>ipt>`-shaped
  input.
- `<[^>]+>` doesn't handle attribute values containing `>` (rare but
  legal in unquoted contexts) and is the canonical "incomplete
  sanitization" footgun.

Replaces both with a `node-html-parser` walk:

- `htmlToText` parses the input, removes `script / style / noscript /
  nav / footer / aside / svg` blocks via `querySelectorAll`, then
  walks the surviving tree and emits text with `\n` breaks at the
  block-level tags we care about (`p`, `div`, `br`, `h1-6`, `li`,
  `tr`, `section`, `article`).
- `stripHtml` (used by `parseMojeekResults`) becomes `parseHtml(s).text`.
- `decodeHtmlEntities` stays — kept for the post-parser pass since
  parser-decoded text can still contain numeric entities in edge
  attribute contexts.
- 5 MB input cap retained.

`node-html-parser` 7.x: 25 KB gzip, zero deps, ~30k packages
depend on it, active maintenance. Lighter than `parse5` for our
use case (we don't need full HTML5 conformance — just the tree).

21 web-tools tests pass unchanged; 2336 total tests green.
2026-05-05 20:38:23 -07:00
zerone0x b65ed747c9 fix(events): emit tool audit trail events (#260) (#304) 2026-05-05 20:28:24 -07:00
zerone0x 86be00299e fix(mcp): make inspect errors actionable (#101) (#303) 2026-05-05 20:24:10 -07:00
YHH b634b5e171 fix(codeql): drain 3 real bugs flagged by CodeQL (#305)
- src/cli/ui/Wizard.tsx — quoteIfNeeded escaped `"` but not `\`. A
  trailing backslash on the input would consume the closing quote
  when a downstream parser un-escaped the result. Escape `\` first
  (CodeQL js/incomplete-sanitization).
- src/cli/ui/clipboard.ts — temp filename was `reasonix-clip-${Date.now()}.txt`,
  predictable + collides under sub-millisecond bursts. Switched to
  `randomUUID()` (CodeQL js/insecure-temporary-file).
- tests/slash.test.ts — `/kb.example.com/` matched too broadly (`.`
  matched any char, no anchors). Tightened to
  `/\bkb\.example\.com\b/` (CodeQL js/incomplete-hostname-regexp +
  js/regex/missing-regexp-anchor).

136 wizard + slash tests pass unchanged.
2026-05-05 20:13:59 -07:00
YHH 3e91082456 fix(edit-blocks): close exists→write and read→write races (CodeQL) (#301)
Drains the last 2 file-system-race warnings in code/edit-blocks.ts.
Replaces the existsSync precheck + separate read/write pair with a
single fd-scoped flow:

- Try `openSync(path, "r+")` first. ENOENT → file doesn't exist:
  bail with `file-missing` (when SEARCH is non-empty) or fall through
  to atomic create.
- Atomic create is `openSync(path, "wx")` — fails on EEXIST so a
  concurrent writer that filled the slot between our `r+` ENOENT and
  our create can't be silently clobbered.
- Modify path keeps one fd open through fstat → read → ftruncate →
  write. ftruncate sets the new length first (so a shorter result
  doesn't leave stale tail bytes), then we overwrite from offset 0.

The 22 edit-blocks tests pass unchanged.

1 file-system-race warning still open (`usage.ts:103` compaction);
needs temp-file + rename, separate PR.
2026-05-05 20:01:04 -07:00
YHH 638eaeeb46 fix(memory): close exists→write race in hash-memory append (CodeQL) (#300)
Drains the last 2 file-system-race warnings in cli/ui that were
exists→write rather than stat→read.

The original `appendBulletToFile` had two races:

1. `existsSync(path)` then `writeFileSync(path, header)` — between the
   check and the write a concurrent appender could create the file,
   and our writeFileSync would clobber it with a fresh header (losing
   their bullet).
2. `readFileSync(path)` then `appendFileSync(path, …)` — peeking the
   trailing byte to decide whether we need a leading `\n`, then a
   separate append. A concurrent writer that added their own newline
   in between would cause us to insert a redundant blank line.

Fix:

- Atomic create-or-fail via `openSync(path, "ax")`. EEXIST means
  another process won the race; we fall through to the append branch
  rather than clobbering.
- For the append branch, open with `a+` once and do the trailing-byte
  peek + write on the same fd. `O_APPEND` means each `writeSync`
  atomically lands at end-of-file regardless of concurrent appenders.

20 hash-memory tests pass unchanged.

3 file-system-race warnings still open: edit-blocks.ts (×2) and
usage.ts:103. They need different surgery (`r+` + ftruncate /
read-modify-write or temp-file + rename); separate PR.
2026-05-05 20:01:00 -07:00
YHH 1a7b88c72f fix(fs): close 4 more stat→read TOCTOU races (CodeQL js/file-system-race) (#299)
Same fix shape as #298 — open once, do the size check and the read
against the same fd. The read on filtered semantic indexes also gets
to enforce its size threshold against the actual bytes we're about
to load, not a stat result that could be stale by the time we look.

- src/index/semantic/chunker.ts:209  per-file read in the indexer —
  re-checks `maxFileBytes` against `fh.stat()` before reading
- src/server/api/semantic.ts:200    `/api/semantic/status` index
  summariser — was `statSync().size` then `readFileSync`
- src/cli/ui/slash/handlers/semantic.ts:78  `/semantic` slash status —
  was `fs.stat` then `fs.readFile`; estimate fallback for >10MB stays
  the same, just bound to the fd's stat
- src/telemetry/usage.ts:74        compactUsageLogIfLarge — folds the
  separate stat / read try-blocks into one fd-scoped sequence

5 file-system-race warnings remain — those are exists→write or
read→write patterns (`hash-memory.ts`, `edit-blocks.ts`,
`usage.ts:103`); separate PR with `openSync(path, "ax" | "r+")`.
2026-05-05 20:00:55 -07:00
YHH 6ee2428802 fix(fs): close 3 stat→read TOCTOU races (CodeQL js/file-system-race) (#298)
Same fix shape across three files: open once, use fstat + read against
the same fd. The fd binding pins the inode for the lifetime of the
operation so a concurrent rename / replace / unlink during the call
can't desync the size or mtime we just observed from the bytes we're
about to read.

- src/tools/filesystem.ts:127  read_file body — was `fs.stat` then
  `fs.readFile`; switches to async `fs.open` + `fh.stat` + `fh.readFile`
- src/server/assets.ts:36      mtime-keyed asset cache — was `statSync`
  then `readFileSync`; switches to `openSync` + `fstatSync` + chunked
  `readSync`
- src/server/api/skills.ts:72  per-skill listing — same shape; also
  drops the redundant `existsSync` precheck that was a separate race
  in the same chain

9 file-system-race warnings remain — those are exists→write or
read→write patterns that need a different fix (open with `ax` / `r+`
flags); separate PR.
2026-05-05 19:28:21 -07:00
YHH febecb3d6f refactor(app): extract codeApply / codeDiscard into useCodeMode hook (#290) (#296)
Phase-2 PR under #290 (start of the feature-cluster phase). The
/apply and /discard slash callbacks move into one hook (93 lines).
The hook owns the partition / snapshot / save / sync sequence and
returns thin string-builders that AppInner forwards to its log.

Drops `partitionEdits` (unused after extraction) and `savePendingEdits`
(only used by the extracted callbacks) from App.tsx's imports.

App.tsx: 3361 → 3304 lines.
2026-05-05 19:18:08 -07:00
YHH 795a1e2f35 refactor(app): extract /loop machinery into useLoopMode hook (#290) (#295)
Phase-1 PR under #290 — biggest single extraction so far. The /loop
slash command's entire lifecycle moves into one hook:

- the activeLoop state + the four refs (mirror, timer handle, firing
  flag, and the timer scheduler effect itself)
- startLoop / stopLoop / getLoopStatus
- the timer scheduler useEffect (35 lines)

External consumers no longer poke at refs directly. The hook exposes:

- startLoop / stopLoop / getLoopStatus (forwarded to slash handlers)
- isLoopActive() — replaces all `activeLoopRef.current` checks (Esc
  handlers, apply-slash-result)
- isLoopFiring() / clearFiringFlag() — replace the cross-component
  `loopFiringRef.current` reads in handleSubmit's cancel guard
- activeLoop — reactive state for the LoopStatusRow render

apply-slash-result.ts's context shape switches `activeLoopRef:
MutableRefObject<...>` to `isLoopActive: () => boolean`. Cleaner —
slash handlers don't need to know about the ref shape.

App.tsx: 3499 → 3400 lines.
2026-05-05 19:07:11 -07:00
YHH 77cd4f7f66 refactor(app): extract quitProcess into useQuit hook (#290) (#293)
Phase-1 PR under #290. The Ctrl+C / SIGINT exit handler now lives in
its own hook: it returns the `quitProcess` callback and registers /
deregisters the SIGINT listener on its own. AppInner just calls
`useQuit(transcriptRef)` and forwards the returned function to the
slash dispatcher and Ctrl+C interceptor.

The "why process.exit, not Ink exit" rationale moves with the code
into the hook's docstring — that's the only place future callers
need to read it.

App.tsx: 3499 → 3483 lines.
2026-05-05 19:01:13 -07:00
YHH 3ec46356f5 refactor(app): extract writeTranscript into useTranscriptWriter hook (#290) (#294)
Phase-1 PR under #290. The transcript writer becomes its own hook —
takes the transcript ref + the model/prefix metadata it needs to
stamp on each record, returns the LoopEvent writer that AppInner
forwards into stream subscribers and slash handlers.

Drops `recordFromLoopEvent` + `writeRecord` from App.tsx's import
block since the hook now owns those.

App.tsx: 3499 → 3493 lines.
2026-05-05 18:58:53 -07:00
YHH 825c6e9608 refactor(app): extract input-recall into useInputRecall hook (#290) (#292)
First Phase-1 PR under #290. Lifts the bash-style ↑/↓ history recall
out of AppInner: the two `useRef`s (`promptHistory`, `historyCursor`)
and the two `useCallback`s (`recallPrev`, `recallNext`) are now
co-located in `src/cli/ui/hooks/useInputRecall.ts`.

The hook returns four functions — `recallPrev` / `recallNext` for
PromptInput, plus `pushHistory` (called on every successful turn) and
`resetCursor` (called once on submit). Existing callers that wrote
`promptHistory.current.push(text)` switch to `pushHistory(text)`;
`historyCursor.current = -1` becomes `resetCursor()`.

`apply-slash-result.ts` had the only out-of-component reference to
the ref — its context shape now accepts `pushHistory: (s: string)
=> void` instead of the raw `MutableRefObject`. Cleaner contract.

App.tsx: 3499 → 3479 lines.
2026-05-05 18:58:47 -07:00
YHH affa03b235 fix: drain CodeQL warnings — polynomial redos + web.ts htmlToText (#291)
* fix(redos): close polynomial backtracking in 4 regexes

CodeQL js/polynomial-redos flagged four regexes whose backtracking
shape is O(n²) on adversarial input. None are reachable from a real
attacker today (the inputs are model output / our own URL constants),
but the patterns are easy to fix and the alerts were noise.

- at-mentions.ts:319 — `/\.+$/` on path-like tokens. Loop-slice instead.
- client.ts:95 — `/\/+$/` on baseUrl. Loop-slice instead.
- scavenge.ts:67/75/86 — three DSML matchers (`<X>[\s\S]*?</X>` shape)
  on reasoning_content. Adds a 100 KB input cap with an early-bail
  note; the matchers themselves stay readable.

Per CodeQL's third remediation example, bounding the input length is
a valid fix when the regex is hard to rewrite — scavenge is best-effort
and the cap is well above any plausible legitimate reasoning_content.

* fix(web): drain CodeQL warnings on htmlToText

htmlToText leaned on a chain of regex replacements that CodeQL flagged
across six rules — bad-tag-filter, incomplete-multi-character-
sanitization, polynomial-redos (×2), double-escaping. None were
exploitable in our pipeline (output goes to the LLM, not a browser),
but the patterns had real correctness bugs visible to the model:

- `<script[\s\S]*?</script>` missed `</script >` (whitespace before
  `>`) and could leave `<script` behind on the second pass when an
  outer match revealed a previously-hidden inner opening.
- Chained `decodeHtmlEntities` replacements ran `&amp;` before `&lt;`,
  so `&amp;lt;` decoded to `<` instead of literal `&lt;`.

Rewrites both:

- `stripTagBlock(s, tag)` — indexOf-based scan with explicit tag-
  boundary check (avoids matching `<scriptfoo>`) and tolerant close
  matching (`</tag …>` with whitespace).
- `decodeHtmlEntities` — single regex pass with an entity map plus
  `&#nnn;` / `&#xHH;` numeric-entity handling. No more order bugs.
- 5 MB input cap on htmlToText so any residual quadratic patterns
  (e.g. the kept `<[^>]+>` strip) stay bounded.

21 web-tools tests still pass.
2026-05-05 18:58:43 -07:00
YHH 4ba0199c42 chore(tsconfig): add @/* path alias for src; migrate slash handlers (#289)
* chore(tsconfig): add @/* path alias for src; migrate slash handlers

The 16 files under src/cli/ui/slash/handlers/ sit four levels deep, so
every import to a top-level module read "../../../../i18n/index.js" or
the equivalent. The depth was load-bearing for nothing — it just made
moves and reviews painful. Adds a single \`@/* → src/*\` alias to
tsconfig.json and mirrors it in vitest.config.ts (esbuild via tsup
already picks up tsconfig paths transparently).

Migrates only the four-level offenders for now. Three-level imports
(siblings within cli/ that resolve to "@/cli/...") are left alone —
the relative form is arguably clearer when the import target is a
neighbour. A follow-up can tighten that if it's worth the churn.

Build, typecheck, and full test suite all pass on the migrated paths.

* fix(jobs): wait for actual close event after SIGKILL, not a fixed timer

stop() had two timing races. Both manifested as the same flake — the
returned snapshot's `running` flag could still be true even though the
test (and the user) just told us to stop the job.

1. SIGKILL phase used a fixed 800ms timer, then returned regardless of
   whether the OS had reaped the process tree. Under Windows scheduler
   load, taskkill /T /F on a 3-level tree (npm → node → vite) can take
   over a second to propagate before Node's `close` event fires.
2. SIGTERM phase awaited `readyPromise`, which is dual-purpose: it
   fires on either a startup ready-signal regex match OR child exit.
   If the job had already matched a ready signal, `readyPromise` was
   already resolved, so the SIGTERM grace race short-circuited
   immediately and we'd jump to SIGKILL with zero pause.

Adds `closedPromise` — fires only on close/error, never on ready
signal — and uses it for both the SIGTERM grace race and the post-
SIGKILL wait, with a 5s ceiling on the latter so a wedged kernel
can't hang us indefinitely.
2026-05-05 18:25:09 -07:00
YHH 5f80b252e8 refactor(filesystem): extract edit + search bodies into src/tools/fs/ (#287)
* refactor(filesystem): extract edit + search bodies into src/tools/fs/

filesystem.ts had grown to 681 lines, with three of the registered
tools — edit_file, search_files, search_content — carrying most of
the weight in their inline `fn:` bodies. The diff helpers (renderEditDiff,
lineDiff) were also trailing the registration code with no good reason
to live in the same file.

- src/tools/fs/edit.ts — applyEdit + lineDiff + renderEditDiff (the LCS-based diff renderer is now in the same file as its only caller).
- src/tools/fs/search.ts — searchFiles + searchContent share a SearchContext shape so each tool's `fn:` becomes a one-line dispatch.
- src/tools/filesystem.ts — registration entries kept; bodies are now thin pass-throughs.

`lineDiff` is re-exported from filesystem.ts so the existing test import
(`tests/filesystem-tools.test.ts`) doesn't move.

No public API change. No behavior change. filesystem.ts: 681 → 486 lines.

* fix(filesystem): close stat→read race in search_content (CodeQL)

Replaces the separate `fs.stat(full)` + `fs.readFile(full)` pair with a
single `fs.open` → `fh.stat` → `fh.readFile` sequence so the size check
and the actual read bind to the same fd / inode. No TOCTOU window.

Functionally equivalent: oversized files still skip cheaply (we never
read past the size check), small files still read in one shot.
2026-05-05 18:24:51 -07:00
YHH 4b77671103 refactor(shell): split tools/shell.ts into parse + exec submodules (#286)
shell.ts had grown to 847 lines, mixing three unrelated concerns:
allowlist parsing (which is pure string work and the security gate),
process spawning (which carries all the Windows / PowerShell / GBK
workarounds), and tool registration with the ToolRegistry. Each lives
in its own file now:

- src/tools/shell/parse.ts — tokenizer, allowlist, RISKY_ARGS demotion
- src/tools/shell/exec.ts — runCommand, prepareSpawn, killProcessTree, all the Windows quirks
- src/tools/shell.ts — registerShellTools, NeedsConfirmationError, format helpers, plus re-exports

No public API change — every name still importable from ./shell.js.
No behavior change. shell.ts: 847 → 304 lines.
2026-05-05 18:24:46 -07:00
YHH 900eae2a3a refactor(loop): extract heal/shrink/thinking/errors helpers into src/loop/ (#284)
loop.ts had grown to 1724 lines, mixing the CacheFirstLoop streaming
class with a dozen helper functions across four unrelated concerns:
message healing, oversized-result shrinking, thinking-mode flags, and
DeepSeek error formatting. Each concern now lives in its own module
under src/loop/, and loop.ts re-exports every public name so external
callers don't move.

No behavior change. loop.ts: 1724 → 1459 lines.
2026-05-05 18:24:42 -07:00
YHH 94047eadf3 fix(jobs): close stop() race condition; drop useless \$ escapes (#288)
* fix(benchmark): drop useless \$ escapes in tdd-eval prompt string

The h1 task description string used `\${file}::\${fullName}` inside a
regular `"..."` literal. The backslashes are no-ops (JS quietly drops
unrecognized escapes in regular strings) but trip CodeQL's
js/useless-regexp-character-escape rule. Identical resulting string,
cleaner source.

* fix(jobs): wait for actual close event after SIGKILL, not a fixed timer

stop() had two timing races. Both manifested as the same flake — the
returned snapshot's `running` flag could still be true even though the
test (and the user) just told us to stop the job.

1. SIGKILL phase used a fixed 800ms timer, then returned regardless of
   whether the OS had reaped the process tree. Under Windows scheduler
   load, taskkill /T /F on a 3-level tree (npm → node → vite) can take
   over a second to propagate before Node's `close` event fires.
2. SIGTERM phase awaited `readyPromise`, which is dual-purpose: it
   fires on either a startup ready-signal regex match OR child exit.
   If the job had already matched a ready signal, `readyPromise` was
   already resolved, so the SIGTERM grace race short-circuited
   immediately and we'd jump to SIGKILL with zero pause.

Adds `closedPromise` — fires only on close/error, never on ready
signal — and uses it for both the SIGTERM grace race and the post-
SIGKILL wait, with a 5s ceiling on the latter so a wedged kernel
can't hang us indefinitely.
2026-05-05 18:24:17 -07:00
YHH 0945a1189e test(comment-policy): ban bare TODO, FIXME, and translator notes (#285)
TODO/HACK markers without a (#nnn) issue anchor are debt leakage —
they accumulate without ever being tracked. Require the anchor; bare
markers fail the comment-policy gate.

FIXME is banned outright: either fix it now or open an issue and turn
it into a TODO(#nnn). The marker without follow-through is just noise.

Translator-note comments in i18n files ("english version", "中文版",
etc.) restate what the strings already say — i18n entries are
self-documenting.

src/ currently has zero violations of any of these rules, so this is a
floor-keeper, not a cleanup.
2026-05-05 18:24:13 -07:00
YHH bba8199ba4 chore(test): retry once on flaky tests (#282)
jobs.test.ts / loop.test.ts / bundle-smoke spawn real processes and
load the tokenizer cold; on Windows under load these intermittently
miss their wall-clock budget. One retry absorbs the scheduler
hiccup; a real failure still re-fails on the second run.
2026-05-05 16:05:58 -07:00
esengine fc8639a697 chore(release): 0.27.2 2026-05-05 15:51:25 -07:00
YHH 8c51dd6f0f fix(settings): preserve fields when settings POST writes more than one (#281)
handleSettings read cfg once up front, then per-field either called a
self-contained helper (setLanguage / saveApiKey / saveReasoningEffort)
or mutated the snapshot and called writeConfig — so a combined POST
like { lang, baseUrl } would write lang via the helper, then clobber
it with the stale snapshot in the writeConfig branch.

Single read up top, all field updates accumulate into one cfg, single
writeConfig at the end. Runtime side-effects (setLanguage,
applyPresetLive, applyEffortLive) fire after the disk write succeeds
so disk + runtime state can't diverge mid-request.

Closes #274
2026-05-05 15:48:51 -07:00
YHH 56f1cbb835 fix(filesystem): search_content.glob accepts real glob patterns (#280)
The `glob` arg was always a `name.toLowerCase().includes(...)` substring
filter, so `*.ts` matched the literal three-char filename instead of
TypeScript files. Param name advertised glob, behavior didn't.

Now glob via picomatch when the value contains `*`, `?`, `{`, or `[`;
case-insensitive substring otherwise so existing `.ts` / `test` callers
keep working. Patterns containing `/` match against the rel-path
(enabling `src/**/*.tsx`), basename otherwise.

Closes #273
2026-05-05 15:42:25 -07:00
YHH abb1b1ec7a fix(filesystem): search_files skips dependency/build dirs by default (#279)
Aligns search_files with search_content + directory_tree: skip the
existing SKIP_DIR_NAMES set (node_modules / .git / dist / build /
.next / target / .venv) by default, accept include_deps:true to opt
back into walking them. Fixes the surprising mismatch where
search_content("foo") and search_files("foo") had different default
scopes.

Closes #267
2026-05-05 15:32:18 -07:00
ctharveyandCharles H 4f1e65a629 fix(filesystem): slash-normalize tool output paths (#270)
Co-authored-by: Charles H <[email protected]>
2026-05-05 14:54:57 -07:00
ctharveyandCharles H 7096e43895 fix(shell): reject cd in parsed chains (#277)
Co-authored-by: Charles H <[email protected]>
2026-05-05 14:54:45 -07:00
427 changed files with 29115 additions and 18341 deletions

No files matched your search

+12 -5
View File
@@ -4,6 +4,10 @@ about: Something is broken
labels: bug
---
> **Screen flicker, garbled output, leftover artifacts, cursor jumping?**
> Use the **Display / rendering issue** template instead — it asks for the
> terminal-specific info we need to diagnose those.
**What happened**
A clear and concise description.
@@ -14,10 +18,13 @@ What you expected to happen.
Steps or minimal code that reproduces it.
**Environment**
- Reasonix version:
- Node version:
- OS:
- DeepSeek model (e.g. `deepseek-chat`, `deepseek-reasoner`):
- Reasonix version (`reasonix --version`):
- Node version (`node --version`):
- OS (Windows 11 / macOS 14 / Ubuntu 24.04 / …):
- Shell (bash, zsh, fish, PowerShell 7, PowerShell 5.1, cmd, …):
- Terminal app (Windows Terminal, iTerm2, Alacritty, kitty, WezTerm, **VSCode integrated**, **Cursor integrated**, Hyper, …):
- DeepSeek model (e.g. `deepseek-v4-flash`, `deepseek-v4-pro`):
**Logs / transcript**
If using the CLI, attach the relevant chunk of `--transcript`.
If using the CLI, attach the relevant chunk of `--transcript`, or run
`reasonix doctor` and paste the output.
+109
View File
@@ -0,0 +1,109 @@
---
name: Display / rendering issue
about: Screen flicker, garbled output, leftover artifacts, cursor jumping
labels: bug, rendering
---
> Display problems almost always come from the **terminal emulator**, not
> the shell. Please fill the terminal section carefully — `bash vs PowerShell`
> tells us very little; `VSCode integrated terminal vs Windows Terminal`
> tells us everything.
**Symptom** (tick all that apply)
- [ ] Whole screen flickers / flashes during streaming response
- [ ] Lines tear or only half-redraw
- [ ] Stale output left behind after a frame updates
- [ ] Cursor jumps to wrong column or vanishes
- [ ] Mojibake / wrong-width characters (e.g. `□`, half-width emoji)
- [ ] Other (describe below)
**When it happens**
- [ ] During assistant streaming (token-by-token output)
- [ ] When tool cards expand / collapse
- [ ] During scroll-up / scrollback
- [ ] On terminal resize
- [ ] On launch / on quit
- [ ] Other (describe below)
**Terminal — the important part**
Where exactly are you running `reasonix`?
- [ ] **VSCode** integrated terminal — VSCode version: `?`
- [ ] **Cursor** integrated terminal — Cursor version: `?`
- [ ] **Windows Terminal** — version: `?`
- [ ] **cmd.exe** (legacy console host)
- [ ] **PowerShell ISE** (note: ISE doesn't support ANSI — most things will look broken)
- [ ] **iTerm2** / **Terminal.app** / **Alacritty** / **kitty** / **WezTerm** / **Hyper**
- [ ] tmux / screen / mosh — and inside which outer terminal? `?`
- [ ] Other:
> 💡 **How to find your VSCode / Cursor version**
> `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS).
> Paste the whole panel — version + commit + Electron + xterm.js if shown.
**Diagnostic dump — copy/paste output**
Run **one** of the snippets below in the same terminal where you saw the
issue, and paste the output here:
<details><summary>PowerShell (Windows)</summary>
```powershell
reasonix --version; node --version
$PSVersionTable.PSVersion.ToString()
[System.Environment]::OSVersion.VersionString
"TERM=$env:TERM"
"TERM_PROGRAM=$env:TERM_PROGRAM"
"TERM_PROGRAM_VERSION=$env:TERM_PROGRAM_VERSION"
"COLORTERM=$env:COLORTERM"
"WT_SESSION=$env:WT_SESSION"
"VSCODE_INJECTION=$env:VSCODE_INJECTION"
"WSL_DISTRO_NAME=$env:WSL_DISTRO_NAME"
```
</details>
<details><summary>bash / zsh (macOS / Linux / WSL / Git Bash)</summary>
```bash
reasonix --version; node --version
uname -a
echo "TERM=$TERM"
echo "TERM_PROGRAM=$TERM_PROGRAM"
echo "TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION"
echo "COLORTERM=$COLORTERM"
echo "WT_SESSION=$WT_SESSION"
echo "VSCODE_INJECTION=$VSCODE_INJECTION"
echo "WSL_DISTRO_NAME=$WSL_DISTRO_NAME"
```
</details>
```
<paste output here>
```
**VSCode / Cursor users only — terminal settings**
Open Settings (`Ctrl+,`), search `terminal.integrated.gpuAcceleration`,
report current value: `auto` / `on` / `canvas` / `off` — `?`
Already tried any of:
- [ ] Switching `gpuAcceleration` to a different value
- [ ] Detaching the terminal (drag tab into its own window)
- [ ] Running the same command in a non-VSCode terminal — did it still flicker? `yes / no`
**Reproduction**
Steps that reliably trigger it (commands run, files edited, was a tool
streaming a long response, was the window being resized, …):
1.
2.
3.
**Screen recording (strongly preferred)**
A 5–10s GIF or MP4 is worth 1000 words for rendering bugs. Drop it in
this comment box — GitHub uploads attachments inline.
-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
+2
View File
@@ -33,4 +33,6 @@ sessions/
.reasonix/semantic-skip
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
scripts/.cm-entry.mjs
# Personal bun lockfile — project uses npm officially.
bun.lock
+1498
View File
File diff suppressed because it is too large. Load diff
+75 -11
View File
@@ -7,7 +7,9 @@
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/reasonix/">Website</a>
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">Guide</a>
&nbsp;·&nbsp;
<a href="./docs/ARCHITECTURE.md">Architecture</a>
&nbsp;·&nbsp;
@@ -41,6 +43,9 @@
> [!TIP]
> **Cache stability isn't a feature you turn on; it's an invariant the loop is designed around.** That's the whole reason Reasonix is DeepSeek-only — every layer is tuned to the byte-stable prefix-cache mechanic.
> [!NOTE]
> **Real user, single day (2026-05-01):** 435M input tokens, **99.82% cache hit**, ~$12 instead of the ~$61 the same workload would cost with no cache on `v4-flash` — see the [case study](./benchmarks/real-world-cache/README.md). DeepSeek provides the cacheable bytes; the four mechanisms in [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) are how Reasonix keeps them cacheable across long sessions.
<br/>
## Install
@@ -50,21 +55,78 @@ cd my-project
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.
Requires Node ≥ 22. Works on macOS · Linux · Windows (PowerShell · Git Bash · Windows Terminal). Grab a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reasonix code --help` for flags.
`npx` is the recommended path — no global install, always latest. If you use Reasonix daily and want it on `PATH`, run `reasonix update` once.
| Command | When |
|---|---|
| `reasonix code [dir]` | The coding agent. **Start here.** |
| `reasonix chat` | Plain chat — no filesystem or shell tools. |
| `reasonix run "task"` | One-shot, streams to stdout. Good for pipes. |
| `reasonix doctor` | Health check: Node, API key, MCP wiring. |
| `reasonix update` | Upgrade Reasonix itself. |
Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`) are in `reasonix --help` and the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
<details>
<summary><strong>Working in another folder · chat vs. code · author a skill</strong></summary>
**Working in a different folder.** Reasonix scopes filesystem tools to the launch directory; pass `--dir` to retarget. Mid-session switching isn't supported by design (memory paths would tangle with stale roots) — quit and relaunch.
```bash
npx reasonix code --dir /path/to/project
```
**Picking `chat` vs `code`.** `code` is the default and the only mode with filesystem / shell tools and SEARCH/REPLACE review. `chat` is the lighter, tools-off shell — reach for it when you want a thinking partner with MCP attached but no disk access.
| What you get | `code` | `chat` |
|---|---|---|
| Filesystem tools + `edit_file` | ✓ | — |
| SEARCH/REPLACE → `/apply` review | ✓ | — |
| Shell tool (gated) | ✓ | — |
| Plan mode · `/todo` · `/skill new` · `/mcp add` | ✓ | — |
| Memory (`remember` / `recall_memory`) | project + global | global only |
| MCP servers from config · web search · `ask_choice` | ✓ | ✓ |
| Coding system prompt | ✓ | generic |
| Session scope | per-directory | shared default |
**Author your first skill.** No remote registry — write them directly. Edit the file (`description:` frontmatter + body), then `/skill list`. Add `runAs: subagent` to spawn an isolated subagent loop instead of inlining the body.
```bash
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # ~/.reasonix/skills for cross-project use
```
</details>
<br/>
## Configuration
One JSON file at `~/.reasonix/config.json` plus per-project overrides under `<project>/.reasonix/`. The full bilingual reference — every key, every slash command, the on-disk shape of skills/memory/hooks — lives at:
> 📘 **[Configuration Guide](https://esengine.github.io/DeepSeek-Reasonix/configuration.html)** · [中文](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh)
| Topic | Quick read |
|---|---|
| [MCP servers](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#mcp) | stdio · SSE · Streamable HTTP. One spec format works for both `config.json` and `--mcp`. |
| [Skills](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#skills) | Markdown playbooks the model can invoke. `inline` or `subagent` mode. |
| [Memory](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#memory) | User-private knowledge pinned into the prefix. `user` / `feedback` / `project` / `reference` types. |
| [Hooks](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#hooks) | Shell commands on lifecycle events. `PreToolUse` (gating) · `PostToolUse` · `UserPromptSubmit` · `Stop`. |
| [Permissions](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#permissions) | Per-workspace shell allowlist. Exact-prefix match. |
| [Web search](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | Mojeek by default; switch to self-hosted SearXNG with `/search-engine`. |
| [Semantic index](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#index) | `reasonix index` — local Ollama or any OpenAI-compatible embedding endpoint. |
<br/>
## 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>
@@ -87,8 +149,10 @@ Click any card to read the full architecture writeup → [Pillar 1](./docs/ARCHI
| Cost profile | **low per task** | premium | subscription + use | varies |
| DeepSeek prefix-cache | **engineered** | not applicable | not applicable | incidental |
| Embedded web dashboard | yes | — | n/a (IDE) | — |
| Configurable web search engine | `/search-engine` | — | — | — |
| Persistent per-workspace sessions | yes | partial | n/a | — |
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
| Web search (Mojeek + SearXNG) | yes | yes | yes | yes |
| Open community development | yes | — | — | yes |
For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmarks/) — the numbers move with model pricing, so they live with the harness, not in the README.
@@ -97,9 +161,9 @@ 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/reasonix/) — getting started, dashboard mockup, TUI mockup
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
- [**Code of Conduct**](./CODE_OF_CONDUCT.md) · [**Security policy**](./SECURITY.md)
@@ -115,7 +179,7 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
**Open Discussions — opinions wanted:**
- [#20 · CLI / TUI design](https://github.com/esengine/reasonix/discussions/20) — what's broken, what's missing, what would you change?
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/reasonix/design/agent-dashboard.html)
- [#21 · Dashboard design](https://github.com/esengine/reasonix/discussions/21) — react against the [proposed mockup](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)
- [#22 · Future feature wishlist](https://github.com/esengine/reasonix/discussions/22) — what would you build into Reasonix next?
**Already using Reasonix and willing to help others discover it?** Publish blog posts, articles, screenshots, talks, or videos to [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell). The project has no marketing budget — community word of mouth is how new users find it. Sustained advocates earn the badge below, displayed next to the contributors wall once awarded:
+69 -10
View File
@@ -7,7 +7,9 @@
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/reasonix/">官方网站</a>
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh">配置指南</a>
&nbsp;·&nbsp;
<a href="./docs/ARCHITECTURE.md">架构文档</a>
&nbsp;·&nbsp;
@@ -50,21 +52,78 @@ cd my-project
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`。
要求 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`。
| 命令 | 何时用 |
|---|---|
| `reasonix code [dir]` | 编码 agent。**先用这个。** |
| `reasonix chat` | 纯聊天 —— 不挂文件系统 / shell 工具。 |
| `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)。
<details>
<summary><strong>切换工作区 · chat vs. code · 写第一个 Skill</strong></summary>
**切换工作区。** Reasonix 把文件系统工具作用域绑定在启动目录,传 `--dir` 可以指别处。中途切换是有意不支持的(消息日志和 memory 路径会和旧根目录混在一起)—— 退出再启动。
```bash
npx reasonix code --dir /path/to/project
```
**`chat` 还是 `code`?** `code` 是默认入口、唯一带文件系统 / shell 工具和 SEARCH/REPLACE 审阅的模式。`chat` 是更轻量的纯对话壳——想要一个挂着 MCP 但没有磁盘权限的“思路助手”时用它。
| 你拿到什么 | `code` | `chat` |
|---|---|---|
| 文件系统工具 + `edit_file` | ✓ | — |
| SEARCH/REPLACE → `/apply` 审阅 | ✓ | — |
| Shell 工具(带 gate) | ✓ | — |
| Plan 模式 · `/todo` · `/skill new` · `/mcp add` | ✓ | — |
| Memory(`remember` / `recall_memory`) | 项目 + 全局 | 仅全局 |
| 配置里的 MCP · web 搜索 · `ask_choice` | ✓ | ✓ |
| 编码导向系统提示词 | ✓ | 通用 |
| Session 作用域 | 按目录 | 共享默认 |
**写第一个 Skill。** 暂无在线市场——自己写。编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 加 `runAs: subagent` 会以隔离 subagent 跑,而不是把正文内联进父 prompt。
```bash
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # ~/.reasonix/skills,跨项目共用
```
</details>
<br/>
## 配置
一个全局 JSON 文件 `~/.reasonix/config.json`,加上项目级 `<project>/.reasonix/` 下的覆盖。完整的双语参考 —— 每一个 key、每一条斜杠命令、skills / memory / hooks 在磁盘上的形状 —— 都在这里:
> 📘 **[配置指南](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh)** · [English](https://esengine.github.io/DeepSeek-Reasonix/configuration.html)
| 主题 | 速读 |
|---|---|
| [MCP 服务器](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#mcp) | stdio · SSE · Streamable HTTP。`config.json` 和 `--mcp` 共用同一种 spec 格式。 |
| [Skills](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#skills) | 模型可以调用的 markdown 剧本。`inline` 或 `subagent` 两种模式。 |
| [Memory](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#memory) | 用户私有的知识,钉进前缀。`user` / `feedback` / `project` / `reference` 四类。 |
| [Hooks](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#hooks) | 生命周期事件触发的 shell 命令。`PreToolUse`(拦截)· `PostToolUse` · `UserPromptSubmit` · `Stop`。 |
| [权限](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#permissions) | 按工作区的 shell 白名单,精确前缀匹配。 |
| [Web 搜索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#search) | 默认 Mojeek;用 `/search-engine` 可切到自托管的 SearXNG。 |
| [语义索引](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#index) | `reasonix index` —— 本地 Ollama,或任何 OpenAI 兼容的 embedding 接口。 |
<br/>
## 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>
@@ -99,7 +158,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
- [**官方网站**](https://esengine.github.io/reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
- [**行为准则**](./CODE_OF_CONDUCT.md) · [**安全策略**](./SECURITY.md)
@@ -115,7 +174,7 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
**正在征集意见的 Discussions:**
- [#20 · CLI / TUI 设计](https://github.com/esengine/reasonix/discussions/20) —— 哪里坏了、哪里少东西、哪里你会怎么改?
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/reasonix/design/agent-dashboard.html)拍砖
- [#21 · Dashboard 设计](https://github.com/esengine/reasonix/discussions/21) —— 对着[设计稿](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)拍砖
- [#22 · 未来功能愿望单](https://github.com/esengine/reasonix/discussions/22) —— 你希望 Reasonix 长出什么功能?
**正在使用 Reasonix,愿意让更多人了解它?** 欢迎将相关博客、文章、截图、演讲或视频发布到 [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell)。项目没有营销预算,新用户主要通过社区口碑找到这里。持续参与传播的用户将获得下方这枚徽章,颁发后会展示在贡献者墙旁:
-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[];
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+76
View File
@@ -0,0 +1,76 @@
# Real-world cache hit — single user, single day
A real Reasonix user shared their DeepSeek dashboard for **2026-05-01**.
Used with permission, anonymized.
![DeepSeek usage dashboard, 2026-05-01](2026-05-01-deepseek-dashboard.png)
## The numbers
| | Tokens |
|---|---:|
| Input — cache hit | 435,033,856 |
| Input — cache miss | 767,616 |
| Output | 179,763 |
| **Day total** | **435,981,235** |
**Cache hit ratio (input):**
`435,033,856 / (435,033,856 + 767,616)` = **99.82%**
## Cost — using the prices Reasonix bills against (`src/telemetry/stats.ts`)
USD per 1M tokens — `inputCacheHit / inputCacheMiss / output`:
- `deepseek-v4-flash` — `0.028 / 0.139 / 0.278`
- `deepseek-v4-pro` — `0.139 / 1.667 / 3.333`
Assuming **v4-flash** (the project default):
| | This user (99.82% hit) | Same workload, **0% cache** |
|---|---:|---:|
| Cache-hit input | $12.18 | — |
| Cache-miss input | $0.11 | $60.58 |
| Output | $0.05 | $0.05 |
| **Total / day** | **$12.34** | **$60.63** |
→ Cache saved this user **$48.29**, or **~80%** off the un-cached baseline, on a single day.
On **v4-pro** (5× the prefix-cache discount) the same workload would cost
**~$62.35** vs **~$727.08** without cache — a **~91% saving**.
## "Isn't that just DeepSeek's prefix cache?"
DeepSeek's API ships prefix caching enabled by default; the *cache* is theirs,
the *hit rate* is the client's. Same API, different clients, very different
hit rates:
- DeepSeek's own web chat: 60–80% within a single conversation, drops to 0%
on a new session (system prompt may differ).
- Cherry Studio / Open WebUI / generic OpenAI-shape SDKs: typically 30–60%
on long sessions — history gets reordered, tool specs get re-serialized,
every drift breaks the prefix.
- Cline / Continue and other XML-tool-call clients: lower still — every tool
result inlines into the conversation, shifting bytes the cache keys on.
99.82% is what falls out of these four design choices in Reasonix:
1. **`ImmutablePrefix`** (`src/memory.ts`) — system prompt + tool specs are
frozen at session start. Same byte sequence every turn.
2. **`AppendOnlyLog`** — turns only append. No reorder, no edit-in-place.
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
the cached prefix so it never poisons the next hit.
4. **Auto-compact** — when context approaches the cap, older turns fold into
a summary message *appended* to the prefix; the prefix itself isn't
rewritten, so the cache survives the fold.
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
the bytes cacheable.
## Reproduce
The synthetic side of this lives in `benchmarks/tau-bench/` — same task set
run through `CacheFirstLoop` vs a deliberately cache-hostile baseline. The
real-world data above is what the synthetic numbers look like once a user
runs the harness in anger.
Submit your own dashboard screenshot if you want it anonymized and added
here — open an issue.
+1 -1
View File
@@ -45,7 +45,7 @@ const PROMPTS = [
{ id: "m3", level: "medium", task: "A function \`debounceAsync<T extends any[], R>(fn: (...args: T) => Promise<R>, ms: number): (...args: T) => Promise<R>\` in src/util/debounce.ts. Resolves only the latest call's promise; earlier callers reject with an AbortError-like." },
// hard (2) — these touch domain types from the repo
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`\${file}::\${fullName}\` with source='native'." },
{ id: "h1", level: "hard", task: "A function \`extractTestId(file: string, fullName: string, source: string): { id: string, source: 'native' | 'annotation' }\` in src/repair/test-id.ts. If \`source\` contains a '// @reasonix-test-id: <slug>' comment within 3 lines above an it()/test() whose name matches \`fullName\`, return that slug with source='annotation'. Otherwise return \`${file}::${fullName}\` with source='native'." },
{ id: "h2", level: "hard", task: "A function \`pairRedGreen(events: Array<{type:string, test_id?:string, status?:string, ts:number}>): Array<{ test_id: string, red_ts: number, green_ts: number }>\` in src/events/pair.ts. For each test_id, find the most recent fail→pass transition and return one entry per test_id. Ignore test_ids that never went green." },
];
+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.
+168 -14
View File
@@ -85,7 +85,7 @@ a:hover { text-decoration: underline; }
/* ── Doc chrome ─────────────────────────────────────────────────────────── */
.page {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-columns: 260px minmax(0, 1fr);
max-width: 1320px;
margin: 0 auto;
min-height: 100vh;
@@ -94,18 +94,19 @@ a:hover { text-decoration: underline; }
position: sticky; top: 0; align-self: start;
height: 100vh; overflow-y: auto;
border-right: 1px solid var(--bd);
padding: 28px 18px;
padding: 28px 16px;
background: var(--bg);
}
.toc h1 { font-size: 14px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 { font-size: 15px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 .dot { color: var(--c-brand); margin-right: 8px; }
.toc .sub { font-size: 11px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc .sub { font-size: 12px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc-section:first-of-type { margin-top: 0; }
.toc ul { list-style: none; padding: 0; margin: 0; }
.toc li a {
display: block; padding: 3px 8px; margin: 1px 0;
color: var(--fg-2); font-size: 12.5px; border-radius: var(--r);
display: block; padding: 4px 10px; margin: 1px 0;
color: var(--fg-2); font-size: 14px; line-height: 1.4;
border-radius: var(--r); overflow-wrap: anywhere;
}
.toc li a:hover { color: var(--fg-0); background: var(--bg-elev); text-decoration: none; }
@@ -118,7 +119,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
}
.section > h2 .num { color: var(--fg-4); margin-right: 10px; font-weight: 500; }
.section > .lede {
color: var(--fg-2); margin: 0 0 22px; font-size: 13px; max-width: 700px; line-height: 1.6;
color: var(--fg-2); margin: 0 0 22px; font-size: 15px; max-width: 720px; line-height: 1.6;
}
.subsec { margin-bottom: 22px; }
.subsec > h3 {
@@ -126,8 +127,8 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
margin: 24px 0 4px; letter-spacing: .04em; text-transform: uppercase;
font-family: var(--font-mono);
}
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 12px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 12.5px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 13px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 15px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
/* "Mock" — a faux-window frame to display dashboard pieces inside the design doc */
.mock {
@@ -155,11 +156,11 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
.swatch .chip { width: 22px; height: 22px; border-radius: var(--r); flex-shrink: 0; border: 1px solid rgba(255,255,255,.04); }
.swatch .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.swatch .name { color: var(--fg-1); font-size: 11.5px; }
.swatch .hex { color: var(--fg-3); font-size: 10.5px; }
.swatch .hex { color: var(--fg-3); font-size: 11.5px; }
.scale-row { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px dashed #181b22; }
.scale-row:last-child { border-bottom: none; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); width: 70px; flex-shrink: 0; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); width: 76px; flex-shrink: 0; }
.scale-row .ex { color: var(--fg-1); }
.glyph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
@@ -311,12 +312,13 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
.pill.acc { color: var(--c-accent); background: rgba(210,168,255,.10); }
/* Table */
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; table-layout: auto; }
.tbl th, .tbl td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--bd); }
.tbl th { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; background: var(--bg-elev); }
.tbl th.num, .tbl td.num { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; }
.tbl td { color: var(--fg-1); }
.tbl tbody tr:hover { background: var(--bg-hover); }
.tbl td.num { font-family: var(--font-mono); text-align: right; color: var(--fg-0); font-variant-numeric: tabular-nums; }
.tbl td.num { color: var(--fg-0); }
.tbl td.dim { color: var(--fg-3); }
.tbl td.path { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-2); }
@@ -812,6 +814,9 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
}
.chip-f:hover { background: var(--bg-hover); color: var(--fg-1); }
.chip-f.active { color: var(--c-brand); border-color: var(--c-brand); background: rgba(121,192,255,.08); }
.chip-f.static { cursor: default; }
.chip-f.static:hover { background: var(--bg-elev); color: var(--fg-2); }
.chip-f.static.active:hover { color: var(--c-brand); background: rgba(121,192,255,.08); }
.chip-f .ct { color: var(--fg-4); font-size: 10px; }
.chip-f.active .ct { color: var(--c-brand); }
.chip-f .x { color: var(--fg-4); padding: 0 2px; }
@@ -2016,6 +2021,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. */
+264 -2
View File
@@ -1,4 +1,5 @@
import { marked } from "marked";
import { memo } from "preact/compat";
import { useState } from "preact/hooks";
import { html } from "../lib/html.js";
import { t, useLang } from "../i18n/index.js";
@@ -104,6 +105,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;
@@ -257,7 +301,11 @@ export function ToolCard({ msg }: ToolCardProps) {
`;
}
export function ChatMessage({ msg, streaming }: ChatMessageProps) {
// memo() short-circuits re-renders when shallow props are unchanged.
// Historical messages keep stable msg references across deltas, so the
// O(N) marked.parse + hljs work that used to fire per assistant_delta
// now only runs on truly new messages and the live streaming bubble.
export const ChatMessage = memo(function ChatMessage({ msg, streaming }: ChatMessageProps) {
const role = msg.role;
const glyph = ROLE_GLYPH[role as ChatRole] ?? "·";
if (role === "tool") {
@@ -278,7 +326,7 @@ export function ChatMessage({ msg, streaming }: ChatMessageProps) {
</div>
</div>
`;
}
});
//
// Each component renders a card matching the TUI's ModalCard accent
@@ -617,6 +665,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) =>
+106 -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",
@@ -180,12 +214,19 @@ export const en = {
dailyMeta: "cost · cache saved · turns",
noData: "No usage data yet — run a turn in reasonix chat / code / run and refresh.",
windows: "Rolling windows",
colWindow: "window",
colTurns: "turns",
colCacheHit: "cache hit",
colCost: "cost (USD)",
colCacheSaved: "cache saved",
colVsClaude: "vs Claude",
colSaved: "saved",
axisTime: "time",
axisUsd: "USD",
axisTurns: "turns",
seriesCost: "cost",
seriesCacheSaved: "cache saved",
seriesTurns: "turns",
mostUsed: "Most used models",
colModel: "model",
},
@@ -197,7 +238,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 +260,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 +299,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 +328,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 +381,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 +425,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 +439,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 +457,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 +513,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 +548,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 +559,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 +583,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",
},
};
+87 -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",
@@ -180,12 +211,19 @@ export const zhCN = {
dailyMeta: "费用 · 缓存节省 · 轮次",
noData: "暂无用量数据 — 在 reasonix chat / code / run 中执行一轮,然后刷新。",
windows: "滚动窗口",
colWindow: "时间范围",
colTurns: "轮次",
colCacheHit: "缓存命中",
colCost: "费用 (USD)",
colCacheSaved: "缓存节省",
colVsClaude: "对比 Claude",
colSaved: "节省",
axisTime: "时间",
axisUsd: "美元",
axisTurns: "轮次",
seriesCost: "费用",
seriesCacheSaved: "缓存节省",
seriesTurns: "轮次",
mostUsed: "最常用模型",
colModel: "模型",
},
@@ -222,20 +260,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 +294,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 +323,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 +375,8 @@ export const zhCN = {
runs7d: "次运行 · 7 天",
pickHint: "选择左侧的技能,或在上方创建新技能。",
readOnlyBuiltin: "只读 · 内置",
builtinDesc: "内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
builtinDesc:
"内置技能随 Reasonix 一起发布;模型会自动识别。如需自定义,请创建同名的项目或全局技能。",
saved: "已保存 {scope}/{name}",
deleteConfirm: "删除技能 {scope}/{name}?",
reloadHint: "在下次 /new 或会话重启时重新加载",
@@ -375,6 +419,7 @@ export const zhCN = {
filterPlaceholder: "筛选计划",
active: "进行中",
done: "已完成",
idle: "未开始",
steps: "步骤",
pickHint: "选择左侧的计划。",
noTitle: "(无标题)",
@@ -404,16 +449,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 +504,14 @@ export const zhCN = {
nothingSkipped: "无跳过 — 所有遍历的文件都将被索引。",
firstIncluded: "前 {count} 个包含的文件",
job: "任务",
phaseSetup: "初始化中",
phaseScan: "扫描文件",
phaseEmbed: "嵌入分块",
phaseWrite: "写入索引",
phaseDone: "完成",
phaseError: "错误",
phaseCancelled: "已停止",
setupFailed: "初始化失败",
stopping: "停止中",
scanned: "已扫描 {count}",
changed: "已变更 {count}",
@@ -473,7 +539,7 @@ export const zhCN = {
shellSubtitle: "模型想要运行一条 Shell 命令",
shellBgSubtitle: "长时间运行 — 批准后继续运行",
runOnce: "运行一次",
alwaysAllow: "始终允许 \"{prefix}\"",
alwaysAllow: '始终允许 "{prefix}"',
deny: "拒绝",
choiceTitle: "模型需要您选择",
typeOwn: "输入自定义回答",
@@ -507,5 +573,15 @@ export const zhCN = {
reject: "拒绝",
arguments: "参数",
revisePlaceholder: "下一步之前需要更改什么?留空则直接继续。",
pickerFilter: "过滤…",
pickerEmpty: "暂无内容。",
pickerLoadMore: "加载更多",
pickerPick: "打开",
pickerInstall: "安装",
pickerUninstall: "卸载",
pickerRename: "重命名…",
pickerNew: "新建…",
pickerNewPlaceholder: "名称(留空使用默认)",
viewerClose: "关闭",
},
};
+45
View File
@@ -0,0 +1,45 @@
export type BudgetState =
| { kind: "off"; spent: number }
| { kind: "running"; cap: number; spent: number; pct: number }
| { kind: "warn"; cap: number; spent: number; pct: number }
| { kind: "exhausted"; cap: number; spent: number; pct: number };
export function deriveBudgetState(
cap: number | null | undefined,
spent: number | null | undefined,
): BudgetState {
const safeSpent = typeof spent === "number" && spent >= 0 ? spent : 0;
if (typeof cap !== "number" || cap <= 0) {
return { kind: "off", spent: safeSpent };
}
const pct = (safeSpent / cap) * 100;
if (pct >= 100) return { kind: "exhausted", cap, spent: safeSpent, pct };
if (pct >= 80) return { kind: "warn", cap, spent: safeSpent, pct };
return { kind: "running", cap, spent: safeSpent, pct };
}
/** Default quick-cap menu — round dollar amounts users actually pick. */
export const QUICK_CAPS_USD: ReadonlyArray<number> = [1, 5, 10, 25, 50];
/** 1.5× / 2× / 4× the current cap, snapped to a "nice" round number per bucket. */
export function bumpSuggestions(currentCap: number): number[] {
if (currentCap <= 0) return [];
return [niceUp(currentCap * 1.5), niceUp(currentCap * 2), niceUp(currentCap * 4)];
}
function niceUp(n: number): number {
// Subtract a tiny epsilon before ceil so FP noise (0.4 * 1.5 = 0.6000…01)
// doesn't bump a value to the next bucket.
const eps = 1e-9;
if (n < 1) return Math.ceil((n - eps) * 10) / 10;
if (n < 10) return Math.ceil((n - eps) * 2) / 2;
if (n < 100) return Math.ceil(n - eps);
return Math.ceil((n - eps) / 5) * 5;
}
/** Tone class shared between the cockpit tile and the settings gauge. */
export function budgetTone(state: BudgetState): "" | "warn" | "err" {
if (state.kind === "exhausted") return "err";
if (state.kind === "warn") return "warn";
return "";
}
+18
View File
@@ -4,6 +4,24 @@ export function fmtUsd(n: number | null | undefined): string {
return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
}
/** Keep in sync with src/cli/ui/theme/tokens.ts USD_TO_CNY. */
const USD_TO_CNY = 7.2;
/** USD-internal cost rendered in the wallet's display currency. Undefined currency → CNY (matches CLI default). */
export function fmtCost(
usd: number | null | undefined,
currency: string | null | undefined,
fractionDigits?: number,
): string {
if (usd === null || usd === undefined) return "—";
const cur = currency ?? "CNY";
const amount = cur === "CNY" ? usd * USD_TO_CNY : usd;
if (amount === 0) return cur === "CNY" ? "¥0" : "$0";
const sym = cur === "CNY" ? "¥" : cur === "USD" ? "$" : `${cur} `;
const digits = fractionDigits ?? (Math.abs(amount) < 0.01 ? 6 : 4);
return `${sym}${amount.toFixed(digits)}`;
}
export function fmtPct(n: number | null | undefined): string {
if (n === null || n === undefined) return "—";
return `${(n * 100).toFixed(1)}%`;
+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`;
}
+15
View File
@@ -0,0 +1,15 @@
/** Pre-release with same core sorts BELOW the bare version — matches npm `latest` dist-tag semantics. */
export function compareVersions(a: string, b: string): number {
const [aCore = "0", aPre = ""] = a.split("-", 2);
const [bCore = "0", bPre = ""] = b.split("-", 2);
const aParts = aCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
const bParts = bCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
for (let i = 0; i < 3; i++) {
const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
if (diff !== 0) return diff;
}
if (!aPre && !bPre) return 0;
if (!aPre) return 1;
if (!bPre) return -1;
return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
}
+85 -18
View File
@@ -6,15 +6,17 @@ import {
ChoiceModal,
EditReviewModal,
type OnResolve,
PickerModal,
PlanModal,
RevisionModal,
ShellModal,
ViewerModal,
WorkspaceModal,
parseToolArgs,
} from "../components/chat-internals.js";
import { MODE, TOKEN, api } from "../lib/api.js";
import { appBus, showToast } from "../lib/bus.js";
import { fmtUsd } from "../lib/format.js";
import { fmtCost, fmtUsd } from "../lib/format.js";
import { html } from "../lib/html.js";
import { t, useLang } from "../i18n/index.js";
@@ -183,8 +185,58 @@ export function ChatPanel() {
};
}, []);
// rAF-coalesce assistant_delta events. A streaming turn fires ~20
// deltas/sec — committing each to React state forces a parent
// re-render per delta, which used to thrash the chat feed. Now the
// accumulated text lives in a ref and we flush at most once per
// frame, capping the streaming-bubble re-render rate at the display
// refresh rate. assistant_final cancels the pending flush.
const streamBufRef = useRef<StreamingState | null>(null);
const streamRafRef = useRef<number | null>(null);
const flushStreaming = useCallback(() => {
streamRafRef.current = null;
if (streamBufRef.current) setStreaming(streamBufRef.current);
}, []);
const cancelStreamingRaf = useCallback(() => {
if (streamRafRef.current !== null) {
cancelAnimationFrame(streamRafRef.current);
streamRafRef.current = null;
}
streamBufRef.current = null;
}, []);
// SSE reconnect drops missed deltas / finals / modals — server only
// snapshots `busy-change` on (re)connect. Pull /messages + /modal to
// recover canonical state, otherwise UI wedges on the last seen state (#521).
const refetchCanonicalState = useCallback(async () => {
try {
const data = await api<MessagesResponse>("/messages");
setMessages(data.messages ?? []);
setBusy(Boolean(data.busy));
cancelStreamingRaf();
setStreaming(null);
setActiveTool(null);
} catch {
/* keep current state — next event or next reconnect will retry */
}
try {
const m = await api<ModalEnvelope>("/modal");
setModal(m.modal ?? null);
} catch {
/* modal endpoint optional in standalone */
}
}, [cancelStreamingRaf]);
useEffect(() => {
const es = new EventSource(`/api/events?token=${TOKEN}`);
let firstOpen = true;
es.onopen = () => {
if (firstOpen) {
firstOpen = false;
return;
}
void refetchCanonicalState();
};
es.onmessage = (ev) => {
let dash;
try {
@@ -202,14 +254,20 @@ export function ChatPanel() {
return;
}
if (dash.kind === "assistant_delta") {
setStreaming((cur) => {
const text = (cur?.text ?? "") + (dash.contentDelta ?? "");
const reasoning = (cur?.reasoning ?? "") + (dash.reasoningDelta ?? "");
return { id: dash.id, text, reasoning };
});
const cur = streamBufRef.current;
const baseId = cur?.id === dash.id ? cur : null;
streamBufRef.current = {
id: dash.id,
text: (baseId?.text ?? "") + (dash.contentDelta ?? ""),
reasoning: (baseId?.reasoning ?? "") + (dash.reasoningDelta ?? ""),
};
if (streamRafRef.current === null) {
streamRafRef.current = requestAnimationFrame(flushStreaming);
}
return;
}
if (dash.kind === "assistant_final") {
cancelStreamingRaf();
setStreaming(null);
setMessages((prev) => [
...prev,
@@ -264,12 +322,16 @@ export function ChatPanel() {
};
es.onerror = () => {
// Auto-reconnect by default; surface a brief banner on persistent
// failure but don't tear down — EventSource retries in the background.
// failure but don't tear down — EventSource retries in the
// background. The next `onopen` will resync canonical state.
setError(t("chat.eventStreamError"));
setTimeout(() => setError(null), 3000);
};
return () => es.close();
}, []);
return () => {
es.close();
cancelStreamingRaf();
};
}, [refetchCanonicalState, cancelStreamingRaf]);
const send = useCallback(async () => {
const text = input.trim();
@@ -559,7 +621,7 @@ export function ChatPanel() {
<div class="chat-shell">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
<div class="chips" style="padding:0">
<span class="chip-f active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
<span class="chip-f static active">${MODE === "attached" ? t("chat.modeMirror") : t("chat.modeView")}</span>
</div>
<div class="header-pickers" style="margin-left:auto">
${
@@ -684,7 +746,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
}
@@ -701,7 +767,7 @@ export function ChatPanel() {
<${ChatMessage}
key=${m.id}
msg=${m}
streaming=${streaming && streaming.id === m.id}
streaming=${Boolean(streaming && streaming.id === m.id)}
/>
`,
)
@@ -786,11 +852,12 @@ interface SideRailProps {
function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
useLang();
if (!stats && !activePlan) return html`<aside class="chat-rail"></aside>`;
const cachePct = stats ? Math.round(stats.cacheHitRatio * 100) : 0;
const cachePct = stats ? stats.cacheHitRatio * 100 : 0;
const cacheTone = cachePct >= 80 ? "ok" : cachePct >= 50 ? "" : "warn";
const showBudget = stats != null && typeof budgetUsd === "number" && budgetUsd > 0;
const budgetPct = showBudget ? Math.min(120, (stats.totalCostUsd / budgetUsd) * 100) : 0;
const budgetTone = budgetPct >= 100 ? "err" : budgetPct >= 80 ? "warn" : "";
const walletCurrency = stats?.balance?.[0]?.currency;
return html`
<aside class="chat-rail">
${activePlan ? html`<${ActivePlanCard} plan=${activePlan} />` : null}
@@ -801,11 +868,11 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
<div class="rh">${t("chat.railSession")}</div>
<div class="rail-kv"><span class="k">${t("chat.railTurns")}</span><span class="v">${stats.turns.toLocaleString()}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railPromptTok")}</span><span class="v">${stats.lastPromptTokens.toLocaleString()}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtUsd(stats.totalCostUsd)}</span></div>
<div class="rail-kv"><span class="k">${t("chat.railCost")}</span><span class="v">${fmtCost(stats.totalCostUsd, walletCurrency)}</span></div>
<div class="progress-row" style="margin-top:8px">
<span class="lbl">${t("chat.railCacheHit")}</span>
<div class=${`progress ${cacheTone}`}><div class="progress-fill" style=${`width:${cachePct}%`}></div></div>
<span class="v">${cachePct}%</span>
<span class="v">${cachePct.toFixed(1)}%</span>
</div>
</div>
`
@@ -819,7 +886,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
<div class="progress-row">
<span class="lbl">${t("chat.railSpend")}</span>
<div class=${`progress ${budgetTone}`}><div class="progress-fill" style=${`width:${Math.min(100, budgetPct)}%`}></div></div>
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtUsd(stats.totalCostUsd)} / ${fmtUsd(budgetUsd)}</span>
<span class="v" style=${budgetTone === "err" ? "color:var(--c-err)" : budgetTone === "warn" ? "color:var(--c-warn)" : ""}>${fmtCost(stats.totalCostUsd, walletCurrency)} / ${fmtCost(budgetUsd, walletCurrency)}</span>
</div>
</div>
`
@@ -989,11 +1056,11 @@ function ChatStatusBar({ stats, model }: ChatStatusBarProps) {
</span>
<span class="status-item">
<span class="status-label">${t("chat.statusTurn")}</span>
<code>${fmtUsd(stats.lastTurnCostUsd)}</code>
<code>${fmtCost(stats.lastTurnCostUsd, balance?.currency)}</code>
</span>
<span class="status-item">
<span class="status-label">${t("chat.statusSession")}</span>
<code>${fmtUsd(stats.totalCostUsd)}</code>
<code>${fmtCost(stats.totalCostUsd, balance?.currency)}</code>
<span class="muted" style="font-size: 10px;">
${t("chat.statusTurns", { count: stats.turns, s: stats.turns === 1 ? "" : "s" })}
</span>
+17 -4
View File
@@ -68,6 +68,7 @@ export function HooksPanel() {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<string | null>(null);
const [eventFilter, setEventFilter] = useState<string>("all");
const load = useCallback(async () => {
try {
@@ -129,19 +130,31 @@ export function HooksPanel() {
data.events.length > 0
? data.events
: Array.from(new Set(matrixRows.flatMap((r) => Object.keys(r.cells))));
const visibleRows =
eventFilter === "all"
? matrixRows
: matrixRows.filter((r) => r.cells[eventFilter]?.on);
const gridCols = `220px repeat(${Math.max(events.length, 1)}, minmax(0, 1fr))`;
return html`
<div style="display:flex;flex-direction:column;gap:6px">
<div class="chips">
<span class="chip-f active">${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
${data.events.map((ev) => html`<span class="chip-f">${ev}</span>`)}
<span
class=${`chip-f ${eventFilter === "all" ? "active" : ""}`}
onClick=${() => setEventFilter("all")}
>${t("hooks.resolved")} <span class="ct">${data.resolved.length}</span></span>
${data.events.map(
(ev) => html`<span
class=${`chip-f ${eventFilter === ev ? "active" : ""}`}
onClick=${() => setEventFilter(ev)}
>${ev}</span>`,
)}
</div>
${info ? html`<div><span class="pill ok">${info}</span></div>` : null}
${error ? html`<div class="card accent-err">${error}</div>` : null}
${sectionH3(t("hooks.eventMatrix"), t("hooks.matrixSub", { scripts: matrixRows.length, s: matrixRows.length === 1 ? "" : "s", events: events.length }))}${
matrixRows.length === 0
visibleRows.length === 0
? html`<div class="card" style="color:var(--fg-3)">
${t("hooks.noHooks")}
</div>`
@@ -152,7 +165,7 @@ export function HooksPanel() {
<div>${t("hooks.colScript")}</div>
${events.map((ev) => html`<div>${ev}</div>`)}
</div>
${matrixRows.map(
${visibleRows.map(
(r) => html`
<div class="row" style=${`grid-template-columns:${gridCols}`}>
<div class="cell" title=${r.command}>
+31 -4
View File
@@ -1,6 +1,8 @@
import { fmtCompactNum, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
import { budgetTone, deriveBudgetState } from "../lib/budget.js";
import { fmtCompactNum, fmtCost, fmtNum, fmtRelativeTime, fmtUsd } from "../lib/format.js";
import { html } from "../lib/html.js";
import { usePoll } from "../lib/use-poll.js";
import { compareVersions } from "../lib/version.js";
import { t, useLang } from "../i18n/index.js";
interface CockpitKpi {
@@ -65,6 +67,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 +112,25 @@ function balanceKpi(c: CockpitData) {
return kpi(t("overview.balance"), `${symbol}${c.balance.total}`, c.balance.currency, "flat");
}
function budgetKpi(o: OverviewData) {
const state = deriveBudgetState(o.budgetUsd, o.cockpit?.currentSession?.totalCostUsd ?? null);
if (state.kind === "off") return null;
const tone = budgetTone(state);
const valueColor =
tone === "err"
? "color:var(--c-err)"
: tone === "warn"
? "color:var(--c-warn)"
: "";
return html`
<div class="kpi cock-w-1">
<div class="label">${t("overview.budget")}</div>
<div class="value" style=${valueColor}>${fmtUsd(state.spent)} / ${fmtUsd(state.cap)}</div>
<div class=${`progress ${tone}`} style="margin-top:4px"><div class="progress-fill" style=${`width:${Math.min(100, state.pct)}%`}></div></div>
</div>
`;
}
function tokens7dKpi(c: CockpitData) {
if (!c.tokens7d) return kpi(t("overview.tokens7d"), "—", t("overview.noUsageYet"), "flat");
const d = deltaPctText(c.tokens7d.deltaPct);
@@ -144,6 +168,7 @@ function currentSessionBlock(c: CockpitData) {
`;
}
const s = c.currentSession;
const currency = c.balance?.currency;
return html`
<div class="cock-list cock-w-2">
<div class="ch"><span class="ttl">${t("overview.currentSession")}</span></div>
@@ -153,7 +178,7 @@ function currentSessionBlock(c: CockpitData) {
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:8px;font-family:var(--font-mono);font-size:11px">
<div><span style="color:var(--fg-3)">${t("overview.promptTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.lastPromptTokens)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.completionTok")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtNum(s.completionTokens)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtUsd(s.totalCostUsd)}</div></div>
<div><span style="color:var(--fg-3)">${t("overview.cost")}</span><div style="color:var(--fg-0);font-size:13px;font-weight:600">${fmtCost(s.totalCostUsd, currency)}</div></div>
</div>
</div>
`;
@@ -185,7 +210,7 @@ function costTrendSpark(c: CockpitData) {
return html`
<div class="chart cock-w-2">
<div class="chart-h"><span class="title">${t("overview.costTrend")}</span></div>
<div class="chart-v">${fmtUsd(avg)}<span class="unit">${t("overview.dayAvg")}</span></div>
<div class="chart-v">${fmtCost(avg, c.balance?.currency)}<span class="unit">${t("overview.dayAvg")}</span></div>
<div class="chart-spark">
<svg viewBox=${`0 0 ${w} ${h}`} preserveAspectRatio="none">
<polyline fill="none" stroke="var(--c-brand)" stroke-width="1.5" points=${points} />
@@ -256,7 +281,8 @@ export function OverviewPanel() {
recentPlans: null,
toolActivity: null,
};
const upToDate = o.latestVersion ? o.latestVersion === o.version : null;
const upToDate =
o.latestVersion && o.version ? compareVersions(o.version, o.latestVersion) >= 0 : null;
const versionDelta =
upToDate === null ? t("overview.checking") : upToDate ? t("overview.latest") : `latest: ${o.latestVersion}`;
const versionTone: "up" | "down" | "flat" = upToDate === false ? "down" : "flat";
@@ -284,6 +310,7 @@ export function OverviewPanel() {
${tokens7dKpi(c)}
${cacheHitKpi(c)}
${toolCallsKpi(c)}
${budgetKpi(o)}
${currentSessionBlock(c)}
${costTrendSpark(c)}
+2 -2
View File
@@ -122,8 +122,8 @@ export function PermissionsPanel() {
}
<div class="chips">
<span class="chip-f active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
<span class="chip-f">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
<span class="chip-f static active">${t("permissions.project")} <span class="ct">${p.project.length}</span></span>
<span class="chip-f static">${t("permissions.builtin")} <span class="ct">${p.builtin.length}</span></span>
</div>
${
+22 -6
View File
@@ -29,7 +29,7 @@ interface PlansData {
function statusPill(p: ArchivedPlan) {
if (p.completionRatio >= 1) return html`<span class="pill ok">${t("plans.done")}</span>`;
if (p.completionRatio > 0) return html`<span class="pill info">${t("plans.active")}</span>`;
return html`<span class="pill">idle</span>`;
return html`<span class="pill">${t("plans.idle")}</span>`;
}
export function PlansPanel() {
@@ -37,6 +37,7 @@ export function PlansPanel() {
const { data, error, loading } = usePoll<PlansData>("/plans", 8000);
const [openIdx, setOpenIdx] = useState<number | null>(null);
const [filter, setFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "done">("all");
if (loading && !data)
return html`<div class="card" style="color:var(--fg-3)">${t("plans.loading")}</div>`;
@@ -48,13 +49,19 @@ export function PlansPanel() {
${t("plans.noPlans")}
</div>`;
const statusFiltered =
statusFilter === "all"
? plans
: statusFilter === "active"
? plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1)
: plans.filter((p) => p.completionRatio >= 1);
const filtered = filter.trim()
? plans.filter(
? statusFiltered.filter(
(p) =>
p.session.toLowerCase().includes(filter.toLowerCase()) ||
(p.summary ?? "").toLowerCase().includes(filter.toLowerCase()),
)
: plans;
: statusFiltered;
const open = openIdx !== null ? plans[openIdx] : null;
@@ -71,12 +78,21 @@ export function PlansPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${plans.length}</span></span>
<span class="chip-f">
<span
class=${`chip-f ${statusFilter === "all" ? "active" : ""}`}
onClick=${() => setStatusFilter("all")}
>${t("common.all")} <span class="ct">${plans.length}</span></span>
<span
class=${`chip-f ${statusFilter === "active" ? "active" : ""}`}
onClick=${() => setStatusFilter("active")}
>
${t("plans.active")}
<span class="ct">${plans.filter((p) => p.completionRatio > 0 && p.completionRatio < 1).length}</span>
</span>
<span class="chip-f">
<span
class=${`chip-f ${statusFilter === "done" ? "active" : ""}`}
onClick=${() => setStatusFilter("done")}
>
${t("plans.done")} <span class="ct">${plans.filter((p) => p.completionRatio >= 1).length}</span>
</span>
</div>
+443 -124
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,43 +274,185 @@ 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 ${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
? html`<span class="chip-f" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
: html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
? html`<span class="chip-f static" style="border-color:var(--c-ok);color:var(--c-ok)">${t("semantic.ready")}</span>`
: html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("semantic.setupNeeded")}</span>`
}
</div>
${info ? html`<div><span class="pill info">${info}</span></div>` : null}
${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,14 +1037,11 @@ 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`
<span class="chip-f">
<span class="chip-f static">
<span>${e}</span>
<span class="x" style="cursor:pointer" onClick=${() => remove(e)} title="remove">×</span>
</span>
@@ -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;
}
+1 -1
View File
@@ -68,7 +68,7 @@ export function SessionsPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
</div>
<div class="ssl-rows">
${filtered.map(
+487 -2
View File
@@ -1,6 +1,20 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { api } from "../lib/api.js";
import {
type BudgetState,
QUICK_CAPS_USD,
budgetTone,
bumpSuggestions,
deriveBudgetState,
} from "../lib/budget.js";
import { html } from "../lib/html.js";
import {
INTERVAL_PRESETS_MS,
type IntervalUnit,
type LoopRunStatus,
formatRemaining,
parseCustomInterval,
} from "../lib/loop-control.js";
import { type DashboardLang, getLang, setLang, t, useLang } from "../i18n/index.js";
interface SettingsData {
@@ -11,6 +25,364 @@ interface SettingsData {
search?: boolean;
model?: string;
editMode?: string;
proNext?: boolean;
budgetUsd?: number | null;
/** Cumulative session spend (USD); null when no session is attached. */
sessionSpendUsd?: number | null;
}
function fmtUsd2(n: number): string {
return `$${n.toFixed(n < 1 ? 4 : 2)}`;
}
interface ModelPriceEntry {
inputCacheHit: number;
inputCacheMiss: number;
output: number;
}
interface ModelCatalog {
models: string[] | null;
current: string | null;
pricing: Record<string, ModelPriceEntry>;
}
function formatPricing(p: ModelPriceEntry | undefined): string | null {
if (!p) return null;
return t("settings.modelPricingLine", {
hit: p.inputCacheHit.toFixed(3),
miss: p.inputCacheMiss.toFixed(3),
out: p.output.toFixed(3),
});
}
function ModelRow({
current,
catalog,
saving,
onPick,
}: {
current: string;
catalog: ModelCatalog | null;
saving: boolean;
onPick: (model: string) => void;
}) {
const list = catalog?.models ?? null;
const ready = list && list.length > 0;
if (!ready) {
// Fallback: catalog hasn't loaded (or API failed). Read-only — same as before D-4.
return html`<code class="mono">${current ?? "—"}</code>`;
}
// Ensure the live model is selectable even if the catalog hasn't reported it
// yet (preset overrides, custom IDs).
const options = list.includes(current) ? list : [current, ...list];
const price = catalog?.pricing[current];
return html`
<span style="display:inline-flex;flex-direction:column;gap:4px">
<select
value=${current}
onChange=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
if (next && next !== current) onPick(next);
}}
disabled=${saving}
style="font-family:var(--font-mono);min-width:200px"
>
${options.map((m) => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
${
price
? html`<span style="color:var(--fg-3);font-size:11px;font-family:var(--font-mono)">${formatPricing(price)}</span>`
: null
}
</span>
`;
}
function BudgetGauge({ state }: { state: BudgetState }) {
if (state.kind === "off") return null;
const tone = budgetTone(state);
const fill = Math.min(100, state.pct);
const valueColor =
tone === "err"
? "color:var(--c-err)"
: tone === "warn"
? "color:var(--c-warn)"
: "color:var(--fg-1)";
return html`
<div style="display:flex;flex-direction:column;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:baseline;font-size:13px">
<span style=${valueColor}>
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.spent)}</strong>
<span style="color:var(--fg-3)"> ${t("settings.budgetOf")} </span>
<strong style="font-family:var(--font-mono)">${fmtUsd2(state.cap)}</strong>
</span>
<span style=${`font-family:var(--font-mono);font-size:11px;${valueColor}`}>${state.pct.toFixed(1)}%</span>
</div>
<div class=${`progress ${tone}`}><div class="progress-fill" style=${`width:${fill}%`}></div></div>
<span style="color:var(--fg-3);font-size:11px">
${
state.kind === "exhausted"
? t("settings.budgetRefusing")
: state.kind === "warn"
? t("settings.budgetWarnLine")
: t("settings.budgetIdleLine")
}
</span>
</div>
`;
}
interface BudgetSectionProps {
state: BudgetState;
saving: boolean;
onSetCap: (usd: number) => void;
onClear: () => void;
}
function BudgetSection({ state, saving, onSetCap, onClear }: BudgetSectionProps) {
const [custom, setCustom] = useState("");
const submitCustom = () => {
const n = Number.parseFloat(custom);
if (Number.isFinite(n) && n > 0) {
onSetCap(n);
setCustom("");
}
};
const quickButtons = (caps: ReadonlyArray<number>) =>
caps.map(
(c) => html`
<button
key=${c}
class="btn"
style="font-family:var(--font-mono)"
disabled=${saving}
onClick=${() => onSetCap(c)}
>$${c}</button>
`,
);
const customField = html`
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
<span style="color:var(--fg-3);font-size:11px">${t("settings.budgetCustom")}</span>
<input
type="number"
min="0.01"
step="0.01"
value=${custom}
placeholder="0.00"
onInput=${(e: Event) => setCustom((e.target as HTMLInputElement).value)}
onKeyDown=${(e: KeyboardEvent) => {
if (e.key === "Enter") submitCustom();
}}
style="width:72px;font-family:var(--font-mono)"
disabled=${saving}
/>
<button
class="btn primary"
disabled=${saving || !(Number.parseFloat(custom) > 0)}
onClick=${submitCustom}
>→</button>
</span>
`;
return html`
<div class="card" style="display:flex;flex-direction:column;gap:12px">
<${BudgetGauge} state=${state} />
${
state.kind === "off"
? html`
<div>
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetSetCap")}</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
${quickButtons(QUICK_CAPS_USD)}
${customField}
</div>
</div>
`
: state.kind === "warn" || state.kind === "exhausted"
? html`
<div>
<div style="color:var(--fg-3);font-size:11px;margin-bottom:6px">${t("settings.budgetBumpHint")}</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
${bumpSuggestions(state.cap).map(
(next) => html`
<button
key=${next}
class="btn primary"
style="font-family:var(--font-mono)"
disabled=${saving}
onClick=${() => onSetCap(next)}
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
`,
)}
${customField}
</div>
<div style="margin-top:8px">
<button class="btn" disabled=${saving} onClick=${onClear}>${t("settings.budgetClear")}</button>
</div>
</div>
`
: html`
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
${bumpSuggestions(state.cap).map(
(next) => html`
<button
key=${next}
class="btn"
style="font-family:var(--font-mono)"
disabled=${saving}
onClick=${() => onSetCap(next)}
>→ $${next % 1 === 0 ? next : next.toFixed(2)}</button>
`,
)}
${customField}
<button
class="btn"
style="margin-left:8px"
disabled=${saving}
onClick=${onClear}
>${t("settings.budgetClear")}</button>
</div>
`
}
</div>
`;
}
interface LoopSectionProps {
status: LoopRunStatus | null;
/** ms remaining until next fire — ticks down client-side between status polls. */
remainingMs: number;
/** Last-turn cost in USD; used as a hint for "each iteration costs ~". */
avgIterCostUsd: number | null;
busy: boolean;
onStart: (intervalMs: number, prompt: string) => void;
onStop: () => void;
}
function LoopSection({
status,
remainingMs,
avgIterCostUsd,
busy,
onStart,
onStop,
}: LoopSectionProps) {
const [intervalMs, setIntervalMs] = useState<number>(INTERVAL_PRESETS_MS[1]!.ms);
const [prompt, setPrompt] = useState("");
const [customValue, setCustomValue] = useState("");
const [customUnit, setCustomUnit] = useState<IntervalUnit>("m");
if (status) {
return html`
<div class="card" style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;justify-content:space-between;align-items:baseline">
<span style="color:var(--c-warn);font-family:var(--font-mono);font-size:11px">⟳ ${t("settings.loopRunning")}</span>
<span style="color:var(--fg-3);font-size:11px">
${t("settings.loopIter", { iter: status.iter })} · ${t("settings.loopFiresIn", { remaining: formatRemaining(remainingMs) })}
</span>
</div>
<div style="background:var(--bg-elev-2);border:1px solid var(--bd);border-radius:var(--r);padding:8px 10px;font-family:var(--font-mono);font-size:12px;color:var(--fg-1);white-space:pre-wrap;max-height:120px;overflow-y:auto">${status.prompt}</div>
<div>
<button class="btn danger" disabled=${busy} onClick=${onStop}>${t("settings.loopStop")}</button>
</div>
</div>
`;
}
const customMs = parseCustomInterval(customValue, customUnit);
const canStart = !busy && intervalMs > 0 && prompt.trim().length > 0;
return html`
<div class="card" style="display:flex;flex-direction:column;gap:10px">
<div style="color:var(--fg-3);font-size:11px">
${t("settings.loopIdleHint")}
${
typeof avgIterCostUsd === "number" && avgIterCostUsd > 0
? html` ${t("settings.loopCostHint", { cost: `$${avgIterCostUsd.toFixed(4)}` })}`
: null
}
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopInterval")}</span>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
${INTERVAL_PRESETS_MS.map(
(p) => html`
<button
key=${p.ms}
class=${`btn ${intervalMs === p.ms && customValue === "" ? "primary" : ""}`}
style="font-family:var(--font-mono)"
disabled=${busy}
onClick=${() => {
setIntervalMs(p.ms);
setCustomValue("");
}}
>${p.label}</button>
`,
)}
<span style="display:inline-flex;align-items:center;gap:4px;margin-left:auto">
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopCustom")}</span>
<input
type="number"
min="1"
step="1"
value=${customValue}
onInput=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
setCustomValue(raw);
const ms = parseCustomInterval(raw, customUnit);
if (ms !== null) setIntervalMs(ms);
}}
style="width:64px;font-family:var(--font-mono)"
disabled=${busy}
/>
<select
value=${customUnit}
onChange=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value as IntervalUnit;
setCustomUnit(next);
if (customValue) {
const ms = parseCustomInterval(customValue, next);
if (ms !== null) setIntervalMs(ms);
}
}}
disabled=${busy}
>
<option value="s">s</option>
<option value="m">m</option>
<option value="h">h</option>
</select>
</span>
</div>
${
customValue && customMs === null
? html`<span style="color:var(--c-err);font-size:11px">${t("settings.loopRangeError")}</span>`
: null
}
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<span style="color:var(--fg-3);font-size:11px">${t("settings.loopPrompt")}</span>
<textarea
rows="3"
placeholder=${t("settings.loopPromptPlaceholder")}
value=${prompt}
onInput=${(e: Event) => setPrompt((e.target as HTMLTextAreaElement).value)}
style="width:100%;font-family:var(--font-mono);resize:vertical"
disabled=${busy}
></textarea>
</div>
<div>
<button
class="btn primary"
disabled=${!canStart}
onClick=${() => onStart(intervalMs, prompt.trim())}
>${t("settings.loopStart")}</button>
</div>
</div>
`;
}
export function SettingsPanel() {
@@ -20,6 +392,13 @@ export function SettingsPanel() {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState<string | null>(null);
const [draft, setDraft] = useState<Partial<SettingsData>>({});
const [catalog, setCatalog] = useState<ModelCatalog | null>(null);
const [loopStatus, setLoopStatus] = useState<LoopRunStatus | null>(null);
const [loopAvgCost, setLoopAvgCost] = useState<number | null>(null);
const [loopBusy, setLoopBusy] = useState(false);
/** Wall-clock time of the last status sync — used to interpolate the countdown. */
const lastStatusSyncRef = useRef<number>(0);
const [now, setNow] = useState<number>(() => Date.now());
const load = useCallback(async () => {
try {
@@ -33,6 +412,73 @@ export function SettingsPanel() {
useEffect(() => {
load();
}, [load]);
useEffect(() => {
api<ModelCatalog>("/models")
.then(setCatalog)
.catch(() => undefined);
}, []);
const refreshLoop = useCallback(async () => {
try {
const r = await api<{ status: LoopRunStatus | null }>("/loop/status");
setLoopStatus(r.status);
lastStatusSyncRef.current = Date.now();
} catch {
/* ignore — status is best-effort */
}
try {
const r = await api<{ stats?: { lastTurnCostUsd?: number } }>("/overview");
setLoopAvgCost(r.stats?.lastTurnCostUsd ?? null);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
let cancelled = false;
refreshLoop();
const id = setInterval(() => {
if (!cancelled) refreshLoop();
}, 5_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [refreshLoop]);
useEffect(() => {
if (!loopStatus) return;
const id = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(id);
}, [loopStatus]);
const remainingMs = loopStatus
? Math.max(0, loopStatus.nextFireMs - (now - lastStatusSyncRef.current))
: 0;
const startLoop = useCallback(
async (intervalMs: number, prompt: string) => {
setLoopBusy(true);
try {
await api("/loop/start", { method: "POST", body: { intervalMs, prompt } });
await refreshLoop();
} catch (err) {
setError((err as Error).message);
} finally {
setLoopBusy(false);
}
},
[refreshLoop],
);
const stopLoop = useCallback(async () => {
setLoopBusy(true);
try {
await api("/loop/stop", { method: "POST" });
await refreshLoop();
} catch (err) {
setError((err as Error).message);
} finally {
setLoopBusy(false);
}
}, [refreshLoop]);
const save = useCallback(
async (fields: Partial<SettingsData>) => {
@@ -189,11 +635,50 @@ export function SettingsPanel() {
)}
</div>
${sectionH3(t("settings.sectionCompute"))}
<div class="card">
${fieldRow(
t("settings.proNext"),
html`
<button
class=${`btn ${v.proNext ? "primary" : ""}`}
onClick=${() => save({ proNext: !v.proNext })}
disabled=${saving}
>${v.proNext ? t("settings.proArmed") : t("settings.proArm")}</button>
`,
t("settings.proNextNote"),
)}
</div>
${sectionH3(t("settings.sectionBudget"))}
<${BudgetSection}
state=${deriveBudgetState(v.budgetUsd, v.sessionSpendUsd)}
saving=${saving}
onSetCap=${(usd: number) => save({ budgetUsd: usd })}
onClear=${() => save({ budgetUsd: null })}
/>
${sectionH3(t("settings.sectionLoop"))}
<${LoopSection}
status=${loopStatus}
remainingMs=${remainingMs}
avgIterCostUsd=${loopAvgCost}
busy=${loopBusy}
onStart=${startLoop}
onStop=${stopLoop}
/>
${sectionH3(t("settings.sectionRuntime"))}
<div class="card">
${fieldRow(
t("settings.activeModel"),
html`<code class="mono">${v.model ?? "—"}</code>`,
html`<${ModelRow}
current=${v.model ?? "—"}
catalog=${catalog}
saving=${saving}
onPick=${(m: string) => save({ model: m })}
/>`,
t("settings.appliesNextTurn"),
)}
${fieldRow(
t("settings.editMode"),
+21 -6
View File
@@ -29,6 +29,7 @@ export function SkillsPanel() {
const [newName, setNewName] = useState("");
const [newScope, setNewScope] = useState<"global" | "project">("global");
const [filter, setFilter] = useState("");
const [scopeFilter, setScopeFilter] = useState<"all" | Scope>("all");
const load = useCallback(async () => {
try {
@@ -120,13 +121,15 @@ export function SkillsPanel() {
...data.global.map((s) => ({ scope: "global" as Scope, ...s })),
...data.builtin.map((s) => ({ scope: "builtin" as Scope, ...s })),
];
const scopeFiltered =
scopeFilter === "all" ? allWith : allWith.filter((s) => s.scope === scopeFilter);
const filtered = filter.trim()
? allWith.filter(
? scopeFiltered.filter(
(s) =>
s.name.toLowerCase().includes(filter.toLowerCase()) ||
(s.description ?? "").toLowerCase().includes(filter.toLowerCase()),
)
: allWith;
: scopeFiltered;
return html`
<div class="sessions-grid">
@@ -141,10 +144,22 @@ export function SkillsPanel() {
/>
</div>
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f active">${t("common.all")} <span class="ct">${allWith.length}</span></span>
<span class="chip-f">${t("skills.project")} <span class="ct">${data.project.length}</span></span>
<span class="chip-f">${t("skills.global")} <span class="ct">${data.global.length}</span></span>
<span class="chip-f">${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "all" ? "active" : ""}`}
onClick=${() => setScopeFilter("all")}
>${t("common.all")} <span class="ct">${allWith.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "project" ? "active" : ""}`}
onClick=${() => setScopeFilter("project")}
>${t("skills.project")} <span class="ct">${data.project.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "global" ? "active" : ""}`}
onClick=${() => setScopeFilter("global")}
>${t("skills.global")} <span class="ct">${data.global.length}</span></span>
<span
class=${`chip-f ${scopeFilter === "builtin" ? "active" : ""}`}
onClick=${() => setScopeFilter("builtin")}
>${t("skills.builtin")} <span class="ct">${data.builtin.length}</span></span>
</div>
<div style="padding:0 12px 8px;display:flex;gap:6px;flex-wrap:wrap">
+2 -1
View File
@@ -1,6 +1,7 @@
import { fmtBytes, fmtNum } from "../lib/format.js";
import { html } from "../lib/html.js";
import { usePoll } from "../lib/use-poll.js";
import { compareVersions } from "../lib/version.js";
import { t, useLang } from "../i18n/index.js";
interface HealthData {
@@ -22,7 +23,7 @@ export function SystemPanel() {
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "health", error: error.message })}</div>`;
if (!data) return null;
const h = data;
const upToDate = h.latestVersion ? h.latestVersion === h.version : null;
const upToDate = h.latestVersion ? compareVersions(h.version, h.latestVersion) >= 0 : null;
return html`
<div style="display:flex;flex-direction:column;gap:14px">
+2 -2
View File
@@ -37,8 +37,8 @@ export function ToolsPanel() {
return html`
<div style="display:flex;flex-direction:column;gap:14px">
<div class="chips">
<span class="chip-f active">${t("common.all")} <span class="ct">${d.total}</span></span>
${d.planMode ? html`<span class="chip-f" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
<span class="chip-f static active">${t("common.all")} <span class="ct">${d.total}</span></span>
${d.planMode ? html`<span class="chip-f static" style="border-color:var(--c-warn);color:var(--c-warn)">${t("tools.planMode")}</span>` : null}
</div>
${
+21 -15
View File
@@ -36,6 +36,7 @@ interface UsageDay {
function UsageChart({ days }: { days: UsageDay[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<UPlotInstance | null>(null);
useLang();
useEffect(() => {
let cancelled = false;
@@ -64,7 +65,7 @@ function UsageChart({ days }: { days: UsageDay[] }) {
{ stroke: "#94a3b8", grid: { stroke: "rgba(148, 163, 184, 0.08)" } },
{
scale: "y",
label: "USD",
label: t("usage.axisUsd"),
stroke: "#94a3b8",
grid: { stroke: "rgba(148, 163, 184, 0.08)" },
values: (_u: unknown, v: number[]) => v.map((n) => `$${n.toFixed(4)}`),
@@ -72,17 +73,22 @@ function UsageChart({ days }: { days: UsageDay[] }) {
{
scale: "turns",
side: 1,
label: "turns",
label: t("usage.axisTurns"),
stroke: "#94a3b8",
grid: { show: false },
},
],
series: [
{},
{ label: "cost", stroke: "#67e8f9", width: 2, fill: "rgba(103, 232, 249, 0.10)" },
{ label: "cache saved", stroke: "#5eead4", width: 2, dash: [4, 4] },
{ label: t("usage.axisTime") },
{
label: "turns",
label: t("usage.seriesCost"),
stroke: "#67e8f9",
width: 2,
fill: "rgba(103, 232, 249, 0.10)",
},
{ label: t("usage.seriesCacheSaved"), stroke: "#5eead4", width: 2, dash: [4, 4] },
{
label: t("usage.seriesTurns"),
stroke: "#c4b5fd",
scale: "turns",
width: 1.5,
@@ -174,8 +180,8 @@ export function UsagePanel() {
return html`
<div style="display:flex;flex-direction:column;gap:6px">
<div class="chips">
<span class="chip-f active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
<span class="chip-f">${u.logSize}</span>
<span class="chip-f static active">${t("usage.records", { count: u.recordCount.toLocaleString() })}</span>
<span class="chip-f static">${u.logSize}</span>
</div>
${
@@ -203,13 +209,13 @@ export function UsagePanel() {
<table class="tbl">
<thead>
<tr>
<th></th>
<th>${t("usage.colTurns")}</th>
<th>${t("usage.colCacheHit")}</th>
<th>${t("usage.colCost")}</th>
<th>${t("usage.colCacheSaved")}</th>
<th>${t("usage.colVsClaude")}</th>
<th>${t("usage.colSaved")}</th>
<th>${t("usage.colWindow")}</th>
<th class="num">${t("usage.colTurns")}</th>
<th class="num">${t("usage.colCacheHit")}</th>
<th class="num">${t("usage.colCost")}</th>
<th class="num">${t("usage.colCacheSaved")}</th>
<th class="num">${t("usage.colVsClaude")}</th>
<th class="num">${t("usage.colSaved")}</th>
</tr>
</thead>
<tbody>
+37 -38
View File
@@ -43,27 +43,30 @@ timestamps each turn — cache hit rate in practice: <20%.
**Metric.** `prompt_cache_hit_tokens / (hit + miss)` exposed per-turn and
aggregated per-session. Visible in the TUI's top-bar cache cell.
### Pillar 2 — R1 Thought Harvesting *(opt-in)*
#### Parallel tool dispatch
**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.
Each tool declares `parallelSafe?: boolean` (default `false`). The loop
dispatcher groups consecutive parallel-safe calls into chunks and races
them via `Promise.allSettled`; the first non-parallel-safe call ends the
chunk and runs alone (serial barrier — read-after-write order
preserved). Tool-result yields and history append still land in declared
order regardless of which call settles first, so the model sees the
same shape it would under a fully serial dispatch.
**Solution.** A two-stage process:
| Env var | Default | Effect |
|---|---|---|
| `REASONIX_PARALLEL_MAX` | `3` (hard cap `16`) | Max chunk size. |
| `REASONIX_TOOL_DISPATCH=serial` | unset | Forces serial dispatch — escape hatch. |
```
R1 output → Harvester (v4-flash, thinking off) → TypedPlanState
├─ subgoals: string[]
├─ hypotheses: string[]
├─ uncertainties: string[]
└─ rejectedPaths: string[]
```
Built-in opt-ins: read-only filesystem (`read_file`, `list_directory`,
`directory_tree`, `search_files`, `search_content`, `get_file_info`),
web (`web_search`, `web_fetch`), `recall_memory`, `semantic_search`,
isolated child loops (`run_skill`, `spawn_subagent`), in-memory job
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.
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.
@@ -83,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
@@ -95,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
@@ -153,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
@@ -172,7 +170,7 @@ src/
│ ├── skills.ts # list + invoke SKILL.md playbooks
│ ├── subagent.ts # spawn_subagent — flash+high by default
│ ├── plan.ts # submit_plan (review gate)
│ └── web.ts # web_search, web_fetch
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek or SearXNG)
├── mcp/ # MCP client + bridge (stdio + SSE)
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
├── project-memory.ts # REASONIX.md loader
@@ -220,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
+687
View File
@@ -0,0 +1,687 @@
<!doctype html>
<html lang="en" data-lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Configuration Guide — Reasonix · MCP, Skills, Memory, Hooks</title>
<meta
name="description"
content="Bilingual configuration guide for Reasonix — MCP servers, skills, memory, hooks, permissions, web search, and the full ~/.reasonix/config.json reference."
/>
<meta
name="keywords"
content="Reasonix configuration, MCP setup, Model Context Protocol config, Claude Code skills, AI agent memory, lifecycle hooks, DeepSeek CLI, reasonix.config.json"
/>
<meta name="author" content="esengine" />
<meta name="theme-color" content="#0b0f17" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<link
rel="canonical"
href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html"
/>
<link
rel="alternate"
hreflang="en"
href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=en"
/>
<link
rel="alternate"
hreflang="zh-CN"
href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh"
/>
<link
rel="alternate"
hreflang="x-default"
href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html"
/>
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Reasonix" />
<meta property="og:title" content="Reasonix Configuration Guide" />
<meta
property="og:description"
content="MCP, skills, memory, hooks, permissions — every key, every slash command, the on-disk shape."
/>
<meta
property="og:url"
content="https://esengine.github.io/DeepSeek-Reasonix/configuration.html"
/>
<meta
property="og:image"
content="https://raw.githubusercontent.com/esengine/reasonix/main/docs/assets/hero-terminal.svg"
/>
<meta property="og:locale" content="en_US" />
<meta property="og:locale:alternate" content="zh_CN" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Reasonix Configuration Guide" />
<meta
name="twitter:description"
content="MCP, skills, memory, hooks, permissions — every key, every slash command."
/>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="guide.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Reasonix Configuration Guide",
"description": "MCP servers, skills, memory, hooks, permissions, web search, and the full ~/.reasonix/config.json reference.",
"url": "https://esengine.github.io/DeepSeek-Reasonix/configuration.html",
"inLanguage": ["en", "zh-CN"],
"author": {
"@type": "Organization",
"name": "esengine",
"url": "https://github.com/esengine"
},
"isPartOf": {
"@type": "WebSite",
"name": "Reasonix",
"url": "https://esengine.github.io/DeepSeek-Reasonix/"
},
"about": {
"@type": "SoftwareApplication",
"name": "Reasonix"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Reasonix",
"item": "https://esengine.github.io/DeepSeek-Reasonix/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Configuration Guide",
"item": "https://esengine.github.io/DeepSeek-Reasonix/configuration.html"
}
]
}
</script>
</head>
<body>
<div class="bg-grid" aria-hidden="true"></div>
<div class="bg-glow" aria-hidden="true"></div>
<div class="bg-horizon" aria-hidden="true"></div>
<header class="nav">
<a class="nav-brand" href="index.html" aria-label="Reasonix">
<span class="brand-mark" aria-hidden="true">
<span class="diamond"></span>
<span class="diamond inner"></span>
</span>
<span class="brand-name">Reasonix</span>
</a>
<nav class="nav-links">
<a href="index.html#why" data-i18n="nav.why">Why</a>
<a href="index.html#features" data-i18n="nav.features">Features</a>
<a href="index.html#quickstart" data-i18n="nav.quickstart">Quick start</a>
<a href="configuration.html" data-i18n="nav.guide" class="active">Guide</a>
<a
href="https://github.com/esengine/reasonix"
target="_blank"
rel="noopener"
data-i18n="nav.github"
>GitHub</a
>
</nav>
<div class="nav-actions">
<div class="lang-switch" role="group" aria-label="Language">
<button data-lang-btn="en" type="button" aria-pressed="true">EN</button>
<button data-lang-btn="zh" type="button" aria-pressed="false">中文</button>
</div>
</div>
</header>
<main class="guide-main" id="top">
<section class="guide-hero">
<div class="container">
<div class="badge" data-i18n="guide.badge">Configuration · MCP · Skills · Memory</div>
<h1 class="guide-title">
<span class="grad-text" data-i18n="guide.title.line1">Configure Reasonix</span>
<br />
<span data-i18n="guide.title.line2">in five minutes</span>
</h1>
<p class="guide-sub" data-i18n="guide.sub">
One JSON file at <code>~/.reasonix/config.json</code> + per-project overrides
under <code>.reasonix/</code>. This page documents every key, every slash
command, and the on-disk shape of skills, memory, and hooks.
</p>
</div>
</section>
<div class="guide-shell container">
<aside class="guide-toc" aria-label="On this page">
<h4 data-i18n="guide.toc.title">On this page</h4>
<ul>
<li><a href="#config-json" data-i18n="guide.toc.config">config.json</a></li>
<li><a href="#mcp" data-i18n="guide.toc.mcp">MCP servers</a></li>
<li><a href="#skills" data-i18n="guide.toc.skills">Skills</a></li>
<li><a href="#memory" data-i18n="guide.toc.memory">Memory</a></li>
<li><a href="#hooks" data-i18n="guide.toc.hooks">Hooks</a></li>
<li><a href="#permissions" data-i18n="guide.toc.perms">Permissions</a></li>
<li><a href="#search" data-i18n="guide.toc.search">Web search</a></li>
<li><a href="#index" data-i18n="guide.toc.index">Semantic index</a></li>
</ul>
</aside>
<article class="guide-body">
<section id="config-json">
<h2 data-i18n="cfg.title">The config.json file</h2>
<p data-i18n="cfg.body1">
Reasonix reads a single global config from <code>~/.reasonix/config.json</code>
(Windows: <code>%USERPROFILE%\.reasonix\config.json</code>). The file is created
automatically on first run; you can hand-edit it any time. The CLI flag
<code>--no-config</code> bypasses it, useful in CI.
</p>
<p data-i18n="cfg.body2">
Per-project overrides live under <code>&lt;project&gt;/.reasonix/</code> —
skills, memory, settings.json (hooks). Project scope wins over global on name
collision.
</p>
<h3 data-i18n="cfg.shape">Top-level keys</h3>
<pre class="code"><code>{
"apiKey": "sk-...",
"baseUrl": "https://api.deepseek.com",
"lang": "en", <span class="hash"># <span data-i18n="cfg.k.lang">UI language: en | zh</span></span>
"preset": "auto", <span class="hash"># <span data-i18n="cfg.k.preset">auto | flash | pro</span></span>
"editMode": "review", <span class="hash"># <span data-i18n="cfg.k.editmode">review | auto | yolo</span></span>
"reasoningEffort": "high", <span class="hash"># <span data-i18n="cfg.k.effort">high | max</span></span>
"theme": "auto", <span class="hash"># <span data-i18n="cfg.k.theme">light | dark | auto</span></span>
"search": false, <span class="hash"># <span data-i18n="cfg.k.search">enable web_search/web_fetch tools</span></span>
"webSearchEngine": "mojeek", <span class="hash"># <span data-i18n="cfg.k.engine">mojeek | searxng</span></span>
"webSearchEndpoint": "http://localhost:8080",
"mcp": [], <span class="hash"># <span data-i18n="cfg.k.mcp">MCP server specs</span></span>
"mcpDisabled": [], <span class="hash"># <span data-i18n="cfg.k.mcpoff">names skipped at startup</span></span>
"projects": { <span class="hash"># <span data-i18n="cfg.k.projects">per-workspace overrides</span></span>
"/abs/path": {
"shellAllowed": ["npm", "git status"]
}
},
"semantic": { ... }, <span class="hash"># <span data-i18n="cfg.k.semantic">embedding provider for `reasonix index`</span></span>
"session": null
}</code></pre>
<div class="callout">
<div class="callout-tag" data-i18n="cfg.callout.tag">Trust dial</div>
<p data-i18n="cfg.callout.body">
<code>editMode</code> is the single trust dial for an entire session.
<code>review</code> queues edits + gates shell. <code>auto</code> applies
edits + still gates shell. <code>yolo</code> skips both gates — only use
inside a sandbox.
</p>
</div>
</section>
<section id="mcp">
<h2 data-i18n="mcp.title">MCP servers</h2>
<p data-i18n="mcp.body1">
Reasonix speaks the Model Context Protocol natively. Every entry in
<code>config.mcp</code> is a single string — the same format the
<code>--mcp</code> CLI flag accepts — so one parser handles both. Three
transports are supported.
</p>
<h3 data-i18n="mcp.h.stdio">Stdio (subprocess)</h3>
<pre class="code"><code>{
"mcp": [
"fs=npx -y @modelcontextprotocol/server-filesystem /tmp",
"git=uvx mcp-server-git --repository ."
]
}</code></pre>
<p data-i18n="mcp.body.stdio">
Format: <code>name=command arg1 arg2</code>. The <code>name=</code> prefix
namespaces every tool the server exposes. Args use shell-style splitting;
quote any with spaces.
</p>
<h3 data-i18n="mcp.h.sse">SSE (HTTP)</h3>
<pre class="code"><code>{
"mcp": [
"remote=https://example.com/mcp/sse",
"https://other.example.com/mcp"
]
}</code></pre>
<p data-i18n="mcp.body.sse">
Plain <code>http://</code> / <code>https://</code> URLs use HTTP+SSE for
back-compat. Anonymous (no <code>name=</code>) entries work but can't be
toggled by name later.
</p>
<h3 data-i18n="mcp.h.streamable">Streamable HTTP (2025-03 spec)</h3>
<pre class="code"><code>{
"mcp": [
"edge=streamable+https://edge.example.com/mcp"
]
}</code></pre>
<p data-i18n="mcp.body.streamable">
Opt in with the <code>streamable+</code> URL prefix.
</p>
<h3 data-i18n="mcp.h.cli">CLI flags &amp; slash commands</h3>
<pre class="code"><code>npx reasonix code --mcp "fs=npx -y @mcp/server-filesystem /tmp"
npx reasonix mcp inspect "git=uvx mcp-server-git"
npx reasonix mcp list</code></pre>
<table class="cmd-table">
<thead>
<tr>
<th data-i18n="th.cmd">Command</th>
<th data-i18n="th.what">What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/mcp</code></td>
<td data-i18n="mcp.cmd.hub">Open the interactive MCP hub.</td>
</tr>
<tr>
<td><code>/mcp disable &lt;name&gt;</code></td>
<td data-i18n="mcp.cmd.disable">
Persist to <code>mcpDisabled</code>; effective on next launch.
</td>
</tr>
<tr>
<td><code>/mcp enable &lt;name&gt;</code></td>
<td data-i18n="mcp.cmd.enable">Re-enable a disabled server.</td>
</tr>
<tr>
<td><code>/mcp reconnect &lt;name&gt;</code></td>
<td data-i18n="mcp.cmd.recon">
Reconnect a live server and pick up newly-registered tools.
</td>
</tr>
</tbody>
</table>
</section>
<section id="skills">
<h2 data-i18n="sk.title">Skills</h2>
<p data-i18n="sk.body1">
A skill is a markdown playbook the model can invoke (<code>/skill &lt;name&gt;</code>).
Names + descriptions are pinned in the prompt; bodies load on demand. Project
skills override global ones with the same name.
</p>
<h3 data-i18n="sk.h.layout">Layout</h3>
<pre class="code"><code>~/.reasonix/skills/<span class="hash"> # global</span>
audit-logs.md
refactor-react/
SKILL.md
&lt;project&gt;/.reasonix/skills/<span class="hash"> # project (overrides global)</span>
release-notes.md</code></pre>
<p data-i18n="sk.body.layout">
Two equivalent shapes: a flat <code>&lt;name&gt;.md</code>, or a
<code>&lt;name&gt;/SKILL.md</code> folder when you want to colocate
attachments.
</p>
<h3 data-i18n="sk.h.fm">Frontmatter</h3>
<pre class="code"><code>---
name: audit-logs
description: <span data-i18n="sk.fm.desc">Review git log for security red flags.</span>
runAs: inline <span class="hash"># <span data-i18n="sk.fm.runas">inline | subagent</span></span>
allowed-tools: bash,read <span class="hash"># <span data-i18n="sk.fm.tools">subagent tool allowlist</span></span>
model: deepseek-chat <span class="hash"># <span data-i18n="sk.fm.model">subagent model override</span></span>
---
## <span data-i18n="sk.body.task">Task</span>
1. <span data-i18n="sk.body.s1">Fetch the last 20 commits.</span>
2. <span data-i18n="sk.body.s2">Flag commits whose message mentions password / secret / token.</span>
3. <span data-i18n="sk.body.s3">Report findings.</span></code></pre>
<ul class="kv-list">
<li>
<code>name</code>
<span data-i18n="sk.fm.f.name">1–64 chars: alnum, <code>_</code>, <code>-</code>, interior <code>.</code>. Defaults to filename stem.</span>
</li>
<li>
<code>description</code>
<span data-i18n="sk.fm.f.desc">One line. Shown in <code>/skill list</code>.</span>
</li>
<li>
<code>runAs</code>
<span data-i18n="sk.fm.f.runas">
<code>inline</code> (default): body enters parent log. <code>subagent</code>:
isolated child loop, only the final answer returns.
</span>
</li>
<li>
<code>allowed-tools</code>
<span data-i18n="sk.fm.f.tools">
Comma-separated literal tool names. Subagent only — scopes the child's tool registry.
</span>
</li>
<li>
<code>model</code>
<span data-i18n="sk.fm.f.model">
Subagent only. Must start with <code>deepseek-</code>; ignored otherwise.
</span>
</li>
</ul>
<h3 data-i18n="sk.h.cmds">Slash commands</h3>
<table class="cmd-table">
<tbody>
<tr>
<td><code>/skill list</code></td>
<td data-i18n="sk.cmd.list">List every skill, scope-tagged.</td>
</tr>
<tr>
<td><code>/skill new &lt;name&gt;</code></td>
<td data-i18n="sk.cmd.new">
Scaffold a stub at project scope. Add <code>--global</code> for
<code>~/.reasonix/skills</code>.
</td>
</tr>
<tr>
<td><code>/skill show &lt;name&gt;</code></td>
<td data-i18n="sk.cmd.show">Print the full body.</td>
</tr>
<tr>
<td><code>/skill &lt;name&gt; [args]</code></td>
<td data-i18n="sk.cmd.run">
Run it. Args are appended to the body as a single string.
</td>
</tr>
</tbody>
</table>
</section>
<section id="memory">
<h2 data-i18n="mem.title">Memory</h2>
<p data-i18n="mem.body1">
Memory is user-private knowledge pinned into the immutable prefix — so the
agent reads it on every turn without re-priming. Two scopes: <em>global</em>
(cross-project facts about you) and <em>project</em> (per-workspace context).
Distinct from a committable <code>REASONIX.md</code>, which lives in the repo.
</p>
<h3 data-i18n="mem.h.layout">Layout</h3>
<pre class="code"><code>~/.reasonix/memory/
global/
MEMORY.md <span class="hash"># <span data-i18n="mem.idx">index — pinned into the prefix</span></span>
user_role.md
feedback_terse_comments.md
&lt;project-hash&gt;/ <span class="hash"># <span data-i18n="mem.proj">sha1(absRoot)[0..16]</span></span>
MEMORY.md
project_release_freeze.md</code></pre>
<h3 data-i18n="mem.h.entry">Entry shape</h3>
<pre class="code"><code>---
name: user_role
description: <span data-i18n="mem.f.desc">User is a senior backend engineer; new to React.</span>
type: user <span class="hash"># <span data-i18n="mem.f.type">user | feedback | project | reference</span></span>
scope: global
created: 2026-05-09
---
<span data-i18n="mem.f.body">Body — the actual remembered fact, in plain markdown.</span></code></pre>
<p data-i18n="mem.body.types">
<strong>Types:</strong> <code>user</code> (who they are),
<code>feedback</code> (corrections / preferences),
<code>project</code> (initiative / deadline / motivation),
<code>reference</code> (where to look in external systems).
</p>
<h3 data-i18n="mem.h.cmds">Slash commands</h3>
<table class="cmd-table">
<tbody>
<tr>
<td><code>/memory list</code></td>
<td data-i18n="mem.cmd.list">List all entries, both scopes.</td>
</tr>
<tr>
<td><code>/memory show &lt;name&gt;</code></td>
<td data-i18n="mem.cmd.show">Display body. Scope is auto-resolved.</td>
</tr>
<tr>
<td><code>/memory forget &lt;name&gt;</code></td>
<td data-i18n="mem.cmd.forget">Delete one entry.</td>
</tr>
<tr>
<td><code>/memory clear &lt;scope&gt; confirm</code></td>
<td data-i18n="mem.cmd.clear">
Wipe an entire scope. <code>confirm</code> is mandatory.
</td>
</tr>
</tbody>
</table>
<p data-i18n="mem.body.write">
<strong>Writing memories:</strong> say it in chat ("remember I prefer
Vitest over Jest"). The model invokes the <code>scaffold_memory</code> tool,
which proposes a file and waits for your <code>/apply</code>.
</p>
</section>
<section id="hooks">
<h2 data-i18n="hk.title">Hooks</h2>
<p data-i18n="hk.body1">
Hooks are shell commands the harness fires on lifecycle events. Configured
in <code>settings.json</code>, not <code>config.json</code>. Project scope
first, then global.
</p>
<h3 data-i18n="hk.h.where">Where to put them</h3>
<pre class="code"><code>&lt;project&gt;/.reasonix/settings.json <span class="hash"># <span data-i18n="hk.path.proj">project scope</span></span>
~/.reasonix/settings.json <span class="hash"># <span data-i18n="hk.path.glob">global scope</span></span></code></pre>
<h3 data-i18n="hk.h.shape">Shape</h3>
<pre class="code"><code>{
"hooks": {
"PreToolUse": [
{
"command": "node scripts/audit.js",
"match": "^(write|edit_file|bash)$",
"description": "<span data-i18n="hk.ex.audit">Audit risky tool calls before they run</span>",
"timeout": 5000
}
],
"PostToolUse": [
{ "command": "echo done >> /tmp/reasonix.log" }
],
"UserPromptSubmit": [],
"Stop": []
}
}</code></pre>
<h3 data-i18n="hk.h.events">Events</h3>
<ul class="kv-list">
<li>
<code>PreToolUse</code>
<span data-i18n="hk.ev.pre">
Before a tool runs. <strong>Gating:</strong> exit 2 blocks; exit 0
passes. 5 s default timeout.
</span>
</li>
<li>
<code>PostToolUse</code>
<span data-i18n="hk.ev.post">
After a tool runs. Non-gating; warn-only on non-zero. 30 s default.
</span>
</li>
<li>
<code>UserPromptSubmit</code>
<span data-i18n="hk.ev.usr">
Before user input is processed. <strong>Gating</strong> (exit 2 blocks
the message).
</span>
</li>
<li>
<code>Stop</code>
<span data-i18n="hk.ev.stop">On <code>/quit</code> or session exit. Non-gating.</span>
</li>
</ul>
<h3 data-i18n="hk.h.payload">Stdin payload</h3>
<p data-i18n="hk.body.payload">
Each hook receives a JSON object on stdin describing the event:
</p>
<pre class="code"><code>{
"event": "PreToolUse",
"cwd": "/workspace",
"toolName": "bash",
"toolArgs": { "command": "rm -rf /" },
"turn": 3
}</code></pre>
</section>
<section id="permissions">
<h2 data-i18n="perm.title">Permissions</h2>
<p data-i18n="perm.body1">
Shell commands are gated per-workspace. The first time the agent runs a
command, you get an interactive <em>allow once / allow always / deny</em>
prompt; "allow always" persists the exact prefix to <code>config.json</code>
under that project.
</p>
<pre class="code"><code>{
"projects": {
"/abs/path/to/repo": {
"shellAllowed": [
"npm test",
"git status",
"ls"
]
}
}
}</code></pre>
<p data-i18n="perm.body.exact">
<strong>Exact match after trim.</strong> <code>git</code> alone does
<em>not</em> cover <code>git push origin main</code>; list each prefix you
actually want green-lit.
</p>
<table class="cmd-table">
<tbody>
<tr>
<td><code>/permissions list</code></td>
<td data-i18n="perm.cmd.list">Show this project's allowlist.</td>
</tr>
<tr>
<td><code>/permissions add &lt;prefix&gt;</code></td>
<td data-i18n="perm.cmd.add">Add a shell prefix.</td>
</tr>
<tr>
<td><code>/permissions rm &lt;prefix|index&gt;</code></td>
<td data-i18n="perm.cmd.rm">Remove by name or list index.</td>
</tr>
<tr>
<td><code>/permissions clear confirm</code></td>
<td data-i18n="perm.cmd.clear">Wipe everything. <code>confirm</code> is mandatory.</td>
</tr>
</tbody>
</table>
</section>
<section id="search">
<h2 data-i18n="ws.title">Web search</h2>
<p data-i18n="ws.body1">
<code>web_search</code> + <code>web_fetch</code> ship in the box. Default
backend is <strong>Mojeek</strong> (no setup); switch to a self-hosted
<strong>SearXNG</strong> when you want full control over upstream engines.
</p>
<pre class="code"><code>/search-engine mojeek
/search-engine searxng <span class="hash"># http://localhost:8080</span>
/search-engine searxng http://192.168.1.5:8888</code></pre>
<p data-i18n="ws.body.json">Equivalent <code>config.json</code>:</p>
<pre class="code"><code>{
"webSearchEngine": "searxng",
"webSearchEndpoint": "http://localhost:8080"
}</code></pre>
<p data-i18n="ws.body.start">Start a local SearXNG:</p>
<pre class="code"><code>podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng</code></pre>
</section>
<section id="index">
<h2 data-i18n="ix.title">Semantic index</h2>
<p data-i18n="ix.body1">
<code>reasonix index</code> builds an embedding index the agent can query.
Pick an embedding provider:
</p>
<pre class="code"><code>{
"semantic": {
"provider": "ollama",
"ollama": {
"baseUrl": "http://localhost:11434",
"model": "nomic-embed-text"
},
"openaiCompat": {
"baseUrl": "https://api.example.com/v1",
"apiKey": "...",
"model": "text-embedding-3-small"
}
}
}</code></pre>
<p data-i18n="ix.body.swap">
Switch by changing <code>provider</code>. Local Ollama is free and
air-gapped; OpenAI-compat lets you point at any hosted embedding API.
</p>
</section>
<section class="guide-cta">
<h2 data-i18n="cta.title">Still stuck?</h2>
<p data-i18n="cta.sub">
Open a discussion or drop into <code>good first issue</code>. Every avatar
on the contributors wall started somewhere.
</p>
<div class="hero-ctas">
<a
class="cta primary"
href="https://github.com/esengine/reasonix/discussions"
target="_blank"
rel="noopener"
data-i18n="cta.disc"
>Discussions →</a
>
<a
class="cta ghost"
href="https://github.com/esengine/reasonix/blob/main/docs/ARCHITECTURE.md"
target="_blank"
rel="noopener"
data-i18n="cta.arch"
>Architecture deep dive</a
>
</div>
</section>
</article>
</div>
</main>
<footer class="foot">
<div class="container foot-inner">
<div>
<a href="index.html" class="nav-brand">
<span class="brand-mark small" aria-hidden="true">
<span class="diamond"></span>
<span class="diamond inner"></span>
</span>
<span class="brand-name">Reasonix</span>
</a>
<p class="foot-tag" data-i18n="foot.tag">DeepSeek does deep, deeply.</p>
</div>
</div>
<div class="foot-bottom">
<span data-i18n="foot.copyright">© 2026 Reasonix · MIT License</span>
</div>
</footer>
<script src="i18n.js"></script>
<script src="guide-i18n.js"></script>
<script src="motion.js"></script>
</body>
</html>
+13 -12
View File
@@ -81,7 +81,7 @@ a:hover { text-decoration: underline; }
/* ── Doc chrome ─────────────────────────────────────────────────────────── */
.page {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-columns: 260px minmax(0, 1fr);
max-width: 1320px;
margin: 0 auto;
min-height: 100vh;
@@ -90,18 +90,19 @@ a:hover { text-decoration: underline; }
position: sticky; top: 0; align-self: start;
height: 100vh; overflow-y: auto;
border-right: 1px solid var(--bd);
padding: 28px 18px;
padding: 28px 16px;
background: var(--bg);
}
.toc h1 { font-size: 14px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 { font-size: 15px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 .dot { color: var(--c-brand); margin-right: 8px; }
.toc .sub { font-size: 11px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc .sub { font-size: 12px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc-section:first-of-type { margin-top: 0; }
.toc ul { list-style: none; padding: 0; margin: 0; }
.toc li a {
display: block; padding: 3px 8px; margin: 1px 0;
color: var(--fg-2); font-size: 12.5px; border-radius: var(--r);
display: block; padding: 4px 10px; margin: 1px 0;
color: var(--fg-2); font-size: 14px; line-height: 1.4;
border-radius: var(--r); overflow-wrap: anywhere;
}
.toc li a:hover { color: var(--fg-0); background: var(--bg-elev); text-decoration: none; }
@@ -114,7 +115,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
}
.section > h2 .num { color: var(--fg-4); margin-right: 10px; font-weight: 500; }
.section > .lede {
color: var(--fg-2); margin: 0 0 22px; font-size: 13px; max-width: 700px; line-height: 1.6;
color: var(--fg-2); margin: 0 0 22px; font-size: 15px; max-width: 720px; line-height: 1.6;
}
.subsec { margin-bottom: 22px; }
.subsec > h3 {
@@ -122,8 +123,8 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
margin: 24px 0 4px; letter-spacing: .04em; text-transform: uppercase;
font-family: var(--font-mono);
}
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 12px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 12.5px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 13px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 15px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
/* "Mock" — a faux-window frame to display dashboard pieces inside the design doc */
.mock {
@@ -151,11 +152,11 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
.swatch .chip { width: 22px; height: 22px; border-radius: var(--r); flex-shrink: 0; border: 1px solid rgba(255,255,255,.04); }
.swatch .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.swatch .name { color: var(--fg-1); font-size: 11.5px; }
.swatch .hex { color: var(--fg-3); font-size: 10.5px; }
.swatch .hex { color: var(--fg-3); font-size: 11.5px; }
.scale-row { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px dashed #181b22; }
.scale-row:last-child { border-bottom: none; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); width: 70px; flex-shrink: 0; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); width: 76px; flex-shrink: 0; }
.scale-row .ex { color: var(--fg-1); }
.glyph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
+392
View File
@@ -0,0 +1,392 @@
/* Configuration-guide translations + scrollspy. Layered on top of i18n.js. */
(function () {
"use strict";
var R = window.Reasonix;
if (!R) return;
var en = {
"nav.guide": "Guide",
"guide.badge": "Configuration · MCP · Skills · Memory",
"guide.title.line1": "Configure Reasonix",
"guide.title.line2": "in five minutes",
"guide.sub":
"One JSON file at <code>~/.reasonix/config.json</code> + per-project overrides under <code>.reasonix/</code>. This page documents every key, every slash command, and the on-disk shape of skills, memory, and hooks.",
"guide.toc.title": "On this page",
"guide.toc.config": "config.json",
"guide.toc.mcp": "MCP servers",
"guide.toc.skills": "Skills",
"guide.toc.memory": "Memory",
"guide.toc.hooks": "Hooks",
"guide.toc.perms": "Permissions",
"guide.toc.search": "Web search",
"guide.toc.index": "Semantic index",
"th.cmd": "Command",
"th.what": "What it does",
"cfg.title": "The config.json file",
"cfg.body1":
"Reasonix reads a single global config from <code>~/.reasonix/config.json</code> (Windows: <code>%USERPROFILE%\\.reasonix\\config.json</code>). The file is created automatically on first run; you can hand-edit it any time. The CLI flag <code>--no-config</code> bypasses it, useful in CI.",
"cfg.body2":
"Per-project overrides live under <code>&lt;project&gt;/.reasonix/</code> — skills, memory, settings.json (hooks). Project scope wins over global on name collision.",
"cfg.shape": "Top-level keys",
"cfg.k.lang": "UI language: en | zh",
"cfg.k.preset": "auto | flash | pro",
"cfg.k.editmode": "review | auto | yolo",
"cfg.k.effort": "high | max",
"cfg.k.theme": "light | dark | auto",
"cfg.k.search": "enable web_search/web_fetch tools",
"cfg.k.engine": "mojeek | searxng",
"cfg.k.mcp": "MCP server specs",
"cfg.k.mcpoff": "names skipped at startup",
"cfg.k.projects": "per-workspace overrides",
"cfg.k.semantic": "embedding provider for `reasonix index`",
"cfg.callout.tag": "Trust dial",
"cfg.callout.body":
"<code>editMode</code> is the single trust dial for an entire session. <code>review</code> queues edits + gates shell. <code>auto</code> applies edits + still gates shell. <code>yolo</code> skips both gates — only use inside a sandbox.",
"mcp.title": "MCP servers",
"mcp.body1":
"Reasonix speaks the Model Context Protocol natively. Every entry in <code>config.mcp</code> is a single string — the same format the <code>--mcp</code> CLI flag accepts — so one parser handles both. Three transports are supported.",
"mcp.h.stdio": "Stdio (subprocess)",
"mcp.body.stdio":
"Format: <code>name=command arg1 arg2</code>. The <code>name=</code> prefix namespaces every tool the server exposes. Args use shell-style splitting; quote any with spaces.",
"mcp.h.sse": "SSE (HTTP)",
"mcp.body.sse":
"Plain <code>http://</code> / <code>https://</code> URLs use HTTP+SSE for back-compat. Anonymous (no <code>name=</code>) entries work but can't be toggled by name later.",
"mcp.h.streamable": "Streamable HTTP (2025-03 spec)",
"mcp.body.streamable": "Opt in with the <code>streamable+</code> URL prefix.",
"mcp.h.cli": "CLI flags &amp; slash commands",
"mcp.cmd.hub": "Open the interactive MCP hub.",
"mcp.cmd.disable":
"Persist to <code>mcpDisabled</code>; effective on next launch.",
"mcp.cmd.enable": "Re-enable a disabled server.",
"mcp.cmd.recon": "Reconnect a live server and pick up newly-registered tools.",
"sk.title": "Skills",
"sk.body1":
"A skill is a markdown playbook the model can invoke (<code>/skill &lt;name&gt;</code>). Names + descriptions are pinned in the prompt; bodies load on demand. Project skills override global ones with the same name.",
"sk.h.layout": "Layout",
"sk.body.layout":
"Two equivalent shapes: a flat <code>&lt;name&gt;.md</code>, or a <code>&lt;name&gt;/SKILL.md</code> folder when you want to colocate attachments.",
"sk.h.fm": "Frontmatter",
"sk.fm.desc": "Review git log for security red flags.",
"sk.fm.runas": "inline | subagent",
"sk.fm.tools": "subagent tool allowlist",
"sk.fm.model": "subagent model override",
"sk.body.task": "Task",
"sk.body.s1": "Fetch the last 20 commits.",
"sk.body.s2":
"Flag commits whose message mentions password / secret / token.",
"sk.body.s3": "Report findings.",
"sk.fm.f.name":
"1–64 chars: alnum, <code>_</code>, <code>-</code>, interior <code>.</code>. Defaults to filename stem.",
"sk.fm.f.desc": "One line. Shown in <code>/skill list</code>.",
"sk.fm.f.runas":
"<code>inline</code> (default): body enters parent log. <code>subagent</code>: isolated child loop, only the final answer returns.",
"sk.fm.f.tools":
"Comma-separated literal tool names. Subagent only — scopes the child's tool registry.",
"sk.fm.f.model":
"Subagent only. Must start with <code>deepseek-</code>; ignored otherwise.",
"sk.h.cmds": "Slash commands",
"sk.cmd.list": "List every skill, scope-tagged.",
"sk.cmd.new":
"Scaffold a stub at project scope. Add <code>--global</code> for <code>~/.reasonix/skills</code>.",
"sk.cmd.show": "Print the full body.",
"sk.cmd.run": "Run it. Args are appended to the body as a single string.",
"mem.title": "Memory",
"mem.body1":
"Memory is user-private knowledge pinned into the immutable prefix — so the agent reads it on every turn without re-priming. Two scopes: <em>global</em> (cross-project facts about you) and <em>project</em> (per-workspace context). Distinct from a committable <code>REASONIX.md</code>, which lives in the repo.",
"mem.h.layout": "Layout",
"mem.idx": "index — pinned into the prefix",
"mem.proj": "sha1(absRoot)[0..16]",
"mem.h.entry": "Entry shape",
"mem.f.desc": "User is a senior backend engineer; new to React.",
"mem.f.type": "user | feedback | project | reference",
"mem.f.body": "Body — the actual remembered fact, in plain markdown.",
"mem.body.types":
"<strong>Types:</strong> <code>user</code> (who they are), <code>feedback</code> (corrections / preferences), <code>project</code> (initiative / deadline / motivation), <code>reference</code> (where to look in external systems).",
"mem.h.cmds": "Slash commands",
"mem.cmd.list": "List all entries, both scopes.",
"mem.cmd.show": "Display body. Scope is auto-resolved.",
"mem.cmd.forget": "Delete one entry.",
"mem.cmd.clear":
"Wipe an entire scope. <code>confirm</code> is mandatory.",
"mem.body.write":
"<strong>Writing memories:</strong> say it in chat (\"remember I prefer Vitest over Jest\"). The model invokes the <code>scaffold_memory</code> tool, which proposes a file and waits for your <code>/apply</code>.",
"hk.title": "Hooks",
"hk.body1":
"Hooks are shell commands the harness fires on lifecycle events. Configured in <code>settings.json</code>, not <code>config.json</code>. Project scope first, then global.",
"hk.h.where": "Where to put them",
"hk.path.proj": "project scope",
"hk.path.glob": "global scope",
"hk.h.shape": "Shape",
"hk.ex.audit": "Audit risky tool calls before they run",
"hk.h.events": "Events",
"hk.ev.pre":
"Before a tool runs. <strong>Gating:</strong> exit 2 blocks; exit 0 passes. 5 s default timeout.",
"hk.ev.post":
"After a tool runs. Non-gating; warn-only on non-zero. 30 s default.",
"hk.ev.usr":
"Before user input is processed. <strong>Gating</strong> (exit 2 blocks the message).",
"hk.ev.stop":
"On <code>/quit</code> or session exit. Non-gating.",
"hk.h.payload": "Stdin payload",
"hk.body.payload":
"Each hook receives a JSON object on stdin describing the event:",
"perm.title": "Permissions",
"perm.body1":
"Shell commands are gated per-workspace. The first time the agent runs a command, you get an interactive <em>allow once / allow always / deny</em> prompt; \"allow always\" persists the exact prefix to <code>config.json</code> under that project.",
"perm.body.exact":
"<strong>Exact match after trim.</strong> <code>git</code> alone does <em>not</em> cover <code>git push origin main</code>; list each prefix you actually want green-lit.",
"perm.cmd.list": "Show this project's allowlist.",
"perm.cmd.add": "Add a shell prefix.",
"perm.cmd.rm": "Remove by name or list index.",
"perm.cmd.clear":
"Wipe everything. <code>confirm</code> is mandatory.",
"ws.title": "Web search",
"ws.body1":
"<code>web_search</code> + <code>web_fetch</code> ship in the box. Default backend is <strong>Mojeek</strong> (no setup); switch to a self-hosted <strong>SearXNG</strong> when you want full control over upstream engines.",
"ws.body.json": "Equivalent <code>config.json</code>:",
"ws.body.start": "Start a local SearXNG:",
"ix.title": "Semantic index",
"ix.body1":
"<code>reasonix index</code> builds an embedding index the agent can query. Pick an embedding provider:",
"ix.body.swap":
"Switch by changing <code>provider</code>. Local Ollama is free and air-gapped; OpenAI-compat lets you point at any hosted embedding API.",
"cta.title": "Still stuck?",
"cta.sub":
"Open a discussion or drop into <code>good first issue</code>. Every avatar on the contributors wall started somewhere.",
"cta.disc": "Discussions →",
"cta.arch": "Architecture deep dive",
};
var zh = {
"nav.guide": "配置指南",
"guide.badge": "配置 · MCP · Skills · Memory",
"guide.title.line1": "五分钟",
"guide.title.line2": "配置完 Reasonix",
"guide.sub":
"一个全局 JSON 文件 <code>~/.reasonix/config.json</code>,加上项目级 <code>.reasonix/</code> 下的覆盖。这一页把每个 key、每条斜杠命令、以及 skills / memory / hooks 在磁盘上的形状全部讲清楚。",
"guide.toc.title": "本页目录",
"guide.toc.config": "config.json",
"guide.toc.mcp": "MCP 服务器",
"guide.toc.skills": "Skills",
"guide.toc.memory": "Memory",
"guide.toc.hooks": "Hooks",
"guide.toc.perms": "权限",
"guide.toc.search": "Web 搜索",
"guide.toc.index": "语义索引",
"th.cmd": "命令",
"th.what": "作用",
"cfg.title": "config.json 配置文件",
"cfg.body1":
"Reasonix 只从 <code>~/.reasonix/config.json</code> 读取全局配置(Windows:<code>%USERPROFILE%\\.reasonix\\config.json</code>)。首次运行会自动创建,之后随便手改。CLI 加 <code>--no-config</code> 即可跳过,CI 友好。",
"cfg.body2":
"项目级覆盖放在 <code>&lt;project&gt;/.reasonix/</code> 下——skills、memory、settings.json(hooks)都遵守这个目录。同名时项目级覆盖全局。",
"cfg.shape": "顶层 key",
"cfg.k.lang": "界面语言:en | zh",
"cfg.k.preset": "auto | flash | pro",
"cfg.k.editmode": "review | auto | yolo",
"cfg.k.effort": "high | max",
"cfg.k.theme": "light | dark | auto",
"cfg.k.search": "启用 web_search / web_fetch 工具",
"cfg.k.engine": "mojeek | searxng",
"cfg.k.mcp": "MCP 服务器列表",
"cfg.k.mcpoff": "启动时跳过的服务器名",
"cfg.k.projects": "按工作区的覆盖配置",
"cfg.k.semantic": "`reasonix index` 用的 embedding 提供方",
"cfg.callout.tag": "信任挡位",
"cfg.callout.body":
"<code>editMode</code> 是整个会话的唯一一档信任旋钮。<code>review</code> 队列改动 + 拦截 shell;<code>auto</code> 直接落盘 + 继续拦截 shell;<code>yolo</code> 两个都不拦——只在沙箱里用。",
"mcp.title": "MCP 服务器",
"mcp.body1":
"Reasonix 原生支持 Model Context Protocol。<code>config.mcp</code> 里每条都是一个字符串——和 <code>--mcp</code> CLI flag 用同一个 parser。支持三种传输。",
"mcp.h.stdio": "Stdio(子进程)",
"mcp.body.stdio":
"格式:<code>name=command arg1 arg2</code>。<code>name=</code> 前缀会给该服务器暴露的所有工具加命名空间。args 走 shell 风格的分词,含空格的部分要加引号。",
"mcp.h.sse": "SSE(HTTP)",
"mcp.body.sse":
"纯 <code>http://</code> / <code>https://</code> URL 走 HTTP+SSE,向后兼容。匿名条目(不带 <code>name=</code>)能用,但之后没法按名字开关。",
"mcp.h.streamable": "Streamable HTTP(2025-03 规范)",
"mcp.body.streamable":
"用 <code>streamable+</code> 前缀显式开启。",
"mcp.h.cli": "CLI flag 与斜杠命令",
"mcp.cmd.hub": "打开交互式 MCP 中心。",
"mcp.cmd.disable":
"写到 <code>mcpDisabled</code>;下次启动生效。",
"mcp.cmd.enable": "重新启用一个被禁用的服务器。",
"mcp.cmd.recon":
"重连一个在线服务器,并增量注册新工具。",
"sk.title": "Skills",
"sk.body1":
"Skill 是 markdown 写的剧本,模型可以调用(<code>/skill &lt;name&gt;</code>)。名称+描述会钉进 prompt,body 按需加载。同名时项目级覆盖全局。",
"sk.h.layout": "目录布局",
"sk.body.layout":
"两种等价的形式:扁平的 <code>&lt;name&gt;.md</code>,或者 <code>&lt;name&gt;/SKILL.md</code> 文件夹(需要附带资源时用后者)。",
"sk.h.fm": "Frontmatter",
"sk.fm.desc": "审查 git log,看看有没有安全相关的危险信号。",
"sk.fm.runas": "inline | subagent",
"sk.fm.tools": "subagent 的工具白名单",
"sk.fm.model": "subagent 的模型覆盖",
"sk.body.task": "任务",
"sk.body.s1": "拉最近 20 个 commit。",
"sk.body.s2":
"标记 commit message 里出现 password / secret / token 的提交。",
"sk.body.s3": "汇总结果。",
"sk.fm.f.name":
"1–64 字符:alnum、<code>_</code>、<code>-</code>,中间允许 <code>.</code>。默认取文件名 stem。",
"sk.fm.f.desc":
"一行。在 <code>/skill list</code> 里展示。",
"sk.fm.f.runas":
"<code>inline</code>(默认):body 进父循环的日志。<code>subagent</code>:起一个隔离子循环,只回传最终结果。",
"sk.fm.f.tools":
"逗号分隔的工具字面名。仅 subagent 生效——给子循环的 tool registry 圈定范围。",
"sk.fm.f.model":
"仅 subagent 生效。必须以 <code>deepseek-</code> 开头,否则忽略。",
"sk.h.cmds": "斜杠命令",
"sk.cmd.list": "按 scope 列出全部 skill。",
"sk.cmd.new":
"在项目 scope 下生成 stub。加 <code>--global</code> 可写到 <code>~/.reasonix/skills</code>。",
"sk.cmd.show": "打印完整 body。",
"sk.cmd.run":
"运行它。后续 args 会作为单一字符串拼到 body 之后。",
"mem.title": "Memory",
"mem.body1":
"Memory 是用户私有的知识,钉进不可变前缀——所以 agent 每回合都自动读到,不用再重新 prime。两个 scope:<em>global</em>(关于你的、跨项目事实)与 <em>project</em>(按工作区的上下文)。和提交进仓库的 <code>REASONIX.md</code> 不是一回事。",
"mem.h.layout": "目录布局",
"mem.idx": "索引——会被钉进前缀",
"mem.proj": "sha1(absRoot)[0..16]",
"mem.h.entry": "条目格式",
"mem.f.desc": "用户是资深后端,第一次接触 React。",
"mem.f.type":
"user | feedback | project | reference",
"mem.f.body": "正文——真正要记的事实,纯 markdown。",
"mem.body.types":
"<strong>四种类型:</strong><code>user</code>(用户是谁)、<code>feedback</code>(修正/偏好)、<code>project</code>(计划/截止/动机)、<code>reference</code>(外部系统该去哪里查)。",
"mem.h.cmds": "斜杠命令",
"mem.cmd.list": "列出两个 scope 的全部条目。",
"mem.cmd.show": "展示 body。scope 自动解析。",
"mem.cmd.forget": "删一条。",
"mem.cmd.clear":
"清空整个 scope。必须显式加 <code>confirm</code>。",
"mem.body.write":
"<strong>怎么写入:</strong>直接在对话里说(“记一下我用 Vitest,不用 Jest”)。模型会调用 <code>scaffold_memory</code> 工具起草一条文件,等你 <code>/apply</code> 落盘。",
"hk.title": "Hooks",
"hk.body1":
"Hooks 是 harness 在生命周期事件上触发的 shell 命令。配置写在 <code>settings.json</code>,不是 <code>config.json</code>。先项目级,再全局。",
"hk.h.where": "放在哪里",
"hk.path.proj": "项目 scope",
"hk.path.glob": "全局 scope",
"hk.h.shape": "Schema",
"hk.ex.audit":
"在风险工具调用执行前先审计一下",
"hk.h.events": "事件",
"hk.ev.pre":
"工具执行前。<strong>会拦截:</strong>exit 2 阻断;exit 0 通过。默认 5 秒超时。",
"hk.ev.post":
"工具执行后。不拦截;非零仅 warn。默认 30 秒。",
"hk.ev.usr":
"用户输入处理前。<strong>会拦截</strong>(exit 2 把这条消息阻断)。",
"hk.ev.stop":
"<code>/quit</code> 或会话退出时触发。不拦截。",
"hk.h.payload": "Stdin 负载",
"hk.body.payload":
"每个 hook 在 stdin 上收到一段 JSON,描述事件:",
"perm.title": "权限",
"perm.body1":
"Shell 命令按工作区拦截。Agent 第一次跑某条命令时弹交互式 <em>本次允许 / 永久允许 / 拒绝</em>;选“永久允许”就把这条精确前缀写进 <code>config.json</code> 的对应项目下。",
"perm.body.exact":
"<strong>trim 后做精确匹配。</strong>光列 <code>git</code> 并<em>不</em>覆盖 <code>git push origin main</code>——你想放行的每个前缀都得单独列。",
"perm.cmd.list": "查看本项目的白名单。",
"perm.cmd.add": "新增一条 shell 前缀。",
"perm.cmd.rm": "按名字或下标删除。",
"perm.cmd.clear":
"全清。必须显式加 <code>confirm</code>。",
"ws.title": "Web 搜索",
"ws.body1":
"<code>web_search</code> + <code>web_fetch</code> 开箱即用。默认走 <strong>Mojeek</strong>(无需配置);想完全控制上游引擎就切到自托管 <strong>SearXNG</strong>。",
"ws.body.json": "等价的 <code>config.json</code>:",
"ws.body.start": "本机起一个 SearXNG:",
"ix.title": "语义索引",
"ix.body1":
"<code>reasonix index</code> 会构建一份 embedding 索引供 agent 查询。挑一个 embedding 提供方:",
"ix.body.swap":
"切换只需要改 <code>provider</code>。本地 Ollama 免费且离线;OpenAI-compat 可以指向任何兼容的 embedding API。",
"cta.title": "还有疑问?",
"cta.sub":
"去开个 discussion,或者从 <code>good first issue</code> 入坑——贡献者墙上每一张头像都是这么开始的。",
"cta.disc": "讨论区 →",
"cta.arch": "架构深度文档",
};
var DICT = { en: en, zh: zh };
function applyGuide(lang) {
var dict = DICT[lang] || DICT.en;
document.querySelectorAll("[data-i18n]").forEach(function (el) {
var key = el.getAttribute("data-i18n");
if (dict[key] !== undefined) el.innerHTML = dict[key];
});
}
// Re-apply on first load and every language change.
applyGuide(R.lang());
R.onLangChange(applyGuide);
// Scrollspy — highlight the current section's TOC entry.
var sections = Array.prototype.slice.call(
document.querySelectorAll(".guide-body section[id]"),
);
var tocLinks = Array.prototype.slice.call(
document.querySelectorAll(".guide-toc a"),
);
if (sections.length && tocLinks.length && "IntersectionObserver" in window) {
var byId = {};
tocLinks.forEach(function (a) {
var href = a.getAttribute("href") || "";
var id = href.replace(/^#/, "");
if (id) byId[id] = a;
});
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (e) {
var link = byId[e.target.id];
if (!link) return;
if (e.isIntersecting) {
tocLinks.forEach(function (l) {
l.classList.remove("is-active");
});
link.classList.add("is-active");
}
});
},
{ rootMargin: "-30% 0px -60% 0px", threshold: 0 },
);
sections.forEach(function (s) {
io.observe(s);
});
}
})();
+247
View File
@@ -0,0 +1,247 @@
/* Reasonix configuration guide — layout extensions over styles.css. */
.guide-main {
padding-bottom: 80px;
}
.guide-hero {
padding: 32px 0 48px;
}
.guide-hero .badge {
margin-bottom: 18px;
}
.guide-title {
font-size: clamp(32px, 4.4vw, 52px);
line-height: 1.05;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0 0 20px;
}
.guide-sub {
font-size: 17px;
color: var(--fg-2);
max-width: 780px;
margin: 0;
}
.guide-shell {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 56px;
align-items: start;
}
.guide-toc {
position: sticky;
top: 24px;
align-self: start;
border-left: 1px solid var(--border);
padding: 4px 0 4px 18px;
}
.guide-toc h4 {
font-size: 11.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-3);
margin: 0 0 14px;
font-weight: 700;
}
.guide-toc ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.guide-toc a {
display: block;
font-size: 14px;
color: var(--fg-2);
padding: 5px 0;
transition: color 0.15s ease;
}
.guide-toc a:hover {
color: var(--fg);
}
.guide-toc a.is-active {
color: var(--accent-2);
}
.guide-body section {
padding: 28px 0 12px;
border-bottom: 1px solid var(--border);
}
.guide-body section:last-child {
border-bottom: 0;
}
.guide-body h2 {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.015em;
margin: 0 0 16px;
scroll-margin-top: 24px;
}
.guide-body h3 {
font-size: 16px;
font-weight: 700;
margin: 24px 0 10px;
color: var(--fg);
letter-spacing: 0.005em;
}
.guide-body p {
color: var(--fg-2);
font-size: 15.5px;
margin: 0 0 14px;
}
.guide-body p strong {
color: var(--fg);
font-weight: 600;
}
/* Slim two-column reference table (slash commands, frontmatter fields). */
.cmd-table {
width: 100%;
border-collapse: collapse;
margin: 8px 0 16px;
font-size: 14.5px;
}
.cmd-table th,
.cmd-table td {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.cmd-table th {
background: rgba(20, 26, 46, 0.4);
color: var(--fg-2);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cmd-table tbody tr:last-child td {
border-bottom: 0;
}
.cmd-table td:first-child {
white-space: nowrap;
width: 1%;
}
.cmd-table td code {
font-size: 13px;
}
.cmd-table td:last-child {
color: var(--fg-2);
}
/* Definition-style list — `field` then description. */
.kv-list {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: grid;
gap: 6px;
}
.kv-list li {
display: grid;
grid-template-columns: 160px 1fr;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid rgba(30, 36, 54, 0.5);
font-size: 14.5px;
color: var(--fg-2);
}
.kv-list li:last-child {
border-bottom: 0;
}
.kv-list li > code {
font-size: 13px;
align-self: start;
}
/* Inline note block — gradient-edged callout that breaks up dense reference. */
.callout {
position: relative;
background: linear-gradient(
135deg,
rgba(94, 234, 212, 0.06),
rgba(196, 181, 253, 0.06)
);
border: 1px solid rgba(165, 180, 252, 0.25);
border-left-width: 3px;
border-left-color: var(--grad-3);
border-radius: var(--radius-sm);
padding: 16px 18px;
margin: 18px 0 8px;
}
.callout-tag {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-2);
margin-bottom: 6px;
}
.callout p {
margin: 0;
color: var(--fg);
font-size: 14.5px;
}
.guide-cta {
text-align: center;
padding: 48px 0 8px !important;
border-bottom: 0 !important;
}
.guide-cta h2 {
text-align: center;
margin-bottom: 8px;
}
.guide-cta p {
text-align: center;
margin: 0 auto 20px;
max-width: 520px;
}
.guide-cta .hero-ctas {
justify-content: center;
}
.nav-links a.active {
color: var(--fg);
position: relative;
}
.nav-links a.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -6px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--grad-1), var(--grad-5));
}
@media (max-width: 960px) {
.guide-shell {
grid-template-columns: 1fr;
gap: 24px;
}
.guide-toc {
position: static;
border-left: 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 14px 0;
}
.guide-toc ul {
flex-direction: row;
flex-wrap: wrap;
gap: 8px 16px;
}
.kv-list li {
grid-template-columns: 1fr;
gap: 4px;
}
}
+64 -26
View File
@@ -14,10 +14,12 @@
"nav.why": "Why",
"nav.features": "Features",
"nav.quickstart": "Quick start",
"nav.guide": "Guide",
"nav.community": "Community",
"nav.github": "GitHub",
"hero.badge": "v{version} · DeepSeek · cache-first · MIT",
"hero.status": "live · v{version}",
"hero.badge": "DeepSeek · cache-first · MIT",
"hero.title.line1": "DeepSeek-native",
"hero.title.line2": "AI coding agent in your terminal",
"hero.sub":
@@ -26,12 +28,12 @@
"hero.copy.done": "Copied",
"hero.cta.start": "Get started →",
"hero.cta.star": "Star on GitHub",
"hero.stat.cache.num": "Cache-engineered",
"hero.stat.cache": "Byte-stable prefix loop",
"hero.stat.lic.num": "Open source",
"hero.stat.lic": "MIT, community-developed",
"hero.stat.tui.num": "Terminal-native",
"hero.stat.tui": "Plus embedded dashboard",
"metric.hit": "Cache hit, single day",
"metric.tokens": "Input tokens served",
"metric.cost": "Cost vs. no-cache",
"metric.lic": "Open, community-built",
"metric.src": "Source: real-world cache case study (2026-05-01) →",
"term.user":
"users.ts findByEmail is case-sensitive — login fails for users with uppercase emails",
@@ -115,12 +117,29 @@
"feat.events.body":
"<code>events.jsonl</code> sidecar with reducers and a <code>reasonix events</code> CLI. Build dashboards, audits, or your own analytics.",
"bench.title": "Verify the cache claim yourself",
"bench.sub":
"We don't ship benchmark numbers in marketing copy — they move with model pricing and harness changes, so they live with the harness instead of the README. Reproduce on your own machine:",
"bench.repro.note":
"Each committed JSONL transcript carries per-turn <code>usage</code>, <code>cost</code>, and <code>prefixHash</code>. Reasonix's prefix hash stays byte-stable across every model call — that's the whole game.",
"bench.link": "Read the τ-bench-lite harness →",
"conf.title": "Configure in five minutes",
"conf.sub":
"One JSON file at <code>~/.reasonix/config.json</code>, plus per-project overrides under <code>.reasonix/</code>. Point. Click. Wire in your stack.",
"conf.read": "Read →",
"conf.mcp.title": "MCP servers",
"conf.mcp.body":
"stdio · SSE · Streamable HTTP. One spec format for both <code>config.json</code> and <code>--mcp</code>.",
"conf.sk.title": "Skills",
"conf.sk.body":
"Markdown playbooks the model invokes. Inline or sub-agent. Project overrides global.",
"conf.mem.title": "Memory",
"conf.mem.body":
"User-private knowledge pinned into the prefix. Global + project scopes. Four typed shapes.",
"conf.hk.title": "Hooks",
"conf.hk.body":
"Shell commands on lifecycle events. Pre/post tool, prompt submit, stop. Exit-2 to block.",
"conf.perm.title": "Permissions",
"conf.perm.body":
"Per-workspace shell allowlist. Exact-prefix match. Interactive \"always allow\" persists.",
"conf.ws.title": "Web search",
"conf.ws.body":
"Mojeek by default, no setup. Switch to self-hosted SearXNG with <code>/search-engine</code>.",
"conf.cta": "Open the configuration guide →",
"cli.title": "CLI at a glance",
"cli.code": "coding mode scoped to path",
@@ -175,10 +194,12 @@
"nav.why": "为什么",
"nav.features": "特性",
"nav.quickstart": "快速上手",
"nav.guide": "配置指南",
"nav.community": "社区",
"nav.github": "GitHub",
"hero.badge": "v{version} · DeepSeek · 缓存优先 · MIT",
"hero.status": "运行中 · v{version}",
"hero.badge": "DeepSeek · 缓存优先 · MIT",
"hero.title.line1": "DeepSeek 原生",
"hero.title.line2": "终端里的 AI 编程代理",
"hero.sub":
@@ -187,12 +208,12 @@
"hero.copy.done": "已复制",
"hero.cta.start": "开始使用 →",
"hero.cta.star": "在 GitHub 加星",
"hero.stat.cache.num": "缓存工程化",
"hero.stat.cache": "字节稳定的前缀循环",
"hero.stat.lic.num": "开源",
"hero.stat.lic": "MIT 协议,社区共建",
"hero.stat.tui.num": "终端原生",
"hero.stat.tui": "外加内嵌仪表盘",
"metric.hit": "单日缓存命中率",
"metric.tokens": "单日输入 token",
"metric.cost": "对比无缓存成本",
"metric.lic": "开源 · 社区共建",
"metric.src": "数据来源:2026-05-01 真实用户缓存命中案例 →",
"term.user":
"users.ts 里 findByEmail 对大小写敏感导致登录失败,帮我改",
@@ -276,12 +297,29 @@
"feat.events.body":
"<code>events.jsonl</code> 旁路日志,附带 reducer 和 <code>reasonix events</code> CLI。自己搭仪表盘、审计、分析都行。",
"bench.title": "缓存命中自己也能验证",
"bench.sub":
"营销文案里我们不放具体 benchmark 数字——这些数会随模型定价和测试集变化,所以归在 harness 里,不进 README。在自己机器上复现:",
"bench.repro.note":
"提交进仓库的每个 JSONL transcript 都带逐轮 <code>usage</code>、<code>cost</code> 和 <code>prefixHash</code>。Reasonix 的前缀哈希在每次模型调用中都字节稳定——这就是全部把戏。",
"bench.link": "查看 τ-bench-lite harness →",
"conf.title": "五分钟配置完",
"conf.sub":
"一个全局 JSON <code>~/.reasonix/config.json</code>,加上项目级 <code>.reasonix/</code> 下的覆盖。点几下,把你的工具链接进来。",
"conf.read": "阅读 →",
"conf.mcp.title": "MCP 服务器",
"conf.mcp.body":
"stdio · SSE · Streamable HTTP。<code>config.json</code> 和 <code>--mcp</code> 共用同一种 spec 格式。",
"conf.sk.title": "Skills",
"conf.sk.body":
"模型可调用的 markdown 剧本。Inline 或 subagent。同名时项目级覆盖全局。",
"conf.mem.title": "Memory",
"conf.mem.body":
"用户私有的知识,钉进前缀。全局 + 项目两个 scope,四种结构化类型。",
"conf.hk.title": "Hooks",
"conf.hk.body":
"生命周期事件触发的 shell 命令。pre/post 工具、prompt 提交、退出。exit 2 即拦截。",
"conf.perm.title": "权限",
"conf.perm.body":
"按工作区的 shell 白名单。精确前缀匹配。交互式“永久允许”会持久化。",
"conf.ws.title": "Web 搜索",
"conf.ws.body":
"默认 Mojeek,零配置。要完全控制就用 <code>/search-engine</code> 切自托管 SearXNG。",
"conf.cta": "打开配置指南 →",
"cli.title": "CLI 速览",
"cli.code": "针对指定路径的编程模式",
+237 -48
View File
@@ -3,22 +3,63 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reasonix — DeepSeek-native AI coding agent</title>
<title>Reasonix — DeepSeek-native AI coding agent for your terminal</title>
<meta
name="description"
content="Open-source AI coding agent for your terminal, engineered around DeepSeek's prefix-cache so token costs stay low across long sessions. MCP first-class · plan mode · custom cell-diff renderer."
content="Open-source AI coding agent for your terminal, engineered around DeepSeek's prefix-cache so token costs stay low across long sessions. MCP first-class · plan mode · custom cell-diff renderer · MIT licensed."
/>
<meta
name="keywords"
content="DeepSeek, AI coding agent, terminal AI, prefix cache, MCP, Model Context Protocol, open source coding assistant, CLI agent, TUI, R1 reasoning, cache-first loop, Claude Code alternative, Cursor alternative, Aider alternative"
/>
<meta name="author" content="esengine" />
<meta name="theme-color" content="#0b0f17" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="color-scheme" content="dark light" />
<link rel="canonical" href="https://esengine.github.io/DeepSeek-Reasonix/" />
<link
rel="alternate"
hreflang="en"
href="https://esengine.github.io/DeepSeek-Reasonix/?lang=en"
/>
<link
rel="alternate"
hreflang="zh-CN"
href="https://esengine.github.io/DeepSeek-Reasonix/?lang=zh"
/>
<link
rel="alternate"
hreflang="x-default"
href="https://esengine.github.io/DeepSeek-Reasonix/"
/>
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Reasonix" />
<meta property="og:title" content="Reasonix — DeepSeek-native AI coding agent" />
<meta
property="og:description"
content="Open-source AI coding agent for your terminal. Engineered around DeepSeek's prefix-cache. MCP first-class · plan mode · embedded dashboard · MIT."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://esengine.github.io/reasonix/" />
<meta property="og:image" content="https://raw.githubusercontent.com/esengine/reasonix/main/docs/logo.svg" />
<meta property="og:url" content="https://esengine.github.io/DeepSeek-Reasonix/" />
<meta
property="og:image"
content="https://raw.githubusercontent.com/esengine/reasonix/main/docs/assets/hero-terminal.svg"
/>
<meta property="og:image:alt" content="Reasonix — terminal showing a SEARCH/REPLACE edit proposal" />
<meta property="og:locale" content="en_US" />
<meta property="og:locale:alternate" content="zh_CN" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Reasonix — DeepSeek-native AI coding agent" />
<meta
name="twitter:description"
content="Open-source AI coding agent for your terminal. Engineered around DeepSeek's prefix-cache."
/>
<meta
name="twitter:image"
content="https://raw.githubusercontent.com/esengine/reasonix/main/docs/assets/hero-terminal.svg"
/>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -28,11 +69,54 @@
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Reasonix",
"alternateName": ["DeepSeek-Reasonix", "reasonix"],
"description": "Open-source AI coding agent for your terminal, engineered around DeepSeek's prefix-cache so token costs stay low across long sessions.",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "macOS, Linux, Windows",
"softwareRequirements": "Node.js >= 22",
"url": "https://esengine.github.io/DeepSeek-Reasonix/",
"downloadUrl": "https://www.npmjs.com/package/reasonix",
"license": "https://opensource.org/licenses/MIT",
"codeRepository": "https://github.com/esengine/reasonix",
"programmingLanguage": "TypeScript",
"author": {
"@type": "Organization",
"name": "esengine",
"url": "https://github.com/esengine"
},
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Reasonix",
"url": "https://esengine.github.io/DeepSeek-Reasonix/",
"inLanguage": ["en", "zh-CN"],
"potentialAction": {
"@type": "SearchAction",
"target": "https://github.com/esengine/reasonix/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
</head>
<body>
<div class="bg-grid" aria-hidden="true"></div>
<div class="bg-glow" aria-hidden="true"></div>
<div class="bg-horizon" aria-hidden="true"></div>
<header class="nav">
<a class="nav-brand" href="#top" aria-label="Reasonix">
@@ -47,6 +131,7 @@
<a href="#why" data-i18n="nav.why">Why</a>
<a href="#features" data-i18n="nav.features">Features</a>
<a href="#quickstart" data-i18n="nav.quickstart">Quick start</a>
<a href="configuration.html" data-i18n="nav.guide">Guide</a>
<a href="#community" data-i18n="nav.community">Community</a>
<a
href="https://github.com/esengine/reasonix"
@@ -67,8 +152,43 @@
<main id="top">
<section class="hero">
<!-- Orbital decoration: three concentric rings + dots, lazily rotating. -->
<div class="hero-orbit" aria-hidden="true">
<svg viewBox="-200 -200 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="orbStroke" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#5eead4" stop-opacity="0.6" />
<stop offset="50%" stop-color="#a5b4fc" stop-opacity="0.35" />
<stop offset="100%" stop-color="#f0abfc" stop-opacity="0.5" />
</linearGradient>
<radialGradient id="orbCore" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#c4b5fd" stop-opacity="0.85" />
<stop offset="100%" stop-color="#5eead4" stop-opacity="0.0" />
</radialGradient>
</defs>
<g class="orb-rings">
<circle r="170" fill="none" stroke="url(#orbStroke)" stroke-width="1" stroke-dasharray="2 6" opacity="0.6" />
<circle r="120" fill="none" stroke="url(#orbStroke)" stroke-width="1" opacity="0.5" />
<circle r="80" fill="none" stroke="url(#orbStroke)" stroke-width="1" stroke-dasharray="1 4" opacity="0.5" />
</g>
<circle r="60" fill="url(#orbCore)" />
<g class="orb-dots">
<circle cx="170" cy="0" r="3" fill="#5eead4" />
<circle cx="0" cy="-120" r="2.5" fill="#a5b4fc" />
<circle cx="-80" cy="0" r="2" fill="#f0abfc" />
</g>
</svg>
</div>
<div class="hero-inner">
<div class="badge" data-i18n="hero.badge">v{version} · DeepSeek · cache-first · MIT</div>
<div class="hero-pills">
<span class="status-pill">
<span class="status-dot"></span>
<span data-i18n="hero.status">live · v{version}</span>
</span>
<span class="badge" data-i18n="hero.badge">DeepSeek · cache-first · MIT</span>
</div>
<h1 class="hero-title">
<span class="grad-text" data-i18n="hero.title.line1">DeepSeek-native</span>
<br />
@@ -101,21 +221,6 @@
>Star on GitHub</a
>
</div>
<div class="hero-stats">
<div class="stat">
<div class="stat-num grad-text" data-i18n="hero.stat.cache.num">Cache-engineered</div>
<div class="stat-label" data-i18n="hero.stat.cache">Byte-stable prefix loop</div>
</div>
<div class="stat">
<div class="stat-num grad-text" data-i18n="hero.stat.lic.num">Open source</div>
<div class="stat-label" data-i18n="hero.stat.lic">MIT, community-developed</div>
</div>
<div class="stat">
<div class="stat-num grad-text" data-i18n="hero.stat.tui.num">Terminal-native</div>
<div class="stat-label" data-i18n="hero.stat.tui">Plus embedded dashboard</div>
</div>
</div>
</div>
<div class="hero-terminal" aria-hidden="true">
@@ -128,6 +233,49 @@
</div>
</section>
<!-- Metric strip: real numbers from the 2026-05-01 case study. Counts up
when scrolled into view. The data-target value is the truth; the
rendered text starts at zero and ticks. -->
<section class="metrics" aria-label="Real-world cache hit metrics">
<div class="container metrics-row">
<div class="metric">
<div class="metric-num">
<span class="counter" data-target="99.82" data-suffix="%" data-decimals="2">0</span>
</div>
<div class="metric-label" data-i18n="metric.hit">Cache hit, single day</div>
</div>
<div class="metric-divider" aria-hidden="true"></div>
<div class="metric">
<div class="metric-num">
<span class="counter" data-target="435" data-suffix="M" data-decimals="0">0</span>
</div>
<div class="metric-label" data-i18n="metric.tokens">Input tokens served</div>
</div>
<div class="metric-divider" aria-hidden="true"></div>
<div class="metric">
<div class="metric-num">
<span class="counter" data-target="5" data-prefix="~" data-suffix="×" data-decimals="0">0</span>
</div>
<div class="metric-label" data-i18n="metric.cost">Cost vs. no-cache</div>
</div>
<div class="metric-divider" aria-hidden="true"></div>
<div class="metric">
<div class="metric-num metric-static">MIT</div>
<div class="metric-label" data-i18n="metric.lic">Open, community-built</div>
</div>
</div>
<p class="metrics-foot">
<a
href="https://github.com/esengine/reasonix/tree/main/benchmarks/real-world-cache"
target="_blank"
rel="noopener"
data-i18n="metric.src"
>Source: real-world cache case study (2026-05-01) →</a
>
</p>
</section>
<section id="why" class="why">
<div class="container">
<h2 class="section-title" data-i18n="why.title">Why Reasonix</h2>
@@ -370,40 +518,80 @@ npx reasonix code</code></pre>
</div>
</section>
<section class="bench">
<section id="configure" class="configure">
<div class="container">
<h2 class="section-title" data-i18n="bench.title">Verify the cache claim yourself</h2>
<p class="section-sub" data-i18n="bench.sub">
We don't ship benchmark numbers in marketing copy — they move with model
pricing and harness changes, so they live with the harness instead of the
README. Reproduce on your own machine:
<h2 class="section-title" data-i18n="conf.title">Configure in five minutes</h2>
<p class="section-sub" data-i18n="conf.sub">
One JSON file at <code>~/.reasonix/config.json</code>, plus per-project
overrides under <code>.reasonix/</code>. Point. Click. Wire in your stack.
</p>
<pre class="code"><code>git clone https://github.com/esengine/reasonix.git &amp;&amp; cd reasonix &amp;&amp; npm install
npx reasonix replay benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl
npx reasonix diff \
benchmarks/tau-bench/transcripts/t01_address_happy.baseline.r1.jsonl \
benchmarks/tau-bench/transcripts/t01_address_happy.reasonix.r1.jsonl</code></pre>
<div class="conf-grid">
<a class="conf-card" href="configuration.html#mcp">
<div class="conf-icon">⌥</div>
<h3 data-i18n="conf.mcp.title">MCP servers</h3>
<p data-i18n="conf.mcp.body">
stdio · SSE · Streamable HTTP. One spec format for both
<code>config.json</code> and <code>--mcp</code>.
</p>
<span class="conf-link" data-i18n="conf.read">Read →</span>
</a>
<a class="conf-card" href="configuration.html#skills">
<div class="conf-icon">◇</div>
<h3 data-i18n="conf.sk.title">Skills</h3>
<p data-i18n="conf.sk.body">
Markdown playbooks the model invokes. Inline or sub-agent. Project
overrides global.
</p>
<span class="conf-link" data-i18n="conf.read">Read →</span>
</a>
<a class="conf-card" href="configuration.html#memory">
<div class="conf-icon">∞</div>
<h3 data-i18n="conf.mem.title">Memory</h3>
<p data-i18n="conf.mem.body">
User-private knowledge pinned into the prefix. Global + project
scopes. Four typed shapes.
</p>
<span class="conf-link" data-i18n="conf.read">Read →</span>
</a>
<a class="conf-card" href="configuration.html#hooks">
<div class="conf-icon">⚙</div>
<h3 data-i18n="conf.hk.title">Hooks</h3>
<p data-i18n="conf.hk.body">
Shell commands on lifecycle events. Pre/post tool, prompt submit,
stop. Exit-2 to block.
</p>
<span class="conf-link" data-i18n="conf.read">Read →</span>
</a>
<a class="conf-card" href="configuration.html#permissions">
<div class="conf-icon">⊟</div>
<h3 data-i18n="conf.perm.title">Permissions</h3>
<p data-i18n="conf.perm.body">
Per-workspace shell allowlist. Exact-prefix match. Interactive
"always allow" persists.
</p>
<span class="conf-link" data-i18n="conf.read">Read →</span>
</a>
<a class="conf-card" href="configuration.html#search">
<div class="conf-icon">⌘</div>
<h3 data-i18n="conf.ws.title">Web search</h3>
<p data-i18n="conf.ws.body">
Mojeek by default, no setup. Switch to self-hosted SearXNG with
<code>/search-engine</code>.
</p>
<span class="conf-link" data-i18n="conf.read">Read →</span>
</a>
</div>
<p data-i18n="bench.repro.note">
Each committed JSONL transcript carries per-turn <code>usage</code>,
<code>cost</code>, and <code>prefixHash</code>. Reasonix's prefix hash stays
byte-stable across every model call — that's the whole game.
</p>
<p>
<a
class="cta ghost"
href="https://github.com/esengine/reasonix/tree/main/benchmarks"
target="_blank"
rel="noopener"
data-i18n="bench.link"
>Read the τ-bench-lite harness →</a
>
</p>
<div class="conf-cta">
<a class="cta primary" href="configuration.html" data-i18n="conf.cta">
Open the configuration guide →
</a>
</div>
</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>
@@ -604,5 +792,6 @@ npx reasonix prune-sessions <span class="hash"># <span data-i18n="c
<script src="i18n.js"></script>
<script src="term-anim.js"></script>
<script src="motion.js"></script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
/* Counter count-up — fires once when a `.counter` enters the viewport.
*
* That's it. No spotlight, no tilt, no scroll reveal, no stagger, no
* parallax. Static layout does the heavy lifting; this file exists only
* because metric numbers count up from zero, and that's a one-shot
* effect that stops as soon as the counter reaches its target. */
(function () {
"use strict";
var prefersReduced =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function format(n, decimals, prefix, suffix) {
var s = n.toFixed(decimals);
var parts = s.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return (prefix || "") + parts.join(".") + (suffix || "");
}
function fill(el) {
var target = parseFloat(el.getAttribute("data-target") || "0");
var decimals = parseInt(el.getAttribute("data-decimals") || "0", 10);
var prefix = el.getAttribute("data-prefix") || "";
var suffix = el.getAttribute("data-suffix") || "";
el.textContent = format(target, decimals, prefix, suffix);
}
function animate(el) {
var target = parseFloat(el.getAttribute("data-target") || "0");
var decimals = parseInt(el.getAttribute("data-decimals") || "0", 10);
var prefix = el.getAttribute("data-prefix") || "";
var suffix = el.getAttribute("data-suffix") || "";
var dur = 1400;
var t0 = 0;
function step(t) {
if (!t0) t0 = t;
var p = Math.min(1, (t - t0) / dur);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = format(target * eased, decimals, prefix, suffix);
if (p < 1) window.requestAnimationFrame(step);
else fill(el);
}
window.requestAnimationFrame(step);
}
function init() {
var nodes = document.querySelectorAll(".counter");
if (!nodes.length) return;
if (prefersReduced || !("IntersectionObserver" in window)) {
nodes.forEach(fill);
return;
}
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
animate(e.target);
io.unobserve(e.target);
}
});
},
{ threshold: 0.4 },
);
nodes.forEach(function (n) { io.observe(n); });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://esengine.github.io/DeepSeek-Reasonix/sitemap.xml
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
>
<url>
<loc>https://esengine.github.io/DeepSeek-Reasonix/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://esengine.github.io/DeepSeek-Reasonix/?lang=en" />
<xhtml:link rel="alternate" hreflang="zh-CN" href="https://esengine.github.io/DeepSeek-Reasonix/?lang=zh" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://esengine.github.io/DeepSeek-Reasonix/" />
</url>
<url>
<loc>https://esengine.github.io/DeepSeek-Reasonix/configuration.html</loc>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=en" />
<xhtml:link rel="alternate" hreflang="zh-CN" href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html" />
</url>
</urlset>
+461 -68
View File
@@ -108,30 +108,95 @@ kbd {
padding: 0 24px;
}
/* Background */
/* ── Background system ────────────────────────────────────────────────
* Three layers, all fixed and pointer-events:none:
* .bg-grid — faint grid lattice, masked to fade at the viewport edges
* .bg-glow — two static, very-soft color blobs (no drift, no scale —
* that's what made the page feel chaotic). Cursor parallax
* on `--gx` / `--gy` still nudges them by a few percent.
* .bg-noise — SVG fractal turbulence at ~4% opacity. Gives the dark
* surface a film-grain texture, which is what reads as
* "premium" and stops gradients looking flat.
* ────────────────────────────────────────────────────────────────────── */
/* Dot grid — single radial-gradient tile vs. two stacked linear-gradients
* for the line version. Reads more premium and is cheaper to paint. */
.bg-grid {
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(165, 180, 252, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(165, 180, 252, 0.04) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 70%);
background-image: radial-gradient(
circle,
rgba(165, 180, 252, 0.10) 1px,
transparent 1.4px
);
background-size: 28px 28px;
-webkit-mask-image: radial-gradient(
ellipse 70% 60% at 50% 25%,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0) 80%
);
mask-image: radial-gradient(
ellipse 70% 60% at 50% 25%,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0) 80%
);
pointer-events: none;
z-index: 0;
}
/* Three static color blobs anchored at top-left, top-right, and bottom
* center. Bottom blob gives the page floor a hint of horizon depth and
* keeps the lower viewport from looking flat. Zero animation. */
.bg-glow {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(800px circle at 20% 10%, rgba(94, 234, 212, 0.10), transparent 50%),
radial-gradient(700px circle at 90% 30%, rgba(196, 181, 253, 0.10), transparent 55%),
radial-gradient(600px circle at 50% 100%, rgba(240, 171, 252, 0.07), transparent 50%);
radial-gradient(
ellipse 55vmax 42vmax at 12% 16%,
rgba(94, 234, 212, 0.13),
transparent 60%
),
radial-gradient(
ellipse 50vmax 40vmax at 88% 20%,
rgba(196, 181, 253, 0.11),
transparent 62%
),
radial-gradient(
ellipse 90vmax 30vmax at 50% 110%,
rgba(165, 180, 252, 0.09),
transparent 70%
);
}
/* Faint horizon hairline — a single fixed gradient line that sits roughly
* at the hero / metrics seam. Adds a subtle depth break without animation. */
.bg-horizon {
position: fixed;
left: 0;
right: 0;
top: 64vh;
height: 1px;
pointer-events: none;
z-index: 0;
background: linear-gradient(
90deg,
transparent 0,
rgba(165, 180, 252, 0.18) 35%,
rgba(196, 181, 253, 0.18) 65%,
transparent 100%
);
opacity: 0.6;
}
/* The fractal-noise overlay was removed — even static, a fullscreen
* fixed layer adds a compositing pass on every scroll/resize. The dot
* grid + horizon hairline + glow blobs already give enough texture. */
/* The conic-gradient + blur + rotate effect was removed: animating a blur
* on a >100% surface repaints the entire viewport every frame. The
* orbital SVG already provides motion under the hero. */
main,
.nav,
.foot {
@@ -265,6 +330,7 @@ main,
/* Hero */
.hero {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
@@ -274,10 +340,82 @@ main,
align-items: center;
}
/* Orbital SVG: lives behind the hero text column, slowly rotates the rings,
* counter-rotates the dot ring so it feels alive but never busy. */
.hero-orbit {
position: absolute;
top: 50%;
left: -120px;
width: 620px;
height: 620px;
transform: translate3d(0, -50%, 0);
pointer-events: none;
z-index: 0;
opacity: 0.55;
mask-image: radial-gradient(circle, #000 35%, transparent 75%);
}
.hero-orbit svg {
width: 100%;
height: 100%;
overflow: visible;
}
/* Orbital SVG is now a static decoration. The composition reads as
* "system diagram" without needing motion — and the page no longer
* pays a continuous repaint cost for it. */
.hero-orbit .orb-rings,
.hero-orbit .orb-dots {
transform-origin: 0 0;
}
.hero-inner,
.hero-terminal {
position: relative;
z-index: 1;
}
.hero-inner {
min-width: 0;
}
.hero-pills {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 24px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.04em;
color: #c5fce8;
background: rgba(94, 234, 212, 0.08);
border: 1px solid rgba(94, 234, 212, 0.32);
padding: 5px 12px;
border-radius: 999px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #34d399;
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.7);
animation: status-pulse 1.8s ease-out infinite;
}
@keyframes status-pulse {
0% {
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.55);
}
70% {
box-shadow: 0 0 0 10px rgba(52, 211, 153, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(52, 211, 153, 0);
}
}
.badge {
display: inline-flex;
align-items: center;
@@ -290,7 +428,6 @@ main,
border: 1px solid rgba(165, 180, 252, 0.25);
padding: 5px 12px;
border-radius: 999px;
margin-bottom: 24px;
}
.hero-title {
@@ -301,13 +438,15 @@ main,
margin: 0 0 24px;
}
.grad-text {
/* Static gradient text — no shimmer animation. `background-position`
* animation forces a paint per frame for every clipped-text element. */
.grad-text,
.grad-text-anim {
background: linear-gradient(
90deg,
var(--grad-1) 0%,
var(--grad-2) 25%,
var(--grad-4) 60%,
var(--grad-6) 90%
var(--grad-3) 50%,
var(--grad-6) 100%
);
-webkit-background-clip: text;
background-clip: text;
@@ -409,27 +548,98 @@ main,
color: #fff;
}
.hero-stats {
/* ── Metrics strip ─────────────────────────────────────────────────────
* Lives directly under the hero. Counters animate from 0 → target the
* first time the strip enters the viewport (motion.js). On dark gradient
* panel, with subtle vertical dividers between cells. */
.metrics {
position: relative;
margin: 0 auto 80px;
max-width: var(--maxw);
padding: 0 32px;
z-index: 1;
}
.metrics-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 520px;
grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr;
align-items: center;
gap: 0;
/* No backdrop-filter — fullscreen-ish blurred backdrop forced a re-blur
* of underlying pixels every scroll frame. Plain solid panel reads
* almost identical at this opacity over the dark background. */
background: linear-gradient(
180deg,
rgba(20, 26, 46, 0.85),
rgba(13, 17, 28, 0.85)
);
border: 1px solid rgba(165, 180, 252, 0.18);
border-radius: 16px;
padding: 28px 32px;
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.stat-num {
.metric {
text-align: center;
min-width: 0;
}
.metric-num {
font-family: var(--font-mono);
font-size: 18px;
font-size: clamp(28px, 3.4vw, 42px);
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: 4px;
letter-spacing: -0.02em;
line-height: 1;
margin-bottom: 8px;
background: linear-gradient(
135deg,
var(--grad-1),
var(--grad-3) 50%,
var(--grad-6)
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.stat-label {
.metric-num.metric-static {
letter-spacing: 0.02em;
}
.metric-label {
font-size: 12.5px;
color: var(--fg-3);
line-height: 1.4;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.metric-divider {
width: 1px;
height: 56px;
background: linear-gradient(
180deg,
transparent,
rgba(165, 180, 252, 0.35) 50%,
transparent
);
}
.metrics-foot {
text-align: center;
margin: 14px 0 0;
font-size: 13px;
color: var(--fg-3);
}
.metrics-foot a {
color: var(--fg-3);
border-bottom: 1px dashed rgba(165, 180, 252, 0.3);
}
.metrics-foot a:hover {
color: var(--fg);
border-bottom-color: var(--accent-2);
}
/* The marquee was removed — a continuous translate at 48 s still costs a
* frame each, and the same information is conveyed by the feature grid. */
/* Hero terminal mock */
.hero-terminal {
position: relative;
background: var(--term-bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
@@ -441,6 +651,26 @@ main,
font-size: 13px;
line-height: 1.55;
}
.hero-terminal::after {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
pointer-events: none;
background: linear-gradient(
135deg,
rgba(94, 234, 212, 0.35),
rgba(196, 181, 253, 0.25) 50%,
rgba(240, 171, 252, 0.2)
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0.6;
}
.term-bar {
display: flex;
@@ -983,66 +1213,122 @@ main,
font-size: 14.5px;
}
/* Benchmarks */
.bench {
/* Configuration teaser — six cards linking into the dedicated guide.
* Card border uses a subtle gradient stroke; hover lifts + brightens.
*/
.configure {
padding: 80px 0;
background: linear-gradient(
180deg,
transparent,
rgba(20, 26, 46, 0.4) 50%,
transparent
);
}
.bench-table-wrap {
max-width: 880px;
margin: 0 auto 32px;
overflow-x: auto;
.conf-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
max-width: 1100px;
margin: 0 auto;
}
.bench-table {
width: 100%;
border-collapse: collapse;
.conf-card {
position: relative;
display: block;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px 26px;
text-decoration: none;
color: inherit;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
overflow: hidden;
}
.bench-table th,
.bench-table td {
text-align: left;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
.conf-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
135deg,
rgba(94, 234, 212, 0.0),
rgba(196, 181, 253, 0.0)
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
transition: background 0.25s ease;
}
.conf-card:hover {
transform: translateY(-3px);
border-color: rgba(165, 180, 252, 0.35);
box-shadow: 0 14px 32px rgba(8, 11, 18, 0.5);
}
.conf-card:hover::before {
background: linear-gradient(
135deg,
rgba(94, 234, 212, 0.55),
rgba(196, 181, 253, 0.55) 50%,
rgba(240, 171, 252, 0.5)
);
}
.conf-icon {
font-family: var(--font-mono);
font-size: 22px;
font-weight: 700;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
rgba(94, 234, 212, 0.18),
rgba(196, 181, 253, 0.18)
);
border: 1px solid rgba(165, 180, 252, 0.3);
border-radius: 10px;
color: var(--accent-2);
margin-bottom: 16px;
}
.conf-card h3 {
font-size: 17px;
font-weight: 700;
margin: 0 0 8px;
color: var(--fg);
}
.conf-card p {
margin: 0 0 14px;
color: var(--fg-2);
font-size: 14.5px;
line-height: 1.6;
}
.bench-table th {
background: rgba(20, 26, 46, 0.6);
color: var(--fg-2);
font-weight: 600;
font-size: 12.5px;
.conf-link {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--accent-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.bench-table tbody tr:last-child td {
border-bottom: 0;
}
.bench-table td.hi {
color: var(--grad-1);
font-weight: 600;
}
.bench p {
.conf-cta {
text-align: center;
color: var(--fg-2);
max-width: 720px;
margin: 0 auto 16px;
margin-top: 36px;
}
.bench .code {
max-width: 880px;
margin: 0 auto 24px;
@media (max-width: 960px) {
.conf-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.conf-grid {
grid-template-columns: 1fr;
}
}
/* Benchmarks */
/* CLI */
.cli {
padding: 80px 0;
@@ -1143,12 +1429,114 @@ main,
font-size: 13px;
}
/* ── Static affordances only ──────────────────────────────────────────
* No spotlight, no tilt, no scroll reveal, no stagger, no shimmer, no
* traveling beam, no hero-stat hover pulse. Just CSS hover hints and
* static decorations. The page should run with zero JS animation cost
* outside the one-shot counter and the term-anim demo. */
/* Cards — plain, scoped hover. Border accent + small lift. */
.why-card,
.feat,
.conf-card {
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.why-card:hover,
.feat:hover,
.conf-card:hover {
border-color: rgba(165, 180, 252, 0.35);
transform: translateY(-2px);
background: rgba(20, 26, 46, 0.6);
}
/* Brand mark hover halo — single tiny element. */
.nav-brand:hover .brand-mark {
filter: drop-shadow(0 0 8px rgba(196, 181, 253, 0.45));
}
/* Faint static scanlines on the hero terminal — pure paint at first
* paint only (no animation). */
.hero-terminal::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
180deg,
rgba(165, 180, 252, 0.025) 0,
rgba(165, 180, 252, 0.025) 1px,
transparent 1px,
transparent 3px
);
opacity: 0.4;
z-index: 1;
}
/* Static section divider — gradient hairline at the top of each major
* section. No traveling pulse, no animation. */
.why,
.configure,
.features,
.cli,
.community,
.cta-band,
.metrics {
position: relative;
}
.why::before,
.configure::before,
.features::before,
.cli::before,
.community::before,
.cta-band::before,
.metrics::before {
content: "";
position: absolute;
top: 0;
left: 12%;
right: 12%;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(165, 180, 252, 0.16) 25%,
rgba(196, 181, 253, 0.16) 75%,
transparent
);
pointer-events: none;
}
/* Static section-title accent — small fixed-width gradient bar under H2. */
.section-title {
position: relative;
}
.section-title::after {
content: "";
display: block;
margin: 14px auto 0;
width: 56px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--grad-1), var(--grad-5), var(--grad-7));
}
/* Responsive */
@media (max-width: 960px) {
.hero {
grid-template-columns: 1fr;
gap: 40px;
}
.hero-orbit {
display: none;
}
.metrics-row {
grid-template-columns: 1fr 1fr;
row-gap: 24px;
padding: 24px 20px;
}
.metric-divider {
display: none;
}
.why-grid,
.feat-grid {
grid-template-columns: repeat(2, 1fr);
@@ -1196,7 +1584,8 @@ main,
}
}
/* Reduced motion */
/* Reduced motion — kill drift / shimmer / reveal animations,
* but keep `data-reveal` content visible (otherwise the page stays blank). */
@media (prefers-reduced-motion: reduce) {
.diamond,
*,
@@ -1208,4 +1597,8 @@ main,
html {
scroll-behavior: auto;
}
[data-reveal] {
opacity: 1 !important;
transform: none !important;
}
}
+562 -450
View File
File diff suppressed because it is too large. Load diff
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.27.1",
"version": "0.37.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
@@ -72,11 +72,12 @@
"commander": "^12.1.0",
"eventsource-parser": "^3.0.0",
"ignore": "^7.0.5",
"ink": "^7.0.2",
"ink-text-input": "^6.0.0",
"node-html-parser": "^7.1.0",
"picomatch": "^4.0.4",
"react": "^18.3.1",
"react-reconciler": "^0.29.2",
"react": "^19.2.6",
"string-width": "^7.2.0",
"yoga-layout": "^3.2.1",
"zod": "^4.4.1"
},
"devDependencies": {
@@ -85,14 +86,12 @@
"@stryker-mutator/vitest-runner": "^9.6.1",
"@types/node": "^22.9.0",
"@types/picomatch": "^4.0.3",
"@types/react": "^18.3.12",
"@types/react-reconciler": "^0.28.9",
"@types/react": "^19.2.14",
"@vitest/coverage-v8": "^2.1.5",
"esbuild": "^0.21.5",
"highlight.js": "^11.10.0",
"htm": "^3.1.1",
"ink": "^5.1.0",
"ink-text-input": "^6.0.0",
"ink-testing-library": "^4.0.0",
"marked": "^15.0.12",
"preact": "^10.22.0",
"simple-git-hooks": "^2.13.1",
+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();
}
+323 -210
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,22 +523,28 @@ 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] ?? "";
// Strip trailing dot (sentence terminator): `@foo.ts.` → `@foo.ts`.
// Keep internal dots intact.
const cleaned = rawPath.replace(/\.+$/, "");
// Keep internal dots intact. Manual loop instead of `/\.+$/` — the
// 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);
}
@@ -333,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 {
@@ -348,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)) {
@@ -363,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 {
@@ -391,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;
@@ -401,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";
+127 -47
View File
@@ -1,10 +1,17 @@
import { render } from "ink";
import React, { useState } from "react";
import { loadApiKey, readConfig, searchEnabled } from "../../config.js";
import {
loadApiKey,
readConfig,
searchEnabled,
webSearchEndpoint,
webSearchEngine,
} from "../../config.js";
import { loadDotenv } from "../../env.js";
import type { CacheFirstLoop } from "../../loop.js";
import { McpClient } from "../../mcp/client.js";
import { type InspectionReport, inspectMcpServer } from "../../mcp/inspect.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { type McpClientHost, bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
@@ -21,9 +28,11 @@ 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";
import { drainTtyResponses } from "../ui/drain-tty.js";
import { KeystrokeProvider } from "../ui/keystroke-context.js";
import { formatMcpLifecycleEvent } from "../ui/mcp-lifecycle.js";
import { formatMcpSlowToast } from "../ui/mcp-toast.js";
@@ -53,6 +62,51 @@ interface RuntimeContext {
progressSink: { current: ((info: ProgressInfo) => void) | null };
}
export type McpLifecycleNotice =
| { kind: "handshake"; name: string }
| {
kind: "connected";
name: string;
tools: number;
resources: number;
prompts: number;
ms: number;
}
| { kind: "disabled"; name: string }
| { kind: "failed"; name: string; reason: string }
| { kind: "slow"; serverName: string; p95Ms: number; sampleSize: number };
export type McpLifecycleSink = (notice: McpLifecycleNotice) => void;
const stderrLifecycleSink: McpLifecycleSink = (n) => {
if (n.kind === "slow") {
process.stderr.write(
`${formatMcpSlowToast({ name: n.serverName, p95Ms: n.p95Ms, sampleSize: n.sampleSize })}\n`,
);
return;
}
if (n.kind === "failed") {
process.stderr.write(
`${formatMcpLifecycleEvent({ state: "failed", name: n.name, reason: n.reason })}\n → run \`reasonix setup\` to remove this entry, or fix the underlying issue (missing npm package, network, etc.).\n`,
);
return;
}
if (n.kind === "connected") {
process.stderr.write(
`${formatMcpLifecycleEvent({
state: "connected",
name: n.name,
tools: n.tools,
resources: n.resources,
prompts: n.prompts,
ms: n.ms,
})}\n`,
);
return;
}
process.stderr.write(`${formatMcpLifecycleEvent({ state: n.kind, name: n.name })}\n`);
};
export interface McpRuntime {
size(): number;
specs(): string[];
@@ -69,11 +123,14 @@ export interface McpRuntime {
summaries: McpServerSummary[];
}>;
closeAll(): Promise<void>;
/** Replace the sink that lifecycle events flow through — App.tsx swaps this in on mount so toasts land in the alt-screen UI instead of corrupting it via stderr. */
setLifecycleSink(sink: McpLifecycleSink): void;
}
function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
const records = new Map<string, SpecRecord>();
const insertionOrder: string[] = [];
let sink: McpLifecycleSink = stderrLifecycleSink;
async function addSpec(
raw: string,
@@ -91,16 +148,17 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
const spec = parseMcpSpec(raw);
label = spec.name ?? "anon";
if (spec.name && disabledNames.has(spec.name)) {
process.stderr.write(`${formatMcpLifecycleEvent({ state: "disabled", name: label })}\n`);
sink({ kind: "disabled", name: label });
return { ok: false, reason: "disabled by user" };
}
process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`);
sink({ kind: "handshake", name: label });
const t0 = Date.now();
const namePrefix = spec.name
? `${spec.name}_`
: ctx.getRequestedCount() === 1 && ctx.getMcpPrefix()
? (ctx.getMcpPrefix() as string)
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
@@ -117,9 +175,12 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
host,
onProgress: (info) => ctx.progressSink.current?.(info),
onSlow: (info) =>
process.stderr.write(
`${formatMcpSlowToast({ name: info.serverName, p95Ms: info.p95Ms, sampleSize: info.sampleSize })}\n`,
),
sink({
kind: "slow",
serverName: info.serverName,
p95Ms: info.p95Ms,
sampleSize: info.sampleSize,
}),
});
let report: InspectionReport;
try {
@@ -138,16 +199,14 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
const ms = Date.now() - t0;
const resourceCount = report.resources.supported ? report.resources.items.length : 0;
const promptCount = report.prompts.supported ? report.prompts.items.length : 0;
process.stderr.write(
`${formatMcpLifecycleEvent({
state: "connected",
name: label,
tools: bridge.registeredNames.length,
resources: resourceCount,
prompts: promptCount,
ms,
})}\n`,
);
sink({
kind: "connected",
name: label,
tools: bridge.registeredNames.length,
resources: resourceCount,
prompts: promptCount,
ms,
});
const summary = buildMcpServerSummary({
label,
spec: raw,
@@ -176,9 +235,7 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
} catch (err) {
await mcp?.close().catch(() => undefined);
const reason = (err as Error).message;
process.stderr.write(
`${formatMcpLifecycleEvent({ state: "failed", name: label, reason })}\n → run \`reasonix setup\` to remove this entry, or fix the underlying issue (missing npm package, network, etc.).\n`,
);
sink({ kind: "failed", name: label, reason });
return { ok: false, reason };
}
}
@@ -239,6 +296,9 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
records.clear();
insertionOrder.length = 0;
}
function setLifecycleSink(s: McpLifecycleSink): void {
sink = s;
}
return {
size: () => records.size,
specs,
@@ -247,6 +307,7 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
removeSpec,
reloadFromConfig,
closeAll,
setLifecycleSink,
};
}
@@ -254,8 +315,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
@@ -289,6 +348,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;
@@ -300,6 +361,21 @@ export interface ChatOptions {
* URL is visible in the status bar.
*/
noDashboard?: boolean;
/**
* Render into the terminal's alternate screen buffer. Default true —
* alt-screen avoids the scrollback-mode resize/wrap ghost class. Pass
* false (CLI: `--no-alt-screen`) when the chat output needs to remain
* in shell scrollback after exit.
*/
altScreen?: boolean;
/**
* Enable DECSET 1007 (alternate-scroll) so the wheel scrolls chat on
* web/cloud/SSH terminals — terminal translates wheel events to ↑/↓
* key sequences in alt-screen, no full mouse tracking, native
* drag-select + right-click unaffected. Default true. Pass false
* (CLI: `--no-mouse`) to suppress entirely.
*/
mouse?: boolean;
}
interface RootProps extends ChatOptions {
@@ -387,8 +463,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}
@@ -398,6 +472,7 @@ function Root({
progressSink={progressSink}
codeMode={appProps.codeMode}
noDashboard={appProps.noDashboard}
mouse={appProps.mouse}
onSwitchSession={setActiveSession}
/>
</KeystrokeProvider>
@@ -405,8 +480,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
@@ -428,23 +505,20 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
progressSink,
});
const failedSpecs: Array<{ spec: string; reason: string }> = [];
for (const raw of requestedSpecs) {
const result = await runtime.addSpec(raw);
if (!result.ok) failedSpecs.push({ spec: raw, reason: result.reason });
}
if (runtime.size() === 0 && !opts.seedTools) {
tools = undefined;
}
const mcpSpecs = runtime.specs();
const mcpServers = runtime.summaries();
// MCP bridging deferred to App.tsx mount — handshakes are 100ms–2s each
// and we don't want the alt-screen UI to block on the slowest one.
const mcpSpecs = [...requestedSpecs];
const mcpServers: McpServerSummary[] = [];
// Register web search/fetch tools unless explicitly disabled. DDG
// backs them with no key required; the model invokes them whenever
// a question needs info fresher than its training data.
if (searchEnabled()) {
if (!tools) tools = new ToolRegistry();
registerWebTools(tools);
registerWebTools(tools, {
webSearchEngine: webSearchEngine(),
webSearchEndpoint: webSearchEndpoint(),
});
}
// Memory tools — available in every session, not just code mode.
@@ -475,17 +549,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
const showPicker =
!opts.session && !opts.forceResume && listSessionsForWorkspace(launchWorkspace).length > 0;
// No startup clear, no resize listener. Earlier attempts wrote
// various combinations of \x1b[2J / \x1b[3J / cursor-home to
// present a 'clean canvas' on launch and to neutralize Ink's
// eraseLines miscount on resize, but on xterm.js-based terminals
// (VSCode integrated terminal in particular) those sequences
// interfere with scrollback in ways that make wheel-up scroll
// dead. Letting Ink mount directly leaves the user's previous
// shell prompt visible above (scrolling up just works) and
// accepts the resize-ghost / launch-noise tradeoffs as known
// limitations — `/clear` is the manual reset.
markPhase("ink_render_call");
const { waitUntilExit } = render(
<Root
initialKey={initialKey}
@@ -498,12 +562,28 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
{...opts}
session={resolvedSession}
/>,
// patchConsole:false — winpty/MINTTY redraw-glitch source.
{ exitOnCtrlC: true, patchConsole: false },
{
exitOnCtrlC: true,
// patchConsole:false — winpty/MINTTY redraw-glitch source.
patchConsole: false,
// incrementalRendering:false — Ink's diff drifts when stringWidth
// misjudges CJK / emoji ZWJ width or when async terminal-event
// bytes interleave mid-render, leaving residual rows. Full-frame
// redraws cost more stdout bytes per flush but eliminate the
// ghost class.
incrementalRendering: false,
// Default true — alt-screen is the only mode without scrollback-
// reflow ghosting. `--no-alt-screen` opts back into scrollback mode
// for users who need chat output preserved in shell history on exit.
alternateScreen: opts.altScreen !== false,
},
);
try {
await waitUntilExit();
} finally {
await runtime.closeAll();
// Eat any pending terminal-feature-detection responses (#365) so the
// parent shell doesn't print them as junk after exit.
await drainTtyResponses();
}
}
+48 -12
View File
@@ -22,6 +22,7 @@ import { readFileSync } from "node:fs";
import { basename, resolve } from "node:path";
import { loadEditMode, loadProjectShellAllowed, readConfig } from "../../config.js";
import { bootstrapSemanticSearchInCodeMode } from "../../index/semantic/tool.js";
import { detectForeignAgentPlatform } from "../../memory/project.js";
import { sanitizeName } from "../../memory/session.js";
import { ToolRegistry } from "../../tools.js";
import { registerChoiceTool } from "../../tools/choice.js";
@@ -29,7 +30,10 @@ import { registerFilesystemTools } from "../../tools/filesystem.js";
import { JobRegistry } from "../../tools/jobs.js";
import { registerMemoryTools } from "../../tools/memory.js";
import { registerPlanTool } from "../../tools/plan.js";
import { registerScaffoldTools } from "../../tools/scaffold.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 +49,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
@@ -66,9 +61,14 @@ export interface CodeOptions {
systemAppend?: string;
/** Path to a UTF-8 text file whose contents are appended to the code system prompt. */
systemAppendFile?: string;
/** Default true. Pass false (CLI: `--no-alt-screen`) to keep chat output in shell scrollback. */
altScreen?: boolean;
/** Default true. Pass false (CLI: `--no-mouse`) to keep terminal-native drag-select unmodified. */
mouse?: boolean;
}
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.
@@ -113,6 +113,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
@@ -125,6 +135,14 @@ 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);
// `create_skill` / `add_mcp_server` — let the model scaffold from chat.
// Both writes go through the same paths the wizard / `/skill new` use,
// so the on-disk shape stays one source of truth. New servers take
// effect on next launch (no live client churn).
registerScaffoldTools(tools, { projectRoot: rootDir });
// `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
@@ -135,7 +153,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)${
@@ -143,6 +165,13 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
}\n`,
);
const foreign = detectForeignAgentPlatform(rootDir);
if (foreign) {
process.stderr.write(
`⚠ workspace contains another agent platform's files (${foreign.join(", ")}). Reasonix Code may read them as project content; relaunch with --dir <your-project> if that's not what you want.\n`,
);
}
// Belt-and-suspenders cleanup: even though spawn(detached:false)
// should tie child processes to the parent's lifetime, Windows cmd.exe
// wrappers occasionally leak. We DON'T install SIGINT/SIGTERM
@@ -175,20 +204,27 @@ 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,
systemAppend: opts.systemAppend,
systemAppendFile: systemAppendFileContents,
modelId: opts.model ?? "deepseek-v4-flash",
}),
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,
noDashboard: opts.noDashboard,
altScreen: opts.altScreen,
mouse: opts.mouse,
});
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { join } from "node:path";
import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { DeepSeekClient } from "../../client.js";
import { loadApiKey } from "../../config.js";
import { loadApiKey, loadBaseUrl } from "../../config.js";
import { loadDotenv } from "../../env.js";
export interface CommitOptions {
@@ -267,7 +267,7 @@ export async function commitCommand(opts: CommitOptions = {}): Promise<void> {
);
}
const client = new DeepSeekClient({ apiKey });
const client = new DeepSeekClient({ apiKey, baseUrl: loadBaseUrl() });
const model = opts.model ?? DEFAULT_MODEL;
const recentCommits = readRecentCommits();
+68 -43
View File
@@ -1,15 +1,21 @@
/** 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,
loadBaseUrl,
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";
@@ -127,7 +133,7 @@ async function checkApiReach(): Promise<Check> {
};
}
try {
const client = new DeepSeekClient({ apiKey: key });
const client = new DeepSeekClient({ apiKey: key, baseUrl: loadBaseUrl() });
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), 8_000);
let balance: Awaited<ReturnType<DeepSeekClient["getBalance"]>>;
@@ -169,32 +175,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 +253,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 +261,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
+8
View File
@@ -80,6 +80,14 @@ function detailsFor(e: Event): string {
return `${e.callId} reason=${e.reason}`;
case "tool.result":
return `${e.callId} ${e.ok ? "ok" : "err"} ${e.output.length}B${e.truncated ? " [trunc]" : ""}`;
case "tool.call":
return `${e.name} args=${truncate(JSON.stringify(e.args), 60)}`;
case "tool.confirm.allow":
return `${e.kind} ${quote(e.payload.command, 60)}`;
case "tool.confirm.deny":
return `${e.kind} ${quote(e.payload.command, 60)}${e.denyContext ? ` — ${truncate(e.denyContext, 40)}` : ""}`;
case "tool.confirm.always_allow":
return `${e.kind} ${quote(e.payload.command, 60)} prefix=${truncate(e.prefix, 30)}`;
case "effect.file.touched":
return `${e.mode} ${e.path} (${e.bytes}B)`;
case "effect.memory.written":
+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() {
-1
View File
@@ -1,7 +1,6 @@
/** `reasonix mcp browse` — Ink TUI for the MCP marketplace. Lazy-loads pages on scroll. */
import { Box, Text, render, useApp, useInput } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { readConfig, writeConfig } from "../../config.js";
import { loadDotenv } from "../../env.js";
+28
View File
@@ -1,6 +1,7 @@
import { McpClient } from "../../mcp/client.js";
import { inspectMcpServer } from "../../mcp/inspect.js";
import type { InspectionReport } from "../../mcp/inspect.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
import { type McpTransport, StdioTransport } from "../../mcp/stdio.js";
@@ -15,6 +16,7 @@ export interface McpInspectOptions {
export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void> {
const spec = parseMcpSpec(opts.spec);
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
@@ -35,6 +37,32 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
}
}
export function formatMcpInspectFailure(err: unknown): string {
const error = err instanceof Error ? err : new Error(String(err));
const message = error.message;
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
const command = message.match(/^spawn\s+([^\s]+)\s+ENOENT$/)?.[1] ?? "the command";
return `${message} — try: install or verify \`${command}\`, then check the MCP spec's command spelling`;
}
if (code === "ECONNREFUSED") {
const target = message.match(/\b(https?:\/\/\S+|\d+\.\d+\.\d+\.\d+:\d+|localhost:\d+)\b/i)?.[1];
return `${message} — try: confirm ${target ?? "the MCP server"} is running and the host/port match the spec`;
}
if (/^MCP request initialize \(id=\d+\) timed out after \d+ms$/.test(message)) {
return `${message} — try: confirm the target speaks MCP and completes the handshake before the request timeout`;
}
if (/^(empty MCP spec|MCP spec ".*" has name but no command)/.test(message)) {
return `${message} — try: pass \`name=command args\` or an http(s):// URL`;
}
return message;
}
function formatReport(nsName: string, r: InspectionReport): string {
const lines: string[] = [];
lines.push(`MCP server [${nsName}]`);
-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)}`);
+5 -8
View File
@@ -5,12 +5,14 @@ import {
defaultConfigPath,
isPlausibleKey,
loadApiKey,
loadBaseUrl,
readConfig,
saveApiKey,
} from "../../config.js";
import { loadDotenv } from "../../env.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
import { McpClient } from "../../mcp/client.js";
import { preflightStdioSpec } from "../../mcp/preflight.js";
import { bridgeMcpTools } from "../../mcp/registry.js";
import { parseMcpSpec } from "../../mcp/spec.js";
import { SseTransport } from "../../mcp/sse.js";
@@ -26,10 +28,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;
@@ -60,7 +58,7 @@ async function ensureApiKey(): Promise<string> {
const answer = (await rl.question("API key › ")).trim();
if (!answer) continue;
if (!isPlausibleKey(answer)) {
process.stdout.write("Invalid format. Keys start with 'sk-' and are 30+ chars.\n");
process.stdout.write("Key looks too short. Paste the full token (16+ chars, no spaces).\n");
continue;
}
saveApiKey(answer);
@@ -103,6 +101,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
: requestedSpecs.length === 1 && opts.mcpPrefix
? opts.mcpPrefix
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
const transport: McpTransport =
spec.transport === "sse"
? new SseTransport({ url: spec.url })
@@ -144,7 +143,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
if (successCount === 0) tools = undefined;
}
const client = new DeepSeekClient();
const client = new DeepSeekClient({ baseUrl: loadBaseUrl() });
const prefix = new ImmutablePrefix({
system: opts.system,
toolSpecs: tools?.specs(),
@@ -154,8 +153,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;
+1 -1
View File
@@ -30,7 +30,7 @@ export async function setupCommand(_opts: SetupOptions = {}): Promise<void> {
const { waitUntilExit, unmount } = render(
<Wizard
existingApiKey={existingKey}
initial={{ preset: existing.preset, mcp: existing.mcp }}
initial={{ preset: existing.preset, mcp: existing.mcp, theme: existing.theme }}
onComplete={() => {
// Ink handles its own enter-to-exit inside the "saved" step; we
// just wait for the app to exit naturally.
+73 -15
View File
@@ -1,7 +1,19 @@
import { spawn } from "node:child_process";
import { VERSION, compareVersions, getLatestVersion, isNpxInstall } from "../../version.js";
import {
type InstallSource,
VERSION,
compareVersions,
detectInstallSource,
detectNpmInstallPrefix,
getLatestVersion,
} from "../../version.js";
export type UpdateAction = "up-to-date" | "newer-local" | "npx-hint" | "run-npm-install";
export type UpdateAction =
| "up-to-date"
| "newer-local"
| "npx-hint"
| "manual-hint"
| "run-install";
export interface UpdatePlan {
action: UpdateAction;
@@ -13,10 +25,18 @@ export interface UpdatePlan {
export interface PlanUpdateInput {
current: string;
latest: string;
/** Overrides `isNpxInstall()` (tests). */
npx?: boolean;
installSource: InstallSource;
/** Pin npm to this prefix so nvm/fnm can't redirect the install. */
npmPrefix?: string | null;
}
export const MANUAL_UPDATE_COMMANDS: readonly string[] = [
"npm install -g reasonix@latest",
"bun add -g reasonix",
"pnpm add -g reasonix@latest",
"yarn global add reasonix@latest",
];
/** Pure decision — split out so tests don't need to spawn child processes or hit the network. */
export function planUpdate(input: PlanUpdateInput): UpdatePlan {
const diff = compareVersions(input.current, input.latest);
@@ -29,7 +49,7 @@ export function planUpdate(input: PlanUpdateInput): UpdatePlan {
if (diff === 0) {
return { action: "up-to-date", message: `reasonix ${input.current} is up to date.` };
}
if (input.npx) {
if (input.installSource === "npx") {
return {
action: "npx-hint",
message: [
@@ -40,20 +60,51 @@ export function planUpdate(input: PlanUpdateInput): UpdatePlan {
].join("\n"),
};
}
if (input.installSource === "unknown") {
return {
action: "manual-hint",
message: [
`reasonix ${input.latest} is available, but the install source could not be determined automatically.`,
"run one of these manually based on how you installed reasonix:",
...MANUAL_UPDATE_COMMANDS.map((c) => ` ${c}`),
].join("\n"),
};
}
const command = buildUpdateCommand(input.installSource, input.npmPrefix ?? null);
return {
action: "run-npm-install",
message: `upgrading reasonix ${input.current} → ${input.latest}`,
command: ["npm", "install", "-g", "reasonix@latest"],
action: "run-install",
message: `upgrading reasonix ${input.current} → ${input.latest} (via ${input.installSource})`,
command,
};
}
function buildUpdateCommand(
source: Exclude<InstallSource, "npx" | "unknown">,
npmPrefix: string | null,
): string[] {
switch (source) {
case "npm":
return npmPrefix
? ["npm", "--prefix", npmPrefix, "install", "-g", "reasonix@latest"]
: ["npm", "install", "-g", "reasonix@latest"];
case "bun":
return ["bun", "add", "-g", "reasonix"];
case "pnpm":
return ["pnpm", "add", "-g", "reasonix@latest"];
case "yarn":
return ["yarn", "global", "add", "reasonix@latest"];
}
}
export interface UpdateCommandOptions {
/** Skip spawning npm; print the decision only. */
/** Skip spawning the package manager; print the decision only. */
dryRun?: boolean;
/** Test seam: override the registry lookup. Returns null = offline. */
fetchLatest?: () => Promise<string | null>;
/** Test seam: override the npx detector. */
isNpx?: () => boolean;
/** Test seam: override the install-source detector. */
detectSource?: () => InstallSource;
/** Test seam: override the npm prefix detector. */
detectPrefix?: () => string | null;
/** Test seam: override the spawner. Must return exit code. */
spawnInstall?: (argv: string[]) => Promise<number>;
/** Test seam: stdout writer. */
@@ -82,7 +133,8 @@ export async function updateCommand(opts: UpdateCommandOptions = {}): Promise<vo
const write = opts.write ?? ((m: string) => process.stdout.write(m));
const exit = opts.exit ?? ((c: number) => process.exit(c));
const fetchLatest = opts.fetchLatest ?? (() => getLatestVersion({ force: true }));
const isNpx = opts.isNpx ?? isNpxInstall;
const detectSource = opts.detectSource ?? (() => detectInstallSource());
const detectPrefix = opts.detectPrefix ?? (() => detectNpmInstallPrefix());
const doSpawn = opts.spawnInstall ?? defaultSpawn;
write(`current: reasonix ${VERSION}\n`);
@@ -94,10 +146,16 @@ export async function updateCommand(opts: UpdateCommandOptions = {}): Promise<vo
}
write(`latest: reasonix ${latest}\n`);
const plan = planUpdate({ current: VERSION, latest, npx: isNpx() });
const installSource = detectSource();
const npmPrefix = installSource === "npm" ? detectPrefix() : null;
const plan = planUpdate({ current: VERSION, latest, installSource, npmPrefix });
write(`\n${plan.message}\n`);
if (plan.action !== "run-npm-install" || !plan.command) return;
if (plan.action === "manual-hint") {
exit(1);
return;
}
if (plan.action !== "run-install" || !plan.command) return;
if (opts.dryRun) {
write(`(dry run) would run: ${plan.command.join(" ")}\n`);
return;
@@ -105,7 +163,7 @@ export async function updateCommand(opts: UpdateCommandOptions = {}): Promise<vo
write(`\nrunning: ${plan.command.join(" ")}\n`);
const code = await doSpawn(plan.command);
if (code !== 0) {
write(`\nnpm exited with code ${code}. upgrade did not complete.\n`);
write(`\n${plan.command[0]} exited with code ${code}. upgrade did not complete.\n`);
exit(code);
}
}
+57 -49
View File
@@ -4,10 +4,14 @@ import { t } from "../i18n/index.js";
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 { escalationContract } from "../prompt-fragments.js";
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
import { markPhase } from "./startup-profile.js";
const DEFAULT_SYSTEM = `You are Reasonix, a helpful DeepSeek-powered assistant. Be concise and accurate. Use tools when available.
markPhase("cli_module_loaded");
function defaultSystemPrompt(modelId: string): string {
return `You are Reasonix, a helpful DeepSeek-powered assistant. Be concise and accurate. Use tools when available.
# Cite or shut up — non-negotiable
@@ -27,25 +31,8 @@ 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 { 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";
${escalationContract(modelId)}`;
}
/** Lenient: malformed → undefined (no cap) so a bad flag doesn't abort launch. */
function parseBudgetFlag(raw: number | undefined): number | undefined {
@@ -74,6 +61,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 +72,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,
system: applyMemoryStack(defaultSystemPrompt(defaults.model), process.cwd()),
session: continueOpts.session,
mcp: defaults.mcp,
forceResume: continueOpts.forceResume,
@@ -99,6 +86,7 @@ program
.command("setup")
.description(t("cli.setup"))
.action(async () => {
const { setupCommand } = await import("./commands/setup.js");
await setupCommand({});
});
@@ -110,12 +98,14 @@ 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("--no-mouse", "disable SGR mouse tracking (keeps drag-select 100% native)")
.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,
@@ -123,11 +113,12 @@ program
transcript: opts.transcript,
forceResume: !!opts.resume,
forceNew: !!opts.new,
harvest: !!opts.harvest,
budgetUsd: parseBudgetFlag(opts.budget),
noDashboard: opts.dashboard === false,
systemAppend: opts.systemAppend,
systemAppendFile: opts.systemAppendFile,
altScreen: opts.altScreen !== false,
mouse: opts.mouse !== false,
});
});
@@ -135,11 +126,9 @@ program
.command("chat")
.description(t("cli.chat"))
.option("-m, --model <id>", t("ui.modelIdHint"))
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
.option("-s, --system <prompt>", t("ui.systemPromptHint"))
.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"))
@@ -155,11 +144,11 @@ program
.option("--mcp-prefix <str>", t("ui.mcpPrefixHint"))
.option("--no-config", t("ui.noConfigHint"))
.option("--no-dashboard", t("ui.noDashboard"))
.option("--no-alt-screen", "keep chat output in shell scrollback (legacy mode, ghost-prone)")
.option("--no-mouse", "disable SGR mouse tracking (keeps drag-select 100% native)")
.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,
@@ -177,12 +166,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()),
system: applyMemoryStack(opts.system ?? defaultSystemPrompt(defaults.model), process.cwd()),
transcript: opts.transcript,
harvest: defaults.harvest,
branch: defaults.branch,
budgetUsd: parseBudgetFlag(opts.budget),
session: continueOpts.session,
mcp: defaults.mcp,
@@ -190,6 +178,8 @@ program
forceResume: continueOpts.forceResume,
forceNew: !!opts.new,
noDashboard: opts.dashboard === false,
altScreen: opts.altScreen !== false,
mouse: opts.mouse !== false,
});
});
@@ -197,10 +187,8 @@ program
.command("run <task>")
.description(t("cli.run"))
.option("-m, --model <id>", t("ui.modelIdHint"))
.option("-s, --system <prompt>", t("ui.systemPromptHint"), DEFAULT_SYSTEM)
.option("-s, --system <prompt>", t("ui.systemPromptHint"))
.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(
@@ -214,18 +202,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,
system: applyMemoryStack(opts.system ?? defaultSystemPrompt(defaults.model), process.cwd()),
budgetUsd: parseBudgetFlag(opts.budget),
transcript: opts.transcript,
mcp: defaults.mcp,
@@ -236,7 +221,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 });
});
@@ -244,6 +230,7 @@ program
.command("doctor")
.description(t("cli.doctor"))
.action(async () => {
const { doctorCommand } = await import("./commands/doctor.js");
await doctorCommand();
});
@@ -253,6 +240,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 });
});
@@ -260,7 +248,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 });
});
@@ -269,7 +258,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 });
});
@@ -281,7 +271,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,
@@ -299,6 +290,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,
@@ -316,6 +308,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,
@@ -340,6 +333,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,
@@ -363,6 +357,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,
@@ -383,6 +378,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:
@@ -399,6 +395,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`);
@@ -411,21 +408,31 @@ 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) {
process.stderr.write(`mcp inspect failed: ${(err as Error).message}\n`);
process.stderr.write(`mcp inspect failed: ${formatMcpInspectFailure(err)}\n`);
process.exit(1);
}
});
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 });
});
@@ -445,6 +452,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;
}
+1249 -995
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>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useEffect, useState } from "react";
import { FG, TONE } from "./theme/tokens.js";
const REASONIX_LOGO = [
"██████╗ ███████╗ █████╗ ███████╗ ██████╗ ███╗ ██╗██╗██╗ ██╗",
"██╔══██╗██╔════╝██╔══██╗██╔════╝██╔═══██╗████╗ ██║██║╚██╗██╔╝",
"██████╔╝█████╗ ███████║███████╗██║ ██║██╔██╗ ██║██║ ╚███╔╝ ",
"██╔══██╗██╔══╝ ██╔══██║╚════██║██║ ██║██║╚██╗██║██║ ██╔██╗ ",
"██║ ██║███████╗██║ ██║███████║╚██████╔╝██║ ╚████║██║██╔╝ ██╗",
"╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝╚═╝ ╚═╝",
];
const WHALE_LINES = [
" _____:_____",
" __.-'' ''-.__",
" ,-' ░░▒▒▒▒▒▒▒▒▒▒▒▒░░ '-.",
" ,' ░▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒░ '\\",
" / ░▒▓▓▓▓◉▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░ '\\___",
" | ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░ '\\\\__",
" | ░▒▓▓▓▓▓▓ ‿‿‿ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░ \\\\__\\",
" | ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░ //__/",
" \\ ░▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒░ //",
" '\\. ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ __,/'",
" '-..___ ___..-'",
" '''---..........---'''",
];
const SPOUT_FRAMES: ReadonlyArray<readonly string[]> = [
[" ", " ", " "],
[" ", " ", " ."],
[" ", " .", " :"],
[" .", " :", " :"],
[" . ' .", " :", " :"],
[" ' . '", " '", " :"],
[" . .", " '", " "],
];
const WAVE_SOURCE = "~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^~^";
const WAVE_WIDTH = 44;
const FRAME_MS = 200;
export function BootSplash(): React.ReactElement {
const [frame, setFrame] = useState(0);
useEffect(() => {
const t = setInterval(() => setFrame((f) => f + 1), FRAME_MS);
return () => clearInterval(t);
}, []);
const spout = SPOUT_FRAMES[frame % SPOUT_FRAMES.length] as readonly string[];
const waveOffset = frame % 4;
const wave = WAVE_SOURCE.slice(waveOffset, waveOffset + WAVE_WIDTH);
const dots = ".".repeat((frame % 4) + 1);
return (
<Box flexDirection="column" alignItems="center" marginY={1}>
<Box flexDirection="column" alignItems="flex-start" marginBottom={1}>
{REASONIX_LOGO.map((line) => (
<Text key={line} color={TONE.brand} bold>
{line}
</Text>
))}
</Box>
<Box flexDirection="column" alignItems="flex-start">
{spout.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length spout column, position is the identity
<Text key={i} color={TONE.accent}>
{line.length > 0 ? line : " "}
</Text>
))}
{WHALE_LINES.map((line) => (
<Text key={line} color={TONE.brand} bold>
{line}
</Text>
))}
<Text color={FG.faint}>{wave}</Text>
</Box>
<Box marginTop={1}>
<Text color={FG.meta}>{`loading${dots}`}</Text>
</Box>
</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>
);
}
-1
View File
@@ -1,6 +1,5 @@
/** Modal picker for `ask_choice` — options + optional "type my own" escape hatch. */
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import type { ChoiceOption } from "../../tools/choice.js";
import { SingleSelect } from "./Select.js";
-259
View File
@@ -1,259 +0,0 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import stringWidth from "string-width";
import type { SessionSummary } from "../../telemetry/stats.js";
import { ChromeRule } from "./primitives.js";
import { COLOR, GRADIENT } from "./theme.js";
const COLD_START_TURNS = 3;
const CACHE_BAR_CELLS = 10;
export type Preset = "auto" | "flash" | "pro";
export interface ChromeBarProps {
summary: SessionSummary;
rootDir?: string;
sessionName?: string | null;
preset?: Preset;
planMode?: boolean;
proArmed?: boolean;
escalated?: boolean;
updateAvailable?: string | null;
balance?: { currency: string; total: number } | null;
budgetUsd?: number | null;
/** Scroll progress: 0 = at latest, 1 = at oldest. Hides the pill at 0. */
scrollRatio?: number;
}
export function ChromeBar(props: ChromeBarProps): React.ReactElement {
const { stdout } = useStdout();
const cols = (stdout?.columns ?? 80) - 2; // subtract paddingX={1} on both sides
const cold = props.summary.turns <= COLD_START_TURNS;
const projectName = props.rootDir ? basename(props.rootDir) : null;
const mode = pickModePill(props.planMode, props.preset);
const proPill = props.escalated
? { label: "⇧ pro", color: COLOR.err }
: props.proArmed
? { label: "⇧ pro", color: COLOR.warn }
: null;
// Greedy width-aware fit. Layout (each gap is exactly ONE space — single
// suffix on update/mode/pro/scroll, single prefix on balance/cache):
// [brand][·project][›session]<spacer>[scroll][update][mode][pro]$cost w bal cache █▓▓▓
// Always-shown carve out the budget first; optional pieces are dropped
// by priority — balance > cache > session > update — until the row fits.
// (`flexGrow` spacer can shrink to 0, so no minimum reserve.) Cache is
// shown unconditionally now: pre-turn-4 it renders dim with a "—" so
// it's "default on" instead of materializing only after a few turns.
const SEP = 3; // " · " / " › "
const GAP = 1;
const scrollLabel =
props.scrollRatio !== undefined && props.scrollRatio > 0
? `[↑ ${Math.round(props.scrollRatio * 100)}%]`
: "";
const updateLabel = props.updateAvailable ? `↑ ${props.updateAvailable}` : "";
const balanceLabel = props.balance ? formatBalanceLabel(props.balance) : "";
const cachePct = Math.round(props.summary.cacheHitRatio * 100);
// Use the worst-case rendered cache string (3-digit pct, full bar) so the
// shed decision doesn't oscillate as the percentage changes turn-to-turn.
const cacheLabel = `cache ${"█".repeat(CACHE_BAR_CELLS)} 100%`;
const costLabel = `$${props.summary.totalCostUsd.toFixed(4)}${
props.budgetUsd && props.budgetUsd > 0 ? ` / $${props.budgetUsd.toFixed(2)}` : ""
}`;
const brandW = stringWidth("◈ reasonix");
const projectW = projectName ? SEP + stringWidth(projectName) : 0;
const fixedLeft = brandW + projectW;
const scrollW = scrollLabel ? stringWidth(scrollLabel) + GAP : 0;
const modeW = mode ? stringWidth(`[${mode.label}]`) + GAP : 0;
const proW = proPill ? stringWidth(`[${proPill.label}]`) + GAP : 0;
const costW = stringWidth(costLabel);
const fixedRight = scrollW + modeW + proW + costW;
let budget = cols - fixedLeft - fixedRight;
const balanceOptW = balanceLabel ? GAP + stringWidth(balanceLabel) : 0;
const cacheOptW = GAP + stringWidth(cacheLabel);
const sessionOptW = props.sessionName ? SEP + stringWidth(props.sessionName) : 0;
const updateOptW = updateLabel ? stringWidth(updateLabel) + GAP : 0;
const showBalance = balanceOptW > 0 && budget >= balanceOptW;
if (showBalance) budget -= balanceOptW;
const showCache = budget >= cacheOptW;
if (showCache) budget -= cacheOptW;
const showSession = sessionOptW > 0 && budget >= sessionOptW;
if (showSession) budget -= sessionOptW;
const showUpdate = updateOptW > 0 && budget >= updateOptW;
if (showUpdate) budget -= updateOptW;
return (
<Box flexDirection="column" paddingX={1}>
<Box>
<Text bold color={GRADIENT[0]}>
{"◈ "}
</Text>
<Text color={COLOR.brand} bold>
reasonix
</Text>
{projectName ? (
<>
<Text color={COLOR.info} dimColor>
{" · "}
</Text>
<Text>{projectName}</Text>
{showSession && props.sessionName ? (
<>
<Text color={COLOR.info} dimColor>
{" › "}
</Text>
<Text color={COLOR.info}>{props.sessionName}</Text>
</>
) : null}
</>
) : null}
<Box flexGrow={1} />
{scrollLabel ? (
<>
<Text color={COLOR.accent} bold>
{scrollLabel}
</Text>
<Text> </Text>
</>
) : null}
{showUpdate ? (
<>
<Text color={COLOR.warn} bold>
{updateLabel}
</Text>
<Text> </Text>
</>
) : null}
{mode ? (
<>
<Text color={mode.color} bold>
{`[${mode.label}]`}
</Text>
<Text> </Text>
</>
) : null}
{proPill ? (
<>
<Text color={proPill.color} bold>
{`[${proPill.label}]`}
</Text>
<Text> </Text>
</>
) : null}
<CostPill summary={props.summary} cold={cold} budgetUsd={props.budgetUsd} />
{showBalance && props.balance ? (
<>
<Text> </Text>
<BalancePill balance={props.balance} />
</>
) : null}
{showCache ? (
<>
<Text> </Text>
<CachePill ratio={props.summary.cacheHitRatio} cold={cold} pct={cachePct} />
</>
) : null}
</Box>
<ChromeRule />
</Box>
);
}
function formatBalanceLabel(balance: { currency: string; total: number }): string {
const sym = balance.currency === "USD" ? "$" : balance.currency === "CNY" ? "¥" : "";
const suf = sym ? "" : ` ${balance.currency}`;
return `w ${sym}${balance.total.toFixed(2)}${suf}`;
}
function CostPill({
summary,
cold,
budgetUsd,
}: {
summary: SessionSummary;
cold: boolean;
budgetUsd?: number | null;
}): React.ReactElement {
const cost = summary.totalCostUsd;
const color = cold ? COLOR.info : sessionCostColor(cost);
return (
<>
<Text color={color} bold={!cold} dimColor={cold}>
{`$${cost.toFixed(4)}`}
</Text>
{budgetUsd && budgetUsd > 0 ? (
<Text color={COLOR.info} dimColor>
{` / $${budgetUsd.toFixed(2)}`}
</Text>
) : null}
</>
);
}
function BalancePill({
balance,
}: {
balance: { currency: string; total: number };
}): React.ReactElement {
const { total } = balance;
const color = total < 1 ? COLOR.err : total < 5 ? COLOR.warn : COLOR.ok;
return <Text color={color}>{formatBalanceLabel(balance)}</Text>;
}
function CachePill({
ratio,
cold,
pct,
}: {
ratio: number;
cold: boolean;
pct: number;
}): React.ReactElement {
const color = ratio >= 0.7 ? COLOR.ok : ratio >= 0.4 ? COLOR.warn : COLOR.err;
const filled = Math.max(0, Math.min(CACHE_BAR_CELLS, Math.round(ratio * CACHE_BAR_CELLS)));
const empty = CACHE_BAR_CELLS - filled;
return (
<>
<Text color={COLOR.info} dimColor>
{"cache "}
</Text>
<Text color={cold ? COLOR.info : color} dimColor={cold}>
{"█".repeat(filled)}
</Text>
<Text dimColor>{"░".repeat(empty)}</Text>
<Text color={cold ? undefined : color} bold={!cold} dimColor={cold}>
{cold && pct === 0 ? " —" : ` ${pct}%`}
</Text>
</>
);
}
function pickModePill(
planMode: boolean | undefined,
preset: Preset | undefined,
): { label: string; color: string } | null {
if (planMode) return { label: "PLAN", color: COLOR.err };
if (!preset) return null;
if (preset === "pro") return { label: "pro", color: COLOR.accent };
if (preset === "flash") return { label: "flash", color: COLOR.info };
return { label: "auto", color: COLOR.primary };
}
function sessionCostColor(cost: number): string | undefined {
if (cost <= 0) return undefined;
if (cost >= 5) return COLOR.err;
if (cost >= 0.5) return COLOR.warn;
return COLOR.ok;
}
function basename(path: string): string {
const norm = path.replace(/\\/g, "/");
const i = norm.lastIndexOf("/");
return i === -1 ? norm : norm.slice(i + 1);
}
-1
View File
@@ -1,7 +1,6 @@
/** `/mcp` browser modal — keyboard-driven server list per design §24. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { toggleMcpDisabled } from "./mcp-disable.js";
+10 -2
View File
@@ -1,10 +1,10 @@
/** `/mcp` slash modal — single hub with two tabs: Live (attached servers) + Marketplace (registry). */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { McpBrowser } from "./McpBrowser.js";
import { McpMarketplace } from "./McpMarketplace.js";
import type { PickerBroadcastPorts } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import type { ApplyAppend } from "./mcp-reconnect-kickoff.js";
import type { McpServerSummary } from "./slash/types.js";
@@ -24,6 +24,8 @@ export interface McpHubProps {
removed: string[];
failed: Array<{ spec: string; reason: string }>;
}>;
/** Forwarded to the marketplace tab so the web dashboard can drive install / uninstall / refine / load-more. */
pickerPorts?: PickerBroadcastPorts;
}
export function McpHub({
@@ -34,6 +36,7 @@ export function McpHub({
postInfo,
applyAppend,
reloadMcp,
pickerPorts,
}: McpHubProps) {
const [tab, setTab] = useState<McpHubTab>(initialTab);
@@ -65,7 +68,12 @@ export function McpHub({
applyAppend={applyAppend}
/>
) : (
<McpMarketplace onClose={onClose} postInfo={postInfo} reloadMcp={reloadMcp} />
<McpMarketplace
onClose={onClose}
postInfo={postInfo}
reloadMcp={reloadMcp}
pickerPorts={pickerPorts}
/>
)}
</Box>
);
+120 -28
View File
@@ -1,7 +1,6 @@
/** `/mcp browse` modal — registry marketplace inside the chat session. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { readConfig, writeConfig } from "../../config.js";
import {
@@ -12,6 +11,7 @@ import {
specStringFor,
} from "../../mcp/registry-fetch.js";
import type { RegistryEntry } from "../../mcp/registry-types.js";
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import { COLOR } from "./theme.js";
@@ -27,6 +27,7 @@ export interface McpMarketplaceProps {
removed: string[];
failed: Array<{ spec: string; reason: string }>;
}>;
pickerPorts?: PickerBroadcastPorts;
}
interface State {
@@ -39,6 +40,39 @@ interface State {
installedSpecs: string[];
}
export function buildMarketplacePickerSnapshot(args: {
filtered: RegistryEntry[];
installedSpecs: string[];
query: string;
status: string;
hasMore: boolean;
}) {
return {
pickerKind: "mcp-marketplace" as const,
title: `MCP marketplace · ${args.status}`,
query: args.query,
items: args.filtered.map((e) => {
const installedSpec = isInstalled(args.installedSpecs, e);
return {
id: e.name,
title: e.title || e.name,
subtitle: e.description?.slice(0, 200) ?? undefined,
badge: installedSpec
? "installed"
: e.source === "official"
? "official"
: e.source === "smithery"
? "smithery"
: "local",
meta: e.popularity !== undefined ? `★ ${e.popularity.toLocaleString()}` : undefined,
};
}),
actions: ["install", "uninstall", "refine", "load-more", "cancel"] as const,
hasMore: args.hasMore,
hint: "type filter · ↑↓ pick · ⏎ install/toggle · PgDn load more · esc close",
};
}
function rankAndFilter(entries: RegistryEntry[], query: string): RegistryEntry[] {
const q = query.trim().toLowerCase();
const list = q
@@ -66,7 +100,7 @@ function isInstalled(installedSpecs: string[], entry: RegistryEntry): string | n
}
}
export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceProps) {
export function McpMarketplace({ onClose, postInfo, reloadMcp, pickerPorts }: McpMarketplaceProps) {
const [state, setState] = useState<State>({
handle: null,
loading: true,
@@ -128,33 +162,30 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
}
}, [state.handle, state.loading]);
const installOrToggle = useCallback(
async (entry: RegistryEntry) => {
const installed = isInstalled(state.installedSpecs, entry);
if (installed) {
const cfg = readConfig();
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
writeConfig({ ...cfg, mcp: next });
setState((s) => ({
...s,
installedSpecs: next,
status: `uninstalled ${entry.name}`,
}));
if (reloadMcp) {
try {
await reloadMcp();
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
} catch (err) {
postInfo(
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
);
}
} else {
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
const doUninstall = useCallback(
async (entry: RegistryEntry, installed: string) => {
const cfg = readConfig();
const next = (cfg.mcp ?? []).filter((s) => s !== installed);
writeConfig({ ...cfg, mcp: next });
setState((s) => ({ ...s, installedSpecs: next, status: `uninstalled ${entry.name}` }));
if (reloadMcp) {
try {
await reloadMcp();
postInfo(`✓ uninstalled ${entry.name} — bridge dropped`);
} catch (err) {
postInfo(
`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge (reload failed: ${(err as Error).message})`,
);
}
return;
} else {
postInfo(`✓ uninstalled ${entry.name} — restart \`reasonix code\` to drop the bridge`);
}
},
[postInfo, reloadMcp],
);
const doInstall = useCallback(
async (entry: RegistryEntry) => {
let install = entry.install;
if (!install && entry.source === "smithery") {
setState((s) => ({ ...s, loading: true, status: "fetching smithery detail…" }));
@@ -176,7 +207,6 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
}));
return;
}
try {
const spec = specStringFor(entry.name, install);
const cfg = readConfig();
@@ -216,7 +246,69 @@ export function McpMarketplace({ onClose, postInfo, reloadMcp }: McpMarketplaceP
setState((s) => ({ ...s, status: `install failed: ${(err as Error).message}` }));
}
},
[state.installedSpecs, postInfo, reloadMcp],
[postInfo, reloadMcp],
);
const installOrToggle = useCallback(
async (entry: RegistryEntry) => {
const installed = isInstalled(state.installedSpecs, entry);
if (installed) await doUninstall(entry, installed);
else await doInstall(entry);
},
[state.installedSpecs, doInstall, doUninstall],
);
const pickerSnapshot = useMemo(
() =>
buildMarketplacePickerSnapshot({
filtered,
installedSpecs: state.installedSpecs,
query: state.query,
status: state.status,
hasMore: state.handle?.cache.pagination.nextCursor != null,
}),
[filtered, state.installedSpecs, state.handle, state.query, state.status],
);
usePickerBroadcast(
!!pickerPorts,
{ ...pickerSnapshot, actions: [...pickerSnapshot.actions] },
(res) => {
if (res.action === "cancel") return onClose();
if (res.action === "refine") {
setState((s) => ({ ...s, query: res.query, selected: 0 }));
return;
}
if (res.action === "load-more") {
void fetchMore();
return;
}
if (res.action === "install") {
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
if (!entry) return;
if (isInstalled(state.installedSpecs, entry)) {
setState((s) => ({ ...s, status: `already installed: ${entry.name}` }));
return;
}
void doInstall(entry);
return;
}
if (res.action === "uninstall") {
const entry = state.handle?.cache.entries.find((e) => e.name === res.id);
if (!entry) return;
const installed = isInstalled(state.installedSpecs, entry);
if (!installed) {
setState((s) => ({ ...s, status: `not installed: ${entry.name}` }));
return;
}
void doUninstall(entry, installed);
}
},
pickerPorts ?? {
broadcast: () => undefined,
resolverRef: { current: null },
snapshotRef: { current: null },
},
);
useKeystroke((ev) => {
+219
View File
@@ -0,0 +1,219 @@
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: "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 = 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 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 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(rows.length - 1, f + 1));
if (ev.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" });
if (ev.input === "r") onRefresh?.();
});
const start = Math.max(
0,
Math.min(focus - Math.floor(visibleCount / 2), rows.length - visibleCount),
);
const end = Math.min(rows.length, start + visibleCount);
const shown = rows.slice(start, end);
const hiddenAbove = start;
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 setup "}
</Text>
<Text color={FG.meta}>
{loading
? " · loading catalog…"
: empty
? " · catalog empty — using known fallbacks"
: ` · ${modelList.length} models available`}
</Text>
</Box>
<Box height={1} />
{hiddenAbove > 0 ? (
<Box>
<Text color={FG.faint}>{` … ${hiddenAbove} earlier`}</Text>
</Box>
) : null}
{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>
</Box>
) : null}
<Box marginTop={1}>
<Text color={FG.faint}>{" ↑↓ pick · ⏎ confirm · [r] refresh · esc cancel"}</Text>
</Box>
</Box>
);
}
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,
active,
}: {
id: string;
focused: boolean;
active: boolean;
}): React.ReactElement {
const badge = modelBadgeFor(id);
return (
<Box>
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
{id.padEnd(24)}
</Text>
<Text> </Text>
<Pill label={badge.label} {...PILL_MODEL[badge.kind]} bold={false} />
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
</Box>
);
}
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",
"deepseek-v4-pro",
"deepseek-chat",
"deepseek-reasoner",
];
+8 -8
View File
@@ -1,8 +1,8 @@
/** Modal picker for `PlanCheckpointError`: continue / revise / stop. */
import { Box } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
@@ -41,7 +41,7 @@ function PlanCheckpointConfirmInner({
const statuses = buildStatusMap(steps, completedStepIds, stepId, isLast);
const subtitle = counter ? `${counter} · ${label}` : label;
return (
<ApprovalCard tone="ok" glyph="⛁" title="Checkpoint — step done" metaRight={subtitle}>
<ApprovalCard tone="ok" glyph="⛁" title={t("planFlow.checkpoint.title")} metaRight={subtitle}>
{steps && steps.length > 0 ? (
<Box marginBottom={1} flexDirection="column">
<PlanStepList steps={steps} statuses={statuses} focusStepId={stepId} />
@@ -52,18 +52,18 @@ function PlanCheckpointConfirmInner({
items={[
{
value: "continue",
label: "Continue — run the next step",
hint: "Model resumes with the next step.",
label: t("planFlow.checkpoint.continue"),
hint: t("planFlow.checkpoint.continueHint"),
},
{
value: "revise",
label: "Revise — give feedback before the next step",
hint: "Stay paused, type guidance; model adjusts the remaining plan.",
label: t("planFlow.checkpoint.revise"),
hint: t("planFlow.checkpoint.reviseHint"),
},
{
value: "stop",
label: "Stop — end the plan here",
hint: "Model summarizes what was done and ends.",
label: t("planFlow.checkpoint.stop"),
hint: t("planFlow.checkpoint.stopHint"),
},
]}
onSubmit={(v) => onChoose(v as CheckpointChoice)}
+58 -23
View File
@@ -1,14 +1,16 @@
/** Modal-style picker for `submit_plan`: accept / refine / cancel. */
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { CARD, TONE } from "./theme/tokens.js";
import { MarkdownView } from "./markdown-view.js";
import { extractOpenQuestionsSection } from "./plan-open-questions.js";
import { CARD, FG, TONE } from "./theme/tokens.js";
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
@@ -21,57 +23,90 @@ export interface PlanConfirmProps {
projectRoot?: string;
}
const PLAN_BODY_PREVIEW_LINES = 24;
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
const stepRows = steps?.length ?? 0;
useReserveRows("modal", { min: 10, max: Math.max(16, stepRows + 14) });
const hasSteps = stepRows > 0;
const openQuestions = extractOpenQuestionsSection(plan);
const planLines = plan.split("\n");
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
const previewRows = truncatedBody
? PLAN_BODY_PREVIEW_LINES
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
const reservedFor = hasSteps ? stepRows : previewRows;
const oqRows = openQuestions ? openQuestions.split("\n").length : 0;
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + oqRows + 14) });
const hasOpenQuestions =
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
/^#{1,6}\s*(待确认|开放问题|风险|未知|假设|不确定)/im.test(plan);
const refineLabel = t("planFlow.picker.refine");
const bannerTemplate = t("planFlow.openQuestionsBanner");
const [bannerBefore, bannerAfter] = bannerTemplate.split("{refine}");
return (
<ApprovalCard
tone="accent"
glyph="⊞"
title="Approve plan"
metaRight="awaiting"
title={t("planFlow.approveCardTitle")}
metaRight={t("planFlow.approveCardMetaRight")}
metaRightColor={CARD.plan.color}
>
{hasOpenQuestions ? (
<Box marginBottom={1}>
{openQuestions ? (
<Box marginBottom={1} flexDirection="column">
<Text color={TONE.warn}>
▲ the plan flags open questions or risks — pick <Text bold>refine</Text> to write
concrete answers before the model moves on.
{bannerBefore ?? ""}
<Text bold>{refineLabel}</Text>
{bannerAfter ?? ""}
</Text>
<Box marginTop={1} flexDirection="column">
<Text color={TONE.warn} bold>
{t("planFlow.openQuestionsHeader")}
</Text>
<MarkdownView text={openQuestions} />
</Box>
</Box>
) : null}
{steps && steps.length > 0 ? (
{hasSteps ? (
<Box marginBottom={1} flexDirection="column">
<PlanStepList steps={steps} />
<PlanStepList steps={steps!} />
</Box>
) : plan.trim().length > 0 ? (
<Box marginBottom={1} flexDirection="column">
<MarkdownView text={previewBody} />
{truncatedBody ? (
<Text color={FG.faint}>
{t(
planLines.length - PLAN_BODY_PREVIEW_LINES === 1
? "planFlow.truncatedBodyMore"
: "planFlow.truncatedBodyMorePlural",
{ n: planLines.length - PLAN_BODY_PREVIEW_LINES },
)}
</Text>
) : null}
</Box>
) : null}
<SingleSelect
initialValue={hasOpenQuestions ? "refine" : "approve"}
initialValue={openQuestions ? "refine" : "approve"}
items={[
{
value: "approve",
label: "accept",
hint: "run it now, in order",
label: t("planFlow.picker.accept"),
hint: t("planFlow.picker.acceptHint"),
},
{
value: "refine",
label: "refine",
hint: "give the agent more guidance, draft a new plan",
label: refineLabel,
hint: t("planFlow.picker.refineHint"),
},
{
value: "revise",
label: "revise",
hint: "edit the plan inline before running (skip / reorder steps)",
label: t("planFlow.picker.revise"),
hint: t("planFlow.picker.reviseHint"),
},
{
value: "cancel",
label: "reject",
hint: "discard, agent will retry from scratch",
label: t("planFlow.picker.reject"),
hint: t("planFlow.picker.rejectHint"),
},
]}
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
+46 -47
View File
@@ -1,12 +1,23 @@
import { Box, Text } from "ink";
import React, { useState } from "react";
import { t } from "../../i18n/index.js";
import { ApprovalCard, type ApprovalCardProps } from "./cards/ApprovalCard.js";
import { useKeystroke } from "./keystroke-context.js";
import { CARD, FG } from "./theme/tokens.js";
import { MarkdownView } from "./markdown-view.js";
import { CARD, FG, TONE } from "./theme/tokens.js";
import { useTick } from "./ticker.js";
export type PlanRefineMode =
| "approve"
| "refine"
| "reject"
| "checkpoint-revise"
| "choice-custom";
export interface PlanRefineInputProps {
mode: "approve" | "refine" | "reject" | "checkpoint-revise" | "choice-custom";
mode: PlanRefineMode;
/** Open-questions / risks block extracted from the plan, rendered above the input on refine. */
questions?: string;
onSubmit: (feedback: string) => void;
onCancel: () => void;
}
@@ -20,51 +31,30 @@ interface ModeMeta {
blankHint: string;
}
const MODES: Record<PlanRefineInputProps["mode"], ModeMeta> = {
approve: {
title: "approving — any last instructions?",
glyph: "◇",
tone: "user",
cursorColor: CARD.user.color,
hint: "Answer questions the plan raised, add constraints, or just press Enter to approve as-is.",
blankHint: " (Enter with blank = approve without extra instructions.)",
},
refine: {
title: "refining — what should the model change?",
glyph: "✎",
tone: "warn",
cursorColor: CARD.warn.color,
hint: "Describe what's wrong or missing, or answer questions the plan raised.",
blankHint: " (Enter with blank = ask the model to list concrete questions.)",
},
reject: {
title: "rejecting — tell the model why (optional)",
glyph: "✗",
tone: "error",
cursorColor: CARD.error.color,
hint: "Say what the model got wrong about your goal, or what you actually want instead.",
blankHint:
" (Enter with blank = cancel without explanation; the model will ask what you want.)",
},
"checkpoint-revise": {
title: "revising — what should change before the next step?",
glyph: "✎",
tone: "warn",
cursorColor: CARD.warn.color,
hint: "Scope change, skip steps, alternative approach — the model adjusts the remaining plan.",
blankHint: " (Enter with blank = continue with the current plan.)",
},
"choice-custom": {
title: "custom answer — type whatever fits",
glyph: "⌥",
tone: "accent",
cursorColor: CARD.plan.color,
hint: "Free-form reply. The model reads it verbatim and proceeds — no need to match the listed options.",
blankHint: " (Enter with blank = ask the model what you actually want.)",
},
const MODE_VISUALS: Record<
PlanRefineMode,
{ glyph: string; tone: ApprovalCardProps["tone"]; cursorColor: string }
> = {
approve: { glyph: "◇", tone: "user", cursorColor: CARD.user.color },
refine: { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
reject: { glyph: "✗", tone: "error", cursorColor: CARD.error.color },
"checkpoint-revise": { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
"choice-custom": { glyph: "⌥", tone: "accent", cursorColor: CARD.plan.color },
};
export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputProps) {
function modeMeta(mode: PlanRefineMode): ModeMeta {
const v = MODE_VISUALS[mode];
return {
title: t(`planFlow.modes.${mode}.title`),
hint: t(`planFlow.modes.${mode}.hint`),
blankHint: t(`planFlow.modes.${mode}.blankHint`),
glyph: v.glyph,
tone: v.tone,
cursorColor: v.cursorColor,
};
}
export function PlanRefineInput({ mode, questions, onSubmit, onCancel }: PlanRefineInputProps) {
const [value, setValue] = useState("");
useKeystroke((ev) => {
@@ -91,15 +81,24 @@ export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputPro
const tick = useTick();
const cursorOn = Math.floor(tick / 4) % 2 === 0;
const meta = MODES[mode];
const meta = modeMeta(mode);
const showQuestions = mode === "refine" && !!questions && questions.trim().length > 0;
return (
<ApprovalCard
tone={meta.tone}
glyph={meta.glyph}
title={meta.title}
footerHint="⏎ send · esc return to picker"
footerHint={t("planFlow.refineFooter")}
>
{showQuestions ? (
<Box marginBottom={1} flexDirection="column">
<Text color={TONE.warn} bold>
{t("planFlow.refineQuestionsHeading")}
</Text>
<MarkdownView text={questions!} />
</Box>
) : null}
<Box marginBottom={1}>
<Text color={FG.sub}>
{meta.hint}
-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>
);
}
+10 -2
View File
@@ -29,6 +29,7 @@
import { Box, Text } from "ink";
import React from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep, PlanStepRisk } from "../../tools/plan.js";
import { CharBar } from "./char-bar.js";
import { COLOR, GLYPH } from "./theme.js";
@@ -103,8 +104,15 @@ function PlanStepListInner({ steps, statuses, focusStepId }: PlanStepListProps)
<Box>
<Text dimColor>
{showProgress
? `${doneCount}/${total} done (${pct}%) · ${total} step${total === 1 ? "" : "s"}`
: `${total} step${total === 1 ? "" : "s"}`}
? t(
total === 1
? "planFlow.stepList.counterDoneSingular"
: "planFlow.stepList.counterDone",
{ done: doneCount, total, pct },
)
: t(total === 1 ? "planFlow.stepList.counterSingular" : "planFlow.stepList.counter", {
total,
})}
</Text>
</Box>
<Box flexDirection="column">
+51 -22
View File
@@ -1,7 +1,7 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value (classic transform compiles JSX to React.createElement)
import React, { useRef, useState } from "react";
import { useCursor } from "../../renderer/index.js";
import { t } from "../../i18n/index.js";
import { useKeystroke } from "./keystroke-context.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
@@ -20,18 +20,20 @@ import { FG, SURFACE, TONE } from "./theme/tokens.js";
/** Raw-stdin keystroke bus → multiline reducer; one logical line per Box row, viewport-clipped. */
/** Pastes shorter than this AND single-line render verbatim; longer ones become a `[paste #N · …]` sentinel chip (#397). */
export const INLINE_PASTE_THRESHOLD = 200;
export function shouldInlinePaste(content: string): boolean {
return !content.includes("\n") && content.length <= INLINE_PASTE_THRESHOLD;
}
export interface PromptInputProps {
value: string;
onChange: (v: string) => void;
onSubmit: (v: string) => void;
disabled?: boolean;
placeholder?: string;
/**
* History recall. Fired when ↑/↓ hits a buffer boundary (empty
* buffer, or cursor already at the first/last line of a multi-line
* buffer). Parent walks its prompt history and swaps the value via
* `onChange`. Absent → keys are consumed silently.
*/
/** Ctrl+P / Ctrl+N hand off here when no in-buffer cursor move applies — parent walks history and swaps `value` via `onChange`. */
onHistoryPrev?: () => void;
onHistoryNext?: () => void;
}
@@ -46,8 +48,12 @@ export function PromptInput({
onHistoryNext,
}: PromptInputProps) {
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
// don't churn viewport-budget; without this every single character that
// adds/removes a newline re-dispatches the allocator and reflows layout.
const inputLineCount = value.length > 0 ? value.split("\n").length : 1;
useReserveRows("input", { min: 1, max: Math.min(inputLineCount + 3, 24) });
const reserveMax = Math.min(Math.ceil(inputLineCount / 4) * 4 + 3, 24);
useReserveRows("input", { min: 1, max: reserveMax });
const [cursor, setCursor] = useState(value.length);
@@ -71,15 +77,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) => {
@@ -146,15 +156,13 @@ export function PromptInput({
// Hint avoids literal `/` and `@` glyphs — they render in the same row as
// a just-cleared buffer and read as residual typed input on dim-poor terminals.
const effectivePlaceholder = disabled
? (placeholder ?? "…waiting for response…")
: (placeholder ?? "type a message · slash for commands · at-sign for files");
? (placeholder ?? t("composer.waitingForResponse"))
: (placeholder ?? t("composer.placeholder"));
const lines = value.length > 0 ? value.split("\n") : [""];
const accentColor = disabled ? FG.faint : TONE.brand;
const cursorVisible = true;
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
// PromptInput paints its own cursor (`▌` / inverse char) — hide the real terminal cursor so it doesn't double up at the bottom of the screen.
useCursor({ col: 0, rowFromBottom: 0, visible: false });
const renderItems = collapseLinesForDisplay(lines, cursorLine);
const showHugeBufferHints = lines.length > 20;
@@ -281,9 +289,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}>
@@ -294,6 +300,29 @@ export function PromptInput({
);
}
function HintRow(): React.ReactElement {
const items: Array<{ key: string; tKey: string }> = [
{ key: "\u23ce", tKey: "composer.hintSend" },
{ key: "\u21e7\u23ce", tKey: "composer.hintNewline" },
{ key: "\u2191\u2193", tKey: "composer.hintScroll" },
{ key: "^P/^N", tKey: "composer.hintHistory" },
{ key: "esc", tKey: "composer.hintAbort" },
{ key: "^C", tKey: "composer.hintQuit" },
];
return (
<Box flexDirection="row">
<Text>{" "}</Text>
{items.map((item, i) => (
<React.Fragment key={item.key}>
{i > 0 && <Text color={FG.faint}>{" \u00b7 "}</Text>}
<Text color={FG.meta}>{item.key}</Text>
<Text color={FG.faint}>{` ${t(item.tKey)}`}</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>
+74 -9
View File
@@ -1,9 +1,10 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import type { SessionInfo } from "../../memory/session.js";
import { type PickerBroadcastPorts, usePickerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { useKeystroke } from "./keystroke-context.js";
import { FG, TONE, USD_TO_CNY } from "./theme/tokens.js";
import { FG, TONE, formatCost } from "./theme/tokens.js";
export type SessionPickerOutcome =
| { kind: "open"; name: string }
@@ -16,6 +17,10 @@ export interface SessionPickerProps {
sessions: ReadonlyArray<SessionInfo>;
workspace: string;
onChoose: (outcome: SessionPickerOutcome) => void;
/** Live wallet currency from App.tsx; falls back to each session's stored `meta.balanceCurrency` per row. */
walletCurrency?: string;
/** When provided, broadcasts to the web dashboard so it can resolve via `/api/modal/resolve`. */
pickerPorts?: PickerBroadcastPorts;
}
const PAGE_MARGIN = 6;
@@ -24,6 +29,8 @@ export function SessionPicker({
sessions,
workspace,
onChoose,
walletCurrency,
pickerPorts,
}: SessionPickerProps): React.ReactElement {
const [focus, setFocus] = useState(0);
const [renaming, setRenaming] = useState<{ from: string; buf: string } | null>(null);
@@ -31,6 +38,53 @@ export function SessionPicker({
const rows = stdout?.rows ?? 40;
const visibleCount = Math.max(3, rows - PAGE_MARGIN);
const snapshot = useMemo(
() => ({
pickerKind: "sessions",
title: `pick a session — ${workspace}`,
items: sessions.map((s) => {
const branch = s.meta.branch ?? "main";
const summary =
s.meta.summary ?? `${s.messageCount} message${s.messageCount === 1 ? "" : "s"}`;
const turns = s.meta.turnCount ?? Math.ceil(s.messageCount / 2);
const currency = walletCurrency ?? s.meta.balanceCurrency;
const costLabel =
s.meta.totalCostUsd !== undefined ? formatCost(s.meta.totalCostUsd, currency, 2) : "";
return {
id: s.name,
title: s.name,
subtitle: summary,
badge: branch,
meta: costLabel ? `${turns} turns · ${costLabel}` : `${turns} turns`,
};
}),
actions: ["pick", "delete", "rename", "new", "cancel"] as const,
hint: "↑↓ pick · ⏎ open · [n] new · [d] delete · [r] rename · esc quit",
}),
[sessions, workspace, walletCurrency],
);
usePickerBroadcast(
!!pickerPorts,
{
...snapshot,
actions: [...snapshot.actions],
},
(res) => {
if (res.action === "pick") return onChoose({ kind: "open", name: res.id });
if (res.action === "delete") return onChoose({ kind: "delete", name: res.id });
if (res.action === "rename")
return onChoose({ kind: "rename", name: res.id, newName: res.text });
if (res.action === "new") return onChoose({ kind: "new" });
if (res.action === "cancel") return onChoose({ kind: "quit" });
},
pickerPorts ?? {
broadcast: () => undefined,
resolverRef: { current: null },
snapshotRef: { current: null },
},
);
useKeystroke((ev) => {
if (ev.paste) {
if (renaming) setRenaming({ ...renaming, buf: renaming.buf + ev.input });
@@ -101,7 +155,14 @@ export function SessionPicker({
<Text color={FG.faint}>{" to start a new one"}</Text>
</Box>
) : (
shown.map((s, i) => <SessionRow key={s.name} info={s} focused={start + i === focus} />)
shown.map((s, i) => (
<SessionRow
key={s.name}
info={s}
focused={start + i === focus}
walletCurrency={walletCurrency}
/>
))
)}
{hiddenBelow > 0 ? (
<Box>
@@ -135,15 +196,19 @@ export function SessionPicker({
function SessionRow({
info,
focused,
}: { info: SessionInfo; focused: boolean }): React.ReactElement {
walletCurrency,
}: {
info: SessionInfo;
focused: boolean;
walletCurrency?: string;
}): React.ReactElement {
const branch = info.meta.branch ?? "main";
const summary =
info.meta.summary ?? `${info.messageCount} message${info.messageCount === 1 ? "" : "s"}`;
const turns = info.meta.turnCount ?? Math.ceil(info.messageCount / 2);
const costCny =
info.meta.totalCostUsd !== undefined
? `¥${(info.meta.totalCostUsd * USD_TO_CNY).toFixed(2)}`
: "";
const currency = walletCurrency ?? info.meta.balanceCurrency;
const costLabel =
info.meta.totalCostUsd !== undefined ? formatCost(info.meta.totalCostUsd, currency, 2) : "";
const time = relativeTime(info.mtime);
return (
<Box>
@@ -156,7 +221,7 @@ function SessionRow({
<Box flexGrow={1} />
<Text color={FG.faint}>{`${time.padStart(11)} `}</Text>
<Text color={FG.faint}>{`${turns} turns`}</Text>
{costCny ? <Text color={FG.faint}>{` · ${costCny}`}</Text> : null}
{costLabel ? <Text color={FG.faint}>{` · ${costLabel}`}</Text> : null}
</Box>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function Setup({ onReady }: SetupProps) {
return;
}
if (!isPlausibleKey(trimmed)) {
setError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
setError("Key looks too short — paste the full token (16+ chars, no spaces).");
setValue("");
return;
}
-1
View File
@@ -1,5 +1,4 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { DenyContextInput } from "./DenyContextInput.js";
import { SingleSelect } from "./Select.js";
+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>
+166 -51
View File
@@ -1,78 +1,108 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
import { Box, Text, useStdout } from "ink";
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";
const GROUP_MODE_MAX_ROWS = 24;
const SEARCH_MODE_MAX_ROWS = 8;
const COMMAND_NAME_CELLS = 14;
const ARGS_CELLS = 14;
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();
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const [rememberedWindowStart, setRememberedWindowStart] = React.useState(0);
// All hooks must run on every render; the early-return branches below
// would otherwise change hook count between renders → "Rendered more
// hooks than during the previous render" crash when matches flips
// between null/empty and non-empty.
const maxRows = groupMode ? GROUP_MODE_MAX_ROWS : SEARCH_MODE_MAX_ROWS;
const safeMatches = matches ?? [];
const windowStart = computeWindowStart(
safeMatches,
maxRows,
selectedIndex,
rememberedWindowStart,
groupMode,
);
React.useEffect(() => {
setRememberedWindowStart(windowStart);
}, [windowStart]);
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 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 items = buildVisibleItems(matches, windowStart, maxRows, groupMode);
const shownCommands = items.filter((item) => item.kind === "command");
const hiddenAbove = windowStart;
const hiddenBelow = total - windowStart - shown.length;
const hiddenBelow = total - windowStart - shownCommands.length;
return (
<Box flexDirection="column" paddingX={1} marginTop={1}>
<Box flexDirection="column" paddingX={1} marginTop={1} flexShrink={0} flexWrap="nowrap">
<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} />
))}
{items.map((item) => {
if (item.kind === "group") {
return <GroupHeader key={`group:${item.group}:${item.beforeIndex}`} group={item.group} />;
}
return (
<SuggestionRow
key={`cmd:${item.spec.group}:${item.spec.cmd}`}
spec={item.spec}
isSelected={item.index === selectedIndex}
columns={cols}
/>
);
})}
{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>
@@ -80,25 +110,110 @@ export function SlashSuggestions({
);
}
function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelected: boolean }) {
export function computeWindowStart(
matches: readonly SlashCommandSpec[],
maxRows: number,
selectedIndex: number,
currentWindowStart: number,
groupMode = false,
): number {
if (matches.length <= 0) return 0;
const maxWindowStart = Math.max(0, matches.length - 1);
let start = Math.max(0, Math.min(currentWindowStart, maxWindowStart));
const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, matches.length - 1));
if (clampedSelectedIndex < start) start = clampedSelectedIndex;
while (start < clampedSelectedIndex) {
const visibleCommandIndexes = buildVisibleItems(matches, start, maxRows, groupMode)
.filter((item) => item.kind === "command")
.map((item) => item.index);
if (visibleCommandIndexes.includes(clampedSelectedIndex)) break;
start += 1;
}
return Math.min(start, maxWindowStart);
}
type VisibleSuggestionItem =
| { kind: "group"; group: SlashGroup; beforeIndex: number }
| { kind: "command"; spec: SlashCommandSpec; index: number };
export function buildVisibleItems(
matches: readonly SlashCommandSpec[],
windowStart: number,
maxRows: number,
groupMode = false,
): VisibleSuggestionItem[] {
const out: VisibleSuggestionItem[] = [];
for (let idx = windowStart; idx < matches.length && out.length < maxRows; idx += 1) {
const spec = matches[idx]!;
if (groupMode && shouldShowGroupHeader(matches, idx)) {
if (out.length >= maxRows) break;
out.push({ kind: "group", group: spec.group, beforeIndex: idx });
}
if (out.length >= maxRows) break;
out.push({ kind: "command", spec, index: idx });
}
return out;
}
function shouldShowGroupHeader(matches: readonly SlashCommandSpec[], idx: number): boolean {
return idx === 0 || matches[idx]?.group !== matches[idx - 1]?.group;
}
function GroupHeader({ group }: { group: SlashGroup }): React.ReactElement {
return (
<Box flexShrink={0} height={1} flexWrap="nowrap">
<Text dimColor wrap="truncate">
{` ${GROUP_LABEL[group]}`}
</Text>
</Box>
);
}
function SuggestionRow({
spec,
isSelected,
columns,
}: {
spec: SlashCommandSpec;
isSelected: boolean;
columns: number;
}) {
const color = useColor();
const name = `/${spec.cmd}`;
const argsSuffix = spec.argsHint ? spec.argsHint : "";
const key = `slash.${spec.cmd}.description`;
const translated = t(key);
const summary = translated === key ? spec.summary : translated;
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
const reservedCells = 2 + COMMAND_NAME_CELLS + ARGS_CELLS + 2 + 2;
const summaryBudget = Math.max(8, columns - reservedCells);
const summaryText = truncateCells(`${summary}${aliasHint}`, summaryBudget);
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
<Box flexDirection="row" flexWrap="nowrap" flexShrink={0} height={1} minHeight={1}>
<Text color={isSelected ? color.primary : color.info} bold={isSelected} wrap="truncate">
{isSelected ? `${GLYPH.cur} ` : " "}
</Text>
<Text color={COLOR.accent} bold={isSelected}>
{name.padEnd(14)}
<Text color={color.accent} bold={isSelected} wrap="truncate">
{padOrTrim(name, COMMAND_NAME_CELLS)}
</Text>
<Text dimColor>{argsSuffix.padEnd(14)}</Text>
<Text>{" "}</Text>
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
{summary}
<Text dimColor wrap="truncate">
{padOrTrim(argsSuffix, ARGS_CELLS)}
</Text>
<Text wrap="truncate">{" "}</Text>
<Text color={isSelected ? color.user : color.info} dimColor={!isSelected} wrap="truncate">
{summaryText}
</Text>
</Box>
);
}
function padOrTrim(value: string, cells: number): string {
const trimmed = truncateCells(value, cells);
return trimmed.padEnd(cells);
}
function truncateCells(value: string, maxCells: number): string {
if (value.length <= maxCells) return value;
if (maxCells <= 1) return value.slice(0, Math.max(0, maxCells));
return `${value.slice(0, maxCells - 1)}…`;
}
+4 -3
View File
@@ -6,6 +6,7 @@ import type { EditMode } from "../../config.js";
import type { SessionSummary } from "../../telemetry/stats.js";
import { Bar, ChromeRule } from "./primitives.js";
import { COLOR, GRADIENT } from "./theme.js";
import { formatBalance, formatCost } from "./theme/tokens.js";
const COLD_START_TURNS = 3;
@@ -87,13 +88,13 @@ function ChromeRow({
? { label: "⇧ pro", color: COLOR.warn }
: null;
const projectName = rootDir ? basename(rootDir) : null;
const cachePct = Math.round(summary.cacheHitRatio * 100);
const cachePct = (summary.cacheHitRatio * 100).toFixed(1);
const cacheColor =
summary.cacheHitRatio >= 0.7 ? COLOR.ok : summary.cacheHitRatio >= 0.4 ? COLOR.warn : COLOR.err;
const balanceLabel = balance
? `[w ${balance.currency === "USD" ? "$" : ""}${balance.total.toFixed(2)}${balance.currency !== "USD" ? ` ${balance.currency}` : ""}]`
? `[${formatBalance(balance.total, balance.currency, { label: true })}]`
: "";
const costLabel = `[$${summary.totalCostUsd.toFixed(4)}]`;
const costLabel = `[${formatCost(summary.totalCostUsd, balance?.currency)}]`;
const cacheLabel = "[c ▰▰▰▰▰▰ 100%]";
const updateLabel = updateAvailable ? `↑ ${updateAvailable}` : "";
+50
View File
@@ -0,0 +1,50 @@
import { Box, Text } from "ink";
import React from "react";
import { type SelectItem, SingleSelect } from "./Select.js";
import { type ThemeName, listThemeNames } from "./theme/tokens.js";
export type ThemeChoice = ThemeName | "auto";
export type ThemePickerOutcome = { kind: "select"; value: ThemeChoice } | { kind: "quit" };
export function ThemePicker({
currentPreference,
activeTheme,
onChoose,
}: {
currentPreference: ThemeChoice;
activeTheme: ThemeName;
onChoose: (outcome: ThemePickerOutcome) => void;
}) {
const choices: ThemeChoice[] = ["auto", ...listThemeNames()];
const items: SelectItem<ThemeChoice>[] = choices.map((value) => ({
value,
label: value,
hint: describeTheme(value, currentPreference, activeTheme),
}));
return (
<Box flexDirection="column" marginY={1}>
<Text bold>Theme</Text>
<SingleSelect
items={items}
initialValue={currentPreference}
onSubmit={(value) => onChoose({ kind: "select", value })}
onCancel={() => onChoose({ kind: "quit" })}
footer="↑↓ pick · ⏎ confirm · esc cancel"
/>
</Box>
);
}
function describeTheme(
value: ThemeChoice,
currentPreference: ThemeChoice,
activeTheme: ThemeName,
): string {
const tags: string[] = [];
if (value === currentPreference) tags.push("current preference");
if (value === activeTheme) tags.push("active now");
if (value === "auto") tags.push("use REASONIX_THEME or default");
return tags.join(" · ");
}
+12
View File
@@ -9,6 +9,8 @@ import { FG, TONE } from "./theme/tokens.js";
export interface WelcomeBannerProps {
/** True when running `reasonix code`. Surfaces code-mode hints. */
inCodeMode?: boolean;
/** Pinned workspace root — only meaningful in code mode. Surfaced so first-time users see they can pass --dir at next launch. */
workspaceRoot?: string;
/** Live URL of the embedded dashboard, or null when it isn't running. */
dashboardUrl?: string | null;
/** Bumped on language change; forces re-render so t() picks up new locale. */
@@ -19,6 +21,7 @@ const HINTS = ["/help", "/init", "/memory", "/cost"] as const;
export function WelcomeBanner({
inCodeMode,
workspaceRoot,
dashboardUrl,
}: WelcomeBannerProps): React.ReactElement {
const tagline = inCodeMode ? t("ui.taglineCode") : t("ui.taglineChat");
@@ -66,6 +69,15 @@ export function WelcomeBanner({
))}
</Box>
{inCodeMode && workspaceRoot ? (
<Box marginTop={1} flexDirection="row" gap={1}>
<Text color={TONE.brand}>{"▸ workspace"}</Text>
<Text color={FG.faint}>{"·"}</Text>
<Text color={FG.body}>{workspaceRoot}</Text>
<Text color={FG.faint}>{" (relaunch with --dir <path> to switch)"}</Text>
</Box>
) : null}
{dashboardUrl ? (
<Box marginTop={1} flexDirection="row" gap={1}>
<Text color={TONE.brand} bold>
+413 -171
View File
@@ -1,32 +1,43 @@
/**
* 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 → theme → 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,
defaultConfigPath,
isPlausibleKey,
loadTheme,
readConfig,
redactKey,
resolveThemePreference,
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";
import { ThemeProvider, useTheme } from "./theme/context.js";
import { type ThemeName, listThemeNames } from "./theme/tokens.js";
export interface WizardProps {
/** Called once the config has been saved. */
@@ -39,189 +50,361 @@ export interface WizardProps {
initial?: {
preset?: PresetName;
mcp?: string[];
theme?: ThemeName | "auto";
};
}
type Step = "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
type Step = "language" | "theme" | "apiKey" | "preset" | "mcp" | "mcpArgs" | "review" | "saved";
interface WizardData {
language: LanguageCode;
theme: ThemeName;
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 [data, setData] = useState<WizardData>({
const [, setLanguageVersion] = useState(0);
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);
const [previewTheme, setPreviewTheme] = useState<ThemeName>(() =>
resolveThemePreference(initial?.theme ?? loadTheme(), process.env.REASONIX_THEME),
);
const [step, setStep] = useState<Step>("language");
const [data, setData] = useState<WizardData>(() => ({
language: getLanguage(),
theme: resolveThemePreference(initial?.theme ?? loadTheme(), process.env.REASONIX_THEME),
apiKey: existingApiKey ?? "",
preset: initial?.preset ?? "auto",
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
catalogArgs: {},
});
}));
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 === "apiKey") {
return (
<ApiKeyStep
onSubmit={(key) => {
setData((d) => ({ ...d, apiKey: key }));
setError(null);
setStep("preset");
}}
error={error}
onError={setError}
/>
);
}
if (step === "preset") {
return (
<StepFrame title="Pick a preset" step={1} total={3}>
<SingleSelect<PresetName>
items={presetItems()}
initialValue={data.preset}
onSubmit={(preset) => {
setData((d) => ({ ...d, preset }));
setStep("mcp");
const content = (() => {
if (step === "language") {
return (
<LanguageStep
initialValue={data.language}
onSubmit={(lang) => {
setLanguage(lang);
notifyLanguageChange();
setData((d) => ({ ...d, language: lang }));
setStep("theme");
}}
/>
<Box marginTop={1}>
<Text dimColor>[↑↓] navigate · [Enter] confirm · [Esc] cancel</Text>
</Box>
</StepFrame>
);
}
if (step === "mcp") {
return (
<StepFrame title="Which MCP servers should Reasonix wire up for you?" 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"
/>
</StepFrame>
);
}
if (step === "mcpArgs") {
const pending = data.selectedCatalog.filter((name) => {
const entry = CATALOG_BY_NAME.get(name);
return entry?.userArgs && !data.catalogArgs[name];
});
if (pending.length === 0) {
setStep("review");
return null;
);
}
const currentName = pending[0]!;
const entry = CATALOG_BY_NAME.get(currentName)!;
return (
<McpArgsStep
entry={entry}
error={error}
onSubmit={(value) => {
setData((d) => ({
...d,
catalogArgs: { ...d.catalogArgs, [currentName]: value },
}));
setError(null);
}}
onError={setError}
/>
);
}
if (step === "review") {
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
return (
<StepFrame title="Ready to save" 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)`}
/>
{specs.map((spec, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: review-only render, order fixed
<Box key={i} paddingLeft={14}>
<Text dimColor>· {spec}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>Saves to {defaultConfigPath()}</Text>
</Box>
{error ? (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
) : null}
<Box marginTop={1}>
<Text dimColor>[Enter] save · [Esc] cancel</Text>
</Box>
</Box>
<ReviewConfirm
onConfirm={() => {
try {
const specsNow = data.selectedCatalog.map((name) =>
buildSpec(name, data.catalogArgs),
);
const prev = readConfig();
const next: ReasonixConfig = {
...prev,
apiKey: data.apiKey,
preset: data.preset,
mcp: specsNow,
setupCompleted: true,
};
writeConfig(next);
setStep("saved");
onComplete(next);
} catch (e) {
setError(`Could not save config: ${(e as Error).message}`);
}
if (step === "theme") {
return (
<ThemeStep
initialValue={data.theme}
onPreview={setPreviewTheme}
onSubmit={(theme) => {
setData((d) => ({ ...d, theme }));
setStep(existingApiKey ? "preset" : "apiKey");
}}
/>
</StepFrame>
);
}
if (step === "apiKey") {
return (
<ApiKeyStep
onSubmit={(key) => {
setData((d) => ({ ...d, apiKey: key }));
setError(null);
setStep("preset");
}}
error={error}
onError={setError}
/>
);
}
if (step === "preset") {
return (
<StepFrame title={t("wizard.presetTitle")} step={1} total={3}>
<SingleSelect<PresetName>
items={presetItems()}
initialValue={data.preset}
onSubmit={(preset) => {
setData((d) => ({ ...d, preset }));
setStep("mcp");
}}
/>
<Box marginTop={1}>
<Text dimColor>{t("wizard.selectFooter")}</Text>
</Box>
</StepFrame>
);
}
if (step === "mcp") {
return (
<StepFrame title={t("wizard.mcpTitle")} step={2} total={3}>
<MultiSelect
items={mcpItems()}
initialSelected={data.selectedCatalog}
onSubmit={(selected) => {
setData((d) => ({ ...d, selectedCatalog: selected }));
const needsArgs = selected.some((name) => CATALOG_BY_NAME.get(name)?.userArgs);
setStep(needsArgs ? "mcpArgs" : "review");
}}
footer={t("wizard.mcpFooterMulti")}
/>
</StepFrame>
);
}
if (step === "mcpArgs") {
const pending = data.selectedCatalog.filter((name) => {
const entry = CATALOG_BY_NAME.get(name);
return entry?.userArgs && !data.catalogArgs[name];
});
if (pending.length === 0) {
setStep("review");
return null;
}
const currentName = pending[0]!;
const entry = CATALOG_BY_NAME.get(currentName)!;
return (
<McpArgsStep
entry={entry}
error={error}
onSubmit={(value) => {
setData((d) => ({
...d,
catalogArgs: { ...d.catalogArgs, [currentName]: value },
}));
setError(null);
}}
onError={setError}
/>
);
}
if (step === "review") {
const specs = data.selectedCatalog.map((name) => buildSpec(name, data.catalogArgs));
return (
<StepFrame title={t("wizard.reviewTitle")} step={3} total={3}>
<Box flexDirection="column">
<SummaryLine
label={t("wizard.reviewLabelLanguage")}
value={LANGUAGE_LABELS[data.language]}
/>
<SummaryLine label={t("wizard.reviewLabelApiKey")} value={redactKey(data.apiKey)} />
<SummaryLine label={t("wizard.reviewLabelTheme")} value={data.theme} />
<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
<Box key={i} paddingLeft={14}>
<Text dimColor>· {spec}</Text>
</Box>
))}
<Box marginTop={1}>
<Text>{t("wizard.reviewSavesTo", { path: defaultConfigPath() })}</Text>
</Box>
{error ? (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
) : null}
<Box marginTop={1}>
<Text dimColor>{t("wizard.reviewFooter")}</Text>
</Box>
</Box>
<ReviewConfirm
onConfirm={() => {
try {
const specsNow = data.selectedCatalog.map((name) =>
buildSpec(name, data.catalogArgs),
);
const prev = readConfig();
const next: ReasonixConfig = {
...prev,
apiKey: data.apiKey,
preset: data.preset,
theme: data.theme,
mcp: specsNow,
setupCompleted: true,
};
writeConfig(next);
setStep("saved");
onComplete(next);
} catch (e) {
setError(t("wizard.reviewSaveError", { message: (e as Error).message }));
}
}}
/>
</StepFrame>
);
}
return (
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
<Text bold color="green">
{t("wizard.savedTitle")}
</Text>
<Box marginTop={1}>
<Text>{t("ui.welcome")}</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>{t("wizard.savedFooter")}</Text>
</Box>
<ExitOnEnter onExit={exit} />
</Box>
);
}
})();
return <ThemeProvider name={previewTheme}>{content}</ThemeProvider>;
}
const THEME_NAMES = listThemeNames();
function ThemeStep({
initialValue,
onPreview,
onSubmit,
}: {
initialValue: ThemeName;
onPreview: (theme: ThemeName) => void;
onSubmit: (theme: ThemeName) => void;
}) {
const initialIndex = Math.max(0, THEME_NAMES.indexOf(initialValue));
const [index, setIndex] = useState(initialIndex);
const theme = useTheme();
useInput((_input, key) => {
if (key.upArrow) {
const next = (index - 1 + THEME_NAMES.length) % THEME_NAMES.length;
setIndex(next);
onPreview(THEME_NAMES[next]!);
} else if (key.downArrow) {
const next = (index + 1) % THEME_NAMES.length;
setIndex(next);
onPreview(THEME_NAMES[next]!);
} else if (key.return) {
onSubmit(THEME_NAMES[index]!);
}
});
// saved
return (
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
<Text bold color="green">
▸ Saved.
<Box flexDirection="column" borderStyle="round" borderColor={theme.tone.brand} paddingX={1}>
<Text bold color={theme.tone.brand}>
{t("wizard.themeTitle")}
</Text>
<Box marginTop={1}>
<Text>Run `reasonix` any time to start chatting — your settings are remembered.</Text>
<Text dimColor>{t("wizard.themeSubtitle")}</Text>
</Box>
<Box marginTop={1} flexDirection="column">
{THEME_NAMES.map((name, i) => (
<Box key={name}>
<Text color={i === index ? theme.tone.brand : undefined}>
{i === index ? "▸ " : " "}
</Text>
<Text bold={i === index} color={i === index ? theme.fg.strong : theme.fg.body}>
{name}
</Text>
<Text color={theme.fg.meta}>{" — "}</Text>
<Text color={theme.fg.meta}>{t(`wizard.themeCaption.${name}`)}</Text>
</Box>
))}
</Box>
<Box
marginTop={1}
flexDirection="column"
borderStyle="round"
borderColor={theme.fg.faint}
paddingX={1}
>
<Text color={theme.fg.meta}>{t("wizard.themeSampleHeading")}</Text>
<Box marginTop={1}>
<Text color={theme.tone.accent}>{"◆ "}</Text>
<Text color={theme.tone.accent}>Reasoning</Text>
</Box>
<Box>
<Text color={theme.tone.info}>{"▣ "}</Text>
<Text color={theme.fg.body}>{"fs.readFile("}</Text>
<Text color={theme.tone.ok}>{'"main.ts"'}</Text>
<Text color={theme.fg.body}>{")"}</Text>
</Box>
<Box>
<Text color={theme.fg.meta}>~/project/main.ts:42</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.tone.ok}>ok</Text>
<Text color={theme.fg.faint}>{" · "}</Text>
<Text color={theme.tone.warn}>warn</Text>
<Text color={theme.fg.faint}>{" · "}</Text>
<Text color={theme.tone.err}>err</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>[Enter] to exit</Text>
<Text dimColor>{t("wizard.themeFooter")}</Text>
</Box>
<ExitOnEnter onExit={exit} />
</Box>
);
}
// ---------- step components ----------
function LanguageStep({
initialValue,
onSubmit,
}: {
initialValue: LanguageCode;
onSubmit: (lang: LanguageCode) => void;
}) {
const items: SelectItem<LanguageCode>[] = getSupportedLanguages().map((code) => ({
value: code,
label: LANGUAGE_LABELS[code],
hint: code === detectSystemLanguage() ? "(detected)" : undefined,
}));
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text bold color="cyan">
{t("wizard.languageTitle")}
</Text>
<Box marginTop={1}>
<Text dimColor>{t("wizard.languageSubtitle")}</Text>
</Box>
<Box marginTop={1}>
<SingleSelect<LanguageCode>
items={items}
initialValue={initialValue}
onSubmit={onSubmit}
footer={t("wizard.selectFooter")}
/>
</Box>
</Box>
);
}
function ApiKeyStep({
onSubmit,
error,
@@ -235,16 +418,16 @@ function ApiKeyStep({
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text bold color="cyan">
Welcome to Reasonix.
{t("wizard.welcomeTitle")}
</Text>
<Box marginTop={1}>
<Text>Paste your DeepSeek API key to get started.</Text>
<Text>{t("wizard.apiKeyPrompt")}</Text>
</Box>
<Text dimColor>Get one at: https://platform.deepseek.com/api_keys</Text>
<Text dimColor>Saved locally to {defaultConfigPath()}</Text>
<Text dimColor>{t("wizard.apiKeyGetOne")}</Text>
<Text dimColor>{t("wizard.apiKeySavedLocally", { path: defaultConfigPath() })}</Text>
<Box marginTop={1}>
<Text bold color="cyan">
{"key › "}
{t("wizard.apiKeyInputLabel")}
</Text>
<TextInput
value={value}
@@ -252,7 +435,7 @@ function ApiKeyStep({
onSubmit={(raw) => {
const trimmed = raw.trim();
if (!isPlausibleKey(trimmed)) {
onError("Doesn't look like a DeepSeek key. They start with 'sk-' and are 30+ chars.");
onError(t("wizard.apiKeyInvalid"));
setValue("");
return;
}
@@ -268,7 +451,7 @@ function ApiKeyStep({
</Box>
) : value ? (
<Box marginTop={1}>
<Text dimColor>preview: {redactKey(value)}</Text>
<Text dimColor>{t("wizard.apiKeyPreview", { redacted: redactKey(value) })}</Text>
</Box>
) : null}
</Box>
@@ -287,8 +470,54 @@ function McpArgsStep({
onError: (e: string | null) => void;
}) {
const [value, setValue] = useState("");
const [pendingCreate, setPendingCreate] = useState<string | null>(null);
useInput((input, key) => {
if (!pendingCreate) return;
const ch = input.toLowerCase();
if (ch === "y" || key.return) {
try {
mkdirSync(pendingCreate, { recursive: true });
const created = pendingCreate;
setPendingCreate(null);
setValue("");
onError(null);
onSubmit(created);
} catch (e) {
onError(
t("wizard.mcpArgsDirCreateFailed", {
path: pendingCreate,
message: (e as Error).message,
}),
);
setPendingCreate(null);
}
} else if (ch === "n" || key.escape) {
setPendingCreate(null);
onError(null);
}
});
if (pendingCreate) {
return (
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
<Box flexDirection="column">
<Text>{t("wizard.mcpArgsDirMissing", { path: pendingCreate })}</Text>
<Box marginTop={1}>
<Text dimColor>{t("wizard.mcpArgsDirCreateHint")}</Text>
</Box>
{error ? (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
) : null}
</Box>
</StepFrame>
);
}
return (
<StepFrame title={`Configure ${entry.name}`} step={2} total={3}>
<StepFrame title={t("wizard.mcpArgsTitle", { name: entry.name })} step={2} total={3}>
<Box flexDirection="column">
<Text>{entry.summary}</Text>
{entry.note ? (
@@ -297,7 +526,7 @@ function McpArgsStep({
</Box>
) : null}
<Box marginTop={1}>
<Text>Required parameter: </Text>
<Text>{t("wizard.mcpArgsRequiredParam")}</Text>
<Text bold>{entry.userArgs}</Text>
</Box>
<Box marginTop={1}>
@@ -311,9 +540,20 @@ function McpArgsStep({
onSubmit={(raw) => {
const trimmed = raw.trim();
if (!trimmed) {
onError(`${entry.name} needs a value — got an empty string.`);
onError(t("wizard.mcpArgsEmpty", { name: entry.name }));
return;
}
if (entry.name === "filesystem") {
const check = checkFilesystemPath(trimmed);
if (check.kind === "missing") {
setPendingCreate(trimmed);
return;
}
if (check.kind === "not-a-dir") {
onError(t("wizard.mcpArgsNotADir", { path: trimmed }));
return;
}
}
onSubmit(trimmed);
setValue("");
}}
@@ -330,6 +570,14 @@ function McpArgsStep({
);
}
function checkFilesystemPath(p: string): { kind: "ok" | "missing" | "not-a-dir" } {
try {
return { kind: statSync(p).isDirectory() ? "ok" : "not-a-dir" };
} catch {
return { kind: "missing" };
}
}
function ReviewConfirm({ onConfirm }: { onConfirm: () => void }) {
useInput((_i, key) => {
if (key.return) onConfirm();
@@ -344,8 +592,6 @@ function ExitOnEnter({ onExit }: { onExit: () => void }) {
return null;
}
// ---------- small bits ----------
function StepFrame({
title,
step,
@@ -360,9 +606,7 @@ function StepFrame({
return (
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Box>
<Text dimColor>
Step {step}/{total} ·{" "}
</Text>
<Text dimColor>{t("wizard.stepCounter", { step, total })}</Text>
<Text bold color="cyan">
{title}
</Text>
@@ -383,8 +627,6 @@ function SummaryLine({ label, value }: { label: string; value: string }) {
);
}
// ---------- data helpers ----------
function presetItems(): SelectItem<PresetName>[] {
return (["auto", "flash", "pro"] as const).map((name) => ({
value: name as PresetName,
@@ -396,7 +638,7 @@ function presetItems(): SelectItem<PresetName>[] {
function mcpItems(): SelectItem<string>[] {
return MCP_CATALOG.map((entry) => {
const hintParts: string[] = [entry.summary];
if (entry.userArgs) hintParts.push(`(you'll provide ${entry.userArgs})`);
if (entry.userArgs) hintParts.push(t("wizard.mcpUserArgsHint", { arg: entry.userArgs }));
if (entry.note) hintParts.push(entry.note);
return {
value: entry.name,
@@ -413,9 +655,6 @@ function placeholderFor(entry: CatalogEntry): string {
}
function deriveInitialCatalog(existingSpecs: string[]): string[] {
// Best-effort recovery: if the user previously picked catalog entries,
// the spec strings look like `name=npx -y <pkg> ...`. Match by package
// so reconfigure pre-checks the same boxes.
const packageToName = new Map(MCP_CATALOG.map((e) => [e.package, e.name]));
const out: string[] = [];
for (const spec of existingSpecs) {
@@ -436,12 +675,15 @@ 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}`;
}
function quoteIfNeeded(s: string): string {
return /\s|"/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
// Escape backslashes BEFORE quotes — otherwise a trailing `\` in the
// input would consume the closing quote when a downstream parser
// un-escapes the output (CodeQL js/incomplete-sanitization).
return /\s|"/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
}
-39
View File
@@ -1,39 +0,0 @@
/** Alt-screen lifecycle — restore must be idempotent and run on SIGINT/SIGTERM/exit so the user's main buffer always comes back. */
import { useEffect } from "react";
function isInteractiveTty(): boolean {
return Boolean(process.stdout?.isTTY);
}
export function useAltScreen(): void {
useEffect(() => {
if (!isInteractiveTty()) return;
// 1002 = button-event tracking (press + drag + release); 1006 = SGR coords.
// Shift+drag bypasses tracking for native terminal selection.
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H\x1b[?1002h\x1b[?1006h");
let restored = false;
const restore = () => {
if (restored) return;
restored = true;
try {
process.stdout.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?1049l");
} catch {
/* terminal closed */
}
};
process.once("exit", restore);
process.once("SIGINT", () => {
restore();
process.exit(130);
});
process.once("SIGTERM", () => {
restore();
process.exit(143);
});
return restore;
}, []);
}
-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>
);
}
+10 -8
View File
@@ -1,9 +1,7 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import 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";
@@ -16,16 +14,20 @@ 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";
import { WarnCard } from "./WarnCard.js";
export function CardRenderer({ card }: { card: Card }): React.ReactElement {
// No marginTop here — Card primitive owns inter-card spacing. Doubling it
// bakes 2 blank rows into every static-emitted block of bytes.
// Memoized so the cards array re-rendering (every store update) only
// reconciles cards whose object identity actually changed — the reducer
// keeps prior cards reference-stable, so unchanged history skips work.
export const CardRenderer = React.memo(function CardRenderer({
card,
}: { card: Card }): React.ReactElement {
return <Box flexDirection="column">{renderCard(card)}</Box>;
}
});
function renderCard(card: Card): React.ReactElement {
switch (card.kind) {
@@ -57,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} />;
}
+13 -10
View File
@@ -1,6 +1,7 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { t } from "../../../i18n/index.js";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { CtxCard as CtxCardData } from "../state/cards.js";
@@ -33,22 +34,24 @@ export function CtxCard({ card }: { card: CtxCardData }): React.ReactElement {
<CardHeader
glyph="⌘"
tone={TONE.brand}
title="context"
title={t("cardTitles.context")}
meta={[`${used.toLocaleString()} / ${cap.toLocaleString()} (${usedPct.toFixed(1)}%)`]}
/>
{row("system", card.systemTokens, card.systemTokens / cap, TONE.brand)}
{row("tools", card.toolsTokens, card.toolsTokens / cap, TONE.warn)}
{row("log", card.logTokens, card.logTokens / cap, TONE.ok)}
{row("input", card.inputTokens, card.inputTokens / cap, TONE.accent)}
{row(t("cardLabels.system"), card.systemTokens, card.systemTokens / cap, TONE.brand)}
{row(t("cardLabels.tools"), card.toolsTokens, card.toolsTokens / cap, TONE.warn)}
{row(t("cardLabels.log"), card.logTokens, card.logTokens / cap, TONE.ok)}
{row(t("cardLabels.input"), card.inputTokens, card.inputTokens / cap, TONE.accent)}
{card.topTools.length > 0 ? (
<>
<Text color={FG.faint}>
{`top tools · ${card.toolsCount} total · ${card.logMessages} log msgs`}
{`${t("cardLabels.topTools")} · ${card.toolsCount} ${t("cardLabels.tools")} · ${card.logMessages} ${t("cardLabels.logMsgs")}`}
</Text>
{card.topTools.slice(0, 5).map((t) => (
<Box key={`${t.turn}-${t.name}`} flexDirection="row" gap={1}>
<Text color={FG.sub}>{t.name}</Text>
<Text color={FG.faint}>{`· turn ${t.turn} · ${t.tokens.toLocaleString()}`}</Text>
{card.topTools.slice(0, 5).map((tool) => (
<Box key={`${tool.turn}-${tool.name}`} flexDirection="row" gap={1}>
<Text color={FG.sub}>{tool.name}</Text>
<Text
color={FG.faint}
>{`· ${t("cardLabels.turn")} ${tool.turn} · ${tool.tokens.toLocaleString()}`}</Text>
</Box>
))}
</>
Loaded 100 of 427 files, more files were not shown because too many files have changed in this diff. Show more