Compare commits

...
76 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
170 changed files with 12028 additions and 2488 deletions

No files matched your search

+652
View File
@@ -3,6 +3,658 @@
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.37.0] — 2026-05-10
**Headline:** boot splash + zh-CN status bar, MCP-handshake stall on
launch is gone (bridging deferred to first paint), card virtualization
keeps long sessions snappy, and four field-reported bugs that all
shared a "silent failure" shape — `/new` was overwriting the live
session file so prior transcripts vanished from the Sessions tab,
flat-format skills (`<dir>/<name>.md`) didn't appear in the dashboard
even though `/skill <name>` ran them, skills missing a `description:`
frontmatter were silently dropped from the prefix index so a new
session claimed they didn't exist, and the escalation contract told
every session it was running on flash so `/preset pro` self-reported
as flash when asked.
**Fixes:**
- fix(loop): `/new` truncated `~/.reasonix/sessions/code-<project>.jsonl`
in place — multiple `/new`s in a project produced exactly one
Sessions row and every prior turn was destroyed without warning.
`clearLog` now rotates the live jsonl plus sidecars to
`<name>__archive_<ts>` via `archiveSession` so the prior conversation
survives in the dashboard. The `__archive_` infix sits outside the
`${name}-` resume-prefix matcher so archives don't auto-resume on
next launch. `sessionName` is unchanged so the cache-first prefix
invariant holds. (#587, #590)
- fix(dashboard): `/api/skills` only walked folder-format skills
(`<dir>/<name>/SKILL.md`); flat-format skills (`<dir>/<name>.md`)
worked from `/skill <name>` in the TUI but the dashboard tab was
silently empty for users who installed them flat. The listing now
dispatches on `Dirent` and resolves both layouts; read / save /
delete share the same resolver so a flat skill can be edited or
removed from the dashboard without spawning a duplicate folder
entry. (#586, #589)
- fix(skills): a skill whose frontmatter omitted `description:` worked
in the install session (because `/skill <name>` calls `store.read`
directly) and silently disappeared the next session (because
`applySkillsIndex` filtered it out of the prefix). Two-layer fix:
the dashboard install POST validates frontmatter via the new
`validateSkillFrontmatter()` and returns 400 instead of writing a
skill the model will never see; `applySkillsIndex` now lists blank-
description skills with a placeholder line so the model can name
them and tell the user how to fix the frontmatter. (#583, #591)
- fix(prompt): `ESCALATION_CONTRACT` was a module-level const with
`deepseek-v4-flash` baked into the literal — interpolated into
`DEFAULT_SYSTEM`, `CODE_SYSTEM_PROMPT`, and `DEFAULT_SUBAGENT_SYSTEM`
at module load. A pro session got told it was running on flash and
answered honestly when asked which model it was. `escalationContract`
is now a function: pro tier gets a short "you are the escalation
tier; <<<NEEDS_PRO>>> is a no-op" note (no ladder, since pro can't
escalate to itself), other tiers get the full contract with the
actual model id interpolated plus an explicit "if asked which model
you are, answer `<id>`" line. The three system-prompt sites thread
the resolved session model through. The public `CODE_SYSTEM_PROMPT`
const is preserved for backward compat. (#582, #592)
- fix(ui): pressing `/` on the empty home screen left the bordered
WelcomeBanner mounted while `SlashSuggestions` rendered below — both
occupied the same flex column so the frame buffer interleaved them
and the welcome card border drew through the menu rows. The empty-
state guard now also requires `slashMatches === null`, so the
welcome card yields the moment the menu opens and returns when it
closes. (#594)
- fix(ui): wheel-up felt laggy because `schedule()` was trailing-edge —
every tick paid a 16 ms timer before any visual feedback, and on
top of Ink reconcile + Yoga layout a single tick cost 30-50 ms
before the frame moved. `schedule()` is now leading-edge so the
first delta lands immediately; subsequent calls inside the window
accumulate. Wheel/PgUp/PgDn step jumps from 3 → 8 rows so each
tick travels roughly a third of a viewport. (#571)
- fix(ui): the default frame flush was 16ms (60Hz), which on
winpty / MINTTY / ConEmu / tmux / high-latency SSH couldn't
atomically swap the cursor-up rewrite — the previous frame's
bottom rows briefly bled through every redraw, visible as
vertical bobbing. Default is now 50ms (20Hz); still reads as
continuous streaming, no bob on any affected terminal. The
`REASONIX_UI=plain` escape hatch (which suppressed every live row)
is removed since the new default addresses the same terminals
without losing the spinner / status line / live cards. Override
via `REASONIX_FLUSH_MS=16` for terminals with atomic frame swap.
(#570)
**Features:**
- feat(ui): boot splash for `reasonix code` / `reasonix chat`. Cold
launch used to flash the alt-screen blank for a few hundred ms
before AppInner's first paint completed; users read that as a
freeze. The splash holds for one whale-spout cycle (~1.4s) so the
REASONIX wordmark lands cleanly and AppInner's heavy first-paint
cost (~150 hooks + several disk reads) hides under it. ANSI Shadow
block letters in brand color; three-tone shaded whale silhouette
with a 7-frame spout cycle and a shifting wave below. Setup screen
and SessionPicker bypass the splash. (#588)
- feat(i18n): status bar, input placeholder, edit-mode hints, and
composer prompts route through `t()` with zh-CN coverage. Final
pieces of the chat surface that were still hardcoded English —
turn / cache / spent / left / slow / disconnect labels in
StatusRow, the "ask anything..." placeholder and "⏎ send · ^C quit"
hint in PromptInput, and the REVIEW / AUTO / YOLO mode label in
LiveRows. (#584)
**Perf:**
- perf(boot): MCP bridging moved from `chatCommand`'s pre-render
serial loop to an App.tsx mount-time effect that runs in the
background. Each `runtime.addSpec(raw)` handshake is 100ms-2s; users
with several servers configured used to watch a black alt-screen
until the last one finished. The UI now paints immediately, MCP
lifecycle events surface as in-app toasts via `log.pushInfo` /
`log.pushWarning`, and `loop.prefix.addTool` hot-adds tools as
they bridge — first turn after bridging is one cache-miss, same as
the existing `/mcp browse install` path. (#585)
- perf(ui): card virtualization. Yoga used to lay out every card in
CardStream's inner Box on every scroll tick — for a 50-card
history that's hundreds of rows re-measured per tick. Each card
now reports its measured height to the chat-scroll store and
CardStream collapses off-viewport ranges into a single spacer Box,
so only the 5-10 cards under the viewport (± a 30-row buffer) go
through Yoga per scroll. Streaming and freshly-mounted cards always
render live for measurement. (#574)
- perf(ui): scroll state isolated from App.tsx via
`chat-scroll-store` (same `useSyncExternalStore` pattern as the
agent store). Wheel/arrow ticks no longer re-render AppInner's
3,800 lines / 122 hooks per tick — only `CardStream` and the
position indicator. The static `↑ earlier` hint is now a live
position indicator (`↑ N / M rows above — K more`) that briefly
highlights on each applied delta so the user gets instant
confirmation. (#573)
## [0.36.2] — 2026-05-09
**Headline:** stability sweep on field-reported crashes and freezes —
TUI no longer tears down on `/model` / `/sessions`, Esc and `/new`
recover from a stuck plan checkpoint, the dashboard chat tab survives
long streaming turns, plan-card spinners can't strand themselves on a
missed end-event, and the model can't infer its identity from a
foreign agent platform's data dir at the workspace root. New `/theme`
picker for one-keystroke theme switching.
**Fixes:**
- fix(tui): a card-stream layout feedback loop (the `↑ earlier` hint
conditionally rendered as a sibling of the measured outer Box) tied
`outer.height` to `scrollRows`. Opening `/model` or `/sessions` —
which mounts a picker that shrinks the outer column by 10+ rows in
a single commit — could stack the cycle deep enough to trip React's
`MAX_NESTED_UPDATES = 50`, raising "Maximum update depth exceeded"
inside ink's `useBoxMetrics` and tearing down the TUI. The hint row
is now reserved unconditionally so its visibility no longer feeds
back into measurement. (#549)
- fix(tui): `pauseGate.ask` ignored AbortSignal — when a tool was
awaiting the gate (e.g. `mark_step_complete` → `plan_checkpoint`)
and the user pressed Esc, the gate's promise stayed pending forever,
`busy` stayed true, the prompt stayed disabled, and `/new` was
silently dropped by `handleSubmit`'s `if (busy) return` guard. New
`pauseGate.cancelAll()` resolves every outstanding request with its
kind's safe-cancel verdict; Esc-during-busy and `/new` both flush
pending modals through it so the awaiting tool fn returns cleanly
and the user can recover. (#552)
- fix(prompt): when the workspace root contained another agent
platform's config (`SOUL.md`, `skills/`, `memories/`, a foreign
`REASONIX.md`) the model would browse those files and claim a
layered architectural relationship — "the underlying runtime is
Hermes Agent" or similar. Top-of-prompt identity guard names the
failure mode: workspace files describe the user's project, never
what Reasonix is; identity questions are answered from the prompt,
not from `ls`. Plus a launch-time detector that warns when those
markers sit at the workspace root, suggesting `--dir <real-project>`.
(#555)
- fix(dashboard): the embedded chat tab triggered Chrome's "Page not
responding" dialog during long sessions and concurrent jobs. Each
`assistant_delta` (~20/sec, more under fan-in) called setState
synchronously, re-rendering every historical `ChatMessage` with no
memoization — every delta re-ran `marked.parse` and `hljs.highlight`
on unchanged content. Memoized `ChatMessage` via `preact/compat`
`memo`, stabilised the per-row `streaming` prop so memo's shallow
compare actually bails out, and rAF-coalesced delta accumulation so
the streaming bubble re-renders at most once per frame regardless
of delta volume. (#560)
- fix(loop): tool-card spinners occasionally kept spinning after the
underlying work had finished — the `running` flag was set
imperatively from paired events, and any exit path that forgot to
emit the closing event (storm-breaker, network drop, parent abort
propagating, hook block) left the card stuck. Replaced with a
finally-guaranteed `InflightSet` on the loop: tools are added at
dispatch entry and deleted in `finally` regardless of how the call
exits. UI tool cards consult the set via `useIsInflight(card.id)`
for the spinner, decoupling running-or-not from end-event delivery.
(#566)
**Features:**
- feat(ui): bare `/theme` opens a SingleSelect picker listing `auto`
+ every registered theme; `/theme <name>` keeps its existing
persist-and-report behaviour. (#543, contributed by @J3y0r;
re-landed via #567 after rebasing onto current main)
## [0.36.1] — 2026-05-09
**Fixes:**
- fix(slash): the slash-suggestion picker sorts by usage frequency, but
the Enter-time substitution recomputed the list without that sort,
so the shared selection index dereferenced a differently-ordered
list — the highlighted row and the command that ran could disagree.
Both calls now share the same ordering. (#547)
## [0.36.0] — 2026-05-09
**Headline:** terminal-compatibility + interaction-loss fixes from
0.35.0 field reports. Mouse wheel now scrolls chat on cloud / web /
SSH terminals (xterm.js, code-server, Cloud Shell, mobile SSH apps,
tmux without `mouse on`) via DECSET 1007 alternate-scroll, with
native drag-to-select restored on Konsole and friends — no Shift
bypass needed because we're not enabling full mouse tracking.
Render ghosting on CJK / emoji-heavy output goes away (Ink
incrementalRendering off so each frame is a single full-screen redraw
inside the BSU/ESU envelope). Pasting a multi-line block stops firing
one agent call per line on hosts where bracketed-paste markers get
stripped — the parser now wraps unbracketed multi-line chunks in
synthetic markers so the existing accumulator delivers exactly one
paste event. Plan-mode Refine finally pipes the user's typed feedback
to the model instead of dropping it on the floor (PlanVerdict was
missing a feedback field, the rich `synthetic` text was built and
discarded). Web dashboard recovers canonical state on SSE reconnect
so a missed end-of-turn event no longer wedges the page on busy=true
forever.
Plus a setup-wizard theme-picker step with live preview, "did you
mean /…?" suggestions on slash typos, install-source-aware
`reasonix update` (no more forced `npm install -g` for bun/pnpm
users), zh-CN coverage extended to the card components, Windows PATH
normalized before `spawn`, slash-popover windowing stabilized, semver
compare on the dashboard up-to-date check, and self-hosted DeepSeek
endpoints with non-standard key prefixes accepted.
**Features:**
- feat(ui): nearest-slash-command suggestion on typos. Slash typos
produce an inline "did you mean `/<closest>`?" hint instead of
silently dropping. (#302)
- feat(wizard): theme-picker step with live preview during setup.
Previously users had to learn `/theme` after the fact and try
themes blind. (#518)
- feat(update): `reasonix update` respects the install source
(npm / yarn / pnpm / bun) instead of always forcing `npm install
-g`. Stops bun-installed users from getting a stale global from a
different package manager. (#511)
- feat(i18n): card component labels route through zh-CN. Final TUI
surface (status / context / streaming / tool / search / reasoning
/ sub-agent / usage cards) localized — closes the English-residue
gap from prior i18n passes. (#526)
**Fixes:**
- fix(slash): hoist hooks above early returns. SlashSuggestions had
`useColor` / `useStdout` / `useState` before two early-return
branches and `useEffect` after, so when matches flipped between
non-empty and null/empty across renders React saw a different hook
count and threw "Rendered more hooks than during the previous
render", killing the entire TUI mid-session. Triggered by everyday
slash editing (typo → backspace → typing again). Hoisted the
effect + windowStart math above the returns. (#538)
- fix(tui): wheel scroll on cloud / web / SSH terminals via DECSET
1007. Old code relied on the implicit "terminal translates
wheel→↑/↓ in alt-screen" behavior — only on by default in xterm /
iTerm / Windows Terminal / Alacritty / Kitty. Web/cloud terminals
ship with it off, leaving the wheel as a dead key. Explicit DECSET
1007 alternate-scroll routes wheel through the existing ↑/↓ chat-
scroll handler without enabling full mouse tracking, so native
drag-select + right-click stay 100% intact (no Shift bypass).
Paired with `incrementalRendering: false` to drop render ghosting
on CJK / emoji-heavy output. `--no-mouse` opts out. (#529, partial
mitigation for #412, fixes #519, #531)
- fix(tui): rescue unbracketed pastes so multi-line content stops
firing N submits. Bracketed-paste markers (DECSET 2004) don't
reach the parser on every host — multiplexers strip them, some
web-SSH gateways drop them, certain Windows pipes never forward
them. Without them, each `\r` in a paste fires an Enter event
and the loop submits the partial buffer per line. Heuristic at
the parser entry wraps multi-line chunks in synthetic paste
markers when 2+ line breaks (or 1 break with text on both sides)
are present and no ESC bytes appear. Bare `\r` and `\r\n` stay
typed-Enter; "abc\r" stays type-then-Enter. (#536, closes #522)
- fix(plan): pipe user feedback through the Refine / Approve /
Cancel gate. PlanVerdict didn't carry a `feedback` field, so
the rich text typed in PlanRefineInput was built into a
`synthetic` string and never sent. Model received bare "user
requested refinement" tool error and proposed a near-identical
plan, looking like the suggestion was ignored. PlanVerdict now
matches CheckpointVerdict's shape and surfaces feedback as the
tool result string. (#534, closes #533)
- fix(dashboard): resync canonical state on SSE reconnect. The
`/api/events` stream snapshots only `busy-change` on (re)connect.
When the connection dropped during a long task — proxy timeout,
browser background-tab throttle, Node event loop blocked past
the 25s ping window during heavy work — every assistant_delta /
assistant_final / tool / modal event fired during the disconnect
window was lost. If the disconnect happened before
`busy-change(false)`, the UI wedged on busy forever. EventSource
`onopen` now refetches `/api/messages` + `/api/modal` on every
reconnect. (#532, closes #521)
- fix(tui): drop xterm mouse tracking — restore native copy/paste,
rebind keys. Multiple users reported they couldn't copy text or
scroll with SGR mouse-tracking modes enabled. ↑/↓ always scroll
chat now; Ctrl+P / Ctrl+N take over what ↑/↓ used to do in
PromptInput (cursor up/down inside multi-line draft, falls back
to prompt history). Pickers still own ↑/↓ while open. Superseded
by #529's DECSET 1007 approach but the rebinding stands. (#514)
- fix(shell): normalize Windows PATH env before spawn. PowerShell
passed PATH with trailing semicolons that broke `where` and
downstream tool resolution on certain Windows builds. (#525,
closes #520)
- fix(slash): stabilize suggestions windowing + isolate status row
layout. Slash-suggestion popover was reflowing on every typed
character; status row width changes were leaking up into the
composer. (#516)
- fix(config): honor `config.baseUrl` + accept self-hosted key
formats. Self-hosted DeepSeek-compatible endpoints with non-
standard key prefixes were rejected by client-side validation.
(#513)
- fix(dashboard): use semver compare for up-to-date check. Lexical
string compare flagged 0.35.0 as older than 0.5.10. (#512)
- fix(semantic): unblock Build when daemon is up but binary lookup
fails. Build path was throwing on daemon start when the embedding
binary wasn't where the registry expected it. (#507)
**Performance:**
- perf(tui): streaming flush rate tuned to 60Hz default. Earlier
landed at 20Hz to suppress repaint glitches on fragile terminals
then raised to 60Hz once frame pacing was proven stable.
`REASONIX_FLUSH_MS` overrides for hosts that need it. (#515, #517)
## [0.35.0] — 2026-05-09
**Headline:** the agent gains the ability to extend itself from chat,
and bug reporting collapses from a multi-tab scavenger hunt into one
slash. `create_skill` and `add_mcp_server` are first-class tools — "add
a skill that runs typecheck before commits" or "wire up a postgres MCP
server" now works as a normal chat request, with structured args
(description / `runAs` / `allowed-tools` / `model` for skills; transport
+ command + args + catalog hydrate for MCP) so the model never writes
raw YAML or hand-crafts a `name=…` spec. Both reuse the same
persistence paths the wizard / `/skill new` already use, so on-disk
shape stays one source of truth.
`/feedback` opens GitHub's new-issue page with an 11-field diagnostic
block (version + latest-version compare + platform + terminal env
markers including WT_SESSION/TMUX/SSH/WSL + cols×rows + theme + edit /
plan mode + MCP count + session) **pre-filled in the textarea via
`?body=`** — clipboard stays as belt-and-suspenders. The status row
shows a `v<VERSION> · ⚑ /feedback` chip at cols ≥ 100 for
discoverability. Diagnostic block is locked by a test that pins the
exact field set so future additions can't sneak in unannounced.
Plan mode finally surfaces the open-questions block it was already
flagging. The banner detected `Open Questions` / `Risks` / `Unknowns`
headings since 0.30, but the actual questions were swallowed by either
the step list or the 24-line body cap. Now the extracted block renders
under the banner regardless, and refines pre-fill the questions above
the input. Whole plan flow (PlanConfirm / PlanRefineInput /
PlanCheckpointConfirm / PlanStepList) moves through `t()` — the i18n
gap the issue called out is closed.
Read tooling gets sharper: `read_file` auto-preview now embeds a
top-level export outline so callers can pick a `range` without a
follow-up grep, and `search_content` adds a per-file cap + a histogram
fallback so a single high-frequency hit can't drown the result. The
subagent loop now sees its own iter budget and gets a near-cap
countdown.
Plus: dashboard typography pass (sidebar 240→260px column, body
12.5→15px, section headers tightened), cache-hit percentages now show
1-decimal precision across CLI + dashboard, Usage panel chart fully
i18n'd, `spawn_subagent` tool result body finally renders as markdown
instead of literal `**`/`##`/code-fences in the JSON envelope.
**Features:**
- feat(tools): `create_skill` + `add_mcp_server` — let the model
scaffold from chat. `create_skill` pre-fills frontmatter
(`description` / `runAs` / `allowed-tools` / `model`) from structured
args; `add_mcp_server` builds `name=…` specs for stdio / sse /
streamable-http with `from_catalog` shortcut for bundled entries,
runs the existing preflight, refuses name collisions. Both register
alongside native filesystem / shell tools in `reasonix code`.
(#498, closes #494)
- feat(ui): `/feedback` + version badge in the status row. Slash
collects an 11-field diagnostic (terminal env / size / theme / edit
+ plan mode / MCP / model + effort / version-vs-latest / session),
opens GitHub's new-issue URL with the body pre-filled via
`?body=<urlencoded>`, falls back to clipboard. StatusRow shows
`v<VERSION>` at cols ≥ 70 and adds a `· ⚑ /feedback` hint at
cols ≥ 100. Field set is locked by test. (#501, closes #499)
- feat(tools): `read_file` auto-preview embeds a top-level export
outline. When the file is > 200 lines and no `head` / `tail` /
`range` was given, the elision marker now also lists function /
class / const / interface / type / enum names with their line
numbers (capped at 30 entries with elision). Callers can pick a
meaningful `range` without a follow-up `search_content`. (#490,
closes #487)
- feat(search): `search_content` per-file cap + histogram fallback.
When a single file dominates the result (typical: a generated lock
file or a long log), the new per-file cap clips its share and the
histogram footer shows the per-file distribution so callers can
re-query against a specific file instead of widening the cast. (#495,
closes #489)
- feat(subagent): tell the child its iter budget; warn near the cap.
The child loop now sees its `maxToolIters` budget in the system
prompt (replaces the static "Cap at 6-8 tool calls" prose), and the
parent injects a remaining-iter hint into tool results once budget
is tight (`[budget: 3 of 20 tool calls left — wrap up soon]`).
Stops the explore-burns-17-iters-then-truncates-mid-thought failure
mode. (#493, closes #488)
**Fixes:**
- fix(plan): surface the open-questions block under the banner; i18n
the plan flow. The `Open Questions` / `Risks` / `Unknowns`
detection regex already fired but the block was swallowed by the
step list or the 24-line body cap. Extract via
`extractOpenQuestionsSection` and render under the banner regardless
of `steps` / cap; thread the questions into `PlanRefineInput`
above the input on `mode === "refine"`. Move `PlanConfirm` /
`PlanRefineInput` / `PlanCheckpointConfirm` / `PlanStepList` strings
through `t()` under a new `planFlow` namespace in EN + zh-CN.
Replace the blank-refine synthetic that asks the model to re-derive
questions with one that tells it to pick safe defaults. (#497,
closes #477)
- fix(ui): render `spawn_subagent` tool result body as markdown.
`formatSubagentResult` returns a JSON envelope with the child's
final answer in `output`; `ToolCard` rendered the JSON-stringified
body as raw `<Text>`, so `## headers`, `**bold**`, fenced code
blocks all leaked through as literal characters. Special-case
`card.name === "spawn_subagent"`: parse the envelope, pass `output`
through the same `Markdown` component the streaming reply uses;
fall back to the line-tail loop on parse failures and `success:
false`. (#496, closes #491)
- fix(dashboard): bump doc-chrome typography; widen sidebar column.
Sidebar 240 → 260px (so 2–3 word section labels fit without
mid-word wraps), section headers 10 → 12px with tracking 0.14em →
0.08em, links 12.5 → 14px with `line-height: 1.4` and
`overflow-wrap: anywhere`, body copy 12.5 → 15px, `.swatch .hex` /
`.scale-row .lbl` 10.5 → 11.5px. Mirrored verbatim into
`docs/design/agent-dashboard.html`. (#500, closes #461)
- fix(ui): improve cache hit percentage display + Usage chart i18n.
Cache-hit ratio now shows 1-decimal precision (85.6% rather than
86%) across the dashboard sidebar, the Stats panel, and `/status`.
Usage panel chart axes (`USD` / `turns` / `time`) and series labels
(`cost` / `cache saved` / `turns`) move through `t()` — they were
hardcoded English. Adds the missing `colWindow` header (was an
empty `<th>`), promotes numeric columns to right-aligned tabular
numerals at the header level, not just the body. Thanks
@kabaka9527. (#503)
## [0.34.1] — 2026-05-09
**Headline:** scroll lag fix for long sessions. `useChatScroll` was
calling `setScrollRows` synchronously on every PgUp / PgDn / arrow /
wheel tick, so a single mouse-wheel gesture (10–30 events on Windows)
triggered 10–30 full Yoga layout passes over the entire `CardStream`
subtree. Layout cost scales linearly with card count — that's why the
lag worsened the longer the session ran. Coalesce deltas into a ref
and flush once per ~16ms; one scroll burst now produces one render
regardless of event volume. `End` (`jumpToBottom`) cancels any
pending delta so it stays instant. Reported in #482 by @GyroChen.
The deeper fix — pre-rendering cards to a row buffer so Yoga isn't on
the scroll/streaming hot path at all — is tracked separately and
covers the streaming-redraw lag too.
**Fixes:**
- `chat-scroll`: coalesce wheel/key events into one render per ~16ms
frame; long-session scroll no longer scales O(history) (#485, closes
#482)
## [0.34.0] — 2026-05-09
**Headline:** two big UX shifts in the composer. The `@`-mention picker
is rebuilt as a streaming file browser — `@` alone shows the immediate
directory listing, anything you type fires a cancelable walk that
streams matches in as it finds them, with a `searching… N scanned`
footer. Fixes the unusable-on-large-repos behavior reported by
@xlingyun8-maker (5000 files would evict 90% before ranking, picker
showed nothing). The mouse wheel now scrolls chat history regardless of
where the cursor is, via SGR mouse tracking — wheel events route
through `mouseScrollUp/Down` instead of being mistranslated as ↑/↓ by
Windows Terminal / ConPTY.
The supporting cast: a structured `TipCard` variant replaces the
multi-line text crammed into a step-progress card (the existing
edit-gate hint reported as ugly), a real `/keys` command with the full
keyboard + mouse + copy-paste reference (was a dangling reference in
the edit-gate tip footer for months), and a one-time mouse/clipboard
tip on first launch so users don't think the prompt is broken when
right-click stops doing the terminal's native paste.
Critical bug fix at the bottom: dashboard was silently overwriting
CLI-side `/language` changes by pushing localStorage back to the
server on every page load.
**Features:**
- feat(at-picker): rebuild as file browser with streaming search.
Empty / trailing-slash queries (`@`, `@some/dir/`) browse one
directory level via a single `readdir` — folders selectable, drill
with Tab. Any non-slash filter (`@foo`, `@auth/log`) kicks off a
cancelable streaming walk across the full tree, matches batch into
the popup as the walker finds them, footer shows scan progress
in flight. Drops the 500-file walker cap; cancellation bounds work
instead. New public API: `walkFilesStream` (streaming + abort),
`listDirectory` (single-level browse), `parseAtQuery` (dir/filter
split with trailing-slash awareness). `expandAtUrls` + helpers
split into `at-mentions-url.ts` to keep `at-mentions.ts` under the
800-line ceiling. (#479, closes #478)
- fix(scroll): route mouse wheel via SGR mouse tracking. Enable
DECSET 1006 + 1000 at startup so the terminal reports wheel events
as `\x1b[<btn;col;row;M` mouse sequences instead of translating
them to ↑/↓ key presses. The chat-scroll handler routes the
resulting `mouseScrollUp/Down` events to scrollback, bypassing the
arrow-key path entirely. ↑/↓ keys retain their existing PromptInput
bindings (history recall on empty buffer, cursor motion otherwise).
The SGR mouse parser already lived in `stdin-reader.ts`; this just
turns on the terminal-side feature. Cost: terminal-native drag-to-
select needs a modifier (Shift on Windows Terminal / Alacritty /
WezTerm, Option on iTerm2) — same convention as tmux, Claude Code,
Cursor's terminal. (#479)
- feat(ui): structured TipCard variant for onboarding hints. The
edit-gate one-time tip rendered as raw multi-line text inside a
`stepProgress` LiveCard — `✓` glyph (success semantic, wrong for
educational content) plus a manually-inlined `▸ TIP:` prefix,
columns aligned with hand-counted spaces that wrap badly on narrow
terminals. Replaces with a dedicated `TipCard` kind: single `ⓘ`
glyph in accent color, topic + "shown once" badge in a justified
header row, each row gets its own `<Text>` with column alignment
driven by `string-width` (CJK-correct), footer separated from body
by a blank row, no border. (#480)
- feat(ui): `/keys` reference + first-run mouse/clipboard tip.
`/keys` was already referenced in the edit-gate tip's footer ("Run
/keys anytime for the full list") but no handler existed; typing
`/keys` hit the unknown-command branch. Adds a multi-section
TipCard with the full keyboard / mouse / copy-paste / edit-gate
reference. Adds a first-run mouse + clipboard tip mirroring the
edit-gate pattern (suppressed thereafter via a
`mouseClipboardHintShown` flag) so users don't think the prompt
is broken when right-click stops doing the terminal's native
paste. TipCard now supports multiple sections; existing single-
section tips are unchanged. New i18n helper `tObj<T>(path)` for
structured translation entries. (#481)
**Bug fixes:**
- fix(dashboard): stop pushing localStorage lang back to server on
init. The dashboard's `initLangFromServer()` had a one-way sync
rule: when localStorage's lang differed from server config AND
localStorage was tagged "explicit", it POSTed localStorage's value
back, silently clobbering CLI-side `/language` changes whenever the
dashboard tab next loaded (including auto-restored tabs from
previous browser sessions). Server config is the single source of
truth now; localStorage stays as a render-cache to avoid first-paint
flicker but is never pushed back. Removes `EXPLICIT_KEY` /
`isExplicit` / `markExplicit` entirely. (#483)
## [0.33.2] — 2026-05-09
**Headline:** two bug fixes for #468 reported by @dacec354.
**Bug fixes:**
- fix(ui): ↑/↓ on an empty buffer recalls prompt history again. The
binding was unbound from arrows back in 9254d3a because Windows
Terminal + ConPTY can translate mouse-wheel events to ↑/↓
keystrokes (wheel-up was clobbering the prompt with a recalled
message); history moved to Ctrl+P / Ctrl+N. That was right for
legacy ConPTY but broke the universal CLI convention for
everyone else (bash / zsh / fish all bind ↑ to history). Restored
↑/↓ on empty buffer = history; Ctrl+P / Ctrl+N stays as the
wheel-immune fallback. Dead `chatScrollHandoff` plumbing dropped.
(#475, closes part 1 of #468)
- fix(doctor): tokenizer check now finds the file. The runtime
resolver in `tokenizer.ts` had three candidates including a
`createRequire("reasonix/package.json")` probe and worked
reliably; the doctor had its own copy of the path math that
walked `dist/cli/commands/doctor.js → ../../../data/`. After the
lazy-import refactor in #467 the doctor compiles to
`dist/cli/doctor-HASH.js` (one level shallower), so three `..`
walked above the package root and reported "tokenizer not
found" even when the npm tarball had it. Reuse the runtime
resolver so the two paths can never disagree. (#475, closes part
2 of #468)
## [0.33.1] — 2026-05-09
**Headline:** the bottom status row now shows the wallet. Both
`status.balance` and `status.sessionCost` were already being
populated by the reducer (refreshed on every submit), and a
`balanceColor()` helper with red/orange thresholds had been sitting
unused in the theme — but `StatusRow` only ever rendered the
per-turn cost and cache-hit pills. Pure plumbing gap; users had
to type `/cost` to see the running spend or remaining DeepSeek
balance. Plus a small polish pass on the prompt input footer.
**UI:**
- feat(ui): wallet pill on the status row. New segment renders
right of the cache pill: `⛁ ¥1.20 spent / ¥45.32 left`. Spent
shows when `sessionCost > 0`, balance shows when known; the
separator only renders when both are present. Balance is colored
via `balanceColor()` (red <¥5, orange <¥20, brand otherwise).
Hidden on terminals narrower than 90 cols so the row doesn't
wrap. (#473)
- feat(ui): friendlier prompt input. Placeholder reads "ask
anything · slash for commands · at-sign for files" instead of
"type a message". Hint footer extracted into a `HintRow`
component with keycap/label spacing — keys (⏎ ⇧⏎ ↑↓ esc ^C) in
`FG.meta`, labels in `FG.faint`. Replaces `shift/alt+⏎` with
`⇧⏎` and `ctrl-c` with `^C`. (#473)
## [0.33.0] — 2026-05-09
**Headline:** the filesystem toolbelt grew a hand. Three new tools —
+49 -60
View File
@@ -9,6 +9,8 @@
&nbsp;·&nbsp;
<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;
<a href="./benchmarks/">Benchmarks</a>
@@ -46,49 +48,6 @@
<br/>
## Web search
Reasonix includes `web_search` and `web_fetch` tools. By default it uses **Mojeek** (no setup required). You can switch to a **self-hosted SearXNG** instance — a metasearch engine that aggregates whatever upstream engines your instance is configured for.
### Switching engines (persists to disk)
The `/search-engine` slash command (alias `/se`) writes your choice to `~/.reasonix/config.json` immediately — it survives restarts:
```
/search-engine mojeek # default, no external deps
/search-engine searxng # SearXNG at http://localhost:8080
/search-engine searxng http://192.168.1.100:8888 # custom endpoint
```
Equivalent `~/.reasonix/config.json`:
```json
{
"webSearchEngine": "searxng",
"webSearchEndpoint": "http://localhost:8080"
}
```
The tool picks up the change on the next call — no restart needed.
### Starting SearXNG
```sh
podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng
# or: docker run -d -p 8080:8080 searxng/searxng
```
Verify it's running:
```sh
curl http://localhost:8080/search?q=test
# → HTML search results page
```
> **Note:** The endpoint must include the protocol (`http://`). `localhost:8080` alone will fail — the tool will show a clear error telling you to install SearXNG if the server is unreachable.
<br/>
## Install
```bash
@@ -96,38 +55,68 @@ 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 picks up the latest version. If you'll use Reasonix daily and want `reasonix` on your `PATH`, run `reasonix update` once and it'll do the `npm install -g` for you.
`npx` is the recommended path — no global install, always latest. If you use Reasonix daily and want it on `PATH`, run `reasonix update` once.
### Subcommand cheatsheet
| Command | When to use |
| Command | When |
|---|---|
| `reasonix code [dir]` | Coding agent rooted at a project. **Start here.** |
| `reasonix chat` | Plain chat — no filesystem tools, just a conversation with persisted history. |
| `reasonix run "task"` | One-shot, streams the answer to stdout. Good for shell pipes. |
| `reasonix doctor` | Environment health check (Node version, API key, MCP wiring). |
| `reasonix 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 listed in `reasonix --help` and on the [CLI reference](https://esengine.github.io/DeepSeek-Reasonix/#cli).
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).
**Working in a different folder:** Reasonix scopes filesystem tools to the launch directory. To work elsewhere, pass `--dir`:
<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 # or use a relative path
npx reasonix code --dir /path/to/project
```
Mid-session switching isn't supported by design (the message log + memory paths get tangled with stale roots). Quit and relaunch with a new `--dir` to retarget. `/status` always shows the current pinned workspace.
**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.
**Author your first skill:** Skills are markdown playbooks the model can invoke (`/skill <name>`). There's no remote registry yet — you author them directly:
| 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 # scaffolds <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # or under ~/.reasonix/skills for cross-project use
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # ~/.reasonix/skills for cross-project use
```
Edit the file (`description:` frontmatter + body), then `/skill list` to see it. Add `runAs: subagent` to the frontmatter to spawn an isolated subagent loop instead of inlining the body.
</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/>
+49 -17
View File
@@ -9,6 +9,8 @@
&nbsp;·&nbsp;
<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;
<a href="./benchmarks/">基准测试</a>
@@ -50,38 +52,68 @@ 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` 就行,它会替你跑 `npm install -g`。
`npx` 是推荐路径 —— 不用全局安装,每次都拿最新版。如果你天天用、想把 `reasonix` 装到 `PATH`,跑一次 `reasonix update`。
### 子命令速查
| 命令 | 适用场景 |
| 命令 | 何时用 |
|---|---|
| `reasonix code [dir]` | 锁在某个项目根目录的编码 agent。**先用这个。** |
| `reasonix chat` | 纯聊天 —— 不挂文件系统工具,只是带历史的对话。 |
| `reasonix run "task"` | 一次性,把答案直接流到 stdout。适合 shell 管道。 |
| `reasonix doctor` | 环境体检(Node 版本、API Key、MCP 接线)。 |
| `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)。
其他子命令(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)在 `reasonix --help` 和 [CLI 参考](https://esengine.github.io/DeepSeek-Reasonix/#cli)。
**在其他目录工作:** Reasonix 把文件系统工具作用域绑定在启动目录。要在别的目录工作,传 `--dir`:
<details>
<summary><strong>切换工作区 · chat vs. code · 写第一个 Skill</strong></summary>
**切换工作区。** Reasonix 把文件系统工具作用域绑定在启动目录,传 `--dir` 可以指别处。中途切换是有意不支持的(消息日志和 memory 路径会和旧根目录混在一起)—— 退出再启动。
```bash
npx reasonix code --dir /path/to/project # 也可以用相对路径
npx reasonix code --dir /path/to/project
```
中途切换工作区是有意不支持的(消息日志和 memory 路径会和旧的根目录混在一起,状态错乱)。退出后用新的 `--dir` 重新启动来切换。`/status` 始终显示当前锁定的工作区。
**`chat` 还是 `code`?** `code` 是默认入口、唯一带文件系统 / shell 工具和 SEARCH/REPLACE 审阅的模式。`chat` 是更轻量的纯对话壳——想要一个挂着 MCP 但没有磁盘权限的“思路助手”时用它。
**写第一个 Skill:** Skills 是模型可以调用的 markdown 剧本(`/skill <name>`)。暂无在线市场 —— 自己写:
| 你拿到什么 | `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,跨项目共用
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # ~/.reasonix/skills,跨项目共用
```
编辑文件(`description:` frontmatter + 正文),然后 `/skill list` 就能看到。frontmatter 里加 `runAs: subagent` 会以独立 subagent 跑,而不是把正文内联进父 prompt。
</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/>
+16 -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); }
+7 -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";
@@ -300,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") {
@@ -321,7 +326,7 @@ export function ChatMessage({ msg, streaming }: ChatMessageProps) {
</div>
</div>
`;
}
});
//
// Each component renders a card matching the TUI's ModalCard accent
+7
View File
@@ -214,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",
},
+7
View File
@@ -211,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: "模型",
},
+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 {
+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;
}
+71 -11
View File
@@ -185,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 {
@@ -204,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,
@@ -266,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();
@@ -707,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)}
/>
`,
)
@@ -792,7 +852,7 @@ 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;
@@ -812,7 +872,7 @@ function SideRail({ stats, budgetUsd, activePlan }: SideRailProps) {
<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>
`
+3 -1
View File
@@ -2,6 +2,7 @@ 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 {
@@ -280,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";
+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">
+19 -13
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,
@@ -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>
+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": "针对指定路径的编程模式",
+236 -47
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,36 +518,76 @@ 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>
@@ -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;
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "reasonix",
"version": "0.30.5",
"version": "0.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reasonix",
"version": "0.30.5",
"version": "0.35.0",
"license": "MIT",
"dependencies": {
"cli-highlight": "^2.1.11",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.33.0",
"version": "0.37.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
+153
View File
@@ -0,0 +1,153 @@
/** @url mentions — async sibling of @path. Fetches each URL once and inlines under "Referenced URLs". */
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
/** Default cap on inlined URL body (chars). */
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
export interface AtUrlExpansion {
/** The raw `@url` token as it appeared in the text. */
token: string;
/** Absolute URL (after trailing-punctuation strip). */
url: string;
/** True if content was inlined. False = skipped (reason in `skip`). */
ok: boolean;
/** Page title when extractable from `<title>`. */
title?: string;
/** Char count of the (post-truncation) inlined body. */
chars?: number;
/** True iff the original page exceeded `maxChars` and was clipped. */
truncated?: boolean;
/** Why the mention was skipped — set when ok=false. */
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
/** Free-form error message attached to skip outcomes. */
error?: string;
}
export interface AtUrlOptions {
/** Max chars of inlined body per URL. */
maxChars?: number;
/** Per-URL fetch timeout in ms. */
timeoutMs?: number;
fetcher?: (
url: string,
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
cache?: Map<string, AtUrlExpansion & { body?: string }>;
/** Forward Esc/abort to the fetcher. */
signal?: AbortSignal;
}
export async function expandAtUrls(
text: string,
opts: AtUrlOptions = {},
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
const fetcher = opts.fetcher;
if (!fetcher) {
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
}
const seen = new Map<string, AtUrlExpansion>();
const bodies = new Map<string, string>();
const order: string[] = [];
for (const match of text.matchAll(AT_URL_PATTERN)) {
const rawUrl = match[1] ?? "";
const url = stripUrlTail(rawUrl);
if (!url) continue;
if (seen.has(url)) continue;
const cached = opts.cache?.get(url);
if (cached) {
seen.set(url, cached);
if (cached.body) bodies.set(url, cached.body);
order.push(url);
continue;
}
let expansion: AtUrlExpansion;
let body = "";
try {
const page = await fetcher(url, {
maxChars,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
});
body = page.text;
expansion = {
token: `@${url}`,
url,
ok: true,
title: page.title,
chars: body.length,
truncated: page.truncated,
};
} catch (err) {
const message = (err as Error).message ?? String(err);
let skip: AtUrlExpansion["skip"] = "fetch-error";
if (/aborted|timeout/i.test(message)) skip = "timeout";
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
expansion = {
token: `@${url}`,
url,
ok: false,
skip,
error: message,
};
}
seen.set(url, expansion);
if (body) bodies.set(url, body);
if (opts.cache) opts.cache.set(url, { ...expansion, body });
order.push(url);
}
if (seen.size === 0) return { text, expansions: [] };
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
const blocks: string[] = [];
for (const ex of expansions) {
if (ex.ok) {
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
const truncTag = ex.truncated ? ' truncated="true"' : "";
const body = bodies.get(ex.url) ?? "";
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
} else {
const reasonAttr = ex.skip ?? "fetch-error";
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
}
}
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
return { text: augmented, expansions };
}
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
export function stripUrlTail(raw: string): string {
let s = raw;
while (s.length > 0) {
const last = s[s.length - 1]!;
if (".,;:!?".includes(last)) {
s = s.slice(0, -1);
continue;
}
if (")]}>".includes(last)) {
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
last as ")" | "]" | "}" | ">"
];
if (!s.includes(open)) {
s = s.slice(0, -1);
continue;
}
}
break;
}
return s;
}
function escapeAttr(s: string): string {
return s
.replace(/"/g, "&quot;")
.replace(/[\r\n]+/g, " ")
.trim();
}
+185 -194
View File
@@ -120,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);
@@ -144,53 +191,41 @@ 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);
await walk(absPath, dirRel ? `${dirRel}/${ent.name}` : ent.name, effectiveLayers);
} else if (ent.isFile() || ent.isSymbolicLink()) {
// Symlinks land in the same batch — statBatch resolves them and drops
// any whose target isn't a regular file (broken or symlink-to-dir).
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))
@@ -201,17 +236,127 @@ async function statBatch(
for (let i = 0; i < accepted.length; i++) {
const ent = accepted[i]!;
const s = stats[i];
if (ent.isSymbolicLink()) {
// Drop broken symlinks and symlinks-to-dirs (latter would cycle).
if (!s || !s.isFile) continue;
}
out.push({
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
emit({
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
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_./\\-]*)$/;
@@ -523,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";
+87 -36
View File
@@ -62,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[];
@@ -78,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,
@@ -100,10 +148,10 @@ 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}_`
@@ -127,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 {
@@ -148,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,
@@ -186,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 };
}
}
@@ -249,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,
@@ -257,6 +307,7 @@ function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
removeSpec,
reloadFromConfig,
closeAll,
setLifecycleSink,
};
}
@@ -317,6 +368,14 @@ export interface ChatOptions {
* 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 {
@@ -413,6 +472,7 @@ function Root({
progressSink={progressSink}
codeMode={appProps.codeMode}
noDashboard={appProps.noDashboard}
mouse={appProps.mouse}
onSwitchSession={setActiveSession}
/>
</KeystrokeProvider>
@@ -445,24 +505,10 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
progressSink,
});
const failedSpecs: Array<{ spec: string; reason: string }> = [];
if (requestedSpecs.length > 0) markPhase("mcp_launch");
for (const raw of requestedSpecs) {
const result = await runtime.addSpec(raw);
if (!result.ok) failedSpecs.push({ spec: raw, reason: result.reason });
}
if (requestedSpecs.length > 0) {
markPhase(
`mcp_connected_${requestedSpecs.length - failedSpecs.length}_of_${requestedSpecs.length}`,
);
}
if (runtime.size() === 0 && !opts.seedTools) {
tools = undefined;
}
// Preserve configured specs even when startup leaves them unbridged
// so `/mcp` can still surface the configured-but-offline set.
// 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 = runtime.summaries();
const mcpServers: McpServerSummary[] = [];
// Register web search/fetch tools unless explicitly disabled. DDG
// backs them with no key required; the model invokes them whenever
@@ -520,7 +566,12 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
exitOnCtrlC: true,
// patchConsole:false — winpty/MINTTY redraw-glitch source.
patchConsole: false,
incrementalRendering: true,
// 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.
+18
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,6 +30,7 @@ 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";
@@ -61,6 +63,8 @@ export interface CodeOptions {
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> {
@@ -134,6 +138,11 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// `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
@@ -156,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
@@ -193,6 +209,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
hasSemanticSearch: semantic.enabled,
systemAppend: opts.systemAppend,
systemAppendFile: systemAppendFileContents,
modelId: opts.model ?? "deepseek-v4-flash",
}),
transcript: opts.transcript,
session,
@@ -208,5 +225,6 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
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();
+23 -29
View File
@@ -2,14 +2,20 @@
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, resolveSemanticEmbeddingConfig } 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 {
+3 -2
View File
@@ -5,6 +5,7 @@ import {
defaultConfigPath,
isPlausibleKey,
loadApiKey,
loadBaseUrl,
readConfig,
saveApiKey,
} from "../../config.js";
@@ -57,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);
@@ -142,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(),
+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);
}
}
+14 -8
View File
@@ -4,13 +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 { escalationContract } from "../prompt-fragments.js";
import { resolveContinueFlag, resolveDefaults } from "./resolve.js";
import { markPhase } from "./startup-profile.js";
markPhase("cli_module_loaded");
const DEFAULT_SYSTEM = `You are Reasonix, a helpful DeepSeek-powered assistant. Be concise and accurate. Use tools when available.
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
@@ -30,7 +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}`;
${escalationContract(modelId)}`;
}
/** Lenient: malformed → undefined (no cap) so a bad flag doesn't abort launch. */
function parseBudgetFlag(raw: number | undefined): number | undefined {
@@ -73,7 +75,7 @@ program.action(async (opts: { continue?: boolean }) => {
const { chatCommand } = await import("./commands/chat.js");
await chatCommand({
model: defaults.model,
system: applyMemoryStack(DEFAULT_SYSTEM, process.cwd()),
system: applyMemoryStack(defaultSystemPrompt(defaults.model), process.cwd()),
session: continueOpts.session,
mcp: defaults.mcp,
forceResume: continueOpts.forceResume,
@@ -99,6 +101,7 @@ program
.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) => {
@@ -115,6 +118,7 @@ program
systemAppend: opts.systemAppend,
systemAppendFile: opts.systemAppendFile,
altScreen: opts.altScreen !== false,
mouse: opts.mouse !== false,
});
});
@@ -122,7 +126,7 @@ 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("--budget <usd>", t("ui.budgetHint"), (v) => Number.parseFloat(v))
@@ -141,6 +145,7 @@ program
.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,
@@ -164,7 +169,7 @@ program
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,
budgetUsd: parseBudgetFlag(opts.budget),
session: continueOpts.session,
@@ -174,6 +179,7 @@ program
forceNew: !!opts.new,
noDashboard: opts.dashboard === false,
altScreen: opts.altScreen !== false,
mouse: opts.mouse !== false,
});
});
@@ -181,7 +187,7 @@ 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("--budget <usd>", t("ui.budgetHintShort"), (v) => Number.parseFloat(v))
.option("--transcript <path>", t("ui.transcriptHintShort"))
@@ -204,7 +210,7 @@ program
await runCommand({
task,
model: defaults.model,
system: applyMemoryStack(opts.system, process.cwd()),
system: applyMemoryStack(opts.system ?? defaultSystemPrompt(defaults.model), process.cwd()),
budgetUsd: parseBudgetFlag(opts.budget),
transcript: opts.transcript,
mcp: defaults.mcp,
+733 -706
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>
);
}
+8 -7
View File
@@ -2,6 +2,7 @@
import { Box } from "ink";
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";
@@ -40,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} />
@@ -51,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)}
+36 -22
View File
@@ -2,12 +2,14 @@
import { Box, Text } from "ink";
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 { 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";
@@ -26,6 +28,7 @@ const PLAN_BODY_PREVIEW_LINES = 24;
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
const stepRows = steps?.length ?? 0;
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;
@@ -33,26 +36,34 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
? PLAN_BODY_PREVIEW_LINES
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
const reservedFor = hasSteps ? stepRows : previewRows;
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + 14) });
const 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}
{hasSteps ? (
@@ -64,35 +75,38 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
<MarkdownView text={previewBody} />
{truncatedBody ? (
<Text color={FG.faint}>
{`… ${planLines.length - PLAN_BODY_PREVIEW_LINES} more line${
planLines.length - PLAN_BODY_PREVIEW_LINES === 1 ? "" : "s"
} above in scrollback`}
{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}
+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">
+28 -13
View File
@@ -1,6 +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 { 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";
@@ -32,12 +33,9 @@ export interface PromptInputProps {
onSubmit: (v: string) => void;
disabled?: boolean;
placeholder?: string;
/** Ctrl+P / Ctrl+N — parent walks its prompt history and swaps `value` via `onChange`. */
/** 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;
/** ↑/↓ on an empty buffer — parent scrolls chat history. */
onChatScrollUp?: () => void;
onChatScrollDown?: () => void;
}
export function PromptInput({
@@ -48,8 +46,6 @@ export function PromptInput({
placeholder,
onHistoryPrev,
onHistoryNext,
onChatScrollUp,
onChatScrollDown,
}: PromptInputProps) {
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
@@ -146,8 +142,6 @@ export function PromptInput({
}
if (action.historyHandoff === "prev") onHistoryPrev?.();
if (action.historyHandoff === "next") onHistoryNext?.();
if (action.chatScrollHandoff === "up") onChatScrollUp?.();
if (action.chatScrollHandoff === "down") onChatScrollDown?.();
}, !disabled);
// ── Render ──────────────────────────────────────────────────────
@@ -162,8 +156,8 @@ 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;
@@ -295,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}>
@@ -308,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 };
+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>
+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;
}
+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>
+139 -36
View File
@@ -1,9 +1,13 @@
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, SlashGroup } from "./slash.js";
import { COLOR, GLYPH } from "./theme.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 {
matches: SlashCommandSpec[] | null;
@@ -31,50 +35,66 @@ export function SlashSuggestions({
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>
);
}
const MAX = groupMode ? 24 : 8;
const total = matches.length;
const windowStart =
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
const shown = matches.slice(windowStart, windowStart + MAX);
const items = buildVisibleItems(matches, windowStart, maxRows, groupMode);
const shownCommands = items.filter((item) => item.kind === "command");
const hiddenAbove = windowStart;
const hiddenBelow = total - windowStart - shown.length;
let lastGroup: SlashGroup | null = null;
if (windowStart > 0) lastGroup = matches[windowStart - 1]?.group ?? null;
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) => {
const idx = windowStart + i;
const showHeader = groupMode && spec.group !== lastGroup;
lastGroup = spec.group;
{items.map((item) => {
if (item.kind === "group") {
return <GroupHeader key={`group:${item.group}:${item.beforeIndex}`} group={item.group} />;
}
return (
<React.Fragment key={spec.cmd}>
{showHeader ? (
<Box marginTop={idx === 0 ? 0 : 1}>
<Text dimColor>{` ${GROUP_LABEL[spec.group]}`}</Text>
</Box>
) : null}
<SuggestionRow spec={spec} isSelected={idx === selectedIndex} />
</React.Fragment>
<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}
@@ -90,27 +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>
{aliasHint ? <Text dimColor>{aliasHint}</Text> : null}
</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)}…`;
}
+1 -1
View File
@@ -88,7 +88,7 @@ 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
+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(" · ");
}
+273 -156
View File
@@ -1,8 +1,8 @@
/**
* First-run / re-configure wizard.
*
* Walks a new user through: language → API key → preset pick → MCP server
* pick → per-server args → save. Saved output lives in
* 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.
*/
@@ -17,8 +17,10 @@ import {
type ReasonixConfig,
defaultConfigPath,
isPlausibleKey,
loadTheme,
readConfig,
redactKey,
resolveThemePreference,
writeConfig,
} from "../../config.js";
import {
@@ -34,6 +36,8 @@ 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. */
@@ -46,13 +50,15 @@ export interface WizardProps {
initial?: {
preset?: PresetName;
mcp?: string[];
theme?: ThemeName | "auto";
};
}
type Step = "language" | "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[];
@@ -71,185 +77,296 @@ export function Wizard({ onComplete, onCancel, existingApiKey, initial }: Wizard
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>({
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);
useInput((_input, key) => {
if (key.escape && step !== "saved" && onCancel) onCancel();
});
if (step === "language") {
return (
<LanguageStep
initialValue={data.language}
onSubmit={(lang) => {
setLanguage(lang);
notifyLanguageChange();
setData((d) => ({ ...d, language: lang }));
setStep(existingApiKey ? "preset" : "apiKey");
}}
/>
);
}
if (step === "apiKey") {
return (
<ApiKeyStep
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");
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>{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.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,
mcp: specsNow,
setupCompleted: true,
};
writeConfig(next);
setStep("saved");
onComplete(next);
} catch (e) {
setError(t("wizard.reviewSaveError", { message: (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]!);
}
});
return (
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
<Text bold color="green">
{t("wizard.savedTitle")}
<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>{t("ui.welcome")}</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>{t("wizard.savedFooter")}</Text>
<Text dimColor>{t("wizard.themeFooter")}</Text>
</Box>
<ExitOnEnter onExit={exit} />
</Box>
);
}
+3
View File
@@ -14,6 +14,7 @@ import { SearchCard } from "./SearchCard.js";
import { StreamingCard } from "./StreamingCard.js";
import { SubAgentCard } from "./SubAgentCard.js";
import { TaskCard } from "./TaskCard.js";
import { TipCard } from "./TipCard.js";
import { ToolCard } from "./ToolCard.js";
import { UsageCard } from "./UsageCard.js";
import { UserCard } from "./UserCard.js";
@@ -58,6 +59,8 @@ 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":
+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>
))}
</>
+12 -11
View File
@@ -4,13 +4,8 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { DoctorCard as DoctorCardData, DoctorCheckEntry } from "../state/cards.js";
import { CARD, FG, TONE } from "../theme/tokens.js";
const LEVEL_COLOR: Record<DoctorCheckEntry["level"], string> = {
ok: TONE.ok,
warn: TONE.warn,
fail: TONE.err,
};
import { useThemeTokens } from "../theme/context.js";
import { CARD } from "../theme/tokens.js";
const LEVEL_GLYPH: Record<DoctorCheckEntry["level"], string> = {
ok: "✓",
@@ -25,6 +20,12 @@ const LEVEL_TAG: Record<DoctorCheckEntry["level"], string> = {
};
export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactElement {
const { fg, tone } = useThemeTokens();
const levelColor: Record<DoctorCheckEntry["level"], string> = {
ok: tone.ok,
warn: tone.warn,
fail: tone.err,
};
const ok = card.checks.filter((c) => c.level === "ok").length;
const warn = card.checks.filter((c) => c.level === "warn").length;
const fail = card.checks.filter((c) => c.level === "fail").length;
@@ -36,12 +37,12 @@ export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactEleme
<CardHeader glyph="⚕" tone={CARD.tool.color} title="doctor" meta={[summary]} />
{card.checks.map((c) => (
<Box key={c.label} flexDirection="row" gap={1}>
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
<Text bold color={FG.body}>
<Text color={levelColor[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
<Text bold color={fg.body}>
{c.label.padEnd(labelWidth + 1)}
</Text>
<Text color={FG.sub}>{c.detail}</Text>
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_TAG[c.level]}</Text>
<Text color={fg.sub}>{c.detail}</Text>
<Text color={levelColor[c.level]}>{LEVEL_TAG[c.level]}</Text>
</Box>
))}
</Card>
+8 -4
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 { ErrorCard as ErrorCardData } from "../state/cards.js";
@@ -11,7 +12,7 @@ const STACK_TAIL = 5;
export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement {
const retryNote =
card.retries !== undefined && card.retries > 0
? `${card.retries} retr${card.retries === 1 ? "y" : "ies"}`
? `${card.retries} ${t("cardLabels.retries")}`
: null;
const stackLines = card.stack ? card.stack.split("\n") : [];
const stackTrunc = stackLines.length > STACK_TAIL;
@@ -25,7 +26,7 @@ export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement
<CardHeader
glyph="✖"
tone={TONE.err}
title={card.title || "error"}
title={card.title || t("cardTitles.error")}
meta={retryNote ? [retryNote] : undefined}
/>
{messageLines.map((line, i) => (
@@ -35,10 +36,13 @@ export function ErrorCard({ card }: { card: ErrorCardData }): React.ReactElement
))}
{hasStack ? (
<Box flexDirection="column" marginTop={1}>
<Text color={FG.meta}>stack trace</Text>
<Text color={FG.meta}>{t("cardLabels.stackTrace")}</Text>
{stackHidden > 0 ? (
<Text color={FG.faint}>
{`⋮ ${stackHidden} earlier stack line${stackHidden === 1 ? "" : "s"} hidden`}
{t(
stackHidden === 1 ? "cardLabels.earlierStackLine" : "cardLabels.earlierStackLines",
{ count: stackHidden },
)}
</Text>
) : null}
{stackVisible.map((line, i) => (
+20 -20
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { PlanCard as PlanCardData, PlanStep } from "../state/cards.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
const STATUS_GLYPH: Record<PlanStep["status"], string> = {
queued: "○",
@@ -15,53 +15,53 @@ const STATUS_GLYPH: Record<PlanStep["status"], string> = {
skipped: "s",
};
const STATUS_COLOR: Record<PlanStep["status"], string> = {
queued: FG.faint,
running: TONE_ACTIVE.brand,
done: TONE.ok,
failed: TONE.err,
blocked: TONE.warn,
skipped: FG.faint,
};
const VISIBLE_WINDOW = 5;
export function PlanCard({ card }: { card: PlanCardData }): React.ReactElement {
const { fg, tone, toneActive } = useThemeTokens();
const statusColor: Record<PlanStep["status"], string> = {
queued: fg.faint,
running: toneActive.brand,
done: tone.ok,
failed: tone.err,
blocked: tone.warn,
skipped: fg.faint,
};
const doneCount = card.steps.filter((s) => s.status === "done").length;
const variantTag =
card.variant === "resumed" ? "resumed · " : card.variant === "replay" ? "⏪ archive · " : "";
const progress = `${variantTag}${doneCount}/${card.steps.length} done`;
const hasRunning = card.steps.some((s) => s.status === "running");
const tone = hasRunning ? TONE_ACTIVE.accent : TONE.accent;
const cardTone = hasRunning ? toneActive.accent : tone.accent;
const window = pickWindow(card.steps);
return (
<Card tone={tone}>
<CardHeader glyph="⊞" tone={tone} title={card.title} meta={[progress]} />
<Card tone={cardTone}>
<CardHeader glyph="⊞" tone={cardTone} title={card.title} meta={[progress]} />
{window.hiddenBefore > 0 ? (
<Box flexDirection="row" gap={1}>
<Text color={TONE.ok}>✓</Text>
<Text color={FG.faint}>{`⋯ ${window.hiddenBefore} done`}</Text>
<Text color={tone.ok}>✓</Text>
<Text color={fg.faint}>{`⋯ ${window.hiddenBefore} done`}</Text>
</Box>
) : null}
{window.steps.map((step) => {
const isActive = step.status === "running";
const titleColor = isActive ? FG.strong : FG.sub;
const titleColor = isActive ? fg.strong : fg.sub;
return (
<Box key={step.id} flexDirection="row" gap={1}>
<Text color={STATUS_COLOR[step.status]}>{STATUS_GLYPH[step.status]}</Text>
<Text color={statusColor[step.status]}>{STATUS_GLYPH[step.status]}</Text>
<Text bold={isActive} color={titleColor}>
{`${step.indexLabel}. ${step.title}`}
</Text>
{isActive ? <Text color={TONE_ACTIVE.brand}>← in progress</Text> : null}
{isActive ? <Text color={toneActive.brand}>← in progress</Text> : null}
</Box>
);
})}
{window.hiddenAfter > 0 ? (
<Box flexDirection="row" gap={1}>
<Text color={FG.faint}>○</Text>
<Text color={FG.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
<Text color={fg.faint}>○</Text>
<Text color={fg.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
</Box>
) : null}
</Card>
+10 -5
View File
@@ -2,6 +2,7 @@ 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 { clipToCells, wrapToCells } from "../../../frame/width.js";
import { t } from "../../../i18n/index.js";
import { Card } from "../primitives/Card.js";
import { CardHeader, type MetaItem } from "../primitives/CardHeader.js";
import { CursorBlock } from "../primitives/CursorBlock.js";
@@ -47,7 +48,11 @@ function ReasoningHeader({ card }: { card: ReasoningCardData }): React.ReactElem
const streamingActive = card.streaming && !card.aborted;
const headColor = card.aborted ? TONE.err : streamingActive ? TONE_ACTIVE.accent : TONE.accent;
const glyph = streamingActive ? "◇" : "◆";
const title = streamingActive ? "reasoning…" : card.aborted ? "reasoning (aborted)" : "reasoning";
const title = streamingActive
? t("cardTitles.reasoningEllipsis")
: card.aborted
? t("cardTitles.reasoningAborted")
: t("cardTitles.reasoning");
const meta: MetaItem[] = [];
const m = headerMeta(card);
if (m) meta.push(m);
@@ -76,12 +81,12 @@ function ReasoningHeader({ card }: { card: ReasoningCardData }): React.ReactElem
function headerMeta(card: ReasoningCardData): string {
if (card.streaming) {
return card.tokens > 0 ? `${card.tokens.toLocaleString()} tok` : "";
return card.tokens > 0 ? `${card.tokens.toLocaleString()} ${t("cardLabels.tok")}` : "";
}
const parts: string[] = [];
if (card.tokens > 0) parts.push(`${card.tokens.toLocaleString()} tok`);
if (card.paragraphs > 0) parts.push(`${card.paragraphs} `);
return parts.join(" · ");
if (card.tokens > 0) parts.push(`${card.tokens.toLocaleString()} ${t("cardLabels.tok")}`);
if (card.paragraphs > 0) parts.push(`${card.paragraphs} ${t("cardLabels.pilcrow")}`);
return parts.join(" \u00b7 ");
}
function headerDuration(card: ReasoningCardData): string {
+14 -5
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 { SearchCard as SearchCardData, SearchHit } from "../state/cards.js";
@@ -9,9 +10,10 @@ import { FG, TONE } from "../theme/tokens.js";
export function SearchCard({ card }: { card: SearchCardData }): React.ReactElement {
const fileCount = new Set(card.hits.map((h) => h.file)).size;
const elapsed = `${(card.elapsedMs / 1000).toFixed(2)}s`;
const stats = `${card.hits.length} hit${card.hits.length === 1 ? "" : "s"} · ${fileCount} file${
fileCount === 1 ? "" : "s"
}`;
const stats = t(card.hits.length === 1 ? "cardLabels.hitSingular" : "cardLabels.hitsPlural", {
count: card.hits.length,
files: fileCount,
});
const grouped = groupByFile(card.hits.slice(0, 10));
@@ -20,7 +22,7 @@ export function SearchCard({ card }: { card: SearchCardData }): React.ReactEleme
<CardHeader
glyph="⊙"
tone={TONE.info}
title="search"
title={t("cardTitles.search")}
subtitle={`"${card.query}"`}
meta={[stats, elapsed]}
/>
@@ -38,7 +40,14 @@ export function SearchCard({ card }: { card: SearchCardData }): React.ReactEleme
</Box>
))}
{card.hits.length > 10 ? (
<Text color={FG.faint}>{`⋮ +${card.hits.length - 10} more hits`}</Text>
<Text color={FG.faint}>
{t(
card.hits.length - 10 === 1
? "cardLabels.moreHitSingular"
: "cardLabels.moreHitsPlural",
{ count: card.hits.length - 10 },
)}
</Text>
) : null}
</Card>
);
+9 -6
View File
@@ -2,6 +2,7 @@ import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useContext } from "react";
import { clipToCells, wrapToCells } from "../../../frame/width.js";
import { t } from "../../../i18n/index.js";
import { countTokens } from "../../../tokenizer.js";
import { LiveExpandContext } from "../layout/LiveExpandContext.js";
import { useReserveRows } from "../layout/viewport-budget.js";
@@ -72,7 +73,7 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
<CardHeader
glyph="‹"
tone={TONE.ok}
title="reply"
title={t("cardTitles.reply")}
right={
<>
{ratePill}
@@ -94,7 +95,7 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
const aborted = !!card.aborted;
const headColor = aborted ? TONE.err : TONE_ACTIVE.brand;
const glyph = aborted ? "‹" : "◈";
const headLabel = aborted ? "aborted" : "writing…";
const headLabel = aborted ? t("cardLabels.aborted") : t("cardLabels.writing");
const { tokens: liveTokens, tps: liveTps } = tokenRate(card.text, card.ts, Date.now());
const liveRatePill =
@@ -121,16 +122,18 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
}
/>
{expanded && droppedAbove > 0 ? (
<Text
color={FG.faint}
>{`⋯ ${droppedAbove} earlier line${droppedAbove === 1 ? "" : "s"} above`}</Text>
<Text color={FG.faint}>
{t(droppedAbove === 1 ? "cardLabels.earlierLine" : "cardLabels.earlierLines", {
count: droppedAbove,
})}
</Text>
) : null}
{visible.map((line, i) => (
<Box key={`${card.id}:${visualLines.length - visible.length + i}`} flexDirection="row">
<Text color={aborted ? FG.meta : FG.body}>{clipToCells(line, lineCells)}</Text>
</Box>
))}
{aborted ? <Text color={FG.faint}>[truncated by esc]</Text> : null}
{aborted ? <Text color={FG.faint}>{t("cardLabels.truncatedByEsc")}</Text> : null}
</Card>
);
}
+38 -30
View File
@@ -1,46 +1,48 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useContext } from "react";
import { t } from "../../../i18n/index.js";
import { ActiveCardContext, Card as CardWrap } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import { Spinner } from "../primitives/Spinner.js";
import type { Card, SubAgentCard as SubAgentCardData } from "../state/cards.js";
import { CARD, FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
const STATUS_COLOR: Record<SubAgentCardData["status"], string> = {
running: TONE_ACTIVE.violet,
done: TONE.ok,
failed: TONE.err,
};
import { useThemeTokens } from "../theme/context.js";
import { CARD } from "../theme/tokens.js";
export function SubAgentCard({ card }: { card: SubAgentCardData }): React.ReactElement {
const headColor = STATUS_COLOR[card.status];
const headGlyph = card.status === "failed" ? "✖" : "⌬";
const { fg, tone, toneActive } = useThemeTokens();
const statusColor: Record<SubAgentCardData["status"], string> = {
running: toneActive.violet,
done: tone.ok,
failed: tone.err,
};
const headColor = statusColor[card.status];
const headGlyph = card.status === "failed" ? "\u2717" : "\u232c";
const runningChildren = card.children.filter((c) => !isChildDone(c)).length;
const isRunning = card.status === "running";
const inLive = useContext(ActiveCardContext);
const headerMeta = isRunning
? runningChildren > 0
? [`${runningChildren} running`]
: ["working"]
? [`${runningChildren} ${t("cardLabels.runningLabel")}`]
: [t("cardLabels.workingLabel")]
: [{ text: card.status, color: headColor }];
return (
<CardWrap tone={headColor}>
<CardHeader
glyph={headGlyph}
tone={headColor}
title="subagent"
titleColor={TONE.violet}
title={t("cardTitles.subagent")}
titleColor={tone.violet}
subtitle={card.task}
meta={headerMeta}
/>
{card.name ? <Text color={FG.faint}>{`agent · ${card.name}`}</Text> : null}
{card.name ? <Text color={fg.faint}>{`${t("cardLabels.agent")} · ${card.name}`}</Text> : null}
{card.tools && card.tools.length > 0 && (
<Text color={FG.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
<Text color={fg.faint}>{`${t("cardLabels.tools")} · ${card.tools.join(", ")}`}</Text>
)}
{card.children.map((child) => (
<Box key={child.id} flexDirection="row" gap={1}>
{inLive ? null : <Text color={TONE.violet}>▎</Text>}
{inLive ? null : <Text color={tone.violet}>▎</Text>}
<ChildRow card={child} />
</Box>
))}
@@ -68,13 +70,14 @@ interface ChildVisual {
}
function ChildRow({ card }: { card: Card }): React.ReactElement {
const v = childVisual(card);
const { fg, tone } = useThemeTokens();
const v = childVisual(card, tone.ok, tone.err, fg.faint);
const isDone = isChildDone(card);
return (
<>
{v.statusGlyph}
<Text color={v.kindColor}>{v.kindGlyph}</Text>
<Text dimColor={isDone} color={FG.body}>
<Text dimColor={isDone} color={fg.body}>
{v.text}
</Text>
</>
@@ -89,25 +92,30 @@ function doneGlyph(color: string): React.ReactElement {
return <Text color={color}>✓</Text>;
}
function failedGlyph(): React.ReactElement {
return <Text color={TONE.err}>✖</Text>;
function failedGlyph(color: string): React.ReactElement {
return <Text color={color}>✖</Text>;
}
function childVisual(card: Card): ChildVisual {
function childVisual(
card: Card,
doneColor: string,
failedColor: string,
fallbackColor: string,
): ChildVisual {
switch (card.kind) {
case "reasoning": {
const done = !card.streaming;
return {
statusGlyph: done ? doneGlyph(TONE.ok) : runningGlyph(CARD.reasoning.color),
statusGlyph: done ? doneGlyph(doneColor) : runningGlyph(CARD.reasoning.color),
kindGlyph: "◆",
kindColor: CARD.reasoning.color,
text: `reasoning · ${card.paragraphs}`,
text: t("cardLabels.reasoningLabel", { count: card.paragraphs }),
};
}
case "tool": {
const elapsed = card.elapsedMs > 0 ? ` · ${(card.elapsedMs / 1000).toFixed(2)}s` : "";
return {
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.tool.color),
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.tool.color),
kindGlyph: "▣",
kindColor: CARD.tool.color,
text: `${card.name}${elapsed}`,
@@ -115,30 +123,30 @@ function childVisual(card: Card): ChildVisual {
}
case "streaming":
return {
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.streaming.color),
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.streaming.color),
kindGlyph: "◈",
kindColor: CARD.streaming.color,
text: card.done ? "response" : "writing …",
text: card.done ? t("cardLabels.response") : t("cardLabels.writing"),
};
case "diff":
return {
statusGlyph: doneGlyph(TONE.ok),
statusGlyph: doneGlyph(doneColor),
kindGlyph: "±",
kindColor: CARD.diff.color,
text: card.file,
};
case "error":
return {
statusGlyph: failedGlyph(),
statusGlyph: failedGlyph(failedColor),
kindGlyph: "✖",
kindColor: CARD.error.color,
text: card.title,
};
default:
return {
statusGlyph: <Text color={FG.faint}>·</Text>,
statusGlyph: <Text color={fallbackColor}>·</Text>,
kindGlyph: "·",
kindColor: FG.faint,
kindColor: fallbackColor,
text: card.kind,
};
}
+20 -21
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { TaskCard as TaskCardData, TaskStep } from "../state/cards.js";
import { FG, TONE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
const STEP_GLYPH: Record<TaskStep["status"], string> = {
queued: "○",
@@ -13,19 +13,6 @@ const STEP_GLYPH: Record<TaskStep["status"], string> = {
failed: "✗",
};
const STEP_COLOR: Record<TaskStep["status"], string> = {
queued: FG.faint,
running: TONE.warn,
done: TONE.ok,
failed: TONE.err,
};
const TASK_COLOR: Record<TaskCardData["status"], string> = {
running: TONE.warn,
done: TONE.ok,
failed: TONE.err,
};
const TASK_GLYPH: Record<TaskCardData["status"], string> = {
running: "▶",
done: "✓",
@@ -33,26 +20,38 @@ const TASK_GLYPH: Record<TaskCardData["status"], string> = {
};
export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
const { fg, tone } = useThemeTokens();
const stepColor: Record<TaskStep["status"], string> = {
queued: fg.faint,
running: tone.warn,
done: tone.ok,
failed: tone.err,
};
const taskColor: Record<TaskCardData["status"], string> = {
running: tone.warn,
done: tone.ok,
failed: tone.err,
};
const elapsed = `${(card.elapsedMs / 1000).toFixed(1)}s`;
return (
<Card tone={TASK_COLOR[card.status]}>
<Card tone={taskColor[card.status]}>
<CardHeader
glyph={TASK_GLYPH[card.status]}
tone={TASK_COLOR[card.status]}
tone={taskColor[card.status]}
title={`step ${card.index}/${card.total}`}
subtitle={card.title}
meta={[elapsed, card.status]}
/>
{card.steps.map((step) => (
<Box key={step.id} flexDirection="row" gap={1}>
<Text color={STEP_COLOR[step.status]}>{STEP_GLYPH[step.status]}</Text>
<Text bold color={FG.body}>
<Text color={stepColor[step.status]}>{STEP_GLYPH[step.status]}</Text>
<Text bold color={fg.body}>
{(step.toolName ?? "step").padEnd(7)}
</Text>
<Text color={FG.sub}>{step.title}</Text>
{step.detail ? <Text color={FG.faint}>{step.detail}</Text> : null}
<Text color={fg.sub}>{step.title}</Text>
{step.detail ? <Text color={fg.faint}>{step.detail}</Text> : null}
{step.elapsedMs !== undefined ? (
<Text color={FG.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
<Text color={fg.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
) : null}
</Box>
))}
+74
View File
@@ -0,0 +1,74 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import stringWidth from "string-width";
import { t } from "../../../i18n/index.js";
import type { TipCard as TipCardData, TipRow as TipRowData } from "../state/cards.js";
import { FG, TONE } from "../theme/tokens.js";
const KEY_GUTTER = 4;
export function TipCard({ card }: { card: TipCardData }): React.ReactElement {
const keyWidth = card.sections.reduce(
(max, sec) => sec.rows.reduce((m, r) => Math.max(m, stringWidth(r.key)), max),
0,
);
return (
<Box flexDirection="column" paddingLeft={2} marginY={1}>
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row" gap={1}>
<Text color={TONE.accent} bold>
ⓘ
</Text>
<Text color={FG.body} bold>
{card.topic}
</Text>
</Box>
{card.oneTime ? <Text color={FG.faint}>{t("ui.tipShownOnce")}</Text> : null}
</Box>
{card.sections.map((section, i) => (
<Box key={section.title ?? `section-${i}`} flexDirection="column" marginTop={1}>
{section.title ? (
<Box marginBottom={0}>
<Text color={FG.sub}>{section.title}</Text>
</Box>
) : null}
{section.rows.map((row) => (
<TipRowRender
key={row.key}
row={row}
keyWidth={keyWidth}
indent={section.title ? 2 : 0}
/>
))}
</Box>
))}
{card.footer ? (
<Box marginTop={1}>
<Text color={FG.faint}>{card.footer}</Text>
</Box>
) : null}
</Box>
);
}
function TipRowRender({
row,
keyWidth,
indent,
}: {
row: TipRowData;
keyWidth: number;
indent: number;
}) {
const pad = " ".repeat(Math.max(0, keyWidth - stringWidth(row.key) + KEY_GUTTER));
const lead = indent > 0 ? " ".repeat(indent) : "";
return (
<Box flexDirection="row">
{lead ? <Text>{lead}</Text> : null}
<Text color={TONE.accent}>{row.key}</Text>
<Text>{pad}</Text>
<Text color={FG.body}>{row.text}</Text>
</Box>
);
}
+57 -26
View File
@@ -2,10 +2,13 @@ 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 { clipToCells } from "../../../frame/width.js";
import { t } from "../../../i18n/index.js";
import { Markdown } from "../markdown.js";
import { Card } from "../primitives/Card.js";
import { CardHeader, type MetaItem } from "../primitives/CardHeader.js";
import { Spinner } from "../primitives/Spinner.js";
import type { ToolCard as ToolCardData } from "../state/cards.js";
import { useIsInflight } from "../state/inflight-context.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
const READ_TAIL = 2;
@@ -25,24 +28,28 @@ export function ToolCard({ card }: { card: ToolCardData }): React.ReactElement {
const cols = stdout?.columns ?? 80;
const lineCells = Math.max(20, cols - 4);
const argsLabel = formatArgsSummary(card.args);
const subagentMarkdown = unwrapSubagentMarkdown(card);
const allLines = card.output.length > 0 ? card.output.split("\n") : [];
const tail = tailLinesFor(card.name);
const truncated = allLines.length > tail;
const visible = truncated ? allLines.slice(-tail) : allLines;
const hidden = truncated ? allLines.length - visible.length : 0;
const status = toolStatus(card);
const isInflight = useIsInflight(card.id);
const status = toolStatus(card, isInflight);
const headColor = headerColorFor(status);
const errColor = card.exitCode && card.exitCode !== 0 ? TONE.err : FG.sub;
// Rejected calls show a single trailing badge — the verbose JSON error body
// is already conveyed by the badge, so dropping the body keeps the card tight.
const showBody = !card.rejected && visible.length > 0;
const showBody = !card.rejected && (subagentMarkdown !== null || visible.length > 0);
const meta: MetaItem[] = [];
if (card.retry) {
meta.push({ text: `↻ ${card.retry.attempt}/${card.retry.max}`, color: TONE.warn });
}
if (card.rejected) {
meta.push({ text: "rejected", color: TONE.err });
meta.push({ text: t("cardLabels.rejected"), color: TONE.err });
}
for (const part of metaTrail(card)) meta.push(part);
@@ -60,34 +67,57 @@ export function ToolCard({ card }: { card: ToolCardData }): React.ReactElement {
) : undefined
}
/>
{showBody && (
<>
{hidden > 0 ? (
<Text color={FG.faint}>
{`⋮ ${hidden} earlier line${hidden === 1 ? "" : "s"} (use /tool to read full)`}
</Text>
) : null}
{visible.map((line, i) => (
<Text
key={`${card.id}:${hidden + i}`}
color={errColor}
dimColor={!card.exitCode || card.exitCode === 0}
>
{clipToCells(line, lineCells) || " "}
</Text>
))}
</>
)}
{showBody &&
(subagentMarkdown !== null ? (
<Markdown text={subagentMarkdown} width={lineCells} />
) : (
<>
{hidden > 0 ? (
<Text color={FG.faint}>
{t(hidden === 1 ? "cardLabels.earlierLine" : "cardLabels.earlierLines", {
count: hidden,
})}
</Text>
) : null}
{visible.map((line, i) => (
<Text
key={`${card.id}:${hidden + i}`}
color={errColor}
dimColor={!card.exitCode || card.exitCode === 0}
>
{clipToCells(line, lineCells) || " "}
</Text>
))}
</>
))}
</Card>
);
}
function unwrapSubagentMarkdown(card: ToolCardData): string | null {
if (card.name !== "spawn_subagent") return null;
if (card.output.length === 0) return null;
try {
const parsed = JSON.parse(card.output) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
if (obj.success !== true) return null;
if (typeof obj.output !== "string") return null;
return obj.output;
} catch {
return null;
}
}
type ToolStatus = "running" | "ok" | "rejected" | "error" | "aborted";
function toolStatus(card: ToolCardData): ToolStatus {
function toolStatus(card: ToolCardData, isInflight: boolean): ToolStatus {
// Running is derived from the loop's inflight set so a missed `tool` event
// can't strand the spinner forever — finally in runOneToolCall guarantees
// the id leaves the set on every exit path.
if (isInflight) return "running";
if (card.rejected) return "rejected";
if (card.aborted) return "aborted";
if (!card.done) return "running";
if (card.exitCode !== undefined && card.exitCode !== 0) return "error";
return "ok";
}
@@ -123,8 +153,9 @@ function headerColorFor(s: ToolStatus): string {
function metaTrail(card: ToolCardData): string[] {
const parts: string[] = [];
const inputBytes = largestStringInputBytes(card.args);
if (inputBytes !== null) parts.push(`${formatBytes(inputBytes)} in`);
if (card.elapsedMs > 0) parts.push(`${(card.elapsedMs / 1000).toFixed(2)}s`);
if (inputBytes !== null) parts.push(t("cardLabels.bytesIn", { bytes: formatBytes(inputBytes) }));
if (card.elapsedMs > 0)
parts.push(t("cardLabels.elapsedSec", { secs: (card.elapsedMs / 1000).toFixed(2) }));
if (
card.done &&
!card.rejected &&
@@ -132,7 +163,7 @@ function metaTrail(card: ToolCardData): string[] {
card.exitCode !== undefined &&
card.exitCode !== 0
) {
parts.push(`exit ${card.exitCode}`);
parts.push(t("cardLabels.exit", { code: card.exitCode }));
}
return parts;
}
+15 -11
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 { UsageCard as UsageCardData } from "../state/cards.js";
@@ -32,13 +33,16 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
const reasonRatio = card.tokens.reason / cap;
const outputRatio = card.tokens.output / cap;
const headerMeta: string[] = [`turn ${card.turn}`, formatCost(card.cost, card.balanceCurrency)];
const headerMeta: string[] = [
`${t("cardLabels.turn")} ${card.turn}`,
formatCost(card.cost, card.balanceCurrency),
];
if (card.elapsedMs !== undefined) headerMeta.push(`${(card.elapsedMs / 1000).toFixed(1)}s`);
return (
<Card tone={FG.meta}>
<CardHeader glyph="Σ" tone={FG.meta} title="usage" meta={headerMeta} />
<CardHeader glyph="Σ" tone={FG.meta} title={t("cardTitles.usage")} meta={headerMeta} />
<Box flexDirection="row" gap={1}>
<Text color={FG.sub}>prompt</Text>
<Text color={FG.sub}>{t("cardLabels.prompt")}</Text>
{bar(promptRatio, TONE.brand)}
<Text bold color={FG.body}>
{card.tokens.prompt.toLocaleString()}
@@ -46,32 +50,32 @@ export function UsageCard({ card }: { card: UsageCardData }): React.ReactElement
<Text color={FG.faint}>{`/ 1M · ${(promptRatio * 100).toFixed(1)}%`}</Text>
</Box>
<Box flexDirection="row" gap={1}>
<Text color={FG.sub}>reason</Text>
<Text color={FG.sub}>{t("cardLabels.reason")}</Text>
{bar(reasonRatio, TONE.accent)}
<Text bold color={FG.body}>
{card.tokens.reason.toLocaleString()}
</Text>
</Box>
<Box flexDirection="row" gap={1}>
<Text color={FG.sub}>output</Text>
<Text color={FG.sub}>{t("cardLabels.output")}</Text>
{bar(outputRatio, TONE.brand)}
<Text bold color={FG.body}>
{card.tokens.output.toLocaleString()}
</Text>
</Box>
<Box flexDirection="row" gap={1}>
<Text color={FG.sub}>cache </Text>
<Text color={FG.sub}>{t("cardLabels.cache")} </Text>
{bar(card.cacheHit, TONE.ok)}
<Text bold color={TONE.ok}>{`${(card.cacheHit * 100).toFixed(1)}%`}</Text>
</Box>
<Box flexDirection="row" gap={1}>
<Text color={FG.faint}>session</Text>
<Text color={FG.faint}>{t("cardLabels.session")}</Text>
<Text bold color={FG.body}>
{`⛁ ${formatCost(card.sessionCost, card.balanceCurrency, 3)}`}
</Text>
{card.balance !== undefined ? (
<>
<Text color={FG.faint}>· balance</Text>
<Text color={FG.faint}>{`· ${t("cardLabels.balance")}`}</Text>
<Text bold color={TONE.brand}>
{formatBalance(card.balance, card.balanceCurrency)}
</Text>
@@ -87,11 +91,11 @@ function CompactUsageRow({ card }: { card: UsageCardData }): React.ReactElement
return (
<Box flexDirection="row" gap={1} marginTop={1}>
<Text color={FG.meta}>Σ</Text>
<Text color={FG.faint}>{`turn ${card.turn}`}</Text>
<Text color={FG.faint}>{`${t("cardLabels.turn")} ${card.turn}`}</Text>
<Text color={FG.meta}>
{`· ${compactNum(card.tokens.prompt)} prompt · ${compactNum(card.tokens.output)} out`}
{`· ${compactNum(card.tokens.prompt)} ${t("cardLabels.prompt")} · ${compactNum(card.tokens.output)} ${t("cardLabels.output")}`}
</Text>
<Text color={FG.faint}>· cache</Text>
<Text color={FG.faint}>{`· ${t("cardLabels.cache")}`}</Text>
<Text color={TONE.ok}>{`${(card.cacheHit * 100).toFixed(0)}%`}</Text>
<Text color={FG.faint}>{`· ${formatCost(card.cost, card.balanceCurrency)}${elapsed}`}</Text>
{card.balance !== undefined ? (
+2 -2
View File
@@ -28,9 +28,9 @@ export interface CharBarProps {
pct: number;
/** Cell count. Default 24. Min 4 enforced so the bar is at least readable. */
width?: number;
/** Filled-cell color. Defaults to brand cyan. */
/** Filled-cell COLOR. Defaults to brand cyan. */
color?: string;
/** Empty-cell color. Defaults to dim slate. */
/** Empty-cell COLOR. Defaults to dim slate. */
emptyColor?: string;
/**
* Whether to render the percentage label after the bar. Off when the
+99
View File
@@ -0,0 +1,99 @@
/** Pre-fills the GitHub new-issue body with version + platform + terminal + Node + locale + model. No transcripts, paths, or secrets. */
import { compareVersions } from "../../version.js";
export interface FeedbackDiagnosticInput {
version: string;
latestVersion?: string | null;
platform: string;
osRelease: string;
termProgram?: string;
term?: string;
colorTerm?: string;
inWindowsTerminal?: boolean;
inTmux?: boolean;
inSsh?: boolean;
wslDistro?: string;
cols?: number;
rows?: number;
nodeVersion: string;
locale: string;
theme?: string;
model: string;
reasoningEffort?: string;
editMode?: string;
planMode?: boolean;
mcpServerCount?: number;
sessionId?: string;
}
const FEEDBACK_ISSUE_BASE = "https://github.com/esengine/DeepSeek-Reasonix/issues/new";
/** Bare URL used as a fallback when query-pre-fill isn't possible (only really if the body somehow blew past URL limits). */
export const FEEDBACK_ISSUE_URL = FEEDBACK_ISSUE_BASE;
/** GitHub safely accepts ~7000 chars in the body query param — well above our ~300-char diagnostic, but cap defensively. */
const FEEDBACK_BODY_QUERY_LIMIT = 6000;
export function buildFeedbackIssueUrl(diagnostic: string): string {
const trimmed =
diagnostic.length > FEEDBACK_BODY_QUERY_LIMIT
? diagnostic.slice(0, FEEDBACK_BODY_QUERY_LIMIT)
: diagnostic;
return `${FEEDBACK_ISSUE_BASE}?body=${encodeURIComponent(trimmed)}`;
}
export function buildFeedbackDiagnostic(input: FeedbackDiagnosticInput): string {
const lines: string[] = [];
lines.push(`**Reasonix**: ${formatVersion(input.version, input.latestVersion)}`);
lines.push(`**Platform**: ${input.platform} (${input.osRelease})`);
lines.push(`**Terminal**: ${formatTerminal(input)}`);
if (typeof input.cols === "number" && typeof input.rows === "number") {
lines.push(`**Size**: ${input.cols}×${input.rows}`);
}
lines.push(`**Node**: ${input.nodeVersion}`);
lines.push(`**Locale**: ${input.locale}`);
if (input.theme) lines.push(`**Theme**: ${input.theme}`);
lines.push(`**Model**: ${formatModel(input.model, input.reasoningEffort)}`);
const modeLine = formatMode(input.editMode, input.planMode);
if (modeLine) lines.push(`**Mode**: ${modeLine}`);
if (typeof input.mcpServerCount === "number") {
lines.push(`**MCP**: ${input.mcpServerCount} server(s)`);
}
if (input.sessionId) lines.push(`**Session**: ${input.sessionId}`);
lines.push("", "<!-- describe what you were doing when this happened -->", "");
return lines.join("\n");
}
function formatVersion(installed: string, latest: string | null | undefined): string {
if (!latest) return installed;
const cmp = compareVersions(installed, latest);
if (cmp === 0) return `${installed} (latest)`;
if (cmp > 0) return installed;
return `${installed} (latest: ${latest})`;
}
function formatModel(model: string, effort: string | undefined): string {
return effort ? `${model} · effort=${effort}` : model;
}
function formatMode(editMode: string | undefined, planMode: boolean | undefined): string {
const parts: string[] = [];
if (editMode) parts.push(`edit=${editMode}`);
parts.push(`plan=${planMode ? "on" : "off"}`);
return parts.join(" · ");
}
function formatTerminal(input: FeedbackDiagnosticInput): string {
const head = input.termProgram ?? "(unknown)";
const env: string[] = [];
if (input.termProgram) env.push(`TERM_PROGRAM=${input.termProgram}`);
if (input.term) env.push(`TERM=${input.term}`);
if (input.colorTerm) env.push(`COLORTERM=${input.colorTerm}`);
if (input.inWindowsTerminal) env.push("WT_SESSION=set");
if (input.inTmux) env.push("TMUX=set");
if (input.inSsh) env.push("SSH_TTY=set");
if (input.wslDistro) env.push(`WSL=${input.wslDistro}`);
if (env.length === 0) return head;
return `${head} (${env.join(", ")})`;
}
+3
View File
@@ -17,6 +17,8 @@ export interface ApplySlashResultContext {
stopLoop: () => void;
quitProcess: () => void;
pushHistory: (text: string) => void;
/** Flush pending modals + cancel awaiting pauseGate requests on /new — without this a stuck plan_checkpoint survives the wipe. */
resetPendingModals?: () => void;
/** The verbatim text the user typed; used for promptHistory bookkeeping. */
text: string;
}
@@ -32,6 +34,7 @@ export function applySlashResult(result: SlashResult, ctx: ApplySlashResultConte
return { kind: "consumed" };
}
if (result.clear) {
ctx.resetPendingModals?.();
// 2J + 3J + H: visible buffer + scrollback + cursor home.
ctx.stdoutWrite("\x1b[2J\x1b[3J\x1b[H");
ctx.log.reset();
+11 -1
View File
@@ -27,7 +27,7 @@ export function handleToolStart(ev: LoopEvent, ctx: ToolStartContext): void {
ctx.setOngoingTool({ name: ev.toolName ?? "?", args: ev.toolArgs });
ctx.setToolProgress(null);
ctx.toolStartedAtRef.current = Date.now();
ctx.translator.toolStart(ev.toolName ?? "?", parseJsonOrRaw(ev.toolArgs));
ctx.translator.toolStart(ev.toolName ?? "?", parseJsonOrRaw(ev.toolArgs), ev.callId);
// Feed the `@` picker's recency LRU from any path-shaped field in the
// tool args. Picker surfaces these next time `@` is typed, even if mtime
// is stale.
@@ -52,9 +52,19 @@ export function handleToolStart(ev: LoopEvent, ctx: ToolStartContext): void {
export interface ErrorContext {
log: Scrollback;
setOngoingTool: Dispatch<SetStateAction<{ name: string; args?: string } | null>>;
setToolProgress: Dispatch<
SetStateAction<{ progress: number; total?: number; message?: string } | null>
>;
toolStartedAtRef: MutableRefObject<number | null>;
translator: TurnTranslator;
}
export function handleErrorEvent(ev: LoopEvent, ctx: ErrorContext): void {
ctx.setOngoingTool(null);
ctx.setToolProgress(null);
ctx.toolStartedAtRef.current = null;
ctx.translator.toolAbort(ev.error ?? ev.content);
ctx.log.pushError("tool error", ev.error ?? ev.content);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Card } from "../state/cards.js";
import { useAgentState } from "../state/provider.js";
export function deriveActivityLabel(cards: ReadonlyArray<Card>): string {
if (cards.some((c) => c.kind === "reasoning" && c.streaming)) return "thinking…";
const last = cards[cards.length - 1];
if (!last || last.kind === "user") return "waiting for model…";
return "processing…";
}
export function useActivityLabel(): string {
return useAgentState((s) => deriveActivityLabel(s.cards));
}
-51
View File
@@ -1,51 +0,0 @@
import { useCallback, useEffect, useState } from "react";
/** Rows advanced per PgUp / PgDn / arrow / wheel — small for smooth feel. */
export const SCROLL_PAGE_ROWS = 3;
export interface ChatScrollState {
/** How many rows of content are above the visible viewport. */
scrollRows: number;
/** True when the user is following the bottom (auto-advances on new content). */
pinned: boolean;
scrollUp: () => void;
scrollDown: () => void;
/** Jump straight to the latest content and resume auto-follow (End key). */
jumpToBottom: () => void;
/** CardStream calls this once it has measured inner/outer Box heights. */
setMaxScroll: (rows: number) => void;
}
/** Row-precision scroll state. CardStream reports maxScroll back via setMaxScroll once Ink has laid out the inner column. */
export function useChatScroll(): ChatScrollState {
const [scrollRows, setScrollRows] = useState(0);
const [pinned, setPinned] = useState(true);
const [maxScroll, setMaxScrollState] = useState(0);
useEffect(() => {
if (pinned) setScrollRows(maxScroll);
}, [pinned, maxScroll]);
useEffect(() => {
if (scrollRows > maxScroll) setScrollRows(maxScroll);
}, [scrollRows, maxScroll]);
const scrollUp = useCallback(() => {
setPinned(false);
setScrollRows((o) => Math.max(0, o - SCROLL_PAGE_ROWS));
}, []);
const scrollDown = useCallback(() => {
setScrollRows((o) => {
const next = Math.min(maxScroll, o + SCROLL_PAGE_ROWS);
if (next >= maxScroll) setPinned(true);
return next;
});
}, [maxScroll]);
const jumpToBottom = useCallback(() => setPinned(true), []);
const setMaxScroll = useCallback((rows: number) => setMaxScrollState(rows), []);
return { scrollRows, pinned, scrollUp, scrollDown, jumpToBottom, setMaxScroll };
}
+69
View File
@@ -0,0 +1,69 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { EditBlock } from "../../../code/edit-blocks.js";
import { type EditMode, loadEditMode, saveEditMode } from "../../../config.js";
const FLASH_MS = 1200;
export interface EditGate {
pendingEdits: MutableRefObject<EditBlock[]>;
pendingCount: number;
/** Bumped on every queue-mutating sync so /walk's `useMemo` re-picks block 0 of the new queue. */
pendingTick: number;
syncPendingCount: () => void;
editMode: EditMode;
setEditMode: Dispatch<SetStateAction<EditMode>>;
/** Live-mode mirror — interceptor closure reads this so mode cycles don't reinstall the hook. */
editModeRef: MutableRefObject<EditMode>;
/** True for ~1.2s after a mode flip — drives the soft "yes, it changed" highlight on the bottom bar. */
modeFlash: boolean;
}
export function useEditGate(codeMode: boolean): EditGate {
const pendingEdits = useRef<EditBlock[]>([]);
const [pendingCount, setPendingCount] = useState(0);
const [pendingTick, setPendingTick] = useState(0);
const syncPendingCount = useCallback(() => {
setPendingCount(pendingEdits.current.length);
setPendingTick((t) => t + 1);
}, []);
const [editMode, setEditMode] = useState<EditMode>(() => (codeMode ? loadEditMode() : "review"));
const editModeRef = useRef<EditMode>(editMode);
useEffect(() => {
editModeRef.current = editMode;
if (codeMode) saveEditMode(editMode);
}, [editMode, codeMode]);
const [modeFlash, setModeFlash] = useState(false);
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevEditModeRef = useRef<EditMode>(editMode);
useEffect(() => {
if (prevEditModeRef.current === editMode) return;
prevEditModeRef.current = editMode;
setModeFlash(true);
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
flashTimerRef.current = setTimeout(() => {
setModeFlash(false);
flashTimerRef.current = null;
}, FLASH_MS);
}, [editMode]);
return {
pendingEdits,
pendingCount,
pendingTick,
syncPendingCount,
editMode,
setEditMode,
editModeRef,
modeFlash,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { useCallback, useState } from "react";
import { type ResolvedHook, loadHooks } from "../../../hooks.js";
export interface HookList {
hookList: ResolvedHook[];
/** `loadHooks(projectRoot)` + state replacement — returns the fresh count for the slash handler's reply. */
reloadHooks: (projectRoot: string | undefined) => number;
}
export function useHookList(initialProjectRoot: string | undefined): HookList {
const [hookList, setHookList] = useState<ResolvedHook[]>(() =>
loadHooks({ projectRoot: initialProjectRoot }),
);
const reloadHooks = useCallback((projectRoot: string | undefined): number => {
const fresh = loadHooks({ projectRoot });
setHookList(fresh);
return fresh.length;
}, []);
return { hookList, reloadHooks };
}
+1 -1
View File
@@ -8,7 +8,7 @@ export interface UseInputRecallResult {
resetCursor: () => void;
}
/** Bash-style ↑/↓ recall over a turn-local prompt history. Cursor is `useRef` so toggles don't re-render. */
/** Bash-style Ctrl+P/Ctrl+N recall over a turn-local prompt history. Cursor is `useRef` so toggles don't re-render. */
export function useInputRecall(setInput: (s: string) => void): UseInputRecallResult {
const promptHistory = useRef<string[]>([]);
const historyCursor = useRef<number>(-1);
+8
View File
@@ -0,0 +1,8 @@
import { useEffect, useState } from "react";
import { onLanguageChange } from "../../../i18n/index.js";
export function useLanguageReload(): number {
const [version, setVersion] = useState(0);
useEffect(() => onLanguageChange(() => setVersion((v) => v + 1)), []);
return version;
}
+22
View File
@@ -0,0 +1,22 @@
import { type Dispatch, type SetStateAction, useState } from "react";
export interface PresetMode {
/** Canonical preset bucket — `pro` if loop is on v4-pro, otherwise `auto`/`flash` (set by the dashboard's `applyPresetLive`). */
preset: "auto" | "flash" | "pro";
setPreset: Dispatch<SetStateAction<"auto" | "flash" | "pro">>;
/** `/pro` armed → next turn runs on v4-pro. State (rather than reading `loop.proArmed`) so toggles trigger StatsPanel re-render. */
proArmed: boolean;
setProArmed: Dispatch<SetStateAction<boolean>>;
/** True for the duration of a turn that ran on v4-pro because of /pro arming or `⇧ pro` auto-escalation. */
turnOnPro: boolean;
setTurnOnPro: Dispatch<SetStateAction<boolean>>;
}
export function usePresetMode(model: string): PresetMode {
const [preset, setPreset] = useState<"auto" | "flash" | "pro">(() =>
model === "deepseek-v4-pro" ? "pro" : "auto",
);
const [proArmed, setProArmed] = useState(false);
const [turnOnPro, setTurnOnPro] = useState(false);
return { preset, setPreset, proArmed, setProArmed, turnOnPro, setTurnOnPro };
}
+28 -4
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { DoctorCheckEntry, PlanStep } from "../state/cards.js";
import type { DoctorCheckEntry, PlanStep, TipSection } from "../state/cards.js";
import { useDispatch } from "../state/provider.js";
let seq = 0;
@@ -22,6 +22,13 @@ export interface Scrollback {
text: string,
tone?: "info" | "ok" | "warn" | "err" | "ghost" | "brand" | "accent",
): string;
/** Structured onboarding-tip card — replaces multi-line TIP strings stuffed into pushInfo. */
pushTip(args: {
topic: string;
sections: ReadonlyArray<TipSection>;
footer?: string;
oneTime?: boolean;
}): string;
/** Emits a `ctxPressure` live card when usedTokens crosses 80% (warn) or 95% (err) of ctxMax. */
pushCtxPressureIfHigh(usedTokens: number, ctxMax: number): void;
pushStepProgress(stepIndex: number, total: number, title: string, elapsedMs?: number): string;
@@ -67,7 +74,8 @@ export interface Scrollback {
appendStreaming(id: string, chunk: string): void;
endStreaming(id: string, aborted?: boolean): void;
startTool(name: string, args: unknown): string;
/** `presetId` overrides the auto-generated card id — pass the loop's callId so the inflight set's key matches the card's id. */
startTool(name: string, args: unknown, presetId?: string): string;
appendToolOutput(id: string, chunk: string): void;
endTool(
id: string,
@@ -139,6 +147,22 @@ export function useScrollback(): Scrollback {
});
return id;
},
pushTip({ topic, sections, footer, oneTime = true }) {
const id = nextId("tip");
dispatch({
type: "tip.show",
id,
ts: Date.now(),
topic,
sections: sections.map((s) => ({
title: s.title,
rows: s.rows.map((r) => ({ key: r.key, text: r.text })),
})),
footer,
oneTime,
});
return id;
},
pushCtxPressureIfHigh(usedTokens, ctxMax) {
if (ctxMax <= 0) return;
const pct = (usedTokens / ctxMax) * 100;
@@ -247,8 +271,8 @@ export function useScrollback(): Scrollback {
endStreaming(id, aborted) {
dispatch({ type: "streaming.end", id, aborted });
},
startTool(name, args) {
const id = nextId("tool");
startTool(name, args, presetId) {
const id = presetId ?? nextId("tool");
dispatch({ type: "tool.start", id, name, args });
return id;
},
+18
View File
@@ -0,0 +1,18 @@
import { useStdout } from "ink";
import { useEffect } from "react";
export function useTerminalSetup(mouse: boolean): void {
const { stdout } = useStdout();
useEffect(() => {
if (!stdout || !stdout.isTTY) return;
stdout.write("\u001b[?2004h");
stdout.write("\u001b[>4;2m");
// 1007 (alt-scroll) over full mouse tracking — keeps native drag-select intact.
if (mouse) stdout.write("\u001b[?1007h");
return () => {
if (mouse) stdout.write("\u001b[?1007l");
stdout.write("\u001b[?2004l");
stdout.write("\u001b[>4m");
};
}, [stdout, mouse]);
}
@@ -0,0 +1,60 @@
import { type Dispatch, type SetStateAction, useCallback, useEffect, useState } from "react";
export interface ProgressSinkRef {
current:
| ((info: { toolName: string; progress: number; total?: number; message?: string }) => void)
| null;
}
export interface ToolProgressDisplay {
ongoingTool: { name: string; args?: string } | null;
setOngoingTool: Dispatch<SetStateAction<{ name: string; args?: string } | null>>;
toolProgress: { progress: number; total?: number; message?: string } | null;
setToolProgress: Dispatch<
SetStateAction<{ progress: number; total?: number; message?: string } | null>
>;
statusLine: string | null;
setStatusLine: Dispatch<SetStateAction<string | null>>;
/** Clears all three — call from the turn-end `finally`. */
clear: () => void;
}
export function useToolProgressDisplay(progressSink?: ProgressSinkRef): ToolProgressDisplay {
const [ongoingTool, setOngoingTool] = useState<{ name: string; args?: string } | null>(null);
const [toolProgress, setToolProgress] = useState<{
progress: number;
total?: number;
message?: string;
} | null>(null);
const [statusLine, setStatusLine] = useState<string | null>(null);
useEffect(() => {
if (!progressSink) return;
progressSink.current = (info) => {
setToolProgress({
progress: info.progress,
total: info.total,
message: info.message,
});
};
return () => {
if (progressSink.current) progressSink.current = null;
};
}, [progressSink]);
const clear = useCallback(() => {
setOngoingTool(null);
setToolProgress(null);
setStatusLine(null);
}, []);
return {
ongoingTool,
setOngoingTool,
toolProgress,
setToolProgress,
statusLine,
setStatusLine,
clear,
};
}
+25
View File
@@ -0,0 +1,25 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useEffect,
useRef,
useState,
} from "react";
export interface WorkspaceRoot {
/** Live working directory — every rootDir-dependent surface (hook cwd, memory root, shell allowlist root, `@file` mention root, applyEditBlocks base, run_command cwd) reads this. */
currentRootDir: string;
setCurrentRootDir: Dispatch<SetStateAction<string>>;
/** Mirror for closures captured at boot (dashboard server, tool interceptor) — without it those reads freeze on the launch root after `/cwd`. */
currentRootDirRef: MutableRefObject<string>;
}
export function useWorkspaceRoot(launchRoot: string | undefined): WorkspaceRoot {
const [currentRootDir, setCurrentRootDir] = useState<string>(() => launchRoot ?? process.cwd());
const currentRootDirRef = useRef<string>(currentRootDir);
useEffect(() => {
currentRootDirRef.current = currentRootDir;
}, [currentRootDir]);
return { currentRootDir, setCurrentRootDir, currentRootDirRef };
}
+114 -21
View File
@@ -1,32 +1,38 @@
import { Box, type DOMElement, Text, useBoxMetrics } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useEffect, useRef } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { CardRenderer } from "../cards/CardRenderer.js";
import type { Card } from "../state/cards.js";
import { useChatScrollActions, useChatScrollState } from "../state/chat-scroll-provider.js";
import { useAgentState } from "../state/provider.js";
import { FG } from "../theme/tokens.js";
import { FG, TONE } from "../theme/tokens.js";
/** Buffer of rows kept rendered on each side of the viewport so a single scroll
* step doesn't reveal an unmeasured card. Larger = smoother but renders more. */
const VISIBLE_BUFFER_ROWS = 30;
/**
* Row-precision virtual scroll: outer Box clips with overflow="hidden",
* inner Box holds all cards and slides up via negative marginTop. Yoga
* computes inner height from card layout; useBoxMetrics reports it back
* so App can clamp scroll bounds. New cards arriving while the user is
* scrolled up sit below the visible window — bytes outside outer bounds
* aren't written, so terminal text selection survives streaming activity.
* Row-precision virtual scroll with card-level virtualization.
*
* outer Box clips with overflow="hidden"; inner Box holds visible cards
* plus spacer Boxes for off-screen ranges and slides up via negative
* marginTop. Off-screen cards are replaced by a single spacer Box of the
* cumulative height — Yoga skips them entirely on every re-layout.
*
* Heights are populated lazily: any card whose height isn't cached yet
* is rendered live (so it can be measured), then collapses into the
* spacer once outside the viewport. A streaming card that grows on every
* delta keeps its height fresh through the same measurement path.
*/
export function CardStream({
scrollRows,
onMaxScrollChange,
suppressLive = false,
}: {
scrollRows: number;
onMaxScrollChange: (rows: number) => void;
suppressLive?: boolean;
}): React.ReactElement {
const cards = useAgentState((s) => s.cards);
// useBoxMetrics types its ref as RefObject<DOMElement> (non-null); the
// null! assertion satisfies the signature — Ink populates the ref on
// mount before the metrics hook reads it.
const scrollRows = useChatScrollState((s) => s.scrollRows);
const cardHeights = useChatScrollState((s) => s.cardHeights);
const { setMaxScroll, setCardHeight, pruneCardHeights } = useChatScrollActions();
const outerRef = useRef<DOMElement>(null!);
const innerRef = useRef<DOMElement>(null!);
const outer = useBoxMetrics(outerRef);
@@ -34,28 +40,115 @@ export function CardStream({
const maxScroll = Math.max(0, inner.height - outer.height);
useEffect(() => {
onMaxScrollChange(maxScroll);
}, [maxScroll, onMaxScrollChange]);
setMaxScroll(maxScroll);
}, [maxScroll, setMaxScroll]);
// Drop heights for cards no longer in the list (resumed sessions, /clear, etc).
useEffect(() => {
const live = new Set<string>();
for (const c of cards) live.add(c.id);
pruneCardHeights(live);
}, [cards, pruneCardHeights]);
let visible = cards;
if (suppressLive && cards.length > 0 && !isFullySettled(cards[cards.length - 1]!)) {
visible = cards.slice(0, -1);
}
/** Compute which cards land inside the visible window + buffer. Cards with
* unknown heights are always kept live so they get measured on first paint. */
const items = useMemo(() => {
const winStart = Math.max(0, scrollRows - VISIBLE_BUFFER_ROWS);
const winEnd = scrollRows + outer.height + VISIBLE_BUFFER_ROWS;
const out: Array<{ kind: "spacer"; rows: number; key: string } | { kind: "card"; card: Card }> =
[];
let cursor = 0;
let pendingSpacer = 0;
let spacerKey = 0;
for (const card of visible) {
const h = cardHeights.get(card.id);
const cardEnd = cursor + (h ?? 0);
// Render live when:
// 1. height isn't cached yet (need to measure), OR
// 2. card range overlaps the visible window.
const live = h === undefined || (cardEnd >= winStart && cursor <= winEnd);
if (live) {
if (pendingSpacer > 0) {
out.push({ kind: "spacer", rows: pendingSpacer, key: `sp-${spacerKey++}` });
pendingSpacer = 0;
}
out.push({ kind: "card", card });
} else {
pendingSpacer += h ?? 0;
}
cursor = cardEnd;
}
if (pendingSpacer > 0) {
out.push({ kind: "spacer", rows: pendingSpacer, key: `sp-${spacerKey}` });
}
return out;
}, [visible, cardHeights, scrollRows, outer.height]);
return (
<>
{scrollRows > 0 ? <Text color={FG.faint}>{" ↑ earlier — PgUp / wheel / ↑"}</Text> : null}
{/* Always reserve the row — making it conditional ties outer.height to scrollRows and closes a setState loop with pinned mode. */}
<Box height={1} flexShrink={0}>
{scrollRows > 0 ? <ScrollIndicator scrollRows={scrollRows} maxScroll={maxScroll} /> : null}
</Box>
<Box ref={outerRef} flexDirection="column" flexGrow={1} overflow="hidden">
<Box ref={innerRef} flexDirection="column" marginTop={-scrollRows} flexShrink={0}>
{visible.map((card) => (
<CardRenderer key={card.id} card={card} />
))}
{items.map((item) =>
item.kind === "spacer" ? (
<Box key={item.key} height={item.rows} flexShrink={0} />
) : (
<MeasuredCard key={item.card.id} card={item.card} report={setCardHeight} />
),
)}
</Box>
</Box>
</>
);
}
/** Thin wrapper that captures a card's row height on every render and reports
* it to the scroll store. Wrapping in React.memo would defeat the purpose —
* we *want* the effect to re-run when the streaming card grows. */
function MeasuredCard({
card,
report,
}: { card: Card; report: (id: string, rows: number) => void }): React.ReactElement {
const ref = useRef<DOMElement>(null!);
const m = useBoxMetrics(ref);
useEffect(() => {
if (m.height > 0) report(card.id, m.height);
}, [card.id, m.height, report]);
return (
<Box ref={ref} flexDirection="column" flexShrink={0}>
<CardRenderer card={card} />
</Box>
);
}
/** Position indicator in the row above the viewport. Briefly highlights on every
* scroll tick (scrollVersion bump) so the user gets visual confirmation that
* the wheel/arrow registered, even before the new frame paints. */
function ScrollIndicator({
scrollRows,
maxScroll,
}: { scrollRows: number; maxScroll: number }): React.ReactElement {
const version = useChatScrollState((s) => s.scrollVersion);
const [hot, setHot] = React.useState(false);
React.useEffect(() => {
if (version === 0) return;
setHot(true);
const id = setTimeout(() => setHot(false), 220);
return () => clearTimeout(id);
}, [version]);
const remaining = Math.max(0, maxScroll - scrollRows);
const text = ` ↑ ${scrollRows} / ${maxScroll} rows above${remaining > 0 ? ` — ${remaining} more` : ""} · PgUp / wheel / ↑`;
return <Text color={hot ? TONE.accent : FG.faint}>{text}</Text>;
}
function isFullySettled(card: Card): boolean {
switch (card.kind) {
case "streaming":
+8 -10
View File
@@ -1,35 +1,33 @@
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 { useAgentState } from "../state/provider.js";
import { FG, TONE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
import { StatusRow } from "./StatusRow.js";
const PLACEHOLDER = "type a message · / for commands · @ to attach a file";
const HINT = "⏎ send · shift/alt+⏎ newline · ↑↓ history · esc abort · ctrl-c quit";
const ABORTED_HINT = "turn aborted by user · esc again to clear · ⏎ to ask a follow-up";
export function Composer(): React.ReactElement {
const composer = useAgentState((s) => s.composer);
const { fg, tone } = useThemeTokens();
return (
<Box flexDirection="column">
<StatusRow />
<Box height={1} />
<Box flexDirection="row">
<Text bold color={composer.shell ? TONE.err : TONE.brand}>
<Text bold color={composer.shell ? tone.err : tone.brand}>
{composer.shell ? "$" : "›"}{" "}
</Text>
{composer.value.length === 0 ? (
<Text color={FG.meta}>{PLACEHOLDER}</Text>
<Text color={fg.meta}>{t("composer.placeholder")}</Text>
) : (
<Text color={FG.body}>{composer.value}</Text>
<Text color={fg.body}>{composer.value}</Text>
)}
</Box>
<Box height={1} />
<Text color={FG.faint}>
<Text color={fg.faint}>
{" "}
{composer.abortedHint ? ABORTED_HINT : HINT}
{composer.abortedHint ? t("composer.abortedHint") : t("composer.hint")}
</Text>
</Box>
);
+18 -10
View File
@@ -3,12 +3,14 @@ import { Box, Text } from "ink";
import React from "react";
import type { ApplyResult } from "../../../code/edit-blocks.js";
import type { EditMode } from "../../../config.js";
import { t } from "../../../i18n/index.js";
import type { JobRegistry } from "../../../tools/jobs.js";
import { CharBar } from "../char-bar.js";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pill.js";
import { Spinner } from "../primitives/Spinner.js";
import { useThemeTokens } from "../theme/context.js";
import { CARD, FG, TONE } from "../theme/tokens.js";
import { useElapsedSeconds, useSlowTick, useTick } from "../ticker.js";
import type { SubagentActivity } from "../useSubagent.js";
@@ -18,6 +20,7 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
/** "Thinking" row — soft pulse + italic label (model wait, not tool call). */
export function ThinkingRow({ text }: { text: string }) {
const elapsed = useElapsedSeconds();
const { fg, tone } = useThemeTokens();
return (
<Box marginY={1} paddingX={1} gap={1}>
<Spinner kind="circle" color={TONE.accent} />
@@ -54,26 +57,31 @@ export function ModeStatusBar({
if (planMode) {
return (
<ModeBarFrame>
<ModePill label="PLAN MODE" color={TONE.err} flash={flash} />
<Text color={FG.faint}>{" writes gated · /plan off to leave"}</Text>
<ModePill label={t("editMode.plan")} color={TONE.err} flash={flash} />
<Text color={FG.faint}>{t("editMode.writesGated")}</Text>
{jobsTag}
</ModeBarFrame>
);
}
const label = editMode === "yolo" ? "YOLO" : editMode === "auto" ? "AUTO" : "REVIEW";
const label =
editMode === "yolo"
? t("editMode.yolo")
: editMode === "auto"
? t("editMode.auto")
: t("editMode.review");
const pillColor = editMode === "yolo" ? TONE.err : editMode === "auto" ? TONE.accent : TONE.brand;
const mid =
editMode === "yolo"
? "edits + shell auto · /undo to roll back"
? t("editMode.editsShellAuto")
: editMode === "auto"
? "edits land now · u to undo"
? t("editMode.editsLandNow")
: pendingCount > 0
? `${pendingCount} queued · y apply · n discard`
: "edits queued · y apply · n discard";
? t("editMode.queuedApplyDiscard", { count: pendingCount })
: t("editMode.editsQueued");
return (
<ModeBarFrame>
<ModePill label={label} color={pillColor} flash={flash} />
<Text color={FG.faint}>{` ${mid} · Shift+Tab to flip`}</Text>
<Text color={FG.faint}>{t("editMode.shiftTabFlip", { mid })}</Text>
{jobsTag}
</ModeBarFrame>
);
@@ -197,7 +205,7 @@ export function SubagentRow({ activity }: { activity: SubagentActivity }) {
{last.meta ? <Text color={FG.faint}>{` ${last.meta}`}</Text> : null}
</>
) : (
<Text color={FG.faint}>queued…</Text>
<Text color={FG.faint}>{t("editMode.queuedDots")}</Text>
)}
</Text>
<Text color={TONE.brand}>
@@ -274,7 +282,7 @@ function CompactSubagentLine({
{last.meta ? <Text color={FG.faint}>{` ${last.meta}`}</Text> : null}
</>
) : (
<Text color={FG.faint}>queued…</Text>
<Text color={FG.faint}>{t("editMode.queuedDots")}</Text>
)}
</Box>
);
+137 -33
View File
@@ -1,13 +1,18 @@
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 { t } from "../../../i18n/index.js";
import { VERSION } from "../../../version.js";
import { Countdown } from "../primitives/Countdown.js";
import { useAgentState } from "../state/provider.js";
import type { Mode, NetworkState, StatusBar } from "../state/state.js";
import { FG, TONE, formatCost } from "../theme/tokens.js";
import { FG, TONE, balanceColor, formatBalance, formatCost } from "../theme/tokens.js";
const RULE_PAD = 4;
const RULE_MIN = 20;
const WALLET_MIN_COLS = 90;
const VERSION_MIN_COLS = 70;
const FEEDBACK_HINT_MIN_COLS = 100;
export function StatusRow(): React.ReactElement {
const status = useAgentState((s) => s.status);
@@ -16,15 +21,20 @@ export function StatusRow(): React.ReactElement {
const cols = stdout?.columns ?? 80;
const ruleWidth = Math.max(RULE_MIN, cols - RULE_PAD);
const hasTurn = status.cost > 0;
const hasSession = status.sessionCost > 0;
const hasBalance = typeof status.balance === "number";
const showWallet = cols >= WALLET_MIN_COLS && (hasSession || hasBalance);
return (
<Box flexDirection="column">
<Box>
<Box flexDirection="column" flexShrink={0} flexWrap="nowrap">
<Box height={1} flexWrap="nowrap">
<Text>{" "}</Text>
<Text color={FG.faint}>{"─".repeat(ruleWidth)}</Text>
<Text color={FG.faint} wrap="truncate">
{"─".repeat(ruleWidth)}
</Text>
</Box>
<Box flexDirection="row">
<Text>{" "}</Text>
<Box flexDirection="row" height={1} minHeight={1} flexWrap="nowrap" flexShrink={0}>
<Text wrap="truncate">{" "}</Text>
{status.recording ? (
<RecordingPill rec={status.recording} />
) : status.countdownSeconds !== undefined ? (
@@ -33,25 +43,96 @@ export function StatusRow(): React.ReactElement {
<ModePill mode={status.mode} network={status.network} detail={status.networkDetail} />
)}
<Sep />
<Text color={FG.sub}>{`${session.id} · ${session.branch}`}</Text>
<Text color={FG.sub} wrap="truncate">{`${session.id} · ${session.branch}`}</Text>
{hasTurn && (
<>
<Sep />
<Text bold color={TONE.brand}>
<Text bold color={TONE.brand} wrap="truncate">
{"▸ "}
</Text>
<Text bold color={FG.body}>
{`${formatCost(status.cost, status.balanceCurrency)} turn`}
<Text bold color={FG.body} wrap="truncate">
{`${formatCost(status.cost, status.balanceCurrency)} ${t("statusBar.turn")}`}
</Text>
</>
)}
<Sep />
<Text color={TONE.accent}>{`cache ${Math.round(status.cacheHit * 100)}%`}</Text>
<Text
color={TONE.accent}
wrap="truncate"
>{`${t("statusBar.cache")} ${Math.round(status.cacheHit * 100)}%`}</Text>
{showWallet && (
<WalletPill
sessionCostUsd={status.sessionCost}
balance={status.balance}
currency={status.balanceCurrency}
/>
)}
{cols >= VERSION_MIN_COLS && (
<>
<Sep />
<Text color={FG.faint} wrap="truncate">{`v${VERSION}`}</Text>
{cols >= FEEDBACK_HINT_MIN_COLS && (
<>
<Text color={FG.faint} wrap="truncate">
{" · "}
</Text>
<Text color={FG.meta} wrap="truncate">
{"⚑ "}
</Text>
<Text color={FG.sub} wrap="truncate">
{"/feedback"}
</Text>
</>
)}
</>
)}
</Box>
</Box>
);
}
function WalletPill({
sessionCostUsd,
balance,
currency,
}: {
sessionCostUsd: number;
balance?: number;
currency?: string;
}): React.ReactElement {
const showSpent = sessionCostUsd > 0;
const showBalance = typeof balance === "number";
return (
<>
<Sep />
<Text color={FG.meta} wrap="truncate">
{"⛁ "}
</Text>
{showSpent && (
<Text
color={FG.body}
wrap="truncate"
>{`${formatCost(sessionCostUsd, currency, 2)} ${t("statusBar.spent")}`}</Text>
)}
{showSpent && showBalance && (
<Text color={FG.meta} wrap="truncate">
{" / "}
</Text>
)}
{showBalance && (
<Text bold color={balanceColor(balance, currency)} wrap="truncate">
{formatBalance(balance, currency, { fractionDigits: 2 })}
</Text>
)}
{showBalance && (
<Text color={FG.faint} wrap="truncate">
{t("statusBar.left")}
</Text>
)}
</>
);
}
function ModePill({
mode,
network,
@@ -64,9 +145,11 @@ function ModePill({
if (network === "online") {
const pill = modeGlyph(mode);
return (
<Box flexDirection="row">
<Text color={pill.color}>{pill.glyph}</Text>
<Text color={FG.sub}>{` ${mode}`}</Text>
<Box flexDirection="row" height={1} flexWrap="nowrap">
<Text color={pill.color} wrap="truncate">
{pill.glyph}
</Text>
<Text color={FG.sub} wrap="truncate">{` ${mode}`}</Text>
</Box>
);
}
@@ -74,25 +157,33 @@ function ModePill({
if (network === "slow") {
const tail = detail ? ` · ${detail}` : "";
return (
<Box flexDirection="row">
<Text color={dot.color}>{dot.glyph}</Text>
<Text color={dot.color}>{` ${mode} · slow${tail}`}</Text>
<Box flexDirection="row" height={1} flexWrap="nowrap">
<Text color={dot.color} wrap="truncate">
{dot.glyph}
</Text>
<Text color={dot.color} wrap="truncate">{` ${mode} · ${t("statusBar.slow")}${tail}`}</Text>
</Box>
);
}
if (network === "disconnected") {
const tail = detail ? ` · ${detail}` : "";
return (
<Box flexDirection="row">
<Text color={dot.color}>{dot.glyph}</Text>
<Text color={dot.color}>{` disconnect${tail}`}</Text>
<Box flexDirection="row" height={1} flexWrap="nowrap">
<Text color={dot.color} wrap="truncate">
{dot.glyph}
</Text>
<Text color={dot.color} wrap="truncate">{` ${t("statusBar.disconnect")}${tail}`}</Text>
</Box>
);
}
return (
<Box flexDirection="row">
<Text color={dot.color}>{dot.glyph}</Text>
<Text color={dot.color}>{" reconnecting…"}</Text>
<Box flexDirection="row" height={1} flexWrap="nowrap">
<Text color={dot.color} wrap="truncate">
{dot.glyph}
</Text>
<Text color={dot.color} wrap="truncate">
{` ${t("statusBar.reconnecting")}`}
</Text>
</Box>
);
}
@@ -107,12 +198,18 @@ function CountdownRow({
const pill = modeGlyph(mode);
const endsAt = Date.now() + secondsLeft * 1000;
return (
<Box flexDirection="row">
<Text color={pill.color}>{pill.glyph}</Text>
<Text color={FG.sub}>{` ${mode} · `}</Text>
<Text color={TONE.warn}>{"approving in "}</Text>
<Box flexDirection="row" height={1} flexWrap="nowrap">
<Text color={pill.color} wrap="truncate">
{pill.glyph}
</Text>
<Text color={FG.sub} wrap="truncate">{` ${mode} · `}</Text>
<Text color={TONE.warn} wrap="truncate">
{t("statusBar.approvingIn")}
</Text>
<Countdown endsAt={endsAt} />
<Text color={TONE.warn}>{"s · esc to interrupt"}</Text>
<Text color={TONE.warn} wrap="truncate">
{t("statusBar.escToInterrupt")}
</Text>
</Box>
);
}
@@ -120,17 +217,24 @@ function CountdownRow({
function RecordingPill({ rec }: { rec: NonNullable<StatusBar["recording"]> }): React.ReactElement {
const sizeMb = (rec.sizeBytes / (1024 * 1024)).toFixed(1);
return (
<Box flexDirection="row">
<Text bold color={TONE.err}>
{"●REC"}
<Box flexDirection="row" height={1} flexWrap="nowrap">
<Text bold color={TONE.err} wrap="truncate">
{t("statusBar.recordingGlyph")}
</Text>
<Text color={TONE.err}>{` ${sizeMb} MB · ${rec.events} evt`}</Text>
<Text
color={TONE.err}
wrap="truncate"
>{` ${sizeMb}${t("statusBar.mb")} · ${rec.events}${t("statusBar.evt")}`}</Text>
</Box>
);
}
function Sep(): React.ReactElement {
return <Text color={FG.meta}>{" · "}</Text>;
return (
<Text color={FG.meta} wrap="truncate">
{" · "}
</Text>
);
}
function modeGlyph(mode: Mode): { glyph: string; color: string } {
+19 -25
View File
@@ -1,4 +1,4 @@
/** Pure keystroke→action reducer; arrow keys at buffer boundary defer to the parent for history recall. */
/** Pure keystroke→action reducer; ↑/↓ NOOP (chat-scroll), Ctrl+P/N do per-line cursor + history. */
export interface MultilineKey {
input: string;
@@ -28,10 +28,8 @@ export interface MultilineAction {
/** When `true`, fire `onSubmit(submitValue ?? value)`. */
submit: boolean;
submitValue?: string;
/** Set on Ctrl+P / Ctrl+N — parent handles prompt-history recall. */
/** Set on Ctrl+P / Ctrl+N when no in-buffer cursor move applies — parent recalls prompt history. */
historyHandoff?: "prev" | "next";
/** Set when ↑/↓ fires on an empty buffer — parent scrolls the chat history. */
chatScrollHandoff?: "up" | "down";
/** Reducer is pure — hands raw paste to PromptInput which allocates a sentinel and inserts that. */
pasteRequest?: { content: string };
}
@@ -58,11 +56,9 @@ export function processMultilineKey(
return NOOP;
}
// Buffer-wide navigation. Per-line motion (↑/↓ / Ctrl+A / Ctrl+E)
// is already covered; PageUp/PageDown are the missing "jump to
// top / bottom of the WHOLE buffer" pair, useful after pasting a
// 500-line blob where ↑×500 is absurd. Repurposed from the
// previous NOOP behavior (PageUp/Down had no binding).
// PageUp/PageDown jump to start/end of the WHOLE buffer — useful
// after pasting a 500-line blob. Per-line motion lives on Ctrl+P /
// Ctrl+N now (↑/↓ are owned by chat scroll at the App level).
if (key.pageUp) {
return cursor === 0 ? NOOP : { next: null, cursor: 0, submit: false };
}
@@ -70,35 +66,33 @@ export function processMultilineKey(
return cursor === value.length ? NOOP : { next: null, cursor: value.length, submit: false };
}
// Prompt-history recall is on Ctrl+P / Ctrl+N (bash readline
// convention). ↑/↓ on an empty buffer scrolls chat history because
// Windows Terminal + ConPTY translates mouse wheel events to ↑/↓ in
// raw mode — the only way to make wheel-up scroll chat is to map ↑
// to chat-scroll. Ctrl+P/N is wheel-immune.
// ↑/↓ belong to chat-scroll at the App level. Ctrl+P / Ctrl+N take
// over what ↑/↓ used to do here:
// • multi-line buffer → cursor up/down within the buffer
// • single-line / empty → hand off to prompt history (readline parity)
if (key.ctrl && key.input === "p") {
if (value.includes("\n")) {
const moved = moveCursorUp(value, cursor);
if (moved !== cursor) return { next: null, cursor: moved, submit: false };
}
return { ...NOOP, historyHandoff: "prev" };
}
if (key.ctrl && key.input === "n") {
if (value.includes("\n")) {
const moved = moveCursorDown(value, cursor);
if (moved !== cursor) return { next: null, cursor: moved, submit: false };
}
return { ...NOOP, historyHandoff: "next" };
}
// Cursor motion. Empty single-line buffer + ↑/↓ is now a no-op
// (used to recall history; see Ctrl+P/N comment above).
if (key.leftArrow) {
return { next: null, cursor: Math.max(0, cursor - 1), submit: false };
}
if (key.rightArrow) {
return { next: null, cursor: Math.min(value.length, cursor + 1), submit: false };
}
if (key.upArrow) {
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "up" };
const moved = moveCursorUp(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
if (key.downArrow) {
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "down" };
const moved = moveCursorDown(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
if (key.upArrow || key.downArrow) {
return NOOP;
}
// Emacs-style line jumps. Home/End come through our own stdin reader
+36
View File
@@ -0,0 +1,36 @@
/** Cross-platform URL opener; no-op under CI / when REASONIX_NO_OPEN is set. */
import { spawn } from "node:child_process";
import { platform } from "node:os";
export interface OpenUrlResult {
opened: boolean;
reason?: "ci" | "disabled" | "spawn-failed";
}
export function openUrl(url: string): OpenUrlResult {
if (process.env.CI) return { opened: false, reason: "ci" };
if (process.env.REASONIX_NO_OPEN) return { opened: false, reason: "disabled" };
const os = platform();
let cmd: string;
let args: string[];
if (os === "win32") {
cmd = "cmd";
args = ["/c", "start", "", url];
} else if (os === "darwin") {
cmd = "open";
args = [url];
} else {
cmd = "xdg-open";
args = [url];
}
try {
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
child.unref();
return { opened: true };
} catch {
return { opened: false, reason: "spawn-failed" };
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Markdown header rule used by PlanConfirm to flag plans with open questions. No `\b` — it's ASCII-only and would skip the Chinese alternatives. */
const HEADER_RE =
/^(#{1,6})\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear|待确认|开放问题|风险|未知|假设|不确定)(?:[\s::/、,,].*)?$/im;
export function hasOpenQuestionsSection(plan: string): boolean {
return HEADER_RE.test(plan);
}
/** Markdown body of the first matching heading down to the next same-or-shallower heading; null when absent. */
export function extractOpenQuestionsSection(plan: string): string | null {
const lines = plan.split("\n");
let startIdx = -1;
let startLevel = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const m = line.match(HEADER_RE);
if (m) {
startIdx = i;
startLevel = (m[1] ?? "#").length;
break;
}
}
if (startIdx === -1) return null;
let endIdx = lines.length;
for (let j = startIdx + 1; j < lines.length; j++) {
const line = lines[j] ?? "";
const lh = line.match(/^(#{1,6})\s+\S/);
if (lh && (lh[1] ?? "").length <= startLevel) {
endIdx = j;
break;
}
}
const block = lines.slice(startIdx, endIdx).join("\n").replace(/\s+$/g, "");
return block.length > 0 ? block : null;
}
+37 -10
View File
@@ -35,6 +35,30 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
summary: "switch DeepSeek model id. Bare opens picker.",
argCompleter: "models",
},
{
cmd: "language",
group: "setup",
argsHint: "<EN|zh-CN>",
summary: "switch the runtime language",
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
},
{
cmd: "theme",
group: "setup",
argsHint: "[auto|default|dark|light|tokyo-night|github-dark|github-light|high-contrast]",
summary: "show or persist the terminal theme preference. Bare opens picker.",
argCompleter: [
"auto",
"default",
"dark",
"light",
"tokyo-night",
"github-dark",
"github-light",
"high-contrast",
],
},
{ cmd: "status", group: "info", summary: "current model, flags, context, session" },
{
@@ -60,6 +84,16 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
group: "info",
summary: "health check (api / config / api-reach / index / hooks / project)",
},
{
cmd: "keys",
group: "info",
summary: "keyboard + mouse + copy/paste reference",
},
{
cmd: "feedback",
group: "info",
summary: "open a GitHub issue with diagnostic info copied to clipboard",
},
{ cmd: "sessions", group: "session", summary: "list saved sessions (current marked with ▸)" },
@@ -227,14 +261,6 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
"session USD cap — warns at 80%, refuses next turn at 100%. Off by default. /budget alone shows status",
argCompleter: ["off", "1", "5", "10", "20", "50"],
},
{
cmd: "language",
group: "advanced",
argsHint: "<EN|zh-CN>",
summary: "switch the runtime language",
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
},
{
cmd: "search-engine",
group: "advanced",
@@ -296,9 +322,10 @@ export function suggestSlashCommands(
): SlashCommandSpec[] {
const p = prefix.toLowerCase();
const matches = SLASH_COMMANDS.filter((c) => {
// Empty prefix = browsing the menu — show the full release command surface except
// advanced rows, which remain collapsed behind the footer hint.
if (p === "") return c.group !== "advanced";
if (c.contextual === "code" && !codeMode) return false;
// Empty prefix = browsing the menu — advanced stays hidden until a letter is typed.
if (p === "" && c.group === "advanced") return false;
if (c.cmd.startsWith(p)) return true;
return c.aliases?.some((a) => a.startsWith(p)) ?? false;
});
+8
View File
@@ -15,7 +15,9 @@ import { handlers as permissionsHandlers } from "./handlers/permissions.js";
import { handlers as plansHandlers } from "./handlers/plans.js";
import { handlers as sessionsHandlers } from "./handlers/sessions.js";
import { handlers as skillHandlers } from "./handlers/skill.js";
import { handlers as themeHandlers } from "./handlers/theme.js";
import { handlers as webSearchEngineHandlers } from "./handlers/web-search-engine.js";
import { nearestCommands } from "./nearest.js";
import type { SlashContext, SlashResult } from "./types.js";
/** Synchronous return — async work fires-and-forgets via `ctx.postInfo` to keep input non-blocking. */
@@ -36,6 +38,7 @@ const HANDLERS: Record<string, SlashHandler> = {
...permissionsHandlers,
...plansHandlers,
...sessionsHandlers,
...themeHandlers,
...skillHandlers,
...webSearchEngineHandlers,
};
@@ -48,5 +51,10 @@ export function handleSlash(
): SlashResult {
const h = HANDLERS[resolveSlashAlias(cmd)];
if (h) return h(args, loop, ctx);
const suggestions = nearestCommands(cmd, Object.keys(HANDLERS));
if (suggestions.length > 0) {
const list = suggestions.map((name) => `/${name}`).join(", ");
return { unknown: true, info: `unknown command: /${cmd} — did you mean ${list}?` };
}
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
}
+24 -14
View File
@@ -7,9 +7,15 @@ import {
} from "@/hooks.js";
import { t } from "@/i18n/index.js";
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "@/telemetry/usage.js";
import { VERSION, compareVersions, isNpxInstall } from "@/version.js";
import {
VERSION,
compareVersions,
detectInstallSource,
detectNpmInstallPrefix,
} from "@/version.js";
import { runDoctorChecks } from "../../../commands/doctor.js";
import { renderDashboard } from "../../../commands/stats.js";
import { MANUAL_UPDATE_COMMANDS, planUpdate } from "../../../commands/update.js";
import type { SlashHandler } from "../dispatch.js";
const doctor: SlashHandler = (_args, _loop, ctx) => {
@@ -97,24 +103,28 @@ const update: SlashHandler = (_args, _loop, ctx) => {
return { info: lines.join("\n") };
}
lines.push(t("handlers.admin.updateLatest", { version: latest }));
const diff = compareVersions(VERSION, latest);
if (diff >= 0) {
if (compareVersions(VERSION, latest) >= 0) {
lines.push("", t("handlers.admin.updateUpToDate"));
return { info: lines.join("\n") };
}
if (isNpxInstall()) {
const installSource = detectInstallSource();
const npmPrefix = installSource === "npm" ? detectNpmInstallPrefix() : null;
const plan = planUpdate({ current: VERSION, latest, installSource, npmPrefix });
if (plan.action === "npx-hint") {
lines.push("", t("handlers.admin.updateNpxHint"), t("handlers.admin.updateNpxForce"));
} else {
lines.push(
"",
t("handlers.admin.updateUpgradeHint"),
t("handlers.admin.updateUpgradeCmd1"),
t("handlers.admin.updateUpgradeCmd2"),
"",
t("handlers.admin.updateInSessionDisabled"),
t("handlers.admin.updateInSessionDisabled2"),
);
return { info: lines.join("\n") };
}
lines.push("", t("handlers.admin.updateUpgradeHint"), t("handlers.admin.updateUpgradeCmd1"));
if (plan.action === "run-install" && plan.command) {
lines.push(t("handlers.admin.updateUpgradeCmd2", { command: plan.command.join(" ") }));
} else {
lines.push(...MANUAL_UPDATE_COMMANDS.map((c) => ` ${c}`));
}
lines.push(
"",
t("handlers.admin.updateInSessionDisabled"),
t("handlers.admin.updateInSessionDisabled2"),
);
return { info: lines.join("\n") };
};
+21 -6
View File
@@ -1,4 +1,4 @@
import { t } from "@/i18n/index.js";
import { t, tObj } from "@/i18n/index.js";
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
import { SLASH_COMMANDS } from "../commands.js";
import type { SlashHandler } from "../dispatch.js";
@@ -7,11 +7,11 @@ import type { SlashCommandSpec, SlashGroup } from "../types.js";
const exit: SlashHandler = () => ({ exit: true });
const resetLog: SlashHandler = (_args, loop) => {
const { dropped } = loop.clearLog();
return {
clear: true,
info: t("handlers.basic.newInfo", { count: dropped }),
};
const { dropped, archived } = loop.clearLog();
const info = archived
? t("handlers.basic.newInfoArchived", { count: dropped, archived })
: t("handlers.basic.newInfo", { count: dropped });
return { clear: true, info };
};
const GROUP_ORDER: ReadonlyArray<SlashGroup> = [
@@ -129,10 +129,25 @@ const loop: SlashHandler = (args, _loop, ctx) => {
};
};
const keys: SlashHandler = (_args, _loop, ctx) => {
if (!ctx.postKeys) return { info: t("handlers.basic.keysNeedsTui") };
const ref = tObj<{
topic: string;
sections: ReadonlyArray<{
title?: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer: string;
}>("ui.keysReference");
ctx.postKeys({ topic: ref.topic, sections: ref.sections, footer: ref.footer });
return {};
};
export const handlers: Record<string, SlashHandler> = {
exit,
new: resetLog,
help,
retry,
loop,
keys,
};
+52 -3
View File
@@ -1,11 +1,17 @@
import { t } from "@/i18n/index.js";
import { release } from "node:os";
import { loadTheme, resolveThemePreference } from "@/config.js";
import { getLanguage, t } from "@/i18n/index.js";
import {
DEEPSEEK_CONTEXT_TOKENS,
DEEPSEEK_PRICING,
DEFAULT_CONTEXT_TOKENS,
} from "@/telemetry/stats.js";
import { countTokens } from "@/tokenizer.js";
import { VERSION } from "@/version.js";
import { writeClipboard } from "../../clipboard.js";
import { computeCtxBreakdown } from "../../ctx-breakdown.js";
import { buildFeedbackDiagnostic, buildFeedbackIssueUrl } from "../../feedback.js";
import { openUrl } from "../../open-url.js";
import type { SlashHandler } from "../dispatch.js";
import { compactNum } from "../helpers.js";
@@ -45,11 +51,11 @@ const status: SlashHandler = (_args, loop, ctx) => {
const cacheLine =
summary.turns > 3
? (() => {
const cachePct = Math.round(summary.cacheHitRatio * 100);
const cachePct = summary.cacheHitRatio * 100;
return t("handlers.observability.statusCost", {
cost: cost.toFixed(4),
bar: renderTinyBar(cachePct, 12),
pct: cachePct,
pct: cachePct.toFixed(1),
turns: summary.turns,
});
})()
@@ -225,9 +231,52 @@ function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop
return { info: lines.join("\n") };
}
const feedback: SlashHandler = (_args, loop, ctx) => {
const themeName = resolveThemePreference(loadTheme(), process.env.REASONIX_THEME);
const diagnostic = buildFeedbackDiagnostic({
version: VERSION,
latestVersion: ctx.latestVersion ?? undefined,
platform: process.platform,
osRelease: release(),
termProgram: process.env.TERM_PROGRAM,
term: process.env.TERM,
colorTerm: process.env.COLORTERM,
inWindowsTerminal: !!process.env.WT_SESSION,
inTmux: !!process.env.TMUX,
inSsh: !!process.env.SSH_TTY,
wslDistro: process.env.WSL_DISTRO_NAME,
cols: process.stdout.columns,
rows: process.stdout.rows,
nodeVersion: process.version,
locale: getLanguage(),
theme: themeName,
model: loop.model,
reasoningEffort: loop.reasoningEffort,
editMode: ctx.editMode,
planMode: ctx.planMode,
mcpServerCount: ctx.mcpServers?.length ?? ctx.mcpSpecs?.length,
sessionId: ctx.sessionId,
});
// Clipboard is the belt-and-suspenders: GitHub's new-issue page accepts
// `?body=…` and we use that, but if the URL ever fails to open the
// user can paste from clipboard against any tracker.
writeClipboard(diagnostic);
const url = buildFeedbackIssueUrl(diagnostic);
const opened = openUrl(url);
const lines = [
opened.opened
? "▸ issue page opened with the diagnostic block pre-filled. Just describe what you were doing and submit."
: `▸ couldn't open the browser (${opened.reason ?? "unknown"}). Diagnostic info is on your clipboard; open this URL manually: ${url}`,
"",
diagnostic,
];
return { info: lines.join("\n") };
};
export const handlers: Record<string, SlashHandler> = {
context,
status,
compact,
cost,
feedback,
};
+26
View File
@@ -0,0 +1,26 @@
import { resolveThemePreference, saveTheme } from "@/config.js";
import { type ThemeName, isThemeName, listThemeNames } from "../../theme/tokens.js";
import type { SlashHandler } from "../dispatch.js";
const themeChoices = ["auto", ...listThemeNames()] as const;
function isThemeChoice(value: string): value is ThemeName | "auto" {
return value === "auto" || isThemeName(value);
}
const theme: SlashHandler = (args) => {
const next = args[0];
if (!next) return { openThemePicker: true };
if (!isThemeChoice(next)) {
return { info: `unknown theme: ${next}\navailable: ${themeChoices.join(", ")}` };
}
saveTheme(next);
const active = resolveThemePreference(next, process.env.REASONIX_THEME);
return { info: `theme saved: ${next}\nactive on next launch: ${active}` };
};
export const handlers: Record<string, SlashHandler> = {
theme,
};
+38
View File
@@ -0,0 +1,38 @@
export type NearestCommandOptions = {
max?: number;
maxDistance?: number;
};
export function nearestCommands(
input: string,
all: readonly string[],
opts: NearestCommandOptions = {},
): string[] {
if (!input) return [];
const max = opts.max ?? 3;
const maxDistance = Math.min(opts.maxDistance ?? 3, Math.floor(input.length / 2));
if (max <= 0 || maxDistance <= 0) return [];
return all
.map((name) => ({ name, distance: levenshtein(input, name) }))
.filter((entry) => entry.distance <= maxDistance)
.sort((a, b) => a.distance - b.distance || a.name.localeCompare(b.name))
.slice(0, max)
.map((entry) => entry.name);
}
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (!a) return b.length;
if (!b) return a.length;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
let next = new Array<number>(b.length + 1).fill(0);
for (let i = 0; i < a.length; i += 1) {
next[0] = i + 1;
for (let j = 0; j < b.length; j += 1) {
const cost = a[i] === b[j] ? 0 : 1;
next[j + 1] = Math.min((next[j] ?? 0) + 1, (prev[j + 1] ?? 0) + 1, (prev[j] ?? 0) + cost);
}
[prev, next] = [next, prev];
}
return prev[b.length] ?? 0;
}
+13
View File
@@ -14,6 +14,8 @@ export interface SlashResult {
openCheckpointPicker?: boolean;
/** Open the ModelPicker modal — bare `/model` (no id) opens it. */
openModelPicker?: boolean;
/** Open the ThemePicker modal — bare `/theme` opens it. */
openThemePicker?: boolean;
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
openMcpHub?: { tab: "live" | "marketplace" };
/** Open the arg-completer picker for this command (e.g. `/language` → language picker). */
@@ -90,6 +92,15 @@ export interface SlashContext {
balanceCurrency?: string;
elapsedMs?: number;
}) => void;
/** Push the keyboard + mouse + copy/paste reference TipCard (multi-section). Used by `/keys`. */
postKeys?: (args: {
topic: string;
sections: ReadonlyArray<{
title?: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer?: string;
}) => void;
dispatch?: (event: import("../state/events.js").AgentEvent) => void;
setPlanMode?: (on: boolean) => void;
@@ -125,6 +136,8 @@ export interface SlashContext {
stopDashboard?: () => Promise<void>;
/** Snapshot the dashboard's URL when running, null otherwise. */
getDashboardUrl?: () => string | null;
/** Current session id — included in `/feedback`'s diagnostic block when present. */
sessionId?: string;
}
export type SlashGroup =
+15 -2
View File
@@ -20,9 +20,11 @@ export class TurnTranslator {
}
}
toolStart(name: string, args: unknown): void {
toolStart(name: string, args: unknown, callId?: string): void {
this.toolStartedAt = Date.now();
this.toolCardId = this.log.startTool(name, args);
// callId from the loop event is the inflight-set key — using it as
// the card id lets the UI derive `running` from `loop.inflight.has(card.id)`.
this.toolCardId = this.log.startTool(name, args, callId);
}
toolEnd(output: string): void {
@@ -35,6 +37,17 @@ export class TurnTranslator {
}
}
toolAbort(output?: string): void {
if (this.toolCardId) {
this.log.endTool(this.toolCardId, {
output,
elapsedMs: Date.now() - this.toolStartedAt,
aborted: true,
});
this.toolCardId = null;
}
}
toolRetry(attempt: number, max: number): void {
if (this.toolCardId) this.log.retryTool(this.toolCardId, attempt, max);
}
+12
View File
@@ -52,6 +52,18 @@ export function cardsToDashboardMessages(cards: ReadonlyArray<Card>): DashboardM
case "ctx":
out.push({ id: card.id, role: "info", text: card.text });
break;
case "tip": {
const sectionTexts = card.sections.map((sec) => {
const body = sec.rows.map((r) => `${r.key}\t${r.text}`).join("\n");
return sec.title ? `[${sec.title}]\n${body}` : body;
});
const body = sectionTexts.join("\n\n");
const text = card.footer
? `${card.topic}\n${body}\n${card.footer}`
: `${card.topic}\n${body}`;
out.push({ id: card.id, role: "info", text });
break;
}
case "plan": {
const done = card.steps.filter((s) => s.status === "done").length;
const tag =
+21 -1
View File
@@ -192,6 +192,25 @@ export interface CtxCard extends CardBase {
readonly topTools: ReadonlyArray<{ name: string; tokens: number; turn: number }>;
}
export interface TipRow {
readonly key: string;
readonly text: string;
}
export interface TipSection {
/** Subsection heading (rendered above its rows). Omit for single-section tips. */
readonly title?: string;
readonly rows: ReadonlyArray<TipRow>;
}
export interface TipCard extends CardBase {
readonly kind: "tip";
readonly topic: string;
readonly sections: ReadonlyArray<TipSection>;
readonly footer?: string;
readonly oneTime: boolean;
}
export type Card =
| UserCard
| ReasoningCard
@@ -208,7 +227,8 @@ export type Card =
| SearchCard
| LiveCard
| CtxCard
| DoctorCard;
| DoctorCard
| TipCard;
export interface DoctorCheckEntry {
readonly label: string;
+47
View File
@@ -0,0 +1,47 @@
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import {
type ChatScrollState,
type ChatScrollStore,
createChatScrollStore,
} from "./chat-scroll-store.js";
const Ctx = React.createContext<ChatScrollStore | null>(null);
export function ChatScrollProvider({
children,
}: {
children: React.ReactNode;
}): React.ReactElement {
const store = React.useMemo(() => createChatScrollStore(), []);
return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
}
function useStore(): ChatScrollStore {
const s = React.useContext(Ctx);
if (!s) throw new Error("useChatScroll* must be used inside ChatScrollProvider");
return s;
}
/** Subscribes to a slice of scroll state — only re-renders when that slice changes. */
export function useChatScrollState<T>(selector: (s: ChatScrollState) => T): T {
const store = useStore();
const subscribe = React.useCallback((cb: () => void) => store.subscribe(cb), [store]);
const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/** Returns the action set — stable across renders, never triggers re-renders by itself. */
export function useChatScrollActions(): Pick<
ChatScrollStore,
| "scrollUp"
| "scrollDown"
| "scrollPageUp"
| "scrollPageDown"
| "jumpToBottom"
| "setMaxScroll"
| "setCardHeight"
| "pruneCardHeights"
> {
return useStore();
}
+141
View File
@@ -0,0 +1,141 @@
/** Chat-scroll state in its own store so wheel/arrow ticks don't dirty App.tsx. */
export interface ChatScrollState {
/** Rows of content above the visible viewport. */
scrollRows: number;
/** True while following the bottom — auto-advances on new content. */
pinned: boolean;
/** Total scrollable rows; CardStream reports this once Yoga has measured. */
maxScroll: number;
/** Bumped on every applied scroll delta — consumers can flash an indicator. */
scrollVersion: number;
/** Per-card row height, populated as cards mount and re-measured on streaming changes. */
cardHeights: ReadonlyMap<string, number>;
}
export type ScrollListener = () => void;
export interface ChatScrollStore {
getState(): ChatScrollState;
subscribe(listener: ScrollListener): () => void;
scrollUp(): void;
scrollDown(): void;
scrollPageUp(): void;
scrollPageDown(): void;
jumpToBottom(): void;
setMaxScroll(rows: number): void;
/** Reports a card's measured height. No-op if value matches the cache. */
setCardHeight(id: string, rows: number): void;
/** Drops heights for cards no longer in the visible list. Called by CardStream when cards change. */
pruneCardHeights(liveIds: ReadonlySet<string>): void;
}
export const SCROLL_ARROW_ROWS = 3;
export const SCROLL_PAGE_ROWS = 5;
const COALESCE_MS = 16;
const EMPTY_HEIGHTS: ReadonlyMap<string, number> = new Map();
const initial: ChatScrollState = {
scrollRows: 0,
pinned: true,
maxScroll: 0,
scrollVersion: 0,
cardHeights: EMPTY_HEIGHTS,
};
export function createChatScrollStore(): ChatScrollStore {
let state = initial;
const listeners = new Set<ScrollListener>();
let pendingDelta = 0;
let flushTimer: NodeJS.Timeout | null = null;
function set(next: Partial<ChatScrollState>): void {
const merged = { ...state, ...next };
if (
merged.scrollRows === state.scrollRows &&
merged.pinned === state.pinned &&
merged.maxScroll === state.maxScroll &&
merged.scrollVersion === state.scrollVersion &&
merged.cardHeights === state.cardHeights
) {
return;
}
state = merged;
for (const l of listeners) l();
}
function applyDelta(): void {
const d = pendingDelta;
pendingDelta = 0;
if (d === 0) return;
const next = Math.max(0, Math.min(state.maxScroll, state.scrollRows + d));
set({
scrollRows: next,
pinned: d < 0 ? false : next >= state.maxScroll ? true : state.pinned,
scrollVersion: state.scrollVersion + 1,
});
}
/** Leading-edge: first tick flushes immediately, rest coalesce into one trailing flush. */
function schedule(delta: number): void {
if (flushTimer === null) {
pendingDelta = delta;
applyDelta();
flushTimer = setTimeout(() => {
flushTimer = null;
if (pendingDelta !== 0) applyDelta();
}, COALESCE_MS);
} else {
pendingDelta += delta;
}
}
return {
getState() {
return state;
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
scrollUp: () => schedule(-SCROLL_ARROW_ROWS),
scrollDown: () => schedule(SCROLL_ARROW_ROWS),
scrollPageUp: () => schedule(-SCROLL_PAGE_ROWS),
scrollPageDown: () => schedule(SCROLL_PAGE_ROWS),
jumpToBottom() {
pendingDelta = 0;
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
set({ pinned: true });
},
setMaxScroll(rows: number) {
const m = rows < 0 ? 0 : rows;
// Pinned-mode invariant: scrollRows tracks maxScroll exactly.
const nextScrollRows = state.pinned ? m : Math.min(state.scrollRows, m);
set({ maxScroll: m, scrollRows: nextScrollRows });
},
setCardHeight(id: string, rows: number) {
if (state.cardHeights.get(id) === rows) return;
const next = new Map(state.cardHeights);
next.set(id, rows);
set({ cardHeights: next });
},
pruneCardHeights(liveIds: ReadonlySet<string>) {
let drop = 0;
for (const id of state.cardHeights.keys()) {
if (!liveIds.has(id)) drop++;
}
if (drop === 0) return;
const next = new Map<string, number>();
for (const [id, h] of state.cardHeights) {
if (liveIds.has(id)) next.set(id, h);
}
set({ cardHeights: next });
},
};
}
+16
View File
@@ -283,6 +283,21 @@ const liveShow = z.object({
meta: z.string().optional(),
});
const tipShow = z.object({
type: z.literal("tip.show"),
id: cardId,
ts: ts,
topic: z.string(),
sections: z.array(
z.object({
title: z.string().optional(),
rows: z.array(z.object({ key: z.string(), text: z.string() })),
}),
),
footer: z.string().optional(),
oneTime: z.boolean(),
});
export const AgentEventSchema = z.discriminatedUnion("type", [
userSubmit,
turnStart,
@@ -315,6 +330,7 @@ export const AgentEventSchema = z.discriminatedUnion("type", [
toastShow,
toastHide,
liveShow,
tipShow,
sessionReset,
planShow,
planStepComplete,
+27
View File
@@ -0,0 +1,27 @@
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { createContext, useContext, useSyncExternalStore } from "react";
import type { InflightSet } from "../../../core/inflight.js";
const Ctx = createContext<InflightSet | null>(null);
export function InflightProvider({
inflight,
children,
}: {
inflight: InflightSet;
children: React.ReactNode;
}): React.ReactElement {
return <Ctx.Provider value={inflight}>{children}</Ctx.Provider>;
}
/** True iff the loop currently has `id` in its inflight set. Re-renders on every set mutation; React bails on unchanged boolean snapshot. */
export function useIsInflight(id: string): boolean {
const inflight = useContext(Ctx);
return useSyncExternalStore(
(cb) => (inflight ? inflight.subscribe(cb) : noop),
() => (inflight ? inflight.has(id) : false),
() => false,
);
}
const noop = () => {};
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more