Compare commits

...
22 Commits
Author SHA1 Message Date
YHH 29020327fe chore(release): 0.35.0 (#505)
* chore(format): reuse fmtPct for cache-hit display; keep cachePct numeric

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

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

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

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

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

Closes #499

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three knobs:

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

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

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

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

Two pieces:

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

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

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

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

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

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

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

Closes #482
2026-05-09 12:01:33 +08:00
YHH bb971bd403 chore(release): 0.34.0 (#484) 2026-05-09 11:39:14 +08:00
YHH 06204c760e fix(dashboard): stop pushing localStorage lang back to server on init (#483)
The dashboard's initLangFromServer() had two branches when localStorage
disagreed with the server config:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #478

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

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

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

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

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

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

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

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

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

   Drops the now-dead `chatScrollHandoff` plumbing.

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

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

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

PromptInput: friendlier placeholder copy ("ask anything") and the
hint footer now uses keycap/label spacing (⇧⏎ instead of
shift/alt+⏎) for a less command-line-jargon read.
2026-05-09 08:21:09 +08:00
80 changed files with 4494 additions and 893 deletions

No files matched your search

+298
View File
@@ -3,6 +3,304 @@
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.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 —
+16 -14
View File
@@ -85,7 +85,7 @@ a:hover { text-decoration: underline; }
/* ── Doc chrome ─────────────────────────────────────────────────────────── */
.page {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-columns: 260px minmax(0, 1fr);
max-width: 1320px;
margin: 0 auto;
min-height: 100vh;
@@ -94,18 +94,19 @@ a:hover { text-decoration: underline; }
position: sticky; top: 0; align-self: start;
height: 100vh; overflow-y: auto;
border-right: 1px solid var(--bd);
padding: 28px 18px;
padding: 28px 16px;
background: var(--bg);
}
.toc h1 { font-size: 14px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 { font-size: 15px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 .dot { color: var(--c-brand); margin-right: 8px; }
.toc .sub { font-size: 11px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc .sub { font-size: 12px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc-section:first-of-type { margin-top: 0; }
.toc ul { list-style: none; padding: 0; margin: 0; }
.toc li a {
display: block; padding: 3px 8px; margin: 1px 0;
color: var(--fg-2); font-size: 12.5px; border-radius: var(--r);
display: block; padding: 4px 10px; margin: 1px 0;
color: var(--fg-2); font-size: 14px; line-height: 1.4;
border-radius: var(--r); overflow-wrap: anywhere;
}
.toc li a:hover { color: var(--fg-0); background: var(--bg-elev); text-decoration: none; }
@@ -118,7 +119,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
}
.section > h2 .num { color: var(--fg-4); margin-right: 10px; font-weight: 500; }
.section > .lede {
color: var(--fg-2); margin: 0 0 22px; font-size: 13px; max-width: 700px; line-height: 1.6;
color: var(--fg-2); margin: 0 0 22px; font-size: 15px; max-width: 720px; line-height: 1.6;
}
.subsec { margin-bottom: 22px; }
.subsec > h3 {
@@ -126,8 +127,8 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
margin: 24px 0 4px; letter-spacing: .04em; text-transform: uppercase;
font-family: var(--font-mono);
}
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 12px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 12.5px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 13px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 15px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
/* "Mock" — a faux-window frame to display dashboard pieces inside the design doc */
.mock {
@@ -155,11 +156,11 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
.swatch .chip { width: 22px; height: 22px; border-radius: var(--r); flex-shrink: 0; border: 1px solid rgba(255,255,255,.04); }
.swatch .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.swatch .name { color: var(--fg-1); font-size: 11.5px; }
.swatch .hex { color: var(--fg-3); font-size: 10.5px; }
.swatch .hex { color: var(--fg-3); font-size: 11.5px; }
.scale-row { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px dashed #181b22; }
.scale-row:last-child { border-bottom: none; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); width: 70px; flex-shrink: 0; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); width: 76px; flex-shrink: 0; }
.scale-row .ex { color: var(--fg-1); }
.glyph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
@@ -311,12 +312,13 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
.pill.acc { color: var(--c-accent); background: rgba(210,168,255,.10); }
/* Table */
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; table-layout: auto; }
.tbl th, .tbl td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--bd); }
.tbl th { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; background: var(--bg-elev); }
.tbl th.num, .tbl td.num { font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums; }
.tbl td { color: var(--fg-1); }
.tbl tbody tr:hover { background: var(--bg-hover); }
.tbl td.num { font-family: var(--font-mono); text-align: right; color: var(--fg-0); font-variant-numeric: tabular-nums; }
.tbl td.num { color: var(--fg-0); }
.tbl td.dim { color: var(--fg-3); }
.tbl td.path { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-2); }
+7
View File
@@ -214,12 +214,19 @@ export const en = {
dailyMeta: "cost · cache saved · turns",
noData: "No usage data yet — run a turn in reasonix chat / code / run and refresh.",
windows: "Rolling windows",
colWindow: "window",
colTurns: "turns",
colCacheHit: "cache hit",
colCost: "cost (USD)",
colCacheSaved: "cache saved",
colVsClaude: "vs Claude",
colSaved: "saved",
axisTime: "time",
axisUsd: "USD",
axisTurns: "turns",
seriesCost: "cost",
seriesCacheSaved: "cache saved",
seriesTurns: "turns",
mostUsed: "Most used models",
colModel: "model",
},
+7
View File
@@ -211,12 +211,19 @@ export const zhCN = {
dailyMeta: "费用 · 缓存节省 · 轮次",
noData: "暂无用量数据 — 在 reasonix chat / code / run 中执行一轮,然后刷新。",
windows: "滚动窗口",
colWindow: "时间范围",
colTurns: "轮次",
colCacheHit: "缓存命中",
colCost: "费用 (USD)",
colCacheSaved: "缓存节省",
colVsClaude: "对比 Claude",
colSaved: "节省",
axisTime: "时间",
axisUsd: "美元",
axisTurns: "轮次",
seriesCost: "费用",
seriesCacheSaved: "缓存节省",
seriesTurns: "轮次",
mostUsed: "最常用模型",
colModel: "模型",
},
+3 -32
View File
@@ -16,7 +16,6 @@ const TO_BACKEND = new Map(LANG_REGISTRY);
const FROM_BACKEND = new Map(LANG_REGISTRY.map(([d, b]) => [b, d]));
const STORAGE_KEY = "rx.lang";
const EXPLICIT_KEY = "rx.langExplicit";
const listeners: Listener[] = [];
let currentLang: DashboardLang = loadFromStorage() ?? "en";
@@ -30,22 +29,6 @@ function loadFromStorage(): DashboardLang | null {
return null;
}
function isExplicit(): boolean {
try {
return localStorage.getItem(EXPLICIT_KEY) === "1";
} catch {
return false;
}
}
function markExplicit(): void {
try {
localStorage.setItem(EXPLICIT_KEY, "1");
} catch {
/* ignore */
}
}
function toBackendLang(lang: DashboardLang): string {
return TO_BACKEND.get(lang) ?? lang;
}
@@ -54,23 +37,12 @@ function fromBackendLang(raw: string): DashboardLang {
return (FROM_BACKEND.get(raw) as DashboardLang) ?? "en";
}
/** Fetch lang from backend on startup. */
/** Adopt server lang on startup; localStorage is render-cache only, never pushed back. */
export async function initLangFromServer(): Promise<void> {
try {
const stored = loadFromStorage();
const res = await api<{ lang?: string }>("/settings");
const serverLang = res.lang ? fromBackendLang(res.lang) : null;
if (!serverLang || stored === serverLang) return;
if (isExplicit() && stored) {
// User explicitly chose a language — push to server.
api("/settings", { method: "POST", body: { lang: toBackendLang(stored) } })
.catch((err) => console.error("[reasonix dashboard] lang sync:", err));
return;
}
// No explicit choice yet (first visit or cleared storage) — adopt server value.
if (!serverLang || serverLang === currentLang) return;
currentLang = serverLang;
try {
localStorage.setItem(STORAGE_KEY, serverLang);
@@ -79,7 +51,7 @@ export async function initLangFromServer(): Promise<void> {
}
for (const cb of listeners) cb();
} catch {
/* offline — keep localStorage value */
/* offline — keep last-known value rendering */
}
}
@@ -90,7 +62,6 @@ export function getLang(): DashboardLang {
export function setLang(lang: DashboardLang): void {
if (!SUPPORTED.has(lang)) return;
currentLang = lang;
markExplicit();
try {
localStorage.setItem(STORAGE_KEY, lang);
} catch {
+2 -2
View File
@@ -792,7 +792,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 +812,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>
`
+19 -13
View File
@@ -36,6 +36,7 @@ interface UsageDay {
function UsageChart({ days }: { days: UsageDay[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<UPlotInstance | null>(null);
useLang();
useEffect(() => {
let cancelled = false;
@@ -64,7 +65,7 @@ function UsageChart({ days }: { days: UsageDay[] }) {
{ stroke: "#94a3b8", grid: { stroke: "rgba(148, 163, 184, 0.08)" } },
{
scale: "y",
label: "USD",
label: t("usage.axisUsd"),
stroke: "#94a3b8",
grid: { stroke: "rgba(148, 163, 184, 0.08)" },
values: (_u: unknown, v: number[]) => v.map((n) => `$${n.toFixed(4)}`),
@@ -72,17 +73,22 @@ function UsageChart({ days }: { days: UsageDay[] }) {
{
scale: "turns",
side: 1,
label: "turns",
label: t("usage.axisTurns"),
stroke: "#94a3b8",
grid: { show: false },
},
],
series: [
{},
{ label: "cost", stroke: "#67e8f9", width: 2, fill: "rgba(103, 232, 249, 0.10)" },
{ label: "cache saved", stroke: "#5eead4", width: 2, dash: [4, 4] },
{ label: t("usage.axisTime") },
{
label: "turns",
label: t("usage.seriesCost"),
stroke: "#67e8f9",
width: 2,
fill: "rgba(103, 232, 249, 0.10)",
},
{ label: t("usage.seriesCacheSaved"), stroke: "#5eead4", width: 2, dash: [4, 4] },
{
label: t("usage.seriesTurns"),
stroke: "#c4b5fd",
scale: "turns",
width: 1.5,
@@ -203,13 +209,13 @@ export function UsagePanel() {
<table class="tbl">
<thead>
<tr>
<th></th>
<th>${t("usage.colTurns")}</th>
<th>${t("usage.colCacheHit")}</th>
<th>${t("usage.colCost")}</th>
<th>${t("usage.colCacheSaved")}</th>
<th>${t("usage.colVsClaude")}</th>
<th>${t("usage.colSaved")}</th>
<th>${t("usage.colWindow")}</th>
<th class="num">${t("usage.colTurns")}</th>
<th class="num">${t("usage.colCacheHit")}</th>
<th class="num">${t("usage.colCost")}</th>
<th class="num">${t("usage.colCacheSaved")}</th>
<th class="num">${t("usage.colVsClaude")}</th>
<th class="num">${t("usage.colSaved")}</th>
</tr>
</thead>
<tbody>
+13 -12
View File
@@ -81,7 +81,7 @@ a:hover { text-decoration: underline; }
/* ── Doc chrome ─────────────────────────────────────────────────────────── */
.page {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-columns: 260px minmax(0, 1fr);
max-width: 1320px;
margin: 0 auto;
min-height: 100vh;
@@ -90,18 +90,19 @@ a:hover { text-decoration: underline; }
position: sticky; top: 0; align-self: start;
height: 100vh; overflow-y: auto;
border-right: 1px solid var(--bd);
padding: 28px 18px;
padding: 28px 16px;
background: var(--bg);
}
.toc h1 { font-size: 14px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 { font-size: 15px; font-weight: 700; margin: 0 0 4px; color: var(--fg-0); letter-spacing: .03em; font-family: var(--font-mono); }
.toc h1 .dot { color: var(--c-brand); margin-right: 8px; }
.toc .sub { font-size: 11px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc .sub { font-size: 12px; color: var(--fg-3); margin: 0 0 18px; letter-spacing: .04em; }
.toc-section { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-4); margin: 22px 0 6px; font-weight: 700; }
.toc-section:first-of-type { margin-top: 0; }
.toc ul { list-style: none; padding: 0; margin: 0; }
.toc li a {
display: block; padding: 3px 8px; margin: 1px 0;
color: var(--fg-2); font-size: 12.5px; border-radius: var(--r);
display: block; padding: 4px 10px; margin: 1px 0;
color: var(--fg-2); font-size: 14px; line-height: 1.4;
border-radius: var(--r); overflow-wrap: anywhere;
}
.toc li a:hover { color: var(--fg-0); background: var(--bg-elev); text-decoration: none; }
@@ -114,7 +115,7 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
}
.section > h2 .num { color: var(--fg-4); margin-right: 10px; font-weight: 500; }
.section > .lede {
color: var(--fg-2); margin: 0 0 22px; font-size: 13px; max-width: 700px; line-height: 1.6;
color: var(--fg-2); margin: 0 0 22px; font-size: 15px; max-width: 720px; line-height: 1.6;
}
.subsec { margin-bottom: 22px; }
.subsec > h3 {
@@ -122,8 +123,8 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
margin: 24px 0 4px; letter-spacing: .04em; text-transform: uppercase;
font-family: var(--font-mono);
}
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 12px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 12.5px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
.subsec > h3 .desc { color: var(--fg-3); font-weight: 400; margin-left: 10px; font-size: 13px; text-transform: none; letter-spacing: 0; }
.subsec > p { color: var(--fg-3); font-size: 15px; margin: 0 0 12px; max-width: 720px; line-height: 1.6; }
/* "Mock" — a faux-window frame to display dashboard pieces inside the design doc */
.mock {
@@ -151,11 +152,11 @@ main { padding: 32px 40px 60px 32px; min-width: 0; }
.swatch .chip { width: 22px; height: 22px; border-radius: var(--r); flex-shrink: 0; border: 1px solid rgba(255,255,255,.04); }
.swatch .meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.swatch .name { color: var(--fg-1); font-size: 11.5px; }
.swatch .hex { color: var(--fg-3); font-size: 10.5px; }
.swatch .hex { color: var(--fg-3); font-size: 11.5px; }
.scale-row { display: flex; align-items: baseline; gap: 16px; padding: 6px 0; border-bottom: 1px dashed #181b22; }
.scale-row:last-child { border-bottom: none; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--fg-3); width: 70px; flex-shrink: 0; }
.scale-row .lbl { font-family: var(--font-mono); font-size: 11.5px; color: var(--fg-3); width: 76px; flex-shrink: 0; }
.scale-row .ex { color: var(--fg-1); }
.glyph-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.33.0",
"version": "0.35.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
+153
View File
@@ -0,0 +1,153 @@
/** @url mentions — async sibling of @path. Fetches each URL once and inlines under "Referenced URLs". */
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
/** Default cap on inlined URL body (chars). */
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
export interface AtUrlExpansion {
/** The raw `@url` token as it appeared in the text. */
token: string;
/** Absolute URL (after trailing-punctuation strip). */
url: string;
/** True if content was inlined. False = skipped (reason in `skip`). */
ok: boolean;
/** Page title when extractable from `<title>`. */
title?: string;
/** Char count of the (post-truncation) inlined body. */
chars?: number;
/** True iff the original page exceeded `maxChars` and was clipped. */
truncated?: boolean;
/** Why the mention was skipped — set when ok=false. */
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
/** Free-form error message attached to skip outcomes. */
error?: string;
}
export interface AtUrlOptions {
/** Max chars of inlined body per URL. */
maxChars?: number;
/** Per-URL fetch timeout in ms. */
timeoutMs?: number;
fetcher?: (
url: string,
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
cache?: Map<string, AtUrlExpansion & { body?: string }>;
/** Forward Esc/abort to the fetcher. */
signal?: AbortSignal;
}
export async function expandAtUrls(
text: string,
opts: AtUrlOptions = {},
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
const fetcher = opts.fetcher;
if (!fetcher) {
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
}
const seen = new Map<string, AtUrlExpansion>();
const bodies = new Map<string, string>();
const order: string[] = [];
for (const match of text.matchAll(AT_URL_PATTERN)) {
const rawUrl = match[1] ?? "";
const url = stripUrlTail(rawUrl);
if (!url) continue;
if (seen.has(url)) continue;
const cached = opts.cache?.get(url);
if (cached) {
seen.set(url, cached);
if (cached.body) bodies.set(url, cached.body);
order.push(url);
continue;
}
let expansion: AtUrlExpansion;
let body = "";
try {
const page = await fetcher(url, {
maxChars,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
});
body = page.text;
expansion = {
token: `@${url}`,
url,
ok: true,
title: page.title,
chars: body.length,
truncated: page.truncated,
};
} catch (err) {
const message = (err as Error).message ?? String(err);
let skip: AtUrlExpansion["skip"] = "fetch-error";
if (/aborted|timeout/i.test(message)) skip = "timeout";
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
expansion = {
token: `@${url}`,
url,
ok: false,
skip,
error: message,
};
}
seen.set(url, expansion);
if (body) bodies.set(url, body);
if (opts.cache) opts.cache.set(url, { ...expansion, body });
order.push(url);
}
if (seen.size === 0) return { text, expansions: [] };
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
const blocks: string[] = [];
for (const ex of expansions) {
if (ex.ok) {
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
const truncTag = ex.truncated ? ' truncated="true"' : "";
const body = bodies.get(ex.url) ?? "";
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
} else {
const reasonAttr = ex.skip ?? "fetch-error";
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
}
}
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
return { text: augmented, expansions };
}
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
export function stripUrlTail(raw: string): string {
let s = raw;
while (s.length > 0) {
const last = s[s.length - 1]!;
if (".,;:!?".includes(last)) {
s = s.slice(0, -1);
continue;
}
if (")]}>".includes(last)) {
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
last as ")" | "]" | "}" | ">"
];
if (!s.includes(open)) {
s = s.slice(0, -1);
continue;
}
}
break;
}
return s;
}
function escapeAttr(s: string): string {
return s
.replace(/"/g, "&quot;")
.replace(/[\r\n]+/g, " ")
.trim();
}
+185 -194
View File
@@ -120,18 +120,65 @@ export async function listFilesWithStatsAsync(
root: string,
opts: ListFilesOptions = {},
): Promise<FileWithStats[]> {
const maxResults = Math.max(1, opts.maxResults ?? 2000);
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
const rootAbs = resolve(root);
const respectGi = opts.respectGitignore !== false;
const out: FileWithStats[] = [];
const maxResults = Math.max(1, opts.maxResults ?? 2000);
await walkFilesStream(root, {
...opts,
onEntry: (e) => {
out.push(e);
return out.length < maxResults;
},
});
return out;
}
export interface StreamWalkOptions {
ignoreDirs?: readonly string[];
respectGitignore?: boolean;
signal?: AbortSignal;
/** Called per file entry. Return false to halt the walk. */
onEntry: (entry: FileWithStats) => boolean | undefined;
/** Called periodically with the running file-count. */
onProgress?: (scanned: number) => void;
/** Default 100ms — minimum gap between onProgress calls. */
progressIntervalMs?: number;
}
/** Cancelable, streaming walker. Drives `listFilesWithStatsAsync` and the picker's search-mode walk. */
export async function walkFilesStream(
root: string,
opts: StreamWalkOptions,
): Promise<{ scanned: number; cancelled: boolean }> {
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
const respectGi = opts.respectGitignore !== false;
const rootAbs = resolve(root);
const progressGap = Math.max(0, opts.progressIntervalMs ?? 100);
let scanned = 0;
let halted = false;
let lastProgress = 0;
const reportProgress = (force: boolean) => {
if (!opts.onProgress) return;
const now = Date.now();
if (force || now - lastProgress >= progressGap) {
lastProgress = now;
opts.onProgress(scanned);
}
};
const emit = (entry: FileWithStats) => {
scanned++;
if (halted) return;
if (opts.onEntry(entry) === false) halted = true;
reportProgress(false);
};
const walk = async (
dirAbs: string,
dirRel: string,
layers: readonly GitignoreLayer[],
): Promise<void> => {
if (out.length >= maxResults) return;
if (halted || opts.signal?.aborted) return;
let effectiveLayers = layers;
if (respectGi) {
const ig = await loadGitignoreAt(dirAbs);
@@ -144,53 +191,41 @@ export async function listFilesWithStatsAsync(
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
// Stats batched per directory to amortize syscall overhead. Recursion stays
// sequential so the merged DFS order matches the sync walker's contract.
const fileEnts: Dirent[] = [];
for (const ent of entries) {
if (out.length >= maxResults) break;
const relPath = dirRel ? `${dirRel}/${ent.name}` : ent.name;
if (halted || opts.signal?.aborted) break;
const absPath = join(dirAbs, ent.name);
if (ent.isDirectory()) {
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
if (ignoredByLayers(effectiveLayers, absPath, true)) continue;
// Drain pending file stats from THIS directory before
// descending so the output order stays DFS-alphabetical.
if (fileEnts.length > 0) {
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
fileEnts.length = 0;
if (out.length >= maxResults) return;
if (halted || opts.signal?.aborted) return;
}
await walk(absPath, relPath, effectiveLayers);
await walk(absPath, dirRel ? `${dirRel}/${ent.name}` : ent.name, effectiveLayers);
} else if (ent.isFile() || ent.isSymbolicLink()) {
// Symlinks land in the same batch — statBatch resolves them and drops
// any whose target isn't a regular file (broken or symlink-to-dir).
fileEnts.push(ent);
}
}
if (fileEnts.length > 0 && out.length < maxResults) {
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
if (fileEnts.length > 0 && !halted && !opts.signal?.aborted) {
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
}
};
await walk(rootAbs, "", []);
return out;
reportProgress(true);
return { scanned, cancelled: !!opts.signal?.aborted };
}
async function statBatch(
async function flushFiles(
ents: readonly Dirent[],
dirAbs: string,
dirRel: string,
out: FileWithStats[],
maxResults: number,
layers: readonly GitignoreLayer[],
emit: (e: FileWithStats) => void,
): Promise<void> {
const accepted: Dirent[] = [];
for (const e of ents) {
if (out.length + accepted.length >= maxResults) break;
if (ignoredByLayers(layers, join(dirAbs, e.name), false)) continue;
accepted.push(e);
}
const accepted = ents.filter((e) => !ignoredByLayers(layers, join(dirAbs, e.name), false));
const stats = await Promise.all(
accepted.map((e) =>
stat(join(dirAbs, e.name))
@@ -201,17 +236,127 @@ async function statBatch(
for (let i = 0; i < accepted.length; i++) {
const ent = accepted[i]!;
const s = stats[i];
if (ent.isSymbolicLink()) {
// Drop broken symlinks and symlinks-to-dirs (latter would cycle).
if (!s || !s.isFile) continue;
}
out.push({
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
emit({
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
mtimeMs: s?.mtimeMs ?? 0,
});
}
}
export interface DirEntry {
name: string;
/** Relative-to-root path (forward slashes). For dirs, no trailing slash. */
path: string;
isDir: boolean;
/** 0 for directories (no stat), real mtime for files. */
mtimeMs: number;
}
export interface ListDirectoryOptions {
ignoreDirs?: readonly string[];
respectGitignore?: boolean;
}
/** One-level browse for the @-picker. Folders first then files, alpha within each group. Resolves outside-root to []. */
export async function listDirectory(
root: string,
relDir: string,
opts: ListDirectoryOptions = {},
): Promise<DirEntry[]> {
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
const respectGi = opts.respectGitignore !== false;
const rootAbs = resolve(root);
const dirAbs = resolve(rootAbs, relDir);
const rel = relative(rootAbs, dirAbs);
if (rel.startsWith("..") || isAbsolute(rel)) return [];
const layers: GitignoreLayer[] = [];
if (respectGi) {
const segs = rel ? rel.split(/[\\/]/) : [];
let cursor = rootAbs;
const ig = await loadGitignoreAt(cursor);
if (ig) layers.push({ dirAbs: cursor, ig });
for (const seg of segs) {
cursor = join(cursor, seg);
const igSeg = await loadGitignoreAt(cursor);
if (igSeg) layers.push({ dirAbs: cursor, ig: igSeg });
}
}
let raw: Dirent[];
try {
raw = await readdir(dirAbs, { withFileTypes: true });
} catch {
return [];
}
const dirRel = rel.split(/[\\/]/).join("/");
const dirs: DirEntry[] = [];
const files: Dirent[] = [];
for (const ent of raw) {
const absPath = join(dirAbs, ent.name);
if (ent.isDirectory()) {
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
if (ignoredByLayers(layers, absPath, true)) continue;
dirs.push({
name: ent.name,
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
isDir: true,
mtimeMs: 0,
});
} else if (ent.isFile() || ent.isSymbolicLink()) {
if (ignoredByLayers(layers, absPath, false)) continue;
files.push(ent);
}
}
const stats = await Promise.all(
files.map((e) =>
stat(join(dirAbs, e.name))
.then((s) => ({ mtimeMs: s.mtimeMs, isFile: s.isFile() }))
.catch(() => null),
),
);
const fileEntries: DirEntry[] = [];
for (let i = 0; i < files.length; i++) {
const ent = files[i]!;
const s = stats[i];
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
fileEntries.push({
name: ent.name,
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
isDir: false,
mtimeMs: s?.mtimeMs ?? 0,
});
}
dirs.sort((a, b) => a.name.localeCompare(b.name));
fileEntries.sort((a, b) => a.name.localeCompare(b.name));
return [...dirs, ...fileEntries];
}
export interface ParsedAtQuery {
/** Directory portion (rel from root, no trailing slash). Empty = root. */
dir: string;
/** Filter portion — chars after the last slash. Empty if query ended in `/`. */
filter: string;
/** True if the query ended in `/` — caller knows to browse `dir`. */
trailingSlash: boolean;
}
/** Split `src/auth/log` → `{dir: "src/auth", filter: "log"}`; trailing slash sets `trailingSlash` and clears filter. */
export function parseAtQuery(query: string): ParsedAtQuery {
const normalized = query.replace(/\\/g, "/");
const trailingSlash = normalized.endsWith("/");
const trimmed = trailingSlash ? normalized.slice(0, -1) : normalized;
const lastSlash = trimmed.lastIndexOf("/");
if (trailingSlash) return { dir: trimmed, filter: "", trailingSlash: true };
if (lastSlash < 0) return { dir: "", filter: trimmed, trailingSlash: false };
return {
dir: trimmed.slice(0, lastSlash),
filter: trimmed.slice(lastSlash + 1),
trailingSlash: false,
};
}
/** Trailing-token only, anchored at end-of-input — distinct from `AT_MENTION_PATTERN` which scans all. */
export const AT_PICKER_PREFIX = /(?:^|\s)@([a-zA-Z0-9_./\\-]*)$/;
@@ -523,164 +668,10 @@ const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
read: (p) => readFileSync(p, "utf8"),
};
// @url mentions — async sibling of @path. Matches `@http(s)://...` after a
// word boundary, fetches each URL once per session (in-memory cache), and
// appends a "Referenced URLs" block under the prompt the model sees. Uses
// the same web-fetch + HTML-strip pipeline as the model's `web_fetch` tool
// so a `@url` reference and a model-issued fetch produce identical content.
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
/** Default cap on inlined URL body (chars). Matches DEFAULT_AT_MENTION_MAX_BYTES order-of-magnitude. */
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
export interface AtUrlExpansion {
/** The raw `@url` token as it appeared in the text. */
token: string;
/** Absolute URL (after trailing-punctuation strip). */
url: string;
/** True if content was inlined. False = skipped (reason in `skip`). */
ok: boolean;
/** Page title when extractable from `<title>`. */
title?: string;
/** Char count of the (post-truncation) inlined body. */
chars?: number;
/** True iff the original page exceeded `maxChars` and was clipped. */
truncated?: boolean;
/** Why the mention was skipped — set when ok=false. */
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
/** Free-form error message attached to skip outcomes. */
error?: string;
}
export interface AtUrlOptions {
/** Max chars of inlined body per URL. Default DEFAULT_AT_URL_MAX_CHARS. */
maxChars?: number;
/** Per-URL fetch timeout in ms. */
timeoutMs?: number;
fetcher?: (
url: string,
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
cache?: Map<string, AtUrlExpansion & { body?: string }>;
/** Forward Esc/abort to the fetcher. */
signal?: AbortSignal;
}
export async function expandAtUrls(
text: string,
opts: AtUrlOptions = {},
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
const fetcher = opts.fetcher;
if (!fetcher) {
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
}
// De-dupe by URL so the same `@https://x.com` referenced twice fetches once.
const seen = new Map<string, AtUrlExpansion>();
const bodies = new Map<string, string>();
const order: string[] = [];
for (const match of text.matchAll(AT_URL_PATTERN)) {
const rawUrl = match[1] ?? "";
const url = stripUrlTail(rawUrl);
if (!url) continue;
if (seen.has(url)) continue;
const cached = opts.cache?.get(url);
if (cached) {
seen.set(url, cached);
if (cached.body) bodies.set(url, cached.body);
order.push(url);
continue;
}
let expansion: AtUrlExpansion;
let body = "";
try {
const page = await fetcher(url, {
maxChars,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
});
body = page.text;
expansion = {
token: `@${url}`,
url,
ok: true,
title: page.title,
chars: body.length,
truncated: page.truncated,
};
} catch (err) {
const message = (err as Error).message ?? String(err);
// Tag a few common shapes so the UI can hint at causes.
let skip: AtUrlExpansion["skip"] = "fetch-error";
if (/aborted|timeout/i.test(message)) skip = "timeout";
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
expansion = {
token: `@${url}`,
url,
ok: false,
skip,
error: message,
};
}
seen.set(url, expansion);
if (body) bodies.set(url, body);
if (opts.cache) opts.cache.set(url, { ...expansion, body });
order.push(url);
}
if (seen.size === 0) return { text, expansions: [] };
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
const blocks: string[] = [];
for (const ex of expansions) {
if (ex.ok) {
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
const truncTag = ex.truncated ? ' truncated="true"' : "";
const body = bodies.get(ex.url) ?? "";
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
} else {
const reasonAttr = ex.skip ?? "fetch-error";
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
}
}
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
return { text: augmented, expansions };
}
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
export function stripUrlTail(raw: string): string {
let s = raw;
while (s.length > 0) {
const last = s[s.length - 1]!;
if (".,;:!?".includes(last)) {
s = s.slice(0, -1);
continue;
}
if (")]}>".includes(last)) {
// Only strip if the matching open bracket isn't elsewhere in the
// URL — avoids butchering legitimate `(thing)` query fragments.
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
last as ")" | "]" | "}" | ">"
];
if (!s.includes(open)) {
s = s.slice(0, -1);
continue;
}
}
break;
}
return s;
}
function escapeAttr(s: string): string {
return s
.replace(/"/g, "&quot;")
.replace(/[\r\n]+/g, " ")
.trim();
}
export {
AT_URL_PATTERN,
DEFAULT_AT_URL_MAX_CHARS,
expandAtUrls,
stripUrlTail,
} from "./at-mentions-url.js";
export type { AtUrlExpansion, AtUrlOptions } from "./at-mentions-url.js";
+6
View File
@@ -29,6 +29,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";
@@ -134,6 +135,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
+16 -27
View File
@@ -2,7 +2,7 @@
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 { loadDotenv } from "../../env.js";
@@ -10,6 +10,7 @@ import { loadHooks } from "../../hooks.js";
import { indexExists } from "../../index/semantic/builder.js";
import { checkOllamaStatus } from "../../index/semantic/ollama-launcher.js";
import { listSessions } from "../../memory/session.js";
import { resolveDataPath } from "../../tokenizer.js";
import { VERSION } from "../../version.js";
export type DoctorLevel = "ok" | "warn" | "fail";
@@ -169,32 +170,20 @@ async function checkApiReach(): Promise<Check> {
}
async function checkTokenizer(): Promise<Check> {
// The tokenizer file is the gzipped vocab shipped under data/. We
// resolve relative to the package's dist/ rather than process.cwd()
// — the same logic the runtime uses when loading on first count.
const candidates = [
join(
dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")),
"..",
"..",
"..",
"data",
"deepseek-tokenizer.json.gz",
),
join(process.cwd(), "data", "deepseek-tokenizer.json.gz"),
];
for (const p of candidates) {
if (existsSync(p)) {
try {
const stat = statSync(p);
return {
label: "tokenizer ",
level: "ok",
detail: `${p} (${fmtBytes(stat.size)})`,
};
} catch {
/* try next */
}
// Reuse the runtime's resolver so the doctor never disagrees with what
// the tokenizer actually loads — three candidates including a global
// npm install probe via createRequire.
const path = resolveDataPath();
if (existsSync(path)) {
try {
const stat = statSync(path);
return {
label: "tokenizer ",
level: "ok",
detail: `${path} (${fmtBytes(stat.size)})`,
};
} catch {
/* fall through to warn */
}
}
return {
+87 -49
View File
@@ -36,14 +36,18 @@ import {
editModeHintShown,
loadEditMode,
loadReasoningEffort,
loadTheme,
markEditModeHintShown,
markMouseClipboardHintShown,
mouseClipboardHintShown,
resolveThemePreference,
saveEditMode,
saveReasoningEffort,
} from "../../config.js";
import { Eventizer } from "../../core/eventize.js";
import { pauseGate } from "../../core/pause-gate.js";
import { type ResolvedHook, formatHookOutcomeMessage, loadHooks, runHooks } from "../../hooks.js";
import { onLanguageChange, t } from "../../i18n/index.js";
import { onLanguageChange, t, tObj } from "../../i18n/index.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
import type { LoopEvent } from "../../loop.js";
import {
@@ -138,12 +142,14 @@ import { applyMcpAppend } from "./mcp-append.js";
import { handleMcpBrowseSlash } from "./mcp-browse.js";
import { replaceMcpServerSummary } from "./mcp-server-list.js";
import { formatLongPaste } from "./paste-collapse.js";
import { extractOpenQuestionsSection } from "./plan-open-questions.js";
import { PRESETS, resolvePreset } from "./presets.js";
import { type McpServerSummary, handleSlash, parseSlash, suggestSlashCommands } from "./slash.js";
import { TurnTranslator } from "./state/TurnTranslator.js";
import { cardsToDashboardMessages } from "./state/cards-to-messages.js";
import { hydrateCardsFromMessages } from "./state/hydrate.js";
import { AgentStoreProvider, useAgentState, useAgentStore } from "./state/provider.js";
import { ThemeProvider } from "./theme/context.js";
import { FG } from "./theme/tokens.js";
import { TickerProvider } from "./ticker.js";
import { useCompletionPickers } from "./useCompletionPickers.js";
@@ -293,10 +299,13 @@ export function App(props: AppProps): React.ReactElement {
() => (props.session ? hydrateCardsFromMessages(loadSessionMessages(props.session)) : []),
[props.session],
);
const themeName = resolveThemePreference(loadTheme(), process.env.REASONIX_THEME);
return (
<AgentStoreProvider session={session} initialCards={initialCards}>
<AppInner {...props} />
</AgentStoreProvider>
<ThemeProvider name={themeName}>
<AgentStoreProvider session={session} initialCards={initialCards}>
<AppInner {...props} />
</AgentStoreProvider>
</ThemeProvider>
);
}
@@ -388,11 +397,21 @@ function AppInner({
// `27;5;13~` as Ctrl+Enter. Terminals that don't understand the
// SGR fall through silently — Shift+Enter just stays
// indistinguishable from Enter, no regression.
//
// • SGR mouse tracking (DECSET 1006 + 1000) — terminal reports
// wheel + button presses as `\x1b[<btn;col;row;M` instead of
// translating the wheel to ↑/↓ key sequences. Lets the chat
// scroll-handler route `mouseScrollUp/Down` independently of
// PromptInput's arrow-key bindings (history / cursor). Cost:
// terminal-native drag-to-select needs a modifier (Shift on
// Windows Terminal / Alacritty / WezTerm, Option on iTerm2).
useEffect(() => {
if (!stdout || !stdout.isTTY) return;
stdout.write("\u001b[?2004h");
stdout.write("\u001b[>4;2m");
stdout.write("\u001b[?1006h\u001b[?1000h");
return () => {
stdout.write("\u001b[?1000l\u001b[?1006l");
stdout.write("\u001b[?2004l");
stdout.write("\u001b[>4m");
};
@@ -569,6 +588,8 @@ function AppInner({
const [stagedInput, setStagedInput] = useState<{
plan: string;
mode: "refine" | "approve" | "reject";
/** Open-questions / risks block extracted from the plan; surfaced in PlanRefineInput on refine. */
questions?: string;
} | null>(null);
// Mid-execution pause from mark_step_complete — model finished a step
// and the loop waits for user to pick Continue / Revise / Stop.
@@ -1088,8 +1109,7 @@ function AppInner({
setSlashSelected,
slashGroupMode,
slashAdvancedHidden,
atPicker,
atMatches,
atState,
atSelected,
setAtSelected,
pickAtMention,
@@ -1187,24 +1207,39 @@ function AppInner({
// per install; the config flag suppresses re-display on every
// relaunch. Skips chat mode — those shortcuts don't apply there.
if (codeMode && !editModeHintShown()) {
log.pushInfo(t("ui.tipEditBindings"));
const tip = tObj<{
topic: string;
sections: ReadonlyArray<{ rows: ReadonlyArray<{ key: string; text: string }> }>;
footer: string;
}>("ui.tipEditBindings");
log.pushTip({ topic: tip.topic, sections: tip.sections, footer: tip.footer });
markEditModeHintShown();
}
if (!mouseClipboardHintShown()) {
const tip = tObj<{
topic: string;
sections: ReadonlyArray<{ rows: ReadonlyArray<{ key: string; text: string }> }>;
footer: string;
}>("ui.tipMouseClipboard");
log.pushTip({ topic: tip.topic, sections: tip.sections, footer: tip.footer });
markMouseClipboardHintShown();
}
}, [session, loop, codeMode, syncPendingCount, log]);
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
const quitProcess = useQuit(transcriptRef);
// PgUp / PgDn always scroll chat history; ↑/↓-on-empty-buffer also
// routes here via PromptInput's chatScrollHandoff. WT translates wheel
// events to ↑/↓ in raw mode, so this is what makes wheel-scroll work.
// PgUp / PgDn always scroll chat history. ↑/↓ also reaches here when
// PromptInput can't act on it: while busy (disabled) or once chat is
// unpinned (user already scrolling). When pinned + idle, PromptInput
// owns arrows — empty buffer recalls history, otherwise cursor motion.
// Mouse wheel routes through mouseScrollUp/Down (SGR mouse mode) and
// bypasses the arrow-key path entirely so the wheel always scrolls
// scrollback regardless of buffer state.
useKeystroke((ev) => {
if (ev.pageUp) chatScroll.scrollUp();
else if (ev.pageDown) chatScroll.scrollDown();
if (ev.pageUp || ev.mouseScrollUp) chatScroll.scrollUp();
else if (ev.pageDown || ev.mouseScrollDown) chatScroll.scrollDown();
else if (ev.end) chatScroll.jumpToBottom();
// Wheel-translated ↑/↓ has nowhere to go when PromptInput can't
// process it: in reading mode (unmounted) or while busy (disabled).
// When pinned + idle, PromptInput owns arrows for cursor / handoff.
else if ((!chatScroll.pinned || busy) && ev.upArrow) chatScroll.scrollUp();
else if ((!chatScroll.pinned || busy) && ev.downArrow) chatScroll.scrollDown();
}, !modalOpen);
@@ -1265,7 +1300,7 @@ function AppInner({
// Esc dismisses any composer-level picker (slash / @ / slash-arg)
// by clearing the prefix that triggered it. Picker footers advertise
// "esc cancel" — this binds it.
if (key.escape && !busy && (slashMatches || atMatches || slashArgContext)) {
if (key.escape && !busy && (slashMatches || atState || slashArgContext)) {
setInput("");
return;
}
@@ -1399,24 +1434,24 @@ function AppInner({
// (hidden) prompt buffer. Bail early.
if (pendingShell) return;
// @-mention picker takes the same priority tier as slash. When
// the user is typing `@…` in code mode and there are file matches,
// ↑/↓ walk the list and Tab substitutes the selected path. Enter
// is caught in handleSubmit. Must come BEFORE slash so the two
// pickers don't fight over arrow keys (mutually exclusive by
// construction — atPicker is null when slashMatches is set).
if (atMatches && atMatches.length > 0) {
// @-mention picker takes the same priority tier as slash. ↑/↓ walk
// the list; Tab on a folder drills into it, Tab on a file commits.
// Enter is caught in handleSubmit. Right arrow stays cursor-move
// (would otherwise fight PromptInput's multiline cursor). Must come
// BEFORE slash so the two pickers don't share arrow keys.
if (atState && atState.entries.length > 0) {
const entries = atState.entries;
if (key.upArrow) {
setAtSelected((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow) {
setAtSelected((i) => Math.min(atMatches.length - 1, i + 1));
setAtSelected((i) => Math.min(entries.length - 1, i + 1));
return;
}
if (key.tab) {
const sel = atMatches[atSelected] ?? atMatches[0];
if (sel) pickAtMention(sel);
const sel = entries[atSelected] ?? entries[0];
if (sel) pickAtMention(sel, sel.isDir ? "drill" : "commit");
return;
}
}
@@ -2022,16 +2057,15 @@ function AppInner({
clearFiringFlag();
if (busy) return;
// @-mention picker intercept. When the picker is open (trailing
// `@…` with file matches), Enter substitutes the highlighted
// path INTO the buffer and does NOT submit — the user almost
// always types more after a mention ("look at @file.ts and…").
// Substituting adds a trailing space which dismisses the picker,
// so the next Enter submits normally.
if (atMatches && atMatches.length > 0 && atPicker) {
const sel = atMatches[atSelected] ?? atMatches[0];
// @-mention picker intercept. Enter on either a file or a folder
// commits the path INTO the buffer (with trailing space) — the
// user almost always types more after a mention. The trailing
// space dismisses the picker, so the next Enter submits normally.
// Folders inline as a directory listing at submit time.
if (atState && atState.entries.length > 0) {
const sel = atState.entries[atSelected] ?? atState.entries[0];
if (sel) {
pickAtMention(sel);
pickAtMention(sel, "commit");
return;
}
}
@@ -2207,10 +2241,18 @@ function AppInner({
startDashboard,
stopDashboard,
getDashboardUrl,
sessionId: session,
jobs: codeMode?.jobs,
postInfo: (text: string) => log.pushInfo(text),
postDoctor: (checks) => log.showDoctor(checks),
postUsage: (args) => log.showUsageVerbose(args),
postKeys: (args) =>
log.pushTip({
topic: args.topic,
sections: args.sections,
footer: args.footer,
oneTime: false,
}),
dispatch: agentStore.dispatch,
reloadHooks: () => {
const fresh = loadHooks({ projectRoot: codeMode ? currentRootDir : undefined });
@@ -2667,8 +2709,7 @@ function AppInner({
planMode,
session,
slashSelected,
atMatches,
atPicker,
atState,
atSelected,
pickAtMention,
recordRecentFile,
@@ -2791,7 +2832,8 @@ function AppInner({
if (choice === "refine" || choice === "approve") {
if (pendingPlan) {
setStagedInput({ plan: pendingPlan, mode: choice });
const questions = extractOpenQuestionsSection(pendingPlan) ?? undefined;
setStagedInput({ plan: pendingPlan, mode: choice, questions });
setPendingPlan(null);
} else if (choice === "approve") {
setStagedInput({ plan: "", mode: "approve" });
@@ -2811,7 +2853,8 @@ function AppInner({
// the user can tell the model *why* — symmetric with the deny-tool
// "press Tab to add reason" pattern. Empty Enter still cancels cleanly.
if (pendingPlan) {
setStagedInput({ plan: pendingPlan, mode: "reject" });
const questions = extractOpenQuestionsSection(pendingPlan) ?? undefined;
setStagedInput({ plan: pendingPlan, mode: "reject", questions });
setPendingPlan(null);
}
},
@@ -2915,8 +2958,8 @@ function AppInner({
marker = `▸ refining — ${trimmed.length > 50 ? `${trimmed.slice(0, 50)}…` : trimmed}`;
} else {
synthetic =
"The plan needs refinement, but the user didn't give specifics. Ask them one or two concrete questions — scope, approach, file boundaries, or the risks you flagged — then wait for their answer before submitting an updated plan.";
marker = "▸ refining — asking the model to clarify";
"The plan needs refinement. The user saw your open questions / risks block and chose not to answer specifics. Pick the safest default for each open question, call those defaults out explicitly in the new plan, and submit the refined submit_plan. Do not re-ask — the user already saw the questions.";
marker = "▸ refining — using safe defaults";
}
}
@@ -3423,6 +3466,7 @@ function AppInner({
{stagedInput ? (
<PlanRefineInput
mode={stagedInput.mode}
questions={stagedInput.questions}
onSubmit={handleStagedInputSubmit}
onCancel={handleStagedInputCancel}
/>
@@ -3687,8 +3731,6 @@ function AppInner({
disabled={busy}
onHistoryPrev={recallPrev}
onHistoryNext={recallNext}
onChatScrollUp={chatScroll.scrollUp}
onChatScrollDown={chatScroll.scrollDown}
/>
{slashMatches !== null ? (
<SlashSuggestions
@@ -3698,12 +3740,8 @@ function AppInner({
advancedHidden={slashAdvancedHidden}
/>
) : null}
{atMatches !== null ? (
<AtMentionSuggestions
matches={atMatches}
selectedIndex={atSelected}
query={atPicker?.query ?? ""}
/>
{atState !== null ? (
<AtMentionSuggestions state={atState} selectedIndex={atSelected} />
) : null}
{slashArgContext ? (
<SlashArgPicker
+119 -77
View File
@@ -1,104 +1,146 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
import React from "react";
import { COLOR, GLYPH } from "./theme.js";
import { GLYPH, useColor } from "./theme.js";
import type { AtPickerEntry, AtPickerState } from "./useCompletionPickers.js";
export interface AtMentionSuggestionsProps {
/**
* Current matching file paths, ranked by the picker. `null` means
* "not in @-prefix mode" — render nothing. Empty array means "in @
* mode but no files match that partial" — render a hint.
*/
matches: readonly string[] | null;
/** Index (within `matches`) of the currently highlighted row. */
state: AtPickerState | null;
selectedIndex: number;
/** The partial query the user typed after `@`. Shown in the hint row. */
query: string;
}
/**
* `@`-mention picker. Rendered below the input box while the user is
* typing an `@…` prefix in code mode. Visual grammar matches the
* design's file picker:
*
* @ files matching "auth" esc
* ▸ login.ts src/auth/
* refresh.ts src/auth/
* validators.ts src/auth/
* auth.test.ts tests/
* [↑↓] navigate · Tab / ⏎ insert as @path · file content inlined
*
* Basename in primary cyan (the user's eye lands here first), dir
* suffix in dim info color. No solid-bg pill on the selected row —
* leading ▸ + bold + brighter color does the same job without the
* loud bg block. Mirrors {@link SlashSuggestions}.
*/
const ROW_WINDOW = 8;
export function AtMentionSuggestions({
matches,
state,
selectedIndex,
query,
}: AtMentionSuggestionsProps): React.ReactElement | null {
if (matches === null) return null;
if (matches.length === 0) {
const color = useColor();
if (!state) return null;
const isBrowse = state.kind === "browse";
const entries = state.entries;
const total = entries.length;
const windowStart =
total <= ROW_WINDOW
? 0
: Math.max(0, Math.min(selectedIndex - Math.floor(ROW_WINDOW / 2), total - ROW_WINDOW));
const shown = entries.slice(windowStart, windowStart + ROW_WINDOW);
const hiddenAbove = windowStart;
const hiddenBelow = total - windowStart - shown.length;
return (
<Box flexDirection="column" paddingX={1} marginTop={1}>
<HeaderRow state={state} hiddenAbove={hiddenAbove} />
{total === 0 ? <EmptyRow state={state} color={color} /> : null}
{shown.map((entry, i) => (
<EntryRow
key={`${entry.insertPath}:${entry.isDir ? "d" : "f"}`}
entry={entry}
isSelected={windowStart + i === selectedIndex}
/>
))}
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
<FooterRow isBrowse={isBrowse} hasFolder={shown.some((e) => e.isDir)} />
</Box>
);
}
function HeaderRow({
state,
hiddenAbove,
}: {
state: AtPickerState;
hiddenAbove: number;
}) {
const color = useColor();
const total = state.entries.length;
const lead = (
<Text color={color.primary} bold>
{"@ "}
</Text>
);
if (state.kind === "browse") {
const where = state.baseDir === "" ? "/" : `${state.baseDir}/`;
const counter = state.loading ? "loading…" : `${total} ${total === 1 ? "entry" : "entries"}`;
return (
<Box paddingX={1} marginTop={1}>
<Text color={COLOR.warn} bold>
{GLYPH.warn}
</Text>
<Text> </Text>
<Text color={COLOR.warn}>{`no files match "@${query}"`}</Text>
<Text dimColor>{" — keep typing or Backspace; paths resolve from the code root"}</Text>
<Box>
{lead}
<Text dimColor>{`${where} ${counter}`}</Text>
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
);
}
const MAX = 8;
const total = matches.length;
const windowStart =
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
const shown = matches.slice(windowStart, windowStart + MAX);
const hiddenAbove = windowStart;
const hiddenBelow = total - windowStart - shown.length;
const status = state.searching
? `searching… ${state.scanned} scanned · ${total} ${total === 1 ? "match" : "matches"}`
: `${total} ${total === 1 ? "match" : "matches"} for "${state.filter}"`;
return (
<Box flexDirection="column" paddingX={1} marginTop={1}>
<Box>
<Text color={COLOR.primary} bold>
{"@ "}
</Text>
<Text dimColor>
{query
? `${total} match${total === 1 ? "" : "es"} for "${query}"`
: `${total} file${total === 1 ? "" : "s"}`}
</Text>
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
{shown.map((path, i) => (
<FileRow key={path} path={path} isSelected={windowStart + i === selectedIndex} />
))}
{hiddenBelow > 0 ? <Text dimColor>{` ↓ ${hiddenBelow} below`}</Text> : null}
<Box marginTop={0}>
<Text dimColor>{" ↑↓ navigate · Tab / ⏎ insert as @path · esc cancel"}</Text>
</Box>
<Box>
{lead}
<Text dimColor>{status}</Text>
{hiddenAbove > 0 ? <Text dimColor>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
);
}
function FileRow({ path, isSelected }: { path: string; isSelected: boolean }) {
// Split the path so the basename visually pops — same dropdown
// affordance as VS Code's Quick Open. Basename in primary cyan
// (selected: bold), dir suffix in dim info color so the eye lands
// on the filename first and uses the path only as a disambiguator.
const slash = path.lastIndexOf("/");
const dir = slash >= 0 ? `${path.slice(0, slash)}/` : "";
const base = slash >= 0 ? path.slice(slash + 1) : path;
function EmptyRow({ state, color }: { state: AtPickerState; color: ReturnType<typeof useColor> }) {
if (state.kind === "browse") {
if (state.loading) return null;
return (
<Box>
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text> </Text>
<Text color={color.warn}>empty directory</Text>
</Box>
);
}
if (state.searching) {
return (
<Box>
<Text dimColor>scanning the tree…</Text>
</Box>
);
}
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
{isSelected ? `${GLYPH.cur} ` : " "}
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text color={COLOR.primary} bold={isSelected}>
{base.padEnd(20)}
<Text> </Text>
<Text color={color.warn}>{`no files match "${state.filter}"`}</Text>
</Box>
);
}
function EntryRow({ entry, isSelected }: { entry: AtPickerEntry; isSelected: boolean }) {
const color = useColor();
const cursor = isSelected ? `${GLYPH.cur} ` : " ";
const labelColor = entry.isDir ? color.accent : color.primary;
const labelText = entry.isDir ? `${entry.label}/` : entry.label;
return (
<Box>
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
{cursor}
</Text>
{dir ? <Text dimColor>{` ${dir}`}</Text> : null}
<Text color={labelColor} bold={isSelected}>
{labelText.padEnd(20)}
</Text>
{entry.dirSuffix ? <Text dimColor>{` ${entry.dirSuffix}`}</Text> : null}
</Box>
);
}
function FooterRow({ isBrowse, hasFolder }: { isBrowse: boolean; hasFolder: boolean }) {
const hint = isBrowse
? hasFolder
? "↑↓ navigate · Tab drill into folder · ⏎ insert · esc cancel"
: "↑↓ navigate · Tab / ⏎ insert as @path · esc cancel"
: "↑↓ navigate · Tab / ⏎ insert as @path · esc cancel";
return (
<Box marginTop={0}>
<Text dimColor>{` ${hint}`}</Text>
</Box>
);
}
+8 -7
View File
@@ -2,6 +2,7 @@
import { Box } from "ink";
import React from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
@@ -40,7 +41,7 @@ function PlanCheckpointConfirmInner({
const statuses = buildStatusMap(steps, completedStepIds, stepId, isLast);
const subtitle = counter ? `${counter} · ${label}` : label;
return (
<ApprovalCard tone="ok" glyph="⛁" title="Checkpoint — step done" metaRight={subtitle}>
<ApprovalCard tone="ok" glyph="⛁" title={t("planFlow.checkpoint.title")} metaRight={subtitle}>
{steps && steps.length > 0 ? (
<Box marginBottom={1} flexDirection="column">
<PlanStepList steps={steps} statuses={statuses} focusStepId={stepId} />
@@ -51,18 +52,18 @@ function PlanCheckpointConfirmInner({
items={[
{
value: "continue",
label: "Continue — run the next step",
hint: "Model resumes with the next step.",
label: t("planFlow.checkpoint.continue"),
hint: t("planFlow.checkpoint.continueHint"),
},
{
value: "revise",
label: "Revise — give feedback before the next step",
hint: "Stay paused, type guidance; model adjusts the remaining plan.",
label: t("planFlow.checkpoint.revise"),
hint: t("planFlow.checkpoint.reviseHint"),
},
{
value: "stop",
label: "Stop — end the plan here",
hint: "Model summarizes what was done and ends.",
label: t("planFlow.checkpoint.stop"),
hint: t("planFlow.checkpoint.stopHint"),
},
]}
onSubmit={(v) => onChoose(v as CheckpointChoice)}
+36 -22
View File
@@ -2,12 +2,14 @@
import { Box, Text } from "ink";
import React from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep } from "../../tools/plan.js";
import { PlanStepList } from "./PlanStepList.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useReserveRows } from "./layout/viewport-budget.js";
import { MarkdownView } from "./markdown-view.js";
import { extractOpenQuestionsSection } from "./plan-open-questions.js";
import { CARD, FG, TONE } from "./theme/tokens.js";
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
@@ -26,6 +28,7 @@ const PLAN_BODY_PREVIEW_LINES = 24;
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
const stepRows = steps?.length ?? 0;
const hasSteps = stepRows > 0;
const openQuestions = extractOpenQuestionsSection(plan);
const planLines = plan.split("\n");
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
@@ -33,26 +36,34 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
? PLAN_BODY_PREVIEW_LINES
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
const reservedFor = hasSteps ? stepRows : previewRows;
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + 14) });
const oqRows = openQuestions ? openQuestions.split("\n").length : 0;
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + oqRows + 14) });
const hasOpenQuestions =
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
/^#{1,6}\s*(待确认|开放问题|风险|未知|假设|不确定)/im.test(plan);
const refineLabel = t("planFlow.picker.refine");
const bannerTemplate = t("planFlow.openQuestionsBanner");
const [bannerBefore, bannerAfter] = bannerTemplate.split("{refine}");
return (
<ApprovalCard
tone="accent"
glyph="⊞"
title="Approve plan"
metaRight="awaiting"
title={t("planFlow.approveCardTitle")}
metaRight={t("planFlow.approveCardMetaRight")}
metaRightColor={CARD.plan.color}
>
{hasOpenQuestions ? (
<Box marginBottom={1}>
{openQuestions ? (
<Box marginBottom={1} flexDirection="column">
<Text color={TONE.warn}>
▲ the plan flags open questions or risks — pick <Text bold>refine</Text> to write
concrete answers before the model moves on.
{bannerBefore ?? ""}
<Text bold>{refineLabel}</Text>
{bannerAfter ?? ""}
</Text>
<Box marginTop={1} flexDirection="column">
<Text color={TONE.warn} bold>
{t("planFlow.openQuestionsHeader")}
</Text>
<MarkdownView text={openQuestions} />
</Box>
</Box>
) : null}
{hasSteps ? (
@@ -64,35 +75,38 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
<MarkdownView text={previewBody} />
{truncatedBody ? (
<Text color={FG.faint}>
{`… ${planLines.length - PLAN_BODY_PREVIEW_LINES} more line${
planLines.length - PLAN_BODY_PREVIEW_LINES === 1 ? "" : "s"
} above in scrollback`}
{t(
planLines.length - PLAN_BODY_PREVIEW_LINES === 1
? "planFlow.truncatedBodyMore"
: "planFlow.truncatedBodyMorePlural",
{ n: planLines.length - PLAN_BODY_PREVIEW_LINES },
)}
</Text>
) : null}
</Box>
) : null}
<SingleSelect
initialValue={hasOpenQuestions ? "refine" : "approve"}
initialValue={openQuestions ? "refine" : "approve"}
items={[
{
value: "approve",
label: "accept",
hint: "run it now, in order",
label: t("planFlow.picker.accept"),
hint: t("planFlow.picker.acceptHint"),
},
{
value: "refine",
label: "refine",
hint: "give the agent more guidance, draft a new plan",
label: refineLabel,
hint: t("planFlow.picker.refineHint"),
},
{
value: "revise",
label: "revise",
hint: "edit the plan inline before running (skip / reorder steps)",
label: t("planFlow.picker.revise"),
hint: t("planFlow.picker.reviseHint"),
},
{
value: "cancel",
label: "reject",
hint: "discard, agent will retry from scratch",
label: t("planFlow.picker.reject"),
hint: t("planFlow.picker.rejectHint"),
},
]}
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
+46 -47
View File
@@ -1,12 +1,23 @@
import { Box, Text } from "ink";
import React, { useState } from "react";
import { t } from "../../i18n/index.js";
import { ApprovalCard, type ApprovalCardProps } from "./cards/ApprovalCard.js";
import { useKeystroke } from "./keystroke-context.js";
import { CARD, FG } from "./theme/tokens.js";
import { MarkdownView } from "./markdown-view.js";
import { CARD, FG, TONE } from "./theme/tokens.js";
import { useTick } from "./ticker.js";
export type PlanRefineMode =
| "approve"
| "refine"
| "reject"
| "checkpoint-revise"
| "choice-custom";
export interface PlanRefineInputProps {
mode: "approve" | "refine" | "reject" | "checkpoint-revise" | "choice-custom";
mode: PlanRefineMode;
/** Open-questions / risks block extracted from the plan, rendered above the input on refine. */
questions?: string;
onSubmit: (feedback: string) => void;
onCancel: () => void;
}
@@ -20,51 +31,30 @@ interface ModeMeta {
blankHint: string;
}
const MODES: Record<PlanRefineInputProps["mode"], ModeMeta> = {
approve: {
title: "approving — any last instructions?",
glyph: "◇",
tone: "user",
cursorColor: CARD.user.color,
hint: "Answer questions the plan raised, add constraints, or just press Enter to approve as-is.",
blankHint: " (Enter with blank = approve without extra instructions.)",
},
refine: {
title: "refining — what should the model change?",
glyph: "✎",
tone: "warn",
cursorColor: CARD.warn.color,
hint: "Describe what's wrong or missing, or answer questions the plan raised.",
blankHint: " (Enter with blank = ask the model to list concrete questions.)",
},
reject: {
title: "rejecting — tell the model why (optional)",
glyph: "✗",
tone: "error",
cursorColor: CARD.error.color,
hint: "Say what the model got wrong about your goal, or what you actually want instead.",
blankHint:
" (Enter with blank = cancel without explanation; the model will ask what you want.)",
},
"checkpoint-revise": {
title: "revising — what should change before the next step?",
glyph: "✎",
tone: "warn",
cursorColor: CARD.warn.color,
hint: "Scope change, skip steps, alternative approach — the model adjusts the remaining plan.",
blankHint: " (Enter with blank = continue with the current plan.)",
},
"choice-custom": {
title: "custom answer — type whatever fits",
glyph: "⌥",
tone: "accent",
cursorColor: CARD.plan.color,
hint: "Free-form reply. The model reads it verbatim and proceeds — no need to match the listed options.",
blankHint: " (Enter with blank = ask the model what you actually want.)",
},
const MODE_VISUALS: Record<
PlanRefineMode,
{ glyph: string; tone: ApprovalCardProps["tone"]; cursorColor: string }
> = {
approve: { glyph: "◇", tone: "user", cursorColor: CARD.user.color },
refine: { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
reject: { glyph: "✗", tone: "error", cursorColor: CARD.error.color },
"checkpoint-revise": { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
"choice-custom": { glyph: "⌥", tone: "accent", cursorColor: CARD.plan.color },
};
export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputProps) {
function modeMeta(mode: PlanRefineMode): ModeMeta {
const v = MODE_VISUALS[mode];
return {
title: t(`planFlow.modes.${mode}.title`),
hint: t(`planFlow.modes.${mode}.hint`),
blankHint: t(`planFlow.modes.${mode}.blankHint`),
glyph: v.glyph,
tone: v.tone,
cursorColor: v.cursorColor,
};
}
export function PlanRefineInput({ mode, questions, onSubmit, onCancel }: PlanRefineInputProps) {
const [value, setValue] = useState("");
useKeystroke((ev) => {
@@ -91,15 +81,24 @@ export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputPro
const tick = useTick();
const cursorOn = Math.floor(tick / 4) % 2 === 0;
const meta = MODES[mode];
const meta = modeMeta(mode);
const showQuestions = mode === "refine" && !!questions && questions.trim().length > 0;
return (
<ApprovalCard
tone={meta.tone}
glyph={meta.glyph}
title={meta.title}
footerHint="⏎ send · esc return to picker"
footerHint={t("planFlow.refineFooter")}
>
{showQuestions ? (
<Box marginBottom={1} flexDirection="column">
<Text color={TONE.warn} bold>
{t("planFlow.refineQuestionsHeading")}
</Text>
<MarkdownView text={questions!} />
</Box>
) : null}
<Box marginBottom={1}>
<Text color={FG.sub}>
{meta.hint}
+10 -2
View File
@@ -29,6 +29,7 @@
import { Box, Text } from "ink";
import React from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep, PlanStepRisk } from "../../tools/plan.js";
import { CharBar } from "./char-bar.js";
import { COLOR, GLYPH } from "./theme.js";
@@ -103,8 +104,15 @@ function PlanStepListInner({ steps, statuses, focusStepId }: PlanStepListProps)
<Box>
<Text dimColor>
{showProgress
? `${doneCount}/${total} done (${pct}%) · ${total} step${total === 1 ? "" : "s"}`
: `${total} step${total === 1 ? "" : "s"}`}
? t(
total === 1
? "planFlow.stepList.counterDoneSingular"
: "planFlow.stepList.counterDone",
{ done: doneCount, total, pct },
)
: t(total === 1 ? "planFlow.stepList.counterSingular" : "planFlow.stepList.counter", {
total,
})}
</Text>
</Box>
<Box flexDirection="column">
+25 -12
View File
@@ -32,12 +32,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`. */
/** ↑/↓ on an empty buffer or Ctrl+P / Ctrl+N — parent walks history and swaps `value` via `onChange`. */
onHistoryPrev?: () => void;
onHistoryNext?: () => void;
/** ↑/↓ on an empty buffer — parent scrolls chat history. */
onChatScrollUp?: () => void;
onChatScrollDown?: () => void;
}
export function PromptInput({
@@ -48,8 +45,6 @@ export function PromptInput({
placeholder,
onHistoryPrev,
onHistoryNext,
onChatScrollUp,
onChatScrollDown,
}: PromptInputProps) {
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
@@ -146,8 +141,6 @@ export function PromptInput({
}
if (action.historyHandoff === "prev") onHistoryPrev?.();
if (action.historyHandoff === "next") onHistoryNext?.();
if (action.chatScrollHandoff === "up") onChatScrollUp?.();
if (action.chatScrollHandoff === "down") onChatScrollDown?.();
}, !disabled);
// ── Render ──────────────────────────────────────────────────────
@@ -163,7 +156,7 @@ export function PromptInput({
// a just-cleared buffer and read as residual typed input on dim-poor terminals.
const effectivePlaceholder = disabled
? (placeholder ?? "…waiting for response…")
: (placeholder ?? "type a message · slash for commands · at-sign for files");
: (placeholder ?? "ask anything · slash for commands · at-sign for files");
const lines = value.length > 0 ? value.split("\n") : [""];
const accentColor = disabled ? FG.faint : TONE.brand;
@@ -295,9 +288,7 @@ export function PromptInput({
) : null}
{!disabled ? (
<Box marginTop={1}>
<Text color={FG.faint}>
{" ⏎ send · shift/alt+⏎ newline · ↑↓ history · esc abort · ctrl-c quit"}
</Text>
<HintRow />
</Box>
) : (
<Box marginTop={1}>
@@ -308,6 +299,28 @@ export function PromptInput({
);
}
function HintRow(): React.ReactElement {
const items: Array<{ key: string; sep: string }> = [
{ key: "⏎", sep: "send" },
{ key: "⇧⏎", sep: "newline" },
{ key: "↑↓", sep: "history" },
{ key: "esc", sep: "abort" },
{ key: "^C", sep: "quit" },
];
return (
<Box flexDirection="row">
<Text>{" "}</Text>
{items.map((item, i) => (
<React.Fragment key={item.key}>
{i > 0 && <Text color={FG.faint}>{" · "}</Text>}
<Text color={FG.meta}>{item.key}</Text>
<Text color={FG.faint}>{` ${item.sep}`}</Text>
</React.Fragment>
))}
</Box>
);
}
type LineSegment =
| { kind: "text"; text: string; startOffset: number }
| { kind: "paste"; id: number; startOffset: number };
+9 -4
View File
@@ -3,7 +3,7 @@
import { Box, Text } from "ink";
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { COLOR } from "./theme.js";
import { type UiColor, useColor } from "./theme.js";
export interface SelectItem<V extends string = string> {
value: V;
@@ -33,6 +33,7 @@ export function SingleSelect<V extends string>({
onCancel,
footer,
}: SingleSelectProps<V>) {
const color = useColor();
const initialIndex = Math.max(
0,
items.findIndex((i) => i.value === initialValue && !i.disabled),
@@ -64,6 +65,7 @@ export function SingleSelect<V extends string>({
item={item}
active={i === index}
marker={i === index ? "▸" : " "}
color={color}
/>
))}
{footer ? (
@@ -91,6 +93,7 @@ export function MultiSelect<V extends string>({
onCancel,
footer,
}: MultiSelectProps<V>) {
const color = useColor();
const [index, setIndex] = useState(() => {
const first = items.findIndex((i) => !i.disabled);
return first === -1 ? 0 : first;
@@ -113,7 +116,6 @@ export function MultiSelect<V extends string>({
return next;
});
} else if (ev.return) {
// Catalog order keeps the resulting config.json diff stable across reruns.
const ordered = items.filter((i) => selected.has(i.value)).map((i) => i.value);
onSubmit(ordered);
} else if (ev.escape && onCancel) {
@@ -132,6 +134,7 @@ export function MultiSelect<V extends string>({
item={item}
active={i === index}
marker={`${i === index ? "▸" : " "} ${marker}`}
color={color}
/>
);
})}
@@ -148,16 +151,18 @@ function SelectRow<V extends string>({
item,
active,
marker,
color,
}: {
item: SelectItem<V>;
active: boolean;
marker: string;
color: UiColor;
}) {
const color = item.disabled ? COLOR.info : active ? COLOR.primary : undefined;
const rowColor = item.disabled ? color.info : active ? color.primary : undefined;
return (
<Box flexDirection="column">
<Box>
<Text color={color} bold={active} dimColor={item.disabled}>
<Text color={rowColor} bold={active} dimColor={item.disabled}>
{marker} {item.label}
</Text>
</Box>
+9 -12
View File
@@ -2,7 +2,7 @@ import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
import React from "react";
import type { SlashCommandSpec } from "./slash.js";
import { COLOR, GLYPH } from "./theme.js";
import { GLYPH, useColor } from "./theme.js";
export interface SlashArgPickerProps {
/**
@@ -37,16 +37,13 @@ export function SlashArgPicker({
kind,
partial,
}: SlashArgPickerProps): React.ReactElement | null {
// Header row shared by both hint and picker modes — same shape so
// the UI looks like the same affordance, just with or without rows
// beneath. cmd in accent violet (matches SlashSuggestions), argsHint
// in dim, summary in info.
const color = useColor();
const headerRow = (
<Box>
<Text color={COLOR.accent} bold>
<Text color={color.accent} bold>
{"/ "}
</Text>
<Text color={COLOR.accent} bold>
<Text color={color.accent} bold>
{`/${spec.cmd}`}
</Text>
{spec.argsHint ? <Text dimColor>{` ${spec.argsHint}`}</Text> : null}
@@ -54,7 +51,6 @@ export function SlashArgPicker({
</Box>
);
// Hint mode: header only.
if (kind === "hint") {
return (
<Box paddingX={1} marginTop={1}>
@@ -69,10 +65,10 @@ export function SlashArgPicker({
<Box flexDirection="column" paddingX={1} marginTop={1}>
{headerRow}
<Box>
<Text color={COLOR.warn} bold>
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text color={COLOR.warn}>{` no match for "${partial}"`}</Text>
<Text color={color.warn}>{` no match for "${partial}"`}</Text>
<Text dimColor>{" — keep typing, or Backspace to edit"}</Text>
</Box>
</Box>
@@ -102,12 +98,13 @@ export function SlashArgPicker({
}
function ArgRow({ value, isSelected }: { value: string; isSelected: boolean }) {
const color = useColor();
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
{isSelected ? `${GLYPH.cur} ` : " "}
</Text>
<Text color={isSelected ? COLOR.user : COLOR.info} bold={isSelected} dimColor={!isSelected}>
<Text color={isSelected ? color.user : color.info} bold={isSelected} dimColor={!isSelected}>
{value}
</Text>
</Box>
+10 -7
View File
@@ -3,7 +3,7 @@ import { Box, Text } 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";
export interface SlashSuggestionsProps {
matches: SlashCommandSpec[] | null;
@@ -31,15 +31,17 @@ export function SlashSuggestions({
groupMode,
advancedHidden,
}: SlashSuggestionsProps): React.ReactElement | null {
const color = useColor();
if (matches === null) return null;
if (matches.length === 0) {
return (
<Box paddingX={1} marginTop={1}>
<Text color={COLOR.warn} bold>
<Text color={color.warn} bold>
{GLYPH.warn}
</Text>
<Text> </Text>
<Text color={COLOR.warn}>no slash command matches that prefix</Text>
<Text color={color.warn}>no slash command matches that prefix</Text>
<Text dimColor>{" — Backspace to edit, or /help for the full list"}</Text>
</Box>
);
@@ -56,7 +58,7 @@ export function SlashSuggestions({
return (
<Box flexDirection="column" paddingX={1} marginTop={1}>
<Box>
<Text color={COLOR.accent} bold>
<Text color={color.accent} bold>
{"/ "}
</Text>
<Text dimColor>{`${total} command${total === 1 ? "" : "s"}`}</Text>
@@ -91,6 +93,7 @@ export function SlashSuggestions({
}
function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelected: boolean }) {
const color = useColor();
const name = `/${spec.cmd}`;
const argsSuffix = spec.argsHint ? spec.argsHint : "";
const key = `slash.${spec.cmd}.description`;
@@ -99,15 +102,15 @@ function SuggestionRow({ spec, isSelected }: { spec: SlashCommandSpec; isSelecte
const aliasHint = spec.aliases?.length ? ` · /${spec.aliases.join(" /")}` : "";
return (
<Box>
<Text color={isSelected ? COLOR.primary : COLOR.info} bold={isSelected}>
<Text color={isSelected ? color.primary : color.info} bold={isSelected}>
{isSelected ? `${GLYPH.cur} ` : " "}
</Text>
<Text color={COLOR.accent} bold={isSelected}>
<Text color={color.accent} bold={isSelected}>
{name.padEnd(14)}
</Text>
<Text dimColor>{argsSuffix.padEnd(14)}</Text>
<Text>{" "}</Text>
<Text color={isSelected ? COLOR.user : COLOR.info} dimColor={!isSelected}>
<Text color={isSelected ? color.user : color.info} dimColor={!isSelected}>
{summary}
</Text>
{aliasHint ? <Text dimColor>{aliasHint}</Text> : null}
+1 -1
View File
@@ -88,7 +88,7 @@ function ChromeRow({
? { label: "⇧ pro", color: COLOR.warn }
: null;
const projectName = rootDir ? basename(rootDir) : null;
const cachePct = Math.round(summary.cacheHitRatio * 100);
const cachePct = (summary.cacheHitRatio * 100).toFixed(1);
const cacheColor =
summary.cacheHitRatio >= 0.7 ? COLOR.ok : summary.cacheHitRatio >= 0.4 ? COLOR.warn : COLOR.err;
const balanceLabel = balance
+3
View File
@@ -14,6 +14,7 @@ import { SearchCard } from "./SearchCard.js";
import { StreamingCard } from "./StreamingCard.js";
import { SubAgentCard } from "./SubAgentCard.js";
import { TaskCard } from "./TaskCard.js";
import { TipCard } from "./TipCard.js";
import { ToolCard } from "./ToolCard.js";
import { UsageCard } from "./UsageCard.js";
import { UserCard } from "./UserCard.js";
@@ -58,6 +59,8 @@ function renderCard(card: Card): React.ReactElement {
return <SearchCard card={card} />;
case "live":
return <LiveCard card={card} />;
case "tip":
return <TipCard card={card} />;
case "ctx":
return <CtxCard card={card} />;
case "doctor":
+12 -11
View File
@@ -4,13 +4,8 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { DoctorCard as DoctorCardData, DoctorCheckEntry } from "../state/cards.js";
import { CARD, FG, TONE } from "../theme/tokens.js";
const LEVEL_COLOR: Record<DoctorCheckEntry["level"], string> = {
ok: TONE.ok,
warn: TONE.warn,
fail: TONE.err,
};
import { useThemeTokens } from "../theme/context.js";
import { CARD } from "../theme/tokens.js";
const LEVEL_GLYPH: Record<DoctorCheckEntry["level"], string> = {
ok: "✓",
@@ -25,6 +20,12 @@ const LEVEL_TAG: Record<DoctorCheckEntry["level"], string> = {
};
export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactElement {
const { fg, tone } = useThemeTokens();
const levelColor: Record<DoctorCheckEntry["level"], string> = {
ok: tone.ok,
warn: tone.warn,
fail: tone.err,
};
const ok = card.checks.filter((c) => c.level === "ok").length;
const warn = card.checks.filter((c) => c.level === "warn").length;
const fail = card.checks.filter((c) => c.level === "fail").length;
@@ -36,12 +37,12 @@ export function DoctorCard({ card }: { card: DoctorCardData }): React.ReactEleme
<CardHeader glyph="⚕" tone={CARD.tool.color} title="doctor" meta={[summary]} />
{card.checks.map((c) => (
<Box key={c.label} flexDirection="row" gap={1}>
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
<Text bold color={FG.body}>
<Text color={levelColor[c.level]}>{LEVEL_GLYPH[c.level]}</Text>
<Text bold color={fg.body}>
{c.label.padEnd(labelWidth + 1)}
</Text>
<Text color={FG.sub}>{c.detail}</Text>
<Text color={LEVEL_COLOR[c.level]}>{LEVEL_TAG[c.level]}</Text>
<Text color={fg.sub}>{c.detail}</Text>
<Text color={levelColor[c.level]}>{LEVEL_TAG[c.level]}</Text>
</Box>
))}
</Card>
+20 -20
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { PlanCard as PlanCardData, PlanStep } from "../state/cards.js";
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
const STATUS_GLYPH: Record<PlanStep["status"], string> = {
queued: "○",
@@ -15,53 +15,53 @@ const STATUS_GLYPH: Record<PlanStep["status"], string> = {
skipped: "s",
};
const STATUS_COLOR: Record<PlanStep["status"], string> = {
queued: FG.faint,
running: TONE_ACTIVE.brand,
done: TONE.ok,
failed: TONE.err,
blocked: TONE.warn,
skipped: FG.faint,
};
const VISIBLE_WINDOW = 5;
export function PlanCard({ card }: { card: PlanCardData }): React.ReactElement {
const { fg, tone, toneActive } = useThemeTokens();
const statusColor: Record<PlanStep["status"], string> = {
queued: fg.faint,
running: toneActive.brand,
done: tone.ok,
failed: tone.err,
blocked: tone.warn,
skipped: fg.faint,
};
const doneCount = card.steps.filter((s) => s.status === "done").length;
const variantTag =
card.variant === "resumed" ? "resumed · " : card.variant === "replay" ? "⏪ archive · " : "";
const progress = `${variantTag}${doneCount}/${card.steps.length} done`;
const hasRunning = card.steps.some((s) => s.status === "running");
const tone = hasRunning ? TONE_ACTIVE.accent : TONE.accent;
const cardTone = hasRunning ? toneActive.accent : tone.accent;
const window = pickWindow(card.steps);
return (
<Card tone={tone}>
<CardHeader glyph="⊞" tone={tone} title={card.title} meta={[progress]} />
<Card tone={cardTone}>
<CardHeader glyph="⊞" tone={cardTone} title={card.title} meta={[progress]} />
{window.hiddenBefore > 0 ? (
<Box flexDirection="row" gap={1}>
<Text color={TONE.ok}>✓</Text>
<Text color={FG.faint}>{`⋯ ${window.hiddenBefore} done`}</Text>
<Text color={tone.ok}>✓</Text>
<Text color={fg.faint}>{`⋯ ${window.hiddenBefore} done`}</Text>
</Box>
) : null}
{window.steps.map((step) => {
const isActive = step.status === "running";
const titleColor = isActive ? FG.strong : FG.sub;
const titleColor = isActive ? fg.strong : fg.sub;
return (
<Box key={step.id} flexDirection="row" gap={1}>
<Text color={STATUS_COLOR[step.status]}>{STATUS_GLYPH[step.status]}</Text>
<Text color={statusColor[step.status]}>{STATUS_GLYPH[step.status]}</Text>
<Text bold={isActive} color={titleColor}>
{`${step.indexLabel}. ${step.title}`}
</Text>
{isActive ? <Text color={TONE_ACTIVE.brand}>← in progress</Text> : null}
{isActive ? <Text color={toneActive.brand}>← in progress</Text> : null}
</Box>
);
})}
{window.hiddenAfter > 0 ? (
<Box flexDirection="row" gap={1}>
<Text color={FG.faint}>○</Text>
<Text color={FG.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
<Text color={fg.faint}>○</Text>
<Text color={fg.faint}>{`⋯ ${window.hiddenAfter} upcoming`}</Text>
</Box>
) : null}
</Card>
+31 -24
View File
@@ -5,16 +5,17 @@ import { ActiveCardContext, Card as CardWrap } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import { Spinner } from "../primitives/Spinner.js";
import type { Card, SubAgentCard as SubAgentCardData } from "../state/cards.js";
import { CARD, FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
const STATUS_COLOR: Record<SubAgentCardData["status"], string> = {
running: TONE_ACTIVE.violet,
done: TONE.ok,
failed: TONE.err,
};
import { useThemeTokens } from "../theme/context.js";
import { CARD } from "../theme/tokens.js";
export function SubAgentCard({ card }: { card: SubAgentCardData }): React.ReactElement {
const headColor = STATUS_COLOR[card.status];
const { fg, tone, toneActive } = useThemeTokens();
const statusColor: Record<SubAgentCardData["status"], string> = {
running: toneActive.violet,
done: tone.ok,
failed: tone.err,
};
const headColor = statusColor[card.status];
const headGlyph = card.status === "failed" ? "✖" : "⌬";
const runningChildren = card.children.filter((c) => !isChildDone(c)).length;
const isRunning = card.status === "running";
@@ -30,17 +31,17 @@ export function SubAgentCard({ card }: { card: SubAgentCardData }): React.ReactE
glyph={headGlyph}
tone={headColor}
title="subagent"
titleColor={TONE.violet}
titleColor={tone.violet}
subtitle={card.task}
meta={headerMeta}
/>
{card.name ? <Text color={FG.faint}>{`agent · ${card.name}`}</Text> : null}
{card.name ? <Text color={fg.faint}>{`agent · ${card.name}`}</Text> : null}
{card.tools && card.tools.length > 0 && (
<Text color={FG.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
<Text color={fg.faint}>{`tools · ${card.tools.join(", ")}`}</Text>
)}
{card.children.map((child) => (
<Box key={child.id} flexDirection="row" gap={1}>
{inLive ? null : <Text color={TONE.violet}>▎</Text>}
{inLive ? null : <Text color={tone.violet}>▎</Text>}
<ChildRow card={child} />
</Box>
))}
@@ -68,13 +69,14 @@ interface ChildVisual {
}
function ChildRow({ card }: { card: Card }): React.ReactElement {
const v = childVisual(card);
const { fg, tone } = useThemeTokens();
const v = childVisual(card, tone.ok, tone.err, fg.faint);
const isDone = isChildDone(card);
return (
<>
{v.statusGlyph}
<Text color={v.kindColor}>{v.kindGlyph}</Text>
<Text dimColor={isDone} color={FG.body}>
<Text dimColor={isDone} color={fg.body}>
{v.text}
</Text>
</>
@@ -89,16 +91,21 @@ function doneGlyph(color: string): React.ReactElement {
return <Text color={color}>✓</Text>;
}
function failedGlyph(): React.ReactElement {
return <Text color={TONE.err}>✖</Text>;
function failedGlyph(color: string): React.ReactElement {
return <Text color={color}>✖</Text>;
}
function childVisual(card: Card): ChildVisual {
function childVisual(
card: Card,
doneColor: string,
failedColor: string,
fallbackColor: string,
): ChildVisual {
switch (card.kind) {
case "reasoning": {
const done = !card.streaming;
return {
statusGlyph: done ? doneGlyph(TONE.ok) : runningGlyph(CARD.reasoning.color),
statusGlyph: done ? doneGlyph(doneColor) : runningGlyph(CARD.reasoning.color),
kindGlyph: "◆",
kindColor: CARD.reasoning.color,
text: `reasoning · ${card.paragraphs}`,
@@ -107,7 +114,7 @@ function childVisual(card: Card): ChildVisual {
case "tool": {
const elapsed = card.elapsedMs > 0 ? ` · ${(card.elapsedMs / 1000).toFixed(2)}s` : "";
return {
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.tool.color),
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.tool.color),
kindGlyph: "▣",
kindColor: CARD.tool.color,
text: `${card.name}${elapsed}`,
@@ -115,30 +122,30 @@ function childVisual(card: Card): ChildVisual {
}
case "streaming":
return {
statusGlyph: card.done ? doneGlyph(TONE.ok) : runningGlyph(CARD.streaming.color),
statusGlyph: card.done ? doneGlyph(doneColor) : runningGlyph(CARD.streaming.color),
kindGlyph: "◈",
kindColor: CARD.streaming.color,
text: card.done ? "response" : "writing …",
};
case "diff":
return {
statusGlyph: doneGlyph(TONE.ok),
statusGlyph: doneGlyph(doneColor),
kindGlyph: "±",
kindColor: CARD.diff.color,
text: card.file,
};
case "error":
return {
statusGlyph: failedGlyph(),
statusGlyph: failedGlyph(failedColor),
kindGlyph: "✖",
kindColor: CARD.error.color,
text: card.title,
};
default:
return {
statusGlyph: <Text color={FG.faint}>·</Text>,
statusGlyph: <Text color={fallbackColor}>·</Text>,
kindGlyph: "·",
kindColor: FG.faint,
kindColor: fallbackColor,
text: card.kind,
};
}
+20 -21
View File
@@ -4,7 +4,7 @@ import React from "react";
import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import type { TaskCard as TaskCardData, TaskStep } from "../state/cards.js";
import { FG, TONE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
const STEP_GLYPH: Record<TaskStep["status"], string> = {
queued: "○",
@@ -13,19 +13,6 @@ const STEP_GLYPH: Record<TaskStep["status"], string> = {
failed: "✗",
};
const STEP_COLOR: Record<TaskStep["status"], string> = {
queued: FG.faint,
running: TONE.warn,
done: TONE.ok,
failed: TONE.err,
};
const TASK_COLOR: Record<TaskCardData["status"], string> = {
running: TONE.warn,
done: TONE.ok,
failed: TONE.err,
};
const TASK_GLYPH: Record<TaskCardData["status"], string> = {
running: "▶",
done: "✓",
@@ -33,26 +20,38 @@ const TASK_GLYPH: Record<TaskCardData["status"], string> = {
};
export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
const { fg, tone } = useThemeTokens();
const stepColor: Record<TaskStep["status"], string> = {
queued: fg.faint,
running: tone.warn,
done: tone.ok,
failed: tone.err,
};
const taskColor: Record<TaskCardData["status"], string> = {
running: tone.warn,
done: tone.ok,
failed: tone.err,
};
const elapsed = `${(card.elapsedMs / 1000).toFixed(1)}s`;
return (
<Card tone={TASK_COLOR[card.status]}>
<Card tone={taskColor[card.status]}>
<CardHeader
glyph={TASK_GLYPH[card.status]}
tone={TASK_COLOR[card.status]}
tone={taskColor[card.status]}
title={`step ${card.index}/${card.total}`}
subtitle={card.title}
meta={[elapsed, card.status]}
/>
{card.steps.map((step) => (
<Box key={step.id} flexDirection="row" gap={1}>
<Text color={STEP_COLOR[step.status]}>{STEP_GLYPH[step.status]}</Text>
<Text bold color={FG.body}>
<Text color={stepColor[step.status]}>{STEP_GLYPH[step.status]}</Text>
<Text bold color={fg.body}>
{(step.toolName ?? "step").padEnd(7)}
</Text>
<Text color={FG.sub}>{step.title}</Text>
{step.detail ? <Text color={FG.faint}>{step.detail}</Text> : null}
<Text color={fg.sub}>{step.title}</Text>
{step.detail ? <Text color={fg.faint}>{step.detail}</Text> : null}
{step.elapsedMs !== undefined ? (
<Text color={FG.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
<Text color={fg.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
) : null}
</Box>
))}
+74
View File
@@ -0,0 +1,74 @@
import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import stringWidth from "string-width";
import { t } from "../../../i18n/index.js";
import type { TipCard as TipCardData, TipRow as TipRowData } from "../state/cards.js";
import { FG, TONE } from "../theme/tokens.js";
const KEY_GUTTER = 4;
export function TipCard({ card }: { card: TipCardData }): React.ReactElement {
const keyWidth = card.sections.reduce(
(max, sec) => sec.rows.reduce((m, r) => Math.max(m, stringWidth(r.key)), max),
0,
);
return (
<Box flexDirection="column" paddingLeft={2} marginY={1}>
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row" gap={1}>
<Text color={TONE.accent} bold>
ⓘ
</Text>
<Text color={FG.body} bold>
{card.topic}
</Text>
</Box>
{card.oneTime ? <Text color={FG.faint}>{t("ui.tipShownOnce")}</Text> : null}
</Box>
{card.sections.map((section, i) => (
<Box key={section.title ?? `section-${i}`} flexDirection="column" marginTop={1}>
{section.title ? (
<Box marginBottom={0}>
<Text color={FG.sub}>{section.title}</Text>
</Box>
) : null}
{section.rows.map((row) => (
<TipRowRender
key={row.key}
row={row}
keyWidth={keyWidth}
indent={section.title ? 2 : 0}
/>
))}
</Box>
))}
{card.footer ? (
<Box marginTop={1}>
<Text color={FG.faint}>{card.footer}</Text>
</Box>
) : null}
</Box>
);
}
function TipRowRender({
row,
keyWidth,
indent,
}: {
row: TipRowData;
keyWidth: number;
indent: number;
}) {
const pad = " ".repeat(Math.max(0, keyWidth - stringWidth(row.key) + KEY_GUTTER));
const lead = indent > 0 ? " ".repeat(indent) : "";
return (
<Box flexDirection="row">
{lead ? <Text>{lead}</Text> : null}
<Text color={TONE.accent}>{row.key}</Text>
<Text>{pad}</Text>
<Text color={FG.body}>{row.text}</Text>
</Box>
);
}
+41 -19
View File
@@ -2,6 +2,7 @@ import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { clipToCells } from "../../../frame/width.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";
@@ -25,6 +26,9 @@ 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;
@@ -35,7 +39,7 @@ export function ToolCard({ card }: { card: ToolCardData }): React.ReactElement {
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) {
@@ -60,28 +64,46 @@ 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}>
{`⋮ ${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>
))}
</>
))}
</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 {
+2 -2
View File
@@ -28,9 +28,9 @@ export interface CharBarProps {
pct: number;
/** Cell count. Default 24. Min 4 enforced so the bar is at least readable. */
width?: number;
/** Filled-cell color. Defaults to brand cyan. */
/** Filled-cell COLOR. Defaults to brand cyan. */
color?: string;
/** Empty-cell color. Defaults to dim slate. */
/** Empty-cell COLOR. Defaults to dim slate. */
emptyColor?: string;
/**
* Whether to render the percentage label after the bar. Off when the
+95
View File
@@ -0,0 +1,95 @@
/** Pre-fills the GitHub new-issue body with version + platform + terminal + Node + locale + model. No transcripts, paths, or secrets. */
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;
if (latest === installed) return `${installed} (latest)`;
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(", ")})`;
}
+51 -15
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
/** Rows advanced per PgUp / PgDn / arrow / wheel — small for smooth feel. */
export const SCROLL_PAGE_ROWS = 3;
const COALESCE_MS = 16;
export interface ChatScrollState {
/** How many rows of content are above the visible viewport. */
@@ -21,6 +22,41 @@ export function useChatScroll(): ChatScrollState {
const [scrollRows, setScrollRows] = useState(0);
const [pinned, setPinned] = useState(true);
const [maxScroll, setMaxScrollState] = useState(0);
const maxScrollRef = useRef(0);
const pendingDelta = useRef(0);
const flushTimer = useRef<NodeJS.Timeout | null>(null);
const flush = useCallback(() => {
flushTimer.current = null;
const d = pendingDelta.current;
pendingDelta.current = 0;
if (d === 0) return;
if (d < 0) setPinned(false);
setScrollRows((o) => {
const next = Math.max(0, Math.min(maxScrollRef.current, o + d));
if (next >= maxScrollRef.current) setPinned(true);
return next;
});
}, []);
const schedule = useCallback(
(delta: number) => {
pendingDelta.current += delta;
if (flushTimer.current !== null) return;
flushTimer.current = setTimeout(flush, COALESCE_MS);
},
[flush],
);
useEffect(() => {
return () => {
if (flushTimer.current !== null) {
clearTimeout(flushTimer.current);
flushTimer.current = null;
}
};
}, []);
useEffect(() => {
if (pinned) setScrollRows(maxScroll);
@@ -30,22 +66,22 @@ export function useChatScroll(): ChatScrollState {
if (scrollRows > maxScroll) setScrollRows(maxScroll);
}, [scrollRows, maxScroll]);
const scrollUp = useCallback(() => {
setPinned(false);
setScrollRows((o) => Math.max(0, o - SCROLL_PAGE_ROWS));
const scrollUp = useCallback(() => schedule(-SCROLL_PAGE_ROWS), [schedule]);
const scrollDown = useCallback(() => schedule(SCROLL_PAGE_ROWS), [schedule]);
const jumpToBottom = useCallback(() => {
pendingDelta.current = 0;
if (flushTimer.current !== null) {
clearTimeout(flushTimer.current);
flushTimer.current = null;
}
setPinned(true);
}, []);
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), []);
const setMaxScroll = useCallback((rows: number) => {
maxScrollRef.current = rows;
setMaxScrollState(rows);
}, []);
return { scrollRows, pinned, scrollUp, scrollDown, jumpToBottom, setMaxScroll };
}
+24 -1
View File
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { DoctorCheckEntry, PlanStep } from "../state/cards.js";
import type { DoctorCheckEntry, PlanStep, TipSection } from "../state/cards.js";
import { useDispatch } from "../state/provider.js";
let seq = 0;
@@ -22,6 +22,13 @@ export interface Scrollback {
text: string,
tone?: "info" | "ok" | "warn" | "err" | "ghost" | "brand" | "accent",
): string;
/** Structured onboarding-tip card — replaces multi-line TIP strings stuffed into pushInfo. */
pushTip(args: {
topic: string;
sections: ReadonlyArray<TipSection>;
footer?: string;
oneTime?: boolean;
}): string;
/** Emits a `ctxPressure` live card when usedTokens crosses 80% (warn) or 95% (err) of ctxMax. */
pushCtxPressureIfHigh(usedTokens: number, ctxMax: number): void;
pushStepProgress(stepIndex: number, total: number, title: string, elapsedMs?: number): string;
@@ -139,6 +146,22 @@ export function useScrollback(): Scrollback {
});
return id;
},
pushTip({ topic, sections, footer, oneTime = true }) {
const id = nextId("tip");
dispatch({
type: "tip.show",
id,
ts: Date.now(),
topic,
sections: sections.map((s) => ({
title: s.title,
rows: s.rows.map((r) => ({ key: r.key, text: r.text })),
})),
footer,
oneTime,
});
return id;
},
pushCtxPressureIfHigh(usedTokens, ctxMax) {
if (ctxMax <= 0) return;
const pct = (usedTokens / ctxMax) * 100;
+6 -5
View File
@@ -2,7 +2,7 @@ import { Box, Text } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React from "react";
import { useAgentState } from "../state/provider.js";
import { FG, TONE } from "../theme/tokens.js";
import { useThemeTokens } from "../theme/context.js";
import { StatusRow } from "./StatusRow.js";
const PLACEHOLDER = "type a message · / for commands · @ to attach a file";
@@ -11,23 +11,24 @@ const ABORTED_HINT = "turn aborted by user · esc again to clear · ⏎ to ask a
export function Composer(): React.ReactElement {
const composer = useAgentState((s) => s.composer);
const { fg, tone } = useThemeTokens();
return (
<Box flexDirection="column">
<StatusRow />
<Box height={1} />
<Box flexDirection="row">
<Text bold color={composer.shell ? TONE.err : TONE.brand}>
<Text bold color={composer.shell ? tone.err : tone.brand}>
{composer.shell ? "$" : "›"}{" "}
</Text>
{composer.value.length === 0 ? (
<Text color={FG.meta}>{PLACEHOLDER}</Text>
<Text color={fg.meta}>{PLACEHOLDER}</Text>
) : (
<Text color={FG.body}>{composer.value}</Text>
<Text color={fg.body}>{composer.value}</Text>
)}
</Box>
<Box height={1} />
<Text color={FG.faint}>
<Text color={fg.faint}>
{" "}
{composer.abortedHint ? ABORTED_HINT : HINT}
</Text>
+2
View File
@@ -9,6 +9,7 @@ import { Card } from "../primitives/Card.js";
import { CardHeader } from "../primitives/CardHeader.js";
import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pill.js";
import { Spinner } from "../primitives/Spinner.js";
import { useThemeTokens } from "../theme/context.js";
import { CARD, FG, TONE } from "../theme/tokens.js";
import { useElapsedSeconds, useSlowTick, useTick } from "../ticker.js";
import type { SubagentActivity } from "../useSubagent.js";
@@ -18,6 +19,7 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
/** "Thinking" row — soft pulse + italic label (model wait, not tool call). */
export function ThinkingRow({ text }: { text: string }) {
const elapsed = useElapsedSeconds();
const { fg, tone } = useThemeTokens();
return (
<Box marginY={1} paddingX={1} gap={1}>
<Spinner kind="circle" color={TONE.accent} />
+57 -1
View File
@@ -1,13 +1,17 @@
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 { 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,6 +20,9 @@ export function StatusRow(): React.ReactElement {
const cols = stdout?.columns ?? 80;
const ruleWidth = Math.max(RULE_MIN, cols - RULE_PAD);
const hasTurn = status.cost > 0;
const hasSession = status.sessionCost > 0;
const hasBalance = typeof status.balance === "number";
const showWallet = cols >= WALLET_MIN_COLS && (hasSession || hasBalance);
return (
<Box flexDirection="column">
@@ -47,11 +54,60 @@ export function StatusRow(): React.ReactElement {
)}
<Sep />
<Text color={TONE.accent}>{`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}>{`v${VERSION}`}</Text>
{cols >= FEEDBACK_HINT_MIN_COLS && (
<>
<Text color={FG.faint}>{" · "}</Text>
<Text color={FG.meta}>{"⚑ "}</Text>
<Text color={FG.sub}>{"/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}>{"⛁ "}</Text>
{showSpent && (
<Text color={FG.body}>{`${formatCost(sessionCostUsd, currency, 2)} spent`}</Text>
)}
{showSpent && showBalance && <Text color={FG.meta}>{" / "}</Text>}
{showBalance && (
<Text bold color={balanceColor(balance, currency)}>
{formatBalance(balance, currency, { fractionDigits: 2 })}
</Text>
)}
{showBalance && <Text color={FG.faint}>{" left"}</Text>}
</>
);
}
function ModePill({
mode,
network,
+8 -12
View File
@@ -28,10 +28,8 @@ export interface MultilineAction {
/** When `true`, fire `onSubmit(submitValue ?? value)`. */
submit: boolean;
submitValue?: string;
/** Set on Ctrl+P / Ctrl+N — parent handles prompt-history recall. */
/** Set on ↑/↓ at a buffer boundary or Ctrl+P / Ctrl+N — parent recalls prompt history. */
historyHandoff?: "prev" | "next";
/** Set when ↑/↓ fires on an empty buffer — parent scrolls the chat history. */
chatScrollHandoff?: "up" | "down";
/** Reducer is pure — hands raw paste to PromptInput which allocates a sentinel and inserts that. */
pasteRequest?: { content: string };
}
@@ -70,11 +68,11 @@ export function processMultilineKey(
return cursor === value.length ? NOOP : { next: null, cursor: value.length, submit: false };
}
// Prompt-history recall is on Ctrl+P / Ctrl+N (bash readline
// convention). ↑/↓ on an empty buffer scrolls chat history because
// Windows Terminal + ConPTY translates mouse wheel events to ↑/↓ in
// raw mode — the only way to make wheel-up scroll chat is to map ↑
// to chat-scroll. Ctrl+P/N is wheel-immune.
// ↑/↓ on an empty buffer recalls prompt history (universal CLI
// convention — bash, zsh, fish all do this). Ctrl+P / Ctrl+N is the
// wheel-immune fallback for Windows ConPTY users whose terminal
// translates mouse-wheel events to arrow keys; bind it on every
// platform so the muscle memory works the same everywhere.
if (key.ctrl && key.input === "p") {
return { ...NOOP, historyHandoff: "prev" };
}
@@ -82,8 +80,6 @@ export function processMultilineKey(
return { ...NOOP, historyHandoff: "next" };
}
// Cursor motion. Empty single-line buffer + ↑/↓ is now a no-op
// (used to recall history; see Ctrl+P/N comment above).
if (key.leftArrow) {
return { next: null, cursor: Math.max(0, cursor - 1), submit: false };
}
@@ -91,12 +87,12 @@ export function processMultilineKey(
return { next: null, cursor: Math.min(value.length, cursor + 1), submit: false };
}
if (key.upArrow) {
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "up" };
if (value.length === 0) return { ...NOOP, historyHandoff: "prev" };
const moved = moveCursorUp(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
if (key.downArrow) {
if (value.length === 0) return { ...NOOP, chatScrollHandoff: "down" };
if (value.length === 0) return { ...NOOP, historyHandoff: "next" };
const moved = moveCursorDown(value, cursor);
return moved === cursor ? NOOP : { next: null, cursor: moved, submit: false };
}
+36
View File
@@ -0,0 +1,36 @@
/** Cross-platform URL opener; no-op under CI / when REASONIX_NO_OPEN is set. */
import { spawn } from "node:child_process";
import { platform } from "node:os";
export interface OpenUrlResult {
opened: boolean;
reason?: "ci" | "disabled" | "spawn-failed";
}
export function openUrl(url: string): OpenUrlResult {
if (process.env.CI) return { opened: false, reason: "ci" };
if (process.env.REASONIX_NO_OPEN) return { opened: false, reason: "disabled" };
const os = platform();
let cmd: string;
let args: string[];
if (os === "win32") {
cmd = "cmd";
args = ["/c", "start", "", url];
} else if (os === "darwin") {
cmd = "open";
args = [url];
} else {
cmd = "xdg-open";
args = [url];
}
try {
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
child.unref();
return { opened: true };
} catch {
return { opened: false, reason: "spawn-failed" };
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Markdown header rule used by PlanConfirm to flag plans with open questions. No `\b` — it's ASCII-only and would skip the Chinese alternatives. */
const HEADER_RE =
/^(#{1,6})\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear|待确认|开放问题|风险|未知|假设|不确定)(?:[\s::/、,,].*)?$/im;
export function hasOpenQuestionsSection(plan: string): boolean {
return HEADER_RE.test(plan);
}
/** Markdown body of the first matching heading down to the next same-or-shallower heading; null when absent. */
export function extractOpenQuestionsSection(plan: string): string | null {
const lines = plan.split("\n");
let startIdx = -1;
let startLevel = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const m = line.match(HEADER_RE);
if (m) {
startIdx = i;
startLevel = (m[1] ?? "#").length;
break;
}
}
if (startIdx === -1) return null;
let endIdx = lines.length;
for (let j = startIdx + 1; j < lines.length; j++) {
const line = lines[j] ?? "";
const lh = line.match(/^(#{1,6})\s+\S/);
if (lh && (lh[1] ?? "").length <= startLevel) {
endIdx = j;
break;
}
}
const block = lines.slice(startIdx, endIdx).join("\n").replace(/\s+$/g, "");
return block.length > 0 ? block : null;
}
+25
View File
@@ -60,6 +60,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 ▸)" },
@@ -235,6 +245,21 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argCompleter: ["EN", "zh-CN"],
aliases: ["lang"],
},
{
cmd: "theme",
group: "advanced",
argsHint: "[auto|default|dark|light|tokyo-night|github-dark|github-light]",
summary: "show or persist the terminal theme preference",
argCompleter: [
"auto",
"default",
"dark",
"light",
"tokyo-night",
"github-dark",
"github-light",
],
},
{
cmd: "search-engine",
group: "advanced",
+2
View File
@@ -15,6 +15,7 @@ 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 type { SlashContext, SlashResult } from "./types.js";
@@ -36,6 +37,7 @@ const HANDLERS: Record<string, SlashHandler> = {
...permissionsHandlers,
...plansHandlers,
...sessionsHandlers,
...themeHandlers,
...skillHandlers,
...webSearchEngineHandlers,
};
+16 -1
View File
@@ -1,4 +1,4 @@
import { t } from "@/i18n/index.js";
import { t, tObj } from "@/i18n/index.js";
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
import { SLASH_COMMANDS } from "../commands.js";
import type { SlashHandler } from "../dispatch.js";
@@ -129,10 +129,25 @@ const loop: SlashHandler = (args, _loop, ctx) => {
};
};
const keys: SlashHandler = (_args, _loop, ctx) => {
if (!ctx.postKeys) return { info: t("handlers.basic.keysNeedsTui") };
const ref = tObj<{
topic: string;
sections: ReadonlyArray<{
title?: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer: string;
}>("ui.keysReference");
ctx.postKeys({ topic: ref.topic, sections: ref.sections, footer: ref.footer });
return {};
};
export const handlers: Record<string, SlashHandler> = {
exit,
new: resetLog,
help,
retry,
loop,
keys,
};
+52 -3
View File
@@ -1,11 +1,17 @@
import { t } from "@/i18n/index.js";
import { release } from "node:os";
import { loadTheme, resolveThemePreference } from "@/config.js";
import { getLanguage, t } from "@/i18n/index.js";
import {
DEEPSEEK_CONTEXT_TOKENS,
DEEPSEEK_PRICING,
DEFAULT_CONTEXT_TOKENS,
} from "@/telemetry/stats.js";
import { countTokens } from "@/tokenizer.js";
import { VERSION } from "@/version.js";
import { writeClipboard } from "../../clipboard.js";
import { computeCtxBreakdown } from "../../ctx-breakdown.js";
import { buildFeedbackDiagnostic, buildFeedbackIssueUrl } from "../../feedback.js";
import { openUrl } from "../../open-url.js";
import type { SlashHandler } from "../dispatch.js";
import { compactNum } from "../helpers.js";
@@ -45,11 +51,11 @@ const status: SlashHandler = (_args, loop, ctx) => {
const cacheLine =
summary.turns > 3
? (() => {
const cachePct = Math.round(summary.cacheHitRatio * 100);
const cachePct = summary.cacheHitRatio * 100;
return t("handlers.observability.statusCost", {
cost: cost.toFixed(4),
bar: renderTinyBar(cachePct, 12),
pct: cachePct,
pct: cachePct.toFixed(1),
turns: summary.turns,
});
})()
@@ -225,9 +231,52 @@ function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop
return { info: lines.join("\n") };
}
const feedback: SlashHandler = (_args, loop, ctx) => {
const themeName = resolveThemePreference(loadTheme(), process.env.REASONIX_THEME);
const diagnostic = buildFeedbackDiagnostic({
version: VERSION,
latestVersion: ctx.latestVersion ?? undefined,
platform: process.platform,
osRelease: release(),
termProgram: process.env.TERM_PROGRAM,
term: process.env.TERM,
colorTerm: process.env.COLORTERM,
inWindowsTerminal: !!process.env.WT_SESSION,
inTmux: !!process.env.TMUX,
inSsh: !!process.env.SSH_TTY,
wslDistro: process.env.WSL_DISTRO_NAME,
cols: process.stdout.columns,
rows: process.stdout.rows,
nodeVersion: process.version,
locale: getLanguage(),
theme: themeName,
model: loop.model,
reasoningEffort: loop.reasoningEffort,
editMode: ctx.editMode,
planMode: ctx.planMode,
mcpServerCount: ctx.mcpServers?.length ?? ctx.mcpSpecs?.length,
sessionId: ctx.sessionId,
});
// Clipboard is the belt-and-suspenders: GitHub's new-issue page accepts
// `?body=…` and we use that, but if the URL ever fails to open the
// user can paste from clipboard against any tracker.
writeClipboard(diagnostic);
const url = buildFeedbackIssueUrl(diagnostic);
const opened = openUrl(url);
const lines = [
opened.opened
? "▸ issue page opened with the diagnostic block pre-filled. Just describe what you were doing and submit."
: `▸ couldn't open the browser (${opened.reason ?? "unknown"}). Diagnostic info is on your clipboard; open this URL manually: ${url}`,
"",
diagnostic,
];
return { info: lines.join("\n") };
};
export const handlers: Record<string, SlashHandler> = {
context,
status,
compact,
cost,
feedback,
};
+39
View File
@@ -0,0 +1,39 @@
import { loadTheme, resolveThemePreference, saveTheme } from "@/config.js";
import { type ThemeName, isThemeName, listThemeNames } from "../../theme/tokens.js";
import type { SlashHandler } from "../dispatch.js";
const themeChoices = ["auto", ...listThemeNames()] as const;
function formatThemeStatus(): string {
const configured = loadTheme();
const active = resolveThemePreference(configured, process.env.REASONIX_THEME);
const source = configured && configured !== "auto" ? "config" : "env/default";
return [
`theme: ${active} (${source})`,
`configured: ${configured ?? "unset"}`,
`available: ${themeChoices.join(", ")}`,
"usage: /theme <name|auto>",
].join("\n");
}
function isThemeChoice(value: string): value is ThemeName | "auto" {
return value === "auto" || isThemeName(value);
}
const theme: SlashHandler = (args) => {
const next = args[0];
if (!next) return { info: formatThemeStatus() };
if (!isThemeChoice(next)) {
return { info: `unknown theme: ${next}\navailable: ${themeChoices.join(", ")}` };
}
saveTheme(next);
const active = resolveThemePreference(next, process.env.REASONIX_THEME);
return { info: `theme saved: ${next}\nactive on next launch: ${active}` };
};
export const handlers: Record<string, SlashHandler> = {
theme,
};
+11
View File
@@ -90,6 +90,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 +134,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 =
+12
View File
@@ -52,6 +52,18 @@ export function cardsToDashboardMessages(cards: ReadonlyArray<Card>): DashboardM
case "ctx":
out.push({ id: card.id, role: "info", text: card.text });
break;
case "tip": {
const sectionTexts = card.sections.map((sec) => {
const body = sec.rows.map((r) => `${r.key}\t${r.text}`).join("\n");
return sec.title ? `[${sec.title}]\n${body}` : body;
});
const body = sectionTexts.join("\n\n");
const text = card.footer
? `${card.topic}\n${body}\n${card.footer}`
: `${card.topic}\n${body}`;
out.push({ id: card.id, role: "info", text });
break;
}
case "plan": {
const done = card.steps.filter((s) => s.status === "done").length;
const tag =
+21 -1
View File
@@ -192,6 +192,25 @@ export interface CtxCard extends CardBase {
readonly topTools: ReadonlyArray<{ name: string; tokens: number; turn: number }>;
}
export interface TipRow {
readonly key: string;
readonly text: string;
}
export interface TipSection {
/** Subsection heading (rendered above its rows). Omit for single-section tips. */
readonly title?: string;
readonly rows: ReadonlyArray<TipRow>;
}
export interface TipCard extends CardBase {
readonly kind: "tip";
readonly topic: string;
readonly sections: ReadonlyArray<TipSection>;
readonly footer?: string;
readonly oneTime: boolean;
}
export type Card =
| UserCard
| ReasoningCard
@@ -208,7 +227,8 @@ export type Card =
| SearchCard
| LiveCard
| CtxCard
| DoctorCard;
| DoctorCard
| TipCard;
export interface DoctorCheckEntry {
readonly label: string;
+16
View File
@@ -283,6 +283,21 @@ const liveShow = z.object({
meta: z.string().optional(),
});
const tipShow = z.object({
type: z.literal("tip.show"),
id: cardId,
ts: ts,
topic: z.string(),
sections: z.array(
z.object({
title: z.string().optional(),
rows: z.array(z.object({ key: z.string(), text: z.string() })),
}),
),
footer: z.string().optional(),
oneTime: z.boolean(),
});
export const AgentEventSchema = z.discriminatedUnion("type", [
userSubmit,
turnStart,
@@ -315,6 +330,7 @@ export const AgentEventSchema = z.discriminatedUnion("type", [
toastShow,
toastHide,
liveShow,
tipShow,
sessionReset,
planShow,
planStepComplete,
+11
View File
@@ -173,6 +173,17 @@ export function reduce(state: AgentState, event: AgentEvent): AgentState {
meta: event.meta,
});
case "tip.show":
return appendCard(state, {
kind: "tip",
id: event.id,
ts: event.ts,
topic: event.topic,
sections: event.sections,
footer: event.footer,
oneTime: event.oneTime,
});
case "session.reset":
return { ...state, cards: [], focusedCardId: null, toasts: [] };
+125 -57
View File
@@ -1,30 +1,121 @@
/** Brand gradient — REASONIX wordmark sweep, reused for accents / progress / dividers. */
export const GRADIENT: ReadonlyArray<string> = [
"#5eead4", // teal
"#67e8f9", // cyan
"#7dd3fc", // sky
"#93c5fd", // blue
"#a5b4fc", // indigo
"#c4b5fd", // violet
"#d8b4fe", // purple
"#f0abfc", // fuchsia
];
import React from "react";
import { useThemeTokens } from "./theme/context.js";
import {
CARD,
FG as TOKEN_FG,
SURFACE as TOKEN_SURFACE,
TONE,
TONE_ACTIVE,
type ThemeTokens,
} from "./theme/tokens.js";
/** Tailwind 400/500 row — keeps tone consistent with GRADIENT. */
export const COLOR = {
primary: "#67e8f9", // cyan-300
accent: "#c4b5fd", // violet-300
brand: "#5eead4", // teal-300
export type UiColor = ReturnType<typeof colorFromTheme>;
export type UiGradient = ReturnType<typeof gradientFromTheme>;
export type UiSurface = ReturnType<typeof surfaceFromTheme>;
export type UiFg = ReturnType<typeof fgFromTheme>;
user: "#67e8f9", // user message glyph + bar
assistant: "#86efac", // green-300, assistant glyph + bar
tool: "#fcd34d", // amber-300, tool ok pill bg
toolErr: "#fda4af", // rose-300, tool err pill bg
info: "#94a3b8", // slate-400, info / dim
warn: "#fbbf24", // amber-400
err: "#f87171", // red-400
ok: "#4ade80", // green-400
} as const;
export function gradientFromTheme(theme: ThemeTokens): ReadonlyArray<string> {
return [
theme.tone.ok,
theme.tone.brand,
theme.tone.info,
theme.toneActive.brand,
theme.toneActive.violet,
theme.tone.accent,
theme.toneActive.accent,
theme.tone.err,
];
}
export function colorFromTheme(theme: ThemeTokens) {
return {
primary: theme.tone.brand,
accent: theme.tone.accent,
brand: theme.tone.ok,
user: theme.tone.brand,
assistant: theme.tone.ok,
tool: theme.tone.warn,
toolErr: theme.tone.err,
info: theme.fg.sub,
warn: theme.tone.warn,
err: theme.tone.err,
ok: theme.tone.ok,
} as const;
}
export function surfaceFromTheme(theme: ThemeTokens) {
return {
canvas: theme.surface.bg,
shell: theme.surface.bgInput,
card: theme.surface.bgElev,
elev: theme.surface.bgElev,
sel: theme.surface.bgInput,
line: theme.fg.faint,
lineSoft: theme.fg.meta,
} as const;
}
export function fgFromTheme(theme: ThemeTokens) {
return {
strong: theme.fg.strong,
default: theme.fg.body,
dim: theme.fg.sub,
faint: theme.fg.meta,
ghost: theme.fg.faint,
} as const;
}
function proxyThemeValue<T extends object>(build: () => T): T {
const target = build();
return new Proxy(target, {
get(_target, prop: string | symbol) {
return build()[prop as keyof T];
},
getOwnPropertyDescriptor(_target, prop: string | symbol) {
return Reflect.getOwnPropertyDescriptor(build(), prop);
},
has(_target, prop: string | symbol) {
return prop in build();
},
ownKeys() {
return Reflect.ownKeys(build());
},
});
}
function currentTheme(): ThemeTokens {
return {
fg: TOKEN_FG,
tone: TONE,
toneActive: TONE_ACTIVE,
surface: TOKEN_SURFACE,
card: CARD,
};
}
export function useGradient(): UiGradient {
const theme = useThemeTokens();
return React.useMemo(() => gradientFromTheme(theme), [theme]);
}
export function useColor(): UiColor {
const theme = useThemeTokens();
return React.useMemo(() => colorFromTheme(theme), [theme]);
}
export function useUiSurface(): UiSurface {
const theme = useThemeTokens();
return React.useMemo(() => surfaceFromTheme(theme), [theme]);
}
export function useUiFg(): UiFg {
const theme = useThemeTokens();
return React.useMemo(() => fgFromTheme(theme), [theme]);
}
export const GRADIENT = proxyThemeValue(() => gradientFromTheme(currentTheme()));
export const COLOR = proxyThemeValue(() => colorFromTheme(currentTheme()));
export const GLYPH = {
brand: "◈",
@@ -43,63 +134,40 @@ export const GLYPH = {
shade2: "▒",
shade3: "▓",
// Status icons — checkbox-style states used across plan steps,
// job rows, history entries. Pair with the COLOR semantics:
// done→ok, cur→primary, pending→info-faint, fail→err.
done: "✓",
cur: "▸",
pending: "○",
fail: "✗",
running: "●",
// Tree-drawing chars for hierarchical lists (plan steps, sub-loops,
// hook attachments). 1 cell each; render fine in every monospace
// font we've tested.
branch: "┣",
branchEnd: "┗",
branchStub: "┃",
rule: "─",
// Spinner frames — 4-step rotation. Cycle every 200ms via setInterval
// (Ink's useEffect setState pattern). Equivalent to ink-spinner but
// with our own cadence + character set.
spinFrames: ["◐", "◓", "◑", "◒"] as readonly string[],
} as const;
/** Ordering survives 256-/16-color quantization — canvas always darker than sel. */
export const SURFACE = {
canvas: "#070a10",
shell: "#0b1019",
card: "#101721",
elev: "#161f2c",
sel: "#1a2433",
line: "#1c2433",
lineSoft: "#141b27",
} as const;
export const FG = {
strong: "#e6edf6",
default: "#cbd5e1",
dim: "#94a3b8",
faint: "#64748b",
ghost: "#475569",
} as const;
export const SURFACE = proxyThemeValue(() => surfaceFromTheme(currentTheme()));
export const FG = proxyThemeValue(() => fgFromTheme(currentTheme()));
export function gradientCells(
width: number,
glyph: string = GLYPH.block,
gradient: ReadonlyArray<string> = GRADIENT,
): Array<{ ch: string; color: string }> {
const cells: Array<{ ch: string; color: string }> = [];
if (width <= 0) return cells;
const last = GRADIENT.length - 1;
const last = gradient.length - 1;
for (let i = 0; i < width; i++) {
if (last <= 0) {
cells.push({ ch: glyph, color: gradient[0] ?? COLOR.primary });
continue;
}
const t = width === 1 ? 0 : (i * last) / (width - 1);
const lo = Math.floor(t);
const hi = Math.min(last, lo + 1);
// Pick the closer of the two anchor colors for this cell. Linear
// hex blending could be fancier but the discrete steps already
// read as a smooth fade at any reasonable width.
const color = t - lo < 0.5 ? GRADIENT[lo]! : GRADIENT[hi]!;
const color = t - lo < 0.5 ? gradient[lo]! : gradient[hi]!;
cells.push({ ch: glyph, color });
}
return cells;
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
import {
DEFAULT_THEME_NAME,
THEMES,
type ThemeName,
type ThemeTokens,
resolveThemeName,
setActiveTheme,
} from "./tokens.js";
const ThemeContext = React.createContext<ThemeTokens>(THEMES[DEFAULT_THEME_NAME]);
export function ThemeProvider({
children,
name,
}: {
children: React.ReactNode;
name?: string | null;
}): React.ReactElement {
const theme = THEMES[resolveThemeName(name)];
const restoreActiveTheme = setActiveTheme(theme);
React.useLayoutEffect(() => restoreActiveTheme, [restoreActiveTheme]);
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
export function useThemeTokens(): ThemeTokens {
return React.useContext(ThemeContext);
}
export function useTheme(): ThemeTokens {
return useThemeTokens();
}
export type { ThemeName, ThemeTokens };
+353 -52
View File
@@ -1,60 +1,361 @@
export const FG = {
strong: "#e6edf3",
body: "#c9d1d9",
sub: "#8b949e",
meta: "#6e7681",
faint: "#484f58",
} as const;
export type ThemeName =
| "default"
| "dark"
| "light"
| "tokyo-night"
| "github-dark"
| "github-light"
| "high-contrast";
export const TONE = {
brand: "#79c0ff",
accent: "#d2a8ff",
violet: "#b395f5",
ok: "#7ee787",
warn: "#f0b07d",
err: "#ff8b81",
info: "#79c0ff",
} as const;
export interface ThemeTokens {
fg: {
strong: string;
body: string;
sub: string;
meta: string;
faint: string;
};
tone: {
brand: string;
accent: string;
violet: string;
ok: string;
warn: string;
err: string;
info: string;
};
toneActive: ThemeTokens["tone"];
surface: {
bg: string;
bgInput: string;
bgCode: string;
bgElev: string;
};
card: Record<
| "user"
| "reasoning"
| "streaming"
| "task"
| "tool"
| "plan"
| "diff"
| "error"
| "warn"
| "usage"
| "subagent"
| "approval"
| "search"
| "memory"
| "ctx"
| "doctor"
| "branch",
{ color: string; glyph: string }
>;
}
/** Used only while a card is streaming/running, so live cards stand out from settled history. */
export const TONE_ACTIVE = {
brand: "#a5d6ff",
accent: "#e2c5ff",
violet: "#c8aaff",
ok: "#a8f5ad",
warn: "#ffc99e",
err: "#ffaba3",
info: "#a5d6ff",
} as const;
type ThemeBase = Omit<ThemeTokens, "card">;
export const SURFACE = {
bg: "#0a0c10",
bgInput: "#0d1015",
bgCode: "#06080c",
bgElev: "#11141a",
} as const;
function card(fg: ThemeTokens["fg"], tone: ThemeTokens["tone"]): ThemeTokens["card"] {
return {
user: { color: fg.meta, glyph: "◇" },
reasoning: { color: tone.accent, glyph: "◆" },
streaming: { color: tone.brand, glyph: "◈" },
task: { color: tone.warn, glyph: "▶" },
tool: { color: tone.info, glyph: "▣" },
plan: { color: tone.accent, glyph: "⊞" },
diff: { color: tone.ok, glyph: "±" },
error: { color: tone.err, glyph: "✖" },
warn: { color: tone.warn, glyph: "⚠" },
usage: { color: fg.meta, glyph: "Σ" },
subagent: { color: tone.violet, glyph: "⌬" },
approval: { color: tone.warn, glyph: "?" },
search: { color: tone.info, glyph: "⊙" },
memory: { color: fg.meta, glyph: "⌑" },
ctx: { color: tone.brand, glyph: "◔" },
doctor: { color: fg.meta, glyph: "⚕" },
branch: { color: tone.violet, glyph: "⎇" },
};
}
export const CARD = {
user: { color: FG.meta, glyph: "◇" },
reasoning: { color: TONE.accent, glyph: "◆" },
streaming: { color: TONE.brand, glyph: "◈" },
task: { color: TONE.warn, glyph: "▶" },
tool: { color: TONE.info, glyph: "▣" },
plan: { color: TONE.accent, glyph: "⊞" },
diff: { color: TONE.ok, glyph: "±" },
error: { color: TONE.err, glyph: "✖" },
warn: { color: TONE.warn, glyph: "⚠" },
usage: { color: FG.meta, glyph: "Σ" },
subagent: { color: TONE.violet, glyph: "⌬" },
approval: { color: TONE.warn, glyph: "?" },
search: { color: TONE.info, glyph: "⊙" },
memory: { color: FG.meta, glyph: "⌑" },
ctx: { color: TONE.brand, glyph: "◔" },
doctor: { color: FG.meta, glyph: "⚕" },
branch: { color: TONE.violet, glyph: "⎇" },
} as const;
function defineTheme(base: ThemeBase): ThemeTokens {
return { ...base, card: card(base.fg, base.tone) };
}
export type CardTone = keyof typeof CARD;
const githubDark = defineTheme({
fg: {
strong: "#e6edf3",
body: "#c9d1d9",
sub: "#8b949e",
meta: "#6e7681",
faint: "#484f58",
},
tone: {
brand: "#79c0ff",
accent: "#d2a8ff",
violet: "#b395f5",
ok: "#7ee787",
warn: "#f0b07d",
err: "#ff8b81",
info: "#79c0ff",
},
toneActive: {
brand: "#a5d6ff",
accent: "#e2c5ff",
violet: "#c8aaff",
ok: "#a8f5ad",
warn: "#ffc99e",
err: "#ffaba3",
info: "#a5d6ff",
},
surface: {
bg: "#0a0c10",
bgInput: "#0d1015",
bgCode: "#06080c",
bgElev: "#11141a",
},
});
const dark = defineTheme({
fg: {
strong: "#f4f7fb",
body: "#d8dee9",
sub: "#a7b1c2",
meta: "#778294",
faint: "#4d5666",
},
tone: {
brand: "#7dd3fc",
accent: "#c084fc",
violet: "#a78bfa",
ok: "#86efac",
warn: "#fbbf24",
err: "#f87171",
info: "#60a5fa",
},
toneActive: {
brand: "#bae6fd",
accent: "#e9d5ff",
violet: "#ddd6fe",
ok: "#bbf7d0",
warn: "#fde68a",
err: "#fecaca",
info: "#bfdbfe",
},
surface: {
bg: "#0b1020",
bgInput: "#111827",
bgCode: "#080c16",
bgElev: "#151d2f",
},
});
const light = defineTheme({
fg: {
strong: "#111827",
body: "#1f2937",
sub: "#4b5563",
meta: "#6b7280",
faint: "#9ca3af",
},
tone: {
brand: "#2563eb",
accent: "#7c3aed",
violet: "#6d28d9",
ok: "#15803d",
warn: "#b45309",
err: "#dc2626",
info: "#0369a1",
},
toneActive: {
brand: "#1d4ed8",
accent: "#6d28d9",
violet: "#5b21b6",
ok: "#166534",
warn: "#92400e",
err: "#b91c1c",
info: "#075985",
},
surface: {
bg: "#ffffff",
bgInput: "#f8fafc",
bgCode: "#f3f4f6",
bgElev: "#eef2f7",
},
});
const tokyoNight = defineTheme({
fg: {
strong: "#c0caf5",
body: "#a9b1d6",
sub: "#9aa5ce",
meta: "#565f89",
faint: "#414868",
},
tone: {
brand: "#7aa2f7",
accent: "#bb9af7",
violet: "#9d7cd8",
ok: "#9ece6a",
warn: "#e0af68",
err: "#f7768e",
info: "#2ac3de",
},
toneActive: {
brand: "#a9c7ff",
accent: "#d7b9ff",
violet: "#c6a0f6",
ok: "#b9f27c",
warn: "#ffd089",
err: "#ff9cac",
info: "#7dcfff",
},
surface: {
bg: "#1a1b26",
bgInput: "#1f2335",
bgCode: "#16161e",
bgElev: "#24283b",
},
});
const githubLight = defineTheme({
fg: {
strong: "#1f2328",
body: "#24292f",
sub: "#57606a",
meta: "#6e7781",
faint: "#8c959f",
},
tone: {
brand: "#0969da",
accent: "#8250df",
violet: "#6639ba",
ok: "#1a7f37",
warn: "#9a6700",
err: "#cf222e",
info: "#0969da",
},
toneActive: {
brand: "#0550ae",
accent: "#6639ba",
violet: "#512a97",
ok: "#116329",
warn: "#7d4e00",
err: "#a40e26",
info: "#0550ae",
},
surface: {
bg: "#ffffff",
bgInput: "#f6f8fa",
bgCode: "#f6f8fa",
bgElev: "#eaeef2",
},
});
const highContrast = defineTheme({
fg: {
strong: "#ffffff",
body: "#f5f5f5",
sub: "#d4d4d4",
meta: "#bdbdbd",
faint: "#8a8a8a",
},
tone: {
brand: "#00e5ff",
accent: "#ff4dff",
violet: "#b388ff",
ok: "#00ff66",
warn: "#ffdd00",
err: "#ff4d4d",
info: "#4da3ff",
},
toneActive: {
brand: "#80f2ff",
accent: "#ff99ff",
violet: "#d0b3ff",
ok: "#80ffb3",
warn: "#ffee80",
err: "#ff9999",
info: "#99c9ff",
},
surface: {
bg: "#000000",
bgInput: "#0a0a0a",
bgCode: "#050505",
bgElev: "#141414",
},
});
export const THEMES = {
default: githubDark,
dark,
light,
"tokyo-night": tokyoNight,
"github-dark": githubDark,
"github-light": githubLight,
"high-contrast": highContrast,
} as const satisfies Record<ThemeName, ThemeTokens>;
export const DEFAULT_THEME_NAME: ThemeName = "default";
export function isThemeName(value: string): value is ThemeName {
return Object.prototype.hasOwnProperty.call(THEMES, value);
}
export function resolveThemeName(value?: string | null): ThemeName {
if (!value || value === "auto") return DEFAULT_THEME_NAME;
return isThemeName(value) ? value : DEFAULT_THEME_NAME;
}
export function listThemeNames(): ThemeName[] {
return Object.keys(THEMES) as ThemeName[];
}
export function themeTokens(name?: string | null): ThemeTokens {
return THEMES[resolveThemeName(name)];
}
export const DEFAULT_THEME = THEMES[DEFAULT_THEME_NAME];
let activeTheme: ThemeTokens = DEFAULT_THEME;
let activeThemeVersion = 0;
export function setActiveTheme(theme: ThemeTokens): () => void {
const previousTheme = activeTheme;
activeTheme = theme;
activeThemeVersion += 1;
const version = activeThemeVersion;
return () => {
if (activeThemeVersion !== version || activeTheme !== theme) return;
activeTheme = previousTheme;
activeThemeVersion += 1;
};
}
function proxyTokens<T extends object>(select: (theme: ThemeTokens) => T): T {
const target = select(DEFAULT_THEME);
return new Proxy(target, {
get(_target, prop: string | symbol) {
return select(activeTheme)[prop as keyof T];
},
getOwnPropertyDescriptor(_target, prop: string | symbol) {
return Reflect.getOwnPropertyDescriptor(select(activeTheme), prop);
},
has(_target, prop: string | symbol) {
return prop in select(activeTheme);
},
ownKeys() {
return Reflect.ownKeys(select(activeTheme));
},
});
}
export const FG = proxyTokens((theme) => theme.fg);
export const TONE = proxyTokens((theme) => theme.tone);
export const TONE_ACTIVE = proxyTokens((theme) => theme.toneActive);
export const SURFACE = proxyTokens((theme) => theme.surface);
export const CARD = proxyTokens((theme) => theme.card);
export type CardTone = keyof ThemeTokens["card"];
/** DeepSeek prices in CNY; our internal table is USD divided by 7.2. Multiply back for display. */
export const USD_TO_CNY = 7.2;
+206 -58
View File
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import {
type DirEntry,
type FileWithStats,
type ParsedAtQuery,
detectAtPicker,
listFilesWithStatsAsync,
listDirectory,
parseAtQuery,
rankPickerCandidates,
walkFilesStream,
} from "../../at-mentions.js";
import {
type McpServerSummary,
@@ -26,6 +30,26 @@ export interface UseCompletionPickersParams {
slashUsage?: Readonly<Record<string, number>>;
}
export interface AtPickerEntry {
/** Basename — what the row leads with. */
label: string;
/** Path the picker substitutes into the buffer (no leading @). */
insertPath: string;
/** Dim suffix shown after the label ("src/auth/" for "src/auth/login.ts" search hits). Empty in browse mode. */
dirSuffix: string;
isDir: boolean;
}
export type AtPickerState =
| { kind: "browse"; baseDir: string; entries: readonly AtPickerEntry[]; loading: boolean }
| {
kind: "search";
filter: string;
entries: readonly AtPickerEntry[];
scanned: number;
searching: boolean;
};
export interface UseCompletionPickersResult {
// ── slash-name picker ──
slashMatches: SlashCommandSpec[] | null;
@@ -37,11 +61,10 @@ export interface UseCompletionPickersResult {
slashAdvancedHidden: number;
// ── @-mention picker ──
atPicker: ReturnType<typeof detectAtPicker>;
atMatches: readonly string[] | null;
atState: AtPickerState | null;
atSelected: number;
setAtSelected: React.Dispatch<React.SetStateAction<number>>;
pickAtMention: (chosenPath: string) => void;
pickAtMention: (entry: AtPickerEntry, action: "commit" | "drill") => void;
recordRecentFile: (path: string) => void;
// ── slash-arg picker ──
@@ -52,6 +75,10 @@ export interface UseCompletionPickersResult {
pickSlashArg: (chosen: string) => void;
}
const SEARCH_DEBOUNCE_MS = 80;
const SEARCH_FLUSH_MS = 50;
const SEARCH_RESULT_CAP = 200;
/** Picker priority: @ > slash-arg > slash-name. Detection already disambiguates by buffer shape. */
export function useCompletionPickers({
input,
@@ -83,36 +110,6 @@ export function useCompletionPickers({
// ── @-mention picker ──
const [atSelected, setAtSelected] = useState(0);
// Walk the code root asynchronously after first paint. Earlier
// versions used `listFilesWithStatsSync` inside `useMemo`, which
// blocked mount for 100-300ms on Windows monorepos (one statSync
// per file × 500 files). Async + parallel-stat-per-directory
// takes the cost off the critical path; the picker stays empty
// for the brief window before the walk completes (typically
// <200ms), and fills in atomically once Promise.all resolves.
// Files created mid-session via tool edits still won't appear
// until restart — rare; the user can type the full path.
const [atFiles, setAtFiles] = useState<readonly FileWithStats[]>([]);
useEffect(() => {
if (!codeMode) {
setAtFiles([]);
return;
}
let cancelled = false;
listFilesWithStatsAsync(rootDir, { maxResults: 500 })
.then((files) => {
if (!cancelled) setAtFiles(files);
})
.catch(() => {
if (!cancelled) setAtFiles([]);
});
return () => {
cancelled = true;
};
}, [codeMode, rootDir]);
// LRU of files touched by recent tool calls. Seeds the picker with
// "stuff I just looked at" at the top, so a user typing `@` in the
// same file they were just discussing gets it instantly.
const recentFilesRef = useRef<string[]>([]);
const recordRecentFile = useCallback((p: string) => {
const list = recentFilesRef.current;
@@ -121,32 +118,67 @@ export function useCompletionPickers({
list.unshift(p);
if (list.length > 20) list.length = 20;
}, []);
const atPicker = useMemo(() => {
if (!codeMode) return null;
// Slash prefix wins — avoids the picker confusingly surfacing on
// `/@wat`-style edge inputs.
if (slashMatches !== null) return null;
return detectAtPicker(input);
}, [codeMode, input, slashMatches]);
const atMatches = useMemo<readonly string[] | null>(() => {
if (!atPicker) return null;
return rankPickerCandidates(atFiles, atPicker.query, {
limit: 40,
recentlyUsed: recentFilesRef.current,
});
}, [atPicker, atFiles]);
const parsed = useMemo<ParsedAtQuery | null>(
() => (atPicker ? parseAtQuery(atPicker.query) : null),
[atPicker],
);
const atMode: "browse" | "search" | null = parsed
? parsed.trailingSlash || parsed.filter === ""
? "browse"
: "search"
: null;
const browseDir = parsed && atMode === "browse" ? parsed.dir : "";
const browse = useBrowseListing(rootDir, atMode === "browse" ? browseDir : null);
const search = useStreamingSearch(
rootDir,
atMode === "search" && parsed ? parsed.filter : null,
recentFilesRef,
);
const atState = useMemo<AtPickerState | null>(() => {
if (!parsed) return null;
if (atMode === "browse") {
return {
kind: "browse",
baseDir: browseDir,
entries: browse.entries,
loading: browse.loading,
};
}
return {
kind: "search",
filter: parsed.filter,
entries: search.entries,
scanned: search.scanned,
searching: search.searching,
};
}, [parsed, atMode, browseDir, browse, search]);
useEffect(() => {
setAtSelected((prev) => {
if (!atMatches || atMatches.length === 0) return 0;
if (prev >= atMatches.length) return atMatches.length - 1;
const len = atState?.entries.length ?? 0;
if (len === 0) return 0;
if (prev >= len) return len - 1;
return prev;
});
}, [atMatches]);
}, [atState]);
const pickAtMention = useCallback(
(chosenPath: string) => {
(entry: AtPickerEntry, action: "commit" | "drill") => {
if (!atPicker) return;
const before = input.slice(0, atPicker.atOffset);
setInput(`${before}@${chosenPath} `);
const tail =
action === "drill" && entry.isDir ? `${entry.insertPath}/` : `${entry.insertPath} `;
setInput(`${before}@${tail}`);
},
[atPicker, input, setInput],
);
@@ -163,10 +195,6 @@ export function useCompletionPickers({
const completer = slashArgContext.spec.argCompleter;
const partial = slashArgContext.partial;
const needle = partial.toLowerCase();
// Once the partial is an EXACT match for a valid completion, hide
// the picker so Enter submits the command instead of re-picking
// the same value in an infinite loop. Case-insensitive — users
// may type `/preset FAST` and still mean the same thing.
if (Array.isArray(completer)) {
if (partial && completer.some((v) => v.toLowerCase() === needle)) return null;
if (!partial) return completer.slice();
@@ -179,7 +207,6 @@ export function useCompletionPickers({
return all.filter((m) => m.toLowerCase().includes(needle)).slice(0, 40);
}
if (completer === "mcp-resources") {
// Aggregate URIs across every server's cached inspection.
const uris: string[] = [];
const servers = mcpServers ?? [];
for (const s of servers) {
@@ -214,8 +241,6 @@ export function useCompletionPickers({
(chosen: string) => {
if (!slashArgContext) return;
const before = input.slice(0, slashArgContext.partialOffset);
// No trailing space — enum picks take no further args, so the
// user presses Enter once more to run the command.
setInput(`${before}${chosen}`);
},
[slashArgContext, input, setInput],
@@ -227,8 +252,7 @@ export function useCompletionPickers({
setSlashSelected,
slashGroupMode,
slashAdvancedHidden,
atPicker,
atMatches,
atState,
atSelected,
setAtSelected,
pickAtMention,
@@ -240,3 +264,127 @@ export function useCompletionPickers({
pickSlashArg,
};
}
function useBrowseListing(rootDir: string, dir: string | null) {
const [entries, setEntries] = useState<readonly AtPickerEntry[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (dir === null) {
setEntries([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
listDirectory(rootDir, dir).then(
(raw) => {
if (cancelled) return;
setEntries(raw.map(toBrowseEntry));
setLoading(false);
},
() => {
if (cancelled) return;
setEntries([]);
setLoading(false);
},
);
return () => {
cancelled = true;
};
}, [rootDir, dir]);
return { entries, loading };
}
function toBrowseEntry(d: DirEntry): AtPickerEntry {
return { label: d.name, insertPath: d.path, dirSuffix: "", isDir: d.isDir };
}
function useStreamingSearch(
rootDir: string,
filter: string | null,
recentFilesRef: React.RefObject<string[]>,
) {
const [, bumpRender] = useReducer((x: number) => x + 1, 0);
const hitsRef = useRef<FileWithStats[]>([]);
const scannedRef = useRef(0);
const searchingRef = useRef(false);
const rankedRef = useRef<readonly AtPickerEntry[]>([]);
useEffect(() => {
if (filter === null) {
hitsRef.current = [];
scannedRef.current = 0;
searchingRef.current = false;
rankedRef.current = [];
bumpRender();
return;
}
hitsRef.current = [];
scannedRef.current = 0;
searchingRef.current = true;
rankedRef.current = [];
bumpRender();
const ac = new AbortController();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleFlush = () => {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
rankedRef.current = rankSearchHits(hitsRef.current, filter, recentFilesRef.current ?? []);
bumpRender();
}, SEARCH_FLUSH_MS);
};
const debounce = setTimeout(() => {
walkFilesStream(rootDir, {
signal: ac.signal,
onEntry: (e) => {
hitsRef.current.push(e);
if (hitsRef.current.length >= SEARCH_RESULT_CAP * 8) return false;
scheduleFlush();
},
onProgress: (n) => {
scannedRef.current = n;
scheduleFlush();
},
}).then(() => {
searchingRef.current = false;
rankedRef.current = rankSearchHits(hitsRef.current, filter, recentFilesRef.current ?? []);
bumpRender();
});
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(debounce);
if (flushTimer) clearTimeout(flushTimer);
ac.abort();
};
}, [rootDir, filter, recentFilesRef]);
return {
entries: rankedRef.current,
scanned: scannedRef.current,
searching: searchingRef.current,
};
}
function rankSearchHits(
hits: readonly FileWithStats[],
filter: string,
recent: readonly string[],
): readonly AtPickerEntry[] {
const ranked = rankPickerCandidates(hits, filter, {
limit: SEARCH_RESULT_CAP,
recentlyUsed: recent,
});
return ranked.map((path) => {
const slash = path.lastIndexOf("/");
return {
label: slash >= 0 ? path.slice(slash + 1) : path,
insertPath: path,
dirSuffix: slash >= 0 ? `${path.slice(0, slash)}/` : "",
isDir: false,
};
});
}
+37
View File
@@ -3,6 +3,7 @@
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { type ThemeName, isThemeName, resolveThemeName } from "./cli/ui/theme/tokens.js";
import type { LanguageCode } from "./i18n/types.js";
import {
type IndexUserConfig,
@@ -80,7 +81,9 @@ export interface ReasonixConfig {
preset?: PresetName;
editMode?: EditMode;
editModeHintShown?: boolean;
mouseClipboardHintShown?: boolean;
reasoningEffort?: ReasoningEffort;
theme?: ThemeName | "auto";
/** Stored as `--mcp`-format strings so one parser handles both flag and config. */
mcp?: string[];
/** Names of servers in `mcp` to skip on bridge — see `/mcp disable <name>`. */
@@ -269,12 +272,38 @@ export function editModeHintShown(path: string = defaultConfigPath()): boolean {
return readConfig(path).editModeHintShown === true;
}
/** True when the mouse-tracking + clipboard tip has been shown. */
export function mouseClipboardHintShown(path: string = defaultConfigPath()): boolean {
return readConfig(path).mouseClipboardHintShown === true;
}
/** Unknown / missing fall back to "max" so hand-edited bad config can't silently override the default. */
export function loadReasoningEffort(path: string = defaultConfigPath()): ReasoningEffort {
const v = readConfig(path).reasoningEffort;
return v === "high" ? "high" : "max";
}
export function loadTheme(path: string = defaultConfigPath()): ThemeName | "auto" | undefined {
const value = readConfig(path).theme;
if (value === "auto") return "auto";
if (typeof value === "string" && isThemeName(value)) return value;
return undefined;
}
export function resolveThemePreference(
configTheme: ThemeName | "auto" | undefined,
envTheme?: string | null,
): ThemeName {
if (configTheme && configTheme !== "auto") return configTheme;
return resolveThemeName(envTheme);
}
export function saveTheme(theme: ThemeName | "auto", path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
cfg.theme = theme;
writeConfig(cfg, path);
}
/** Persist the reasoning_effort cap so `/effort high` survives a relaunch. */
export function saveReasoningEffort(
effort: ReasoningEffort,
@@ -373,6 +402,14 @@ export function markEditModeHintShown(path: string = defaultConfigPath()): void
writeConfig(cfg, path);
}
/** Mark the mouse + clipboard tip as shown. */
export function markMouseClipboardHintShown(path: string = defaultConfigPath()): void {
const cfg = readConfig(path);
if (cfg.mouseClipboardHintShown === true) return;
cfg.mouseClipboardHintShown = true;
writeConfig(cfg, path);
}
export function isPlausibleKey(key: string): boolean {
const trimmed = key.trim();
return /^sk-[A-Za-z0-9_-]{16,}$/.test(trimmed);
+161 -3
View File
@@ -59,8 +59,101 @@ export const EN: TranslationSchema = {
restoredEdits:
"▸ restored {count} pending edit block(s) from an interrupted prior run — /apply to commit or /discard to drop.",
resumedPlan: "Resumed plan · {when}{summary}",
tipEditBindings:
"▸ TIP: edit-gate keybindings\n y / n accept or drop pending edits\n Shift+Tab switch review ↔ AUTO (persisted; AUTO applies instantly)\n u undo the last auto-applied batch (within the 5s banner)\n Current mode is shown in the bottom status bar. Run /keys anytime for the full list.\n (This tip shows once — suppressed after.)",
tipEditBindings: {
topic: "edit-gate keybindings",
sections: [
{
rows: [
{ key: "y / n", text: "accept or drop pending edits" },
{
key: "Shift+Tab",
text: "switch review ↔ AUTO (persisted; AUTO applies instantly)",
},
{ key: "u", text: "undo the last auto-applied batch (within the 5s banner)" },
],
},
],
footer: "Current mode shown in the bottom status bar · /keys for the full reference",
},
tipMouseClipboard: {
topic: "mouse + clipboard",
sections: [
{
rows: [
{ key: "wheel", text: "scrolls the chat history above" },
{
key: "right-click",
text: "captured by the app — use Ctrl+V (Win/Linux) or Cmd+V (macOS) to paste",
},
{
key: "Shift + drag",
text: "selects text natively (Option on iTerm2, Shift on Win Term / Alacritty / WezTerm)",
},
],
},
],
footer: "Run /keys for the full keyboard + mouse reference",
},
keysReference: {
topic: "Reasonix keys + mouse reference",
sections: [
{
title: "keyboard",
rows: [
{ key: "Enter", text: "submit the prompt" },
{ key: "Shift+Enter", text: "insert a newline in the prompt" },
{ key: "Ctrl+P / Ctrl+N", text: "recall previous / next prompt from history" },
{ key: "Ctrl+A / Ctrl+E", text: "jump to start / end of the current line" },
{ key: "Ctrl+W", text: "delete the word before the cursor" },
{ key: "Ctrl+U", text: "clear the entire prompt buffer" },
{ key: "Tab", text: "complete @-mention · drill folder · accept slash command" },
{ key: "Shift+Tab", text: "edit-gate: toggle review ↔ AUTO mode" },
{ key: "Esc", text: "dismiss picker · abort the running model turn" },
{ key: "Ctrl+C", text: "abort the running model turn (NOT copy — see clipboard)" },
{
key: "↑ / ↓",
text: "scroll chat history (PromptInput cursor when buffer non-empty)",
},
{ key: "PgUp / PgDn", text: "scroll chat history a page at a time" },
{ key: "End", text: "jump chat to the most recent line" },
],
},
{
title: "mouse",
rows: [
{ key: "wheel", text: "scrolls the chat history" },
{ key: "right-click", text: "captured by the app — use Ctrl+V / Cmd+V to paste" },
{ key: "left-click", text: "captured (no action yet — reserved for future use)" },
],
},
{
title: "copy / paste",
rows: [
{ key: "select text", text: "hold Shift while dragging (Option on iTerm2)" },
{
key: "copy",
text: "Ctrl+Shift+C (Win/Linux) · Cmd+C (macOS) — terminal-native after selection",
},
{ key: "paste", text: "Ctrl+V or Ctrl+Shift+V (Win/Linux) · Cmd+V (macOS)" },
{
key: "bracketed paste",
text: "multi-line pastes stay one block — no auto-submit on intermediate newlines",
},
],
},
{
title: "edit-gate (code mode)",
rows: [
{ key: "y / n", text: "accept or drop pending edits in the review modal" },
{ key: "Shift+Tab", text: "toggle review ↔ AUTO (persisted across sessions)" },
{ key: "u", text: "undo the last auto-applied batch (within the 5s banner)" },
],
},
],
footer:
"Mouse tracking is on so the wheel scrolls chat instead of moving the cursor — that's why right-click no longer does the terminal's native paste.",
},
tipShownOnce: "shown once",
modelOverride: "override the default model",
noSession: "disable session persistence for this run",
resumeHint: "force-resume the named session (even if idle)",
@@ -190,7 +283,7 @@ export const EN: TranslationSchema = {
"shrink oversized tool results AND tool-call args (edit_file search/replace) in the log; cap in tokens, default 4000",
argsHint: "[tokens]",
},
keys: { description: "show all keyboard shortcuts and prompt prefixes" },
keys: { description: "keyboard + mouse + copy/paste reference" },
plans: { description: "list this session's active + archived plans, newest first" },
replay: {
description: "load an archived plan as a read-only Time Travel snapshot (default: newest)",
@@ -299,6 +392,70 @@ export const EN: TranslationSchema = {
selectFooter: "[↑↓] navigate · [Enter] confirm · [Esc] cancel",
stepCounter: "Step {step}/{total} · ",
},
planFlow: {
approveCardTitle: "Approve plan",
approveCardMetaRight: "awaiting",
openQuestionsBanner:
"▲ the plan flags open questions or risks — pick {refine} to write concrete answers before the model moves on.",
openQuestionsHeader: "Open questions / risks",
truncatedBodyMore: "… {n} more line above in scrollback",
truncatedBodyMorePlural: "… {n} more lines above in scrollback",
picker: {
accept: "accept",
acceptHint: "run it now, in order",
refine: "refine",
refineHint: "give the agent more guidance, draft a new plan",
revise: "revise",
reviseHint: "edit the plan inline before running (skip / reorder steps)",
reject: "reject",
rejectHint: "discard, agent will retry from scratch",
},
refineFooter: "⏎ send · esc return to picker",
refineQuestionsHeading: "Answer these or describe the change you want:",
modes: {
approve: {
title: "approving — any last instructions?",
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?",
hint: "Describe what's wrong or missing, or answer questions the plan raised.",
blankHint: " (Enter with blank = let the model pick safe defaults for any open questions.)",
},
reject: {
title: "rejecting — tell the model why (optional)",
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?",
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",
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.)",
},
},
checkpoint: {
title: "Checkpoint — step done",
continue: "Continue — run the next step",
continueHint: "Model resumes with the next step.",
revise: "Revise — give feedback before the next step",
reviseHint: "Stay paused, type guidance; model adjusts the remaining plan.",
stop: "Stop — end the plan here",
stopHint: "Model summarizes what was done and ends.",
},
stepList: {
counter: "{total} steps",
counterSingular: "{total} step",
counterDone: "{done}/{total} done ({pct}%) · {total} steps",
counterDoneSingular: "{done}/{total} done ({pct}%) · {total} step",
},
},
app: {
walkCancelledRemaining: "▸ walk cancelled — {count} block(s) still pending.",
walkCancelled: "▸ walk cancelled.",
@@ -470,6 +627,7 @@ export const EN: TranslationSchema = {
"no active loop. Start one with `/loop <interval> <prompt>` (e.g. /loop 30s npm test).\nCancels on: /loop stop · Esc · /clear /new · any user-typed prompt.",
loopStarted:
'▸ loop started — re-submitting "{prompt}" every {duration}. Type anything (or /loop stop) to cancel.',
keysNeedsTui: "/keys needs a TUI context (postKeys wired).",
},
admin: {
doctorNeedsTui: "/doctor needs a TUI context (postDoctor wired).",
+18
View File
@@ -56,6 +56,24 @@ export function getSupportedLanguages(): LanguageCode[] {
return Object.keys(translations) as LanguageCode[];
}
/** Returns a structured (non-string) translation entry — for tables / row objects passed to TipCard etc. */
export function tObj<T>(path: string): T {
const parts = path.split(".");
let val: unknown = translations[currentLang] || translations.EN;
for (const part of parts) {
val = (val as Record<string, unknown> | undefined)?.[part];
if (val === undefined) break;
}
if (val === undefined && currentLang !== "EN") {
val = translations.EN;
for (const part of parts) {
val = (val as Record<string, unknown> | undefined)?.[part];
if (val === undefined) break;
}
}
return val as T;
}
/** Simple t() — nested keys (e.g. "common.error") + param replacement (e.g. "{code}"). */
export function t(path: string, params?: Record<string, string | number>): string {
const parts = path.split(".");
+67 -1
View File
@@ -53,7 +53,31 @@ export interface TranslationSchema {
ephemeralSession: string;
restoredEdits: string;
resumedPlan: string;
tipEditBindings: string;
tipEditBindings: {
topic: string;
sections: ReadonlyArray<{
title?: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer: string;
};
tipMouseClipboard: {
topic: string;
sections: ReadonlyArray<{
title?: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer: string;
};
keysReference: {
topic: string;
sections: ReadonlyArray<{
title: string;
rows: ReadonlyArray<{ key: string; text: string }>;
}>;
footer: string;
};
tipShownOnce: string;
modelOverride: string;
noSession: string;
resumeHint: string;
@@ -251,4 +275,46 @@ export interface TranslationSchema {
selectFooter: string;
stepCounter: string;
};
planFlow: {
approveCardTitle: string;
approveCardMetaRight: string;
openQuestionsBanner: string;
openQuestionsHeader: string;
truncatedBodyMore: string;
truncatedBodyMorePlural: string;
picker: {
accept: string;
acceptHint: string;
refine: string;
refineHint: string;
revise: string;
reviseHint: string;
reject: string;
rejectHint: string;
};
refineFooter: string;
refineQuestionsHeading: string;
modes: {
approve: { title: string; hint: string; blankHint: string };
refine: { title: string; hint: string; blankHint: string };
reject: { title: string; hint: string; blankHint: string };
"checkpoint-revise": { title: string; hint: string; blankHint: string };
"choice-custom": { title: string; hint: string; blankHint: string };
};
checkpoint: {
title: string;
continue: string;
continueHint: string;
revise: string;
reviseHint: string;
stop: string;
stopHint: string;
};
stepList: {
counter: string;
counterSingular: string;
counterDone: string;
counterDoneSingular: string;
};
};
}
+154 -3
View File
@@ -57,8 +57,95 @@ export const zhCN: TranslationSchema = {
restoredEdits:
"▸ 从中断的运行中恢复了 {count} 个待处理的编辑块 — /apply 提交或 /discard 放弃。",
resumedPlan: "已恢复计划 · {when}{summary}",
tipEditBindings:
"▸ 提示:编辑门控快捷键\n y / n 接受或放弃待处理的编辑\n Shift+Tab 切换 预览 ↔ 自动 (持久化;自动模式立即应用)\n u 撤消上次自动应用的批处理 (在 5 秒横幅内)\n 当前模式显示在底部状态栏。随时运行 /keys 查看完整列表。\n (此提示仅显示一次 — 之后将隐藏。)",
tipEditBindings: {
topic: "编辑门控快捷键",
sections: [
{
rows: [
{ key: "y / n", text: "接受或放弃待处理的编辑" },
{ key: "Shift+Tab", text: "切换 预览 ↔ 自动(持久化;自动模式立即应用)" },
{ key: "u", text: "撤销上次自动应用的批处理(5 秒横幅内)" },
],
},
],
footer: "当前模式显示在底部状态栏 · /keys 查看完整快捷键参考",
},
tipMouseClipboard: {
topic: "鼠标 + 剪贴板",
sections: [
{
rows: [
{ key: "滚轮", text: "滚动上方的聊天记录" },
{
key: "右键",
text: "已被应用接管 — 用 Ctrl+V(Win/Linux)或 Cmd+V(macOS)粘贴",
},
{
key: "Shift + 拖动",
text: "原生选中文本(iTerm2 用 Option,Win Term / Alacritty / WezTerm 用 Shift)",
},
],
},
],
footer: "运行 /keys 查看完整键盘 + 鼠标参考",
},
keysReference: {
topic: "Reasonix 键盘 + 鼠标参考",
sections: [
{
title: "键盘",
rows: [
{ key: "Enter", text: "提交输入" },
{ key: "Shift+Enter", text: "在输入框中插入换行" },
{ key: "Ctrl+P / Ctrl+N", text: "调出上一条 / 下一条历史提示" },
{ key: "Ctrl+A / Ctrl+E", text: "跳到当前行的开头 / 结尾" },
{ key: "Ctrl+W", text: "删除光标前的一个词" },
{ key: "Ctrl+U", text: "清空整个输入缓冲区" },
{ key: "Tab", text: "补全 @-mention · 进入文件夹 · 接受 slash 命令" },
{ key: "Shift+Tab", text: "编辑门控:切换 预览 ↔ 自动 模式" },
{ key: "Esc", text: "关闭弹出选择器 · 中止当前模型回合" },
{ key: "Ctrl+C", text: "中止当前模型回合(不是复制 — 见剪贴板段)" },
{ key: "↑ / ↓", text: "滚动聊天记录(缓冲区非空时为光标移动)" },
{ key: "PgUp / PgDn", text: "整页滚动聊天记录" },
{ key: "End", text: "跳到聊天的最新一行" },
],
},
{
title: "鼠标",
rows: [
{ key: "滚轮", text: "滚动聊天记录" },
{ key: "右键", text: "被应用接管 — 用 Ctrl+V / Cmd+V 粘贴" },
{ key: "左键", text: "被接管(暂无动作 — 预留给后续功能)" },
],
},
{
title: "复制 / 粘贴",
rows: [
{ key: "选中文字", text: "拖动时按住 Shift(iTerm2 用 Option)" },
{
key: "复制",
text: "Ctrl+Shift+C(Win/Linux)· Cmd+C(macOS)— 选中后由终端原生处理",
},
{ key: "粘贴", text: "Ctrl+V 或 Ctrl+Shift+V(Win/Linux)· Cmd+V(macOS)" },
{
key: "bracketed paste",
text: "多行粘贴整体进入 — 中间换行不会触发提交",
},
],
},
{
title: "编辑门控(仅 code 模式)",
rows: [
{ key: "y / n", text: "在预览模态中接受或放弃待处理的编辑" },
{ key: "Shift+Tab", text: "切换 预览 ↔ 自动(持久化)" },
{ key: "u", text: "撤销上次自动应用的批处理(5 秒横幅内)" },
],
},
],
footer:
"鼠标追踪已开启,滚轮用来滚动聊天而不是移动光标 — 这也是右键不再触发终端原生粘贴的原因。",
},
tipShownOnce: "仅显示一次",
modelOverride: "覆盖默认模型",
noSession: "禁用本次运行的会话持久化",
resumeHint: "强制恢复指定会话(即使空闲)",
@@ -185,7 +272,7 @@ export const zhCN: TranslationSchema = {
description: "缩小日志中过大的工具结果和工具调用参数;上限为 tokens,默认 4000",
argsHint: "[tokens]",
},
keys: { description: "显示所有键盘快捷键和提示前缀" },
keys: { description: "键盘 + 鼠标 + 复制粘贴参考" },
plans: { description: "列出此会话的活跃 + 归档计划(最新在前)" },
replay: {
description: "加载归档计划为只读的时间旅行快照(默认:最新)",
@@ -292,6 +379,69 @@ export const zhCN: TranslationSchema = {
selectFooter: "[↑↓] 移动 · [Enter] 确认 · [Esc] 取消",
stepCounter: "步骤 {step}/{total} · ",
},
planFlow: {
approveCardTitle: "确认计划",
approveCardMetaRight: "等待中",
openQuestionsBanner:
"▲ 计划中标记了待确认的问题或风险 —— 请选 {refine} 给出明确答案,再让模型继续。",
openQuestionsHeader: "待确认 / 风险",
truncatedBodyMore: "… 还有 {n} 行在上方滚动历史中",
truncatedBodyMorePlural: "… 还有 {n} 行在上方滚动历史中",
picker: {
accept: "采纳",
acceptHint: "立即按顺序执行",
refine: "细化",
refineHint: "给模型更多指引,重新出一版计划",
revise: "改写",
reviseHint: "在执行前就地编辑计划(跳过 / 重排步骤)",
reject: "驳回",
rejectHint: "丢弃,让模型从头再来",
},
refineFooter: "⏎ 发送 · esc 返回选项",
refineQuestionsHeading: "回答以下问题,或直接说明你想要的修改:",
modes: {
approve: {
title: "采纳 —— 还有补充指示吗?",
hint: "回答计划中的问题、补充约束,或直接回车按现状采纳。",
blankHint: "(留空回车 = 不附加指示直接采纳。)",
},
refine: {
title: "细化 —— 模型应该改什么?",
hint: "说明问题在哪、缺什么,或者回答计划提出的疑问。",
blankHint: "(留空回车 = 让模型对所有待确认问题选用安全默认。)",
},
reject: {
title: "驳回 —— 告诉模型原因(可选)",
hint: "说明模型对你的目标理解错在哪里,或你真正想要什么。",
blankHint: "(留空回车 = 不解释直接取消;模型会反过来问你想要什么。)",
},
"checkpoint-revise": {
title: "改写 —— 下一步前要调整什么?",
hint: "范围调整、跳过步骤、换个思路 —— 模型会据此修改剩余步骤。",
blankHint: "(留空回车 = 按当前计划继续。)",
},
"choice-custom": {
title: "自定义回答 —— 想说什么都行",
hint: "自由文本。模型会原样读取并继续 —— 不必匹配候选项。",
blankHint: "(留空回车 = 让模型反过来问你想要什么。)",
},
},
checkpoint: {
title: "检查点 —— 当前步骤已完成",
continue: "继续 —— 执行下一步",
continueHint: "模型从下一步继续。",
revise: "调整 —— 在下一步前给反馈",
reviseHint: "先暂停,输入指引;模型会调整剩余计划。",
stop: "停止 —— 在此结束计划",
stopHint: "模型总结已完成的工作并结束。",
},
stepList: {
counter: "{total} 个步骤",
counterSingular: "{total} 个步骤",
counterDone: "{done}/{total} 已完成({pct}%) · 共 {total} 步",
counterDoneSingular: "{done}/{total} 已完成({pct}%) · 共 {total} 步",
},
},
app: {
walkCancelledRemaining: "▸ 浏览已取消 — 还有 {count} 个待处理编辑块。",
walkCancelled: "▸ 浏览已取消。",
@@ -448,6 +598,7 @@ export const zhCN: TranslationSchema = {
"没有活动的循环。使用 `/loop <interval> <prompt>` 启动一个(例如 /loop 30s npm test)。\n取消方式:/loop stop · Esc · /clear /new · 任何用户输入的提示。",
loopStarted:
'▸ 循环已启动 — 每 {duration} 重新提交 "{prompt}"。输入任何内容(或 /loop stop)取消。',
keysNeedsTui: "/keys 需要 TUI 上下文(postKeys 已连接)。",
},
admin: {
doctorNeedsTui: "/doctor 需要 TUI 上下文(postDoctor 已连接)。",
+7
View File
@@ -19,18 +19,25 @@ export {
DEFAULT_PICKER_IGNORE_DIRS,
detectAtPicker,
expandAtMentions,
listDirectory,
listFilesSync,
listFilesWithStatsAsync,
listFilesWithStatsSync,
parseAtQuery,
rankPickerCandidates,
walkFilesStream,
} from "./at-mentions.js";
export type {
AtMentionExpansion,
AtMentionOptions,
DirEntry,
FileWithStats,
ListDirectoryOptions,
ListFilesOptions,
ParsedAtQuery,
PickerCandidate,
RankPickerOptions,
StreamWalkOptions,
} from "./at-mentions.js";
export type {
CacheFirstLoopOptions,
+10 -1
View File
@@ -135,6 +135,15 @@ export class SkillStore {
/** Scaffold a new skill stub at the chosen scope. Refuses to overwrite. */
create(name: string, scope: "project" | "global"): { path: string } | { error: string } {
return this.createWithContent(name, scope, skillStubBody(name));
}
/** Like `create` but writes caller-supplied file contents instead of the stub — used by the scaffold tool. */
createWithContent(
name: string,
scope: "project" | "global",
content: string,
): { path: string } | { error: string } {
if (!isValidSkillName(name)) {
return { error: `invalid skill name: "${name}" — use letters, digits, _, -, .` };
}
@@ -152,7 +161,7 @@ export class SkillStore {
}
mkdirSync(dirname(flat), { recursive: true });
try {
writeFileSync(flat, skillStubBody(name), { encoding: "utf8", flag: "wx" });
writeFileSync(flat, content, { encoding: "utf8", flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
return { error: `skill "${name}" already exists at ${flat}` };
+1 -1
View File
@@ -77,7 +77,7 @@ function buildByteToChar(): string[] {
let cached: LoadedTokenizer | null = null;
/** Two ../data candidates needed: dist/index.js AND dist/cli/index.js resolve to different roots. */
function resolveDataPath(): string {
export function resolveDataPath(): string {
if (process.env.REASONIX_TOKENIZER_PATH) return process.env.REASONIX_TOKENIZER_PATH;
const candidates: string[] = [];
try {
+28 -6
View File
@@ -47,12 +47,20 @@ export type ToolInterceptor = (
args: Record<string, unknown>,
) => string | null | undefined | Promise<string | null | undefined>;
/** Final-stage post-processor — runs on every dispatch return (success and error paths) so callers can append context like a remaining-budget hint. Whatever it returns becomes the dispatch result. */
export type ToolResultAugmenter = (
name: string,
args: Record<string, unknown>,
result: string,
) => string;
export class ToolRegistry {
private readonly _tools = new Map<string, InternalTool>();
private readonly _autoFlatten: boolean;
private _planMode = false;
private _interceptor: ToolInterceptor | null = null;
private _auditListener: ToolCallAuditListener | null = null;
private _resultAugmenter: ToolResultAugmenter | null = null;
constructor(opts: ToolRegistryOptions = {}) {
this._autoFlatten = opts.autoFlatten !== false;
@@ -77,6 +85,11 @@ export class ToolRegistry {
this._auditListener = fn;
}
/** Final-stage post-processor; replaces previous augmenter when called twice. Pass null to clear. */
setResultAugmenter(fn: ToolResultAugmenter | null): void {
this._resultAugmenter = fn;
}
register<A, R>(def: ToolDefinition<A, R>): this {
if (!def.name) throw new Error("tool requires a name");
const internal: InternalTool = { ...(def as ToolDefinition) };
@@ -192,6 +205,7 @@ export class ToolRegistry {
}
}
let finalResult: string;
try {
try {
this._auditListener?.({ name, args });
@@ -219,7 +233,7 @@ export class ToolRegistry {
if (opts.maxResultChars !== undefined) {
clipped = truncateForModel(clipped, opts.maxResultChars);
}
return clipped;
finalResult = clipped;
} catch (err) {
const e = err as Error & { toToolResult?: () => unknown };
// Errors may opt into a richer tool-result shape by implementing
@@ -229,15 +243,23 @@ export class ToolRegistry {
// but keeping payloads structured is cleaner for UI parsing).
if (typeof e.toToolResult === "function") {
try {
return JSON.stringify(e.toToolResult());
finalResult = JSON.stringify(e.toToolResult());
} catch {
/* fall through to the default shape */
finalResult = JSON.stringify({ error: `${e.name}: ${e.message}` });
}
} else {
finalResult = JSON.stringify({ error: `${e.name}: ${e.message}` });
}
return JSON.stringify({
error: `${e.name}: ${e.message}`,
});
}
if (this._resultAugmenter) {
try {
return this._resultAugmenter(name, args, finalResult);
} catch {
/* augmenter must never break the tool result */
}
}
return finalResult;
}
}
+59 -4
View File
@@ -29,6 +29,50 @@ const DEFAULT_MAX_LIST_BYTES = 256 * 1024;
const DEFAULT_AUTO_PREVIEW_LINES = 200;
const AUTO_PREVIEW_HEAD_LINES = 80;
const AUTO_PREVIEW_TAIL_LINES = 40;
const OUTLINE_MAX_ENTRIES = 30;
const OUTLINE_TAIL_KEEP = 5;
type OutlineEntry = { line: number; kind: string; name: string };
const TS_EXPORT_RE =
/^export\s+(?:default\s+)?(?:async\s+)?(function|class|const|let|var|interface|type|enum)\s+\*?\s*(\w+)/;
function extractTsExportOutline(lines: readonly string[]): OutlineEntry[] {
const out: OutlineEntry[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (!line.startsWith("export ")) continue;
const m = TS_EXPORT_RE.exec(line);
if (!m) continue;
out.push({ line: i + 1, kind: m[1]!, name: m[2]! });
}
return out;
}
function formatOutline(entries: readonly OutlineEntry[]): string {
const total = entries.length;
if (total === 0) return "";
const lastEntry = entries[total - 1]!;
const width = String(lastEntry.line).length;
const fmt = (e: OutlineEntry) =>
` L${String(e.line).padStart(width, " ")} export ${e.kind} ${e.name}`;
const header = `[outline: ${total} top-level export${total === 1 ? "" : "s"}]`;
if (total <= OUTLINE_MAX_ENTRIES) {
return [header, ...entries.map(fmt)].join("\n");
}
const headCount = OUTLINE_MAX_ENTRIES - OUTLINE_TAIL_KEEP;
const headEntries = entries.slice(0, headCount);
const tailEntries = entries.slice(-OUTLINE_TAIL_KEEP);
const omitted = total - OUTLINE_MAX_ENTRIES;
const gapStart = headEntries[headEntries.length - 1]!.line;
const gapEnd = tailEntries[0]!.line;
return [
header,
...headEntries.map(fmt),
` [… ${omitted} more export${omitted === 1 ? "" : "s"} between L${gapStart} and L${gapEnd} …]`,
...tailEntries.map(fmt),
].join("\n");
}
/** Skipped unless `include_deps:true` — shared with the semantic indexer via DEFAULT_INDEX_EXCLUDES. */
const SKIP_DIR_NAMES: ReadonlySet<string> = new Set(DEFAULT_INDEX_EXCLUDES.dirs);
@@ -110,7 +154,7 @@ export function registerFilesystemTools(
- head: N → first N lines (imports, public API, small configs)
- tail: N → last N lines (recently-added code, log tails)
- range: "A-B" → inclusive line range A..B, 1-indexed (e.g. "120-180" around an edit site)
When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_LINES} lines, the tool auto-returns a head+tail preview with an "N lines omitted" marker rather than dumping everything. If you need the middle, re-call with a range. Prefer search_content to locate a symbol first, then read_file with a range around the hit — one scoped read beats three full-file reads.`,
When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_LINES} lines, the tool auto-returns a head+tail preview with an "N lines omitted" marker, plus a top-level export outline (function / class / const / interface / type / enum names with line numbers, capped at ${OUTLINE_MAX_ENTRIES}) so you can pick a smart range without a follow-up grep. If you need the middle, re-call with a range. Prefer search_content to locate a symbol first only when the outline doesn't have what you want — one scoped read beats three full-file reads.`,
readOnly: true,
stormExempt: true,
parameters: {
@@ -195,12 +239,17 @@ When none of these is given AND the file is longer than ${DEFAULT_AUTO_PREVIEW_L
const head = lines.slice(0, AUTO_PREVIEW_HEAD_LINES).join("\n");
const tail = lines.slice(totalLines - AUTO_PREVIEW_TAIL_LINES).join("\n");
const omitted = totalLines - AUTO_PREVIEW_HEAD_LINES - AUTO_PREVIEW_TAIL_LINES;
return [
const outline = formatOutline(extractTsExportOutline(lines));
const parts: string[] = [
`[auto-preview: head ${AUTO_PREVIEW_HEAD_LINES} + tail ${AUTO_PREVIEW_TAIL_LINES} of ${totalLines} lines]`,
head,
];
if (outline) parts.push("", outline);
parts.push(
`\n[… ${omitted} lines omitted — call read_file again with range:"A-B" (1-indexed) or head / tail to get the middle]\n`,
tail,
].join("\n");
);
return parts.join("\n");
},
});
@@ -352,7 +401,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
name: "search_content",
parallelSafe: true,
description:
"Recursively grep file CONTENTS for a substring or regex. This is the right tool for 'find all places that call X', 'where is Y referenced', 'what files contain Z'. Different from search_files (which matches FILE NAMES). Returns one match per line in 'path:line: text' format. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) and binary files by default.",
"Recursively grep file CONTENTS for a substring or regex. This is the right tool for 'find all places that call X', 'where is Y referenced', 'what files contain Z'. Different from search_files (which matches FILE NAMES). Returns one match per line in 'path:line: text' format. Per-file hits are capped at 30 (a footer reports any extras); when the byte budget is mostly spent the remaining files switch to a 'rel: N matches' histogram so distribution stays visible instead of one popular file drowning the rest. Pass `summary_only:true` to skip line content entirely and get just the histogram. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) and binary files by default.",
readOnly: true,
parameters: {
type: "object",
@@ -384,6 +433,11 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
description:
"Lines of context to show around each match (both before and after). Default 0 (just the matching line). Capped at 20. Output uses ripgrep style: `:` after the line number on the matching line, `-` on context lines, `--` separating non-adjacent windows.",
},
summary_only: {
type: "boolean",
description:
"When true, skip line content and return one 'rel: N matches' line per matching file. Use for 'where does this exist at all' questions before drilling in with a targeted read_file.",
},
},
required: ["pattern"],
},
@@ -395,6 +449,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
case_sensitive?: boolean;
include_deps?: boolean;
context?: number;
summary_only?: boolean;
},
toolCtx,
) =>
+65 -19
View File
@@ -67,6 +67,11 @@ export async function searchFiles(
return matches.length === 0 ? "(no matches)" : matches.join("\n");
}
/** Per-file printed-hit cap; beyond this we emit a "N more matches in this file" footer. */
const MAX_HITS_PER_FILE = 30;
/** Once printed bytes pass this fraction of the byte budget, remaining files switch to histogram. */
const SUMMARY_MODE_TRIGGER_RATIO = 0.8;
export async function searchContent(
ctx: SearchContext,
startAbs: string,
@@ -75,6 +80,8 @@ export async function searchContent(
case_sensitive?: boolean;
include_deps?: boolean;
context?: number;
/** Skip line content; return only "rel: N matches" per file. */
summary_only?: boolean;
signal?: AbortSignal;
},
): Promise<string> {
@@ -82,6 +89,7 @@ export async function searchContent(
const caseSensitive = args.case_sensitive === true;
const includeDeps = args.include_deps === true;
const ctxLines = Math.max(0, Math.min(20, Math.floor(args.context ?? 0)));
const summaryOnly = args.summary_only === true;
let re: RegExp | null = null;
try {
re = new RegExp(args.pattern, caseSensitive ? "" : "i");
@@ -93,6 +101,9 @@ export async function searchContent(
let totalBytes = 0;
let scanned = 0;
let truncated = false;
let summaryMode = summaryOnly;
let summaryNoticeEmitted = false;
const fileHitCounts = new Map<string, number>();
const pushLine = (out: string): boolean => {
if (totalBytes + out.length + 1 > ctx.maxListBytes) {
@@ -105,6 +116,19 @@ export async function searchContent(
return true;
};
const maybeEnterSummaryMode = (): void => {
if (summaryMode) return;
if (totalBytes <= SUMMARY_MODE_TRIGGER_RATIO * ctx.maxListBytes) return;
summaryMode = true;
if (!summaryNoticeEmitted) {
const pct = Math.round((totalBytes / ctx.maxListBytes) * 100);
pushLine(
`[switching to summary mode — byte budget at ${pct}%; remaining files will report match counts only]`,
);
summaryNoticeEmitted = true;
}
};
const walk = async (dir: string): Promise<void> => {
if (truncated) return;
throwIfAborted(args.signal);
@@ -162,33 +186,55 @@ export async function searchContent(
}
scanned++;
if (hits.length === 0) continue;
fileHitCounts.set(rel, hits.length);
if (summaryMode) {
if (!pushLine(`${rel}: ${hits.length} match${hits.length === 1 ? "" : "es"}`)) return;
continue;
}
const printable = Math.min(hits.length, MAX_HITS_PER_FILE);
const omittedFromFile = hits.length - printable;
const printableHits = hits.slice(0, printable);
if (ctxLines === 0) {
for (const li of hits) {
for (const li of printableHits) {
if (truncated) return;
const line = lines[li]!;
const display = line.length > 200 ? `${line.slice(0, 200)}…` : line;
if (!pushLine(`${rel}:${li + 1}: ${display}`)) return;
}
continue;
}
const hitSet = new Set(hits);
let prevWindowEnd = -2;
for (const li of hits) {
if (truncated) return;
const winStart = Math.max(0, li - ctxLines);
const winEnd = Math.min(lines.length - 1, li + ctxLines);
if (winStart > prevWindowEnd + 1 && prevWindowEnd >= 0) {
if (!pushLine("--")) return;
} else {
const hitSet = new Set(printableHits);
let prevWindowEnd = -2;
for (const li of printableHits) {
if (truncated) return;
const winStart = Math.max(0, li - ctxLines);
const winEnd = Math.min(lines.length - 1, li + ctxLines);
if (winStart > prevWindowEnd + 1 && prevWindowEnd >= 0) {
if (!pushLine("--")) return;
}
const realStart = winStart > prevWindowEnd + 1 ? winStart : prevWindowEnd + 1;
for (let i = realStart; i <= winEnd; i++) {
const line = lines[i]!;
const display = line.length > 200 ? `${line.slice(0, 200)}…` : line;
const sep = hitSet.has(i) ? ":" : "-";
if (!pushLine(`${rel}:${i + 1}${sep} ${display}`)) return;
}
prevWindowEnd = winEnd;
}
const realStart = winStart > prevWindowEnd + 1 ? winStart : prevWindowEnd + 1;
for (let i = realStart; i <= winEnd; i++) {
const line = lines[i]!;
const display = line.length > 200 ? `${line.slice(0, 200)}…` : line;
const sep = hitSet.has(i) ? ":" : "-";
if (!pushLine(`${rel}:${i + 1}${sep} ${display}`)) return;
}
prevWindowEnd = winEnd;
}
if (omittedFromFile > 0) {
if (
!pushLine(
`[${rel}: ${omittedFromFile} more match${omittedFromFile === 1 ? "" : "es"} in this file — re-grep with a tighter pattern or use read_file to see them]`,
)
)
return;
}
maybeEnterSummaryMode();
}
};
await walk(startAbs);
+352
View File
@@ -0,0 +1,352 @@
/** Agent-facing tools for scaffolding skills + MCP servers from chat. Persists via the same paths the wizard / `/skill new` use. */
import { defaultConfigPath, readConfig, writeConfig } from "../config.js";
import { MCP_CATALOG } from "../mcp/catalog.js";
import { preflightStdioSpec } from "../mcp/preflight.js";
import { type McpSpec, parseMcpSpec } from "../mcp/spec.js";
import { SkillStore } from "../skills.js";
import type { ToolRegistry } from "../tools.js";
export interface ScaffoldToolsOptions {
homeDir?: string;
projectRoot?: string;
/** Override config path — tests point this at a tmp file. */
configPath?: string;
}
const VALID_SKILL_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
const VALID_SERVER_NAME = /^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$/;
const VALID_TOOL_NAME = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
export function registerScaffoldTools(
registry: ToolRegistry,
opts: ScaffoldToolsOptions = {},
): ToolRegistry {
const configPath = opts.configPath ?? defaultConfigPath();
registry.register({
name: "create_skill",
description:
'Scaffold a new skill (`SKILL.md` in `.reasonix/skills/<name>.md`) the user can invoke later via `/skill <name>`. Use this when the user asks the agent to add a playbook, automate a recurring workflow, or capture a multi-step recipe as a named skill. The frontmatter is filled from the structured args here (description / allowed_tools / run_as / model) so the model never has to write raw YAML. Use `run_as: "subagent"` for read-and-synthesize playbooks where only the final answer should come back; default `"inline"` appends the body to the parent log so the user sees the steps. Refuses to overwrite an existing skill — pick a different name or ask the user to delete the old one.',
parameters: {
type: "object",
properties: {
name: {
type: "string",
description:
"Skill identifier — letters/digits/`_`/`-`/`.`, 1–64 chars. Becomes the `name` frontmatter and the `<name>.md` filename.",
},
description: {
type: "string",
description:
'One-line summary shown in the pinned skills index. Lead with the verb ("Run X and …") so the parent agent can scan it.',
},
body: {
type: "string",
description:
"Markdown body of the skill — the playbook the model follows when invoked. Plain prose + bullets; reference tools by name.",
},
scope: {
type: "string",
enum: ["project", "global"],
description:
"`project` = `.reasonix/skills/` under the workspace (default, requires `reasonix code`); `global` = `~/.reasonix/skills/` shared across all repos.",
},
allowed_tools: {
type: "array",
items: { type: "string" },
description:
"Optional whitelist of tool names the subagent registry is scoped to (only meaningful for `run_as: subagent`). Common values: `read_file`, `search_content`, `directory_tree`, `run_command`. Omit to give the subagent the full inherited toolset.",
},
run_as: {
type: "string",
enum: ["inline", "subagent"],
description:
"`inline` (default) appends the body to the parent log as a tool result. `subagent` spawns an isolated child loop and only the final answer comes back — use for read-and-synthesize playbooks (explore, research, review).",
},
model: {
type: "string",
enum: ["deepseek-v4-flash", "deepseek-v4-pro"],
description:
"Subagent model override (only meaningful for `run_as: subagent`). Default is the same as `spawn_subagent` — `deepseek-v4-flash`. Set to `deepseek-v4-pro` only when the playbook empirically needs the stronger model.",
},
},
required: ["name", "description", "body"],
},
fn: async (args: {
name?: unknown;
description?: unknown;
body?: unknown;
scope?: unknown;
allowed_tools?: unknown;
run_as?: unknown;
model?: unknown;
}) => {
const name = typeof args.name === "string" ? args.name.trim() : "";
if (!VALID_SKILL_NAME.test(name)) {
return JSON.stringify({
error: `invalid skill name: ${JSON.stringify(name)} — use letters, digits, _, -, .`,
});
}
const description =
typeof args.description === "string" ? args.description.trim().replace(/\n+/g, " ") : "";
if (!description) {
return JSON.stringify({
error: "create_skill requires a non-empty 'description'",
});
}
const body = typeof args.body === "string" ? args.body : "";
if (!body.trim()) {
return JSON.stringify({ error: "create_skill requires a non-empty 'body'" });
}
const scope: "project" | "global" =
args.scope === "global" ? "global" : opts.projectRoot ? "project" : "global";
const runAs: "inline" | "subagent" = args.run_as === "subagent" ? "subagent" : "inline";
const allowedTools = parseAllowedTools(args.allowed_tools);
if (allowedTools && "error" in allowedTools) {
return JSON.stringify({ error: allowedTools.error });
}
const model =
typeof args.model === "string" && args.model.startsWith("deepseek-")
? args.model
: undefined;
const content = serializeSkill({
name,
description,
runAs,
allowedTools: allowedTools ?? undefined,
model,
body,
});
const store = new SkillStore({
homeDir: opts.homeDir,
projectRoot: opts.projectRoot,
});
const result = store.createWithContent(name, scope, content);
if ("error" in result) {
return JSON.stringify({ error: result.error });
}
return JSON.stringify({
success: true,
path: result.path,
scope,
name,
run_as: runAs,
});
},
});
registry.register({
name: "add_mcp_server",
description:
'Register a new MCP server in the user\'s Reasonix config (`mcp` array). Takes effect on the next session — does NOT spawn the server now. Use stdio for local commands (npx packages, local binaries), `sse` or `streamable-http` for remote endpoints. Pass `from_catalog: "<name>"` (e.g. `"filesystem"`, `"memory"`, `"github"`) to auto-fill `command` + `args` from the bundled catalog — the user still has to supply user-args (filesystem: a sandbox dir; github: GITHUB_PERSONAL_ACCESS_TOKEN in env). Refuses to add a server whose name collides with an existing entry.',
parameters: {
type: "object",
properties: {
name: {
type: "string",
description:
"Server name — used as the namespace prefix on every tool the server exposes. Letters/digits/`_`/`-`, must start with a letter or `_`.",
},
transport: {
type: "string",
enum: ["stdio", "sse", "streamable-http"],
description:
"`stdio` = spawn a local command and pipe MCP over stdin/stdout. `sse` = HTTP+SSE remote. `streamable-http` = Streamable HTTP remote. Required unless `from_catalog` is set.",
},
command: {
type: "string",
description:
'Argv[0] for stdio servers — typically `npx` or a binary path. Required when `transport: "stdio"` (and no `from_catalog`).',
},
args: {
type: "array",
items: { type: "string" },
description:
'Remaining argv for stdio servers — e.g. `["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]`. The dir at the tail is enforced to exist by the preflight check.',
},
url: {
type: "string",
description:
"Endpoint URL for `sse` / `streamable-http` transports. Must be `http://` or `https://`.",
},
from_catalog: {
type: "string",
description:
"Optional shortcut — name out of the bundled catalog (`filesystem`, `memory`, `github`, `puppeteer`, `everything`). When set, fills `command` + `args` from the catalog entry; you still supply `name` (defaults to the catalog name) and any user-args via `args`.",
},
},
required: ["name"],
},
fn: async (args: {
name?: unknown;
transport?: unknown;
command?: unknown;
args?: unknown;
url?: unknown;
from_catalog?: unknown;
}) => {
const name = typeof args.name === "string" ? args.name.trim() : "";
if (!VALID_SERVER_NAME.test(name)) {
return JSON.stringify({
error: `invalid server name: ${JSON.stringify(name)} — must match [a-zA-Z_][a-zA-Z0-9_-]*`,
});
}
const specStr = buildSpecString({
name,
transport: typeof args.transport === "string" ? args.transport : undefined,
command: typeof args.command === "string" ? args.command : undefined,
argv: Array.isArray(args.args)
? (args.args.filter((a) => typeof a === "string") as string[])
: undefined,
url: typeof args.url === "string" ? args.url : undefined,
fromCatalog: typeof args.from_catalog === "string" ? args.from_catalog : undefined,
});
if ("error" in specStr) {
return JSON.stringify({ error: specStr.error });
}
let parsed: McpSpec;
try {
parsed = parseMcpSpec(specStr.spec);
} catch (err) {
return JSON.stringify({ error: (err as Error).message });
}
if (parsed.transport === "stdio") {
try {
preflightStdioSpec(parsed);
} catch (err) {
return JSON.stringify({ error: (err as Error).message });
}
}
const cfg = readConfig(configPath);
const existing = cfg.mcp ?? [];
const collision = existing.find((s) => parseSpecName(s) === name);
if (collision) {
return JSON.stringify({
error: `MCP server ${JSON.stringify(name)} already registered: ${collision}`,
});
}
cfg.mcp = [...existing, specStr.spec];
writeConfig(cfg, configPath);
return JSON.stringify({
success: true,
name,
transport: parsed.transport,
spec: specStr.spec,
config_path: configPath,
active_on_next_launch: true,
});
},
});
return registry;
}
interface SerializeSkillArgs {
name: string;
description: string;
runAs: "inline" | "subagent";
allowedTools?: readonly string[];
model?: string;
body: string;
}
export function serializeSkill(args: SerializeSkillArgs): string {
const lines: string[] = ["---", `name: ${args.name}`, `description: ${args.description}`];
if (args.runAs === "subagent") {
lines.push("runAs: subagent");
}
if (args.allowedTools && args.allowedTools.length > 0) {
lines.push(`allowed-tools: ${args.allowedTools.join(", ")}`);
}
if (args.model) {
lines.push(`model: ${args.model}`);
}
lines.push("---", "");
return `${lines.join("\n")}\n${args.body.trim()}\n`;
}
function parseAllowedTools(raw: unknown): readonly string[] | { error: string } | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw)) {
return { error: "'allowed_tools' must be an array of tool-name strings" };
}
const out: string[] = [];
for (const v of raw) {
if (typeof v !== "string") {
return { error: "'allowed_tools' entries must be strings" };
}
const trimmed = v.trim();
if (!trimmed) continue;
if (!VALID_TOOL_NAME.test(trimmed)) {
return { error: `invalid tool name in allowed_tools: ${JSON.stringify(trimmed)}` };
}
out.push(trimmed);
}
return out.length > 0 ? out : undefined;
}
interface BuildSpecInput {
name: string;
transport?: string;
command?: string;
argv?: string[];
url?: string;
fromCatalog?: string;
}
function buildSpecString(input: BuildSpecInput): { spec: string } | { error: string } {
if (input.fromCatalog) {
const entry = MCP_CATALOG.find((e) => e.name === input.fromCatalog);
if (!entry) {
const known = MCP_CATALOG.map((e) => e.name).join(", ");
return {
error: `unknown catalog entry: ${JSON.stringify(input.fromCatalog)} — known: ${known}`,
};
}
const userArgs = input.argv ?? [];
if (entry.userArgs && userArgs.length === 0) {
return {
error: `catalog entry "${entry.name}" needs ${entry.userArgs} — pass it via the 'args' parameter`,
};
}
const tail = userArgs.map(quoteIfNeeded).join(" ");
const body = `npx -y ${entry.package}${tail ? ` ${tail}` : ""}`;
return { spec: `${input.name}=${body}` };
}
const transport = input.transport;
if (!transport) {
return { error: "add_mcp_server requires 'transport' (or 'from_catalog')" };
}
if (transport === "stdio") {
if (!input.command || !input.command.trim()) {
return { error: "stdio transport requires 'command'" };
}
const tail = (input.argv ?? []).map(quoteIfNeeded).join(" ");
const body = `${quoteIfNeeded(input.command.trim())}${tail ? ` ${tail}` : ""}`;
return { spec: `${input.name}=${body}` };
}
if (transport === "sse" || transport === "streamable-http") {
if (!input.url || !/^https?:\/\//i.test(input.url)) {
return { error: `${transport} transport requires an http(s):// 'url'` };
}
const prefix = transport === "streamable-http" ? "streamable+" : "";
return { spec: `${input.name}=${prefix}${input.url.trim()}` };
}
return { error: `unknown transport: ${JSON.stringify(transport)}` };
}
function parseSpecName(spec: string): string | null {
const m = spec.trim().match(/^([a-zA-Z_][a-zA-Z0-9_-]*)=/);
return m ? (m[1] ?? null) : null;
}
function quoteIfNeeded(s: string): string {
return /\s|"/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
}
+22 -1
View File
@@ -101,6 +101,12 @@ const DEFAULT_MAX_RESULT_CHARS = 8000;
const DEFAULT_MAX_ITERS = 16;
const MIN_MAX_ITERS = 1;
const MAX_MAX_ITERS = 32;
/** Iters-from-cap at which we start appending a remaining-budget hint to tool results. */
const BUDGET_WARN_THRESHOLD = 3;
function budgetParagraph(maxToolIters: number): string {
return `Tool budget: you have ${maxToolIters} tool call${maxToolIters === 1 ? "" : "s"} for this task. The cap is enforced from outside — the call after #${maxToolIters} is refused. Pace yourself: if you can't fully resolve the task within the budget, stop early and return what you have plus what's missing, rather than burning the budget on one branch.`;
}
// Subagents default to flash — their work is read-and-synthesize
// (explore, research), which doesn't need the 12× pro tier. Skill
// frontmatter `model: deepseek-v4-pro` is the opt-in override for
@@ -176,8 +182,23 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
NEVER_INHERITED_TOOLS,
)
: forkRegistryExcluding(opts.parentRegistry, NEVER_INHERITED_TOOLS);
// Budget telemetry: count dispatches and append a remaining-iters hint
// when the child is within BUDGET_WARN_THRESHOLD of the cap, so the
// model can choose to wrap up rather than open another rabbit hole.
let dispatchCount = 0;
childTools.setResultAugmenter((_name, _args, result) => {
dispatchCount++;
const remaining = maxToolIters - dispatchCount;
if (remaining <= 0) {
return `${result}\n\n[budget: 0 of ${maxToolIters} tool calls left — finalize NOW; the next tool call will be refused]`;
}
if (remaining <= BUDGET_WARN_THRESHOLD) {
return `${result}\n\n[budget: ${remaining} of ${maxToolIters} tool call${remaining === 1 ? "" : "s"} left — wrap up soon]`;
}
return result;
});
const childPrefix = new ImmutablePrefix({
system: opts.system,
system: `${opts.system}\n\n${budgetParagraph(maxToolIters)}`,
toolSpecs: childTools.specs(),
});
const childLoop = new CacheFirstLoop({
+156
View File
@@ -12,10 +12,13 @@ import {
detectAtPicker,
expandAtMentions,
expandAtUrls,
listDirectory,
listFilesSync,
listFilesWithStatsAsync,
parseAtQuery,
rankPickerCandidates,
stripUrlTail,
walkFilesStream,
} from "../src/at-mentions.js";
describe("AT_MENTION_PATTERN", () => {
@@ -730,3 +733,156 @@ describe("expandAtUrls", () => {
expect(out.text).not.toContain('"\n');
});
});
describe("parseAtQuery", () => {
it("empty input is the root browse", () => {
expect(parseAtQuery("")).toEqual({ dir: "", filter: "", trailingSlash: false });
});
it("bare token is a root-level filter", () => {
expect(parseAtQuery("auth")).toEqual({ dir: "", filter: "auth", trailingSlash: false });
});
it("trailing slash flips on browse-this-dir mode", () => {
expect(parseAtQuery("src/")).toEqual({ dir: "src", filter: "", trailingSlash: true });
});
it("query inside a path splits on the last slash", () => {
expect(parseAtQuery("src/auth/log")).toEqual({
dir: "src/auth",
filter: "log",
trailingSlash: false,
});
});
it("backslashes normalize to forward slashes", () => {
expect(parseAtQuery("src\\auth")).toEqual({
dir: "src",
filter: "auth",
trailingSlash: false,
});
});
});
describe("listDirectory", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "reasonix-listdir-"));
mkdirSync(join(root, "src", "auth"), { recursive: true });
mkdirSync(join(root, "tests"), { recursive: true });
mkdirSync(join(root, "node_modules"), { recursive: true });
mkdirSync(join(root, ".git"), { recursive: true });
writeFileSync(join(root, "README.md"), "x");
writeFileSync(join(root, "package.json"), "{}");
writeFileSync(join(root, "src", "index.ts"), "x");
writeFileSync(join(root, "src", "loop.ts"), "x");
writeFileSync(join(root, "src", "auth", "login.ts"), "x");
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it("lists immediate children only — dirs before files, alpha within group", async () => {
const entries = await listDirectory(root, "");
const labels = entries.map((e) => `${e.name}${e.isDir ? "/" : ""}`);
expect(labels).toEqual(["src/", "tests/", "package.json", "README.md"]);
});
it("drills into a subdir without scanning siblings", async () => {
const entries = await listDirectory(root, "src");
const names = entries.map((e) => e.name);
expect(names).toEqual(["auth", "index.ts", "loop.ts"]);
expect(entries.find((e) => e.name === "auth")?.isDir).toBe(true);
});
it("paths returned for subdir entries are root-relative", async () => {
const entries = await listDirectory(root, "src");
const auth = entries.find((e) => e.name === "auth");
expect(auth?.path).toBe("src/auth");
const idx = entries.find((e) => e.name === "index.ts");
expect(idx?.path).toBe("src/index.ts");
});
it("escapes outside the root resolve to empty", async () => {
const out = await listDirectory(root, "../..");
expect(out).toEqual([]);
});
it("missing dir resolves to empty (not a throw)", async () => {
const out = await listDirectory(root, "does-not-exist");
expect(out).toEqual([]);
});
it("respects .gitignore in the dir being listed", async () => {
writeFileSync(join(root, ".gitignore"), "secret.txt\n");
writeFileSync(join(root, "secret.txt"), "x");
writeFileSync(join(root, "ok.txt"), "x");
const entries = await listDirectory(root, "");
const names = entries.map((e) => e.name);
expect(names).toContain("ok.txt");
expect(names).not.toContain("secret.txt");
});
});
describe("walkFilesStream", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "reasonix-stream-"));
mkdirSync(join(root, "a"), { recursive: true });
mkdirSync(join(root, "b", "c"), { recursive: true });
mkdirSync(join(root, "node_modules", "junk"), { recursive: true });
writeFileSync(join(root, "a", "1.ts"), "x");
writeFileSync(join(root, "a", "2.ts"), "x");
writeFileSync(join(root, "b", "3.ts"), "x");
writeFileSync(join(root, "b", "c", "4.ts"), "x");
writeFileSync(join(root, "node_modules", "junk", "skip.ts"), "x");
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it("emits each file via onEntry exactly once", async () => {
const seen: string[] = [];
await walkFilesStream(root, { onEntry: (e) => void seen.push(e.path) });
expect(seen.sort()).toEqual(["a/1.ts", "a/2.ts", "b/3.ts", "b/c/4.ts"]);
});
it("returning false from onEntry halts the walk", async () => {
const seen: string[] = [];
await walkFilesStream(root, {
onEntry: (e) => {
seen.push(e.path);
return seen.length < 2;
},
});
expect(seen.length).toBe(2);
});
it("AbortSignal halts the walk", async () => {
const ac = new AbortController();
const seen: string[] = [];
const result = await walkFilesStream(root, {
signal: ac.signal,
onEntry: (e) => {
seen.push(e.path);
if (seen.length === 1) ac.abort();
},
});
expect(result.cancelled).toBe(true);
expect(seen.length).toBeLessThanOrEqual(4);
});
it("default ignoreDirs blocks node_modules", async () => {
const seen: string[] = [];
await walkFilesStream(root, { onEntry: (e) => void seen.push(e.path) });
expect(seen.find((p) => p.includes("node_modules"))).toBeUndefined();
});
it("onProgress fires at end with the total scanned count", async () => {
let last = 0;
await walkFilesStream(root, {
onEntry: () => {},
onProgress: (n) => {
last = n;
},
progressIntervalMs: 0,
});
expect(last).toBe(4);
});
});
+38
View File
@@ -14,17 +14,20 @@ import {
loadProjectShellAllowed,
loadReasoningEffort,
loadSemanticEmbeddingUserConfig,
loadTheme,
markEditModeHintShown,
readConfig,
redactKey,
redactSemanticEmbeddingConfig,
removeProjectShellAllowed,
resolveSemanticEmbeddingConfig,
resolveThemePreference,
saveApiKey,
saveEditMode,
saveIndexConfig,
saveReasoningEffort,
saveSemanticEmbeddingConfig,
saveTheme,
searchEnabled,
writeConfig,
} from "../src/config.js";
@@ -286,6 +289,41 @@ describe("config", () => {
expect(loadReasoningEffort(path)).toBe("high");
});
it("saveTheme + loadTheme round-trip a registered theme", () => {
saveTheme("tokyo-night", path);
expect(loadTheme(path)).toBe("tokyo-night");
expect(readConfig(path).theme).toBe("tokyo-night");
});
it("saveTheme + loadTheme round-trip auto", () => {
saveTheme("auto", path);
expect(loadTheme(path)).toBe("auto");
expect(readConfig(path).theme).toBe("auto");
});
it("loadTheme returns undefined for invalid runtime values", () => {
const invalidValues = ["unknown", null, false, 123, [], { name: "github-light" }];
for (const theme of invalidValues) {
writeConfig({ theme } as never, path);
expect(loadTheme(path)).toBeUndefined();
}
});
it("resolveThemePreference lets env override auto but not registered config themes", () => {
expect(resolveThemePreference("auto", "github-light")).toBe("github-light");
expect(resolveThemePreference(undefined, "tokyo-night")).toBe("tokyo-night");
expect(resolveThemePreference("github-dark", "github-light")).toBe("github-dark");
expect(resolveThemePreference("auto", "unknown")).toBe("default");
});
it("saveTheme doesn't clobber other persisted fields", () => {
saveEditMode("auto", path);
saveTheme("github-light", path);
expect(loadEditMode(path)).toBe("auto");
expect(loadTheme(path)).toBe("github-light");
});
it("editModeHintShown defaults to false and toggles on markEditModeHintShown", () => {
expect(editModeHintShown(path)).toBe(false);
markEditModeHintShown(path);
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { buildFeedbackDiagnostic, buildFeedbackIssueUrl } from "../src/cli/ui/feedback.js";
const FIXTURE = {
version: "0.34.1",
latestVersion: "0.34.1",
platform: "win32",
osRelease: "10.0.26200",
termProgram: "Windows Terminal",
term: "xterm-256color",
colorTerm: "truecolor",
inWindowsTerminal: true,
inTmux: false,
inSsh: false,
wslDistro: undefined as string | undefined,
cols: 142,
rows: 40,
nodeVersion: "v22.10.0",
locale: "zh-CN",
theme: "tokyo-night",
model: "deepseek-v4-flash",
reasoningEffort: "high",
editMode: "auto",
planMode: false,
mcpServerCount: 3,
sessionId: "code-reasonix",
};
describe("buildFeedbackDiagnostic", () => {
it("emits all flicker-relevant fields when supplied", () => {
const out = buildFeedbackDiagnostic(FIXTURE);
expect(out).toContain("**Reasonix**: 0.34.1 (latest)");
expect(out).toContain("**Platform**: win32 (10.0.26200)");
expect(out).toContain(
"**Terminal**: Windows Terminal (TERM_PROGRAM=Windows Terminal, TERM=xterm-256color, COLORTERM=truecolor, WT_SESSION=set)",
);
expect(out).toContain("**Size**: 142×40");
expect(out).toContain("**Node**: v22.10.0");
expect(out).toContain("**Locale**: zh-CN");
expect(out).toContain("**Theme**: tokyo-night");
expect(out).toContain("**Model**: deepseek-v4-flash · effort=high");
expect(out).toContain("**Mode**: edit=auto · plan=off");
expect(out).toContain("**MCP**: 3 server(s)");
expect(out).toContain("**Session**: code-reasonix");
expect(out).toContain("<!-- describe what you were doing when this happened -->");
});
it("only emits fields the codebase advertises — no surprise leaks", () => {
const out = buildFeedbackDiagnostic(FIXTURE);
const fieldNames = out
.split("\n")
.filter((l) => l.startsWith("**"))
.map((l) => l.split(":")[0]);
expect(fieldNames).toEqual([
"**Reasonix**",
"**Platform**",
"**Terminal**",
"**Size**",
"**Node**",
"**Locale**",
"**Theme**",
"**Model**",
"**Mode**",
"**MCP**",
"**Session**",
]);
});
it("renders the latest-version comparison when behind", () => {
const out = buildFeedbackDiagnostic({ ...FIXTURE, latestVersion: "0.35.0" });
expect(out).toContain("**Reasonix**: 0.34.1 (latest: 0.35.0)");
});
it("omits optional fields cleanly when absent", () => {
const out = buildFeedbackDiagnostic({
version: "0.34.1",
platform: "linux",
osRelease: "6.6.0",
nodeVersion: "v22.10.0",
locale: "EN",
model: "deepseek-v4-flash",
});
expect(out).toContain("**Reasonix**: 0.34.1");
expect(out).not.toContain("(latest");
expect(out).not.toContain("**Size**");
expect(out).not.toContain("**Theme**");
expect(out).not.toContain("**MCP**");
expect(out).not.toContain("**Session**");
expect(out).toContain("**Mode**: plan=off");
});
it("flags WSL / tmux / ssh when those env markers are set", () => {
const out = buildFeedbackDiagnostic({
...FIXTURE,
inWindowsTerminal: false,
inTmux: true,
inSsh: true,
wslDistro: "Ubuntu-22.04",
});
expect(out).toContain("TMUX=set");
expect(out).toContain("SSH_TTY=set");
expect(out).toContain("WSL=Ubuntu-22.04");
expect(out).not.toContain("WT_SESSION=set");
});
it("does not include API keys, file paths, or transcript content", () => {
const out = buildFeedbackDiagnostic(FIXTURE);
expect(out).not.toMatch(/sk-[a-zA-Z0-9]/);
expect(out).not.toMatch(/[a-zA-Z]:\\|\/home\/|\/Users\//);
expect(out).not.toMatch(/<\|user\|>|<\|assistant\|>|tool_call/);
});
});
describe("buildFeedbackIssueUrl", () => {
it("encodes the diagnostic into the body query param so the issue page opens pre-filled", () => {
const diagnostic = buildFeedbackDiagnostic(FIXTURE);
const url = buildFeedbackIssueUrl(diagnostic);
expect(url.startsWith("https://github.com/esengine/DeepSeek-Reasonix/issues/new?body=")).toBe(
true,
);
const decoded = decodeURIComponent(url.split("?body=")[1] ?? "");
expect(decoded).toBe(diagnostic);
});
it("caps body length so a runaway diagnostic can't blow past GitHub's URL limit", () => {
const huge = `${"x".repeat(20000)}`;
const url = buildFeedbackIssueUrl(huge);
expect(url.length).toBeLessThan(20000);
expect(url).toMatch(/^https:\/\/github\.com\/esengine\/DeepSeek-Reasonix\/issues\/new\?body=/);
});
});
+126
View File
@@ -93,6 +93,67 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
expect(out).not.toContain("line 200");
});
it("auto-preview embeds a top-level export outline for TS-shaped files", async () => {
const filler = (n: number) => Array.from({ length: n }, () => " // filler").join("\n");
const src = [
'import { foo } from "./foo";',
'import { bar } from "./bar";',
"",
"export interface AppProps {",
" id: string;",
"}",
"",
filler(120),
"export function AppInner(props: AppProps) {",
" return null;",
"}",
filler(80),
"export const handleSubmit = () => {};",
filler(38),
"export default AppInner;",
].join("\n");
await fs.writeFile(join(root, "App.tsx"), src);
const out = await tools.dispatch("read_file", JSON.stringify({ path: "App.tsx" }));
expect(out).toMatch(/\[outline: 3 top-level exports\]/);
expect(out).toMatch(/L\s*4\s+export interface AppProps/);
expect(out).toMatch(/L128\s+export function AppInner/);
expect(out).toMatch(/L211\s+export const handleSubmit/);
});
it("auto-preview omits the outline when no top-level exports are present", async () => {
const src = Array.from({ length: 220 }, (_, i) => `line ${i + 1}`).join("\n");
await fs.writeFile(join(root, "plain.txt"), src);
const out = await tools.dispatch("read_file", JSON.stringify({ path: "plain.txt" }));
expect(out).toMatch(/auto-preview: /);
expect(out).not.toMatch(/\[outline:/);
});
it("auto-preview outline elides the middle when more than 30 exports", async () => {
// Spread the 35 exports out so head/tail slices don't mask the elision.
const lines: string[] = [];
const exportPositions: number[] = [];
for (let i = 0; i < 35; i++) {
for (let f = 0; f < 6; f++) lines.push(`// filler block ${i}-${f}`);
exportPositions.push(lines.length + 1);
lines.push(`export const sym${String(i + 1).padStart(2, "0")} = ${i + 1};`);
}
for (let i = 0; i < 60; i++) lines.push(`// trailer ${i}`);
await fs.writeFile(join(root, "many.ts"), lines.join("\n"));
const out = await tools.dispatch("read_file", JSON.stringify({ path: "many.ts" }));
const outlineMatch = /\[outline: 35 top-level exports\]([\s\S]*?)\n\n/.exec(out);
expect(outlineMatch).not.toBeNull();
const outlineBlock = outlineMatch![1]!;
expect(outlineBlock).toMatch(/export const sym01/);
expect(outlineBlock).toMatch(/export const sym25/);
expect(outlineBlock).not.toMatch(/export const sym26/);
expect(outlineBlock).not.toMatch(/export const sym30/);
expect(outlineBlock).toMatch(/export const sym31/);
expect(outlineBlock).toMatch(/export const sym35/);
const gapStart = exportPositions[24]!;
const gapEnd = exportPositions[30]!;
expect(outlineBlock).toContain(`[… 5 more exports between L${gapStart} and L${gapEnd} …]`);
});
it("returns full content when file is at or below the auto-preview threshold", async () => {
const out = await tools.dispatch("read_file", JSON.stringify({ path: "hello.txt" }));
expect(out).toBe("line 1\nline 2\nline 3");
@@ -501,6 +562,71 @@ describe("filesystem tools (built-in, sandbox-enforced)", () => {
expect(out).not.toContain("gamma.tsx:");
});
});
describe("per-file cap and histogram fallback", () => {
it("caps a single file's printed hits at 30 and footers the overflow", async () => {
const lines = Array.from({ length: 47 }, () => "TARGETSTRING here");
await fs.writeFile(join(root, "many.ts"), lines.join("\n"));
const out = await tools.dispatch(
"search_content",
JSON.stringify({ pattern: "TARGETSTRING", glob: "many.ts" }),
);
const hitLines = out.split("\n").filter((l) => /^many\.ts:\d+:/.test(l));
expect(hitLines).toHaveLength(30);
expect(out).toMatch(/\[many\.ts: 17 more matches in this file/);
});
it("does not emit the cap footer when hits fit under the cap", async () => {
const lines = Array.from({ length: 5 }, () => "TARGETSTRING here");
await fs.writeFile(join(root, "few.ts"), lines.join("\n"));
const out = await tools.dispatch(
"search_content",
JSON.stringify({ pattern: "TARGETSTRING", glob: "few.ts" }),
);
expect(out).not.toMatch(/more matches in this file/);
});
it("summary_only:true returns histogram with no line content", async () => {
await fs.writeFile(
join(root, "a.ts"),
["MARK one", "noise", "MARK two", "MARK three"].join("\n"),
);
await fs.writeFile(join(root, "b.ts"), ["MARK only"].join("\n"));
const out = await tools.dispatch(
"search_content",
JSON.stringify({ pattern: "MARK", summary_only: true, glob: "*.ts" }),
);
expect(out).toContain("a.ts: 3 matches");
expect(out).toContain("b.ts: 1 match");
expect(out).not.toMatch(/MARK one/);
expect(out).not.toMatch(/MARK two/);
});
it("flips remaining files to summary mode once 80% of the byte budget is spent", async () => {
const tiny = new ToolRegistry();
registerFilesystemTools(tiny, { rootDir: root, maxListBytes: 4096 });
const dir = join(root, "histtest");
await fs.mkdir(dir, { recursive: true });
// Per-file output ≈ 8 hits × ~75 bytes ≈ 600 bytes. 5 files → 3000 bytes
// (~73%); 6 → ~88%, so the flip lands somewhere in the back half of
// the alphabetical walk.
const fileNames = "abcdefghij".split("");
const hitLine = `TARGET ${"y".repeat(50)}`;
for (const name of fileNames) {
const lines = Array.from({ length: 8 }, () => hitLine);
await fs.writeFile(join(dir, `${name}.ts`), lines.join("\n"));
}
const out = await tiny.dispatch(
"search_content",
JSON.stringify({ pattern: "TARGET", path: "histtest" }),
);
expect(out).toMatch(/switching to summary mode — byte budget at \d+%/);
const histogramLines = out
.split("\n")
.filter((l) => /^histtest\/[a-j]\.ts: \d+ match/.test(l));
expect(histogramLines.length).toBeGreaterThan(0);
});
});
});
describe("compileNameFilter", () => {
+9 -14
View File
@@ -148,21 +148,16 @@ describe("processMultilineKey — cursor motion", () => {
expect(processMultilineKey("abc", 3, key({ rightArrow: true })).cursor).toBe(3);
});
it("↑/↓ on a single-line non-empty buffer is a NOOP (no history recall)", () => {
// 0.7.10: history recall moved to Ctrl+P/N because mouse wheel
// on Windows ConPTY translates to ↑/↓ events, indistinguishable
// from keyboard. Wheel scrolling was clobbering the prompt with
// recalled history. Now ↑/↓ does nothing on a single-line
// buffer; users press Ctrl+P / Ctrl+N for history.
it("↑/↓ on a single-line non-empty buffer is a NOOP", () => {
const up = processMultilineKey("hello", 3, key({ upArrow: true }));
expect(up).toEqual({ next: null, cursor: null, submit: false });
const down = processMultilineKey("hello", 3, key({ downArrow: true }));
expect(down).toEqual({ next: null, cursor: null, submit: false });
});
it("↑/↓ on an empty buffer hands off to chat scroll (wheel-routed)", () => {
expect(processMultilineKey("", 0, key({ upArrow: true })).chatScrollHandoff).toBe("up");
expect(processMultilineKey("", 0, key({ downArrow: true })).chatScrollHandoff).toBe("down");
it("↑/↓ on an empty buffer hands off to history recall (universal CLI convention)", () => {
expect(processMultilineKey("", 0, key({ upArrow: true })).historyHandoff).toBe("prev");
expect(processMultilineKey("", 0, key({ downArrow: true })).historyHandoff).toBe("next");
});
it("Ctrl+P / Ctrl+N hand off to history recall (bash readline binding)", () => {
@@ -197,13 +192,13 @@ describe("processMultilineKey — cursor motion", () => {
expect(result).toEqual({ next: null, cursor: null, submit: false });
});
it("raw `\\x1b[A` escape sequence is rewritten into upArrow (chat-scroll handoff on empty buffer)", () => {
it("raw `\\x1b[A` escape sequence is rewritten into upArrow (history handoff on empty buffer)", () => {
const result = processMultilineKey("", 0, { input: "\x1b[A" });
expect(result.chatScrollHandoff).toBe("up");
expect(result.historyHandoff).toBe("prev");
});
it("raw `\\x1b[B` becomes downArrow; `\\x1b[C` rightArrow; `\\x1b[D` leftArrow", () => {
expect(processMultilineKey("", 0, { input: "\x1b[B" }).chatScrollHandoff).toBe("down");
expect(processMultilineKey("", 0, { input: "\x1b[B" }).historyHandoff).toBe("next");
expect(processMultilineKey("abc", 0, { input: "\x1b[C" }).cursor).toBe(1);
expect(processMultilineKey("abc", 2, { input: "\x1b[D" }).cursor).toBe(1);
});
@@ -216,8 +211,8 @@ describe("processMultilineKey — cursor motion", () => {
// newline boundary.
expect(processMultilineKey("ab\ncd", 2, { input: "[C" }).cursor).toBe(3);
expect(processMultilineKey("ab\ncd", 3, { input: "[D" }).cursor).toBe(2);
expect(processMultilineKey("", 0, { input: "[A" }).chatScrollHandoff).toBe("up");
expect(processMultilineKey("", 0, { input: "[B" }).chatScrollHandoff).toBe("down");
expect(processMultilineKey("", 0, { input: "[A" }).historyHandoff).toBe("prev");
expect(processMultilineKey("", 0, { input: "[B" }).historyHandoff).toBe("next");
});
it("↑ moves cursor to the previous line, preserving column when possible", () => {
+24
View File
@@ -45,4 +45,28 @@ describe("PlanConfirm — issue #336 plan body must be visible", () => {
expect(out).toContain("line 1");
expect(out).toMatch(/more line.*scrollback/);
});
it("surfaces the open-questions block even when steps are present (issue #477)", () => {
const plan = [
"## Summary",
"swap backend",
"",
"## Open Questions",
"- which adapter wins on tie?",
"- keep deprecated env var?",
].join("\n");
const steps = [{ id: "s1", title: "do thing" }];
const out = bytesFor(plan, steps);
expect(out).toContain("which adapter wins on tie?");
expect(out).toContain("keep deprecated env var?");
expect(out).toContain("do thing");
});
it("surfaces the open-questions block even when the body is past the truncation cap", () => {
const filler = Array.from({ length: 30 }, (_, i) => `body line ${i + 1}`).join("\n");
const plan = `## Summary\n${filler}\n\n## Risks\n- migration runs hot\n- breaking config`;
const out = bytesFor(plan);
expect(out).toContain("migration runs hot");
expect(out).toContain("breaking config");
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
extractOpenQuestionsSection,
hasOpenQuestionsSection,
} from "../src/cli/ui/plan-open-questions.js";
describe("extractOpenQuestionsSection", () => {
it("returns null when no matching heading exists", () => {
const plan = "## Summary\nrefactor X\n## Steps\n1. do it";
expect(extractOpenQuestionsSection(plan)).toBeNull();
expect(hasOpenQuestionsSection(plan)).toBe(false);
});
it("extracts an Open Questions block from the heading to end of plan", () => {
const plan = [
"## Summary",
"swap backend",
"",
"## Open Questions",
"- which adapter wins on tie?",
"- keep deprecated env var?",
].join("\n");
const block = extractOpenQuestionsSection(plan);
expect(block).toContain("Open Questions");
expect(block).toContain("which adapter wins on tie?");
expect(block).toContain("keep deprecated env var?");
});
it("stops at the next same-level heading", () => {
const plan = [
"## Summary",
"swap backend",
"",
"## Risks",
"- breaking config",
"- migration runs hot",
"",
"## Steps",
"1. ship it",
].join("\n");
const block = extractOpenQuestionsSection(plan);
expect(block).toContain("breaking config");
expect(block).toContain("migration runs hot");
expect(block).not.toContain("ship it");
});
it("includes deeper sub-headings nested under the questions block", () => {
const plan = ["## Open Questions", "- top", "### sub", "- nested", "## Steps", "1. go"].join(
"\n",
);
const block = extractOpenQuestionsSection(plan);
expect(block).toContain("nested");
expect(block).not.toContain("1. go");
});
it("matches Chinese headings", () => {
const plan = ["## 待确认", "- 边界 A 还是 B?", "## 步骤", "1. 走起"].join("\n");
const block = extractOpenQuestionsSection(plan);
expect(block).toContain("边界 A 还是 B?");
expect(block).not.toContain("走起");
});
it("matches case-insensitively and through plural variants", () => {
expect(hasOpenQuestionsSection("# RISK\n- thing")).toBe(true);
expect(hasOpenQuestionsSection("### unknowns\n- x")).toBe(true);
expect(hasOpenQuestionsSection("## Assumption\n- y")).toBe(true);
});
it("does not match the literal word inside body text", () => {
expect(hasOpenQuestionsSection("## Summary\nthis lists open questions later")).toBe(false);
});
});
+7
View File
@@ -69,6 +69,7 @@ const PUBLIC_API: readonly string[] = [
"DiffRenderOptions",
"DiffReport",
"DiffSide",
"DirEntry",
"EditBlock",
"EditSnapshot",
"EventRole",
@@ -101,6 +102,7 @@ const PUBLIC_API: readonly string[] = [
"JsonRpcResponse",
"LATEST_CACHE_TTL_MS",
"LATEST_FETCH_TIMEOUT_MS",
"ListDirectoryOptions",
"ListFilesOptions",
"ListPromptsResult",
"ListResourcesResult",
@@ -138,6 +140,7 @@ const PUBLIC_API: readonly string[] = [
"PROJECT_MEMORY_FILE",
"PROJECT_MEMORY_MAX_CHARS",
"PageContent",
"ParsedAtQuery",
"PickerCandidate",
"PlanProposedError",
"PlanRevisionProposedError",
@@ -176,6 +179,7 @@ const PUBLIC_API: readonly string[] = [
"StepCompletion",
"StormBreaker",
"StreamChunk",
"StreamWalkOptions",
"StreamableHttpMcpSpec",
"StreamableHttpTransport",
"StreamableHttpTransportOptions",
@@ -255,6 +259,7 @@ const PUBLIC_API: readonly string[] = [
"isJsonRpcError",
"isNpxInstall",
"isPlausibleKey",
"listDirectory",
"listFilesSync",
"listFilesWithStatsAsync",
"listFilesWithStatsSync",
@@ -268,6 +273,7 @@ const PUBLIC_API: readonly string[] = [
"nestArguments",
"openTranscriptFile",
"outputCostUsd",
"parseAtQuery",
"parseEditBlocks",
"parseMcpSpec",
"parseMojeekResults",
@@ -312,6 +318,7 @@ const PUBLIC_API: readonly string[] = [
"tokenizeCommand",
"truncateForModel",
"truncateForModelByTokens",
"walkFilesStream",
"webFetch",
"webSearch",
"withUtf8Codepage",
+55
View File
@@ -10,6 +10,7 @@ import {
suggestSlashCommands,
} from "../src/cli/ui/slash.js";
import { DeepSeekClient, Usage } from "../src/client.js";
import { loadTheme } from "../src/config.js";
import {
getLanguage,
notifyLanguageChange,
@@ -492,6 +493,7 @@ describe("handleSlash", () => {
"status",
"preset",
"model",
"theme",
"mcp",
"memory",
"retry",
@@ -1138,6 +1140,59 @@ describe("handleSlash", () => {
});
});
describe("/theme", () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let originalTheme: string | undefined;
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), "reasonix-theme-slash-"));
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
originalTheme = process.env.REASONIX_THEME;
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.REASONIX_THEME = "github-dark";
});
afterEach(() => {
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
if (originalTheme === undefined) {
process.env.REASONIX_THEME = undefined;
} else {
process.env.REASONIX_THEME = originalTheme;
}
rmSync(tempHome, { recursive: true, force: true });
});
it("shows current theme status and available choices", () => {
const r = handleSlash("theme", [], makeLoop());
expect(r.info).toMatch(/theme: github-dark/);
expect(r.info).toMatch(/configured: unset/);
expect(r.info).toMatch(/tokyo-night/);
});
it("persists a registered theme", () => {
const r = handleSlash("theme", ["tokyo-night"], makeLoop());
expect(r.info).toMatch(/theme saved: tokyo-night/);
expect(loadTheme()).toBe("tokyo-night");
});
it("persists auto so env can resolve the active theme", () => {
const r = handleSlash("theme", ["auto"], makeLoop());
expect(r.info).toMatch(/active on next launch: github-dark/);
expect(loadTheme()).toBe("auto");
});
it("rejects unknown theme names", () => {
const r = handleSlash("theme", ["solarized"], makeLoop());
expect(r.info).toMatch(/unknown theme: solarized/);
expect(loadTheme()).toBeUndefined();
});
});
describe("/language", () => {
afterEach(() => {
setLanguageRuntime("EN");
+38 -2
View File
@@ -288,7 +288,8 @@ describe("registerSubagentTool", () => {
"spawn_subagent",
JSON.stringify({ task: "go", system: "You are a custom subagent." }),
);
expect(seenSystems[0]).toBe("You are a custom subagent.");
expect(seenSystems[0]).toContain("You are a custom subagent.");
expect(seenSystems[0]).toMatch(/Tool budget: you have \d+ tool calls/);
});
it("falls back to the default model when the model arg is invalid", async () => {
@@ -526,7 +527,8 @@ describe("registerSubagentTool", () => {
"spawn_subagent",
JSON.stringify({ task: "go", type: "explore", system: "I am a custom prompt." }),
);
expect(seenSystems[0]).toBe("I am a custom prompt.");
expect(seenSystems[0]).toContain("I am a custom prompt.");
expect(seenSystems[0]).toMatch(/Tool budget: you have 20 tool calls/);
});
it("omitted type leaves the default persona and budget unchanged", async () => {
@@ -538,6 +540,40 @@ describe("registerSubagentTool", () => {
const parsed = JSON.parse(out);
expect(parsed.tool_iters).toBe(5);
});
it("appends a remaining-budget hint to tool results within 3 iters of the cap", async () => {
// Drive 5 tool calls then a stop. The augmenter should append a hint
// starting at iter 2 (remaining=3) through iter 5 (remaining=0).
const responses = [...makeToolCallResponses(5), { content: "done" }];
const innerFetch = fakeFetch(responses);
const seenToolResults: string[] = [];
const fetchSpy = vi.fn(async (url: any, init: any) => {
const body = init?.body ? JSON.parse(init.body) : {};
for (const m of body.messages ?? []) {
if (m.role === "tool") seenToolResults.push(m.content);
}
return innerFetch(url, init);
});
const client = new DeepSeekClient({
apiKey: "sk-test",
fetch: fetchSpy as any,
});
const parent = new ToolRegistry();
parent.register({ name: "noop", readOnly: true, fn: () => "noop-result" });
registerSubagentTool(parent, { client, maxToolIters: 5 });
await parent.dispatch("spawn_subagent", JSON.stringify({ task: "go" }));
expect(seenToolResults.length).toBeGreaterThanOrEqual(5);
// Each tool result is sent on every subsequent turn — dedupe by
// taking the first occurrence of each unique result.
const unique = Array.from(new Set(seenToolResults));
expect(unique).toHaveLength(5);
expect(unique[0]).not.toMatch(/budget:/);
expect(unique[0]).toBe("noop-result");
expect(unique[1]).toMatch(/\[budget: 3 of 5 tool calls left/);
expect(unique[2]).toMatch(/\[budget: 2 of 5 tool calls left/);
expect(unique[3]).toMatch(/\[budget: 1 of 5 tool call left/);
expect(unique[4]).toMatch(/\[budget: 0 of 5 tool calls left — finalize NOW/);
});
});
describe("forkRegistryExcluding", () => {
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { COLOR, GRADIENT } from "../src/cli/ui/theme.js";
import {
DEFAULT_THEME_NAME,
FG,
THEMES,
listThemeNames,
resolveThemeName,
setActiveTheme,
themeTokens,
} from "../src/cli/ui/theme/tokens.js";
describe("theme tokens", () => {
it("resolves missing, auto, and invalid names to the default theme", () => {
expect(resolveThemeName()).toBe(DEFAULT_THEME_NAME);
expect(resolveThemeName("auto")).toBe(DEFAULT_THEME_NAME);
expect(resolveThemeName("unknown")).toBe(DEFAULT_THEME_NAME);
});
it("lists all registered themes", () => {
expect(listThemeNames()).toEqual([
"default",
"dark",
"light",
"tokyo-night",
"github-dark",
"github-light",
"high-contrast",
]);
});
it("provides complete token sets for every theme", () => {
for (const name of listThemeNames()) {
const theme = THEMES[name];
expect(theme.fg.body).toBeTruthy();
expect(theme.tone.brand).toBeTruthy();
expect(theme.toneActive.brand).toBeTruthy();
expect(theme.surface.bg).toBeTruthy();
expect(theme.card.error.color).toBe(theme.tone.err);
expect(theme.card.streaming.color).toBe(theme.tone.brand);
}
});
it("returns theme tokens by resolved name", () => {
expect(themeTokens("github-light")).toBe(THEMES["github-light"]);
expect(themeTokens("bad-name")).toBe(THEMES.default);
});
it("keeps legacy token exports bound to the active theme", () => {
const restore = setActiveTheme(THEMES["github-light"]);
expect(FG.body).toBe(THEMES["github-light"].fg.body);
expect(COLOR.primary).toBe(THEMES["github-light"].tone.brand);
restore();
});
it("keeps legacy token exports object-compatible", () => {
const restore = setActiveTheme(THEMES["github-light"]);
expect(Object.keys(FG)).toEqual(Object.keys(THEMES["github-light"].fg));
expect({ ...COLOR }).toMatchObject({ primary: THEMES["github-light"].tone.brand });
expect(JSON.parse(JSON.stringify(COLOR))).toMatchObject({
primary: THEMES["github-light"].tone.brand,
});
expect(Array.isArray(GRADIENT)).toBe(true);
expect([...GRADIENT]).toEqual([
THEMES["github-light"].tone.ok,
THEMES["github-light"].tone.brand,
THEMES["github-light"].tone.info,
THEMES["github-light"].toneActive.brand,
THEMES["github-light"].toneActive.violet,
THEMES["github-light"].tone.accent,
THEMES["github-light"].toneActive.accent,
THEMES["github-light"].tone.err,
]);
restore();
});
it("restores legacy token exports after scoped active theme cleanup", () => {
const restoreDefault = setActiveTheme(THEMES.default);
const restoreLight = setActiveTheme(THEMES["github-light"]);
expect(FG.body).toBe(THEMES["github-light"].fg.body);
restoreLight();
expect(FG.body).toBe(THEMES.default.fg.body);
restoreDefault();
});
});
+238
View File
@@ -0,0 +1,238 @@
/** create_skill / add_mcp_server — temp homeDir + configPath so the tool never touches the real config. */
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readConfig } from "../src/config.js";
import { ToolRegistry } from "../src/tools.js";
import { registerScaffoldTools, serializeSkill } from "../src/tools/scaffold.js";
interface Setup {
home: string;
projectRoot: string;
configPath: string;
reg: ToolRegistry;
}
function setup(): Setup {
const home = mkdtempSync(join(tmpdir(), "reasonix-scaffold-"));
const projectRoot = mkdtempSync(join(tmpdir(), "reasonix-scaffold-proj-"));
const configPath = join(home, "config.json");
const reg = new ToolRegistry();
registerScaffoldTools(reg, { homeDir: home, projectRoot, configPath });
return { home, projectRoot, configPath, reg };
}
function teardown(s: Setup): void {
rmSync(s.home, { recursive: true, force: true });
rmSync(s.projectRoot, { recursive: true, force: true });
}
async function call(reg: ToolRegistry, name: string, args: Record<string, unknown>): Promise<any> {
const def = reg.get(name);
if (!def) throw new Error(`tool ${name} not registered`);
const out = await def.fn(args);
return JSON.parse(out as string);
}
describe("create_skill", () => {
let s: Setup;
beforeEach(() => {
s = setup();
});
afterEach(() => {
teardown(s);
});
it("scaffolds a project-scope skill with the structured frontmatter", async () => {
const r = await call(s.reg, "create_skill", {
name: "lint-before-commit",
description: "Run typecheck + lint before letting the user commit.",
body: "# lint-before-commit\n\nRun `npm run verify` ...",
});
expect(r.success).toBe(true);
expect(r.scope).toBe("project");
expect(r.path).toContain(".reasonix");
expect(existsSync(r.path)).toBe(true);
const content = readFileSync(r.path, "utf8");
expect(content).toContain("name: lint-before-commit");
expect(content).toContain("description: Run typecheck + lint before letting the user commit.");
expect(content).toContain("# lint-before-commit");
});
it("emits subagent + allowed-tools + model frontmatter when supplied", async () => {
const r = await call(s.reg, "create_skill", {
name: "deep-explore",
description: "Wide-net read-only investigation.",
body: "Use read_file + search_content.",
run_as: "subagent",
allowed_tools: ["read_file", "search_content"],
model: "deepseek-v4-pro",
});
expect(r.success).toBe(true);
const content = readFileSync(r.path, "utf8");
expect(content).toContain("runAs: subagent");
expect(content).toContain("allowed-tools: read_file, search_content");
expect(content).toContain("model: deepseek-v4-pro");
});
it("rejects an invalid skill name", async () => {
const r = await call(s.reg, "create_skill", {
name: "../escape",
description: "x",
body: "y",
});
expect(r.error).toMatch(/invalid skill name/);
});
it("refuses to overwrite an existing skill", async () => {
await call(s.reg, "create_skill", {
name: "twice",
description: "first",
body: "first body",
});
const r = await call(s.reg, "create_skill", {
name: "twice",
description: "second",
body: "second body",
});
expect(r.error).toMatch(/already exists/);
});
it("rejects bad allowed_tools entries", async () => {
const r = await call(s.reg, "create_skill", {
name: "bad-tools",
description: "x",
body: "y",
allowed_tools: ["valid_tool", "with space"],
});
expect(r.error).toMatch(/invalid tool name/);
});
});
describe("serializeSkill", () => {
it("omits optional frontmatter fields when not set", () => {
const out = serializeSkill({
name: "minimal",
description: "no extras",
runAs: "inline",
body: "body here",
});
expect(out).toContain("name: minimal");
expect(out).toContain("description: no extras");
expect(out).not.toContain("runAs:");
expect(out).not.toContain("allowed-tools:");
expect(out).not.toContain("model:");
});
});
describe("add_mcp_server", () => {
let s: Setup;
beforeEach(() => {
s = setup();
});
afterEach(() => {
teardown(s);
});
it("registers a stdio server in cfg.mcp", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "myserver",
transport: "stdio",
command: "node",
args: ["server.js"],
});
expect(r.success).toBe(true);
expect(r.transport).toBe("stdio");
expect(r.spec).toBe("myserver=node server.js");
const cfg = readConfig(s.configPath);
expect(cfg.mcp).toEqual(["myserver=node server.js"]);
});
it("registers a streamable-http server with the streamable+ prefix", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "remote",
transport: "streamable-http",
url: "https://example.com/mcp",
});
expect(r.success).toBe(true);
expect(r.spec).toBe("remote=streamable+https://example.com/mcp");
});
it("registers an SSE server", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "events",
transport: "sse",
url: "https://example.com/events",
});
expect(r.success).toBe(true);
expect(r.spec).toBe("events=https://example.com/events");
});
it("fills command + args from the catalog when from_catalog is set", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "memory",
from_catalog: "memory",
});
expect(r.success).toBe(true);
expect(r.spec).toBe("memory=npx -y @modelcontextprotocol/server-memory");
});
it("requires user-args for catalog entries that need them", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "fs",
from_catalog: "filesystem",
});
expect(r.error).toMatch(/needs <dir>/);
});
it("preflights the filesystem sandbox and refuses missing directories", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "fs",
from_catalog: "filesystem",
args: [join(tmpdir(), "definitely-does-not-exist-xyz-12345")],
});
expect(r.error).toMatch(/does not exist/);
});
it("rejects a name collision with an existing entry", async () => {
await call(s.reg, "add_mcp_server", {
name: "dup",
transport: "stdio",
command: "node",
args: ["a.js"],
});
const r = await call(s.reg, "add_mcp_server", {
name: "dup",
transport: "stdio",
command: "node",
args: ["b.js"],
});
expect(r.error).toMatch(/already registered/);
});
it("rejects a non-http url", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "bad",
transport: "sse",
url: "ftp://example.com",
});
expect(r.error).toMatch(/http/);
});
it("rejects an unknown catalog entry", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "x",
from_catalog: "not-a-real-server",
});
expect(r.error).toMatch(/unknown catalog/);
});
it("requires transport when from_catalog is absent", async () => {
const r = await call(s.reg, "add_mcp_server", {
name: "x",
});
expect(r.error).toMatch(/transport/);
});
});