mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29020327fe | ||
|
|
7642ad6ba2 | ||
|
|
e205c59775 | ||
|
|
874f0d1cc1 | ||
|
|
2462b1c278 | ||
|
|
d421970a27 | ||
|
|
d1f491b29f | ||
|
|
3f6fb51b08 | ||
|
|
c63239ddb8 | ||
|
|
ec5bfabde2 | ||
|
|
a525182aff | ||
|
|
f795583cea | ||
|
|
bb971bd403 | ||
|
|
06204c760e | ||
|
|
c504707af8 | ||
|
|
f0a552bed5 | ||
|
|
bb5b124817 | ||
|
|
636ad7e551 | ||
|
|
1c360ac81b | ||
|
|
c120ed3a39 | ||
|
|
42f75c7478 | ||
|
|
34b8275f20 |
No files matched your search
+298
@@ -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
@@ -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); }
|
||||
|
||||
|
||||
@@ -214,12 +214,19 @@ export const en = {
|
||||
dailyMeta: "cost · cache saved · turns",
|
||||
noData: "No usage data yet — run a turn in reasonix chat / code / run and refresh.",
|
||||
windows: "Rolling windows",
|
||||
colWindow: "window",
|
||||
colTurns: "turns",
|
||||
colCacheHit: "cache hit",
|
||||
colCost: "cost (USD)",
|
||||
colCacheSaved: "cache saved",
|
||||
colVsClaude: "vs Claude",
|
||||
colSaved: "saved",
|
||||
axisTime: "time",
|
||||
axisUsd: "USD",
|
||||
axisTurns: "turns",
|
||||
seriesCost: "cost",
|
||||
seriesCacheSaved: "cache saved",
|
||||
seriesTurns: "turns",
|
||||
mostUsed: "Most used models",
|
||||
colModel: "model",
|
||||
},
|
||||
|
||||
@@ -211,12 +211,19 @@ export const zhCN = {
|
||||
dailyMeta: "费用 · 缓存节省 · 轮次",
|
||||
noData: "暂无用量数据 — 在 reasonix chat / code / run 中执行一轮,然后刷新。",
|
||||
windows: "滚动窗口",
|
||||
colWindow: "时间范围",
|
||||
colTurns: "轮次",
|
||||
colCacheHit: "缓存命中",
|
||||
colCost: "费用 (USD)",
|
||||
colCacheSaved: "缓存节省",
|
||||
colVsClaude: "对比 Claude",
|
||||
colSaved: "节省",
|
||||
axisTime: "时间",
|
||||
axisUsd: "美元",
|
||||
axisTurns: "轮次",
|
||||
seriesCost: "费用",
|
||||
seriesCacheSaved: "缓存节省",
|
||||
seriesTurns: "轮次",
|
||||
mostUsed: "最常用模型",
|
||||
colModel: "模型",
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ const TO_BACKEND = new Map(LANG_REGISTRY);
|
||||
const FROM_BACKEND = new Map(LANG_REGISTRY.map(([d, b]) => [b, d]));
|
||||
|
||||
const STORAGE_KEY = "rx.lang";
|
||||
const EXPLICIT_KEY = "rx.langExplicit";
|
||||
const listeners: Listener[] = [];
|
||||
let currentLang: DashboardLang = loadFromStorage() ?? "en";
|
||||
|
||||
@@ -30,22 +29,6 @@ function loadFromStorage(): DashboardLang | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExplicit(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(EXPLICIT_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markExplicit(): void {
|
||||
try {
|
||||
localStorage.setItem(EXPLICIT_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function toBackendLang(lang: DashboardLang): string {
|
||||
return TO_BACKEND.get(lang) ?? lang;
|
||||
}
|
||||
@@ -54,23 +37,12 @@ function fromBackendLang(raw: string): DashboardLang {
|
||||
return (FROM_BACKEND.get(raw) as DashboardLang) ?? "en";
|
||||
}
|
||||
|
||||
/** Fetch lang from backend on startup. */
|
||||
/** Adopt server lang on startup; localStorage is render-cache only, never pushed back. */
|
||||
export async function initLangFromServer(): Promise<void> {
|
||||
try {
|
||||
const stored = loadFromStorage();
|
||||
const res = await api<{ lang?: string }>("/settings");
|
||||
const serverLang = res.lang ? fromBackendLang(res.lang) : null;
|
||||
|
||||
if (!serverLang || stored === serverLang) return;
|
||||
|
||||
if (isExplicit() && stored) {
|
||||
// User explicitly chose a language — push to server.
|
||||
api("/settings", { method: "POST", body: { lang: toBackendLang(stored) } })
|
||||
.catch((err) => console.error("[reasonix dashboard] lang sync:", err));
|
||||
return;
|
||||
}
|
||||
|
||||
// No explicit choice yet (first visit or cleared storage) — adopt server value.
|
||||
if (!serverLang || serverLang === currentLang) return;
|
||||
currentLang = serverLang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, serverLang);
|
||||
@@ -79,7 +51,7 @@ export async function initLangFromServer(): Promise<void> {
|
||||
}
|
||||
for (const cb of listeners) cb();
|
||||
} catch {
|
||||
/* offline — keep localStorage value */
|
||||
/* offline — keep last-known value rendering */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +62,6 @@ export function getLang(): DashboardLang {
|
||||
export function setLang(lang: DashboardLang): void {
|
||||
if (!SUPPORTED.has(lang)) return;
|
||||
currentLang = lang;
|
||||
markExplicit();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch {
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** @url mentions — async sibling of @path. Fetches each URL once and inlines under "Referenced URLs". */
|
||||
|
||||
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
|
||||
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
|
||||
|
||||
/** Default cap on inlined URL body (chars). */
|
||||
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
|
||||
|
||||
export interface AtUrlExpansion {
|
||||
/** The raw `@url` token as it appeared in the text. */
|
||||
token: string;
|
||||
/** Absolute URL (after trailing-punctuation strip). */
|
||||
url: string;
|
||||
/** True if content was inlined. False = skipped (reason in `skip`). */
|
||||
ok: boolean;
|
||||
/** Page title when extractable from `<title>`. */
|
||||
title?: string;
|
||||
/** Char count of the (post-truncation) inlined body. */
|
||||
chars?: number;
|
||||
/** True iff the original page exceeded `maxChars` and was clipped. */
|
||||
truncated?: boolean;
|
||||
/** Why the mention was skipped — set when ok=false. */
|
||||
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
|
||||
/** Free-form error message attached to skip outcomes. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AtUrlOptions {
|
||||
/** Max chars of inlined body per URL. */
|
||||
maxChars?: number;
|
||||
/** Per-URL fetch timeout in ms. */
|
||||
timeoutMs?: number;
|
||||
fetcher?: (
|
||||
url: string,
|
||||
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
|
||||
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
|
||||
cache?: Map<string, AtUrlExpansion & { body?: string }>;
|
||||
/** Forward Esc/abort to the fetcher. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function expandAtUrls(
|
||||
text: string,
|
||||
opts: AtUrlOptions = {},
|
||||
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
|
||||
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
|
||||
const fetcher = opts.fetcher;
|
||||
if (!fetcher) {
|
||||
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
|
||||
}
|
||||
|
||||
const seen = new Map<string, AtUrlExpansion>();
|
||||
const bodies = new Map<string, string>();
|
||||
const order: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(AT_URL_PATTERN)) {
|
||||
const rawUrl = match[1] ?? "";
|
||||
const url = stripUrlTail(rawUrl);
|
||||
if (!url) continue;
|
||||
if (seen.has(url)) continue;
|
||||
|
||||
const cached = opts.cache?.get(url);
|
||||
if (cached) {
|
||||
seen.set(url, cached);
|
||||
if (cached.body) bodies.set(url, cached.body);
|
||||
order.push(url);
|
||||
continue;
|
||||
}
|
||||
|
||||
let expansion: AtUrlExpansion;
|
||||
let body = "";
|
||||
try {
|
||||
const page = await fetcher(url, {
|
||||
maxChars,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
signal: opts.signal,
|
||||
});
|
||||
body = page.text;
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: true,
|
||||
title: page.title,
|
||||
chars: body.length,
|
||||
truncated: page.truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? String(err);
|
||||
let skip: AtUrlExpansion["skip"] = "fetch-error";
|
||||
if (/aborted|timeout/i.test(message)) skip = "timeout";
|
||||
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: false,
|
||||
skip,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
seen.set(url, expansion);
|
||||
if (body) bodies.set(url, body);
|
||||
if (opts.cache) opts.cache.set(url, { ...expansion, body });
|
||||
order.push(url);
|
||||
}
|
||||
|
||||
if (seen.size === 0) return { text, expansions: [] };
|
||||
|
||||
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
|
||||
const blocks: string[] = [];
|
||||
for (const ex of expansions) {
|
||||
if (ex.ok) {
|
||||
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
|
||||
const truncTag = ex.truncated ? ' truncated="true"' : "";
|
||||
const body = bodies.get(ex.url) ?? "";
|
||||
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
|
||||
} else {
|
||||
const reasonAttr = ex.skip ?? "fetch-error";
|
||||
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
|
||||
}
|
||||
}
|
||||
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
|
||||
return { text: augmented, expansions };
|
||||
}
|
||||
|
||||
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
|
||||
export function stripUrlTail(raw: string): string {
|
||||
let s = raw;
|
||||
while (s.length > 0) {
|
||||
const last = s[s.length - 1]!;
|
||||
if (".,;:!?".includes(last)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (")]}>".includes(last)) {
|
||||
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
|
||||
last as ")" | "]" | "}" | ">"
|
||||
];
|
||||
if (!s.includes(open)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, """)
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
+185
-194
@@ -120,18 +120,65 @@ export async function listFilesWithStatsAsync(
|
||||
root: string,
|
||||
opts: ListFilesOptions = {},
|
||||
): Promise<FileWithStats[]> {
|
||||
const maxResults = Math.max(1, opts.maxResults ?? 2000);
|
||||
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
|
||||
const rootAbs = resolve(root);
|
||||
const respectGi = opts.respectGitignore !== false;
|
||||
const out: FileWithStats[] = [];
|
||||
const maxResults = Math.max(1, opts.maxResults ?? 2000);
|
||||
await walkFilesStream(root, {
|
||||
...opts,
|
||||
onEntry: (e) => {
|
||||
out.push(e);
|
||||
return out.length < maxResults;
|
||||
},
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface StreamWalkOptions {
|
||||
ignoreDirs?: readonly string[];
|
||||
respectGitignore?: boolean;
|
||||
signal?: AbortSignal;
|
||||
/** Called per file entry. Return false to halt the walk. */
|
||||
onEntry: (entry: FileWithStats) => boolean | undefined;
|
||||
/** Called periodically with the running file-count. */
|
||||
onProgress?: (scanned: number) => void;
|
||||
/** Default 100ms — minimum gap between onProgress calls. */
|
||||
progressIntervalMs?: number;
|
||||
}
|
||||
|
||||
/** Cancelable, streaming walker. Drives `listFilesWithStatsAsync` and the picker's search-mode walk. */
|
||||
export async function walkFilesStream(
|
||||
root: string,
|
||||
opts: StreamWalkOptions,
|
||||
): Promise<{ scanned: number; cancelled: boolean }> {
|
||||
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
|
||||
const respectGi = opts.respectGitignore !== false;
|
||||
const rootAbs = resolve(root);
|
||||
const progressGap = Math.max(0, opts.progressIntervalMs ?? 100);
|
||||
let scanned = 0;
|
||||
let halted = false;
|
||||
let lastProgress = 0;
|
||||
|
||||
const reportProgress = (force: boolean) => {
|
||||
if (!opts.onProgress) return;
|
||||
const now = Date.now();
|
||||
if (force || now - lastProgress >= progressGap) {
|
||||
lastProgress = now;
|
||||
opts.onProgress(scanned);
|
||||
}
|
||||
};
|
||||
|
||||
const emit = (entry: FileWithStats) => {
|
||||
scanned++;
|
||||
if (halted) return;
|
||||
if (opts.onEntry(entry) === false) halted = true;
|
||||
reportProgress(false);
|
||||
};
|
||||
|
||||
const walk = async (
|
||||
dirAbs: string,
|
||||
dirRel: string,
|
||||
layers: readonly GitignoreLayer[],
|
||||
): Promise<void> => {
|
||||
if (out.length >= maxResults) return;
|
||||
if (halted || opts.signal?.aborted) return;
|
||||
let effectiveLayers = layers;
|
||||
if (respectGi) {
|
||||
const ig = await loadGitignoreAt(dirAbs);
|
||||
@@ -144,53 +191,41 @@ export async function listFilesWithStatsAsync(
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// Stats batched per directory to amortize syscall overhead. Recursion stays
|
||||
// sequential so the merged DFS order matches the sync walker's contract.
|
||||
const fileEnts: Dirent[] = [];
|
||||
for (const ent of entries) {
|
||||
if (out.length >= maxResults) break;
|
||||
const relPath = dirRel ? `${dirRel}/${ent.name}` : ent.name;
|
||||
if (halted || opts.signal?.aborted) break;
|
||||
const absPath = join(dirAbs, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
|
||||
if (ignoredByLayers(effectiveLayers, absPath, true)) continue;
|
||||
// Drain pending file stats from THIS directory before
|
||||
// descending so the output order stays DFS-alphabetical.
|
||||
if (fileEnts.length > 0) {
|
||||
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
|
||||
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
|
||||
fileEnts.length = 0;
|
||||
if (out.length >= maxResults) return;
|
||||
if (halted || opts.signal?.aborted) return;
|
||||
}
|
||||
await walk(absPath, relPath, effectiveLayers);
|
||||
await walk(absPath, dirRel ? `${dirRel}/${ent.name}` : ent.name, effectiveLayers);
|
||||
} else if (ent.isFile() || ent.isSymbolicLink()) {
|
||||
// Symlinks land in the same batch — statBatch resolves them and drops
|
||||
// any whose target isn't a regular file (broken or symlink-to-dir).
|
||||
fileEnts.push(ent);
|
||||
}
|
||||
}
|
||||
if (fileEnts.length > 0 && out.length < maxResults) {
|
||||
await statBatch(fileEnts, dirAbs, dirRel, out, maxResults, effectiveLayers);
|
||||
if (fileEnts.length > 0 && !halted && !opts.signal?.aborted) {
|
||||
await flushFiles(fileEnts, dirAbs, dirRel, effectiveLayers, emit);
|
||||
}
|
||||
};
|
||||
|
||||
await walk(rootAbs, "", []);
|
||||
return out;
|
||||
reportProgress(true);
|
||||
return { scanned, cancelled: !!opts.signal?.aborted };
|
||||
}
|
||||
|
||||
async function statBatch(
|
||||
async function flushFiles(
|
||||
ents: readonly Dirent[],
|
||||
dirAbs: string,
|
||||
dirRel: string,
|
||||
out: FileWithStats[],
|
||||
maxResults: number,
|
||||
layers: readonly GitignoreLayer[],
|
||||
emit: (e: FileWithStats) => void,
|
||||
): Promise<void> {
|
||||
const accepted: Dirent[] = [];
|
||||
for (const e of ents) {
|
||||
if (out.length + accepted.length >= maxResults) break;
|
||||
if (ignoredByLayers(layers, join(dirAbs, e.name), false)) continue;
|
||||
accepted.push(e);
|
||||
}
|
||||
const accepted = ents.filter((e) => !ignoredByLayers(layers, join(dirAbs, e.name), false));
|
||||
const stats = await Promise.all(
|
||||
accepted.map((e) =>
|
||||
stat(join(dirAbs, e.name))
|
||||
@@ -201,17 +236,127 @@ async function statBatch(
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const ent = accepted[i]!;
|
||||
const s = stats[i];
|
||||
if (ent.isSymbolicLink()) {
|
||||
// Drop broken symlinks and symlinks-to-dirs (latter would cycle).
|
||||
if (!s || !s.isFile) continue;
|
||||
}
|
||||
out.push({
|
||||
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
|
||||
emit({
|
||||
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
|
||||
mtimeMs: s?.mtimeMs ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
/** Relative-to-root path (forward slashes). For dirs, no trailing slash. */
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
/** 0 for directories (no stat), real mtime for files. */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ListDirectoryOptions {
|
||||
ignoreDirs?: readonly string[];
|
||||
respectGitignore?: boolean;
|
||||
}
|
||||
|
||||
/** One-level browse for the @-picker. Folders first then files, alpha within each group. Resolves outside-root to []. */
|
||||
export async function listDirectory(
|
||||
root: string,
|
||||
relDir: string,
|
||||
opts: ListDirectoryOptions = {},
|
||||
): Promise<DirEntry[]> {
|
||||
const ignoreDirs = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
|
||||
const respectGi = opts.respectGitignore !== false;
|
||||
const rootAbs = resolve(root);
|
||||
const dirAbs = resolve(rootAbs, relDir);
|
||||
const rel = relative(rootAbs, dirAbs);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) return [];
|
||||
|
||||
const layers: GitignoreLayer[] = [];
|
||||
if (respectGi) {
|
||||
const segs = rel ? rel.split(/[\\/]/) : [];
|
||||
let cursor = rootAbs;
|
||||
const ig = await loadGitignoreAt(cursor);
|
||||
if (ig) layers.push({ dirAbs: cursor, ig });
|
||||
for (const seg of segs) {
|
||||
cursor = join(cursor, seg);
|
||||
const igSeg = await loadGitignoreAt(cursor);
|
||||
if (igSeg) layers.push({ dirAbs: cursor, ig: igSeg });
|
||||
}
|
||||
}
|
||||
|
||||
let raw: Dirent[];
|
||||
try {
|
||||
raw = await readdir(dirAbs, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const dirRel = rel.split(/[\\/]/).join("/");
|
||||
const dirs: DirEntry[] = [];
|
||||
const files: Dirent[] = [];
|
||||
for (const ent of raw) {
|
||||
const absPath = join(dirAbs, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
if (ent.name.startsWith(".") || ignoreDirs.has(ent.name)) continue;
|
||||
if (ignoredByLayers(layers, absPath, true)) continue;
|
||||
dirs.push({
|
||||
name: ent.name,
|
||||
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
|
||||
isDir: true,
|
||||
mtimeMs: 0,
|
||||
});
|
||||
} else if (ent.isFile() || ent.isSymbolicLink()) {
|
||||
if (ignoredByLayers(layers, absPath, false)) continue;
|
||||
files.push(ent);
|
||||
}
|
||||
}
|
||||
const stats = await Promise.all(
|
||||
files.map((e) =>
|
||||
stat(join(dirAbs, e.name))
|
||||
.then((s) => ({ mtimeMs: s.mtimeMs, isFile: s.isFile() }))
|
||||
.catch(() => null),
|
||||
),
|
||||
);
|
||||
const fileEntries: DirEntry[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const ent = files[i]!;
|
||||
const s = stats[i];
|
||||
if (ent.isSymbolicLink() && (!s || !s.isFile)) continue;
|
||||
fileEntries.push({
|
||||
name: ent.name,
|
||||
path: dirRel ? `${dirRel}/${ent.name}` : ent.name,
|
||||
isDir: false,
|
||||
mtimeMs: s?.mtimeMs ?? 0,
|
||||
});
|
||||
}
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
fileEntries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...dirs, ...fileEntries];
|
||||
}
|
||||
|
||||
export interface ParsedAtQuery {
|
||||
/** Directory portion (rel from root, no trailing slash). Empty = root. */
|
||||
dir: string;
|
||||
/** Filter portion — chars after the last slash. Empty if query ended in `/`. */
|
||||
filter: string;
|
||||
/** True if the query ended in `/` — caller knows to browse `dir`. */
|
||||
trailingSlash: boolean;
|
||||
}
|
||||
|
||||
/** Split `src/auth/log` → `{dir: "src/auth", filter: "log"}`; trailing slash sets `trailingSlash` and clears filter. */
|
||||
export function parseAtQuery(query: string): ParsedAtQuery {
|
||||
const normalized = query.replace(/\\/g, "/");
|
||||
const trailingSlash = normalized.endsWith("/");
|
||||
const trimmed = trailingSlash ? normalized.slice(0, -1) : normalized;
|
||||
const lastSlash = trimmed.lastIndexOf("/");
|
||||
if (trailingSlash) return { dir: trimmed, filter: "", trailingSlash: true };
|
||||
if (lastSlash < 0) return { dir: "", filter: trimmed, trailingSlash: false };
|
||||
return {
|
||||
dir: trimmed.slice(0, lastSlash),
|
||||
filter: trimmed.slice(lastSlash + 1),
|
||||
trailingSlash: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Trailing-token only, anchored at end-of-input — distinct from `AT_MENTION_PATTERN` which scans all. */
|
||||
export const AT_PICKER_PREFIX = /(?:^|\s)@([a-zA-Z0-9_./\\-]*)$/;
|
||||
|
||||
@@ -523,164 +668,10 @@ const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
|
||||
read: (p) => readFileSync(p, "utf8"),
|
||||
};
|
||||
|
||||
// @url mentions — async sibling of @path. Matches `@http(s)://...` after a
|
||||
// word boundary, fetches each URL once per session (in-memory cache), and
|
||||
// appends a "Referenced URLs" block under the prompt the model sees. Uses
|
||||
// the same web-fetch + HTML-strip pipeline as the model's `web_fetch` tool
|
||||
// so a `@url` reference and a model-issued fetch produce identical content.
|
||||
|
||||
/** Trailing punctuation stripped separately — URLs legitimately contain `,` `.` `)` in query strings. */
|
||||
export const AT_URL_PATTERN = /(?<=^|\s)@(https?:\/\/\S+)/g;
|
||||
|
||||
/** Default cap on inlined URL body (chars). Matches DEFAULT_AT_MENTION_MAX_BYTES order-of-magnitude. */
|
||||
export const DEFAULT_AT_URL_MAX_CHARS = 32_000;
|
||||
|
||||
export interface AtUrlExpansion {
|
||||
/** The raw `@url` token as it appeared in the text. */
|
||||
token: string;
|
||||
/** Absolute URL (after trailing-punctuation strip). */
|
||||
url: string;
|
||||
/** True if content was inlined. False = skipped (reason in `skip`). */
|
||||
ok: boolean;
|
||||
/** Page title when extractable from `<title>`. */
|
||||
title?: string;
|
||||
/** Char count of the (post-truncation) inlined body. */
|
||||
chars?: number;
|
||||
/** True iff the original page exceeded `maxChars` and was clipped. */
|
||||
truncated?: boolean;
|
||||
/** Why the mention was skipped — set when ok=false. */
|
||||
skip?: "fetch-error" | "non-text" | "timeout" | "blocked";
|
||||
/** Free-form error message attached to skip outcomes. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AtUrlOptions {
|
||||
/** Max chars of inlined body per URL. Default DEFAULT_AT_URL_MAX_CHARS. */
|
||||
maxChars?: number;
|
||||
/** Per-URL fetch timeout in ms. */
|
||||
timeoutMs?: number;
|
||||
fetcher?: (
|
||||
url: string,
|
||||
opts: { maxChars?: number; timeoutMs?: number; signal?: AbortSignal },
|
||||
) => Promise<{ url: string; title?: string; text: string; truncated: boolean }>;
|
||||
cache?: Map<string, AtUrlExpansion & { body?: string }>;
|
||||
/** Forward Esc/abort to the fetcher. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function expandAtUrls(
|
||||
text: string,
|
||||
opts: AtUrlOptions = {},
|
||||
): Promise<{ text: string; expansions: AtUrlExpansion[] }> {
|
||||
const maxChars = opts.maxChars ?? DEFAULT_AT_URL_MAX_CHARS;
|
||||
const fetcher = opts.fetcher;
|
||||
if (!fetcher) {
|
||||
throw new Error("expandAtUrls: fetcher option is required (wire src/tools/web.ts:webFetch)");
|
||||
}
|
||||
|
||||
// De-dupe by URL so the same `@https://x.com` referenced twice fetches once.
|
||||
const seen = new Map<string, AtUrlExpansion>();
|
||||
const bodies = new Map<string, string>();
|
||||
const order: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(AT_URL_PATTERN)) {
|
||||
const rawUrl = match[1] ?? "";
|
||||
const url = stripUrlTail(rawUrl);
|
||||
if (!url) continue;
|
||||
if (seen.has(url)) continue;
|
||||
|
||||
const cached = opts.cache?.get(url);
|
||||
if (cached) {
|
||||
seen.set(url, cached);
|
||||
if (cached.body) bodies.set(url, cached.body);
|
||||
order.push(url);
|
||||
continue;
|
||||
}
|
||||
|
||||
let expansion: AtUrlExpansion;
|
||||
let body = "";
|
||||
try {
|
||||
const page = await fetcher(url, {
|
||||
maxChars,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
signal: opts.signal,
|
||||
});
|
||||
body = page.text;
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: true,
|
||||
title: page.title,
|
||||
chars: body.length,
|
||||
truncated: page.truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? String(err);
|
||||
// Tag a few common shapes so the UI can hint at causes.
|
||||
let skip: AtUrlExpansion["skip"] = "fetch-error";
|
||||
if (/aborted|timeout/i.test(message)) skip = "timeout";
|
||||
else if (/40\d|forbidden|access denied|captcha/i.test(message)) skip = "blocked";
|
||||
expansion = {
|
||||
token: `@${url}`,
|
||||
url,
|
||||
ok: false,
|
||||
skip,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
seen.set(url, expansion);
|
||||
if (body) bodies.set(url, body);
|
||||
if (opts.cache) opts.cache.set(url, { ...expansion, body });
|
||||
order.push(url);
|
||||
}
|
||||
|
||||
if (seen.size === 0) return { text, expansions: [] };
|
||||
|
||||
const expansions = order.map((u) => seen.get(u)!).filter(Boolean);
|
||||
const blocks: string[] = [];
|
||||
for (const ex of expansions) {
|
||||
if (ex.ok) {
|
||||
const titleAttr = ex.title ? ` title="${escapeAttr(ex.title)}"` : "";
|
||||
const truncTag = ex.truncated ? ' truncated="true"' : "";
|
||||
const body = bodies.get(ex.url) ?? "";
|
||||
blocks.push(`<url href="${ex.url}"${titleAttr}${truncTag}>\n${body}\n</url>`);
|
||||
} else {
|
||||
const reasonAttr = ex.skip ?? "fetch-error";
|
||||
blocks.push(`<url href="${ex.url}" skipped="${reasonAttr}" />`);
|
||||
}
|
||||
}
|
||||
const augmented = `${text}\n\n[Referenced URLs]\n${blocks.join("\n\n")}`;
|
||||
return { text: augmented, expansions };
|
||||
}
|
||||
|
||||
/** Only strips `.,;:!?` and unmatched close-brackets — internal path / query punctuation preserved. */
|
||||
export function stripUrlTail(raw: string): string {
|
||||
let s = raw;
|
||||
while (s.length > 0) {
|
||||
const last = s[s.length - 1]!;
|
||||
if (".,;:!?".includes(last)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
if (")]}>".includes(last)) {
|
||||
// Only strip if the matching open bracket isn't elsewhere in the
|
||||
// URL — avoids butchering legitimate `(thing)` query fragments.
|
||||
const open = ({ ")": "(", "]": "[", "}": "{", ">": "<" } as const)[
|
||||
last as ")" | "]" | "}" | ">"
|
||||
];
|
||||
if (!s.includes(open)) {
|
||||
s = s.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, """)
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
export {
|
||||
AT_URL_PATTERN,
|
||||
DEFAULT_AT_URL_MAX_CHARS,
|
||||
expandAtUrls,
|
||||
stripUrlTail,
|
||||
} from "./at-mentions-url.js";
|
||||
export type { AtUrlExpansion, AtUrlOptions } from "./at-mentions-url.js";
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Box } from "ink";
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
@@ -40,7 +41,7 @@ function PlanCheckpointConfirmInner({
|
||||
const statuses = buildStatusMap(steps, completedStepIds, stepId, isLast);
|
||||
const subtitle = counter ? `${counter} · ${label}` : label;
|
||||
return (
|
||||
<ApprovalCard tone="ok" glyph="⛁" title="Checkpoint — step done" metaRight={subtitle}>
|
||||
<ApprovalCard tone="ok" glyph="⛁" title={t("planFlow.checkpoint.title")} metaRight={subtitle}>
|
||||
{steps && steps.length > 0 ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<PlanStepList steps={steps} statuses={statuses} focusStepId={stepId} />
|
||||
@@ -51,18 +52,18 @@ function PlanCheckpointConfirmInner({
|
||||
items={[
|
||||
{
|
||||
value: "continue",
|
||||
label: "Continue — run the next step",
|
||||
hint: "Model resumes with the next step.",
|
||||
label: t("planFlow.checkpoint.continue"),
|
||||
hint: t("planFlow.checkpoint.continueHint"),
|
||||
},
|
||||
{
|
||||
value: "revise",
|
||||
label: "Revise — give feedback before the next step",
|
||||
hint: "Stay paused, type guidance; model adjusts the remaining plan.",
|
||||
label: t("planFlow.checkpoint.revise"),
|
||||
hint: t("planFlow.checkpoint.reviseHint"),
|
||||
},
|
||||
{
|
||||
value: "stop",
|
||||
label: "Stop — end the plan here",
|
||||
hint: "Model summarizes what was done and ends.",
|
||||
label: t("planFlow.checkpoint.stop"),
|
||||
hint: t("planFlow.checkpoint.stopHint"),
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as CheckpointChoice)}
|
||||
|
||||
+36
-22
@@ -2,12 +2,14 @@
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { MarkdownView } from "./markdown-view.js";
|
||||
import { extractOpenQuestionsSection } from "./plan-open-questions.js";
|
||||
import { CARD, FG, TONE } from "./theme/tokens.js";
|
||||
|
||||
export type PlanConfirmChoice = "approve" | "refine" | "revise" | "cancel";
|
||||
@@ -26,6 +28,7 @@ const PLAN_BODY_PREVIEW_LINES = 24;
|
||||
function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
const stepRows = steps?.length ?? 0;
|
||||
const hasSteps = stepRows > 0;
|
||||
const openQuestions = extractOpenQuestionsSection(plan);
|
||||
const planLines = plan.split("\n");
|
||||
const truncatedBody = planLines.length > PLAN_BODY_PREVIEW_LINES;
|
||||
const previewBody = truncatedBody ? planLines.slice(0, PLAN_BODY_PREVIEW_LINES).join("\n") : plan;
|
||||
@@ -33,26 +36,34 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
? PLAN_BODY_PREVIEW_LINES
|
||||
: Math.min(planLines.length, PLAN_BODY_PREVIEW_LINES);
|
||||
const reservedFor = hasSteps ? stepRows : previewRows;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + 14) });
|
||||
const oqRows = openQuestions ? openQuestions.split("\n").length : 0;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(16, reservedFor + oqRows + 14) });
|
||||
|
||||
const hasOpenQuestions =
|
||||
/^#{1,6}\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear)/im.test(plan) ||
|
||||
/^#{1,6}\s*(待确认|开放问题|风险|未知|假设|不确定)/im.test(plan);
|
||||
const refineLabel = t("planFlow.picker.refine");
|
||||
const bannerTemplate = t("planFlow.openQuestionsBanner");
|
||||
const [bannerBefore, bannerAfter] = bannerTemplate.split("{refine}");
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
tone="accent"
|
||||
glyph="⊞"
|
||||
title="Approve plan"
|
||||
metaRight="awaiting"
|
||||
title={t("planFlow.approveCardTitle")}
|
||||
metaRight={t("planFlow.approveCardMetaRight")}
|
||||
metaRightColor={CARD.plan.color}
|
||||
>
|
||||
{hasOpenQuestions ? (
|
||||
<Box marginBottom={1}>
|
||||
{openQuestions ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={TONE.warn}>
|
||||
▲ the plan flags open questions or risks — pick <Text bold>refine</Text> to write
|
||||
concrete answers before the model moves on.
|
||||
{bannerBefore ?? ""}
|
||||
<Text bold>{refineLabel}</Text>
|
||||
{bannerAfter ?? ""}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={TONE.warn} bold>
|
||||
{t("planFlow.openQuestionsHeader")}
|
||||
</Text>
|
||||
<MarkdownView text={openQuestions} />
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
{hasSteps ? (
|
||||
@@ -64,35 +75,38 @@ function PlanConfirmInner({ plan, steps, onChoose }: PlanConfirmProps) {
|
||||
<MarkdownView text={previewBody} />
|
||||
{truncatedBody ? (
|
||||
<Text color={FG.faint}>
|
||||
{`… ${planLines.length - PLAN_BODY_PREVIEW_LINES} more line${
|
||||
planLines.length - PLAN_BODY_PREVIEW_LINES === 1 ? "" : "s"
|
||||
} above in scrollback`}
|
||||
{t(
|
||||
planLines.length - PLAN_BODY_PREVIEW_LINES === 1
|
||||
? "planFlow.truncatedBodyMore"
|
||||
: "planFlow.truncatedBodyMorePlural",
|
||||
{ n: planLines.length - PLAN_BODY_PREVIEW_LINES },
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<SingleSelect
|
||||
initialValue={hasOpenQuestions ? "refine" : "approve"}
|
||||
initialValue={openQuestions ? "refine" : "approve"}
|
||||
items={[
|
||||
{
|
||||
value: "approve",
|
||||
label: "accept",
|
||||
hint: "run it now, in order",
|
||||
label: t("planFlow.picker.accept"),
|
||||
hint: t("planFlow.picker.acceptHint"),
|
||||
},
|
||||
{
|
||||
value: "refine",
|
||||
label: "refine",
|
||||
hint: "give the agent more guidance, draft a new plan",
|
||||
label: refineLabel,
|
||||
hint: t("planFlow.picker.refineHint"),
|
||||
},
|
||||
{
|
||||
value: "revise",
|
||||
label: "revise",
|
||||
hint: "edit the plan inline before running (skip / reorder steps)",
|
||||
label: t("planFlow.picker.revise"),
|
||||
hint: t("planFlow.picker.reviseHint"),
|
||||
},
|
||||
{
|
||||
value: "cancel",
|
||||
label: "reject",
|
||||
hint: "discard, agent will retry from scratch",
|
||||
label: t("planFlow.picker.reject"),
|
||||
hint: t("planFlow.picker.rejectHint"),
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import { ApprovalCard, type ApprovalCardProps } from "./cards/ApprovalCard.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { CARD, FG } from "./theme/tokens.js";
|
||||
import { MarkdownView } from "./markdown-view.js";
|
||||
import { CARD, FG, TONE } from "./theme/tokens.js";
|
||||
import { useTick } from "./ticker.js";
|
||||
|
||||
export type PlanRefineMode =
|
||||
| "approve"
|
||||
| "refine"
|
||||
| "reject"
|
||||
| "checkpoint-revise"
|
||||
| "choice-custom";
|
||||
|
||||
export interface PlanRefineInputProps {
|
||||
mode: "approve" | "refine" | "reject" | "checkpoint-revise" | "choice-custom";
|
||||
mode: PlanRefineMode;
|
||||
/** Open-questions / risks block extracted from the plan, rendered above the input on refine. */
|
||||
questions?: string;
|
||||
onSubmit: (feedback: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -20,51 +31,30 @@ interface ModeMeta {
|
||||
blankHint: string;
|
||||
}
|
||||
|
||||
const MODES: Record<PlanRefineInputProps["mode"], ModeMeta> = {
|
||||
approve: {
|
||||
title: "approving — any last instructions?",
|
||||
glyph: "◇",
|
||||
tone: "user",
|
||||
cursorColor: CARD.user.color,
|
||||
hint: "Answer questions the plan raised, add constraints, or just press Enter to approve as-is.",
|
||||
blankHint: " (Enter with blank = approve without extra instructions.)",
|
||||
},
|
||||
refine: {
|
||||
title: "refining — what should the model change?",
|
||||
glyph: "✎",
|
||||
tone: "warn",
|
||||
cursorColor: CARD.warn.color,
|
||||
hint: "Describe what's wrong or missing, or answer questions the plan raised.",
|
||||
blankHint: " (Enter with blank = ask the model to list concrete questions.)",
|
||||
},
|
||||
reject: {
|
||||
title: "rejecting — tell the model why (optional)",
|
||||
glyph: "✗",
|
||||
tone: "error",
|
||||
cursorColor: CARD.error.color,
|
||||
hint: "Say what the model got wrong about your goal, or what you actually want instead.",
|
||||
blankHint:
|
||||
" (Enter with blank = cancel without explanation; the model will ask what you want.)",
|
||||
},
|
||||
"checkpoint-revise": {
|
||||
title: "revising — what should change before the next step?",
|
||||
glyph: "✎",
|
||||
tone: "warn",
|
||||
cursorColor: CARD.warn.color,
|
||||
hint: "Scope change, skip steps, alternative approach — the model adjusts the remaining plan.",
|
||||
blankHint: " (Enter with blank = continue with the current plan.)",
|
||||
},
|
||||
"choice-custom": {
|
||||
title: "custom answer — type whatever fits",
|
||||
glyph: "⌥",
|
||||
tone: "accent",
|
||||
cursorColor: CARD.plan.color,
|
||||
hint: "Free-form reply. The model reads it verbatim and proceeds — no need to match the listed options.",
|
||||
blankHint: " (Enter with blank = ask the model what you actually want.)",
|
||||
},
|
||||
const MODE_VISUALS: Record<
|
||||
PlanRefineMode,
|
||||
{ glyph: string; tone: ApprovalCardProps["tone"]; cursorColor: string }
|
||||
> = {
|
||||
approve: { glyph: "◇", tone: "user", cursorColor: CARD.user.color },
|
||||
refine: { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
|
||||
reject: { glyph: "✗", tone: "error", cursorColor: CARD.error.color },
|
||||
"checkpoint-revise": { glyph: "✎", tone: "warn", cursorColor: CARD.warn.color },
|
||||
"choice-custom": { glyph: "⌥", tone: "accent", cursorColor: CARD.plan.color },
|
||||
};
|
||||
|
||||
export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputProps) {
|
||||
function modeMeta(mode: PlanRefineMode): ModeMeta {
|
||||
const v = MODE_VISUALS[mode];
|
||||
return {
|
||||
title: t(`planFlow.modes.${mode}.title`),
|
||||
hint: t(`planFlow.modes.${mode}.hint`),
|
||||
blankHint: t(`planFlow.modes.${mode}.blankHint`),
|
||||
glyph: v.glyph,
|
||||
tone: v.tone,
|
||||
cursorColor: v.cursorColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanRefineInput({ mode, questions, onSubmit, onCancel }: PlanRefineInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useKeystroke((ev) => {
|
||||
@@ -91,15 +81,24 @@ export function PlanRefineInput({ mode, onSubmit, onCancel }: PlanRefineInputPro
|
||||
|
||||
const tick = useTick();
|
||||
const cursorOn = Math.floor(tick / 4) % 2 === 0;
|
||||
const meta = MODES[mode];
|
||||
const meta = modeMeta(mode);
|
||||
const showQuestions = mode === "refine" && !!questions && questions.trim().length > 0;
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
tone={meta.tone}
|
||||
glyph={meta.glyph}
|
||||
title={meta.title}
|
||||
footerHint="⏎ send · esc return to picker"
|
||||
footerHint={t("planFlow.refineFooter")}
|
||||
>
|
||||
{showQuestions ? (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={TONE.warn} bold>
|
||||
{t("planFlow.refineQuestionsHeading")}
|
||||
</Text>
|
||||
<MarkdownView text={questions!} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={FG.sub}>
|
||||
{meta.hint}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import type { PlanStep, PlanStepRisk } from "../../tools/plan.js";
|
||||
import { CharBar } from "./char-bar.js";
|
||||
import { COLOR, GLYPH } from "./theme.js";
|
||||
@@ -103,8 +104,15 @@ function PlanStepListInner({ steps, statuses, focusStepId }: PlanStepListProps)
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{showProgress
|
||||
? `${doneCount}/${total} done (${pct}%) · ${total} step${total === 1 ? "" : "s"}`
|
||||
: `${total} step${total === 1 ? "" : "s"}`}
|
||||
? t(
|
||||
total === 1
|
||||
? "planFlow.stepList.counterDoneSingular"
|
||||
: "planFlow.stepList.counterDone",
|
||||
{ done: doneCount, total, pct },
|
||||
)
|
||||
: t(total === 1 ? "planFlow.stepList.counterSingular" : "planFlow.stepList.counter", {
|
||||
total,
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
|
||||
+25
-12
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import type { TaskCard as TaskCardData, TaskStep } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
import { useThemeTokens } from "../theme/context.js";
|
||||
|
||||
const STEP_GLYPH: Record<TaskStep["status"], string> = {
|
||||
queued: "○",
|
||||
@@ -13,19 +13,6 @@ const STEP_GLYPH: Record<TaskStep["status"], string> = {
|
||||
failed: "✗",
|
||||
};
|
||||
|
||||
const STEP_COLOR: Record<TaskStep["status"], string> = {
|
||||
queued: FG.faint,
|
||||
running: TONE.warn,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
|
||||
const TASK_COLOR: Record<TaskCardData["status"], string> = {
|
||||
running: TONE.warn,
|
||||
done: TONE.ok,
|
||||
failed: TONE.err,
|
||||
};
|
||||
|
||||
const TASK_GLYPH: Record<TaskCardData["status"], string> = {
|
||||
running: "▶",
|
||||
done: "✓",
|
||||
@@ -33,26 +20,38 @@ const TASK_GLYPH: Record<TaskCardData["status"], string> = {
|
||||
};
|
||||
|
||||
export function TaskCard({ card }: { card: TaskCardData }): React.ReactElement {
|
||||
const { fg, tone } = useThemeTokens();
|
||||
const stepColor: Record<TaskStep["status"], string> = {
|
||||
queued: fg.faint,
|
||||
running: tone.warn,
|
||||
done: tone.ok,
|
||||
failed: tone.err,
|
||||
};
|
||||
const taskColor: Record<TaskCardData["status"], string> = {
|
||||
running: tone.warn,
|
||||
done: tone.ok,
|
||||
failed: tone.err,
|
||||
};
|
||||
const elapsed = `${(card.elapsedMs / 1000).toFixed(1)}s`;
|
||||
return (
|
||||
<Card tone={TASK_COLOR[card.status]}>
|
||||
<Card tone={taskColor[card.status]}>
|
||||
<CardHeader
|
||||
glyph={TASK_GLYPH[card.status]}
|
||||
tone={TASK_COLOR[card.status]}
|
||||
tone={taskColor[card.status]}
|
||||
title={`step ${card.index}/${card.total}`}
|
||||
subtitle={card.title}
|
||||
meta={[elapsed, card.status]}
|
||||
/>
|
||||
{card.steps.map((step) => (
|
||||
<Box key={step.id} flexDirection="row" gap={1}>
|
||||
<Text color={STEP_COLOR[step.status]}>{STEP_GLYPH[step.status]}</Text>
|
||||
<Text bold color={FG.body}>
|
||||
<Text color={stepColor[step.status]}>{STEP_GLYPH[step.status]}</Text>
|
||||
<Text bold color={fg.body}>
|
||||
{(step.toolName ?? "step").padEnd(7)}
|
||||
</Text>
|
||||
<Text color={FG.sub}>{step.title}</Text>
|
||||
{step.detail ? <Text color={FG.faint}>{step.detail}</Text> : null}
|
||||
<Text color={fg.sub}>{step.title}</Text>
|
||||
{step.detail ? <Text color={fg.faint}>{step.detail}</Text> : null}
|
||||
{step.elapsedMs !== undefined ? (
|
||||
<Text color={FG.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
|
||||
<Text color={fg.faint}>{`${(step.elapsedMs / 1000).toFixed(2)}s`}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import type { TipCard as TipCardData, TipRow as TipRowData } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
|
||||
const KEY_GUTTER = 4;
|
||||
|
||||
export function TipCard({ card }: { card: TipCardData }): React.ReactElement {
|
||||
const keyWidth = card.sections.reduce(
|
||||
(max, sec) => sec.rows.reduce((m, r) => Math.max(m, stringWidth(r.key)), max),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2} marginY={1}>
|
||||
<Box flexDirection="row" justifyContent="space-between">
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={TONE.accent} bold>
|
||||
ⓘ
|
||||
</Text>
|
||||
<Text color={FG.body} bold>
|
||||
{card.topic}
|
||||
</Text>
|
||||
</Box>
|
||||
{card.oneTime ? <Text color={FG.faint}>{t("ui.tipShownOnce")}</Text> : null}
|
||||
</Box>
|
||||
{card.sections.map((section, i) => (
|
||||
<Box key={section.title ?? `section-${i}`} flexDirection="column" marginTop={1}>
|
||||
{section.title ? (
|
||||
<Box marginBottom={0}>
|
||||
<Text color={FG.sub}>{section.title}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{section.rows.map((row) => (
|
||||
<TipRowRender
|
||||
key={row.key}
|
||||
row={row}
|
||||
keyWidth={keyWidth}
|
||||
indent={section.title ? 2 : 0}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{card.footer ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>{card.footer}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TipRowRender({
|
||||
row,
|
||||
keyWidth,
|
||||
indent,
|
||||
}: {
|
||||
row: TipRowData;
|
||||
keyWidth: number;
|
||||
indent: number;
|
||||
}) {
|
||||
const pad = " ".repeat(Math.max(0, keyWidth - stringWidth(row.key) + KEY_GUTTER));
|
||||
const lead = indent > 0 ? " ".repeat(indent) : "";
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{lead ? <Text>{lead}</Text> : null}
|
||||
<Text color={TONE.accent}>{row.key}</Text>
|
||||
<Text>{pad}</Text>
|
||||
<Text color={FG.body}>{row.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,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 {
|
||||
|
||||
@@ -28,9 +28,9 @@ export interface CharBarProps {
|
||||
pct: number;
|
||||
/** Cell count. Default 24. Min 4 enforced so the bar is at least readable. */
|
||||
width?: number;
|
||||
/** Filled-cell color. Defaults to brand cyan. */
|
||||
/** Filled-cell COLOR. Defaults to brand cyan. */
|
||||
color?: string;
|
||||
/** Empty-cell color. Defaults to dim slate. */
|
||||
/** Empty-cell COLOR. Defaults to dim slate. */
|
||||
emptyColor?: string;
|
||||
/**
|
||||
* Whether to render the percentage label after the bar. Off when the
|
||||
|
||||
@@ -0,0 +1,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(", ")})`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Cross-platform URL opener; no-op under CI / when REASONIX_NO_OPEN is set. */
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
|
||||
export interface OpenUrlResult {
|
||||
opened: boolean;
|
||||
reason?: "ci" | "disabled" | "spawn-failed";
|
||||
}
|
||||
|
||||
export function openUrl(url: string): OpenUrlResult {
|
||||
if (process.env.CI) return { opened: false, reason: "ci" };
|
||||
if (process.env.REASONIX_NO_OPEN) return { opened: false, reason: "disabled" };
|
||||
|
||||
const os = platform();
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
if (os === "win32") {
|
||||
cmd = "cmd";
|
||||
args = ["/c", "start", "", url];
|
||||
} else if (os === "darwin") {
|
||||
cmd = "open";
|
||||
args = [url];
|
||||
} else {
|
||||
cmd = "xdg-open";
|
||||
args = [url];
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return { opened: true };
|
||||
} catch {
|
||||
return { opened: false, reason: "spawn-failed" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Markdown header rule used by PlanConfirm to flag plans with open questions. No `\b` — it's ASCII-only and would skip the Chinese alternatives. */
|
||||
const HEADER_RE =
|
||||
/^(#{1,6})\s*(open[-\s]?questions?|risks?|unknowns?|assumptions?|unclear|待确认|开放问题|风险|未知|假设|不确定)(?:[\s::/、,,].*)?$/im;
|
||||
|
||||
export function hasOpenQuestionsSection(plan: string): boolean {
|
||||
return HEADER_RE.test(plan);
|
||||
}
|
||||
|
||||
/** Markdown body of the first matching heading down to the next same-or-shallower heading; null when absent. */
|
||||
export function extractOpenQuestionsSection(plan: string): string | null {
|
||||
const lines = plan.split("\n");
|
||||
let startIdx = -1;
|
||||
let startLevel = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const m = line.match(HEADER_RE);
|
||||
if (m) {
|
||||
startIdx = i;
|
||||
startLevel = (m[1] ?? "#").length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startIdx === -1) return null;
|
||||
|
||||
let endIdx = lines.length;
|
||||
for (let j = startIdx + 1; j < lines.length; j++) {
|
||||
const line = lines[j] ?? "";
|
||||
const lh = line.match(/^(#{1,6})\s+\S/);
|
||||
if (lh && (lh[1] ?? "").length <= startLevel) {
|
||||
endIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const block = lines.slice(startIdx, endIdx).join("\n").replace(/\s+$/g, "");
|
||||
return block.length > 0 ? block : null;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,11 +1,17 @@
|
||||
import { t } from "@/i18n/index.js";
|
||||
import { release } from "node:os";
|
||||
import { loadTheme, resolveThemePreference } from "@/config.js";
|
||||
import { getLanguage, t } from "@/i18n/index.js";
|
||||
import {
|
||||
DEEPSEEK_CONTEXT_TOKENS,
|
||||
DEEPSEEK_PRICING,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
} from "@/telemetry/stats.js";
|
||||
import { countTokens } from "@/tokenizer.js";
|
||||
import { VERSION } from "@/version.js";
|
||||
import { writeClipboard } from "../../clipboard.js";
|
||||
import { computeCtxBreakdown } from "../../ctx-breakdown.js";
|
||||
import { buildFeedbackDiagnostic, buildFeedbackIssueUrl } from "../../feedback.js";
|
||||
import { openUrl } from "../../open-url.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
import { compactNum } from "../helpers.js";
|
||||
|
||||
@@ -45,11 +51,11 @@ const status: SlashHandler = (_args, loop, ctx) => {
|
||||
const cacheLine =
|
||||
summary.turns > 3
|
||||
? (() => {
|
||||
const cachePct = Math.round(summary.cacheHitRatio * 100);
|
||||
const cachePct = summary.cacheHitRatio * 100;
|
||||
return t("handlers.observability.statusCost", {
|
||||
cost: cost.toFixed(4),
|
||||
bar: renderTinyBar(cachePct, 12),
|
||||
pct: cachePct,
|
||||
pct: cachePct.toFixed(1),
|
||||
turns: summary.turns,
|
||||
});
|
||||
})()
|
||||
@@ -225,9 +231,52 @@ function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
|
||||
const feedback: SlashHandler = (_args, loop, ctx) => {
|
||||
const themeName = resolveThemePreference(loadTheme(), process.env.REASONIX_THEME);
|
||||
const diagnostic = buildFeedbackDiagnostic({
|
||||
version: VERSION,
|
||||
latestVersion: ctx.latestVersion ?? undefined,
|
||||
platform: process.platform,
|
||||
osRelease: release(),
|
||||
termProgram: process.env.TERM_PROGRAM,
|
||||
term: process.env.TERM,
|
||||
colorTerm: process.env.COLORTERM,
|
||||
inWindowsTerminal: !!process.env.WT_SESSION,
|
||||
inTmux: !!process.env.TMUX,
|
||||
inSsh: !!process.env.SSH_TTY,
|
||||
wslDistro: process.env.WSL_DISTRO_NAME,
|
||||
cols: process.stdout.columns,
|
||||
rows: process.stdout.rows,
|
||||
nodeVersion: process.version,
|
||||
locale: getLanguage(),
|
||||
theme: themeName,
|
||||
model: loop.model,
|
||||
reasoningEffort: loop.reasoningEffort,
|
||||
editMode: ctx.editMode,
|
||||
planMode: ctx.planMode,
|
||||
mcpServerCount: ctx.mcpServers?.length ?? ctx.mcpSpecs?.length,
|
||||
sessionId: ctx.sessionId,
|
||||
});
|
||||
// Clipboard is the belt-and-suspenders: GitHub's new-issue page accepts
|
||||
// `?body=…` and we use that, but if the URL ever fails to open the
|
||||
// user can paste from clipboard against any tracker.
|
||||
writeClipboard(diagnostic);
|
||||
const url = buildFeedbackIssueUrl(diagnostic);
|
||||
const opened = openUrl(url);
|
||||
const lines = [
|
||||
opened.opened
|
||||
? "▸ issue page opened with the diagnostic block pre-filled. Just describe what you were doing and submit."
|
||||
: `▸ couldn't open the browser (${opened.reason ?? "unknown"}). Diagnostic info is on your clipboard; open this URL manually: ${url}`,
|
||||
"",
|
||||
diagnostic,
|
||||
];
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
context,
|
||||
status,
|
||||
compact,
|
||||
cost,
|
||||
feedback,
|
||||
};
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -52,6 +52,18 @@ export function cardsToDashboardMessages(cards: ReadonlyArray<Card>): DashboardM
|
||||
case "ctx":
|
||||
out.push({ id: card.id, role: "info", text: card.text });
|
||||
break;
|
||||
case "tip": {
|
||||
const sectionTexts = card.sections.map((sec) => {
|
||||
const body = sec.rows.map((r) => `${r.key}\t${r.text}`).join("\n");
|
||||
return sec.title ? `[${sec.title}]\n${body}` : body;
|
||||
});
|
||||
const body = sectionTexts.join("\n\n");
|
||||
const text = card.footer
|
||||
? `${card.topic}\n${body}\n${card.footer}`
|
||||
: `${card.topic}\n${body}`;
|
||||
out.push({ id: card.id, role: "info", text });
|
||||
break;
|
||||
}
|
||||
case "plan": {
|
||||
const done = card.steps.filter((s) => s.status === "done").length;
|
||||
const tag =
|
||||
|
||||
@@ -192,6 +192,25 @@ export interface CtxCard extends CardBase {
|
||||
readonly topTools: ReadonlyArray<{ name: string; tokens: number; turn: number }>;
|
||||
}
|
||||
|
||||
export interface TipRow {
|
||||
readonly key: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface TipSection {
|
||||
/** Subsection heading (rendered above its rows). Omit for single-section tips. */
|
||||
readonly title?: string;
|
||||
readonly rows: ReadonlyArray<TipRow>;
|
||||
}
|
||||
|
||||
export interface TipCard extends CardBase {
|
||||
readonly kind: "tip";
|
||||
readonly topic: string;
|
||||
readonly sections: ReadonlyArray<TipSection>;
|
||||
readonly footer?: string;
|
||||
readonly oneTime: boolean;
|
||||
}
|
||||
|
||||
export type Card =
|
||||
| UserCard
|
||||
| ReasoningCard
|
||||
@@ -208,7 +227,8 @@ export type Card =
|
||||
| SearchCard
|
||||
| LiveCard
|
||||
| CtxCard
|
||||
| DoctorCard;
|
||||
| DoctorCard
|
||||
| TipCard;
|
||||
|
||||
export interface DoctorCheckEntry {
|
||||
readonly label: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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).",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 已连接)。",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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=/);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user