mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
736e615c8e | ||
|
|
447a31cc3a | ||
|
|
87eb8f168f | ||
|
|
2af4cb3fdd | ||
|
|
2813af8329 | ||
|
|
79da127680 | ||
|
|
8fab388608 | ||
|
|
484c40a02c | ||
|
|
54dd2ea8b4 | ||
|
|
07bddea41c | ||
|
|
08b7ced357 | ||
|
|
ffcc0443f1 | ||
|
|
3426f5a126 | ||
|
|
39685d0ab3 | ||
|
|
6e723e2e60 | ||
|
|
59babbb604 | ||
|
|
bcc64ff822 | ||
|
|
9a43aa214b | ||
|
|
d61d6806c8 | ||
|
|
5a4754ffc6 | ||
|
|
a9314cf23a | ||
|
|
26d2c527db | ||
|
|
054d3989ed | ||
|
|
bcfb7e586d | ||
|
|
122d2e0fc9 | ||
|
|
cf122c357d | ||
|
|
de4b342199 | ||
|
|
6761a31697 | ||
|
|
94dc1926f2 | ||
|
|
98183baf99 | ||
|
|
614236eb03 | ||
|
|
b1efcfe091 | ||
|
|
c2966f5c0e | ||
|
|
4dbb03ae36 | ||
|
|
05491f16c6 | ||
|
|
e2b3de7246 | ||
|
|
d9cfeffd50 | ||
|
|
67a1d6194b | ||
|
|
0a1c65d9db | ||
|
|
ef2b50d873 | ||
|
|
6cde945a01 | ||
|
|
faf59cee4a | ||
|
|
1e7be37a55 | ||
|
|
7f54ae0678 | ||
|
|
bdf8faeb4e | ||
|
|
a9c407853a | ||
|
|
959e9ffefd | ||
|
|
012757567e | ||
|
|
453a3bc9d3 | ||
|
|
dbafd1632c | ||
|
|
1aca86b424 | ||
|
|
5ce642aeeb | ||
|
|
c888ab33d7 | ||
|
|
88514595ac | ||
|
|
29020327fe | ||
|
|
7642ad6ba2 | ||
|
|
e205c59775 | ||
|
|
874f0d1cc1 | ||
|
|
2462b1c278 | ||
|
|
d421970a27 | ||
|
|
d1f491b29f | ||
|
|
3f6fb51b08 | ||
|
|
c63239ddb8 | ||
|
|
ec5bfabde2 | ||
|
|
a525182aff | ||
|
|
f795583cea | ||
|
|
bb971bd403 | ||
|
|
06204c760e | ||
|
|
c504707af8 | ||
|
|
f0a552bed5 | ||
|
|
bb5b124817 | ||
|
|
636ad7e551 | ||
|
|
1c360ac81b | ||
|
|
c120ed3a39 | ||
|
|
42f75c7478 | ||
|
|
34b8275f20 |
No files matched your search
+652
@@ -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 —
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">Guide</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">Architecture</a>
|
||||
·
|
||||
<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
@@ -9,6 +9,8 @@
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh">配置指南</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">架构文档</a>
|
||||
·
|
||||
<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
@@ -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); }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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: "模型",
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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><project>/.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 & 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 <name></code></td>
|
||||
<td data-i18n="mcp.cmd.disable">
|
||||
Persist to <code>mcpDisabled</code>; effective on next launch.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/mcp enable <name></code></td>
|
||||
<td data-i18n="mcp.cmd.enable">Re-enable a disabled server.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/mcp reconnect <name></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 <name></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
|
||||
|
||||
<project>/.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><name>.md</code>, or a
|
||||
<code><name>/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 <name></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 <name></code></td>
|
||||
<td data-i18n="sk.cmd.show">Print the full body.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/skill <name> [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
|
||||
<project-hash>/ <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 <name></code></td>
|
||||
<td data-i18n="mem.cmd.show">Display body. Scope is auto-resolved.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/memory forget <name></code></td>
|
||||
<td data-i18n="mem.cmd.forget">Delete one entry.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/memory clear <scope> 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><project>/.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 <prefix></code></td>
|
||||
<td data-i18n="perm.cmd.add">Add a shell prefix.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/permissions rm <prefix|index></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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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><project>/.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 & 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 <name></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><name>.md</code>, or a <code><name>/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><project>/.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 <name></code>)。名称+描述会钉进 prompt,body 按需加载。同名时项目级覆盖全局。",
|
||||
"sk.h.layout": "目录布局",
|
||||
"sk.body.layout":
|
||||
"两种等价的形式:扁平的 <code><name>.md</code>,或者 <code><name>/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
@@ -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
@@ -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
@@ -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 && cd reasonix && 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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://esengine.github.io/DeepSeek-Reasonix/sitemap.xml
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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, """)
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
+185
-194
@@ -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, """)
|
||||
.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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)}…`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import type { TipCard as TipCardData, TipRow as TipRowData } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
const KEY_GUTTER = 4;
|
||||
|
||||
export function TipCard({ card }: { card: TipCardData }): React.ReactElement {
|
||||
const keyWidth = card.sections.reduce(
|
||||
(max, sec) => sec.rows.reduce((m, r) => Math.max(m, stringWidth(r.key)), max),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2} marginY={1}>
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.accent} bold>
|
||||
ⓘ
|
||||
</Text>
|
||||
<Text color={FG.body} bold>
|
||||
{card.topic}
|
||||
</Text>
|
||||
</Box>
|
||||
{card.oneTime ? <Text color={FG.faint}>{t("ui.tipShownOnce")}</Text> : null}
|
||||
</Box>
|
||||
{card.sections.map((section, i) => (
|
||||
<Box key={section.title ?? `section-${i}`} flexDirection="column" marginTop={1}>
|
||||
{section.title ? (
|
||||
<Box marginBottom={0}>
|
||||
<Text color={FG.sub}>{section.title}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{section.rows.map((row) => (
|
||||
<TipRowRender
|
||||
key={row.key}
|
||||
row={row}
|
||||
keyWidth={keyWidth}
|
||||
indent={section.title ? 2 : 0}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{card.footer ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>{card.footer}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TipRowRender({
|
||||
row,
|
||||
keyWidth,
|
||||
indent,
|
||||
}: {
|
||||
row: TipRowData;
|
||||
keyWidth: number;
|
||||
indent: number;
|
||||
}) {
|
||||
const pad = " ".repeat(Math.max(0, keyWidth - stringWidth(row.key) + KEY_GUTTER));
|
||||
const lead = indent > 0 ? " ".repeat(indent) : "";
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{lead ? <Text>{lead}</Text> : null}
|
||||
<Text color={TONE.accent}>{row.key}</Text>
|
||||
<Text>{pad}</Text>
|
||||
<Text color={FG.body}>{row.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,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;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -28,9 +28,9 @@ export interface CharBarProps {
|
||||
pct: number;
|
||||
/** Cell count. Default 24. Min 4 enforced so the bar is at least readable. */
|
||||
width?: number;
|
||||
/** Filled-cell color. Defaults to brand cyan. */
|
||||
/** Filled-cell COLOR. Defaults to brand cyan. */
|
||||
color?: string;
|
||||
/** Empty-cell color. Defaults to dim slate. */
|
||||
/** Empty-cell COLOR. Defaults to dim slate. */
|
||||
emptyColor?: string;
|
||||
/**
|
||||
* Whether to render the percentage label after the bar. Off when the
|
||||
|
||||
@@ -0,0 +1,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(", ")})`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 } {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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)` };
|
||||
}
|
||||
@@ -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") };
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in new issue
Block a user