Compare commits

...
23 Commits
Author SHA1 Message Date
esengine 7542aca687 feat(ui): show "N ready · building call M" during multi-tool turns (0.5.19)
Long assistant responses with many tool_calls used to render as a
single static "building tool call <write_file> · 37 chars" spinner
for 10–30s — user could see tokens being consumed but had no sense of
how many files were coming or how far along the stream was.

Each tool_call's `arguments` blob is now tried as JSON at every delta.
Once it first parses, the index is marked "ready" and the count goes
into subsequent `tool_call_delta` events. The UI label becomes
"building tool call (call 3) <write_file> · 127 chars · 2 ready"
so the user sees progress accumulating.

Dispatch timing is UNCHANGED — this is purely a visibility upgrade.
Mid-stream eager dispatch was considered and punted because it would
interact awkwardly with the repair step (truncation fix, storm
breaker, scavenge) that runs post-stream.

+6 tests locking down looksLikeCompleteJson (the gate that decides
when a streaming call counts as "ready"). 991 tests.
2026-04-23 20:18:10 -07:00
esengine 1e906bb577 fix(r1): round-trip reasoning_content on EVERY reasoner assistant turn (0.5.18)
0.5.15 scoped the fix too narrowly — only attached reasoning_content
when `tool_calls.length > 0`. DeepSeek's "thinking mode must be
passed back" rule applies to ANY R1-generated assistant message.

Symptom: plan approved → synthetic user message "implement it now" →
next API call 400s. The intermediate "plan submitted — awaiting
approval" plain-text R1 turn was persisted without its reasoning,
and R1 rejected the entire history the next time around.

Changes:
- assistantMessage() drops the tool_calls guard; reasoning attaches
  whenever the model emitted any (even on plain-text turns).
- Forced-summary path (no-tools fallback) now captures reasoning from
  the summary response.
- New syntheticAssistantMessage() stamps an empty-string
  reasoning_content on synthetic turns (Esc-abort notices) when the
  model is a reasoner, so R1's validator is satisfied even when no
  real reasoning exists. V3 stays clean.
- Test updated: plain-text reasoner turn now asserts reasoning
  DOES round-trip (reversed from 0.5.15's assumption).

985 tests.
2026-04-23 20:03:06 -07:00
esengine a8c367ad0c fix(citation): leading / resolves to project root, not filesystem root (0.5.17)
Symptom: user wrote a game design doc to `F:/Reasonix/game-design-doc.md`,
the model cited it as `[...](/game-design-doc.md)`, and Reasonix rendered
"✗ /game-design-doc.md → file not found" even though the file was
right there on disk.

Cause: `validateCitation` ran `isAbsolute(path)` on the raw URL. A model
writing `/foo.md` means "project-root relative" (Aider / Claude-Code
convention), but Node's isAbsolute returns true, so the validator
stat'd the literal `/foo.md` under the filesystem root and got ENOENT.

Fix: strip leading `/` or `\` before the absoluteness test. Drive-letter
paths (`C:\...`) still pass isAbsolute and are respected; plain
leading-slash paths become project-relative. Unix absolute citations
like `/etc/hosts` lose precision but are vanishingly rare in code
citations — if we need them we'll add an explicit scheme.

+2 markdown tests for the new leading-slash behavior (both `/` and `\`).
985 tests.
2026-04-23 19:57:49 -07:00
esengine 2d35cab39e fix(plan): actually stop the flicker — pause ticker, tighten clamp, stabilize onChoose (0.5.16)
0.5.14's clamp was insufficient. Three root causes the earlier fix missed:

1. TickerProvider kept pumping 120ms re-renders even while the picker
   was up — the memo'd PlanConfirm still re-evaluated on every tick
   because `onChoose` identity changed (handlePlanConfirm has `busy` in
   its deps; busy toggles during model streaming). Fix: disable the
   ticker while `pendingPlan` or `pendingShell` is set. No ticker = no
   re-render cadence to drive Ink redraws.

2. `clampBodyByLines` clamped SOURCE lines, not rendered rows.
   Markdown expands ~2× (wrap, code fences, heading blanks, list
   indent), so `rows - 18` source lines still overflowed on a 40-row
   terminal. Fix: divide by MARKDOWN_EXPANSION before clamping so the
   rendered output actually fits.

3. `handlePlanConfirm` identity churns — wrapped in a ref-stable
   `stableHandlePlanConfirm` so `React.memo(PlanConfirm)`'s shallow
   compare actually skips re-renders when `plan` + `projectRoot`
   haven't changed.

Together these pin the picker's rendered height to a known budget AND
eliminate the re-render loop that caused Ink to clear+redraw each
frame. The overflow-can't-scroll symptom is inherent to Ink's live
mode; only remedy is to make content fit, which the tightened clamp
now guarantees.
2026-04-23 19:54:19 -07:00
esengine 874d7df2f7 fix(r1): round-trip reasoning_content through tool loop (0.5.15)
DeepSeek R1 thinking mode 400s with "The reasoning_content in the
thinking mode must be passed back to the API" when a tool-loop
continuation replays an assistant message that had tool_calls but
no reasoning. Root cause: assistantMessage() stripped the field.

Fix: ChatMessage gains optional reasoning_content. Loop passes the
captured reasoning into assistantMessage; we attach it only when
the turn has tool_calls AND reasoning is non-empty — plain-text
R1 turns stay lean, deepseek-chat is unaffected.

+2 tests (loop-r1-reasoning): turn-2 body asserts the echoed
assistant message carries reasoning_content on a tool-call turn,
and does NOT carry it on a plain-text turn.
2026-04-23 19:50:18 -07:00
esengine 4d39744d99 feat(0.5.14): visible/durable/reachable + plan-flicker + pending-edit persistence
- !cmd bang output now persists via loop.appendAndPersist, so session
  resume sees the shell history the model already reasoned against.
- Subagent runs (run_skill · runAs:subagent) now emit cost/model/usage
  on their end event. App.tsx writes one row to ~/.reasonix/usage.jsonl
  per run; the completion line also shows $X inline. `/stats` and
  `reasonix stats` render a new "subagent activity" section with
  per-skill breakdown.
- `/resource [uri]` + `/prompt [name]` slashes with argument-level
  pickers (reusing 0.5.13's SlashArgPicker). No arg lists what every
  connected MCP server exposes; with arg reads via live McpClient
  stored on McpServerSummary. Display-only v1 — no loop injection.
  `/mcp` now hints toward these when servers expose resources/prompts,
  `/keys` gets an "MCP exploration" section, `/help` lists both.
- Bug A — plan picker flicker on small terminals: PlanConfirm's
  markdown body is now line-clamped to (rows − 18), and the component
  is React.memoed so tick-driven parent re-renders skip the heavy
  Markdown subtree when props are unchanged.
- Bug B — pending-edit queue wiped on crash: new
  src/code/pending-edits.ts checkpoints the queue to
  ~/.reasonix/sessions/<name>.pending.json on every parse. /apply
  and /discard clear the file; session resume restores it with a
  visible banner. deleteSession removes the sidecar too.

981 tests (+31). lint / typecheck / build clean.
2026-04-23 19:44:28 -07:00
esengine 74da4f2a92 revert /edit slash + fix picker blocking Enter on exact match (still 0.5.13)
Two small course-corrections on the 0.5.13 work-in-progress. Version
stays at 0.5.13 — these land before publish.

### 1. Remove /edit

The one-shot `/edit <file> <instruction>` slash was 99% redundant
with `@path/file.ts <instruction>`: the 0.5.5 `@`-mention already
inlines the file, and code-mode's system prompt already teaches the
model to output SEARCH/REPLACE blocks. `/edit` added only the
"output ONLY a block, no prose" hint — not worth a whole slash.

Reverted:
- case "edit" handler (slash.ts)
- SLASH_COMMANDS registry entry
- /help line
- /edit test describe block + "/help mentions /edit" + required-list
- "file" from the argCompleter union type (no consumer left; the
  `@` picker remains the single file-picker surface, avoiding two
  competing file-autocompletes)
- "file" branch in App.tsx's slashArgMatches memo + the trailing-
  space special case in pickSlashArg

Users who want one-shot edits type `@src/foo.ts fix the typo` —
clearer to read in history and the model obliges by default.

### 2. Picker blocked Enter after exact match

Typing `/preset smart` left the picker visible with one match
("smart") → Enter was intercepted and re-substituted the same value
instead of submitting the command. Fix: slashArgMatches memo now
returns null when the partial is a case-insensitive EXACT match for
an enum value (or a model id). Picker hides; Enter submits.

- Test added documenting that the detector itself remains kind-
  only — the hide-on-exact-match logic lives in the App-side memo
  so the detector stays UI-agnostic.

### Tests

946 (was 952; dropped 6 /edit-specific tests, added 1 regression
for the exact-match fix, dropped the File-picker-for-/edit detector
test since that path is gone). Lint + typecheck + build clean.
2026-04-23 19:08:14 -07:00
esengine 36f6d245f9 feat(ui): argument-level pickers for slash commands (0.5.13)
User feedback: "after I type `/edit` there's no hint about what files
exist — same for other commands with args." Fixed.

### Mechanism

SlashCommandSpec gains an `argCompleter` field:

- `"file"`    → file picker (reuses atFiles + rankPickerCandidates
                with mtime + recently-used ranking from 0.5.12)
- `"models"`  → DeepSeek model-id list fetched at startup
- `string[]`  → small enum (e.g. `["fast", "smart", "max"]`)
- omitted     → no picker, just a dim usage hint below the prompt

Attached to: /edit → file, /preset → enum, /model → models,
/plan → on/off, /harvest → on/off, /branch → off + a few N values.

`detectSlashArgContext(input, codeMode)` in slash.ts classifies the
buffer once the user has typed `/<cmd> ` — returns `{spec, partial,
partialOffset, kind}`. `kind` is "picker" (completable first-arg
position) or "hint" (past the first arg / no completer).

`src/cli/ui/SlashArgPicker.tsx` — new component mirroring the
SlashSuggestions / AtMentionSuggestions layout. Header row always
shows `/<cmd> <argsHint>  — <summary>` so even when no matches,
the user sees what's expected. Hint-only mode is a single dim row.

App.tsx: `slashArgSelected` state, `slashArgContext` memo,
`slashArgMatches` memo (dispatches on completer type), useEffect
clamp, `pickSlashArg` callback (splices at partialOffset; adds
trailing space for file picker so user can keep typing the
instruction; no trailing space for enum picks since those commands
take no further args). Wired into useInput arrow/Tab and
handleSubmit Enter intercept. Renders after SlashSuggestions +
AtMentionSuggestions.

### Tests (+11, suite 941 → 952)

tests/slash.test.ts — `detectSlashArgContext` suite covers: null
before name commit, null on unknown cmd, file picker for /edit in
code mode, contextual-command gating outside code mode, enum picker
for /preset, models completer for /model, /plan gated to code mode,
hint kind once partial has internal whitespace, picker with empty
partial (just-typed space), hint kind for free-form arg commands
like /commit.

Lint + typecheck + build clean. 952 tests, 7.5s.
2026-04-23 19:02:38 -07:00
esengine a44c23c0a8 feat(ui): @-picker recency ranking + /keys cheatsheet (0.5.12)
Two UX polishers bundled, closing out the A-E batch.

### D. `@` picker ranking by mtime + recently-used

Alphabetical sort was arbitrary on large repos — 500 files deep, the
first page was almost never what the user wanted. New ranking:

- Empty query → recently-used paths first, then mtime descending,
  then path alpha. "Recently used" = files touched by `read_file` /
  `edit_file` / `write_file` / `search_content` / etc. tool calls
  this session (20-slot LRU; resets on session change).
- Non-empty query → existing substring/prefix scoring, tiebreak by
  recently-used membership then mtime.
- Back-compat: `string[]` input with no recency list still returns
  input order (old tests pass unchanged).

Implementation:

- `listFilesWithStatsSync(root, opts)` — same walk as listFilesSync
  plus a statSync per file. Stat failures don't throw; the entry
  keeps `mtimeMs: 0` and sinks to the bottom of the recency bucket.
- `FileWithStats` / `PickerCandidate` / `RankPickerOptions` types
  exported for library callers.
- `rankPickerCandidates(files, query, limitOrOpts)` — overloaded:
  bare number still works (back-compat), or pass
  `{ limit, recentlyUsed }`.
- App.tsx: `atFiles` memo switched to listFilesWithStatsSync;
  `recentFilesRef` (useRef<string[]>) + `recordRecentFile(p)` LRU
  callback; `tool_start` event handler inspects `toolArgs` for
  `path` / `file_path` / `file` fields and feeds the LRU.

### E. `/keys` cheatsheet slash

Discoverability gap: `!` bang, `@` picker, `/` slash, Shift+Enter,
Ctrl+J, Ctrl+A/E, Esc, y/n — the kit is extensive but undocumented
in one place. `/keys` dumps a formatted table:

    Keyboard & prompt shortcuts:
      Enter                  submit the current prompt
      Shift+Enter / Ctrl+J   insert a newline
      ...

    Prompt prefixes:
      /<name>    slash command
      @<path>    inline a file (code mode)
      !<cmd>     run <cmd> as shell

    Pickers (slash + @-mention):
      ↑ / ↓      navigate
      Tab        insert without submitting
      Enter      insert + run/keep-editing

Added to SLASH_COMMANDS registry + cross-linked from `/help`.

### Tests (+9, suite 932 → 941)

- tests/at-mentions.test.ts — 5 new for rankPickerCandidates:
  mtime sort on empty query with FileWithStats, recently-used boost
  trumps mtime, non-empty query tiebreak by recent+mtime, back-compat
  input-order preservation for bare strings, numeric-limit literal
  as third arg.
- tests/slash.test.ts — 4 new for /keys: shortcuts listed, prefixes
  listed, pickers mentioned, /help cross-reference.

### Note on release cadence

A (0.5.9) + B (0.5.10) + C (0.5.11) + D + E were split across five
commits but each dist is cumulative — publishing only the final one
gets users everything. Going forward I'll bundle closer unless the
user asks otherwise.

Lint + typecheck + build clean. 941 tests, 8.3s.
2026-04-23 18:54:04 -07:00
esengine 337ded3839 feat(code): unified-diff preview for pending edit blocks (0.5.11)
Before /apply, the UI used to show only
    ▸ 2 pending edit block(s) — /apply or /discard
          src/a.ts  (-3 +4 lines)
          src/b.ts  (-1 +1 lines)
— a count summary, nothing about what was actually changing. Common
mistake shape: press y, look at git diff afterwards, discover the
model edited the wrong block / wrong file.

Now each pending block is rendered as a compact diff with ±2 lines of
context and a hard cap at 20 lines per block:

    ▸ 2 pending edit block(s) — /apply (or y) to commit · /discard (or n) to drop
          src/foo.ts  (-1 +1 lines)
            function foo() {
        -   const x = 1;
        +   const x = 2;
            return x;

      NEW src/bar.ts  (-0 +3 lines)
        + export const bar = 1;
        + export const baz = 2;
        + export default { bar, baz };

### Mechanism

`src/code/diff-preview.ts`:

- `formatEditBlockDiff(block, opts)` — shared leading/trailing lines
  collapse to context (default 2 lines each side, `… N unchanged lines
  above/below` when truncated). Middle divergence is `-old` / `+new`.
  Whole block capped at 20 lines with `… (N more diff lines — full
  content applies on /apply)` footer when exceeded. Empty-search
  (new file) case shows all-`+` lines.
- `formatAllBlockDiffs(blocks, opts)` — stitches each block's header
  (path + ±line counts, `NEW` prefix for new files) + its diff body,
  separated by blank lines.

Zero new deps. Small hand-rolled helper using prefix/suffix-compare;
sufficient for the typical SEARCH/REPLACE shape (small change inside
a function body, wrapped in a few lines of surrounding context).
Myers-diff quality is not needed — the model's SEARCH block already
minimizes context.

`formatPendingPreview` in App.tsx dropped its own summary line list
(paths now come from the per-block diff header) and its local
`countLines` helper (moved to diff-preview.ts alongside its only
consumer).

### No colors yet

Plain text, `-` / `+` / ` ` prefixes. ANSI colorization would need
Ink component plumbing that isn't worth the complexity for v1 —
the prefix convention is clear and universal (git / hunks / etc.).

### Tests (+10, suite 922 → 932)

tests/diff-preview.test.ts — new-file all-`+`, context collapse for
shared prefix/suffix, maxLines truncation with footer, leading and
trailing hidden-context annotations, single-line edits, custom
indent, multi-block formatAllBlockDiffs (path+counts headers, blank
separators, empty-input).

Lint + typecheck + build clean. 932 tests.
2026-04-23 18:47:01 -07:00
esengine 8ec6c630bd feat(slash): /edit <file> <instruction> — one-shot surgical edit (0.5.10)
One-liner shortcut for "change this one thing in this one file" —
faster than the multi-turn code-mode dance of "explain the file",
"here's my plan", "apply", etc. Code mode only.

### Mechanism

Zero new code paths. The slash is sugar: it builds a resubmit text of

    @<file> <instruction>

    Output ONLY a SEARCH/REPLACE block for this one edit. No prose,
    no explanation.

and hands that back through the normal turn machinery:

- 0.5.5's `@`-mention expansion inlines `<file>` under
  `[Referenced files]` so the model sees the current content.
- Code-mode's SEARCH/REPLACE handling already catches the output
  into `pendingEdits` (0.4.0+).
- The existing y/n confirm gate fires on the next event.

So `/edit src/foo.ts fix the typo` becomes equivalent to the user
typing `@src/foo.ts fix the typo. Output ONLY a SEARCH/REPLACE
block…` — the value is (a) discoverability via /help and slash
autocomplete, (b) the guaranteed-good prompt format without the user
having to remember it.

### Validation

- Outside code mode (no `codeRoot` in SlashContext) → refusal with a
  pointer to `reasonix code <dir>`.
- Missing file arg → usage hint.
- Missing instruction → usage hint with the file name echoed back.

### /help + discovery

Added to the slash autocomplete registry (`contextual: "code"` so
it only surfaces in code mode) and to the /help code-mode block.

### Tests (+6, suite 916 → 922)

tests/slash.test.ts — /edit describe block covers: non-code-mode
refusal, missing file, missing instruction, resubmit shape
(contains `@file` + instruction + SEARCH/REPLACE + ONLY), multi-
word instruction preservation. Plus `/help mentions /edit` and
`edit` added to the `SLASH_COMMANDS registry contains every handler`
required-list.

Lint + typecheck + build clean. 922 tests.
2026-04-23 18:41:12 -07:00
esengine a787272ceb feat(ui): collapse large user pastes in Historical display (0.5.9)
Pasting a stack trace / log dump / sample file flooded the Historical
scrollback with a massive block, hiding prior turns. Now user input
that exceeds a threshold renders as:

    ▸ pasted 2.3 KB (84 lines) — first 10 shown, full text sent to model
    line 1
    …
    line 10
    … (74 more lines)

The MODEL still receives the full text. Only the displayed row
collapses — `loop.step(modelInput)` gets the original string
(including any `@`-mention expansion) unchanged.

### Mechanism

- `src/cli/ui/paste-collapse.ts` — `formatLongPaste(text, opts)` pure
  helper. Collapse triggers when line count > 40 OR char count > 2000
  (defaults, override via opts). Head shows 10 lines; remaining
  footer reads "… (N more lines)". Byte header formats B/KB/MB.
- App.tsx — wired at the user-row push site in handleSubmit
  (between the UserPromptSubmit hook gate and the loop.step dispatch).
  The `pasteDisplay.displayText` goes to the Historical row; the
  existing `modelInput` variable (raw or `@`-expanded) goes to the
  loop.

### Scope

Affects only the regular user-message row. Separate paths for
`!`-bang commands, `@` expansions, and slash outputs are untouched.
Skipped on short messages (pass-through, no allocation overhead
beyond a `split("\n")`).

### Tests (+10, suite 906 → 916)

tests/paste-collapse.test.ts covers: short pass-through, multi-line
pass-through, line-threshold collapse, char-threshold collapse,
KB/MB formatting, 1-vs-N-more-lines pluralization, custom
thresholds, custom headLines, zero remaining (head covers all),
exported defaults.

Lint + typecheck + build clean. 916 tests.
2026-04-23 18:37:26 -07:00
esengine 0ca16d6e2d feat(ui): !cmd bash-mode prefix — user-typed shell passthrough (0.5.8)
Claude-Code-style bash mode. User types `!ls src/` or `!git status`;
Reasonix runs the command in the sandbox root, shows both the command
and its output, and appends the combined text to the conversation log
so the next model turn has the context automatically.

Faster than round-tripping through the model just to cat a file or
check git state, and closes a common UX gap vs. Claude Code.

### Mechanism

- `src/cli/ui/bang.ts` — `detectBangCommand(text)` returns the command
  body (whitespace-trimmed) or null. Only a LEADING `!` counts; `!`
  alone / `!   ` returns null; `echo !hi` doesn't trigger (the `!`
  isn't at position 0).
- `formatBangUserMessage(cmd, output)` — prepends a `[!cmd]\n`
  header so the model can distinguish user-typed shell output from
  its own `run_command` tool results (which are `role: tool`,
  structurally different).
- App.tsx handleSubmit intercept — fires AFTER the y/n fast-path (so
  pending edits still resolve cleanly) and BEFORE slash parsing:
  - Push `role: "user"` Historical row with the raw `!cmd` text.
  - `await runCommand(cmd, { cwd: codeMode?.rootDir ?? cwd,
    timeoutSec: 60, maxOutputChars: 32_000 })`.
  - Push `role: "info"` Historical row with
    `formatCommandResult(cmd, result)`.
  - `loop.log.append({ role: "user", content: formatBangUserMessage
    (cmd, formatted) })` so the next step() includes it in
    buildMessages.
  - setBusy true/false around the run so PromptInput reflects the
    in-flight state.
  - Catch errors → warning row, don't log.

### No allowlist gate

User-typed `!` is explicit consent (consistent with Claude Code; the
allowlist exists to bound what the MODEL can do with `run_command`,
not what the user chooses by hand). If someone wants stricter gating
later a `REASONIX_BANG_CONFIRM=1` toggle could layer on — out of
scope here.

### Known limitation: session durability

`loop.log.append` updates in-memory only. On session resume, the bang
output is gone (the session file didn't see it). The next actual
step() does appendAndPersist for the next user turn, and the model
sees this bang output live within the same session either way.
Treating bang as ephemeral keeps the fast-path simple; durable
capture could land later if real usage surfaces demand.

### /help + discovery

/help gains a "Shell shortcut" section documenting `!cmd` with
example commands (`!git status   !ls src/   !npm test`), plus a
"File references" section covering the `@` picker from 0.5.5 / 0.5.7
that was undocumented there.

### Tests (+9, suite 897 → 906)

tests/bang.test.ts — detectBangCommand covers: body extraction,
whitespace trim, non-bang pass-through, `!`-alone / blank body,
mid-string `!` rejection, embedded-trailing-`!` preservation,
multi-word with flags/quotes. formatBangUserMessage covers header
prepending and verbatim body preservation.

Lint + typecheck + build clean. 906 tests, 8.6s.
2026-04-23 18:31:30 -07:00
esengine ecf2ea372b feat(ui): @mentions picker + post-build bundle smoke test (0.5.7)
Two shipped, both extending 0.5's foundation:

### 1. @mentions picker UI

0.5.5 shipped `@path/to/file` inline expansion but made the user type
the full path. 0.5.7 adds a picker that mirrors the existing
/slash autocomplete: type `@` (with optional partial) in code mode,
get a filtered file list with ↑/↓ navigation and Tab/Enter pick.

- `listFilesSync(root, opts)` — sync recursive walk, skips
  node_modules / .git / dist / build / .next / etc. + any dot-dirs.
  Caps at 500 results by default. Forward-slash paths on all
  platforms. 10-50ms on moderate repos — fits the TUI's single-turn-
  per-tick model cleanly.
- `detectAtPicker(input)` — returns `{query, atOffset}` when the
  buffer ends with an in-progress mention; `null` otherwise. Distinct
  from `AT_MENTION_PATTERN` which finds completed mentions anywhere
  for expandAtMentions.
- `rankPickerCandidates(files, query, limit)` — case-insensitive
  substring filter with a boost for basename-prefix matches. Empty
  query returns the first `limit` candidates alpha.
- `AtMentionSuggestions.tsx` — same shape as `SlashSuggestions`
  (matches null = hide, empty = "no matches", array = highlight
  selected cyan/bold). Shows basename bold + dir dimmed à la
  VS Code Quick Open.
- App.tsx wiring: `atFiles` memo (walk on rootDir change), `atPicker`
  / `atMatches` memos, `atSelected` state with useEffect clamp.
  Arrow keys + Tab in useInput (before slash so they don't conflict
  — mutually exclusive by construction). Enter in handleSubmit
  substitutes at the `@` offset and appends a space (space auto-
  dismisses the picker, next Enter submits).
- Limitation intentionally not fixed: files created mid-session by
  edit tools won't appear in the picker until next launch. User can
  always type the full path. Rebuilding the list on every turn would
  be wasteful for the 95% case.

### 2. Bundle smoke test

v0.5.4 shipped to npm with a tokenizer path resolution bug that
crashed on first API call. The unit tests were fine — they load
modules from src/, which doesn't exercise the post-bundle directory
layout. These two tests close that gap:

- `dist/index.js`: dynamic import (via file:// URL on Windows),
  invoke `truncateForModelByTokens` to force a tokenizer load. ENOENT
  ⇒ fail.
- `dist/cli/index.js`: spawn with `DEEPSEEK_BASE_URL=127.0.0.1:1` so
  fetch fails fast. Preflight's tokenizer load runs BEFORE fetch, so
  if the bundled layout can't find the data file we see the ENOENT
  in stderr. If tokenizer loads fine, we see connection-refused and
  the test passes.
- Both tests skip when the bundle is absent (dev runs that haven't
  built yet). CI runs `build` before `test` → always exercised.

Would have caught 0.5.4's regression pre-publish.

### Tests (+23, suite 874 → 897)

- tests/at-mentions.test.ts — 7 new for detectAtPicker, 2 for
  AT_PICKER_PREFIX vs AT_MENTION_PATTERN sanity, 6 for
  rankPickerCandidates (empty query, substring, basename-prefix
  boost, path-prefix, no-match, limit), 6 for listFilesSync (recurse,
  forward slashes, ignore dirs, dotfile inclusion, custom ignores,
  maxResults cap, unreadable root).
- tests/bundle-smoke.test.ts — 2 tests for lib + CLI bundle
  tokenizer resolution.

Library exports: `detectAtPicker`, `listFilesSync`,
`rankPickerCandidates`, `AT_PICKER_PREFIX`, `DEFAULT_PICKER_IGNORE_DIRS`,
`ListFilesOptions`.

Lint + typecheck + build clean. 897 tests, 8.1s.
2026-04-23 18:14:46 -07:00
esengine 335de43db5 fix(tokenizer): resolve data-file path in bundled builds (0.5.6)
**Critical production bug.** Since 0.5.0 introduced the tokenizer, any
bundled `reasonix run` / `code` / `chat` crashed with ENOENT on the
first API turn after 0.5.4 put the tokenizer on the hot path.

Root cause: `resolveDataPath()` used
`join(dirname(import.meta.url), "..", "data", "deepseek-tokenizer.json.gz")`.

- In dev (`src/tokenizer.ts`) → resolves to `data/` ✓
- In the bundled CLI (`dist/cli/index.js`) → `dist/cli/` + `..` +
  `data/` = `dist/data/` ✗ — the data file ships at PACKAGE ROOT per
  package.json `files` entry; `dist/data/` doesn't exist.

Why this slipped through: before 0.5.4, `countTokens()` was only
called from the opt-in `/context` slash. Casual smoke tests and unit
tests (which run tokenizer.ts directly from src/) never hit the
bundled resolution. 0.5.4's preflight put `countTokens()` on every
step() call — so the first user-facing turn after `npm publish 0.5.4`
would have crashed.

Fix: `resolveDataPath()` now tries multiple candidate paths and
returns the first one that `existsSync`:

1. `REASONIX_TOKENIZER_PATH` env override (unchanged).
2. `../data/...` — dev + bundled `dist/index.js`.
3. `../../data/...` — bundled CLI at `dist/cli/index.js`.
4. `require.resolve("reasonix/package.json")` root — fallback for
   unusual bundlers.

Also falls back to `process.cwd() + data/...` if all candidates
missed, so readFileSync surfaces a concrete path in the error rather
than silently missing.

Caught via live `DEEPSEEK_API_KEY=... node dist/cli/index.js run
"hi"` against the actual bundled output. Unit tests alone would not
have caught this (they load src/tokenizer.ts directly, bypassing
the bundle path). Follow-up consideration: an integration test that
runs the bundled CLI against a fake API response — out of scope here.

874 tests still pass. Lint + typecheck + build clean.
2026-04-23 17:58:35 -07:00
esengine af077ddb24 feat: @file mentions in code mode + session-load heal → tokens (0.5.5)
Two shipped in one release, both closing out the 0.5 arc:

### 1. Session-load heal migrated to tokens

0.5.2 and 0.5.3 made dispatch truncation, /compact, and auto-compact
token-aware. Session load was the last char-based truncation surface;
now closed. CJK tool results in a resumed session come back at the
same effective context footprint as English instead of slipping past
the old 32k-char cap at 2× the token cost.

- `healLoadedMessagesByTokens(messages, maxTokens)` — sibling of the
  existing char-based `healLoadedMessages`.
- `fixToolCallPairing(messages)` — pure helper extracted so both
  variants reuse the structural tool_calls ↔ tool pairing pass.
- Session-resume call in loop.ts constructor switched to the token
  variant with `DEFAULT_MAX_RESULT_TOKENS`. Stderr "healed" line now
  reads "shrunk N tokens of oversized tool output" instead of chars.
- buildMessages's per-turn heal (hot path) stays on the char variant
  — avoids a per-turn tokenizer pass on every tool message.

### 2. `@file` mentions in code mode

User types `@src/loop.ts` → the model sees the file content inlined
under a "Referenced files" block:

    look at @src/loop.ts and explain

    [Referenced files]
    <file path="src/loop.ts">
    ...
    </file>

The user's original text is preserved verbatim; expansion is append-
only so the display stays clean and the model can cite the file
unambiguously.

- Regex `(?<=^|\s)@([a-zA-Z0-9_./\-]+)` — requires word boundary so
  `[email protected]` and `foo@bar` don't trigger. Trailing sentence
  period (`@foo.ts.`) is stripped before lookup.
- Sandbox: relative paths only, resolved against `rootDir`, no `..`
  escape, no absolute paths. Violations get
  `<file path="..." skipped="escape"/>` so the model sees the refusal.
- `DEFAULT_AT_MENTION_MAX_BYTES = 64 KB` (matches tool-result cap
  ballpark). Larger → skipped="too-large" with byte count.
- Missing / not-a-regular-file → skipped with reason.
- De-dupe: same file referenced twice inlines once.
- UI feedback: an info row lists inlined/skipped files so the user
  sees what reached the model.
- Only active in code mode (codeMode.rootDir). Plain chat mode
  doesn't expand — file references there are ambiguous.

### Tests (+15, suite 859 → 874)

- tests/at-mentions.test.ts — regex: boundary matching, email
  rejection, multi-mention; expansion: inline content, de-dupe,
  multiple distinct files, missing/escape/absolute/too-large skips,
  trailing-dot stripping, directory rejection.

Library exports: `expandAtMentions`, `AT_MENTION_PATTERN`,
`DEFAULT_AT_MENTION_MAX_BYTES`, `AtMentionExpansion`,
`AtMentionOptions`. Plus `healLoadedMessagesByTokens` and
`fixToolCallPairing` from loop.ts.

Lint + typecheck + build clean. 874 tests, 5.2s.
2026-04-23 17:48:50 -07:00
esengine 84ea648c84 feat(loop): preflight context-size check before each request (0.5.4)
Reactive auto-compact (60%/80% on prior prompt_tokens) keys off the
PREVIOUS turn's server usage, so a fresh resumed session — or one new
oversized tool result — can push the current turn's buildMessages
straight past 131,072 tokens and make DeepSeek 400 with "maximum
context length" before we see any usage number. Preflight closes that
hole by estimating locally before sending.

Mechanism:

- `estimateRequestTokens(messages, toolSpecs)` in src/tokenizer.ts:
  `estimateConversationTokens(messages) + countTokens(JSON.stringify
  (toolSpecs))`. Mirrors the arithmetic `/context` slash already uses
  so the two numbers don't drift.

- loop.step() injection right after buildMessages(). If the estimate
  exceeds 95% of `DEEPSEEK_CONTEXT_TOKENS[model]` (fallback
  `DEFAULT_CONTEXT_TOKENS`), run `compact(1_000)` and rebuild
  messages from the shrunk log. Warning event names the preflight
  path + tokens saved.

- When there's nothing to compact (first-ever turn with a bulky
  system prompt, for example), still yield a warning naming the
  preflight estimate + "nothing to auto-compact — DeepSeek will likely
  400". User sees the reason before the crash, can /forget or /clear.
  We don't short-circuit the request — let DeepSeek decide; its 400
  catch path already surfaces a useful hint.

- Non-branching AND branching paths share the `messages` variable
  from the outer buildMessages call, so the one injection covers
  both. Forced-summary fallback keeps its own path (a preflight
  there would be too late; the summary is the recovery step).

Tests (+3, suite 856 → 859) in tests/preflight.test.ts:

- Fires when estimate > 95%: compact runs, "pre-compacted N tool
  result(s), saved M tokens" warning appears, loop still completes.
- Stays silent when estimate is comfortably under 95%.
- Warns (but does not block) when over 95% with nothing to compact.

Per-test context budgets are set by mutating `DEEPSEEK_CONTEXT_TOKENS
[TEST_MODEL]` — the table is `Record<string, number>` so the write
is well-typed; teardown deletes the key. Seeded tool results are
properly paired with a prior assistant.tool_calls so
healLoadedMessages doesn't strip them as orphans (which would make
preflight never fire).

Lint + typecheck + build clean. 859 tests, 5.0s.
2026-04-23 17:32:09 -07:00
esengine 2d95c5787d feat(loop): token-aware /compact + auto-compact (0.5.3)
0.5.2 put the DeepSeek V3 tokenizer on the dispatch hot path to cap
NEW tool results by real tokens. 0.5.3 extends that to the live log:
/compact and auto-compact now shrink existing tool results by tokens
too, closing the last surface where CJK text slipped past a char cap
at 2× the intended context cost.

Mechanism:

- `shrinkOversizedToolResultsByTokens(messages, maxTokens)` in
  src/loop.ts — sibling of the char-based `shrinkOversizedToolResults`.
  Fast-pathes tool entries whose length ≤ cap (tokens ≤ length
  guarantee); otherwise tokenizes each candidate twice (before +
  after `truncateForModelByTokens`) to report exact tokensSaved.
  Never tokenizes the full input (same 0.5.2 guard against BPE
  O(n²) on pathological repetitive content).

- `loop.compact(maxTokens = 4000)` now shrinks by tokens and returns
  `{ healedCount, tokensSaved, charsSaved }` (was `{ healedCount,
  charsSaved }`). Persists to the session file when present.

- Auto-compact thresholds in `step()`:
  · 60%-80% band: soft compact at 4000 tokens/result (was 16k chars,
    which was 4k tokens in English but 16k tokens in CJK).
  · >80% band: hard compact at 1000 tokens/result (was 4k chars).
  Warnings report exact tokensSaved instead of `charsSaved / 4`.

- `/compact [N]` slash cap semantic flipped from chars to tokens
  (default 4000). The `info` line now reads "saved N tokens
  (M chars)". Help text + suggestion hint updated.

- Session-load heal (`healLoadedMessages`) left on the char cap so
  stored session files keep loading under their existing semantics.
  Live compaction and dispatch-time truncation are the only two
  surfaces that went token-aware.

Tests (+6, suite 850 → 856):

- tests/compact-tokens.test.ts — small-tool no-op, oversize shrink,
  input non-mutation, user/assistant protection, CJK parity, fast
  path by length.
- tests/loop.test.ts — seeded log content in the two auto-compact
  tests reshaped from `"Z".repeat(N)` to realistic log lines (was
  hitting BPE pathological path; also the 60%-80% warning now
  reads "4k tokens" instead of "16k" chars).
- tests/slash.test.ts — /compact assertions updated for token-based
  output ("saved N tokens", "N tokens each"); test content reshaped
  to realistic log lines.

Breaking change: library callers that pass an explicit number to
`loop.compact(N)` expecting chars will now get a tighter cap (4000
tokens ≈ 16k chars English, ~4k chars CJK). Fail mode is "compaction
is more aggressive than expected," which is the safe direction.

Lint + typecheck + build clean. 856 tests, 5.1s.
2026-04-23 17:21:02 -07:00
esengine 19a2b8d2be feat(tools): token-aware tool-result truncation at dispatch (0.5.2)
Before: every tool result was char-capped at 32K chars (8K English
tokens, but 16K+ tokens for CJK — Chinese/Japanese/Korean tool output
slipped past the budget at 2× the intended context cost).

After: dispatch truncates by REAL DeepSeek V3 tokens, using the
tokenizer that shipped in 0.5.0. Default 8K tokens per result, same
effective footprint regardless of char density.

Mechanism (src/mcp/registry.ts:truncateForModelByTokens):

- Fast path `s.length <= maxTokens` — every token is ≥1 char, so
  length bounds tokens. Skip tokenize entirely for small results.
- Intermediate check for moderate strings (`length <= maxTokens * 4`)
  — tokenize once to confirm it really is over budget.
- For large strings: char-slice head + tail by budget, tokenize the
  slices (NOT the full input), shrink on overshoot. 1–2 iterations per
  slice, bounded at 6. We explicitly never tokenize the whole input —
  pathological repetitive content (`"A".repeat(50_000)`) costs 30s+
  in the pure-TS BPE and is bounded to small slices.
- Dropped-tokens count in the marker is estimated from the per-slice
  char/token ratio (marker reads "~N tokens") so we don't pay a full
  tokenize just for diagnostics.

Wire-up:

- src/tools.ts — `ToolRegistry.dispatch` accepts both `maxResultChars`
  (legacy) and `maxResultTokens` (new). Both apply if both set; the
  tighter one wins. Char-only callers keep their old behavior.
- src/loop.ts — hot-path dispatch switched from
  `maxResultChars: 32_000` to `maxResultTokens: DEFAULT_MAX_RESULT_TOKENS`
  (8_000). Session-healing paths still use chars (preserves backwards
  compat on loaded session files).
- `DEFAULT_MAX_RESULT_TOKENS` and `truncateForModelByTokens` exported
  via src/index.ts for library consumers.

Tests (+9, suite 841 → 850):

- tests/truncate-tokens.test.ts — fast-path, short-string verify,
  CJK cap parity with English, head+tail preservation, zero-budget,
  empty input, marker format.
- tests/loop.test.ts — existing "pre-clips at dispatch" test reshaped
  to use realistic log-line content (was `"A".repeat(50_000)` which
  hit the BPE O(n²) pathological path, 46s+ in the suite).

Lint + typecheck clean. Suite now 5.2s total.
2026-04-23 16:52:23 -07:00
esengine ca27ca50bf fix(shell): reject shell operators (|, >, <, &, &&, ||, 2>) with a clear error (0.5.1)
Before: model writes `dir | findstr foo` → tokenizer produced `["dir",
"/b", "|", "findstr", "foo"]` → quoteForCmdExe wrapped `|` as `"|"`
(literal) → cmd.exe passed `|` as an argument to dir → no pipe, no
error, silent wrong output. The 0.4.26 memory flagged this as a known
issue; encoding fix from that release made the failure visible but
didn't address the root confusion.

After: detectShellOperator() walks the raw command with quote-tracking
and returns the first unquoted operator token at a token boundary.
runCommand rejects before spawn with:

  run_command: shell operator "|" is not supported — this tool spawns
  one process, no shell expansion. Split into separate run_command
  calls and combine the output in your reasoning...

Quoted operators (`grep "a|b" file.txt`) and chars embedded in larger
tokens (`--flag=1&2`) pass through — those are literal argv bytes.
Matches the tool's stated "no shell expansion, no pipes, no redirects"
contract; turns the silent failure into a loud, actionable one.

Tool description updated to document the rejection + the quote-it-as-
a-literal workaround so the model learns the constraint from the spec.

Tests: 841 (+13 covering pipe, redirect, fd-redirect, &&/||, stuck-
redirect, quoted operators, embedded operators).
2026-04-23 16:38:27 -07:00
esengine d4615d6e76 chore(0.5.0): fix CI + add animated SVG logo
CI fix — biome lint surfaced 9 errors on the 0.5.0 commit:
- tokenizer.ts: Infinity → Number.POSITIVE_INFINITY (style/useNumberNamespace)
- tokenizer.ts: formatter wrap on the CJS fallback branch
- StatsPanel.tsx: noArrayIndexKey on the Wordmark map — restructured to a
  module-level WORDMARK_STYLES table keyed by char+color pair, dropped
  the `i` parameter entirely (previous biome-ignore comment only
  suppressed one of two sites the rule fires on)
- various whitespace / line-wrap fixes from `biome check --write`

Animated SVG logo at docs/logo.svg, embedded at the top of README:
- Concentric diamond brand mark (◈) with three layers of motion:
  slow rotation (18s), outer-stroke pulse (2.4s), inner-fill breathing
- REASONIX wordmark in the same cyan → fuchsia gradient the TUI uses,
  so the README and the running app share one visual identity
- Tagline underneath for SEO-style context
- Colors (~4.5:1 contrast on both GitHub dark and light) — no
  prefers-color-scheme switching needed

828 tests, lint + typecheck clean.
2026-04-23 09:34:25 -07:00
esengine 9eff2e2cfc feat(0.5.0): Reasonix visual identity + DeepSeek tokenizer + models/pricing/context
Visual redesign — drop the generic vertical bars for a Reasonix-native
design language. The UI should be recognizable as ours at a glance:

- ◈ REASONIX wordmark in truecolor gradient (cyan → violet), with
  breathing animation — slow when idle, faster when the model is
  thinking, so the app's state is readable from the edge of vision.
- Geometric role glyphs replace all text labels: ◇ user · ◆ assistant ·
  ▣ tool · ▥ tool-err · ▲ warn · ✦ error. Consistent geometric family,
  distinct per role, no overlap with Claude / Cursor / Cline.
- Streaming assistant glyph pulses ◆↔◇ at ~480ms — visible heartbeat
  during R1's long (20-90s) pre-first-byte silence.
- Turn separators render "─── ◆ ───" with the Reasonix mark centered.
- Responsive StatsPanel: stacks metrics vertically on <120-col
  terminals (SSH, split-pane); inline when there's room. Context
  cell gains a 10-cell progress bar in the stacked layout.
- Cold-start cache hit ratio (turn ≤ 3) renders dim gray with a
  "(cold start)" hint instead of red — new users stopped reading
  "0% cache on turn 1" as a configuration error.
- Tool results tail-truncate by default (useful signal is usually at
  the end). ERROR-prefixed results keep the first line + a tail.
- Modal footers gain "[↑↓] navigate · [Enter] select · [Esc] cancel"
  hints; Esc actually cancels now (ShellConfirm → deny, PlanConfirm →
  cancel); SingleSelect gained an optional footer slot.
- Wait-phase messaging (pre-first-byte, R1 reasoning, branching,
  tool-call streaming) gets visible yellow italic instead of dim —
  users had been misreading the dim variant as "frozen".

New features:

- Pure-TS port of DeepSeek's V3 tokenizer (vocab + merges + pre_tokenizer
  from the official tokenizer.json). Bundled as data/deepseek-
  tokenizer.json.gz (1.74MB, slimmed + gzipped from 7.5MB). Zero new
  deps — uses built-in zlib. Encodes ~12k tokens of mixed source in
  ~40ms. scripts/prepare-tokenizer.ts regenerates the bundle from
  upstream.
- /context slash — renders a breakdown of the next request's tokens:
  system prompt, tool specs, per-turn log, top 5 heaviest tool results.
  Answers "where is my context going?" directly; closes the gap where
  StatsPanel showed ctx % without saying why.
- /models slash — fetches DeepSeek's live model catalog via
  GET /models. Renders the list with the current model marked.
- /model <id> now soft-validates against the fetched catalog (warn
  but still switch — accommodates newly-released models we haven't
  indexed). DeepSeekClient gains a listModels() method.

Loop + billing correctness:

- Tool results pre-clipped at dispatch time (default 32k chars) via
  ToolRegistry.dispatch({ maxResultChars }). Oversized results no
  longer bloat the AppendOnlyLog or the persisted session file.
- New proactive-compact tier: when promptTokens / ctxMax crosses 60%
  (before the existing 80% reactive guard), shrink oversized tool
  results to 16k chars. Catches slow-growth patterns before they
  compound into a forced summary.
- DEEPSEEK_PRICING updated to the current unified rates (¥0.2 / ¥2 /
  ¥3 per 1M at cache-hit / cache-miss / output; USD 0.028 / 0.28 /
  0.42 at 7.2 CNY/USD). chat and reasoner are now priced identically
  at source. Stats dashboard figures were systematically high under
  the old tiered constants — new sessions bill correctly.
- Context display switches to binary-K (128K) to match DeepSeek's
  marketing label. DEEPSEEK_CONTEXT_TOKENS stays at 131072 — only
  the formatter divisor changed.

OSC 8 clickable hyperlinks in markdown were attempted and reverted:
Ink 5 strips \x1b / \x07 from Text output, so file:// links never
reach the terminal. Documented as a known limit; re-visit with a
future Ink release or raw-stdout approach.

828 tests (14 new: tokenizer golden cases + /context + /models +
listModels + pricing unification + proactive compact). Lint +
typecheck + build clean.
2026-04-23 09:28:40 -07:00
esengine c2f8e9b734 feat(0.4.28): reasonix code defaults harvest OFF — opt in via --harvest
Pillar 2 harvesting fires an extra V3 call per turn to extract a typed
plan state (subgoals/hypotheses/uncertainties/rejectedPaths) from R1's
reasoning_content. Cost ~10-15% per turn. Closer audit of who actually
consumes the output:

- subgoals / hypotheses / rejectedPaths — purely cosmetic, only rendered
  in PlanStateBlock. No programmatic consumer. Can mislead users by
  giving structured-looking output extra perceived authority (today's
  citation work was triggered by hallucinated "hypotheses" that looked
  thoughtful).
- uncertainties.length — single real downstream use, drives self-
  consistency branching when count exceeds threshold. Could be replaced
  with a cheap heuristic on the raw reasoning text.

For coding tasks specifically, branching is rarely on (default budget=1)
and the cosmetic display value doesn't justify the per-turn premium.
Flip the default off; users who genuinely want the plan state surfaced
can pass --harvest. `reasonix chat` is unchanged — preset behaviour
there still controls harvest.

799 tests, lint + build clean.
2026-04-23 05:13:20 -07:00
63 changed files with 6485 additions and 315 deletions

No files matched your search

+8
View File
@@ -1,3 +1,11 @@
<p align="center">
<img src="docs/logo.svg" alt="Reasonix — DeepSeek-native agent framework" width="640"/>
</p>
<p align="center">
<em>Cache-first agent loop for DeepSeek V3 &amp; R1 — Ink TUI, MCP first-class, no LangChain.</em>
</p>
# Reasonix
[![npm version](https://img.shields.io/npm/v/reasonix.svg)](https://www.npmjs.com/package/reasonix)
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="170" viewBox="0 0 760 170" role="img" aria-label="Reasonix — DeepSeek-native agent framework">
<title>Reasonix</title>
<defs>
<!-- Signature cyan → violet → fuchsia gradient used across the TUI wordmark
and the brand mark. Renders well on both GitHub dark and light bg. -->
<linearGradient id="rx-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="15%" stop-color="#67e8f9"/>
<stop offset="30%" stop-color="#7dd3fc"/>
<stop offset="45%" stop-color="#93c5fd"/>
<stop offset="60%" stop-color="#a5b4fc"/>
<stop offset="75%" stop-color="#c4b5fd"/>
<stop offset="90%" stop-color="#d8b4fe"/>
<stop offset="100%" stop-color="#f0abfc"/>
</linearGradient>
<linearGradient id="rx-grad-mark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#5eead4"/>
<stop offset="50%" stop-color="#93c5fd"/>
<stop offset="100%" stop-color="#c4b5fd"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- ◈ brand mark: concentric diamonds. Outer stroke pulses,
inner fill breathes, whole group rotates slowly. The rotation
is subtle (12s / full turn) so it feels like a heartbeat, not a
spinner. -->
<g transform="translate(90, 85)" filter="url(#glow)">
<g>
<animateTransform attributeName="transform" type="rotate"
from="0" to="360" dur="18s" repeatCount="indefinite"/>
<!-- Outer hollow diamond -->
<path d="M 0,-46 L 46,0 L 0,46 L -46,0 Z"
fill="none"
stroke="url(#rx-grad-mark)"
stroke-width="3.5"
stroke-linejoin="round">
<animate attributeName="stroke-width"
values="3.5;5.5;3.5"
dur="2.4s"
repeatCount="indefinite"/>
</path>
<!-- Middle hollow diamond -->
<path d="M 0,-26 L 26,0 L 0,26 L -26,0 Z"
fill="none"
stroke="url(#rx-grad-mark)"
stroke-width="2"
stroke-linejoin="round"
opacity="0.7">
<animate attributeName="opacity"
values="0.4;0.9;0.4"
dur="2.4s"
begin="0.8s"
repeatCount="indefinite"/>
</path>
<!-- Inner solid diamond -->
<path d="M 0,-11 L 11,0 L 0,11 L -11,0 Z"
fill="url(#rx-grad-mark)">
<animate attributeName="opacity"
values="0.75;1;0.75"
dur="2.4s"
repeatCount="indefinite"/>
</path>
</g>
</g>
<!-- REASONIX wordmark with shifting gradient. The x1 offset animates
so the gradient crawls left-to-right across the letters — a
slow rainbow sweep that gives the logo life without being noisy. -->
<text x="170" y="102"
font-family="ui-monospace, SFMono-Regular, 'Cascadia Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font-size="68"
font-weight="800"
fill="url(#rx-grad)"
letter-spacing="6">REASONIX
<animate attributeName="opacity"
values="0.9;1;0.9"
dur="3.2s"
repeatCount="indefinite"/>
</text>
<!-- Tagline sits just below the wordmark. Slate-500 reads well on
both GitHub dark and light themes (~4.5:1 contrast either way). -->
<text x="172" y="136"
font-family="ui-monospace, SFMono-Regular, 'Cascadia Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace"
font-size="15"
fill="#64748b"
letter-spacing="0.5">DeepSeek-native agent framework · cache-first loop · Ink TUI</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.4.27",
"version": "0.5.19",
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
"type": "module",
"bin": {
@@ -17,6 +17,7 @@
},
"files": [
"dist",
"data/deepseek-tokenizer.json.gz",
"README.md",
"LICENSE"
],
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* Regenerate `data/deepseek-tokenizer.json.gz` from an upstream
* `tokenizer.json` (as shipped in DeepSeek's deepseek_v3_tokenizer.zip).
*
* Why slim+gz: the full tokenizer.json is 7.5MB; we only need four
* fields (vocab, merges, pre_tokenizer, added_tokens). Strip + gzip
* drops the committed artifact to ~1.7MB with no loss of fidelity
* for encode-side token counting.
*
* Usage: `node --experimental-strip-types scripts/prepare-tokenizer.ts <path/to/tokenizer.json>`
* (or via `tsx` / `ts-node`.)
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
const src = process.argv[2];
if (!src) {
process.stderr.write("usage: prepare-tokenizer.ts <tokenizer.json>\n");
process.exit(2);
}
const raw = JSON.parse(readFileSync(src, "utf8"));
const slim = {
added_tokens: raw.added_tokens,
pre_tokenizer: raw.pre_tokenizer,
model: { type: raw.model.type, vocab: raw.model.vocab, merges: raw.model.merges },
};
const gz = gzipSync(Buffer.from(JSON.stringify(slim)), { level: 9 });
const outPath = join(process.cwd(), "data", "deepseek-tokenizer.json.gz");
writeFileSync(outPath, gz);
process.stdout.write(`wrote ${outPath} (${(gz.length / 1024).toFixed(1)} KB)\n`);
+397
View File
@@ -0,0 +1,397 @@
/**
* Expand `@path/to/file` mentions in a user prompt to inline file
* content.
*
* Why: most interactive coding sessions start with "look at X, then
* change Y". Typing `@src/loop.ts` reads faster and cheaper than
* "look at src/loop.ts (and the model fires read_file, and we pay for
* the round trip)" — the model sees the file content from turn 1
* instead of round-tripping a tool call for it.
*
* Shape: the user's text is kept verbatim. Expanded file contents are
* appended in a "Referenced files" block at the end, each wrapped in
* `<file path="...">...</file>` so the model can cite them back
* unambiguously.
*
* Safety: paths must resolve inside `rootDir` (no `..` escape, no
* absolute paths), must exist as a regular file, and must be under
* `maxBytes`. Missing / too-large / escaping paths get a short note
* appended instead of content so the user sees why it was skipped.
*/
import { type Dirent, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
/** Caps match tool-result dispatch truncation (0.5.2). */
export const DEFAULT_AT_MENTION_MAX_BYTES = 64 * 1024;
/**
* Default directory names skipped when listing files for the picker.
* Matches what most repos gitignore AND keeps the picker off the
* hottest bloat — `node_modules` alone can be 100k+ entries.
*/
export const DEFAULT_PICKER_IGNORE_DIRS: readonly string[] = [
"node_modules",
".git",
"dist",
"build",
".next",
"out",
"coverage",
".cache",
".vscode",
".idea",
"target",
".venv",
"venv",
"__pycache__",
];
export interface ListFilesOptions {
/** Cap the walk once we've collected this many entries. Default 500. */
maxResults?: number;
/** Directory names to skip entirely. Defaults to {@link DEFAULT_PICKER_IGNORE_DIRS}. */
ignoreDirs?: readonly string[];
}
/**
* Walk `root` recursively and return relative file paths (forward-slash
* separator, regardless of platform) for the `@` picker.
*
* Synchronous on purpose: this runs once at App mount (and on each turn
* so newly-created files show up) and blocks the render thread for a
* predictable ~10-50ms on a moderate repo. An async variant would need
* to coordinate with the Ink render loop; sync fits the rest of the
* TUI's single-turn-per-tick model cleanly.
*
* Skips:
* - directories in `ignoreDirs` (default: DEFAULT_PICKER_IGNORE_DIRS)
* - any directory whose name starts with `.` (covers `.git`,
* `.vscode`, dotfile vendors). Dotfile REGULAR FILES (`.env`,
* `.gitignore`, `.prettierrc`) are kept — users reference them.
* - entries the walker can't read (permission errors, broken links).
*/
export function listFilesSync(root: string, opts: ListFilesOptions = {}): string[] {
return listFilesWithStatsSync(root, opts).map((e) => e.path);
}
export interface FileWithStats {
/** Relative path with forward-slash separator. */
path: string;
/** Modification time (Date.getTime() / ms since epoch). 0 when stat failed. */
mtimeMs: number;
}
/**
* Same walk as {@link listFilesSync} but also statS each file for
* modification time. Used by the `@` picker to surface recently-
* edited files first — matches VS Code Quick Open / similar UX.
*
* Stat failures don't throw: the entry is kept with `mtimeMs: 0` so
* it still appears in the picker (just sinks to the bottom of the
* recency sort).
*/
export function listFilesWithStatsSync(root: string, opts: ListFilesOptions = {}): FileWithStats[] {
const maxResults = Math.max(1, opts.maxResults ?? 500);
const ignore = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
const rootAbs = resolve(root);
const out: FileWithStats[] = [];
const walk = (dirAbs: string, dirRel: string) => {
if (out.length >= maxResults) return;
let entries: Dirent[];
try {
entries = readdirSync(dirAbs, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const ent of entries) {
if (out.length >= maxResults) return;
const relPath = dirRel ? `${dirRel}/${ent.name}` : ent.name;
if (ent.isDirectory()) {
if (ent.name.startsWith(".") || ignore.has(ent.name)) continue;
walk(join(dirAbs, ent.name), relPath);
} else if (ent.isFile()) {
let mtimeMs = 0;
try {
mtimeMs = statSync(join(dirAbs, ent.name)).mtimeMs;
} catch {
/* stat failed (permission / EAGAIN) — keep the entry with mtime=0 */
}
out.push({ path: relPath, mtimeMs });
}
}
};
walk(rootAbs, "");
return out;
}
/**
* Prefix pattern used by the `@` picker to detect an IN-PROGRESS
* mention at the END of the input buffer. Captures the partial path
* (which may be empty — just `@`) so the picker can use it as a
* substring filter.
*
* Distinct from {@link AT_MENTION_PATTERN} (which finds completed
* mentions anywhere in the text for expansion-at-submit). This one
* fires on the trailing token only, anchored at end-of-input.
*/
export const AT_PICKER_PREFIX = /(?:^|\s)@([a-zA-Z0-9_./\\-]*)$/;
/**
* Return the picker state for a given input buffer: the partial query
* (may be empty string — just `@`) and the buffer offset of the `@`
* character. `null` when the buffer doesn't end in a mention-in-
* progress.
*/
export function detectAtPicker(input: string): { query: string; atOffset: number } | null {
const m = AT_PICKER_PREFIX.exec(input);
if (!m) return null;
const query = m[1] ?? "";
// `m.index` is the offset of the capture group's SURROUNDING match —
// which starts at either ^ or the preceding whitespace. The `@`
// itself is at `end-of-input - query.length - 1`.
const atOffset = input.length - query.length - 1;
return { query, atOffset };
}
/** A candidate accepted by the picker ranker — either a bare path or a path with mtime. */
export type PickerCandidate = string | FileWithStats;
export interface RankPickerOptions {
/** Upper bound on returned entries. Default 40. */
limit?: number;
/**
* Paths the user or model has touched recently (via tool calls like
* `read_file` / `edit_file`). Matching paths get a recency boost so
* the picker surfaces "stuff I just looked at" near the top.
*/
recentlyUsed?: readonly string[];
}
/**
* Filter and rank candidate files against the picker's partial query.
*
* Empty query:
* - Sort by "recently used" bucket first (if provided), then mtime
* descending (newer first), then path alpha.
* - Pure-string input (no mtime data) falls back to alpha since
* recency info isn't available.
*
* Non-empty query:
* - Case-insensitive substring match, with a basename-prefix boost
* so `lo` floats `loop.ts`-shaped paths to the top.
* - Ties broken first by recently-used membership, then mtime.
*
* Back-compat: passes `string[]` through the same logic (mtime = 0,
* recently-used still honored).
*/
export function rankPickerCandidates(
files: readonly PickerCandidate[],
query: string,
limitOrOpts?: number | RankPickerOptions,
): string[] {
const opts: RankPickerOptions =
typeof limitOrOpts === "number" ? { limit: limitOrOpts } : (limitOrOpts ?? {});
const limit = opts.limit ?? 40;
const recent = new Set(opts.recentlyUsed ?? []);
const entries: FileWithStats[] = files.map((f) =>
typeof f === "string" ? { path: f, mtimeMs: 0 } : f,
);
if (!query) {
// Only re-sort when we actually have signal to sort by. If input
// is bare strings (mtime = 0 everywhere) AND there's no recent-
// used list, preserve input order so callers keep their existing
// layout. Passing FileWithStats or a non-empty recentlyUsed opts
// you into mtime+recency ranking.
const anyMtime = entries.some((e) => e.mtimeMs > 0);
if (!anyMtime && recent.size === 0) {
return entries.slice(0, limit).map((e) => e.path);
}
const sorted = [...entries].sort((a, b) => {
const aRecent = recent.has(a.path) ? 1 : 0;
const bRecent = recent.has(b.path) ? 1 : 0;
if (aRecent !== bRecent) return bRecent - aRecent;
if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
return a.path.localeCompare(b.path);
});
return sorted.slice(0, limit).map((e) => e.path);
}
const needle = query.toLowerCase();
const scored: Array<{ path: string; score: number; mtimeMs: number; recent: boolean }> = [];
for (const e of entries) {
const lower = e.path.toLowerCase();
const hit = lower.indexOf(needle);
if (hit < 0) continue;
const slash = lower.lastIndexOf("/");
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
let score = 2;
if (base.startsWith(needle)) score = 0;
else if (lower.startsWith(needle)) score = 1;
scored.push({
path: e.path,
score: score * 10_000 + hit,
mtimeMs: e.mtimeMs,
recent: recent.has(e.path),
});
}
scored.sort((a, b) => {
if (a.score !== b.score) return a.score - b.score;
// Tie-break: recently-used, then mtime (newer first).
if (a.recent !== b.recent) return a.recent ? -1 : 1;
return b.mtimeMs - a.mtimeMs;
});
return scored.slice(0, limit).map((s) => s.path);
}
/**
* Matches `@` at a word boundary (start-of-string or preceded by
* whitespace) followed by a path-like token. Deliberately rejects `@`
* embedded in longer words (email addresses, mentions on social sites)
* by requiring the word boundary.
*
* Path charset keeps it to the characters that appear in real repo
* paths — letters, digits, `_` `-` `.` `/` `\`. Trailing `.` (e.g.
* `@foo.ts.`) is stripped before lookup so a sentence-terminating
* period doesn't break the mention.
*/
export const AT_MENTION_PATTERN = /(?<=^|\s)@([a-zA-Z0-9_./\\-]+)/g;
export interface AtMentionExpansion {
/** The raw `@path` token as it appeared in the text. */
token: string;
/** The relative path, as resolved against rootDir. */
path: string;
/** True if the content was inlined. False = skipped (reason in `skip`). */
ok: boolean;
/** Bytes read (only for ok=true). */
bytes?: number;
/** Why the mention was skipped. Set when ok=false. */
skip?: "missing" | "not-file" | "too-large" | "escape" | "read-error";
}
export interface AtMentionOptions {
/** Max file size in bytes before a mention is skipped. */
maxBytes?: number;
/**
* Optional file-system overrides for tests. Real callers omit these;
* the helper falls through to `node:fs`.
*/
fs?: {
exists: (path: string) => boolean;
isFile: (path: string) => boolean;
size: (path: string) => number;
read: (path: string) => string;
};
}
/**
* Expand `@path` mentions in `text`. Returns the (possibly augmented)
* text plus a per-mention report so the caller can surface expansions
* in the UI.
*/
export function expandAtMentions(
text: string,
rootDir: string,
opts: AtMentionOptions = {},
): { text: string; expansions: AtMentionExpansion[] } {
const maxBytes = opts.maxBytes ?? DEFAULT_AT_MENTION_MAX_BYTES;
const fs = opts.fs ?? defaultFs;
const root = resolve(rootDir);
// De-dupe by token so `@file.ts` referenced twice inlines once.
const seen = new Map<string, AtMentionExpansion>();
const expansions: AtMentionExpansion[] = [];
for (const match of text.matchAll(AT_MENTION_PATTERN)) {
const rawPath = match[1] ?? "";
// Strip trailing dot (sentence terminator): `@foo.ts.` → `@foo.ts`.
// Keep internal dots intact.
const cleaned = rawPath.replace(/\.+$/, "");
if (!cleaned) continue;
const token = `@${cleaned}`;
if (seen.has(token)) continue;
const expansion = resolveMention(cleaned, root, maxBytes, fs);
seen.set(token, expansion);
expansions.push(expansion);
}
if (expansions.length === 0) return { text, expansions };
// Build the trailing "Referenced files" block. Keep successful
// inlines and skipped ones (with their reason) so the model sees
// both what's here and what's missing.
const blocks: string[] = [];
for (const ex of expansions) {
if (ex.ok) {
const content = readSafe(root, ex.path, fs);
blocks.push(`<file path="${ex.path}">\n${content}\n</file>`);
} else {
blocks.push(`<file path="${ex.path}" skipped="${ex.skip}" />`);
}
}
const augmented = `${text}\n\n[Referenced files]\n${blocks.join("\n\n")}`;
return { text: augmented, expansions };
}
function resolveMention(
rawPath: string,
root: string,
maxBytes: number,
fs: NonNullable<AtMentionOptions["fs"]>,
): AtMentionExpansion {
// Reject absolute paths — `@/etc/passwd` should not inline.
if (isAbsolute(rawPath)) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
}
const resolved = resolve(root, rawPath);
// Sandbox escape: after resolution the path must still be inside root.
const rel = relative(root, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
}
if (!fs.exists(resolved)) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "missing" };
}
if (!fs.isFile(resolved)) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
}
const size = fs.size(resolved);
if (size > maxBytes) {
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
}
return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
}
function readSafe(root: string, rawPath: string, fs: NonNullable<AtMentionOptions["fs"]>): string {
const resolved = resolve(root, rawPath);
try {
return fs.read(resolved);
} catch {
return "(read failed)";
}
}
const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
exists: (p) => existsSync(p),
isFile: (p) => {
try {
return statSync(p).isFile();
} catch {
return false;
}
},
size: (p) => {
try {
return statSync(p).size;
} catch {
return 0;
}
},
read: (p) => readFileSync(p, "utf8"),
};
+1
View File
@@ -211,6 +211,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
spec: raw,
toolCount: bridge.registeredNames.length,
report,
client: mcp,
});
} catch (err) {
// Per-server failure is non-fatal: one broken server shouldn't
+10 -1
View File
@@ -41,6 +41,15 @@ export interface CodeOptions {
forceResume?: boolean;
/** Skip the session picker — always wipe prior messages and start fresh. */
forceNew?: boolean;
/**
* Opt into Pillar 2 harvesting (extracts a typed plan state from R1
* reasoning via an extra V3 call). Default OFF in code mode — the
* displayed subgoals/hypotheses/rejectedPaths have no programmatic
* consumer, only `uncertainties.length` feeds branching, and the
* extra V3 call adds ~10-15% per-turn cost. Users who want the
* reasoning surfaced explicitly can pass `--harvest`.
*/
harvest?: boolean;
}
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
@@ -87,7 +96,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
await chatCommand({
model: opts.model ?? "deepseek-reasoner",
harvest: true, // smart preset's harvest setting, always on for code
harvest: opts.harvest ?? false,
system: codeSystemPrompt(rootDir),
transcript: opts.transcript,
session,
+24
View File
@@ -125,6 +125,30 @@ export function renderDashboard(agg: UsageAggregate, logPath: string): string {
if (agg.firstSeen) {
lines.push(`tracked since: ${new Date(agg.firstSeen).toISOString().slice(0, 10)}`);
}
if (agg.subagents) {
lines.push("");
lines.push(renderSubagentSection(agg.subagents));
}
return lines.join("\n");
}
function renderSubagentSection(sub: NonNullable<UsageAggregate["subagents"]>): string {
const lines: string[] = [];
const seconds = (sub.totalDurationMs / 1000).toFixed(1);
lines.push(
`subagent activity: ${sub.total} run(s) · $${sub.costUsd.toFixed(6)} · ${seconds}s total`,
);
// Show at most 5 skills so the section never dwarfs the main table.
const top = sub.bySkill.slice(0, 5);
for (const s of top) {
const sec = (s.durationMs / 1000).toFixed(1);
lines.push(
` ${pad(s.skillName, 18)} ${pad(`${s.count}`, 4, "right")} $${s.costUsd.toFixed(6)} ${sec}s`,
);
}
if (sub.bySkill.length > top.length) {
lines.push(` (+${sub.bySkill.length - top.length} more)`);
}
return lines.join("\n");
}
+6 -1
View File
@@ -68,13 +68,17 @@ program
program
.command("code [dir]")
.description(
"Code-editing chat — filesystem MCP auto-bridged at <dir> (default: cwd), coding system prompt, smart preset. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk.",
"Code-editing chat — filesystem tools rooted at <dir> (default: cwd), coding system prompt, deepseek-reasoner. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk.",
)
.option("-m, --model <id>", "Override default reasoner model")
.option("--no-session", "Disable session persistence for this run")
.option("-r, --resume", "Skip the session picker — always continue prior messages")
.option("-n, --new", "Skip the session picker — always wipe prior messages and start fresh")
.option("--transcript <path>", "Write a JSONL transcript to this path")
.option(
"--harvest",
"Extract typed plan state from R1 reasoning (Pillar 2). Adds ~10-15% cost per turn. Off by default in code mode.",
)
.action(async (dir: string | undefined, opts) => {
await codeCommand({
dir,
@@ -83,6 +87,7 @@ program
transcript: opts.transcript,
forceResume: !!opts.resume,
forceNew: !!opts.new,
harvest: !!opts.harvest,
});
});
+531 -29
View File
@@ -1,6 +1,14 @@
import type { WriteStream } from "node:fs";
import { Box, Static, Text, useApp, useInput } from "ink";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type FileWithStats,
detectAtPicker,
expandAtMentions,
listFilesWithStatsSync,
rankPickerCandidates,
} from "../../at-mentions.js";
import { formatAllBlockDiffs } from "../../code/diff-preview.js";
import {
type ApplyResult,
type EditBlock,
@@ -10,6 +18,7 @@ import {
restoreSnapshots,
snapshotBeforeEdits,
} from "../../code/edit-blocks.js";
import { clearPendingEdits, loadPendingEdits, savePendingEdits } from "../../code/pending-edits.js";
import { addProjectShellAllowed } from "../../config.js";
import { type ResolvedHook, formatHookOutcomeMessage, loadHooks, runHooks } from "../../hooks.js";
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
@@ -27,14 +36,26 @@ import {
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
import { appendUsage } from "../../usage.js";
import { VERSION, compareVersions, getLatestVersion } from "../../version.js";
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
import { type DisplayEvent, EventRow } from "./EventLog.js";
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
import { PlanRefineInput } from "./PlanRefineInput.js";
import { PromptInput } from "./PromptInput.js";
import { ShellConfirm, type ShellConfirmChoice, derivePrefix } from "./ShellConfirm.js";
import { SlashArgPicker } from "./SlashArgPicker.js";
import { SlashSuggestions } from "./SlashSuggestions.js";
import { StatsPanel } from "./StatsPanel.js";
import { type McpServerSummary, handleSlash, parseSlash, suggestSlashCommands } from "./slash.js";
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
import { handleMcpBrowseSlash } from "./mcp-browse.js";
import { formatLongPaste } from "./paste-collapse.js";
import {
type McpServerSummary,
type SlashArgContext,
detectSlashArgContext,
handleSlash,
parseSlash,
suggestSlashCommands,
} from "./slash.js";
import { TickerProvider, useElapsedSeconds, useTick } from "./ticker.js";
export interface AppProps {
@@ -156,6 +177,11 @@ export function App({
// tracks reality. `null` means either the endpoint failed or we
// haven't fetched yet; the panel hides the cell in that case.
const [balance, setBalance] = useState<{ currency: string; total: number } | null>(null);
// Model catalog fetched from DeepSeek's /models endpoint once at
// launch. `null` while the call is in flight or failed; `[]` means
// the API answered with zero models (unlikely but possible). Powers
// /models and validation in /model.
const [models, setModels] = useState<string[] | null>(null);
// Latest published version the npm registry returned, REGARDLESS
// of whether it's newer than what we're running. `null` only while
// the background check is in flight or when the network fails —
@@ -287,6 +313,151 @@ export function App({
});
}, [slashMatches]);
// File picker for `@` mentions — only meaningful in code mode where
// the model has filesystem tools and expandAtMentions is active.
const [atSelected, setAtSelected] = useState(0);
// Walk the code root ONCE on mount, collecting mtime alongside path
// so the picker can surface recently-edited files first. Files
// created mid-session via tool edits won't appear until restart —
// rare, and the user can always type the full path.
const atFiles = useMemo<readonly FileWithStats[]>(() => {
if (!codeMode?.rootDir) return [];
try {
return listFilesWithStatsSync(codeMode.rootDir, { maxResults: 500 });
} catch {
return [];
}
}, [codeMode?.rootDir]);
// LRU of files touched by recent tool calls (read_file / edit_file /
// write_file / search_content / directory_tree). 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;
const i = list.indexOf(p);
if (i >= 0) list.splice(i, 1);
list.unshift(p);
if (list.length > 20) list.length = 20;
}, []);
// Detect the trailing `@…` prefix and the partial query. `null` when
// we're not in picker mode (non-code mode, buffer doesn't end in a
// mention-in-progress, or already in slash mode).
const atPicker = useMemo(() => {
if (!codeMode?.rootDir) return null;
// Slash prefix wins — avoids the picker confusingly surfacing on
// `/@wat`-style edge inputs.
if (slashMatches !== null) return null;
return detectAtPicker(input);
}, [codeMode?.rootDir, input, slashMatches]);
const atMatches = useMemo<readonly string[] | null>(() => {
if (!atPicker) return null;
return rankPickerCandidates(atFiles, atPicker.query, {
limit: 40,
recentlyUsed: recentFilesRef.current,
});
}, [atPicker, atFiles]);
useEffect(() => {
setAtSelected((prev) => {
if (!atMatches || atMatches.length === 0) return 0;
if (prev >= atMatches.length) return atMatches.length - 1;
return prev;
});
}, [atMatches]);
// Substitute the trailing `@partial` with `@chosenPath ` and keep
// the rest of the buffer intact. The trailing space auto-closes
// the picker (regex no longer matches) so Enter next time submits
// cleanly without needing an explicit dismissal.
const pickAtMention = useCallback(
(chosenPath: string) => {
if (!atPicker) return;
const before = input.slice(0, atPicker.atOffset);
setInput(`${before}@${chosenPath} `);
},
[atPicker, input],
);
// Slash-argument picker. After `/<cmd> <partial>` we surface one of
// three states:
// - file picker → reuses `atFiles` + rankPickerCandidates
// - enum picker → literal values from spec.argCompleter
// - usage hint only → dim "what goes here?" line
// Mutually exclusive with the slash-name picker (those conditions
// don't overlap by regex) and with the atMention picker (we don't
// run both — slash args take priority inside a slash buffer).
const [slashArgSelected, setSlashArgSelected] = useState(0);
const slashArgContext = useMemo<SlashArgContext | null>(() => {
// Only active inside a slash buffer that's past the name boundary.
if (!input.startsWith("/")) return null;
if (slashMatches !== null) return null;
return detectSlashArgContext(input, !!codeMode);
}, [input, slashMatches, codeMode]);
const slashArgMatches = useMemo<readonly string[] | null>(() => {
if (!slashArgContext || slashArgContext.kind !== "picker") return null;
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();
return completer.filter((v) => v.toLowerCase().startsWith(needle));
}
if (completer === "models") {
const all = models ?? [];
if (partial && all.some((m) => m.toLowerCase() === needle)) return null;
if (!partial) return all.slice(0, 40);
return all.filter((m) => m.toLowerCase().includes(needle)).slice(0, 40);
}
if (completer === "mcp-resources") {
// Aggregate URIs across every server's cached inspection. Prefix
// with the server label when >1 server so users can tell them
// apart — the read handler strips the prefix before calling.
const uris: string[] = [];
const servers = mcpServers ?? [];
for (const s of servers) {
if (!s.report.resources.supported) continue;
for (const r of s.report.resources.items) uris.push(r.uri);
}
if (partial && uris.some((u) => u.toLowerCase() === needle)) return null;
if (!partial) return uris.slice(0, 40);
return uris.filter((u) => u.toLowerCase().includes(needle)).slice(0, 40);
}
if (completer === "mcp-prompts") {
const names: string[] = [];
const servers = mcpServers ?? [];
for (const s of servers) {
if (!s.report.prompts.supported) continue;
for (const p of s.report.prompts.items) names.push(p.name);
}
if (partial && names.some((n) => n.toLowerCase() === needle)) return null;
if (!partial) return names.slice(0, 40);
return names.filter((n) => n.toLowerCase().includes(needle)).slice(0, 40);
}
return null;
}, [slashArgContext, models, mcpServers]);
useEffect(() => {
setSlashArgSelected((prev) => {
if (!slashArgMatches || slashArgMatches.length === 0) return 0;
if (prev >= slashArgMatches.length) return slashArgMatches.length - 1;
return prev;
});
}, [slashArgMatches]);
const pickSlashArg = useCallback(
(chosen: string) => {
if (!slashArgContext) return;
const before = input.slice(0, slashArgContext.partialOffset);
// No trailing space — enum picks (preset / model / plan / branch /
// harvest) take no further args, so the user presses Enter once
// more to run the command.
setInput(`${before}${chosen}`);
},
[slashArgContext, input],
);
const loopRef = useRef<CacheFirstLoop | null>(null);
// Sink the subagent tool emits live events through (`start` →
// `progress` → `end`). App attaches its updater on first loop
@@ -327,6 +498,9 @@ export function App({
// else falls through to spawnSubagent's default.
model: skill.model,
sink: subagentSinkRef.current,
// Stamped onto every event so the TUI sink + usage log can
// attribute the run to a skill without extra bookkeeping.
skillName: skill.name,
});
return formatSubagentResult(result);
},
@@ -375,6 +549,21 @@ export function App({
};
}, [loop]);
// Fetch the model catalog from DeepSeek once. Same pattern as
// balance: silent degrade on failure (stays null), so /models can
// tell "still loading / offline" apart from "loaded, here's the list."
useEffect(() => {
let cancelled = false;
void (async () => {
const list = await loop.client.listModels().catch(() => null);
if (cancelled || !list) return;
setModels(list.data.map((m) => m.id));
})();
return () => {
cancelled = true;
};
}, [loop]);
// Background registry check — 24h disk cache absorbs repeated
// launches, timeout bounded so a flaky network doesn't delay the
// notification. Set to `null` on failure (silent: no network, no
@@ -437,9 +626,13 @@ export function App({
// end
setSubagentActivity(null);
const seconds = ((ev.elapsedMs ?? 0) / 1000).toFixed(1);
// Inline cost: the one number most users look at. Shown in the
// Historical row so they don't have to run `/stats` to see it.
const costTail =
ev.costUsd !== undefined && ev.costUsd > 0 ? ` · $${ev.costUsd.toFixed(4)}` : "";
const summary = ev.error
? `🧬 subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
: `🧬 subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)`;
? `⌬ subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
: `⌬ subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)${costTail}`;
setHistorical((prev) => [
...prev,
{
@@ -448,11 +641,28 @@ export function App({
text: summary,
},
]);
// Persist a subagent summary row to ~/.reasonix/usage.jsonl so
// `/stats` and `reasonix stats` surface it. Skipped on error — we
// only record what actually cost money and did work.
if (!ev.error && ev.usage && ev.model) {
appendUsage({
session: session ?? null,
model: ev.model,
usage: ev.usage,
kind: "subagent",
subagent: {
skillName: ev.skillName,
taskPreview: ev.task.slice(0, 60),
toolIters: ev.iter ?? 0,
durationMs: ev.elapsedMs ?? 0,
},
});
}
};
return () => {
subagentSinkRef.current.current = null;
};
}, []);
}, [session]);
// Surface a one-time banner about session state on first mount.
const sessionBannerShown = useRef(false);
@@ -487,7 +697,25 @@ export function App({
},
]);
}
}, [session, loop]);
// Restore any pending edit queue from a prior run that was
// interrupted before /apply or /discard. The checkpoint file sits
// next to the session log; if present, we re-populate pendingEdits
// and post an info row so the user knows what's waiting.
if (session && codeMode) {
const restored = loadPendingEdits(session);
if (restored && restored.length > 0) {
pendingEdits.current = restored;
setHistorical((prev) => [
...prev,
{
id: `sys-pending-${Date.now()}`,
role: "info",
text: `▸ restored ${restored.length} pending edit block(s) from an interrupted prior run — /apply to commit or /discard to drop.`,
},
]);
}
}
}, [session, loop, codeMode]);
// Esc during busy → forward to the loop as an abort signal. The loop
// finishes the tool call in flight (we can't kill subprocess stdio
@@ -512,6 +740,48 @@ export function App({
// (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) {
if (key.upArrow) {
setAtSelected((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow) {
setAtSelected((i) => Math.min(atMatches.length - 1, i + 1));
return;
}
if (key.tab) {
const sel = atMatches[atSelected] ?? atMatches[0];
if (sel) pickAtMention(sel);
return;
}
}
// Slash-argument picker. Fires inside `/<cmd> <partial>` — either
// a file picker (for /edit), enum picker (for /preset, /model,
// /plan, /branch, /harvest), or hint-only row. Navigation + Tab
// substitute the highlighted value at the arg's offset.
if (slashArgMatches && slashArgMatches.length > 0) {
if (key.upArrow) {
setSlashArgSelected((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow) {
setSlashArgSelected((i) => Math.min(slashArgMatches.length - 1, i + 1));
return;
}
if (key.tab) {
const sel = slashArgMatches[slashArgSelected] ?? slashArgMatches[0];
if (sel) pickSlashArg(sel);
return;
}
}
// Slash-suggestion mode takes priority over history recall.
// When the user is typing a `/…` prefix and there are matches,
// ↑/↓ walk the suggestion list and Tab snaps the input to the
@@ -589,8 +859,9 @@ export function App({
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
if (anyApplied) lastEditSnapshots.current = snaps;
pendingEdits.current = [];
clearPendingEdits(session ?? null);
return formatEditResults(results);
}, [codeMode]);
}, [codeMode, session]);
/**
* /discard callback — forget the pending edits without touching
@@ -601,8 +872,9 @@ export function App({
const count = pendingEdits.current.length;
if (count === 0) return "nothing pending to discard.";
pendingEdits.current = [];
clearPendingEdits(session ?? null);
return `▸ discarded ${count} pending edit block(s). Nothing was written to disk.`;
}, []);
}, [session]);
const prefixHash = loop.prefix.fingerprint;
@@ -639,6 +911,34 @@ export function App({
let text = raw.trim();
if (!text || 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];
if (sel) {
pickAtMention(sel);
return;
}
}
// Slash-argument picker intercept — same shape as @-picker. For
// file pickers (/edit) we splice + trailing space so the user
// keeps typing the instruction. For enum pickers (/preset,
// /model, /plan, …) we splice without trailing space; those
// commands take no further args, so the user presses Enter a
// second time to run.
if (slashArgMatches && slashArgMatches.length > 0 && slashArgContext) {
const sel = slashArgMatches[slashArgSelected] ?? slashArgMatches[0];
if (sel) {
pickSlashArg(sel);
return;
}
}
// Slash auto-complete on Enter. When the user typed a prefix
// (e.g. "/he") and the suggestion list is visible, substitute
// the highlighted match so Enter runs it — same effect as Tab
@@ -670,6 +970,73 @@ export function App({
return;
}
// Bash mode — `!cmd` runs a shell command in the sandbox root
// immediately (no allowlist gate: user-typed = explicit consent),
// surfaces the formatted output in the Historical log, and
// persists a user-role message so the next model turn sees what
// happened AND the bang exchange survives session resume.
const bangCmd = detectBangCommand(text);
if (bangCmd !== null) {
const bangRoot = codeMode?.rootDir ?? process.cwd();
promptHistory.current.push(text);
setHistorical((prev) => [
...prev,
{
id: `bang-u-${Date.now()}`,
role: "user",
text,
leadSeparator: prev.length > 0,
},
]);
setBusy(true);
try {
const result = await runCommand(bangCmd, {
cwd: bangRoot,
timeoutSec: 60,
maxOutputChars: 32_000,
});
const formatted = formatCommandResult(bangCmd, result);
setHistorical((prev) => [
...prev,
{ id: `bang-o-${Date.now()}`, role: "info", text: formatted },
]);
loop.appendAndPersist({
role: "user",
content: formatBangUserMessage(bangCmd, formatted),
});
} catch (err) {
setHistorical((prev) => [
...prev,
{
id: `bang-e-${Date.now()}`,
role: "warning",
text: `! command failed: ${(err as Error).message}`,
},
]);
} finally {
setBusy(false);
}
return;
}
// MCP resource / prompt browsers — async calls that don't fit the
// synchronous handleSlash shape, so we intercept the exact command
// forms here. The slash-command registry still lists them (for
// /help + argument-level picker completion), but this branch is
// what actually runs the read/fetch.
const mcpBrowseMatch = /^\/(resource|prompt)(?:\s+([\s\S]*))?$/.exec(text);
if (mcpBrowseMatch) {
const kind = mcpBrowseMatch[1] as "resource" | "prompt";
const arg = mcpBrowseMatch[2]?.trim() ?? "";
promptHistory.current.push(text);
setHistorical((prev) => [
...prev,
{ id: `mcp-u-${Date.now()}`, role: "user", text, leadSeparator: prev.length > 0 },
]);
await handleMcpBrowseSlash(kind, arg, mcpServers ?? [], setHistorical);
return;
}
const slash = parseSlash(text);
if (slash) {
const result = handleSlash(slash.cmd, slash.args, loop, {
@@ -697,6 +1064,13 @@ export function App({
if (fresh) setLatestVersion(fresh);
})();
},
models,
refreshModels: () => {
void (async () => {
const list = await loop.client.listModels().catch(() => null);
if (list) setModels(list.data.map((m) => m.id));
})();
},
});
if (result.exit) {
transcriptRef.current?.end();
@@ -715,10 +1089,21 @@ export function App({
text: result.info,
},
]);
// /new wipes conversation; any pending edits from the prior
// assistant turn are stale (the user no longer sees the
// preview). Drop them so a later /apply doesn't surprise.
if (codeMode) {
pendingEdits.current = [];
clearPendingEdits(session ?? null);
}
return;
}
if (result.clear) {
setHistorical([]);
if (codeMode) {
pendingEdits.current = [];
clearPendingEdits(session ?? null);
}
return;
}
if (result.info) {
@@ -769,8 +1154,24 @@ export function App({
}
// User message is immutable — push to Static immediately.
// Large pastes (stack traces, log dumps, file contents) get a
// collapsed preview in Historical so scrollback stays navigable;
// the MODEL still receives the full text below via modelInput.
promptHistory.current.push(text);
setHistorical((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", text }]);
const pasteDisplay = formatLongPaste(text);
setHistorical((prev) => [
...prev,
// `leadSeparator`: thin rule above this user turn when history
// isn't empty — visual pacing for multi-turn sessions. First
// user message leaves it off so the UI doesn't open with a
// dangling divider.
{
id: `u-${Date.now()}`,
role: "user",
text: pasteDisplay.displayText,
leadSeparator: prev.length > 0,
},
]);
const assistantId = `a-${Date.now()}`;
// Refs are the source of truth for accumulated streaming text; the React
@@ -779,7 +1180,14 @@ export function App({
const contentBuf = { current: "" };
const reasoningBuf = { current: "" };
// Coalesces tool_call_delta events into one re-render per flush tick.
const toolCallBuildBuf: { current: { name: string; chars: number } | null } = {
const toolCallBuildBuf: {
current: {
name: string;
chars: number;
index?: number;
readyCount?: number;
} | null;
} = {
current: null,
};
@@ -810,8 +1218,35 @@ export function App({
// streamRef does no visible work — skip the interval entirely.
const timer = PLAIN_UI ? null : setInterval(flush, FLUSH_INTERVAL_MS);
// Expand `@path/to/file.ts` mentions in code mode: the model
// gets the inlined content appended under a "Referenced files"
// block; the Historical row above keeps the user's verbatim text
// so the display doesn't balloon.
let modelInput = text;
if (codeMode?.rootDir) {
const expanded = expandAtMentions(text, codeMode.rootDir);
if (expanded.expansions.length > 0) {
modelInput = expanded.text;
const inlined = expanded.expansions
.filter((ex) => ex.ok)
.map((ex) => `${ex.path} (${(ex.bytes ?? 0).toLocaleString()} bytes)`);
const skipped = expanded.expansions
.filter((ex) => !ex.ok)
.map((ex) => `${ex.path} (${ex.skip})`);
const parts: string[] = [];
if (inlined.length > 0) parts.push(`inlined ${inlined.join(", ")}`);
if (skipped.length > 0) parts.push(`skipped ${skipped.join(", ")}`);
if (parts.length > 0) {
setHistorical((prev) => [
...prev,
{ id: `at-${Date.now()}`, role: "info", text: `▸ @mentions: ${parts.join("; ")}` },
]);
}
}
}
try {
for await (const ev of loop.step(text)) {
for await (const ev of loop.step(modelInput)) {
writeTranscript(ev);
// Status lines are transient — any primary event (streaming
// starts, a tool fires, etc.) means whatever we were waiting
@@ -831,6 +1266,8 @@ export function App({
toolCallBuildBuf.current = {
name: ev.toolName,
chars: ev.toolCallArgsChars ?? 0,
index: ev.toolCallIndex,
readyCount: ev.toolCallReadyCount,
};
}
} else if (ev.role === "branch_start") {
@@ -912,6 +1349,11 @@ export function App({
const blocks = parseEditBlocks(finalText);
if (blocks.length > 0) {
pendingEdits.current = blocks;
// Checkpoint the queue so a crash / Ctrl+C between
// "blocks parsed" and "user /apply" doesn't lose the
// edits. On next launch App.tsx's restore effect reads
// this file. /apply + /discard clear it explicitly.
savePendingEdits(session ?? null, blocks);
setHistorical((prev) => [
...prev,
{
@@ -929,6 +1371,29 @@ export function App({
// the new spinner starts clean.
setOngoingTool({ name: ev.toolName ?? "?", args: ev.toolArgs });
setToolProgress(null);
// Feed the `@` picker's recency LRU from tool args — any
// path-shaped field (`path`, `file_path`, `file`) under a
// filesystem tool call means the user/model is actively
// working on that file. Picker surfaces it next time `@`
// is typed, even if the file's mtime is stale.
if (codeMode && ev.toolArgs) {
try {
const parsed = JSON.parse(ev.toolArgs) as {
path?: unknown;
file_path?: unknown;
file?: unknown;
};
for (const k of ["path", "file_path", "file"] as const) {
const v = parsed[k];
if (typeof v === "string" && v.trim()) {
recordRecentFile(v.trim());
break;
}
}
} catch {
/* malformed args — skip recency tracking */
}
}
} else if (ev.role === "tool") {
flush();
setOngoingTool(null);
@@ -1055,9 +1520,19 @@ export function App({
latestVersion,
mcpSpecs,
mcpServers,
models,
planMode,
session,
slashSelected,
atMatches,
atPicker,
atSelected,
pickAtMention,
recordRecentFile,
slashArgMatches,
slashArgContext,
slashArgSelected,
pickSlashArg,
togglePlanMode,
writeTranscript,
],
@@ -1202,6 +1677,22 @@ export function App({
[pendingPlan, togglePlanMode, busy, loop, handleSubmit],
);
// Ref-wrapped stable alias. `handlePlanConfirm` has deps that churn
// every turn (busy toggles while the model is still streaming its
// wrap-up) — passing it directly to `React.memo(PlanConfirm)` breaks
// the memo's shallow prop compare, so even without the ticker the
// picker re-rendered on every parent state change. The ref keeps the
// identity stable across the whole picker lifetime; the callback
// itself always reads the latest closure via `.current`.
const handlePlanConfirmRef = useRef(handlePlanConfirm);
useEffect(() => {
handlePlanConfirmRef.current = handlePlanConfirm;
}, [handlePlanConfirm]);
const stableHandlePlanConfirm = useCallback(
async (choice: PlanConfirmChoice) => handlePlanConfirmRef.current(choice),
[],
);
/**
* Fired when the user submits feedback from the inline input. The
* staged `mode` decides whether this is a refine or approve: refine
@@ -1262,7 +1753,7 @@ export function App({
}, [stagedInput]);
return (
<TickerProvider disabled={PLAIN_UI}>
<TickerProvider disabled={PLAIN_UI || !!pendingPlan || !!pendingShell}>
<Box flexDirection="column">
<StatsPanel
summary={summary}
@@ -1272,6 +1763,7 @@ export function App({
branchBudget={loop.branchOptions.budget}
planMode={planMode}
balance={balance}
busy={busy}
updateAvailable={updateAvailable}
/>
<Static items={historical}>
@@ -1328,7 +1820,11 @@ export function App({
onCancel={handleStagedInputCancel}
/>
) : pendingPlan ? (
<PlanConfirm plan={pendingPlan} onChoose={handlePlanConfirm} projectRoot={hookCwd} />
<PlanConfirm
plan={pendingPlan}
onChoose={stableHandlePlanConfirm}
projectRoot={hookCwd}
/>
) : pendingShell ? (
<ShellConfirm
command={pendingShell}
@@ -1344,6 +1840,20 @@ export function App({
disabled={busy}
/>
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
<AtMentionSuggestions
matches={atMatches}
selectedIndex={atSelected}
query={atPicker?.query ?? ""}
/>
{slashArgContext ? (
<SlashArgPicker
matches={slashArgMatches}
selectedIndex={slashArgSelected}
spec={slashArgContext.spec}
kind={slashArgContext.kind}
partial={slashArgContext.partial}
/>
) : null}
</>
)}
</Box>
@@ -1392,7 +1902,7 @@ function StatusRow({ text }: { text: string }) {
* Live one-line indicator for a running subagent. Sits below the
* OngoingToolRow (the parent's tool dispatch row for `spawn_subagent`)
* so the user sees both layers at once: outer "spawn_subagent
* running…" + inner "🧬 subagent: <task> · iter N · 12.3s". Cleared
* running…" + inner "⌬ subagent: <task> · iter N · 12.3s". Cleared
* when the subagent ends; a one-line summary lands in historical.
*/
function SubagentRow({
@@ -1405,7 +1915,7 @@ function SubagentRow({
return (
<Box paddingLeft={2}>
<Text color="magenta">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
<Text color="magenta">{` 🧬 subagent: ${activity.task}`}</Text>
<Text color="magenta">{` ⌬ subagent: ${activity.task}`}</Text>
<Text dimColor>{` · iter ${activity.iter} · ${seconds}s`}</Text>
</Box>
);
@@ -1538,24 +2048,16 @@ function formatEditResults(results: ApplyResult[]): string {
/**
* Pending-edits preview shown after each assistant turn that proposed
* changes. We keep it deliberately thin — path + approximate
* ±line-count — because a full diff would crowd the TUI and the user
* can always run `git diff` after /apply.
* changes. Per-block path header + ±line-count, then a unified-diff-
* style preview (context trimmed to 2 lines each side, total capped
* at 20 lines per block). Users can eyeball what's about to land
* BEFORE pressing `y` — the old summary-only view was a common
* mistake surface.
*/
function formatPendingPreview(blocks: EditBlock[]): string {
const lines = blocks.map((b) => {
const removed = b.search === "" ? 0 : countLines(b.search);
const added = countLines(b.replace);
const tag = b.search === "" ? "NEW " : " ";
return ` ${tag}${b.path} (-${removed} +${added} lines)`;
});
const header = `▸ ${blocks.length} pending edit block(s) — /apply (or y) to commit · /discard (or n) to drop`;
return [header, ...lines].join("\n");
}
function countLines(s: string): number {
if (s.length === 0) return 0;
return (s.match(/\n/g)?.length ?? 0) + 1;
const diffLines = formatAllBlockDiffs(blocks);
return [header, ...diffLines].join("\n");
}
function formatUndoResults(results: ApplyResult[]): string {
+86
View File
@@ -0,0 +1,86 @@
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";
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. */
selectedIndex: number;
/** The partial query the user typed after `@`. Shown in the hint row. */
query: string;
}
/**
* Floating `@`-mention picker. Rendered below the input box when the
* user is typing an `@…` prefix in code mode. Navigation state lives
* in the parent (App.tsx owns `atSelected`) — this component is pure
* display. Mirrors {@link SlashSuggestions} shape so the keybindings
* and layout feel identical across the two pickers.
*/
export function AtMentionSuggestions({
matches,
selectedIndex,
query,
}: AtMentionSuggestionsProps): React.ReactElement | null {
if (matches === null) return null;
if (matches.length === 0) {
return (
<Box paddingX={1}>
<Text color="yellow">no files match "@{query}"</Text>
<Text dimColor>
{" "}
— keep typing, or Backspace to edit. Paths resolve from the code root.
</Text>
</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;
return (
<Box flexDirection="column" paddingX={1}>
{hiddenAbove > 0 ? <Text dimColor> ↑ {hiddenAbove} more above</Text> : null}
{shown.map((path, i) => (
<FileRow key={path} path={path} isSelected={windowStart + i === selectedIndex} />
))}
{hiddenBelow > 0 ? <Text dimColor> ↓ {hiddenBelow} more below</Text> : null}
<Text dimColor> [↑↓] navigate · [Tab]/[Enter] pick · file content inlined on send</Text>
</Box>
);
}
function FileRow({ path, isSelected }: { path: string; isSelected: boolean }) {
const marker = isSelected ? "▸" : " ";
// Split the path so the basename visually pops — same dropdown
// affordance as VS Code's Quick Open.
const slash = path.lastIndexOf("/");
const dir = slash >= 0 ? `${path.slice(0, slash)}/` : "";
const base = slash >= 0 ? path.slice(slash + 1) : path;
if (isSelected) {
return (
<Box>
<Text bold color="cyan">
{marker} {base}
</Text>
<Text color="cyan">{dir ? ` ${dir}` : ""}</Text>
</Box>
);
}
return (
<Box>
<Text dimColor>
{marker} {base}
{dir ? ` ${dir}` : ""}
</Text>
</Box>
);
}
+228 -74
View File
@@ -1,4 +1,4 @@
import { Box, Text } from "ink";
import { Box, Text, useStdout } from "ink";
import React from "react";
import { type TypedPlanState, isPlanStateEmpty } from "../../harvest.js";
import type { BranchProgress, BranchSummary } from "../../loop.js";
@@ -21,7 +21,46 @@ export interface DisplayEvent {
stats?: TurnStats;
repair?: string;
streaming?: boolean;
toolCallBuild?: { name: string; chars: number };
toolCallBuild?: { name: string; chars: number; index?: number; readyCount?: number };
/**
* Render a thin horizontal rule above this event. Used to mark
* "start of a new user turn" so long scrollbacks get visual
* pacing — without it, five-turn sessions read as one wall of
* text. Only the *first* user message in a session should leave
* this off; App.tsx sets it based on whether history is empty.
*/
leadSeparator?: boolean;
}
/**
* Reasonix visual language: every event is anchored by a geometric
* glyph in its role color. Together they form a consistent alphabet
* (◇ ◆ ▣ ▥ ▲ ✦) the eye learns fast — no text labels needed after
* the first couple of turns. `◈` is the brand mark, reserved for
* the app header.
*/
const ROLE_GLYPH = {
user: "◇",
assistant: "◆",
assistantPulse: "◇", // pulse alternate for streaming state
toolOk: "▣",
toolErr: "▥",
warning: "▲",
error: "✦",
} as const;
function RoleGlyph({
glyph,
color,
}: {
glyph: string;
color: "cyan" | "green" | "yellow" | "red" | "magenta" | "blue";
}) {
return (
<Text color={color} bold>
{glyph}
</Text>
);
}
export const EventRow = React.memo(function EventRow({
@@ -33,11 +72,12 @@ export const EventRow = React.memo(function EventRow({
}) {
if (event.role === "user") {
return (
<Box>
<Text bold color="cyan">
you ›{" "}
<Box marginTop={event.leadSeparator ? 1 : 0}>
<RoleGlyph glyph={ROLE_GLYPH.user} color="cyan" />
<Text>
{" "}
{event.text}
</Text>
<Text>{event.text}</Text>
</Box>
);
}
@@ -46,33 +86,34 @@ export const EventRow = React.memo(function EventRow({
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text bold color="green">
assistant
</Text>
<RoleGlyph glyph={ROLE_GLYPH.assistant} color="green" />
{event.stats ? <Text dimColor>{` ${event.stats.model}`}</Text> : null}
</Box>
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
{event.branch ? <BranchBlock branch={event.branch} /> : null}
{event.reasoning ? <ReasoningBlock reasoning={event.reasoning} /> : null}
{!isPlanStateEmpty(event.planState) ? (
<PlanStateBlock planState={event.planState!} />
) : null}
{event.text ? (
<Markdown text={event.text} projectRoot={projectRoot} />
) : (
<Text dimColor>(no content)</Text>
)}
{event.stats ? <StatsLine stats={event.stats} /> : null}
{event.repair ? <Text color="magenta">{event.repair}</Text> : null}
</Box>
{event.branch ? <BranchBlock branch={event.branch} /> : null}
{event.reasoning ? <ReasoningBlock reasoning={event.reasoning} /> : null}
{!isPlanStateEmpty(event.planState) ? (
<PlanStateBlock planState={event.planState!} />
) : null}
{event.text ? (
<Markdown text={event.text} projectRoot={projectRoot} />
) : (
<Text dimColor>(no content)</Text>
)}
{event.stats ? <StatsLine stats={event.stats} /> : null}
{event.repair ? <Text color="magenta">{event.repair}</Text> : null}
</Box>
);
}
if (event.role === "tool") {
// `flattenMcpResult` prefixes server-side errors with "ERROR: ".
// Render those in red with a ✗ marker so they don't blend into
// successful tool output (yellow) — the failure mode is what the
// model most likely needs to act on next, and the user needs to
// see at a glance.
// Render the tool glyph in red with a ✗ direction marker so the
// failure mode is unmissable — it's the signal the model needs
// most for its next decision.
const isError = event.text.startsWith("ERROR:");
const color = isError ? "red" : "yellow";
const glyph = isError ? ROLE_GLYPH.toolErr : ROLE_GLYPH.toolOk;
const marker = isError ? "✗" : "→";
// `edit_file` results get a dedicated diff renderer — colored
// line-by-line so `-` removals show red, `+` additions show
@@ -83,25 +124,31 @@ export const EventRow = React.memo(function EventRow({
(event.toolName === "edit_file" || event.toolName?.endsWith("_edit_file")) && !isError;
return (
<Box flexDirection="column" marginTop={1}>
<Text color={color}>{`tool<${event.toolName ?? "?"}> ${marker}`}</Text>
{isEditFile ? (
<EditFileDiff text={event.text} />
) : (
<Text color={isError ? "red" : undefined} dimColor={!isError}>
{" "}
{truncate(event.text, 400)}
</Text>
)}
<Box>
<RoleGlyph glyph={glyph} color={color} />
<Text color={color} bold>{` ${event.toolName ?? "?"}`}</Text>
<Text color={color} dimColor>{` ${marker}`}</Text>
</Box>
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
{isEditFile ? (
<EditFileDiff text={event.text} />
) : (
<Text color={isError ? "red" : undefined} dimColor={!isError}>
{truncate(event.text, 400)}
</Text>
)}
</Box>
</Box>
);
}
if (event.role === "error") {
return (
<Box marginTop={1}>
<Text color="red" bold>
error{" "}
<RoleGlyph glyph={ROLE_GLYPH.error} color="red" />
<Text color="red">
{" "}
{event.text}
</Text>
<Text color="red">{event.text}</Text>
</Box>
);
}
@@ -115,8 +162,11 @@ export const EventRow = React.memo(function EventRow({
if (event.role === "warning") {
return (
<Box>
<Text color="yellow">▸ </Text>
<Text color="yellow">{event.text}</Text>
<RoleGlyph glyph={ROLE_GLYPH.warning} color="yellow" />
<Text color="yellow">
{" "}
{event.text}
</Text>
</Box>
);
}
@@ -127,6 +177,32 @@ export const EventRow = React.memo(function EventRow({
);
});
/**
* Thin horizontal rule between turns with a cyan ◆ centered as the
* Reasonix mark. Past-turn separators live inside `<Static>` (Ink's
* render-once optimization), so animation here would freeze at
* tick=0 on every past turn — we keep it static by design. The
* animated heartbeat lives in PulsingAssistantGlyph (current turn)
* and the Wordmark (StatsPanel) where rerender is free.
*/
function TurnSeparator() {
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const width = Math.max(16, cols - 2);
const half = Math.floor((width - 3) / 2);
const left = "─".repeat(half);
const right = "─".repeat(width - half - 3);
return (
<Box marginTop={1} marginBottom={1}>
<Text dimColor>{left}</Text>
<Text color="cyan" bold>
{" ◆ "}
</Text>
<Text dimColor>{right}</Text>
</Box>
);
}
/**
* Render the payload of an `edit_file` tool result with proper
* diff coloring: first line is the header ("edited X (A→B chars)"),
@@ -193,7 +269,7 @@ function BranchBlock({ branch }: { branch: BranchSummary }) {
return (
<Box>
<Text color="blue">
{"🔀 branched "}
{"⎇ branched "}
<Text bold>{branch.budget}</Text>
{` samples → picked #${branch.chosenIndex} `}
<Text dimColor>{per}</Text>
@@ -212,11 +288,15 @@ function ReasoningBlock({ reasoning }: { reasoning: string }) {
// Users can dump the full reasoning with `/think` if needed.
const preview =
flat.length <= max ? flat : `… (+${flat.length - max} earlier chars) ${flat.slice(-max)}`;
// ▏ (LEFT ONE EIGHTH BLOCK) renders as a thin vertical rule at the
// cell edge — subtler than the role-level ▎ above, giving a
// visual hierarchy: thick bar = role, thin bar = nested detail
// under that role (thinking, stats, citations, etc).
return (
<Box marginBottom={1}>
<Text dimColor>▏ </Text>
<Text dimColor italic>
{"↳ thinking: "}
{preview}
thinking {preview}
</Text>
</Box>
);
@@ -234,6 +314,22 @@ function Elapsed() {
return <Text dimColor>{`${mm}:${ss}`}</Text>;
}
/**
* Animated role glyph for the actively-streaming assistant. Alternates
* ◆ ↔ ◇ every ~480ms so the user can see the model is alive even
* during R1's long pre-first-byte silence. Settles back to a static
* ◆ once the turn ends (StreamingAssistant is unmounted).
*/
function PulsingAssistantGlyph() {
const tick = useTick();
const on = Math.floor(tick / 4) % 2 === 0;
return (
<Text color="green" bold>
{on ? ROLE_GLYPH.assistant : ROLE_GLYPH.assistantPulse}
</Text>
);
}
function StreamingAssistant({ event }: { event: DisplayEvent }) {
if (event.branchProgress) {
const p = event.branchProgress;
@@ -242,15 +338,17 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text bold color="green">
assistant{" "}
</Text>
<PulsingAssistantGlyph />
<Text color="blue">
🔀 launching {p.total} parallel samples (R1 thinking in parallel)…{" "}
{" ⎇ launching "}
{p.total}
{" parallel samples (R1 thinking in parallel)… "}
</Text>
<Elapsed />
</Box>
<Text dimColor>{" "}spread across T=0.0/0.5/1.0 · typical wait 30-90s for reasoner</Text>
<Text color="yellow">
{" "}spread across T=0.0/0.5/1.0 · reasoner typically takes 30-90s — this is normal
</Text>
</Box>
);
}
@@ -258,11 +356,13 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text bold color="green">
assistant{" "}
</Text>
<PulsingAssistantGlyph />
<Text color="blue">
🔀 branching {p.completed}/{p.total} ({pct}%){" "}
{" ⎇ branching "}
{p.completed}/{p.total}
{" ("}
{pct}
{"%) "}
</Text>
<Elapsed />
</Box>
@@ -305,13 +405,15 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
label = `R1 reasoning · ${event.reasoning?.length ?? 0} chars of thought`;
labelColor = "cyan";
} else if (toolCallOnly) {
label = `assembling tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments`;
label = `assembling tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments${formatReadyTail(toolCallBuild)}`;
labelColor = "magenta";
} else {
const parts: string[] = [`writing response · ${event.text.length} chars`];
if (event.reasoning) parts.push(`after ${event.reasoning.length} chars of reasoning`);
if (toolCallBuild) {
parts.push(`building tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars`);
parts.push(
`building tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars${formatReadyTail(toolCallBuild)}`,
);
}
label = parts.join(" · ");
labelColor = "green";
@@ -319,9 +421,8 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text bold color="green">
assistant{" "}
</Text>
<PulsingAssistantGlyph />
<Text>{" "}</Text>
<Pulse />
<Text color={labelColor}>{` ${label} `}</Text>
<Elapsed />
@@ -334,22 +435,24 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
{tail ? (
<Text dimColor>▸ {tail}</Text>
) : preFirstByte ? (
<Text dimColor italic>
{" connection open, first byte typically in 5-60s depending on model + load"}
// Non-dim yellow: first-time users misread the dim version as
// "app frozen". The reassurance has to be VISIBLE to do its job.
<Text color="yellow" italic>
{" waiting for first byte — this is normal, typically 5-60s depending on model + load"}
</Text>
) : reasoningOnly ? (
<Text color="yellow" dimColor>
<Text color="yellow" italic>
{
" R1 is thinking before it speaks — body text starts when reasoning completes (typically 20-90s)."
" R1 is thinking before it speaks — body text arrives when reasoning finishes (typically 20-90s, this is normal)"
}
</Text>
) : toolCallOnly ? (
<Text color="magenta" dimColor>
<Text color="magenta" italic>
{" tool-call arguments streaming — the model is about to dispatch a tool"}
</Text>
) : event.reasoning ? (
<Text color="yellow" dimColor>
{" R1 still reasoning — body text or tool call arrives when thinking completes"}
<Text color="yellow" italic>
{" R1 still reasoning — body text or tool call arrives when thinking finishes"}
</Text>
) : null}
</Box>
@@ -368,6 +471,29 @@ function Pulse() {
return <Text color="cyan">{frames[Math.floor(tick / 4) % frames.length]}</Text>;
}
/**
* "(call 3)" suffix for the tool-call label when we have the index.
* Hides when the turn only has one call so the common case stays tidy.
*/
function formatToolCallIndex(tb: { index?: number; readyCount?: number } | undefined): string {
if (!tb || tb.index === undefined) return "";
// Only show the index when this isn't obviously the first + only call.
if (tb.index === 0 && (tb.readyCount ?? 0) === 0) return "";
return ` (call ${tb.index + 1})`;
}
/**
* "· 2 ready" tail that answers "how many tool calls have finished
* streaming so far this turn?" — user feedback for multi-file turns
* where the response takes 10–30s to stream. Hidden until readyCount
* > 0 so single-call turns aren't cluttered.
*/
function formatReadyTail(tb: { readyCount?: number } | undefined): string {
const n = tb?.readyCount ?? 0;
if (n <= 0) return "";
return ` · ${n} ready`;
}
function lastLine(s: string, maxChars: number): string {
const flat = s.replace(/\s+/g, " ").trim();
if (!flat) return "";
@@ -377,19 +503,47 @@ function lastLine(s: string, maxChars: number): string {
function StatsLine({ stats }: { stats: TurnStats }) {
const hit = (stats.cacheHitRatio * 100).toFixed(1);
return (
<Text dimColor>
{" ↳ cache "}
{hit}
{"% · tokens "}
{stats.usage.promptTokens}
{"→"}
{stats.usage.completionTokens}
{" · $"}
{stats.cost.toFixed(6)}
</Text>
<Box>
<Text dimColor>▏ </Text>
<Text dimColor>
{"cache "}
{hit}
{"% · tokens "}
{stats.usage.promptTokens}
{" → "}
{stats.usage.completionTokens}
{" · $"}
{stats.cost.toFixed(6)}
</Text>
</Box>
);
}
/**
* Show the *tail* of a long tool result by default — the useful signal
* (return value, summary, error line) usually lives at the end, not the
* top-of-file boilerplate. For `ERROR:`-prefixed results, keep the
* first line intact (that's the actual error message) AND include the
* tail, so stack-trace endings stay visible too.
*
* Rendered like: `(+N earlier chars) …<tail>` so users can tell at a
* glance that something was elided. `/tool N` still dumps the full
* content for cases where the middle matters.
*/
function truncate(s: string, max: number): string {
return s.length <= max ? s : `${s.slice(0, max)}… (+${s.length - max} chars)`;
if (s.length <= max) return s;
if (s.startsWith("ERROR:")) {
const firstNl = s.indexOf("\n");
const firstLine = firstNl === -1 ? s : s.slice(0, firstNl);
// If the first line alone is bigger than max, just show it truncated.
if (firstLine.length >= max) {
return `${firstLine.slice(0, max)}… (+${s.length - max} chars — /tool N for full)`;
}
const budget = max - firstLine.length - 10; // reserve space for separator + ellipsis
const tail = s.slice(-budget);
const skipped = s.length - firstLine.length - tail.length;
return `${firstLine}\n… (+${skipped} chars) …\n${tail}`;
}
const skipped = s.length - max;
return `… (+${skipped} earlier chars — /tool N for full) …\n${s.slice(-max)}`;
}
+75 -3
View File
@@ -34,16 +34,74 @@ export interface PlanConfirmProps {
*/
maxRenderedChars?: number;
projectRoot?: string;
/**
* Override the terminal-row count used for vertical clamping. Tests
* pass this to avoid depending on `process.stdout.rows`; runtime
* callers leave it unset and we read the real TTY size.
*/
terminalRows?: number;
}
const DEFAULT_MAX_RENDERED = 2400;
/**
* Reserved terminal rows for the picker chrome (border + header +
* divider + open-questions hint + SingleSelect with three options +
* footer + the assistant-turn block Ink already printed above).
* Empirically ~16; we round up to 18 for safety.
*/
const PICKER_CHROME_ROWS = 18;
/**
* Rough markdown expansion factor. One source line commonly renders
* as 1–3 terminal rows: wrap on narrow terminals doubles long lines,
* code fences add top/bottom rules, list items get bullet indent,
* headings sometimes pick up blank rows. A factor of 2 is the
* conservative bet that lets us keep `bodyRowBudget` > 0 on a 24-row
* terminal while still guaranteeing the picker stays on-screen. If
* this is wrong in practice it's wrong in the "trim too aggressively"
* direction, which shows a truncation marker rather than flickering.
*/
const MARKDOWN_EXPANSION = 2;
const MIN_BODY_ROWS = 4;
export function PlanConfirm({ plan, onChoose, maxRenderedChars, projectRoot }: PlanConfirmProps) {
/**
* Trim `text` to the first `maxLines` source lines, appending a
* truncation marker when it was cut. Source lines, NOT rendered rows
* — callers must divide their row budget by the expected markdown
* expansion factor before passing it here.
*/
export function clampBodyByLines(text: string, maxLines: number): string {
const lines = text.split("\n");
if (lines.length <= maxLines) return text;
const kept = lines.slice(0, maxLines).join("\n");
const dropped = lines.length - maxLines;
return `${kept}\n\n… (${dropped} more lines truncated — resize the terminal to see more, or /tool for the full proposal)`;
}
function PlanConfirmInner({
plan,
onChoose,
maxRenderedChars,
projectRoot,
terminalRows,
}: PlanConfirmProps) {
const cap = maxRenderedChars ?? DEFAULT_MAX_RENDERED;
const tooLong = plan.length > cap;
const visible = tooLong
const charTrunc = plan.length > cap;
const charCapped = charTrunc
? `${plan.slice(0, cap)}\n\n… (${plan.length - cap} chars truncated — use /tool to view the full proposal)`
: plan;
// Vertical clamp. Flicker root-cause: when rendered content exceeds
// the TTY row count Ink falls back to "clear + full redraw" each
// frame, and every parent re-render redraws the big Markdown subtree.
// Capping the body to (rows - picker chrome) keeps the whole modal
// inside the terminal so Ink uses incremental diffs and the picker
// stops thrashing. See Bug A in 0.5.14.
const rows = terminalRows ?? process.stdout?.rows ?? 24;
const renderedBudget = Math.max(MIN_BODY_ROWS * MARKDOWN_EXPANSION, rows - PICKER_CHROME_ROWS);
// Divide by the expansion factor so source-line clamp produces a
// rendered height that actually fits the terminal. Without this the
// rendered body overflows → Ink clears + redraws each frame → flicker.
const sourceLineBudget = Math.max(MIN_BODY_ROWS, Math.floor(renderedBudget / MARKDOWN_EXPANSION));
const visible = clampBodyByLines(charCapped, sourceLineBudget);
// Crude signal for "the model left questions or risks for me" — the
// typical section headings. Triggers an extra hint toward the Refine
// option so users know where to answer them.
@@ -58,6 +116,11 @@ export function PlanConfirm({ plan, onChoose, maxRenderedChars, projectRoot }: P
▸ plan submitted — awaiting your review
</Text>
</Box>
<Box>
<Text color="cyan" dimColor>
{"──────────────────────────────────────────"}
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Markdown text={visible} projectRoot={projectRoot} />
</Box>
@@ -91,8 +154,17 @@ export function PlanConfirm({ plan, onChoose, maxRenderedChars, projectRoot }: P
},
]}
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
onCancel={() => onChoose("cancel")}
footer="[↑↓] navigate · [Enter] select · [Esc] cancel"
/>
</Box>
</Box>
);
}
// React.memo: parent App re-renders every 120ms while the global ticker
// is running (even with the live status rows hidden — context changes
// propagate). Unless props change, skip re-rendering the heavy Markdown
// subtree. Default shallow prop compare is fine — `plan` + `onChoose`
// identity + `projectRoot` are the only fields that change.
export const PlanConfirm = React.memo(PlanConfirmInner);
+2 -2
View File
@@ -83,8 +83,8 @@ export function PromptInput({
);
const effectivePlaceholder = disabled
? (placeholder ?? "…waiting for response…")
: (placeholder ?? "type a message, or /command · Ctrl+J for newline");
? (placeholder ?? "…waiting for response… · [Esc] to stop")
: (placeholder ?? "type a message, or /command · [Shift+Enter] / [Ctrl+J] newline");
const lines = value.length > 0 ? value.split("\n") : [""];
const borderColor = disabled ? "gray" : "cyan";
+13
View File
@@ -28,6 +28,13 @@ export interface SingleSelectProps<V extends string> {
initialValue?: V;
onSubmit: (value: V) => void;
onCancel?: () => void;
/**
* Optional footer rendered dim beneath the list, e.g.
* `"↑/↓ navigate · Enter select · Esc cancel"`. Makes keyboard
* affordances discoverable — otherwise new users hit `y`/`n` and
* wonder why nothing happens.
*/
footer?: string;
}
export function SingleSelect<V extends string>({
@@ -35,6 +42,7 @@ export function SingleSelect<V extends string>({
initialValue,
onSubmit,
onCancel,
footer,
}: SingleSelectProps<V>) {
const initialIndex = Math.max(
0,
@@ -65,6 +73,11 @@ export function SingleSelect<V extends string>({
marker={i === index ? "▸" : " "}
/>
))}
{footer ? (
<Box marginTop={1}>
<Text dimColor>{footer}</Text>
</Box>
) : null}
</Box>
);
}
+7
View File
@@ -33,6 +33,11 @@ export function ShellConfirm({ command, allowPrefix, onChoose }: ShellConfirmPro
▸ model wants to run a shell command
</Text>
</Box>
<Box>
<Text color="yellow" dimColor>
{"──────────────────────────────────────────"}
</Text>
</Box>
<Box marginTop={1}>
<Text>
<Text dimColor>{"$ "}</Text>
@@ -60,6 +65,8 @@ export function ShellConfirm({ command, allowPrefix, onChoose }: ShellConfirmPro
},
]}
onSubmit={(v) => onChoose(v as ShellConfirmChoice)}
onCancel={() => onChoose("deny")}
footer="[↑↓] navigate · [Enter] select · [Esc] deny"
/>
</Box>
</Box>
+108
View File
@@ -0,0 +1,108 @@
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";
export interface SlashArgPickerProps {
/**
* When set, render a picker with these matches (filter already
* applied upstream). Null → not in picker mode; check `hintSpec`
* for a usage hint instead.
*/
matches: readonly string[] | null;
/** Highlighted row within `matches`. */
selectedIndex: number;
/**
* Spec of the command the user is typing args for. Used to render
* the header label ("/edit <file>") even when matches is empty or
* the caller wants a hint instead of a picker.
*/
spec: SlashCommandSpec;
/** What kind of arg guidance to render. */
kind: "picker" | "hint";
/** The user's partial input — shown in the "no matches" hint. */
partial: string;
}
/**
* Argument-level picker for a slash command. Mirrors the visual
* layout of SlashSuggestions / AtMentionSuggestions so the UI stays
* consistent across all three picker surfaces.
*/
export function SlashArgPicker({
matches,
selectedIndex,
spec,
kind,
partial,
}: SlashArgPickerProps): React.ReactElement | null {
// Hint mode: a single dim row explaining the argsHint + summary.
if (kind === "hint") {
return (
<Box paddingX={1}>
<Text dimColor>
{" "}
<Text bold>/{spec.cmd}</Text>
{spec.argsHint ? ` ${spec.argsHint}` : ""} — {spec.summary}
</Text>
</Box>
);
}
if (matches === null) return null;
if (matches.length === 0) {
return (
<Box flexDirection="column" paddingX={1}>
<Text dimColor>
{" "}
<Text bold>/{spec.cmd}</Text>
{spec.argsHint ? ` ${spec.argsHint}` : ""} — {spec.summary}
</Text>
<Text color="yellow"> no match for "{partial}" — keep typing, or Backspace to edit</Text>
</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;
return (
<Box flexDirection="column" paddingX={1}>
<Text dimColor>
{" "}
<Text bold>/{spec.cmd}</Text>
{spec.argsHint ? ` ${spec.argsHint}` : ""} — {spec.summary}
</Text>
{hiddenAbove > 0 ? <Text dimColor> ↑ {hiddenAbove} more above</Text> : null}
{shown.map((value, i) => (
<ArgRow key={value} value={value} isSelected={windowStart + i === selectedIndex} />
))}
{hiddenBelow > 0 ? <Text dimColor> ↓ {hiddenBelow} more below</Text> : null}
<Text dimColor> [↑↓] navigate · [Tab]/[Enter] pick</Text>
</Box>
);
}
function ArgRow({ value, isSelected }: { value: string; isSelected: boolean }) {
const marker = isSelected ? "▸" : " ";
if (isSelected) {
return (
<Box>
<Text bold color="cyan">
{marker} {value}
</Text>
</Box>
);
}
return (
<Box>
<Text dimColor>
{marker} {value}
</Text>
</Box>
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function SlashSuggestions({
<SuggestionRow key={spec.cmd} spec={spec} isSelected={windowStart + i === selectedIndex} />
))}
{hiddenBelow > 0 ? <Text dimColor> ↓ {hiddenBelow} more below</Text> : null}
<Text dimColor> ↑/↓ navigate · Tab or Enter to pick</Text>
<Text dimColor> [↑↓] navigate · [Tab]/[Enter] pick</Text>
</Box>
);
}
+369 -79
View File
@@ -1,8 +1,54 @@
import { Box, Text } from "ink";
import { Box, Text, useStdout } from "ink";
import React from "react";
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../telemetry.js";
import type { SessionSummary } from "../../telemetry.js";
import { VERSION } from "../../version.js";
import { useTick } from "./ticker.js";
/**
* Cyan → blue → purple truecolor gradient for the REASONIX wordmark.
* Each char gets its own hex color — terminals that support 24-bit
* color (nearly all since 2016) render this as a smooth fade. Older
* 256-color terminals snap to nearest; 8-color fall all the way back
* to plain cyan via Ink's color pipeline, so the worst case is still
* legible.
*/
const WORDMARK_STYLES: ReadonlyArray<{ ch: string; color: string; isLogo: boolean }> = [
{ ch: "◈", color: "#5eead4", isLogo: true }, // teal — brand mark
{ ch: " ", color: "#5eead4", isLogo: false },
{ ch: "R", color: "#67e8f9", isLogo: false }, // cyan
{ ch: "E", color: "#7dd3fc", isLogo: false }, // sky
{ ch: "A", color: "#93c5fd", isLogo: false }, // blue
{ ch: "S", color: "#a5b4fc", isLogo: false }, // indigo
{ ch: "O", color: "#c4b5fd", isLogo: false }, // violet
{ ch: "N", color: "#d8b4fe", isLogo: false }, // purple
{ ch: "I", color: "#f0abfc", isLogo: false }, // fuchsia
{ ch: "X", color: "#f0abfc", isLogo: false }, // fuchsia
];
/**
* Gradient-filled, animated app wordmark. The `◈` brand mark breathes
* (bold on/off) once every ~1.6s when idle, ~600ms when the model is
* working, so a glance at the top of the screen tells you immediately
* whether the app is doing something. Letters' keys use the char+color
* pair (not an index) so React state attaches to the glyph itself;
* fine because WORDMARK_STYLES never reorders.
*/
function Wordmark({ busy }: { busy: boolean }) {
const tick = useTick();
// Slow pulse = 12 ticks (~1.4s). Busy pulse = 5 ticks (~600ms).
const period = busy ? 5 : 12;
const bright = Math.floor(tick / period) % 2 === 0;
return (
<Text>
{WORDMARK_STYLES.map((c) => (
<Text key={`${c.ch}-${c.color}`} color={c.color} bold={c.isLogo ? bright : true}>
{c.ch}
</Text>
))}
</Text>
);
}
export interface StatsPanelProps {
summary: SessionSummary;
@@ -33,8 +79,31 @@ export interface StatsPanelProps {
* still in flight).
*/
updateAvailable?: string | null;
/**
* True when the loop is currently mid-turn — drives the wordmark's
* "busy breathing" animation. Visual signal that the app is thinking,
* visible at a glance from anywhere on screen.
*/
busy?: boolean;
}
/**
* Terminal width under which we switch from one-line metrics to a
* stacked layout. Below 120 cols the wide version wraps mid-number
* and the parens ("in $X · out $Y") get split across lines — which
* looks broken. Stacking costs one row but stays readable on every
* SSH / split-pane case we've hit.
*/
const NARROW_BREAKPOINT = 120;
/**
* Cache-hit ratio is always zero on turn 1 (there's literally no cache
* to hit yet). Showing that as red "0.0%" is a false alarm — new users
* read it as "something is broken." We suppress the gradient until a
* few turns have landed; by turn 4 the cache has had time to warm up.
*/
const COLD_START_TURNS = 3;
export function StatsPanel({
summary,
model,
@@ -44,99 +113,320 @@ export function StatsPanel({
planMode,
balance,
updateAvailable,
busy,
}: StatsPanelProps) {
const hitPct = (summary.cacheHitRatio * 100).toFixed(1);
const hitColor =
summary.cacheHitRatio >= 0.7 ? "green" : summary.cacheHitRatio >= 0.4 ? "yellow" : "red";
const branchOn = (branchBudget ?? 1) > 1;
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
const ctxRatio = summary.lastPromptTokens / ctxMax;
const ctxColor = ctxRatio >= 0.8 ? "red" : ctxRatio >= 0.5 ? "yellow" : undefined;
const { stdout } = useStdout();
const columns = stdout?.columns ?? 80;
const narrow = columns < NARROW_BREAKPOINT;
// Cold-start: first few turns render cache+cost muted so a 0.0% hit
// rate on turn 1 doesn't look like an error. Once the cache has
// actually had a chance to build, we flip to the live gradient.
const coldStart = summary.turns <= COLD_START_TURNS;
return (
<Box borderStyle="round" borderColor="cyan" flexDirection="column" paddingX={1}>
<Box justifyContent="space-between">
<Text>
<Text color="cyan" bold>
Reasonix
</Text>
<Text dimColor>{` v${VERSION}`}</Text>
<Text dimColor> · model </Text>
<Text color="yellow">{model}</Text>
<Text dimColor> · prefix </Text>
<Text dimColor>{prefixHash}</Text>
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
{planMode ? (
<Text color="red" bold>
{" "}
· PLAN
</Text>
) : null}
</Text>
<Text>
{updateAvailable ? (
<Text color="yellow" bold>{`update: ${updateAvailable} · `}</Text>
) : null}
<Text dimColor>turns {summary.turns} · type /help</Text>
</Text>
</Box>
<Box marginTop={1} gap={3}>
<Text>
<Text dimColor>cache hit </Text>
<Text color={hitColor} bold>
{hitPct}%
</Text>
</Text>
<Text>
<Text dimColor>cost </Text>
<Text color="green" bold>
${summary.totalCostUsd.toFixed(6)}
</Text>
<Text dimColor>
{" (in "}${summary.totalInputCostUsd.toFixed(6)}
{" · out "}${summary.totalOutputCostUsd.toFixed(6)}
{")"}
</Text>
</Text>
{summary.lastPromptTokens > 0 ? (
<Text>
<Text dimColor>ctx </Text>
<Text color={ctxColor} bold={ctxColor !== undefined}>
{formatTokens(summary.lastPromptTokens)}/{formatTokens(ctxMax)}
</Text>
<Text dimColor> ({(ctxRatio * 100).toFixed(0)}%)</Text>
{ctxRatio >= 0.8 ? (
<Text color="red" bold>
{" "}
· /compact
</Text>
) : null}
</Text>
) : null}
{balance ? (
<Text>
<Text dimColor>balance </Text>
<Text color={balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green"} bold>
{balance.currency === "USD" ? "$" : ""}
{balance.total.toFixed(2)}
{balance.currency !== "USD" ? ` ${balance.currency}` : ""}
</Text>
<Header
model={model}
prefixHash={prefixHash}
harvestOn={harvestOn}
branchOn={branchOn}
branchBudget={branchBudget ?? 1}
planMode={planMode}
turns={summary.turns}
updateAvailable={updateAvailable}
narrow={narrow}
busy={busy ?? false}
/>
{narrow ? (
<StackedMetrics
summary={summary}
ctxRatio={ctxRatio}
ctxMax={ctxMax}
balance={balance}
coldStart={coldStart}
/>
) : (
<InlineMetrics
summary={summary}
ctxRatio={ctxRatio}
ctxMax={ctxMax}
balance={balance}
coldStart={coldStart}
/>
)}
</Box>
);
}
function Header({
model,
prefixHash,
harvestOn,
branchOn,
branchBudget,
planMode,
turns,
updateAvailable,
narrow,
busy,
}: {
model: string;
prefixHash: string;
harvestOn?: boolean;
branchOn: boolean;
branchBudget: number;
planMode?: boolean;
turns: number;
updateAvailable?: string | null;
narrow: boolean;
busy: boolean;
}) {
return (
<Box justifyContent="space-between">
<Box>
<Wordmark busy={busy} />
<Text dimColor>{` v${VERSION}`}</Text>
<Text dimColor> · </Text>
<Text color="yellow">{model}</Text>
{narrow ? null : (
<>
<Text dimColor> · </Text>
<Text dimColor>{prefixHash}</Text>
</>
)}
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
{planMode ? (
<Text color="red" bold>
{" · PLAN"}
</Text>
) : null}
</Box>
<Text>
{updateAvailable ? (
<Text color="yellow" bold>{`update: ${updateAvailable} · `}</Text>
) : null}
<Text dimColor>{narrow ? `turn ${turns}` : `turn ${turns} · /help`}</Text>
</Text>
</Box>
);
}
/**
* Compact integer formatter: 1234 → "1.2k", 131072 → "131k". Keeps the
* panel narrow enough to fit on 80-col terminals even when the context
* is near full.
* Wide-terminal layout (≥120 cols): single metrics row. Matches the
* original panel, just with calmer spacing and the cold-start
* treatment folded in.
*/
function InlineMetrics({
summary,
ctxRatio,
ctxMax,
balance,
coldStart,
}: {
summary: SessionSummary;
ctxRatio: number;
ctxMax: number;
balance?: { currency: string; total: number } | null;
coldStart: boolean;
}) {
return (
<Box marginTop={1} gap={3}>
<ContextCell ratio={ctxRatio} promptTokens={summary.lastPromptTokens} ctxMax={ctxMax} />
<CacheCell hitRatio={summary.cacheHitRatio} coldStart={coldStart} turns={summary.turns} />
<CostCell summary={summary} coldStart={coldStart} />
{balance ? <BalanceCell balance={balance} /> : null}
</Box>
);
}
/**
* Narrow-terminal layout (<120 cols): one metric per line, in priority
* order (ctx → balance → cache → cost). Order matters because users
* scanning a cramped terminal want "am I about to hit a wall?" answered
* first; dollars spent comes later.
*/
function StackedMetrics({
summary,
ctxRatio,
ctxMax,
balance,
coldStart,
}: {
summary: SessionSummary;
ctxRatio: number;
ctxMax: number;
balance?: { currency: string; total: number } | null;
coldStart: boolean;
}) {
return (
<Box marginTop={1} flexDirection="column">
<ContextCell
ratio={ctxRatio}
promptTokens={summary.lastPromptTokens}
ctxMax={ctxMax}
showBar
/>
{balance ? <BalanceCell balance={balance} /> : null}
<CacheCell hitRatio={summary.cacheHitRatio} coldStart={coldStart} turns={summary.turns} />
<CostCell summary={summary} coldStart={coldStart} />
</Box>
);
}
function ContextCell({
ratio,
promptTokens,
ctxMax,
showBar,
}: {
ratio: number;
promptTokens: number;
ctxMax: number;
showBar?: boolean;
}) {
if (promptTokens === 0) {
return (
<Text>
<Text dimColor>ctx </Text>
<Text dimColor>— (no turns yet)</Text>
</Text>
);
}
const color = ratio >= 0.8 ? "red" : ratio >= 0.6 ? "yellow" : "green";
const pct = Math.round(ratio * 100);
return (
<Text>
<Text dimColor>ctx </Text>
{showBar ? <Bar ratio={ratio} color={color} /> : null}
{showBar ? <Text> </Text> : null}
<Text color={color} bold>
{formatTokens(promptTokens)}/{formatTokens(ctxMax)}
</Text>
<Text dimColor> ({pct}%)</Text>
{ratio >= 0.8 ? (
<Text color="red" bold>
{" · /compact"}
</Text>
) : null}
</Text>
);
}
function CacheCell({
hitRatio,
coldStart,
turns,
}: {
hitRatio: number;
coldStart: boolean;
turns: number;
}) {
const pct = (hitRatio * 100).toFixed(1);
if (turns === 0) {
return (
<Text>
<Text dimColor>cache </Text>
<Text dimColor>—</Text>
</Text>
);
}
if (coldStart) {
return (
<Text>
<Text dimColor>cache </Text>
<Text dimColor>{pct}% </Text>
<Text dimColor italic>
(cold start)
</Text>
</Text>
);
}
const color = hitRatio >= 0.7 ? "green" : hitRatio >= 0.4 ? "yellow" : "red";
return (
<Text>
<Text dimColor>cache </Text>
<Text color={color} bold>
{pct}%
</Text>
</Text>
);
}
function CostCell({
summary,
coldStart,
}: {
summary: SessionSummary;
coldStart: boolean;
}) {
if (summary.turns === 0) {
return (
<Text>
<Text dimColor>cost </Text>
<Text dimColor>—</Text>
</Text>
);
}
// The first turn is unavoidably a cache-miss (nothing to hit),
// so the dollar figure is front-loaded. Muting it during the
// cold-start window keeps the "expensive first turn" from
// reading as "something is wrong."
const primaryColor = coldStart ? undefined : "green";
return (
<Text>
<Text dimColor>cost </Text>
<Text color={primaryColor} bold={!coldStart} dimColor={coldStart}>
${summary.totalCostUsd.toFixed(6)}
</Text>
<Text dimColor>
{" (in "}${summary.totalInputCostUsd.toFixed(6)}
{" · out "}${summary.totalOutputCostUsd.toFixed(6)}
{")"}
</Text>
</Text>
);
}
function BalanceCell({ balance }: { balance: { currency: string; total: number } }) {
const color = balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green";
return (
<Text>
<Text dimColor>balance </Text>
<Text color={color} bold>
{balance.currency === "USD" ? "$" : ""}
{balance.total.toFixed(2)}
{balance.currency !== "USD" ? ` ${balance.currency}` : ""}
</Text>
</Text>
);
}
/**
* Unicode progress bar for the narrow layout's context cell. 10 cells
* wide — narrow terminals are already cramped, and the bar is just a
* visual cue for the percentage next to it, not the primary readout.
*/
function Bar({ ratio, color }: { ratio: number; color: "green" | "yellow" | "red" }) {
const cells = 10;
const filled = Math.max(0, Math.min(cells, Math.round(ratio * cells)));
const bar = "█".repeat(filled) + "░".repeat(cells - filled);
return <Text color={color}>{bar}</Text>;
}
/**
* Compact token formatter using binary K (÷1024): 1234 → "1.2K",
* 131072 → "128K". Matches DeepSeek's marketing label of "128K context
* length" — showing "131k" confused users (the actual limit is
* 128×1024 = 131,072 tokens, but the doc consistently says "128K").
* Uppercase K signals binary-K to anyone who notices.
*/
function formatTokens(n: number): string {
if (n < 1000) return String(n);
const k = n / 1000;
return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`;
if (n < 1024) return String(n);
const k = n / 1024;
return k >= 100 ? `${k.toFixed(0)}K` : `${k.toFixed(1)}K`;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Bash-mode (`!cmd`) prefix parsing.
*
* A `!` at the start of the user's input means "run this as a shell
* command in the sandbox root, and put both the command and its output
* into the conversation so the model sees it next turn." Same idea as
* Claude Code's `!` prefix — faster than round-tripping through the
* model just to cat a file or check git status.
*
* Design choice: user-typed `!` commands skip the allowlist. The
* allowlist exists to bound what the MODEL can do with run_command;
* the user typing `!git push` by hand is explicit consent. If a user
* wants stricter gating they can set `REASONIX_BANG_CONFIRM=1` in a
* future iteration.
*/
/**
* Return the command portion of a `!`-prefixed input, or `null` if
* the text isn't a bang invocation. Trims leading/trailing whitespace
* from the command body. `!` alone (or `!` followed by only whitespace)
* returns `null` — no command to run.
*/
export function detectBangCommand(text: string): string | null {
if (!text.startsWith("!")) return null;
const body = text.slice(1).trim();
if (!body) return null;
return body;
}
/**
* Format a bang command + its output into a single user-role message
* body that the model will see on the next turn. The `[!cmd]` header
* marks the run explicitly so the model can distinguish
* user-typed shell output from its own `run_command` tool results
* (which are `role: tool`, different message shape entirely).
*/
export function formatBangUserMessage(cmd: string, output: string): string {
return `[!${cmd}]\n${output}`;
}
+11 -3
View File
@@ -195,7 +195,15 @@ export function parseCitationUrl(url: string): CitationParts | null {
export function validateCitation(url: string, projectRoot: string): CitationStatus {
const parts = parseCitationUrl(url);
if (!parts || !parts.path) return { ok: false, reason: "empty path" };
const fullPath = isAbsolute(parts.path) ? parts.path : join(projectRoot, parts.path);
// Strip a leading `/` or `\\`. Models habitually write `/foo.ts`
// meaning "project-root relative" — Aider / Claude-Code convention —
// rather than POSIX root-absolute. A literal absolute path is still
// reachable as `C:\foo` on Windows or once the strip drops the slash
// and Node's `isAbsolute` correctly rejects what's left. Real unix
// absolute references like `/etc/hosts` in a code citation are vanishingly
// rare — if anyone needs one we can revisit.
const normalized = parts.path.replace(/^[/\\]+/, "");
const fullPath = isAbsolute(normalized) ? normalized : join(projectRoot, normalized);
let stat: ReturnType<typeof statSync>;
try {
stat = statSync(fullPath);
@@ -295,7 +303,7 @@ function InlineMd({
if (status && !status.ok) {
parts.push(
<Text key={`l${idx++}`} color="red" strikethrough>
{`${linkText} ❌`}
{`${linkText} ✗`}
</Text>,
);
} else {
@@ -902,7 +910,7 @@ function BrokenCitationsBlock({ items }: { items: Array<{ url: string; reason: s
{items.map((b, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: list is derived from a Map iteration order, stable per render
<Text key={`bc-${i}`} color="red">
{` ❌ ${b.url} → ${b.reason}`}
{` ✗ ${b.url} → ${b.reason}`}
</Text>
))}
</Box>
+242
View File
@@ -0,0 +1,242 @@
/**
* `/resource` and `/prompt` slash handlers for the chat/code TUI.
*
* The slash-command registry (slash.ts) advertises both commands so the
* argument-level picker and /help surface them, but the actual read/fetch
* work is async — McpClient.readResource and getPrompt both round-trip to
* the server — so it doesn't fit the synchronous `handleSlash` shape used
* by every other slash command. App.tsx intercepts the commands directly
* and calls into the helpers below.
*
* Split by effect boundary:
* - formatters (resource list, contents, prompt list, messages) are
* pure over already-fetched data and unit-tested
* - find-server helpers are pure lookups against inspection reports
* - `handleMcpBrowseSlash` is the async orchestrator App.tsx calls
*
* v1 intentionally does NOT inject the fetched resource/prompt into the
* next model turn. It's a display-only browser so users can see what
* their MCP servers expose. A future version may add a Tab-to-inject
* hotkey once the UX is settled.
*/
import type { McpClient } from "../../mcp/client.js";
import type {
GetPromptResult,
McpPromptMessage,
McpResourceContents,
ReadResourceResult,
} from "../../mcp/types.js";
import type { DisplayEvent } from "./EventLog.js";
import type { McpServerSummary } from "./slash.js";
/**
* React setState for Historical — typed loose because the App state
* shape is App-internal. We only ever append, so `(prev) => prev.concat(row)`
* works against any array-of-DisplayEvent state.
*/
export type HistoricalSetter = (updater: (prev: DisplayEvent[]) => DisplayEvent[]) => void;
export function formatResourceList(servers: readonly McpServerSummary[]): string {
const lines: string[] = [];
let total = 0;
for (const s of servers) {
if (!s.report.resources.supported) continue;
const items = s.report.resources.items;
if (items.length === 0) continue;
lines.push(`[${s.label}] ${items.length} resource(s):`);
for (const r of items.slice(0, 20)) {
const name = r.name && r.name !== r.uri ? ` ${r.name}` : "";
const mime = r.mimeType ? ` · ${r.mimeType}` : "";
lines.push(` · ${r.uri}${name}${mime}`);
total++;
}
if (items.length > 20) lines.push(` (+${items.length - 20} more)`);
lines.push("");
}
if (total === 0) {
return "No resources on any connected MCP server (or no servers connected). `/mcp` shows the current set.";
}
lines.push("Read one: `/resource <uri>` — or use Tab in the picker.");
return lines.join("\n");
}
export function formatPromptList(servers: readonly McpServerSummary[]): string {
const lines: string[] = [];
let total = 0;
for (const s of servers) {
if (!s.report.prompts.supported) continue;
const items = s.report.prompts.items;
if (items.length === 0) continue;
lines.push(`[${s.label}] ${items.length} prompt(s):`);
for (const p of items.slice(0, 20)) {
const desc = p.description ? ` — ${p.description}` : "";
const argHint =
p.arguments && p.arguments.length > 0
? ` (args: ${p.arguments.map((a) => a.name + (a.required ? "*" : "?")).join(", ")})`
: "";
lines.push(` · ${p.name}${argHint}${desc}`);
total++;
}
if (items.length > 20) lines.push(` (+${items.length - 20} more)`);
lines.push("");
}
if (total === 0) {
return "No prompts on any connected MCP server (or no servers connected). `/mcp` shows the current set.";
}
lines.push(
"Fetch one: `/prompt <name>` — args are not supported yet; prompts with required args will surface an error from the server.",
);
return lines.join("\n");
}
export function findServerForResource(
servers: readonly McpServerSummary[],
uri: string,
): McpServerSummary | null {
for (const s of servers) {
if (!s.report.resources.supported) continue;
if (s.report.resources.items.some((r) => r.uri === uri)) return s;
}
return null;
}
export function findServerForPrompt(
servers: readonly McpServerSummary[],
name: string,
): McpServerSummary | null {
for (const s of servers) {
if (!s.report.prompts.supported) continue;
if (s.report.prompts.items.some((p) => p.name === name)) return s;
}
return null;
}
export function formatResourceContents(uri: string, result: ReadResourceResult): string {
const lines: string[] = [`Resource ${uri} (${result.contents.length} content block(s)):`, ""];
for (let i = 0; i < result.contents.length; i++) {
const c = result.contents[i]!;
const header = `— block ${i + 1}${c.mimeType ? ` · ${c.mimeType}` : ""}`;
lines.push(header);
lines.push(formatOneResourceContent(c));
lines.push("");
}
return lines.join("\n").trimEnd();
}
function formatOneResourceContent(c: McpResourceContents): string {
if ("text" in c) {
const MAX = 8_000;
if (c.text.length > MAX) {
return `${c.text.slice(0, MAX)}\n\n[…truncated ${c.text.length - MAX} chars; full contents available via McpClient.readResource in library mode.]`;
}
return c.text;
}
// blob — we can't render arbitrary binary in the TUI; give the size.
const bytes = typeof c.blob === "string" ? approximateBase64ByteSize(c.blob) : 0;
return `[binary · ~${bytes.toLocaleString()} bytes · base64]`;
}
function approximateBase64ByteSize(b64: string): number {
// 4 base64 chars encode 3 bytes; padding `=` trims the output.
const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
return Math.floor((b64.length * 3) / 4) - padding;
}
export function formatPromptMessages(name: string, result: GetPromptResult): string {
const lines: string[] = [
`Prompt ${name}${result.description ? ` — ${result.description}` : ""}`,
`(${result.messages.length} message(s))`,
"",
];
for (let i = 0; i < result.messages.length; i++) {
const m = result.messages[i]!;
lines.push(`— ${i + 1}. ${m.role}`);
lines.push(formatOnePromptMessage(m));
lines.push("");
}
return lines.join("\n").trimEnd();
}
function formatOnePromptMessage(m: McpPromptMessage): string {
const block = m.content as { type?: string; text?: string; resource?: McpResourceContents };
if (block.type === "text" && typeof block.text === "string") return block.text;
if (block.type === "resource" && block.resource) {
return `[resource: ${block.resource.uri}]\n${formatOneResourceContent(block.resource)}`;
}
return `[non-text content: ${block.type ?? "unknown"}]`;
}
/**
* Orchestrator. Looks up the right server, calls readResource/getPrompt
* via its `client`, and pushes one info row into `Historical`. Errors
* (server missing, -32601, network) surface as a warning row; the user
* can always fall back to `reasonix mcp inspect <spec>` for diagnostics.
*/
export async function handleMcpBrowseSlash(
kind: "resource" | "prompt",
arg: string,
servers: readonly McpServerSummary[],
setHistorical: HistoricalSetter,
): Promise<void> {
const ts = Date.now();
const push = (role: DisplayEvent["role"], text: string) => {
setHistorical((prev) => [...prev, { id: `mcp-${role}-${ts}-${prev.length}`, role, text }]);
};
// No arg → list mode.
if (!arg) {
push("info", kind === "resource" ? formatResourceList(servers) : formatPromptList(servers));
return;
}
if (kind === "resource") {
const server = findServerForResource(servers, arg);
if (!server) {
push(
"warning",
`no server exposes resource "${arg}". \`/resource\` with no arg lists what's available.`,
);
return;
}
const client: McpClient | undefined = server.client;
if (!client) {
push(
"warning",
`server [${server.label}] is not connected (display-only). Resource read requires a live MCP client.`,
);
return;
}
try {
const result = await client.readResource(arg);
push("info", formatResourceContents(arg, result));
} catch (err) {
push("warning", `readResource failed: ${(err as Error).message}`);
}
return;
}
// prompt
const server = findServerForPrompt(servers, arg);
if (!server) {
push(
"warning",
`no server exposes prompt "${arg}". \`/prompt\` with no arg lists what's available.`,
);
return;
}
const client: McpClient | undefined = server.client;
if (!client) {
push(
"warning",
`server [${server.label}] is not connected (display-only). Prompt fetch requires a live MCP client.`,
);
return;
}
try {
const result = await client.getPrompt(arg);
push("info", formatPromptMessages(arg, result));
} catch (err) {
push("warning", `getPrompt failed: ${(err as Error).message}`);
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Collapse long user-typed input for display purposes.
*
* Problem: pasting a stack trace, log dump, or sample file into the
* prompt box floods the Historical scrollback with a huge block — the
* user can't see their previous turns, the assistant's replies, or
* where the cursor is. Terminal UIs have shipped this shape for
* years (iTerm's "pasted text" notation, Claude Code's preview).
*
* Split of concerns: the MODEL still gets the full text. This helper
* only rewrites what we render in the Historical row. `@` expansions
* and `!` bang outputs have their own display paths (unaffected).
*/
/** Defaults tuned to typical prose vs. typical code-paste shapes. */
export const DEFAULT_PASTE_LINE_THRESHOLD = 40;
export const DEFAULT_PASTE_CHAR_THRESHOLD = 2000;
/** Lines kept visible at the head of a collapsed paste. */
export const DEFAULT_PASTE_HEAD_LINES = 10;
export interface PasteCollapseOptions {
lineThreshold?: number;
charThreshold?: number;
headLines?: number;
}
export interface PasteCollapseResult {
/** Text to render in the Historical row (possibly collapsed). */
displayText: string;
/** True when collapsing happened. False = input passed through verbatim. */
collapsed: boolean;
/** Original char length — exposed so callers can log/annotate. */
originalChars: number;
/** Original line count. */
originalLines: number;
}
/**
* Collapse `input` when it exceeds either the line OR char threshold.
* Display shape:
*
* ▸ pasted 2.3 KB (84 lines) — first 10 shown, full text sent to model
* line 1
* line 2
* …
* line 10
* … (74 more lines)
*
* Short input passes through unchanged — collapsed = false.
*/
export function formatLongPaste(
input: string,
opts: PasteCollapseOptions = {},
): PasteCollapseResult {
const lineCap = opts.lineThreshold ?? DEFAULT_PASTE_LINE_THRESHOLD;
const charCap = opts.charThreshold ?? DEFAULT_PASTE_CHAR_THRESHOLD;
const headN = Math.max(1, opts.headLines ?? DEFAULT_PASTE_HEAD_LINES);
const originalChars = input.length;
const lines = input.split("\n");
const originalLines = lines.length;
if (originalChars <= charCap && originalLines <= lineCap) {
return { displayText: input, collapsed: false, originalChars, originalLines };
}
const header = `▸ pasted ${formatBytes(originalChars)} (${originalLines} lines) — first ${Math.min(headN, originalLines)} shown, full text sent to model`;
const head = lines.slice(0, headN).join("\n");
const remaining = originalLines - headN;
const footer = remaining > 0 ? `… (${remaining} more line${remaining === 1 ? "" : "s"})` : "";
const displayText = footer ? `${header}\n${head}\n${footer}` : `${header}\n${head}`;
return { displayText, collapsed: true, originalChars, originalLines };
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const kb = n / 1024;
if (kb < 1024) return `${kb.toFixed(kb >= 10 ? 0 : 1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`;
}
+348 -21
View File
@@ -7,11 +7,13 @@ import {
projectSettingsPath,
} from "../../hooks.js";
import type { CacheFirstLoop } from "../../loop.js";
import type { McpClient } from "../../mcp/client.js";
import type { InspectionReport } from "../../mcp/inspect.js";
import { PROJECT_MEMORY_FILE, memoryEnabled, readProjectMemory } from "../../project-memory.js";
import { deleteSession, listSessions } from "../../session.js";
import { SkillStore } from "../../skills.js";
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../telemetry.js";
import { countTokens } from "../../tokenizer.js";
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "../../usage.js";
import { type MemoryScope, MemoryStore } from "../../user-memory.js";
import { VERSION, compareVersions, isNpxInstall } from "../../version.js";
@@ -142,6 +144,20 @@ export interface SlashContext {
* with no retry path.
*/
refreshLatestVersion?: () => void;
/**
* Model catalog fetched from DeepSeek's `/models` endpoint at App
* mount. `null` → still in flight or the call failed (auth / offline);
* `[]` → the API answered with zero entries. `/models` uses this to
* render the list, and `/model <id>` uses it for soft validation
* (warn-only — we still switch even on unknown ids since the list
* can lag a newly-released model).
*/
models?: string[] | null;
/**
* Fire-and-forget refresh for the models list. Lets `/models` retry
* after a flaky first fetch without needing async slash support.
*/
refreshModels?: () => void;
}
export interface McpServerSummary {
@@ -153,6 +169,13 @@ export interface McpServerSummary {
toolCount: number;
/** Full inspection snapshot — used for the resources + prompts sections. */
report: InspectionReport;
/**
* Live MCP client, kept so `/resource` and `/prompt` can call
* `readResource` / `getPrompt` against this server. Omitted by
* callers that only build the summary for display; those slashes
* fall back to "not available" when the client is missing.
*/
client?: McpClient;
}
/**
@@ -171,6 +194,24 @@ export interface SlashCommandSpec {
contextual?: "code";
/** If the command takes args, hint text shown after the name. */
argsHint?: string;
/**
* How the first argument position should autocomplete. Shapes the
* picker that appears below the prompt once the user types `/<cmd>`
* + space:
* - `"models"` → DeepSeek model-id list fetched at startup.
* - `"mcp-resources"` → live URIs aggregated across connected MCP servers.
* - `"mcp-prompts"` → live prompt names aggregated across MCP servers.
* - `string[]` → small enum of literal values (e.g. `["on", "off"]`).
* - omitted → no picker; a persistent usage hint shows the
* argsHint + summary so the user knows what to type.
*
* File-path completion is deliberately NOT offered here. Users who
* want to reference a file in a prompt use `@path/to/file` (0.5.5);
* the `@` picker already surfaces files with mtime + recently-used
* ranking. Adding a second file-picker surface for slash commands
* would split the UX without adding leverage.
*/
argCompleter?: "models" | "mcp-resources" | "mcp-prompts" | readonly string[];
}
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
@@ -180,11 +221,40 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
cmd: "preset",
argsHint: "<fast|smart|max>",
summary: "one-tap model + harvest + branch bundle",
argCompleter: ["fast", "smart", "max"],
},
{
cmd: "model",
argsHint: "<id>",
summary: "switch DeepSeek model id",
argCompleter: "models",
},
{ cmd: "models", summary: "list available models fetched from DeepSeek /models" },
{
cmd: "harvest",
argsHint: "[on|off]",
summary: "toggle Pillar-2 plan-state extraction",
argCompleter: ["on", "off"],
},
{
cmd: "branch",
argsHint: "<N|off>",
summary: "run N parallel samples per turn (N>=2)",
argCompleter: ["off", "2", "3", "4", "5"],
},
{ cmd: "model", argsHint: "<id>", summary: "switch DeepSeek model id" },
{ cmd: "harvest", argsHint: "[on|off]", summary: "toggle Pillar-2 plan-state extraction" },
{ cmd: "branch", argsHint: "<N|off>", summary: "run N parallel samples per turn (N>=2)" },
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
{
cmd: "resource",
argsHint: "[uri]",
summary: "browse + read MCP resources (no arg → list URIs; <uri> → fetch contents)",
argCompleter: "mcp-resources",
},
{
cmd: "prompt",
argsHint: "[name]",
summary: "browse + fetch MCP prompts (no arg → list names; <name> → render prompt)",
argCompleter: "mcp-prompts",
},
{ cmd: "tool", argsHint: "[N]", summary: "dump full output of the Nth tool call (1=latest)" },
{
cmd: "memory",
@@ -211,8 +281,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
"cross-session cost dashboard (today / week / month / all-time · cache hit · vs Claude)",
},
{ cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
{
cmd: "context",
summary: "break down where context tokens are going: system / tools / per-turn log",
},
{ cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
{ cmd: "compact", argsHint: "[cap]", summary: "shrink oversized tool results in the log" },
{
cmd: "compact",
argsHint: "[tokens]",
summary: "shrink oversized tool results in the log (cap in tokens, default 4000)",
},
{ cmd: "keys", summary: "show all keyboard shortcuts and prompt prefixes" },
{ cmd: "sessions", summary: "list saved sessions (current marked with ▸)" },
{ cmd: "forget", summary: "delete the current session from disk" },
{ cmd: "setup", summary: "reminds you to exit and run `reasonix setup`" },
@@ -234,6 +313,7 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "[on|off]",
summary: "toggle read-only plan mode (writes bounced until submit_plan + approval)",
contextual: "code",
argCompleter: ["on", "off"],
},
{
cmd: "apply-plan",
@@ -255,6 +335,72 @@ export function suggestSlashCommands(prefix: string, codeMode = false): SlashCom
});
}
/**
* Shape describing what the prompt buffer is asking for AFTER the
* user has committed to a slash command (`/<cmd> `) and started
* typing its first argument. Consumed by the TUI to drive an
* argument-level picker.
*/
export interface SlashArgContext {
/** The command spec (looked up by name from SLASH_COMMANDS). */
spec: SlashCommandSpec;
/** The partial first-argument text, possibly empty. */
partial: string;
/**
* Buffer offset where `partial` begins. Used by the TUI to splice
* a picked completion back in at the right position.
*/
partialOffset: number;
/**
* Classification of what the caller should show:
* - `"picker"` → an interactive picker (file / enum / models). The
* caller uses `spec.argCompleter` to pick the data source and
* filters against `partial`.
* - `"hint"` → past the completable position (additional args or
* no completer declared). Caller shows a dim usage hint only.
*/
kind: "picker" | "hint";
}
/**
* Classify the prompt buffer for argument completion. Returns `null`
* when the buffer isn't in a slash-with-args state.
*
* Firing shape: input must start with `/<cmd> ` (space after a known
* command). The character right after the space through end-of-buffer
* is the "arg tail"; if the tail has NO internal whitespace the
* picker is live (tail is the partial). If the tail has whitespace,
* the user has moved past the first argument position and we surface
* the usage hint only.
*/
export function detectSlashArgContext(input: string, codeMode = false): SlashArgContext | null {
// `/cmd <rest>` — one space, rest captured up to end-of-buffer.
const m = /^\/(\S+) ([\s\S]*)$/.exec(input);
if (!m) return null;
const cmdName = m[1]!.toLowerCase();
const tail = m[2] ?? "";
const spec = SLASH_COMMANDS.find(
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
);
if (!spec) return null;
const hasInternalSpace = /\s/.test(tail);
const partialOffset = input.length - tail.length;
if (hasInternalSpace) {
// Past the first arg position (typing the second arg for e.g.
// `/edit <file> <instruction>`, or mid-sentence for free-form).
return { spec, partial: tail, partialOffset, kind: "hint" };
}
// No internal whitespace — we're still typing the first arg. Picker
// is live if the spec declares a completer; otherwise show a hint
// ("what goes here?") since the user's guessing.
return {
spec,
partial: tail,
partialOffset,
kind: spec.argCompleter ? "picker" : "hint",
};
}
export function parseSlash(text: string): { cmd: string; args: string[] } | null {
if (!text.startsWith("/")) return null;
const parts = text.slice(1).trim().split(/\s+/);
@@ -292,20 +438,58 @@ export function handleSlash(
};
}
case "keys":
return {
info: [
"Keyboard & prompt shortcuts:",
"",
" Enter submit the current prompt",
" Shift+Enter / Ctrl+J insert a newline (multi-line prompt)",
" \\<Enter> bash-style line continuation",
" ← → ↑ ↓ move cursor / recall history when buffer empty",
" Ctrl+A / Ctrl+E jump to start / end of the current line",
" Backspace delete left; Delete delete under cursor",
" Esc abort the in-flight turn",
" y / n accept / reject pending edits (code mode)",
"",
"Prompt prefixes:",
" /<name> slash command; Tab/Enter picks from the suggestion list",
" @<path> inline a file under [Referenced files] (code mode).",
" Trailing `@…` opens a file picker; ↑/↓ navigate, Tab/Enter pick.",
" !<cmd> run <cmd> as shell in the sandbox root; output goes into context",
" so the model sees it next turn. No allowlist gate.",
"",
"Pickers (slash + @-mention):",
" ↑ / ↓ navigate the suggestion list",
" Tab insert the highlighted item without submitting",
" Enter insert and (slash) run it, (@) keep editing",
"",
"MCP exploration:",
" /mcp servers + tool/resource/prompt counts",
" /resource [uri] browse & read resources exposed by your MCP servers",
" /prompt [name] browse & fetch prompts exposed by your MCP servers",
"",
"Useful slashes: /help · /context · /stats · /compact · /new · /exit",
].join("\n"),
};
case "help":
case "?":
return {
info: [
"Commands:",
" /help this message",
" /keys keyboard shortcuts + prompt prefixes (!, @, /)",
" /status show current settings",
" /preset <fast|smart|max> one-tap presets — see below",
" /model <id> deepseek-chat or deepseek-reasoner",
" /harvest [on|off] Pillar 2: structured plan-state extraction",
" /branch <N|off> run N parallel samples (N>=2), pick most confident",
" /mcp list MCP servers + tools attached to this session",
" /resource [uri] browse + read MCP resources (no arg → list URIs; <uri> → fetch)",
" /prompt [name] browse + fetch MCP prompts (no arg → list names; <name> → render)",
" /setup (exit + reconfigure) → run `reasonix setup`",
" /compact [cap] shrink large tool results in history (default 4k/result)",
" /compact [tokens] shrink large tool results in history (default 4000 tokens/result)",
" /think dump the most recent turn's full R1 reasoning (reasoner only)",
" /tool [N] list tool calls (or dump full output of #N, 1=most recent)",
" /memory [sub] show pinned memory (REASONIX.md + ~/.reasonix/memory).",
@@ -325,6 +509,16 @@ export function handleSlash(
" /clear clear displayed scrollback only (context kept — model still sees it)",
" /exit quit",
"",
"Shell shortcut:",
" !<cmd> run <cmd> in the sandbox root; output goes into",
" the conversation so the model sees it next turn.",
" No allowlist gate — user-typed = explicit consent.",
" Example: !git status !ls src/ !npm test",
"",
"File references (code mode):",
" @path/to/file inline file content under [Referenced files] on send.",
" Type `@` to open the picker (↑↓ navigate, Tab/Enter pick).",
"",
"Presets:",
" fast deepseek-chat no harvest no branch ~1¢/100turns ← default",
" smart reasoner harvest ~10x cost, slower",
@@ -351,6 +545,8 @@ export function handleSlash(
// server with its tools / resources / prompts grouped together.
if (servers.length > 0) {
const lines: string[] = [];
let anyResources = false;
let anyPrompts = false;
for (const s of servers) {
const { report } = s;
const serverName = report.serverInfo.name || "(unknown)";
@@ -359,11 +555,20 @@ export function handleSlash(
lines.push(` tools ${s.toolCount}`);
appendSection(lines, "resources", report.resources);
appendSection(lines, "prompts ", report.prompts);
if (report.resources.supported && report.resources.items.length > 0) anyResources = true;
if (report.prompts.supported && report.prompts.items.length > 0) anyPrompts = true;
lines.push("");
}
lines.push(
"Chat mode consumes tools today; resources+prompts are surfaced here for awareness.",
);
if (anyResources || anyPrompts) {
const hints: string[] = [];
if (anyResources) hints.push("`/resource` to browse+read");
if (anyPrompts) hints.push("`/prompt` to browse+fetch");
lines.push(hints.join(" · "));
} else {
lines.push(
"Chat mode consumes tools today; resources+prompts are surfaced here for awareness.",
);
}
lines.push(
"Full catalog: `reasonix mcp list` · deeper diagnosis: `reasonix mcp inspect <spec>`.",
);
@@ -565,21 +770,22 @@ export function handleSlash(
}
case "compact": {
// Manual companion to the automatic heal-on-load. Re-applies
// truncation with a tighter cap (4k chars per tool result) and
// rewrites the session file so the shrink persists. Useful when
// the ctx gauge in StatsPanel goes yellow/red mid-session and
// the user wants to keep chatting without /forget'ing everything.
// Manual companion to the automatic 60%/80% auto-compact. Re-
// applies token-aware truncation with a tighter cap (default 4000
// tokens per tool result) and rewrites the session file so the
// shrink persists. Useful when the ctx gauge in StatsPanel goes
// yellow/red mid-session and the user wants to keep chatting
// without /forget'ing everything.
const tight = Number.parseInt(args[0] ?? "", 10);
const cap = Number.isFinite(tight) && tight >= 500 ? tight : 4000;
const { healedCount, charsSaved } = loop.compact(cap);
const cap = Number.isFinite(tight) && tight >= 100 ? tight : 4000;
const { healedCount, tokensSaved, charsSaved } = loop.compact(cap);
if (healedCount === 0) {
return {
info: `▸ nothing to compact — no tool result in history exceeds ${cap.toLocaleString()} chars.`,
info: `▸ nothing to compact — no tool result in history exceeds ${cap.toLocaleString()} tokens.`,
};
}
return {
info: `▸ compacted ${healedCount} tool result(s), saved ${charsSaved.toLocaleString()} chars (~${Math.round(charsSaved / 4).toLocaleString()} tokens). Session file rewritten.`,
info: `▸ compacted ${healedCount} tool result(s) to ${cap.toLocaleString()} tokens each, saved ${tokensSaved.toLocaleString()} tokens (${charsSaved.toLocaleString()} chars). Session file rewritten.`,
};
}
@@ -617,6 +823,79 @@ export function handleSlash(
};
}
case "context": {
// Measure each slice of the next request locally so the user can
// see *where* context is being spent — a much more useful number
// than the "last turn's total" the gauge shows. Tokenization is
// lazy so the first /context call carries the data-file load
// cost (~100ms); subsequent calls are pure compute.
const systemTokens = countTokens(loop.prefix.system);
const toolsTokens = countTokens(JSON.stringify(loop.prefix.toolSpecs));
const entries = loop.log.toMessages();
let userTokens = 0;
let assistantTokens = 0;
let toolResultTokens = 0;
let toolCallTokens = 0;
const toolBreakdown: Array<{ name: string; tokens: number; turn: number }> = [];
let logTurn = 0;
for (const e of entries) {
const content = typeof e.content === "string" ? e.content : "";
if (e.role === "user") {
userTokens += countTokens(content);
logTurn += 1;
} else if (e.role === "assistant") {
assistantTokens += countTokens(content);
if (Array.isArray(e.tool_calls) && e.tool_calls.length > 0) {
toolCallTokens += countTokens(JSON.stringify(e.tool_calls));
}
} else if (e.role === "tool") {
const n = countTokens(content);
toolResultTokens += n;
toolBreakdown.push({ name: e.name ?? "?", tokens: n, turn: logTurn });
}
}
const logTokens = userTokens + assistantTokens + toolResultTokens + toolCallTokens;
const total = systemTokens + toolsTokens + logTokens;
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
const pct = (n: number) =>
total > 0 ? `${Math.round((n / total) * 100)}%`.padStart(4) : " 0%";
const row = (label: string, n: number, note = "") =>
` ${label.padEnd(20)}${compactNum(n).padStart(8)} tokens ${pct(n)}${note ? ` ${note}` : ""}`;
const lines = [
`Next-request estimate: ~${compactNum(total)} tokens of ${compactNum(ctxMax)} (${Math.round(
(total / ctxMax) * 100,
)}% of window)`,
"",
row("system prompt", systemTokens),
row("tool specs", toolsTokens, `(${loop.prefix.toolSpecs.length} tools)`),
row("log (all turns)", logTokens, `(${entries.length} messages)`),
` user ${compactNum(userTokens).padStart(8)} tokens`,
` assistant ${compactNum(assistantTokens).padStart(8)} tokens`,
` tool-call args ${compactNum(toolCallTokens).padStart(8)} tokens`,
` tool results ${compactNum(toolResultTokens).padStart(8)} tokens`,
];
// Top 5 heaviest tool results — usually where unexpected bloat
// lives (a big read_file, an unfiltered search_content).
if (toolBreakdown.length > 0) {
const top = [...toolBreakdown].sort((a, b) => b.tokens - a.tokens).slice(0, 5);
lines.push("");
lines.push(`Top tool results by cost (of ${toolBreakdown.length} total):`);
for (const t of top) {
lines.push(
` turn ${String(t.turn).padStart(3)} ${t.name.padEnd(22)} ${compactNum(t.tokens).padStart(8)} tokens`,
);
}
}
lines.push("");
lines.push(
"Count is a local estimate (DeepSeek V3 tokenizer, pure-TS port); server prompt_tokens may add ~3-6% for chat-template role markers.",
);
return { info: lines.join("\n") };
}
case "status": {
const branchBudget = loop.branchOptions.budget ?? 1;
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
@@ -650,11 +929,53 @@ export function handleSlash(
case "model": {
const id = args[0];
if (!id) return { info: "usage: /model <id> (try deepseek-chat or deepseek-reasoner)" };
const known = ctx.models ?? null;
if (!id) {
const hint =
known && known.length > 0
? known.join(" | ")
: "try deepseek-chat or deepseek-reasoner — run /models to fetch the live list";
return { info: `usage: /model <id> (${hint})` };
}
loop.configure({ model: id });
// Soft validation: if we have the live list and the id isn't in
// it, flag a warning but still switch — DeepSeek may have just
// released something we haven't indexed yet, and refusing would
// be worse than a bad API error on the next call.
if (known && known.length > 0 && !known.includes(id)) {
return {
info: `model → ${id} (⚠ not in the fetched catalog: ${known.join(", ")}. If this is wrong the next call will 400 — run /models to refresh.)`,
};
}
return { info: `model → ${id}` };
}
case "models": {
const list = ctx.models ?? null;
if (list === null) {
ctx.refreshModels?.();
return {
info: "fetching /models from DeepSeek… run /models again in a moment. If it stays empty, your API key may lack permission or the network is blocked.",
};
}
if (list.length === 0) {
return {
info: "DeepSeek /models returned an empty list. Try /models again, or check your account status at api-docs.deepseek.com.",
};
}
const current = loop.model;
const lines = list.map((id) => (id === current ? `▸ ${id} (current)` : ` ${id}`));
return {
info: [
`Available models (DeepSeek /models · ${list.length} total):`,
"",
...lines,
"",
"Switch with: /model <id>",
].join("\n"),
};
}
case "harvest": {
const arg = (args[0] ?? "").toLowerCase();
const on = arg === "" ? !loop.harvestEnabled : arg === "on" || arg === "true" || arg === "1";
@@ -1176,10 +1497,16 @@ function formatToolList(history: Array<{ toolName: string; text: string }>): str
return lines.join("\n");
}
/**
* Binary-K token formatter: 1234 → "1.2K", 131072 → "128K". Matches
* DeepSeek's doc ("128K context"). Every call site here is rendering
* token counts — if a future caller wants decimal-K for dollars or
* similar, add a separate formatter rather than reusing this one.
*/
function compactNum(n: number): string {
if (n < 1000) return String(n);
const k = n / 1000;
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
if (n < 1024) return String(n);
const k = n / 1024;
return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
}
function stripOuterQuotes(s: string): string {
+40
View File
@@ -64,6 +64,23 @@ export interface UserBalance {
balance_infos: BalanceInfo[];
}
/**
* Response shape for DeepSeek's `/models` endpoint. Mirrors the OpenAI
* models list shape DeepSeek copied — `id` is the model name to pass to
* `/chat/completions`, `owned_by` is the provider string (always
* `"deepseek"` today).
*/
export interface ModelInfo {
id: string;
object: "model";
owned_by: string;
}
export interface ModelList {
object: "list";
data: ModelInfo[];
}
export interface DeepSeekClientOptions {
apiKey?: string;
baseUrl?: string;
@@ -133,6 +150,29 @@ export class DeepSeekClient {
}
}
/**
* Fetch the model catalog DeepSeek currently exposes. Today this is
* `deepseek-chat` (V3) and `deepseek-reasoner` (R1), but querying is
* the only way to learn about new ones without a Reasonix release.
* Returns null on any network/auth failure so callers can degrade
* gracefully — e.g. `/models` falls back to the hardcoded hint.
*/
async listModels(opts: { signal?: AbortSignal } = {}): Promise<ModelList | null> {
try {
const resp = await this._fetch(`${this.baseUrl}/models`, {
method: "GET",
headers: { Authorization: `Bearer ${this.apiKey}` },
signal: opts.signal,
});
if (!resp.ok) return null;
const data = (await resp.json()) as ModelList;
if (!data || !Array.isArray(data.data)) return null;
return data;
} catch {
return null;
}
}
async chat(opts: ChatRequestOptions): Promise<ChatResponse> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
+124
View File
@@ -0,0 +1,124 @@
/**
* Render a SEARCH/REPLACE edit block as a compact unified-diff-style
* preview for the code-mode confirm gate. Shows the user what's about
* to land on disk BEFORE they press `y` — catches "wrong file" and
* "wrong replacement" mistakes that the old path + line-count summary
* couldn't.
*
* Not a full Myers diff: we trim shared leading/trailing lines as
* context and render the middle divergence as `-old` / `+new`. That's
* enough for the common SEARCH/REPLACE shape (small change inside a
* function body, wrapped with a few lines of surrounding code for
* uniqueness) and avoids pulling in a diff library.
*/
import type { EditBlock } from "./edit-blocks.js";
export interface DiffPreviewOptions {
/** How many lines of unchanged context to show at each end. Default 2. */
contextLines?: number;
/** Hard cap on total rendered lines. Default 20 — beyond this the preview collapses. */
maxLines?: number;
/** Indent applied to every output line. Default 8 spaces — matches the pending-preview nesting. */
indent?: string;
}
/** Render one edit block's diff. Returns an array of formatted lines. */
export function formatEditBlockDiff(block: EditBlock, opts: DiffPreviewOptions = {}): string[] {
const contextLines = Math.max(0, opts.contextLines ?? 2);
const maxLines = Math.max(4, opts.maxLines ?? 20);
const indent = opts.indent ?? " ";
const search = block.search === "" ? [] : block.search.split("\n");
const replace = block.replace.split("\n");
// New-file case: no search to compare, show the full new content
// (capped). Mark every line `+` so the user knows it's all additions.
if (search.length === 0) {
return renderAllPlus(replace, indent, maxLines);
}
// Common leading / trailing lines — shared context we can collapse.
let leading = 0;
while (
leading < search.length &&
leading < replace.length &&
search[leading] === replace[leading]
) {
leading++;
}
let trailing = 0;
while (
trailing < search.length - leading &&
trailing < replace.length - leading &&
search[search.length - 1 - trailing] === replace[replace.length - 1 - trailing]
) {
trailing++;
}
const searchMiddle = search.slice(leading, search.length - trailing);
const replaceMiddle = replace.slice(leading, replace.length - trailing);
// Trim context to `contextLines` on each side.
const leadShown = search.slice(Math.max(0, leading - contextLines), leading);
const leadHidden = leading - leadShown.length;
const trailShown = search.slice(
search.length - trailing,
search.length - trailing + contextLines,
);
const trailHidden = trailing - trailShown.length;
const out: string[] = [];
if (leadHidden > 0) {
out.push(`${indent} … ${leadHidden} unchanged line${leadHidden === 1 ? "" : "s"} above`);
}
for (const l of leadShown) out.push(`${indent} ${l}`);
for (const l of searchMiddle) out.push(`${indent}- ${l}`);
for (const l of replaceMiddle) out.push(`${indent}+ ${l}`);
for (const l of trailShown) out.push(`${indent} ${l}`);
if (trailHidden > 0) {
out.push(`${indent} … ${trailHidden} unchanged line${trailHidden === 1 ? "" : "s"} below`);
}
return capLines(out, maxLines, indent);
}
/**
* Render the full set of blocks back-to-back, each preceded by a
* path-and-size header so the user can tell which file owns each
* diff chunk. Blocks are separated by a blank line for breathing room.
*/
export function formatAllBlockDiffs(
blocks: readonly EditBlock[],
opts: DiffPreviewOptions = {},
): string[] {
const out: string[] = [];
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i]!;
const removed = b.search === "" ? 0 : countLines(b.search);
const added = countLines(b.replace);
const tag = b.search === "" ? "NEW " : " ";
if (i > 0) out.push("");
out.push(` ${tag}${b.path} (-${removed} +${added} lines)`);
out.push(...formatEditBlockDiff(b, opts));
}
return out;
}
function countLines(s: string): number {
if (s.length === 0) return 0;
return (s.match(/\n/g)?.length ?? 0) + 1;
}
function renderAllPlus(lines: string[], indent: string, maxLines: number): string[] {
const out = lines.map((l) => `${indent}+ ${l}`);
return capLines(out, maxLines, indent);
}
function capLines(lines: string[], maxLines: number, indent: string): string[] {
if (lines.length <= maxLines) return lines;
const head = lines.slice(0, maxLines - 1);
const hidden = lines.length - head.length;
head.push(`${indent}… (${hidden} more diff lines — full content applies on /apply)`);
return head;
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Durable checkpoint for the pending-edits queue.
*
* Code mode holds parsed SEARCH/REPLACE blocks in an in-memory
* `useRef<EditBlock[]>` until the user runs `/apply` or `/discard`.
* Pre-0.5.14 that was ephemeral: a crash or accidental Ctrl+C after
* the model proposed seven edits discarded all seven — the session
* log had the assistant text, but the "please review this batch"
* state was gone.
*
* This module checkpoints the queue to
* `~/.reasonix/sessions/<name>.pending.json`
* after every mutation (parse → set / apply → clear / discard →
* clear). On session resume the App reads the file and re-populates
* the queue. If the file doesn't exist the session resumes as before.
*
* Format choices:
* - JSON (not JSONL) — the queue is a single array written at once,
* not a stream of records.
* - Overwrite-on-write — the queue is small (kilobytes) and atomicity
* is "last write wins"; partial-write on crash is fine since we'd
* rather lose the checkpoint than have a torn file block resume.
* - Best-effort I/O — disk full or permission denied must not break
* the session. Silent skip matches the rest of the durable logs
* (session.ts, usage.ts).
*
* Ephemeral sessions (sessionName === null) skip persistence entirely:
* there's no stable name to key the checkpoint to.
*/
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { sanitizeName, sessionsDir } from "../session.js";
import type { EditBlock } from "./edit-blocks.js";
/** Absolute path for the checkpoint file that belongs to this session. */
export function pendingEditsPath(sessionName: string): string {
return join(sessionsDir(), `${sanitizeName(sessionName)}.pending.json`);
}
/**
* Write the queue to disk, or delete the checkpoint file when `blocks`
* is empty. Callers pass the full current queue every time; we don't
* attempt append-style deltas because the queue is tiny.
*
* No-op when `sessionName` is null (ephemeral session has nowhere to
* write), so call sites can pass the session name directly without a
* null guard.
*/
export function savePendingEdits(sessionName: string | null, blocks: EditBlock[]): void {
if (!sessionName) return;
const path = pendingEditsPath(sessionName);
try {
if (blocks.length === 0) {
if (existsSync(path)) unlinkSync(path);
return;
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(blocks, null, 2), "utf8");
} catch {
/* best-effort — disk full / perms should not break the session */
}
}
/**
* Load the queue from disk, returns `null` when there's no checkpoint.
* A malformed file (power-loss torn write, user hand-edit) also returns
* null — silent recovery beats failing to open the session.
*/
export function loadPendingEdits(sessionName: string | null): EditBlock[] | null {
if (!sessionName) return null;
const path = pendingEditsPath(sessionName);
if (!existsSync(path)) return null;
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return null;
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
const out: EditBlock[] = [];
for (const item of parsed) {
if (
item &&
typeof item === "object" &&
typeof item.path === "string" &&
typeof item.search === "string" &&
typeof item.replace === "string" &&
typeof item.offset === "number"
) {
out.push(item as EditBlock);
}
}
return out;
} catch {
return null;
}
}
/** Delete the checkpoint file unconditionally — called by /apply and /discard. */
export function clearPendingEdits(sessionName: string | null): void {
if (!sessionName) return;
const path = pendingEditsPath(sessionName);
try {
if (existsSync(path)) unlinkSync(path);
} catch {
/* best-effort */
}
}
+24
View File
@@ -6,9 +6,30 @@ export type { ChatResponse, StreamChunk, DeepSeekClientOptions } from "./client.
export {
CacheFirstLoop,
formatLoopError,
fixToolCallPairing,
healLoadedMessages,
healLoadedMessagesByTokens,
stripHallucinatedToolMarkup,
} from "./loop.js";
export {
AT_MENTION_PATTERN,
AT_PICKER_PREFIX,
DEFAULT_AT_MENTION_MAX_BYTES,
DEFAULT_PICKER_IGNORE_DIRS,
detectAtPicker,
expandAtMentions,
listFilesSync,
listFilesWithStatsSync,
rankPickerCandidates,
} from "./at-mentions.js";
export type {
AtMentionExpansion,
AtMentionOptions,
FileWithStats,
ListFilesOptions,
PickerCandidate,
RankPickerOptions,
} from "./at-mentions.js";
export type {
CacheFirstLoopOptions,
LoopEvent,
@@ -72,6 +93,7 @@ export type {
} from "./tools/subagent.js";
export {
NeedsConfirmationError,
detectShellOperator,
formatCommandResult,
injectPowerShellUtf8,
isAllowed,
@@ -173,9 +195,11 @@ export { SseTransport } from "./mcp/sse.js";
export type { SseTransportOptions } from "./mcp/sse.js";
export {
DEFAULT_MAX_RESULT_CHARS,
DEFAULT_MAX_RESULT_TOKENS,
bridgeMcpTools,
flattenMcpResult,
truncateForModel,
truncateForModelByTokens,
} from "./mcp/registry.js";
export type { BridgeOptions, BridgeResult, FlattenOptions } from "./mcp/registry.js";
export { parseMcpSpec } from "./mcp/spec.js";
+306 -71
View File
@@ -13,7 +13,12 @@ import {
formatHookOutcomeMessage,
runHooks,
} from "./hooks.js";
import { DEFAULT_MAX_RESULT_CHARS, truncateForModel } from "./mcp/registry.js";
import {
DEFAULT_MAX_RESULT_CHARS,
DEFAULT_MAX_RESULT_TOKENS,
truncateForModel,
truncateForModelByTokens,
} from "./mcp/registry.js";
import { AppendOnlyLog, type ImmutablePrefix, VolatileScratch } from "./memory.js";
import { type RepairReport, ToolCallRepair } from "./repair/index.js";
import { appendSessionMessage, loadSessionMessages, rewriteSession } from "./session.js";
@@ -23,6 +28,7 @@ import {
SessionStats,
type TurnStats,
} from "./telemetry.js";
import { countTokens, estimateRequestTokens } from "./tokenizer.js";
import { ToolRegistry } from "./tools.js";
import type { ChatMessage, ToolCall } from "./types.js";
@@ -90,6 +96,19 @@ export interface LoopEvent {
toolArgs?: string;
/** Cumulative arguments-string length for `role === "tool_call_delta"`. */
toolCallArgsChars?: number;
/**
* Zero-based index of the tool call this delta belongs to. Surfaces
* multi-tool turns: on a response emitting 4 write_file calls the UI
* can show "building call 3/?" instead of a context-free spinner.
*/
toolCallIndex?: number;
/**
* Count of prior tool calls (this turn) whose arguments have finished
* streaming into valid JSON. Not all ready calls have been dispatched
* yet — dispatch still happens post-stream — but the user gets "2
* ready" progress feedback while later calls keep streaming.
*/
toolCallReadyCount?: number;
stats?: TurnStats;
planState?: TypedPlanState;
repair?: RepairReport;
@@ -267,9 +286,9 @@ export class CacheFirstLoop {
this.sessionName = opts.session ?? null;
if (this.sessionName) {
const prior = loadSessionMessages(this.sessionName);
const { messages, healedCount, healedFrom } = healLoadedMessages(
const { messages, healedCount, tokensSaved } = healLoadedMessagesByTokens(
prior,
DEFAULT_MAX_RESULT_CHARS,
DEFAULT_MAX_RESULT_TOKENS,
);
for (const msg of messages) this.log.append(msg);
this.resumedMessageCount = messages.length;
@@ -285,7 +304,7 @@ export class CacheFirstLoop {
/* disk full / perms — skip, in-memory heal still applies */
}
process.stderr.write(
`▸ session "${this.sessionName}": healed ${healedCount} entr${healedCount === 1 ? "y" : "ies"}${healedFrom > 0 ? ` (was ${healedFrom.toLocaleString()} chars oversized)` : " (dropped dangling tool_calls tail)"}. Rewrote session file.\n`,
`▸ session "${this.sessionName}": healed ${healedCount} entr${healedCount === 1 ? "y" : "ies"}${tokensSaved > 0 ? ` (shrunk ${tokensSaved.toLocaleString()} tokens of oversized tool output)` : " (dropped dangling tool_calls tail)"}. Rewrote session file.\n`,
);
}
} else {
@@ -295,28 +314,36 @@ export class CacheFirstLoop {
/**
* Shrink the log by re-truncating oversized tool results to a tighter
* cap, and persist the result back to disk so the next launch doesn't
* re-inherit a fat session file. Returns a summary the TUI can
* display.
* token cap, and persist the result back to disk so the next launch
* doesn't re-inherit a fat session file. Returns a summary the TUI
* can display.
*
* The cap is in DeepSeek V3 tokens (not chars) — so CJK text gets
* capped at the same effective context footprint as English instead
* of slipping past a char cap at 2× the token cost. Default 4000
* tokens, matching the token-aware dispatch cap from 0.5.2.
*
* Only tool-role messages are touched (same rationale as
* {@link healLoadedMessages}). User and assistant messages carry
* authored intent we can't mechanically shrink without losing
* meaning.
*/
compact(tightCapChars = 4000): { healedCount: number; charsSaved: number } {
compact(maxTokens = 4000): {
healedCount: number;
tokensSaved: number;
charsSaved: number;
} {
const before = this.log.toMessages();
// Use `shrinkOversizedToolResults` (not `healLoadedMessages`) — the
// full heal would also strip a dangling `assistant.tool_calls` tail,
// which during an active turn is legitimate state we still need
// (tools haven't been dispatched yet). Structural healing is only
// appropriate at session LOAD; mid-session `/compact` is strictly
// about shrinking oversized tool payloads.
const { messages, healedCount, healedFrom } = shrinkOversizedToolResults(before, tightCapChars);
const afterBytes = messages
.filter((m) => m.role === "tool")
.reduce((s, m) => s + (typeof m.content === "string" ? m.content.length : 0), 0);
const charsSaved = healedFrom - afterBytes;
// Use `shrinkOversizedToolResultsByTokens` (not `healLoadedMessages`)
// — the full heal would also strip a dangling `assistant.tool_calls`
// tail, which during an active turn is legitimate state we still
// need (tools haven't been dispatched yet). Structural healing is
// only appropriate at session LOAD; mid-session `/compact` is
// strictly about shrinking oversized tool payloads.
const { messages, healedCount, tokensSaved, charsSaved } = shrinkOversizedToolResultsByTokens(
before,
maxTokens,
);
if (healedCount > 0) {
this.log.compactInPlace(messages);
if (this.sessionName) {
@@ -327,10 +354,10 @@ export class CacheFirstLoop {
}
}
}
return { healedCount, charsSaved };
return { healedCount, tokensSaved, charsSaved };
}
private appendAndPersist(message: ChatMessage): void {
appendAndPersist(message: ChatMessage): void {
this.log.append(message);
if (this.sessionName) {
try {
@@ -511,7 +538,12 @@ export class CacheFirstLoop {
};
const stoppedMsg =
"[aborted by user (Esc) — no summary produced. Ask again or /retry when ready; prior tool output is still in the log.]";
this.appendAndPersist({ role: "assistant", content: stoppedMsg });
// Synthetic assistant turn — no real model output exists. For
// reasoner sessions R1 still demands `reasoning_content` on
// every assistant message, so we attach an empty-string
// placeholder to satisfy the validator without inventing
// reasoning we don't have. V3 gets a plain message as before.
this.appendAndPersist(this.syntheticAssistantMessage(stoppedMsg));
yield {
turn: this._turn,
role: "assistant_final",
@@ -548,7 +580,43 @@ export class CacheFirstLoop {
content: `${iter}/${this.maxToolIters} tool calls used — approaching budget. Press Esc to force a summary now.`,
};
}
const messages = this.buildMessages(pendingUser);
let messages = this.buildMessages(pendingUser);
// Preflight context check. Reactive auto-compact at 60%/80%
// keys off the PREVIOUS turn's server-reported prompt_tokens,
// so a single new oversized tool result (or a fresh resumed
// session) can push this turn's request straight past 131,072
// tokens before we ever see a usage number — DeepSeek 400s with
// "maximum context length". Here we estimate the outgoing
// payload locally and compact preemptively when it's in the red
// zone (>95% of the model's context window). One cheap
// tokenize-pass per iter, only on our side.
{
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
const estimate = estimateRequestTokens(messages, this.prefix.toolSpecs);
if (estimate / ctxMax > 0.95) {
const result = this.compact(1_000);
if (result.healedCount > 0) {
yield {
turn: this._turn,
role: "warning",
content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
(estimate / ctxMax) * 100,
)}%) — pre-compacted ${result.healedCount} tool result(s), saved ${result.tokensSaved.toLocaleString()} tokens. Sending.`,
};
// Rebuild with the compacted log so we send the smaller payload.
messages = this.buildMessages(pendingUser);
} else {
yield {
turn: this._turn,
role: "warning",
content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
(estimate / ctxMax) * 100,
)}%) and nothing to auto-compact — DeepSeek will likely 400. Run /forget or /clear to start fresh.`,
};
}
}
}
let assistantContent = "";
let reasoningContent = "";
@@ -650,6 +718,13 @@ export class CacheFirstLoop {
};
} else if (this.stream) {
const callBuf: Map<number, ToolCall> = new Map();
// Indices whose accumulated args have parsed as valid JSON at
// least once. Purely informational — we don't dispatch until
// the stream ends (that's the eager-dispatch feature we
// intentionally punted) but the UI shows "N ready" so the
// user sees progress on long multi-tool turns instead of a
// stagnant "building tool call" spinner.
const readyIndices = new Set<number>();
for await (const chunk of this.client.stream({
model: this.model,
messages,
@@ -685,6 +760,18 @@ export class CacheFirstLoop {
if (d.argumentsDelta)
cur.function.arguments = (cur.function.arguments ?? "") + d.argumentsDelta;
callBuf.set(d.index, cur);
// Mark this index "ready" once its args first parse as
// valid JSON. JSON.parse is sub-millisecond on typical
// tool-call payloads; skip the check once already ready.
if (
!readyIndices.has(d.index) &&
cur.function.name &&
looksLikeCompleteJson(cur.function.arguments ?? "")
) {
readyIndices.add(d.index);
}
// Skip the id-only opener: name is empty until the next chunk.
if (cur.function.name) {
yield {
@@ -693,6 +780,8 @@ export class CacheFirstLoop {
content: "",
toolName: cur.function.name,
toolCallArgsChars: (cur.function.arguments ?? "").length,
toolCallIndex: d.index,
toolCallReadyCount: readyIndices.size,
};
}
}
@@ -771,7 +860,9 @@ export class CacheFirstLoop {
assistantContent || null,
);
this.appendAndPersist(this.assistantMessage(assistantContent, repairedCalls));
this.appendAndPersist(
this.assistantMessage(assistantContent, repairedCalls, reasoningContent),
);
yield {
turn: this._turn,
@@ -826,19 +917,38 @@ export class CacheFirstLoop {
// tool_calls"). The summary is about what was LEARNED so far,
// not what we intended to do next.
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
// Proactive tier: between 60% and 80%, pre-shrink oversized tool
// results to a moderate cap (4k tokens) so the next iter doesn't
// slam straight into the 80% reactive path — which shrinks far
// more aggressively (1k tokens) and risks losing useful tail
// info. This catches the slow-growth pattern (lots of medium
// reads) before it compounds.
if (usage) {
const ratio = usage.promptTokens / ctxMax;
if (ratio > 0.6 && ratio <= 0.8) {
const before = usage.promptTokens;
const soft = this.compact(4_000);
if (soft.healedCount > 0) {
const after = Math.max(0, before - soft.tokensSaved);
yield {
turn: this._turn,
role: "warning",
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
ratio * 100,
)}%) — proactively compacted ${soft.healedCount} tool result(s) to 4k tokens, saved ${soft.tokensSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Staying ahead of the 80% guard.`,
};
}
}
}
if (usage && usage.promptTokens / ctxMax > 0.8) {
const before = usage.promptTokens;
const compactResult = this.compact(4000);
const compactResult = this.compact(1_000);
if (compactResult.healedCount > 0) {
// Rough estimate: 4 chars per token. Good enough to decide
// whether compaction pushed us back under the threshold; the
// exact number comes back from the NEXT API response's usage.
const approxSaved = Math.round(compactResult.charsSaved / 4);
const after = before - approxSaved;
const after = Math.max(0, before - compactResult.tokensSaved);
yield {
turn: this._turn,
role: "warning",
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} — auto-compacted ${compactResult.healedCount} oversized tool result(s), saved ~${approxSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Continuing.`,
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} — auto-compacted ${compactResult.healedCount} oversized tool result(s), saved ${compactResult.tokensSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Continuing.`,
};
// Intentionally don't re-check the threshold here: even if
// compaction didn't fully clear us under 80%, one more tool
@@ -921,7 +1031,10 @@ export class CacheFirstLoop {
).trim();
result = `[hook block] ${blocking?.hook.command ?? "<unknown>"}\n${reason}`;
} else {
result = await this.tools.dispatch(name, args, { signal });
result = await this.tools.dispatch(name, args, {
signal,
maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
});
// PostToolUse hooks — block is meaningless after the fact, so
// every non-pass outcome is a warning. Hooks here are the
@@ -1001,7 +1114,7 @@ export class CacheFirstLoop {
const reasonPrefix = reasonPrefixFor(opts.reason, this.maxToolIters);
const annotated = `${reasonPrefix}\n\n${summary}`;
const summaryStats = this.stats.record(this._turn, this.model, resp.usage ?? new Usage());
this.appendAndPersist({ role: "assistant", content: summary });
this.appendAndPersist(this.assistantMessage(summary, [], resp.reasoningContent ?? undefined));
yield {
turn: this._turn,
role: "assistant_final",
@@ -1032,9 +1145,41 @@ export class CacheFirstLoop {
return final;
}
private assistantMessage(content: string, toolCalls: ToolCall[]): ChatMessage {
private assistantMessage(
content: string,
toolCalls: ToolCall[],
reasoningContent?: string,
): ChatMessage {
const msg: ChatMessage = { role: "assistant", content };
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
// DeepSeek's "thinking mode must be passed back" rule applies to
// ANY R1-generated assistant message, not just the ones with
// tool_calls. 0.5.15 mis-scoped this: if the loop produced a
// plain-text R1 turn (e.g. "plan submitted — awaiting approval")
// between a tool-call turn and the next user turn, its reasoning
// was stripped, and the following request 400'd with the same
// error we already thought we'd fixed. deepseek-chat never emits
// reasoning_content, so V3 requests stay untouched (the field
// simply isn't present).
if (reasoningContent && reasoningContent.length > 0) {
msg.reasoning_content = reasoningContent;
}
return msg;
}
/**
* Build a synthetic assistant message we insert into the log without
* a real API round trip (abort notices, future system injections).
* Reasoner models reject follow-up requests whose assistant history
* is missing `reasoning_content`, so we stamp an empty-string
* placeholder on reasoner sessions to satisfy the validator. V3
* doesn't care — field stays absent there.
*/
private syntheticAssistantMessage(content: string): ChatMessage {
const msg: ChatMessage = { role: "assistant", content };
if (this.model.includes("reasoner")) {
msg.reasoning_content = "";
}
return msg;
}
}
@@ -1078,6 +1223,25 @@ function safeParseToolArgs(raw: string): unknown {
}
}
/**
* Cheap "is this accumulated tool-call arguments blob now a complete
* JSON value?" check. Used during streaming to mark a tool call as
* ready for UI progress feedback — not for dispatch gating. Empty /
* whitespace-only is not complete; anything that parses is.
*
* Exported so tests can lock down the precise shapes we consider
* "ready" vs "still streaming."
*/
export function looksLikeCompleteJson(s: string): boolean {
if (!s || !s.trim()) return false;
try {
JSON.parse(s);
return true;
} catch {
return false;
}
}
/** Format non-pass hook outcomes as `LoopEvent`s of role `warning`. */
function* hookWarnings(outcomes: HookOutcome[], turn: number): Generator<LoopEvent> {
for (const o of outcomes) {
@@ -1138,44 +1302,84 @@ export function shrinkOversizedToolResults(
return { messages: out, healedCount, healedFrom };
}
export function healLoadedMessages(
/**
* Token-aware variant of `shrinkOversizedToolResults`. Used by live
* `/compact` and auto-compact so the shrink cap bounds the REAL
* context footprint (CJK at 1 char/token would otherwise survive a
* char cap at 2× the intended token cost). Session-load heal still
* uses the char version for backward-compat on stored session files.
*
* Per-message token accounting: we tokenize each shrink candidate
* twice (before + after) so `tokensSaved` is exact. At typical log
* sizes (≤20 tool results) this is bounded; at pathological sizes
* the `truncateForModelByTokens` call internally never tokenizes the
* full input, so worst-case stays bounded too.
*/
export function shrinkOversizedToolResultsByTokens(
messages: ChatMessage[],
maxChars: number,
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
// Pass 1: shrink oversized tool results (original heal purpose).
const shrunk = shrinkOversizedToolResults(messages, maxChars);
let healedCount = shrunk.healedCount;
// Pass 2: enforce tool_calls ↔ tool pairing across the full log.
//
// DeepSeek rejects two shapes at the API boundary:
// (a) assistant with tool_calls not followed by matching tool
// responses ("insufficient tool messages following tool_calls")
// (b) tool message without a preceding assistant.tool_calls with
// the matching tool_call_id ("must be a response to a preceding
// message with 'tool_calls'")
//
// Corrupted session files from earlier builds have hit both. Rebuild
// the message stream so only well-formed (assistant.tool_calls + all
// matching responses) groups survive. Plain user/assistant messages
// (no tool_calls) always pass through.
maxTokens: number,
): {
messages: ChatMessage[];
healedCount: number;
tokensSaved: number;
charsSaved: number;
} {
let healedCount = 0;
let tokensSaved = 0;
let charsSaved = 0;
const out = messages.map((msg) => {
if (msg.role !== "tool") return msg;
const content = typeof msg.content === "string" ? msg.content : "";
// Fast path: length ≤ maxTokens ⇒ tokens ≤ maxTokens (every token
// is ≥1 char). Skip the per-message tokenize for small results.
if (content.length <= maxTokens) return msg;
const beforeTokens = countTokens(content);
if (beforeTokens <= maxTokens) return msg;
const truncated = truncateForModelByTokens(content, maxTokens);
const afterTokens = countTokens(truncated);
healedCount += 1;
tokensSaved += Math.max(0, beforeTokens - afterTokens);
charsSaved += Math.max(0, content.length - truncated.length);
return { ...msg, content: truncated };
});
return { messages: out, healedCount, tokensSaved, charsSaved };
}
/**
* Enforce tool_calls ↔ tool pairing across a message log. DeepSeek
* rejects two shapes at the API boundary:
* (a) assistant with tool_calls not followed by matching tool
* responses ("insufficient tool messages following tool_calls")
* (b) tool message without a preceding assistant.tool_calls with
* the matching tool_call_id ("must be a response to a preceding
* message with 'tool_calls'")
*
* Corrupted session files from earlier builds have hit both. This pass
* rebuilds the message stream so only well-formed (assistant.tool_calls
* + all matching responses) groups survive. Plain user/assistant/system
* messages (no tool_calls) always pass through.
*
* Exported so both char-based and token-based heal can compose it.
*/
export function fixToolCallPairing(messages: ChatMessage[]): {
messages: ChatMessage[];
droppedAssistantCalls: number;
droppedStrayTools: number;
} {
const out: ChatMessage[] = [];
const openCallIds = new Set<string>();
let droppedAssistantCalls = 0;
let droppedStrayTools = 0;
for (let i = 0; i < shrunk.messages.length; i++) {
const msg = shrunk.messages[i]!;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!;
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
// Look ahead for tool responses matching every id in this
// assistant's tool_calls. If all present (in any order, but
// contiguous after this message), include the whole group.
const needed = new Set<string>();
for (const call of msg.tool_calls) {
if (call?.id) needed.add(call.id);
}
const candidates: ChatMessage[] = [];
let j = i + 1;
while (j < shrunk.messages.length && needed.size > 0) {
const nxt = shrunk.messages[j]!;
while (j < messages.length && needed.size > 0) {
const nxt = messages[j]!;
if (nxt.role !== "tool") break;
const id = nxt.tool_call_id ?? "";
if (!needed.has(id)) break;
@@ -1184,13 +1388,10 @@ export function healLoadedMessages(
j++;
}
if (needed.size === 0) {
// Every call has a response — emit the whole group.
out.push(msg);
for (const r of candidates) out.push(r);
i = j - 1; // for-loop ++ will advance past the last response
i = j - 1;
} else {
// Drop the assistant entry and anything that was speculatively
// its responses — they'd become stray tool messages.
droppedAssistantCalls += 1;
droppedStrayTools += candidates.length;
i = j - 1;
@@ -1198,17 +1399,51 @@ export function healLoadedMessages(
continue;
}
if (msg.role === "tool") {
// Any tool message that reaches here did NOT get consumed by
// the assistant-with-tool_calls branch above, so it's stray.
// Drop it — surfacing it would 400 the next API call.
droppedStrayTools += 1;
continue;
}
// Plain user/assistant/system message — pass through.
out.push(msg);
}
healedCount += droppedAssistantCalls + droppedStrayTools;
return { messages: out, healedCount, healedFrom: shrunk.healedFrom };
return { messages: out, droppedAssistantCalls, droppedStrayTools };
}
export function healLoadedMessages(
messages: ChatMessage[],
maxChars: number,
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
const shrunk = shrinkOversizedToolResults(messages, maxChars);
const paired = fixToolCallPairing(shrunk.messages);
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
return { messages: paired.messages, healedCount, healedFrom: shrunk.healedFrom };
}
/**
* Token-aware counterpart of {@link healLoadedMessages}. Used at
* session-load time so resumed sessions come back capped at the same
* token budget (not char budget) as live tool results — CJK text no
* longer slips past at 2× the intended token cost when re-hydrated.
*
* Still does the same structural pass for tool_calls ↔ tool pairing;
* that logic is orthogonal to the truncation cap.
*/
export function healLoadedMessagesByTokens(
messages: ChatMessage[],
maxTokens: number,
): {
messages: ChatMessage[];
healedCount: number;
tokensSaved: number;
charsSaved: number;
} {
const shrunk = shrinkOversizedToolResultsByTokens(messages, maxTokens);
const paired = fixToolCallPairing(shrunk.messages);
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
return {
messages: paired.messages,
healedCount,
tokensSaved: shrunk.tokensSaved,
charsSaved: shrunk.charsSaved,
};
}
/**
+101
View File
@@ -7,6 +7,7 @@
* MCP ecosystem tool, wrapped in Reasonix's Pillar 1 + Pillar 3 benefits.
*/
import { countTokens } from "../tokenizer.js";
import { ToolRegistry } from "../tools.js";
import type { JSONSchema } from "../types.js";
import type { McpClient } from "./client.js";
@@ -58,6 +59,18 @@ export interface BridgeOptions {
*/
export const DEFAULT_MAX_RESULT_CHARS = 32_000;
/**
* Token-aware cap for tool results, in DeepSeek V3 tokens.
*
* 8,000 tokens ≈ 6% of DeepSeek V3's 131K context. One oversized tool
* result can't eat more than that no matter what character density the
* content has. The char cap (32K chars) only bounds tokens for English
* — CJK text at 1 char/token blows past 16K tokens under the same
* ceiling. With the tokenizer shipped in 0.5.0 we can cap the thing
* that actually matters.
*/
export const DEFAULT_MAX_RESULT_TOKENS = 8_000;
export interface BridgeResult {
registry: ToolRegistry;
/** Names actually registered (may differ from MCP names when a prefix is applied). */
@@ -159,6 +172,94 @@ export function truncateForModel(s: string, maxChars: number): string {
return `${head}\n\n[…truncated ${dropped} chars — raise BridgeOptions.maxResultChars, or call the tool with a narrower scope (filter, head, pagination)…]\n\n${tail}`;
}
/**
* Token-aware truncation. Same head+tail policy as `truncateForModel`,
* but sizes the slices against a DeepSeek V3 token budget instead of a
* raw character count — so CJK text (which previously survived at 2×
* the token cost per char) gets capped at the same effective context
* footprint as English.
*
* Strategy: fast path when `s.length <= maxTokens` (every token is ≥1
* char, so this bounds tokens ≤ maxTokens — skip tokenize entirely).
* Short-ish strings are confirmed against the real token count.
* Long strings go straight to char-sliced head+tail with one or two
* tokenize-verify-and-shrink rounds per slice — we deliberately never
* tokenize the full input, because pathological repetitive text
* (megabytes of `AAAA…`) can cost 30s+ on the pure-TS BPE port.
*/
export function truncateForModelByTokens(s: string, maxTokens: number): string {
if (maxTokens <= 0) return "";
// Every token is ≥1 char — if length ≤ budget, tokens ≤ budget.
if (s.length <= maxTokens) return s;
// Small enough to tokenize-check without pathological cost: confirm
// whether we're actually over budget. (Threshold is the char-bound
// worst case for English/code — ~4 chars/token.)
if (s.length <= maxTokens * 4) {
const tokens = countTokens(s);
if (tokens <= maxTokens) return s;
}
const markerOverhead = 48; // rough token cost of the truncation marker
const contentBudget = Math.max(0, maxTokens - markerOverhead);
const tailBudget = Math.min(256, Math.floor(contentBudget * 0.1));
const headBudget = Math.max(0, contentBudget - tailBudget);
const head = sizePrefixToTokens(s, headBudget);
const tail = sizeSuffixToTokens(s, tailBudget);
const droppedChars = s.length - head.length - tail.length;
// Estimate dropped tokens from the per-slice char/token ratio we
// already measured, rather than paying another full-string tokenize.
// The marker says "~N tokens" so the ≤10% slop is visible to readers.
const headTokens = head ? countTokens(head) : 0;
const tailTokens = tail ? countTokens(tail) : 0;
const sampleChars = head.length + tail.length;
const sampleTokens = headTokens + tailTokens;
const ratio = sampleChars > 0 ? sampleTokens / sampleChars : 0.3;
const estTotalTokens = Math.ceil(s.length * ratio);
const droppedTokens = Math.max(0, estTotalTokens - sampleTokens);
return `${head}\n\n[…truncated ~${droppedTokens} tokens (${droppedChars} chars) — raise BridgeOptions.maxResultTokens, or call the tool with a narrower scope (filter, head, pagination)…]\n\n${tail}`;
}
/**
* Slice `s` from the start to the largest prefix that fits `budget`
* tokens. Never tokenizes the full input: starts with a char-bound
* estimate, then verifies and shrinks. Converges in 1–2 iterations.
*/
function sizePrefixToTokens(s: string, budget: number): string {
if (budget <= 0 || s.length === 0) return "";
// Optimistic starting size: assume ~4 chars/token (English/code
// average). If the content is denser (CJK ~1 char/token), the first
// tokenize will show we're over and we shrink.
let size = Math.min(s.length, budget * 4);
for (let iter = 0; iter < 6; iter++) {
if (size <= 0) return "";
const slice = s.slice(0, size);
const count = countTokens(slice);
if (count <= budget) return slice;
// Shrink by the overshoot fraction plus a small safety margin.
const next = Math.floor(size * (budget / count) * 0.95);
if (next >= size) return s.slice(0, Math.max(0, size - 1));
size = next;
}
return s.slice(0, Math.max(0, size));
}
/** Slice `s` from the end to the largest suffix that fits `budget` tokens. */
function sizeSuffixToTokens(s: string, budget: number): string {
if (budget <= 0 || s.length === 0) return "";
let size = Math.min(s.length, budget * 4);
for (let iter = 0; iter < 6; iter++) {
if (size <= 0) return "";
const slice = s.slice(-size);
const count = countTokens(slice);
if (count <= budget) return slice;
const next = Math.floor(size * (budget / count) * 0.95);
if (next >= size) return s.slice(-Math.max(0, size - 1));
size = next;
}
return s.slice(-Math.max(0, size));
}
function blockToString(block: McpContentBlock): string {
if (block.type === "text") return block.text;
if (block.type === "image") return `[image ${block.mimeType}, ${block.data.length} chars base64]`;
+9
View File
@@ -107,6 +107,15 @@ export function deleteSession(name: string): boolean {
const path = sessionPath(name);
try {
unlinkSync(path);
// Best-effort cleanup of side-car files that belong to this session
// so `/forget` doesn't leave orphans in `sessionsDir()`. Currently
// just the pending-edits checkpoint (src/code/pending-edits.ts).
const sidecar = path.replace(/\.jsonl$/, ".pending.json");
try {
unlinkSync(sidecar);
} catch {
/* no sidecar present — expected for sessions without pending edits */
}
return true;
} catch {
return false;
+15 -3
View File
@@ -1,12 +1,24 @@
import type { Usage } from "./client.js";
/** USD per 1M tokens. Update as DeepSeek pricing changes. */
/**
* USD per 1M tokens. Source of truth is DeepSeek's CNY price sheet at
* https://api-docs.deepseek.com/zh-cn/quick_start/pricing (as of
* 2026-04-23): chat and reasoner are unified at ¥0.2 / ¥2 / ¥3 per 1M
* tokens (cache-hit input / cache-miss input / output). Converted at
* a fixed 7.2 CNY/USD rate so stats stay stable across the daily FX
* drift; revisit if the rate moves more than ±5%.
*
* Historical note: the pre-unification prices were chat $0.07/$0.27/$1.10
* and reasoner $0.14/$0.55/$2.19. Sessions logged under those values
* in `~/.reasonix/usage.jsonl` remain as-is (USD frozen at record time)
* — we never retroactively rewrite billing history.
*/
export const DEEPSEEK_PRICING: Record<
string,
{ inputCacheHit: number; inputCacheMiss: number; output: number }
> = {
"deepseek-chat": { inputCacheHit: 0.07, inputCacheMiss: 0.27, output: 1.1 },
"deepseek-reasoner": { inputCacheHit: 0.14, inputCacheMiss: 0.55, output: 2.19 },
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
};
/** Reference Claude Sonnet 4.6 pricing (USD per 1M tokens). */
+375
View File
@@ -0,0 +1,375 @@
/**
* Pure-TS port of DeepSeek's V3 tokenizer. Reads the slimmed+gzipped
* HuggingFace-format tokenizer data shipped at
* `data/deepseek-tokenizer.json.gz` and implements enough of the HF
* pipeline (Split pre-tokenizers → ByteLevel → BPE) to count tokens
* offline without pulling the `@lenml/tokenizers` or native Rust deps.
*
* Accuracy target: within ~3% of the API-returned `usage.prompt_tokens`
* for mixed CJK+English+code input. Exact-to-API match would also
* require replaying the Jinja chat template (role markers / tool call
* framing); we intentionally skip that — for a gauge/UI estimate the
* raw-text count is the useful number.
*
* Scope: ENCODE-SIDE ONLY. We don't need decode for counting; adding
* it later is trivial via the inverse byte-level table.
*/
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { gunzipSync } from "node:zlib";
interface AddedToken {
id: number;
content: string;
special: boolean;
normalized: boolean;
}
interface SplitPretokenizer {
type: "Split";
pattern: { Regex: string };
behavior: "Isolated" | "Removed" | string;
invert: boolean;
}
interface ByteLevelPretokenizer {
type: "ByteLevel";
add_prefix_space: boolean;
trim_offsets: boolean;
use_regex: boolean;
}
type Pretokenizer = SplitPretokenizer | ByteLevelPretokenizer;
interface TokenizerData {
added_tokens: AddedToken[];
pre_tokenizer: {
type: "Sequence";
pretokenizers: Pretokenizer[];
};
model: {
type: "BPE";
vocab: Record<string, number>;
merges: string[];
};
}
interface LoadedTokenizer {
vocab: Record<string, number>;
mergeRank: Map<string, number>;
splitRegexes: RegExp[];
byteToChar: string[];
/**
* Non-special added tokens (e.g. `<think>`, `<|fim▁hole|>`) that the
* HF tokenizer recognizes as atomic units when they appear inline in
* the text. Special tokens like `<|begin▁of▁sentence|>` are NOT in
* this list — the model ignores them when they appear in user text
* (the default HF `split_special_tokens=False` behavior), so we do
* the same: they get tokenized byte-by-byte, not as the special ID.
*/
addedPattern: RegExp | null;
addedMap: Map<string, number>;
}
/**
* GPT-2 byte-to-unicode mapping. Covers every byte 0..255 with a
* deterministic visible-printable unicode char — this is what lets the
* byte-level BPE show up in JSON vocabs as readable strings (`Ġ` for
* space, `Ċ` for `\n`, etc). Identical across every byte-level BPE
* tokenizer DeepSeek ships.
*/
function buildByteToChar(): string[] {
const result: string[] = new Array(256);
const bs: number[] = [];
for (let b = 33; b <= 126; b++) bs.push(b);
for (let b = 161; b <= 172; b++) bs.push(b);
for (let b = 174; b <= 255; b++) bs.push(b);
const cs = bs.slice();
let n = 0;
for (let b = 0; b < 256; b++) {
if (!bs.includes(b)) {
bs.push(b);
cs.push(256 + n);
n++;
}
}
for (let i = 0; i < bs.length; i++) {
result[bs[i]!] = String.fromCodePoint(cs[i]!);
}
return result;
}
let cached: LoadedTokenizer | null = null;
/**
* Find the bundled tokenizer data file. Resolution order (first hit wins):
* 1. `REASONIX_TOKENIZER_PATH` env var (tests / custom builds).
* 2. `../data/deepseek-tokenizer.json.gz` relative to this module —
* covers dev (src/tokenizer.ts → data/) AND production bundled at
* dist/index.js → dist/../data = data/.
* 3. `../../data/deepseek-tokenizer.json.gz` — covers the CLI bundle
* at dist/cli/index.js → dist/cli/../../data = data/. Without this
* step 2 resolves to dist/data/ which doesn't exist (the data file
* is shipped at package root per package.json `files` entry), and
* every step() preflight crashes the loop with ENOENT.
* 4. `require.resolve("reasonix/package.json")` — last-resort package
* root lookup for unusual bundlers.
*/
function resolveDataPath(): string {
if (process.env.REASONIX_TOKENIZER_PATH) return process.env.REASONIX_TOKENIZER_PATH;
const candidates: string[] = [];
try {
const here = dirname(fileURLToPath(import.meta.url));
candidates.push(join(here, "..", "data", "deepseek-tokenizer.json.gz"));
candidates.push(join(here, "..", "..", "data", "deepseek-tokenizer.json.gz"));
} catch {
/* import.meta.url unavailable — skip to the package resolution step. */
}
try {
const req = createRequire(import.meta.url);
candidates.push(
join(dirname(req.resolve("reasonix/package.json")), "data", "deepseek-tokenizer.json.gz"),
);
} catch {
/* Not installed as `reasonix/` — the earlier candidates still may hit. */
}
for (const p of candidates) {
if (existsSync(p)) return p;
}
// Nothing exists — return the first candidate anyway so readFileSync
// surfaces a concrete path in the ENOENT message (better than silent miss).
return candidates[0] ?? join(process.cwd(), "data", "deepseek-tokenizer.json.gz");
}
function loadTokenizer(): LoadedTokenizer {
if (cached) return cached;
const buf = readFileSync(resolveDataPath());
const json = gunzipSync(buf).toString("utf8");
const data = JSON.parse(json) as TokenizerData;
const mergeRank = new Map<string, number>();
for (let i = 0; i < data.model.merges.length; i++) {
mergeRank.set(data.model.merges[i]!, i);
}
const splitRegexes: RegExp[] = [];
for (const p of data.pre_tokenizer.pretokenizers) {
if (p.type === "Split") {
// All three Split rules use Isolated — matches become their own
// pre-tokens and so do the in-between stretches. The ByteLevel
// stage in the Sequence does no extra splitting here
// (use_regex:false), so our 3 Split regexes are the whole story.
splitRegexes.push(new RegExp(p.pattern.Regex, "gu"));
}
}
const addedMap = new Map<string, number>();
const addedContents: string[] = [];
for (const t of data.added_tokens) {
if (!t.special) {
addedMap.set(t.content, t.id);
addedContents.push(t.content);
}
}
// Longest-first ensures greedy matching doesn't lose a longer token
// to a shorter prefix (e.g. `<think>` before `<`).
addedContents.sort((a, b) => b.length - a.length);
const addedPattern = addedContents.length
? new RegExp(addedContents.map(escapeRegex).join("|"), "g")
: null;
cached = {
vocab: data.model.vocab,
mergeRank,
splitRegexes,
byteToChar: buildByteToChar(),
addedPattern,
addedMap,
};
return cached;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Apply one "Isolated" Split: the matches become their own pre-tokens,
* stretches between matches pass through unchanged. Empty pre-tokens
* are dropped so downstream stages don't see them.
*/
function applySplit(chunks: string[], re: RegExp): string[] {
const out: string[] = [];
for (const chunk of chunks) {
if (!chunk) continue;
// Reset lastIndex — reusing a /g regex across matchAll iterations
// is safe (matchAll internally advances), but across different
// input strings we want a clean start.
re.lastIndex = 0;
let last = 0;
for (const m of chunk.matchAll(re)) {
const idx = m.index ?? 0;
if (idx > last) out.push(chunk.slice(last, idx));
if (m[0].length > 0) out.push(m[0]);
last = idx + m[0].length;
}
if (last < chunk.length) out.push(chunk.slice(last));
}
return out;
}
/** UTF-8 bytes of `s`, each mapped to its byte-level visible char. */
function byteLevelEncode(s: string, byteToChar: string[]): string {
const bytes = new TextEncoder().encode(s);
let out = "";
for (let i = 0; i < bytes.length; i++) out += byteToChar[bytes[i]!];
return out;
}
/**
* Standard BPE merge loop. Starts from single byte-level chars and
* repeatedly applies the lowest-rank merge until none remain. O(n²)
* in chunk length, which is fine because chunks are ≤ a few hundred
* byte-level chars after pre-tokenization (and most are <50).
*/
function bpeEncode(piece: string, mergeRank: Map<string, number>): string[] {
if (piece.length <= 1) return piece ? [piece] : [];
let word: string[] = Array.from(piece);
while (true) {
let bestIdx = -1;
let bestRank = Number.POSITIVE_INFINITY;
for (let i = 0; i < word.length - 1; i++) {
const pair = `${word[i]} ${word[i + 1]}`;
const rank = mergeRank.get(pair);
if (rank !== undefined && rank < bestRank) {
bestRank = rank;
bestIdx = i;
if (rank === 0) break; // 0 is already the best possible
}
}
if (bestIdx < 0) break;
word = [
...word.slice(0, bestIdx),
word[bestIdx]! + word[bestIdx + 1]!,
...word.slice(bestIdx + 2),
];
if (word.length === 1) break;
}
return word;
}
/**
* Tokenize a UTF-8 string into DeepSeek token IDs. Mirrors the HF
* pipeline: (1) isolate non-special added tokens so they stay atomic,
* (2) for each in-between segment run the three Split regexes, (3)
* byte-level encode, (4) BPE merge, (5) look up vocab IDs.
*
* Not reentrancy-hazardous: loads a module-level singleton on first
* call and reuses it. Subsequent calls are pure compute.
*/
export function encode(text: string): number[] {
if (!text) return [];
const t = loadTokenizer();
const ids: number[] = [];
const process = (segment: string) => {
if (!segment) return;
let chunks: string[] = [segment];
for (const re of t.splitRegexes) chunks = applySplit(chunks, re);
for (const chunk of chunks) {
if (!chunk) continue;
const byteLevel = byteLevelEncode(chunk, t.byteToChar);
const pieces = bpeEncode(byteLevel, t.mergeRank);
for (const p of pieces) {
const id = t.vocab[p];
// If not in vocab we silently skip: shouldn't happen for
// byte-level BPE (every single byte has its own vocab entry),
// but if a future tokenizer update breaks that invariant we'd
// rather under-count than throw from a UI gauge.
if (id !== undefined) ids.push(id);
}
}
};
if (t.addedPattern) {
t.addedPattern.lastIndex = 0;
let last = 0;
for (const m of text.matchAll(t.addedPattern)) {
const idx = m.index ?? 0;
if (idx > last) process(text.slice(last, idx));
const id = t.addedMap.get(m[0]);
if (id !== undefined) ids.push(id);
last = idx + m[0].length;
}
if (last < text.length) process(text.slice(last));
} else {
process(text);
}
return ids;
}
/**
* Fast path for UI: we only need the count, not the IDs. Saves a
* per-call array allocation and lets callers batch large logs
* without holding the full ID stream.
*/
export function countTokens(text: string): number {
return encode(text).length;
}
/**
* Estimate the tokens a full conversation would cost. Sums raw-text
* counts for every message's content; we do NOT add overhead for the
* chat template's role markers (`<|User|>`, `<|Assistant|>`) because
* the exact framing varies between the chat/reasoner templates and
* DeepSeek applies them server-side anyway. Empirically adds ~3-6%
* to the real `prompt_tokens` — callers who care can post-multiply.
*/
export function estimateConversationTokens(
messages: Array<{ content?: string | null; tool_calls?: unknown }>,
): number {
let total = 0;
for (const m of messages) {
if (typeof m.content === "string" && m.content) {
total += countTokens(m.content);
}
// Tool-call arguments are serialized as JSON in the prompt by the
// chat template; their bytes WILL count upstream, so we count
// them too. Stringify-once is cheap relative to the tokenize.
if (m.tool_calls && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
total += countTokens(JSON.stringify(m.tool_calls));
}
}
return total;
}
/**
* Estimate the tokens a full DeepSeek request would cost: the
* conversation-side tokens (what `estimateConversationTokens` already
* counts) PLUS the serialized tool-spec payload. Tool specs ride in
* their own JSON blob in the request body, not folded into any
* message's `content`, so they need a separate count to land an
* accurate preflight estimate.
*
* Returned number matches what `/context` displays for "next request"
* — reuse this helper anywhere (preflight guard, diagnostics, UI) to
* keep the two values from drifting.
*/
export function estimateRequestTokens(
messages: Array<{ content?: string | null; tool_calls?: unknown }>,
toolSpecs?: ReadonlyArray<unknown> | null,
): number {
let total = estimateConversationTokens(messages);
if (toolSpecs && toolSpecs.length > 0) {
total += countTokens(JSON.stringify(toolSpecs));
}
return total;
}
/** Exposed for tests — resets the lazy-load singleton. */
export function _resetForTests(): void {
cached = null;
}
+20 -2
View File
@@ -1,3 +1,4 @@
import { truncateForModel, truncateForModelByTokens } from "./mcp/registry.js";
import { analyzeSchema, flattenSchema, nestArguments } from "./repair/flatten.js";
import type { JSONSchema, ToolSpec } from "./types.js";
@@ -128,7 +129,7 @@ export class ToolRegistry {
async dispatch(
name: string,
argumentsRaw: string | Record<string, unknown>,
opts: { signal?: AbortSignal } = {},
opts: { signal?: AbortSignal; maxResultChars?: number; maxResultTokens?: number } = {},
): Promise<string> {
const tool = this._tools.get(name);
if (!tool) {
@@ -168,7 +169,24 @@ export class ToolRegistry {
try {
const result = await tool.fn(args, { signal: opts.signal });
return typeof result === "string" ? result : JSON.stringify(result);
const str = typeof result === "string" ? result : JSON.stringify(result);
// Pre-clip at dispatch so a single fat result can't balloon the
// log (and disk session file) on its way in. Healing at load time
// still catches pre-existing oversize entries; this closes the
// door on new ones.
//
// Two caps available: `maxResultTokens` (preferred — bounds the
// real context footprint, so CJK doesn't slip past at 2× density)
// and `maxResultChars` (legacy). If both are set, apply both and
// the tighter one wins; char-only callers keep their old behavior.
let clipped = str;
if (opts.maxResultTokens !== undefined) {
clipped = truncateForModelByTokens(clipped, opts.maxResultTokens);
}
if (opts.maxResultChars !== undefined) {
clipped = truncateForModel(clipped, opts.maxResultChars);
}
return clipped;
} catch (err) {
const e = err as Error & { toToolResult?: () => unknown };
// Errors may opt into a richer tool-result shape by implementing
+72 -1
View File
@@ -171,6 +171,71 @@ export function tokenizeCommand(cmd: string): string[] {
return out;
}
/**
* Scan `cmd` for a shell operator (`|`, `||`, `>`, `>>`, `<`, `<<`,
* `&`, `&&`, `2>`, `2>>`, `2>&1`, `&>`) that appears unquoted at a
* token boundary. Returns the operator string, or null if none.
*
* Why this exists: `run_command` documents "no shell expansion, no
* pipes, no redirects" (the tool spawns argv directly, not through a
* shell), but when the model writes `dir | findstr foo` the `|`
* survives tokenization as a standalone token and gets quoted as the
* literal string `"|"` by `quoteForCmdExe` — cmd.exe sees it as an
* argument, not an operator, so the pipe silently fails. Detecting
* operators up front lets us throw a clear error ("split into separate
* calls") instead of letting the command run with surprising results.
*
* Quoted operators (`grep "a|b"`) and operator characters embedded in
* larger tokens (`--flag=1&2`) are NOT flagged — those are literal
* argv bytes and are safe to pass through.
*
* Exported for testing.
*/
export function detectShellOperator(cmd: string): string | null {
const opPrefix = /^(?:2>&1|&>|\|{1,2}|&{1,2}|2>{1,2}|>{1,2}|<{1,2})/;
let cur = "";
let curQuoted = false;
let quote: '"' | "'" | null = null;
const check = (): string | null => {
if (cur.length === 0 && !curQuoted) return null;
if (!curQuoted) {
const m = opPrefix.exec(cur);
if (m) return m[0] ?? null;
}
return null;
};
for (let i = 0; i < cmd.length; i++) {
const ch = cmd[i]!;
if (quote) {
if (ch === quote) {
quote = null;
} else if (ch === "\\" && quote === '"' && i + 1 < cmd.length) {
cur += cmd[++i];
curQuoted = true;
} else {
cur += ch;
curQuoted = true;
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
curQuoted = true;
continue;
}
if (ch === " " || ch === "\t") {
const op = check();
if (op) return op;
cur = "";
curQuoted = false;
continue;
}
cur += ch;
}
if (quote) return null; // let tokenizeCommand throw the unclosed-quote error
return check();
}
/**
* Return true when `cmd` matches an allowlisted prefix. Exported for
* testing. Match is on the space-normalized leading tokens so
@@ -205,6 +270,12 @@ export async function runCommand(
): Promise<RunCommandResult> {
const argv = tokenizeCommand(cmd);
if (argv.length === 0) throw new Error("run_command: empty command");
const operator = detectShellOperator(cmd);
if (operator !== null) {
throw new Error(
`run_command: shell operator "${operator}" is not supported — this tool spawns one process, no shell expansion. Split into separate run_command calls and combine the output in your reasoning (e.g. instead of \`grep foo *.ts | wc -l\`, call \`grep -c foo *.ts\` or two separate commands). To pass "${operator}" as a literal argument, wrap it in quotes.`,
);
}
const timeoutMs = (opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC) * 1000;
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
@@ -538,7 +609,7 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
command: {
type: "string",
description:
"Full command line. Tokenized with POSIX-ish quoting; no shell expansion, no pipes, no redirects.",
'Full command line. Tokenized with POSIX-ish quoting; no shell expansion. Pipes (`|`), redirects (`>`, `<`, `2>`), and `&&`/`||` chaining are rejected with an error — split into separate calls instead. To pass an operator character as a literal argument (e.g. a regex), wrap it in quotes: `grep "a|b" file.txt`.',
},
timeoutSec: {
type: "integer",
+51 -1
View File
@@ -41,7 +41,7 @@
* Cancellation still works via the AbortSignal.
*/
import type { DeepSeekClient } from "../client.js";
import { type DeepSeekClient, Usage } from "../client.js";
import { CacheFirstLoop } from "../loop.js";
import { ImmutablePrefix } from "../memory.js";
import { applyProjectMemory } from "../project-memory.js";
@@ -58,6 +58,10 @@ export interface SubagentEvent {
kind: "start" | "progress" | "end";
/** First ~30 chars of the task prompt — used for the TUI status row. */
task: string;
/** Skill that spawned this subagent, when applicable. Stamped on every event so the TUI/logger can attribute without extra plumbing. */
skillName?: string;
/** Model id the child loop ran on. Stamped alongside skillName. */
model?: string;
/** Iteration count inside the child loop (number of tool results so far). */
iter?: number;
/** Wall-clock ms since the subagent started. */
@@ -68,6 +72,10 @@ export interface SubagentEvent {
error?: string;
/** Total turns the subagent took. Set on `end`. */
turns?: number;
/** Total USD spent inside the child loop. Set on `end`. */
costUsd?: number;
/** Aggregated child-loop Usage (sum across turns). Set on `end`. */
usage?: Usage;
}
/**
@@ -119,6 +127,12 @@ export interface SpawnSubagentOptions {
* that don't care about cancellation.
*/
parentSignal?: AbortSignal;
/**
* Skill name when this spawn is driven by a `runAs: subagent` skill.
* Used purely for downstream attribution (TUI summary line, usage log).
* Omit for raw `spawn_subagent` tool calls.
*/
skillName?: string;
}
/**
@@ -140,6 +154,12 @@ export interface SubagentResult {
elapsedMs: number;
/** USD spent in the child loop, summed across its turns. */
costUsd: number;
/** Model id the child loop ran on. */
model: string;
/** Skill name if the spawn was driven by a `runAs: subagent` skill. */
skillName?: string;
/** Aggregated Usage across the child loop's turns. Zero-filled when no API calls landed. */
usage: Usage;
}
export interface SubagentToolOptions {
@@ -207,12 +227,15 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
const maxToolIters = opts.maxToolIters ?? DEFAULT_MAX_ITERS;
const maxResultChars = opts.maxResultChars ?? DEFAULT_MAX_RESULT_CHARS;
const sink = opts.sink;
const skillName = opts.skillName;
const startedAt = Date.now();
const taskPreview = opts.task.length > 30 ? `${opts.task.slice(0, 30)}…` : opts.task;
sink?.current?.({
kind: "start",
task: taskPreview,
skillName,
model,
iter: 0,
elapsedMs: 0,
});
@@ -245,6 +268,8 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
sink?.current?.({
kind: "progress",
task: taskPreview,
skillName,
model,
iter: toolIter,
elapsedMs: Date.now() - startedAt,
});
@@ -276,6 +301,7 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
const elapsedMs = Date.now() - startedAt;
const turns = childLoop.stats.turns.length;
const costUsd = childLoop.stats.totalCost;
const usage = aggregateChildUsage(childLoop);
const truncated =
final.length > maxResultChars
@@ -285,11 +311,15 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
sink?.current?.({
kind: "end",
task: taskPreview,
skillName,
model,
iter: toolIter,
elapsedMs,
summary: errorMessage ? undefined : truncated.slice(0, 120),
error: errorMessage,
turns,
costUsd,
usage,
});
return {
@@ -300,9 +330,29 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
toolIters: toolIter,
elapsedMs,
costUsd,
model,
skillName,
usage,
};
}
/**
* Sum the child loop's per-turn `Usage` into one aggregate record.
* Zero-filled when the loop made no API calls (e.g. failed before the
* first request) so downstream consumers always see a valid shape.
*/
function aggregateChildUsage(loop: CacheFirstLoop): Usage {
const agg = new Usage();
for (const t of loop.stats.turns) {
agg.promptTokens += t.usage.promptTokens;
agg.completionTokens += t.usage.completionTokens;
agg.totalTokens += t.usage.totalTokens;
agg.promptCacheHitTokens += t.usage.promptCacheHitTokens;
agg.promptCacheMissTokens += t.usage.promptCacheMissTokens;
}
return agg;
}
/**
* Serialize a {@link SubagentResult} into the JSON tool-result shape
* the model consumes. Both the spawn_subagent tool and the run_skill
+8
View File
@@ -36,6 +36,14 @@ export interface ChatMessage {
name?: string;
tool_call_id?: string;
tool_calls?: ToolCall[];
/**
* R1 `reasoning_content` captured from the assistant's thinking turn.
* DeepSeek's thinking mode 400s with "reasoning_content in the
* thinking mode must be passed back" when a tool-loop continuation
* omits it from the preceding assistant message. Round-tripped for
* deepseek-reasoner turns with tool_calls; absent for deepseek-chat.
*/
reasoning_content?: string | null;
}
export interface RawUsage {
+66
View File
@@ -52,6 +52,22 @@ export interface UsageRecord {
costUsd: number;
/** What the same turn would have cost at Claude Sonnet 4.6 rates. */
claudeEquivUsd: number;
/**
* Distinguishes ordinary parent-loop turns from subagent summary rows.
* Absent on pre-0.5.14 records — treat as "turn" when missing.
*/
kind?: "turn" | "subagent";
/** Present when `kind === "subagent"`. Attribution metadata for the /stats roll-up. */
subagent?: {
/** Skill that spawned it, when the spawn came from a `runAs: subagent` skill. */
skillName?: string;
/** First ~60 chars of the task prompt — enough context to recognize a run, never the full text. */
taskPreview: string;
/** Tool calls the child loop dispatched before returning. */
toolIters: number;
/** Wall-clock ms. */
durationMs: number;
};
}
/** Where the log lives. Tests override via `opts.path`. */
@@ -67,6 +83,9 @@ export interface AppendUsageInput {
now?: number;
/** Override the log path (tests). */
path?: string;
/** When appending a subagent summary row, set `kind: "subagent"` and populate `subagent`. */
kind?: "turn" | "subagent";
subagent?: UsageRecord["subagent"];
}
/**
@@ -89,6 +108,8 @@ export function appendUsage(input: AppendUsageInput): UsageRecord {
costUsd: costUsd(input.model, input.usage),
claudeEquivUsd: claudeEquivalentCost(input.usage),
};
if (input.kind === "subagent") record.kind = "subagent";
if (input.subagent) record.subagent = input.subagent;
const path = input.path ?? defaultUsageLogPath();
try {
@@ -206,6 +227,21 @@ export interface UsageAggregate {
firstSeen: number | null;
/** Latest record's ts, or `null` when the log is empty. */
lastSeen: number | null;
/**
* Subagent-specific rollup. Undefined when no subagent records exist
* in the log so consumers can cheaply skip the section. Counts reflect
* subagent SPAWNS (not internal child-loop turns) — one row per run.
*/
subagents?: SubagentAggregate;
}
/** Rolled-up view of all `kind: "subagent"` records. */
export interface SubagentAggregate {
total: number;
costUsd: number;
totalDurationMs: number;
/** Per-skill breakdown. Records without `skillName` (raw spawn_subagent calls) group under `"(adhoc)"`. */
bySkill: Array<{ skillName: string; count: number; costUsd: number; durationMs: number }>;
}
/**
@@ -232,6 +268,10 @@ export function aggregateUsage(
const sessionCounts = new Map<string, number>();
let firstSeen: number | null = null;
let lastSeen: number | null = null;
const skillCounts = new Map<string, { count: number; costUsd: number; durationMs: number }>();
let subagentTotal = 0;
let subagentCost = 0;
let subagentDuration = 0;
for (const r of records) {
addToBucket(all, r);
@@ -245,6 +285,19 @@ export function aggregateUsage(
if (firstSeen === null || r.ts < firstSeen) firstSeen = r.ts;
if (lastSeen === null || r.ts > lastSeen) lastSeen = r.ts;
if (r.kind === "subagent") {
subagentTotal += 1;
subagentCost += r.costUsd;
const dur = r.subagent?.durationMs ?? 0;
subagentDuration += dur;
const key = r.subagent?.skillName?.trim() || "(adhoc)";
const prev = skillCounts.get(key) ?? { count: 0, costUsd: 0, durationMs: 0 };
prev.count += 1;
prev.costUsd += r.costUsd;
prev.durationMs += dur;
skillCounts.set(key, prev);
}
}
const byModel = Array.from(modelCounts.entries())
@@ -254,12 +307,25 @@ export function aggregateUsage(
.map(([session, turns]) => ({ session, turns }))
.sort((a, b) => b.turns - a.turns);
const subagents: SubagentAggregate | undefined =
subagentTotal > 0
? {
total: subagentTotal,
costUsd: subagentCost,
totalDurationMs: subagentDuration,
bySkill: Array.from(skillCounts.entries())
.map(([skillName, v]) => ({ skillName, ...v }))
.sort((a, b) => b.count - a.count),
}
: undefined;
return {
buckets: [today, week, month, all],
byModel,
bySession,
firstSeen,
lastSeen,
subagents,
};
}
+372
View File
@@ -0,0 +1,372 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
AT_MENTION_PATTERN,
AT_PICKER_PREFIX,
DEFAULT_AT_MENTION_MAX_BYTES,
DEFAULT_PICKER_IGNORE_DIRS,
detectAtPicker,
expandAtMentions,
listFilesSync,
rankPickerCandidates,
} from "../src/at-mentions.js";
describe("AT_MENTION_PATTERN", () => {
it("matches @path at start of string", () => {
const matches = [...".".matchAll(AT_MENTION_PATTERN)];
expect(matches).toHaveLength(0);
const m2 = [..."@src/loop.ts".matchAll(AT_MENTION_PATTERN)];
expect(m2).toHaveLength(1);
expect(m2[0]![1]).toBe("src/loop.ts");
});
it("matches @path after whitespace", () => {
const m = [..."look at @src/loop.ts please".matchAll(AT_MENTION_PATTERN)];
expect(m).toHaveLength(1);
expect(m[0]![1]).toBe("src/loop.ts");
});
it("does NOT match @ embedded in a word (email, social handle)", () => {
const m1 = [..."email [email protected]".matchAll(AT_MENTION_PATTERN)];
expect(m1).toHaveLength(0);
const m2 = [..."foo@bar".matchAll(AT_MENTION_PATTERN)];
expect(m2).toHaveLength(0);
});
it("matches multiple @paths in one string", () => {
const m = [..."compare @a.ts and @b.ts".matchAll(AT_MENTION_PATTERN)];
expect(m).toHaveLength(2);
expect(m[0]![1]).toBe("a.ts");
expect(m[1]![1]).toBe("b.ts");
});
});
describe("expandAtMentions", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "reasonix-at-mentions-"));
mkdirSync(join(root, "src"), { recursive: true });
writeFileSync(join(root, "src", "loop.ts"), "export const x = 1;\n");
writeFileSync(join(root, "notes.md"), "# Notes\nhello\n");
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it("returns text unchanged when there are no mentions", () => {
const r = expandAtMentions("plain prompt with no mentions", root);
expect(r.text).toBe("plain prompt with no mentions");
expect(r.expansions).toEqual([]);
});
it("inlines an existing file under a `Referenced files` block", () => {
const r = expandAtMentions("look at @src/loop.ts", root);
expect(r.expansions).toHaveLength(1);
expect(r.expansions[0]!.ok).toBe(true);
expect(r.expansions[0]!.path).toBe("src/loop.ts");
expect(r.text).toContain("look at @src/loop.ts");
expect(r.text).toContain("[Referenced files]");
expect(r.text).toContain('<file path="src/loop.ts">');
expect(r.text).toContain("export const x = 1;");
expect(r.text).toContain("</file>");
});
it("de-duplicates repeated mentions of the same file", () => {
const r = expandAtMentions("compare @src/loop.ts with @src/loop.ts", root);
expect(r.expansions).toHaveLength(1);
// Only one file block in the output.
const fileBlocks = r.text.match(/<file path="/g) ?? [];
expect(fileBlocks).toHaveLength(1);
});
it("expands multiple different files in the same prompt", () => {
const r = expandAtMentions("read @src/loop.ts and @notes.md", root);
expect(r.expansions).toHaveLength(2);
expect(r.expansions.every((ex) => ex.ok)).toBe(true);
expect(r.text).toContain('<file path="src/loop.ts">');
expect(r.text).toContain('<file path="notes.md">');
});
it("marks missing files as skipped with a reason", () => {
const r = expandAtMentions("look at @src/does-not-exist.ts", root);
expect(r.expansions).toHaveLength(1);
expect(r.expansions[0]!.ok).toBe(false);
expect(r.expansions[0]!.skip).toBe("missing");
expect(r.text).toContain('skipped="missing"');
});
it("rejects paths that escape the root directory", () => {
const r = expandAtMentions("peek at @../../../etc/passwd", root);
expect(r.expansions).toHaveLength(1);
expect(r.expansions[0]!.skip).toBe("escape");
expect(r.text).not.toContain("passwd content");
});
it("rejects absolute paths", () => {
const r = expandAtMentions("look at @/etc/hosts", root);
expect(r.expansions).toHaveLength(1);
expect(r.expansions[0]!.skip).toBe("escape");
});
it("skips files larger than maxBytes", () => {
const big = join(root, "big.log");
writeFileSync(big, "x".repeat(1000));
const r = expandAtMentions("inspect @big.log", root, { maxBytes: 500 });
expect(r.expansions).toHaveLength(1);
expect(r.expansions[0]!.ok).toBe(false);
expect(r.expansions[0]!.skip).toBe("too-large");
expect(r.expansions[0]!.bytes).toBe(1000);
expect(r.text).toContain('skipped="too-large"');
});
it("strips a trailing sentence-terminator dot from the path", () => {
// `@src/loop.ts.` — the trailing `.` is a sentence period, not
// part of the filename. The mention should resolve src/loop.ts.
const r = expandAtMentions("look at @src/loop.ts.", root);
expect(r.expansions).toHaveLength(1);
expect(r.expansions[0]!.ok).toBe(true);
expect(r.expansions[0]!.path).toBe("src/loop.ts");
});
it("default max bytes is 64KB", () => {
expect(DEFAULT_AT_MENTION_MAX_BYTES).toBe(64 * 1024);
});
it("marks a directory mention as not-file", () => {
const r = expandAtMentions("look at @src", root);
expect(r.expansions).toHaveLength(1);
expect(r.expansions[0]!.ok).toBe(false);
expect(r.expansions[0]!.skip).toBe("not-file");
});
});
describe("detectAtPicker", () => {
it("fires when the buffer ends with `@`", () => {
const r = detectAtPicker("look at @");
expect(r).not.toBeNull();
expect(r!.query).toBe("");
// `@` is at offset 8 (after "look at ").
expect(r!.atOffset).toBe(8);
});
it("captures the partial query after `@`", () => {
const r = detectAtPicker("edit @src/lo");
expect(r).not.toBeNull();
expect(r!.query).toBe("src/lo");
expect(r!.atOffset).toBe(5);
});
it("does NOT fire when @ is embedded in a word", () => {
expect(detectAtPicker("[email protected]")).toBeNull();
});
it("does NOT fire when the buffer ends with a space after the mention", () => {
// Trailing space closes the picker — the user's done picking.
expect(detectAtPicker("@src/loop.ts ")).toBeNull();
});
it("does NOT fire when there's no @ at all", () => {
expect(detectAtPicker("just a normal message")).toBeNull();
});
it("fires at start of string", () => {
const r = detectAtPicker("@sr");
expect(r).not.toBeNull();
expect(r!.query).toBe("sr");
expect(r!.atOffset).toBe(0);
});
});
describe("AT_PICKER_PREFIX vs AT_MENTION_PATTERN (sanity)", () => {
it("picker captures empty partial", () => {
const m = AT_PICKER_PREFIX.exec("hi @");
expect(m).not.toBeNull();
expect(m![1]).toBe("");
});
it("expansion pattern requires a non-empty path", () => {
// Completed mentions for expandAtMentions need at least one char.
const m = [..."hi @".matchAll(AT_MENTION_PATTERN)];
expect(m).toHaveLength(0);
});
});
describe("rankPickerCandidates", () => {
const files = [
"src/loop.ts",
"src/at-mentions.ts",
"src/tokenizer.ts",
"src/cli/ui/App.tsx",
"src/cli/ui/PromptInput.tsx",
"tests/loop.test.ts",
"tests/at-mentions.test.ts",
"README.md",
];
it("returns the first `limit` entries when query is empty", () => {
const r = rankPickerCandidates(files, "", 3);
expect(r).toHaveLength(3);
expect(r).toEqual(files.slice(0, 3));
});
it("filters by substring match (case-insensitive)", () => {
const r = rankPickerCandidates(files, "LOOP");
expect(r).toContain("src/loop.ts");
expect(r).toContain("tests/loop.test.ts");
expect(r).not.toContain("README.md");
});
it("ranks basename-prefix matches above substring matches", () => {
// `ment` appears in "at-mentions" (both src and tests). Basenames
// are "at-mentions.ts" and "at-mentions.test.ts" — both start
// with `at-m` not `ment`. Not a basename-prefix hit; both should
// score the same (substring).
const r = rankPickerCandidates(files, "at-m");
// `at-m` is a basename prefix for both at-mentions files:
expect(r[0]).toMatch(/at-mentions/);
expect(r[1]).toMatch(/at-mentions/);
});
it("ranks path-prefix above substring when basename doesn't match", () => {
// `tests/` is a path prefix (not basename). Both tests/* hit.
const r = rankPickerCandidates(files, "tests/");
expect(r[0]).toMatch(/^tests\//);
});
it("returns empty array when nothing matches", () => {
const r = rankPickerCandidates(files, "zzznomatch");
expect(r).toEqual([]);
});
it("respects the limit parameter", () => {
const r = rankPickerCandidates(files, "s", 2);
expect(r).toHaveLength(2);
});
it("sorts by mtime descending when entries carry FileWithStats and query is empty", () => {
const entries = [
{ path: "a.ts", mtimeMs: 100 },
{ path: "b.ts", mtimeMs: 300 },
{ path: "c.ts", mtimeMs: 200 },
];
const r = rankPickerCandidates(entries, "", 5);
// Newest (b, mtime 300) → middle (c, 200) → oldest (a, 100).
expect(r).toEqual(["b.ts", "c.ts", "a.ts"]);
});
it("recently-used paths float to the top on empty query regardless of mtime", () => {
const entries = [
{ path: "a.ts", mtimeMs: 300 },
{ path: "b.ts", mtimeMs: 100 },
{ path: "c.ts", mtimeMs: 200 },
];
const r = rankPickerCandidates(entries, "", {
limit: 5,
recentlyUsed: ["c.ts"],
});
// Recently-used c.ts comes first even though a.ts has a newer mtime.
expect(r[0]).toBe("c.ts");
// Remaining sorted by mtime descending.
expect(r[1]).toBe("a.ts");
expect(r[2]).toBe("b.ts");
});
it("tie-breaks query matches by recently-used, then mtime", () => {
const entries = [
{ path: "src/alpha.ts", mtimeMs: 100 },
{ path: "src/alpha2.ts", mtimeMs: 500 }, // newer
];
const r = rankPickerCandidates(entries, "alpha", { limit: 5 });
// Both match with the same score (basename prefix, same hit
// position) — mtime tiebreak puts alpha2 first.
expect(r[0]).toBe("src/alpha2.ts");
expect(r[1]).toBe("src/alpha.ts");
// Now with recency: older alpha.ts boosted over newer alpha2.ts.
const r2 = rankPickerCandidates(entries, "alpha", {
limit: 5,
recentlyUsed: ["src/alpha.ts"],
});
expect(r2[0]).toBe("src/alpha.ts");
expect(r2[1]).toBe("src/alpha2.ts");
});
it("preserves input order on empty query when no mtime + no recency signal", () => {
// Back-compat: bare string input behaves as before.
const r = rankPickerCandidates(files, "", 3);
expect(r).toEqual(files.slice(0, 3));
});
it("accepts a number literal as the third arg for limit (back-compat)", () => {
const r = rankPickerCandidates(files, "loop", 1);
expect(r).toHaveLength(1);
});
});
describe("listFilesSync", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "reasonix-listfiles-"));
mkdirSync(join(root, "src"), { recursive: true });
mkdirSync(join(root, "src", "cli"), { recursive: true });
mkdirSync(join(root, "node_modules", "foo"), { recursive: true });
mkdirSync(join(root, ".git", "objects"), { recursive: true });
writeFileSync(join(root, "package.json"), "{}");
writeFileSync(join(root, "README.md"), "# hi");
writeFileSync(join(root, ".gitignore"), "dist/");
writeFileSync(join(root, "src", "index.ts"), "");
writeFileSync(join(root, "src", "cli", "app.ts"), "");
writeFileSync(join(root, "node_modules", "foo", "index.js"), "");
writeFileSync(join(root, ".git", "objects", "abc"), "");
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it("returns files recursively, with forward-slash separators", () => {
const files = listFilesSync(root);
expect(files).toContain("package.json");
expect(files).toContain("README.md");
expect(files).toContain("src/index.ts");
expect(files).toContain("src/cli/app.ts");
// All entries use forward slashes even on Windows.
for (const f of files) {
expect(f).not.toContain("\\");
}
});
it("skips ignored directories by default", () => {
const files = listFilesSync(root);
expect(files.every((f) => !f.includes("node_modules"))).toBe(true);
expect(files.every((f) => !f.includes(".git/"))).toBe(true);
});
it("includes dotfiles at the top level (e.g. .gitignore)", () => {
const files = listFilesSync(root);
expect(files).toContain(".gitignore");
});
it("respects custom ignoreDirs", () => {
const files = listFilesSync(root, { ignoreDirs: ["src"] });
expect(files.every((f) => !f.startsWith("src/"))).toBe(true);
expect(files).toContain("package.json");
});
it("caps the result count at maxResults", () => {
const files = listFilesSync(root, { maxResults: 2 });
expect(files.length).toBeLessThanOrEqual(2);
});
it("returns an empty list for an unreadable root (falls through)", () => {
const files = listFilesSync(join(root, "does-not-exist"));
expect(files).toEqual([]);
});
it("exposes the default ignore list", () => {
expect(DEFAULT_PICKER_IGNORE_DIRS).toContain("node_modules");
expect(DEFAULT_PICKER_IGNORE_DIRS).toContain(".git");
expect(DEFAULT_PICKER_IGNORE_DIRS).toContain("dist");
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { detectBangCommand, formatBangUserMessage } from "../src/cli/ui/bang.js";
describe("detectBangCommand", () => {
it("returns the command body for a `!`-prefixed input", () => {
expect(detectBangCommand("!ls src/")).toBe("ls src/");
expect(detectBangCommand("!git status")).toBe("git status");
});
it("trims whitespace after the bang", () => {
expect(detectBangCommand("! ls")).toBe("ls");
expect(detectBangCommand("!ls ")).toBe("ls");
});
it("returns null for non-bang input", () => {
expect(detectBangCommand("ls src/")).toBeNull();
expect(detectBangCommand("hello world")).toBeNull();
expect(detectBangCommand("/help")).toBeNull();
expect(detectBangCommand("")).toBeNull();
});
it("returns null for `!` alone (no command body)", () => {
expect(detectBangCommand("!")).toBeNull();
expect(detectBangCommand("! ")).toBeNull();
});
it("does NOT trigger when `!` appears mid-string", () => {
// Only leading `!` counts. Otherwise commands like `cat foo!bar`
// would be incorrectly intercepted.
expect(detectBangCommand("echo !hello")).toBeNull();
expect(detectBangCommand("what is ! for?")).toBeNull();
});
it("accepts a command with embedded `!` (not leading)", () => {
// The bang is at position 0; the trailing ! in `echo hi!` is
// part of the command body and passes through intact.
expect(detectBangCommand("!echo hi!")).toBe("echo hi!");
});
it("handles multi-word commands with flags and quotes", () => {
expect(detectBangCommand('!grep -R "foo bar" src/')).toBe('grep -R "foo bar" src/');
});
});
describe("formatBangUserMessage", () => {
it("prepends a `[!cmd]` header so the model can distinguish bang runs from its own tool output", () => {
const out = formatBangUserMessage("ls", "$ ls\n[exit 0]\nfile1 file2");
expect(out.startsWith("[!ls]\n")).toBe(true);
expect(out).toContain("$ ls");
expect(out).toContain("[exit 0]");
expect(out).toContain("file1 file2");
});
it("preserves the output verbatim after the header", () => {
const rawOutput = "line1\nline2\n\n[…truncated…]";
const formatted = formatBangUserMessage("cmd", rawOutput);
expect(formatted).toBe(`[!cmd]\n${rawOutput}`);
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Post-build smoke test for the bundled `dist/`.
*
* Unit tests load modules from `src/`, which means path-resolution
* bugs that only appear AFTER tsup bundles everything into a different
* directory layout never surface in the regular test suite. v0.5.4
* shipped to npm with exactly such a bug — `src/tokenizer.ts` at
* `dist/cli/index.js` tried to read the tokenizer data file relative
* to the bundled entry, ended up at `dist/data/...` instead of the
* package-root `data/...`, and every `reasonix run` crashed on first
* turn with ENOENT.
*
* These tests spawn the bundled output and verify the two bundle
* layouts (`dist/index.js` library + `dist/cli/index.js` CLI) can
* both locate the tokenizer data file. They're skipped when the
* build hasn't run yet (CI always runs `npm run build` first).
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
const LIB_BUNDLE = resolve("dist/index.js");
const CLI_BUNDLE = resolve("dist/cli/index.js");
describe("bundled dist — tokenizer path resolution", () => {
const libExists = existsSync(LIB_BUNDLE);
const cliExists = existsSync(CLI_BUNDLE);
(libExists ? it : it.skip)(
"dist/index.js resolves the tokenizer data file at package-root data/",
() => {
// truncateForModelByTokens internally calls countTokens when the
// input exceeds the fast-path threshold, which forces the
// tokenizer's lazy data-file load. If resolveDataPath() lands on
// a non-existent path (the 0.5.4 regression) this crashes with
// ENOENT and the spawned process exits non-zero.
// ESM dynamic imports on Windows require `file://` URLs, not bare
// absolute paths (which Node's ESM loader rejects as an unknown
// protocol). pathToFileURL handles the cross-platform form.
const libUrl = pathToFileURL(LIB_BUNDLE).href;
const result = spawnSync(
"node",
[
"--input-type=module",
"-e",
`import { truncateForModelByTokens } from "${libUrl}";
const s = "hello world ".repeat(500);
const out = truncateForModelByTokens(s, 100);
console.log(JSON.stringify({ ok: true, len: out.length }));`,
],
{ encoding: "utf8", timeout: 30_000 },
);
expect(result.status).toBe(0);
expect(result.stderr).not.toMatch(/deepseek-tokenizer\.json\.gz/);
expect(result.stderr).not.toMatch(/ENOENT/);
expect(result.stdout).toMatch(/"ok":true/);
},
);
(cliExists ? it : it.skip)("dist/cli/index.js loads tokenizer before the first API fetch", () => {
// Spawn the CLI pointed at a bogus local address that fails fetch
// fast. In step(), preflight's estimateRequestTokens runs BEFORE
// client.chat — so if the bundled layout can't find the
// tokenizer data, we see ENOENT in stderr even though the fetch
// never happens. If tokenizer loads fine, we see a connection
// error instead (and that's OK — we're not testing the network
// path, only that the tokenizer path resolution works from
// dist/cli/).
const result = spawnSync("node", [CLI_BUNDLE, "run", "--no-config", "hi"], {
encoding: "utf8",
timeout: 10_000,
env: {
...process.env,
DEEPSEEK_API_KEY: "sk-smoke-test-bogus",
// Fail-fast fetch target: the :1 port is almost never open,
// so we get connection-refused within ~1ms instead of the
// client's 120s timeout waiting on api.deepseek.com.
DEEPSEEK_BASE_URL: "http://127.0.0.1:1",
},
});
const combined = `${result.stdout}\n${result.stderr}`;
// The crucial assertion: bundle must not crash on the tokenizer
// path. Connection errors to 127.0.0.1:1 are expected and fine.
expect(combined).not.toMatch(/deepseek-tokenizer\.json\.gz/);
// Also not a missing-module style ENOENT (network errors are
// ECONNREFUSED or fetch failure, never ENOENT).
expect(combined).not.toMatch(/ENOENT.*tokenizer/i);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { DeepSeekClient } from "../src/client.js";
function makeFetch(status: number, body: unknown) {
return vi.fn(
async () =>
new Response(typeof body === "string" ? body : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
) as unknown as typeof fetch;
}
describe("DeepSeekClient.listModels", () => {
it("parses the OpenAI-style model list", async () => {
const client = new DeepSeekClient({
apiKey: "sk-test",
fetch: makeFetch(200, {
object: "list",
data: [
{ id: "deepseek-chat", object: "model", owned_by: "deepseek" },
{ id: "deepseek-reasoner", object: "model", owned_by: "deepseek" },
],
}),
});
const list = await client.listModels();
expect(list).not.toBeNull();
expect(list!.data.map((m) => m.id)).toEqual(["deepseek-chat", "deepseek-reasoner"]);
});
it("returns null on non-2xx (bad key / offline)", async () => {
const client = new DeepSeekClient({
apiKey: "sk-bad",
fetch: makeFetch(401, { error: "unauthorized" }),
});
expect(await client.listModels()).toBeNull();
});
it("returns null on malformed payload", async () => {
const client = new DeepSeekClient({
apiKey: "sk-test",
fetch: makeFetch(200, { whatever: "not a list" }),
});
expect(await client.listModels()).toBeNull();
});
it("returns null when fetch throws (network error)", async () => {
const client = new DeepSeekClient({
apiKey: "sk-test",
fetch: vi.fn(async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch,
});
expect(await client.listModels()).toBeNull();
});
it("sends the bearer token header", async () => {
const spy = vi.fn(
async () =>
new Response(JSON.stringify({ object: "list", data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const client = new DeepSeekClient({
apiKey: "sk-xyz",
fetch: spy as unknown as typeof fetch,
});
await client.listModels();
const [, init] = spy.mock.calls[0]!;
expect((init as RequestInit).method).toBe("GET");
const headers = (init as RequestInit).headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer sk-xyz");
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { shrinkOversizedToolResultsByTokens } from "../src/loop.js";
import { countTokens } from "../src/tokenizer.js";
import type { ChatMessage } from "../src/types.js";
describe("shrinkOversizedToolResultsByTokens", () => {
it("leaves small tool messages alone", () => {
const msgs: ChatMessage[] = [
{ role: "user", content: "hi" },
{ role: "tool", tool_call_id: "t1", content: "short" },
];
const r = shrinkOversizedToolResultsByTokens(msgs, 1000);
expect(r.healedCount).toBe(0);
expect(r.tokensSaved).toBe(0);
expect(r.messages).toEqual(msgs);
});
it("shrinks tool messages that exceed the token budget", () => {
const huge = "some event detail line with words\n".repeat(1000);
const msgs: ChatMessage[] = [
{ role: "user", content: "do stuff" },
{ role: "tool", tool_call_id: "t1", content: huge },
];
const r = shrinkOversizedToolResultsByTokens(msgs, 500);
expect(r.healedCount).toBe(1);
expect(r.tokensSaved).toBeGreaterThan(0);
expect(r.charsSaved).toBeGreaterThan(0);
const toolMsg = r.messages.find((m) => m.role === "tool");
const shrunk = typeof toolMsg?.content === "string" ? toolMsg.content : "";
// Final token count stays reasonably near the cap (plus marker
// overhead from truncateForModelByTokens).
expect(countTokens(shrunk)).toBeLessThanOrEqual(600);
});
it("never mutates the input array", () => {
const big = "line of text with content\n".repeat(800);
const msgs: ChatMessage[] = [{ role: "tool", tool_call_id: "t1", content: big }];
const original = msgs[0]!.content;
shrinkOversizedToolResultsByTokens(msgs, 200);
expect(msgs[0]!.content).toBe(original);
});
it("does not touch user or assistant messages even when long", () => {
const bigUser = "user-intent prose ".repeat(2000);
const msgs: ChatMessage[] = [
{ role: "user", content: bigUser },
{ role: "assistant", content: "ok" },
];
const r = shrinkOversizedToolResultsByTokens(msgs, 100);
expect(r.healedCount).toBe(0);
expect(r.messages[0]!.content).toBe(bigUser);
});
it("caps CJK tool results at the same token budget as English", () => {
// Under the old char cap, CJK text slipped through at ~2× the
// intended token cost. With a token cap, both must converge.
const cjk = "错误:步骤执行失败需要复查\n".repeat(1000);
const msgs: ChatMessage[] = [{ role: "tool", tool_call_id: "t1", content: cjk }];
const r = shrinkOversizedToolResultsByTokens(msgs, 500);
expect(r.healedCount).toBe(1);
const shrunk = typeof r.messages[0]!.content === "string" ? r.messages[0]!.content : "";
expect(countTokens(shrunk)).toBeLessThanOrEqual(600);
});
it("fast-pathes tool messages whose content length is already below the budget", () => {
// Every token is ≥1 char, so length <= maxTokens implies tokens
// <= maxTokens — no tokenize call needed, message untouched.
const content = "x".repeat(50);
const msgs: ChatMessage[] = [{ role: "tool", tool_call_id: "t1", content }];
const r = shrinkOversizedToolResultsByTokens(msgs, 100);
expect(r.healedCount).toBe(0);
expect(r.messages[0]!.content).toBe(content);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { formatAllBlockDiffs, formatEditBlockDiff } from "../src/code/diff-preview.js";
import type { EditBlock } from "../src/code/edit-blocks.js";
function block(path: string, search: string, replace: string): EditBlock {
return { path, search, replace, offset: 0 };
}
describe("formatEditBlockDiff", () => {
it("shows `+` lines only for a new-file edit (empty search)", () => {
const b = block("src/new.ts", "", "export const x = 1;\nexport const y = 2;");
const out = formatEditBlockDiff(b);
expect(out.every((l) => /^\s+\+/.test(l))).toBe(true);
expect(out.join("\n")).toContain("+ export const x = 1;");
expect(out.join("\n")).toContain("+ export const y = 2;");
});
it("collapses shared leading + trailing lines into context", () => {
const b = block(
"src/foo.ts",
"function foo() {\n const x = 1;\n return x;\n}",
"function foo() {\n const x = 2;\n return x;\n}",
);
const out = formatEditBlockDiff(b);
const joined = out.join("\n");
// Context lines (prefixed with two spaces) for unchanged parts.
expect(joined).toContain(" function foo() {");
expect(joined).toContain(" return x;");
expect(joined).toContain(" }");
// The diverging middle shows as `-`/`+`.
expect(joined).toContain("- const x = 1;");
expect(joined).toContain("+ const x = 2;");
});
it("truncates diff to maxLines with a footer note", () => {
// 30 different lines — no shared prefix/suffix so they all show.
const search = Array.from({ length: 30 }, (_, i) => `old ${i}`).join("\n");
const replace = Array.from({ length: 30 }, (_, i) => `new ${i}`).join("\n");
const b = block("src/big.ts", search, replace);
const out = formatEditBlockDiff(b, { maxLines: 10 });
expect(out.length).toBeLessThanOrEqual(10);
expect(out[out.length - 1]).toMatch(/more diff lines/);
});
it("notes hidden leading context lines when prefix > contextLines", () => {
const pre = Array.from({ length: 10 }, (_, i) => `pre ${i}`).join("\n");
const b = block("src/a.ts", `${pre}\nOLD`, `${pre}\nNEW`);
const out = formatEditBlockDiff(b, { contextLines: 2 });
const joined = out.join("\n");
// Leading context should be collapsed — we keep 2 visible and
// note the rest as hidden.
expect(joined).toMatch(/8 unchanged lines? above/);
});
it("notes hidden trailing context lines when suffix > contextLines", () => {
const post = Array.from({ length: 10 }, (_, i) => `post ${i}`).join("\n");
const b = block("src/a.ts", `OLD\n${post}`, `NEW\n${post}`);
const out = formatEditBlockDiff(b, { contextLines: 2 });
const joined = out.join("\n");
expect(joined).toMatch(/8 unchanged lines? below/);
});
it("handles single-line edits cleanly", () => {
const b = block("one.ts", "old", "new");
const out = formatEditBlockDiff(b);
expect(out).toContain(" - old");
expect(out).toContain(" + new");
});
it("respects custom indent", () => {
const b = block("x.ts", "a", "b");
const out = formatEditBlockDiff(b, { indent: ">> " });
expect(out.some((l) => l.startsWith(">> - a"))).toBe(true);
expect(out.some((l) => l.startsWith(">> + b"))).toBe(true);
});
});
describe("formatAllBlockDiffs", () => {
it("emits a path+count header before each block's diff", () => {
const blocks = [block("a.ts", "x", "y"), block("b.ts", "", "new content")];
const out = formatAllBlockDiffs(blocks);
const joined = out.join("\n");
expect(joined).toMatch(/ {4}a\.ts {2}\(-1 \+1 lines\)/);
expect(joined).toMatch(/NEW b\.ts {2}\(-0 \+1 lines\)/);
});
it("separates blocks with a blank line", () => {
const blocks = [block("a.ts", "x", "y"), block("b.ts", "p", "q")];
const out = formatAllBlockDiffs(blocks);
// Blank line between first block's diff and second block's header.
const blankIdx = out.indexOf("");
expect(blankIdx).toBeGreaterThan(0);
});
it("returns an empty array for zero blocks", () => {
expect(formatAllBlockDiffs([])).toEqual([]);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* R1 thinking-mode contract: when a reasoner turn returns both
* `reasoning_content` and `tool_calls`, the assistant message we
* persist + send on the NEXT request (the tool-loop continuation)
* must carry `reasoning_content` back. Otherwise DeepSeek 400s with
* "The reasoning_content in the thinking mode must be passed back to
* the API." Reproduces the bug that surfaced in 0.5.14 live usage.
*/
import { describe, expect, it, vi } from "vitest";
import { DeepSeekClient } from "../src/client.js";
import { CacheFirstLoop } from "../src/loop.js";
import { ImmutablePrefix } from "../src/memory.js";
import { ToolRegistry } from "../src/tools.js";
import type { ChatMessage } from "../src/types.js";
interface FakeResponseShape {
content?: string;
reasoning_content?: string;
tool_calls?: any[];
usage?: Record<string, number>;
}
function capturingFetch(responses: FakeResponseShape[]): {
fetch: typeof fetch;
bodies: Array<{ messages: ChatMessage[] }>;
} {
const bodies: Array<{ messages: ChatMessage[] }> = [];
let i = 0;
const fn = vi.fn(async (_url: any, init: any) => {
const body = init?.body ? JSON.parse(init.body) : {};
bodies.push({ messages: body.messages });
const resp = responses[i++] ?? responses[responses.length - 1]!;
return new Response(
JSON.stringify({
choices: [
{
index: 0,
message: {
role: "assistant",
content: resp.content ?? "",
reasoning_content: resp.reasoning_content ?? null,
tool_calls: resp.tool_calls ?? undefined,
},
finish_reason: resp.tool_calls ? "tool_calls" : "stop",
},
],
usage: resp.usage ?? {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 100,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
return { fetch: fn, bodies };
}
describe("R1 reasoning_content round-trip", () => {
it("preserves reasoning_content on the assistant message when the turn has tool_calls", async () => {
const tools = new ToolRegistry();
tools.register({
name: "noop",
readOnly: true,
fn: () => "ok",
});
const { fetch: fakeFetch, bodies } = capturingFetch([
{
// Turn 1: model emits reasoning + tool call.
content: "",
reasoning_content: "I should call noop to check something.",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "noop", arguments: "{}" },
},
],
},
{
// Turn 2: plain text wrap-up after the tool result comes back.
content: "done",
},
]);
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
tools,
model: "deepseek-reasoner",
stream: false,
});
for await (const _ev of loop.step("please noop")) {
/* drain */
}
expect(bodies.length).toBe(2);
// Turn 2's request messages include the turn-1 assistant message;
// find it and verify reasoning_content landed.
const turn2Messages = bodies[1]!.messages;
const assistantWithCalls = turn2Messages.find(
(m) => m.role === "assistant" && (m.tool_calls?.length ?? 0) > 0,
);
expect(assistantWithCalls).toBeDefined();
expect(assistantWithCalls?.reasoning_content).toBe("I should call noop to check something.");
});
it("also preserves reasoning_content on plain-text reasoner turns", async () => {
// 0.5.18 regression: R1 requires reasoning_content on ANY
// assistant message the model produced in thinking mode, not just
// ones with tool_calls. 0.5.15 scoped the fix too narrowly and a
// plan-approval flow (submit_plan → "plan submitted" plain-text
// turn → approval) kept 400ing on the follow-up request.
const { fetch: fakeFetch, bodies } = capturingFetch([
{
content: "a plain answer",
reasoning_content: "reasoning attached to a plain-text turn",
},
{ content: "follow-up" },
]);
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s" }),
model: "deepseek-reasoner",
stream: false,
});
for await (const _ev of loop.step("hello")) {
/* drain */
}
for await (const _ev of loop.step("next")) {
/* drain */
}
const turn2Messages = bodies[1]!.messages;
const assistant = turn2Messages.find((m) => m.role === "assistant");
expect(assistant).toBeDefined();
expect(assistant?.reasoning_content).toBe("reasoning attached to a plain-text turn");
});
});
+99 -1
View File
@@ -470,11 +470,13 @@ describe("CacheFirstLoop (non-streaming)", () => {
maxToolIters: 64,
});
// Put a big tool message into the log so compact has work to do.
// Realistic log-line content (not "Z".repeat(N)) to avoid the
// tokenizer's BPE O(n²) pathological path on pure-repeat inputs.
loop.log.append({ role: "user", content: "do stuff" });
loop.log.append({
role: "tool",
tool_call_id: "prior",
content: "Z".repeat(60_000),
content: "ERROR: something went wrong in module X at step Y\n".repeat(2000),
});
const events: { role: string; forcedSummary?: boolean; content?: string }[] = [];
@@ -494,6 +496,102 @@ describe("CacheFirstLoop (non-streaming)", () => {
expect(last!.forcedSummary).toBeFalsy();
});
it("proactively compacts oversized tool results between 60%-80% of the context window", async () => {
const reg = new ToolRegistry();
reg.register({
name: "probe",
description: "no-op",
parameters: { type: "object", properties: {} },
fn: async () => "ok",
});
const responses: FakeResponseShape[] = [
// 131k * 0.72 ≈ 94,320 tokens — squarely in the proactive band.
{
content: "",
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
usage: {
prompt_tokens: 95_000,
completion_tokens: 10,
total_tokens: 95_010,
prompt_cache_hit_tokens: 70_000,
prompt_cache_miss_tokens: 25_000,
},
},
{ content: "done." },
];
const client = makeClient(responses);
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
tools: reg,
stream: false,
maxToolIters: 64,
});
// Seed an oversized tool result (>4k tokens, the proactive cap)
// so proactive compact has work to do. Using realistic log-line
// content to avoid the tokenizer's BPE O(n²) pathological path.
loop.log.append({ role: "user", content: "prior" });
loop.log.append({
role: "tool",
tool_call_id: "prior",
content: "INFO: step completed with some trailing detail\n".repeat(1500),
});
const events: { role: string; content?: string; forcedSummary?: boolean }[] = [];
for await (const ev of loop.step("continue")) {
events.push({ role: ev.role, content: ev.content, forcedSummary: ev.forcedSummary });
}
const proactive = events.find(
(e) => e.role === "warning" && /proactively compacted/.test(e.content ?? ""),
);
expect(proactive).toBeDefined();
expect(proactive!.content).toMatch(/4k tokens/);
// 60%-80% band must NOT force a summary (that's the 80% reactive path).
const finals = events.filter((e) => e.role === "assistant_final");
expect(finals[finals.length - 1]!.forcedSummary).toBeFalsy();
});
it("pre-clips new tool results at dispatch so they never enter the log oversized", async () => {
const reg = new ToolRegistry();
// Tool returns ~50k chars of realistic-shape log text; the default
// token budget (8k) bounds the resulting log entry to a small
// fraction of the raw size. (Using "A".repeat(N) would hit the
// tokenizer's BPE O(n²) path for repeated single-char inputs —
// pathological enough to slow the suite by tens of seconds, and
// not representative of real tool output.)
const huge = "ERROR: repeated failure with some detail\n".repeat(1250);
reg.register({
name: "big",
description: "returns a lot",
parameters: { type: "object", properties: {} },
fn: async () => huge,
});
const responses: FakeResponseShape[] = [
{
content: "",
tool_calls: [{ id: "c1", type: "function", function: { name: "big", arguments: "{}" } }],
},
{ content: "summarized." },
];
const client = makeClient(responses);
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
tools: reg,
stream: false,
});
for await (const _ev of loop.step("go")) {
/* drain */
}
const toolEntry = loop.log.toMessages().find((m) => m.role === "tool");
expect(toolEntry).toBeDefined();
const content = typeof toolEntry!.content === "string" ? toolEntry!.content : "";
// Well under the raw 50k — pre-clip fired before append.
expect(content.length).toBeLessThan(40_000);
expect(content).toMatch(/truncated/);
});
it("buildMessages strips a dangling assistant-with-tool_calls tail — defensive against 'insufficient tool messages' 400", async () => {
// Craft a log where the last entry is an assistant message with
// tool_calls but no matching tool responses. This is the shape
+15
View File
@@ -504,6 +504,21 @@ describe("citation links — validateCitation + collectCitations", () => {
if (!result.ok) expect(result.reason).toBe("file not found");
});
it("leading `/` is treated as project-rooted, not filesystem-absolute", () => {
// Models habitually write `/foo.ts` meaning "project root" (Aider /
// Claude-Code style). Without the strip this would be resolved
// against POSIX `/` or the Windows drive root and always fail —
// that was the symptom in 0.5.16: a real file at the project root
// rendered as "file not found" because the leading slash sent the
// validator to the wrong place.
expect(validateCitation("/real.ts", tmp)).toEqual({ ok: true });
expect(validateCitation("/real.ts:2", tmp)).toEqual({ ok: true });
});
it("leading `\\` is treated the same way (Windows-style)", () => {
expect(validateCitation("\\real.ts", tmp)).toEqual({ ok: true });
});
it("collectCitations validates every unique citation, skips externals", () => {
const text = [
"See [foo](real.ts:2) for details.",
+299
View File
@@ -0,0 +1,299 @@
import { describe, expect, it, vi } from "vitest";
import type { DisplayEvent } from "../src/cli/ui/EventLog.js";
import {
findServerForPrompt,
findServerForResource,
formatPromptList,
formatPromptMessages,
formatResourceContents,
formatResourceList,
handleMcpBrowseSlash,
} from "../src/cli/ui/mcp-browse.js";
import type { McpServerSummary } from "../src/cli/ui/slash.js";
import type { McpClient } from "../src/mcp/client.js";
function server(partial: Partial<McpServerSummary> & { label: string }): McpServerSummary {
return {
spec: partial.spec ?? `fake://${partial.label}`,
toolCount: partial.toolCount ?? 0,
report: partial.report ?? {
protocolVersion: "2024-11-05",
serverInfo: { name: partial.label, version: "1.0" },
capabilities: {},
tools: { supported: true, items: [] },
resources: { supported: true, items: [] },
prompts: { supported: true, items: [] },
},
...partial,
};
}
describe("formatResourceList", () => {
it("returns a polite message when no server exposes any resources", () => {
expect(formatResourceList([])).toMatch(/No resources/);
});
it("groups URIs by server label, shows name + mime when present", () => {
const out = formatResourceList([
server({
label: "fs",
report: {
protocolVersion: "2024-11-05",
serverInfo: { name: "fs", version: "1" },
capabilities: {},
tools: { supported: true, items: [] },
prompts: { supported: false, reason: "" },
resources: {
supported: true,
items: [{ uri: "file:///a.md", name: "a.md", mimeType: "text/markdown" }],
},
},
}),
]);
expect(out).toContain("[fs] 1 resource(s):");
expect(out).toContain("file:///a.md");
expect(out).toContain("a.md");
expect(out).toContain("text/markdown");
});
});
describe("formatPromptList", () => {
it("returns a polite message when no server exposes any prompts", () => {
expect(formatPromptList([])).toMatch(/No prompts/);
});
it("shows argument requirement markers (*/?)", () => {
const out = formatPromptList([
server({
label: "p",
report: {
protocolVersion: "2024-11-05",
serverInfo: { name: "p", version: "1" },
capabilities: {},
tools: { supported: true, items: [] },
resources: { supported: false, reason: "" },
prompts: {
supported: true,
items: [
{
name: "summarize",
description: "one-line summary",
arguments: [
{ name: "topic", required: true },
{ name: "length", required: false },
],
},
],
},
},
}),
]);
expect(out).toContain("summarize");
expect(out).toContain("topic*");
expect(out).toContain("length?");
expect(out).toContain("one-line summary");
});
});
describe("findServerForResource", () => {
it("returns the server that claims the URI", () => {
const a = server({
label: "a",
report: {
protocolVersion: "",
serverInfo: { name: "a", version: "" },
capabilities: {},
tools: { supported: true, items: [] },
prompts: { supported: false, reason: "" },
resources: { supported: true, items: [{ uri: "x://1", name: "x1" }] },
},
});
const b = server({
label: "b",
report: {
protocolVersion: "",
serverInfo: { name: "b", version: "" },
capabilities: {},
tools: { supported: true, items: [] },
prompts: { supported: false, reason: "" },
resources: { supported: true, items: [{ uri: "y://2", name: "y2" }] },
},
});
expect(findServerForResource([a, b], "y://2")).toBe(b);
expect(findServerForResource([a, b], "nope://")).toBeNull();
});
it("skips servers that don't support resources", () => {
const a = server({
label: "a",
report: {
protocolVersion: "",
serverInfo: { name: "a", version: "" },
capabilities: {},
tools: { supported: true, items: [] },
prompts: { supported: false, reason: "" },
resources: { supported: false, reason: "-32601" },
},
});
expect(findServerForResource([a], "x://any")).toBeNull();
});
});
describe("findServerForPrompt", () => {
it("returns the server that claims the prompt name", () => {
const a = server({
label: "a",
report: {
protocolVersion: "",
serverInfo: { name: "a", version: "" },
capabilities: {},
tools: { supported: true, items: [] },
resources: { supported: false, reason: "" },
prompts: { supported: true, items: [{ name: "summarize" }] },
},
});
expect(findServerForPrompt([a], "summarize")).toBe(a);
expect(findServerForPrompt([a], "missing")).toBeNull();
});
});
describe("formatResourceContents", () => {
it("renders a text block verbatim", () => {
const out = formatResourceContents("x://1", {
contents: [{ uri: "x://1", mimeType: "text/plain", text: "hello world" }],
});
expect(out).toContain("hello world");
expect(out).toContain("text/plain");
});
it("truncates extremely large text blocks with a notice", () => {
const big = "A".repeat(12_000);
const out = formatResourceContents("x://1", {
contents: [{ uri: "x://1", text: big }],
});
expect(out).toContain("truncated");
expect(out.length).toBeLessThan(big.length);
});
it("summarizes binary blobs rather than rendering them", () => {
const out = formatResourceContents("x://1", {
contents: [{ uri: "x://1", mimeType: "image/png", blob: "AAAA" }],
});
expect(out).toContain("binary");
expect(out).toContain("bytes");
});
});
describe("formatPromptMessages", () => {
it("renders role + text content for each message", () => {
const out = formatPromptMessages("summarize", {
description: "one-line summary",
messages: [
{ role: "user", content: { type: "text", text: "Summarize X." } as any },
{ role: "assistant", content: { type: "text", text: "X is …" } as any },
],
});
expect(out).toContain("summarize");
expect(out).toContain("one-line summary");
expect(out).toContain("user");
expect(out).toContain("assistant");
expect(out).toContain("Summarize X.");
expect(out).toContain("X is …");
});
});
describe("handleMcpBrowseSlash", () => {
function makeSetter() {
const rows: DisplayEvent[] = [];
const setter = (updater: (prev: DisplayEvent[]) => DisplayEvent[]) => {
const next = updater(rows.slice());
rows.splice(0, rows.length, ...next);
};
return { rows, setter };
}
it("list mode: no arg writes a single info row with the list text", async () => {
const { rows, setter } = makeSetter();
await handleMcpBrowseSlash("resource", "", [], setter);
expect(rows).toHaveLength(1);
expect(rows[0]?.role).toBe("info");
expect(rows[0]?.text).toMatch(/No resources/);
});
it("read mode: unknown URI emits a warning row", async () => {
const { rows, setter } = makeSetter();
await handleMcpBrowseSlash("resource", "mystery://1", [], setter);
expect(rows[0]?.role).toBe("warning");
expect(rows[0]?.text).toContain("no server exposes resource");
});
it("read mode: calls client.readResource and emits the formatted contents", async () => {
const { rows, setter } = makeSetter();
const readResource = vi.fn(async () => ({
contents: [{ uri: "x://1", text: "hello" }],
}));
const fake: McpServerSummary = server({
label: "a",
report: {
protocolVersion: "",
serverInfo: { name: "a", version: "" },
capabilities: {},
tools: { supported: true, items: [] },
prompts: { supported: false, reason: "" },
resources: { supported: true, items: [{ uri: "x://1", name: "x1" }] },
},
client: { readResource } as unknown as McpClient,
});
await handleMcpBrowseSlash("resource", "x://1", [fake], setter);
expect(readResource).toHaveBeenCalledWith("x://1");
expect(rows[0]?.role).toBe("info");
expect(rows[0]?.text).toContain("hello");
});
it("read mode: surfaces readResource rejection as a warning row", async () => {
const { rows, setter } = makeSetter();
const fake: McpServerSummary = server({
label: "a",
report: {
protocolVersion: "",
serverInfo: { name: "a", version: "" },
capabilities: {},
tools: { supported: true, items: [] },
prompts: { supported: false, reason: "" },
resources: { supported: true, items: [{ uri: "x://1", name: "x1" }] },
},
client: {
readResource: vi.fn(async () => {
throw new Error("server returned -32002 not-found");
}),
} as unknown as McpClient,
});
await handleMcpBrowseSlash("resource", "x://1", [fake], setter);
expect(rows[0]?.role).toBe("warning");
expect(rows[0]?.text).toContain("-32002");
});
it("prompt mode: calls client.getPrompt and emits messages", async () => {
const { rows, setter } = makeSetter();
const getPrompt = vi.fn(async () => ({
description: "hi",
messages: [{ role: "user", content: { type: "text", text: "Hello." } as any }],
}));
const fake: McpServerSummary = server({
label: "a",
report: {
protocolVersion: "",
serverInfo: { name: "a", version: "" },
capabilities: {},
tools: { supported: true, items: [] },
resources: { supported: false, reason: "" },
prompts: { supported: true, items: [{ name: "greet" }] },
},
client: { getPrompt } as unknown as McpClient,
});
await handleMcpBrowseSlash("prompt", "greet", [fake], setter);
expect(getPrompt).toHaveBeenCalledWith("greet");
expect(rows[0]?.role).toBe("info");
expect(rows[0]?.text).toContain("Hello.");
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PASTE_CHAR_THRESHOLD,
DEFAULT_PASTE_HEAD_LINES,
DEFAULT_PASTE_LINE_THRESHOLD,
formatLongPaste,
} from "../src/cli/ui/paste-collapse.js";
describe("formatLongPaste", () => {
it("passes through short input verbatim", () => {
const r = formatLongPaste("hello world");
expect(r.collapsed).toBe(false);
expect(r.displayText).toBe("hello world");
expect(r.originalChars).toBe(11);
expect(r.originalLines).toBe(1);
});
it("passes through a multi-line but small-ish input", () => {
const r = formatLongPaste("line 1\nline 2\nline 3");
expect(r.collapsed).toBe(false);
expect(r.displayText).toBe("line 1\nline 2\nline 3");
expect(r.originalLines).toBe(3);
});
it("collapses when line count exceeds threshold", () => {
const input = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join("\n");
const r = formatLongPaste(input);
expect(r.collapsed).toBe(true);
expect(r.originalLines).toBe(60);
expect(r.displayText).toMatch(/pasted/);
expect(r.displayText).toMatch(/60 lines/);
// Header + first 10 lines + "… (50 more lines)" footer.
expect(r.displayText).toContain("line 1");
expect(r.displayText).toContain("line 10");
expect(r.displayText).not.toContain("line 11");
expect(r.displayText).toMatch(/50 more lines/);
});
it("collapses when char count exceeds threshold even with few lines", () => {
const input = "x".repeat(3000); // 1 line, 3000 chars
const r = formatLongPaste(input);
expect(r.collapsed).toBe(true);
expect(r.originalChars).toBe(3000);
expect(r.displayText).toMatch(/pasted/);
expect(r.displayText).toMatch(/2\.9 KB|3 KB/);
});
it("renders bytes in KB when >= 1024", () => {
const r = formatLongPaste(Array.from({ length: 100 }, () => "x".repeat(30)).join("\n"));
expect(r.collapsed).toBe(true);
// 100 * 31 = ~3.0 KB
expect(r.displayText).toMatch(/(KB|MB)/);
});
it("handles single-line remaining — 'more line' not 'more lines'", () => {
const lines = Array.from({ length: DEFAULT_PASTE_HEAD_LINES + 1 }, (_, i) => `l${i}`);
// Still below the line threshold by default (head+1), so trigger via chars.
const big = `${lines.join("\n")}\n${"x".repeat(DEFAULT_PASTE_CHAR_THRESHOLD + 100)}`;
const r = formatLongPaste(big);
expect(r.collapsed).toBe(true);
});
it("respects custom thresholds", () => {
const r = formatLongPaste("line1\nline2\nline3", { lineThreshold: 2 });
expect(r.collapsed).toBe(true);
expect(r.displayText).toMatch(/3 lines/);
});
it("respects custom headLines", () => {
const lines = Array.from({ length: 100 }, (_, i) => `l${i}`);
const r = formatLongPaste(lines.join("\n"), { headLines: 3 });
expect(r.collapsed).toBe(true);
expect(r.displayText).toContain("l0");
expect(r.displayText).toContain("l2");
expect(r.displayText).not.toContain("l3");
expect(r.displayText).toMatch(/97 more lines/);
});
it("reports 0 'more lines' when headLines covers all", () => {
// Trigger collapse via chars, with very large headLines.
const big = `line1\n${"x".repeat(DEFAULT_PASTE_CHAR_THRESHOLD + 100)}`;
const r = formatLongPaste(big, { headLines: 999 });
expect(r.collapsed).toBe(true);
// When head covers everything, no footer is appended.
expect(r.displayText).not.toMatch(/more line/);
});
it("exposes default thresholds", () => {
expect(DEFAULT_PASTE_LINE_THRESHOLD).toBe(40);
expect(DEFAULT_PASTE_CHAR_THRESHOLD).toBe(2000);
expect(DEFAULT_PASTE_HEAD_LINES).toBe(10);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { EditBlock } from "../src/code/edit-blocks.js";
import {
clearPendingEdits,
loadPendingEdits,
pendingEditsPath,
savePendingEdits,
} from "../src/code/pending-edits.js";
import { appendSessionMessage, deleteSession, sessionPath } from "../src/session.js";
function block(overrides: Partial<EditBlock> = {}): EditBlock {
return {
path: "src/a.ts",
search: "foo",
replace: "bar",
offset: 0,
...overrides,
};
}
describe("pending-edits checkpoint", () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "reasonix-pending-"));
vi.stubEnv("USERPROFILE", tmp);
vi.stubEnv("HOME", tmp);
vi.spyOn(require("node:os"), "homedir").mockReturnValue(tmp);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true });
});
it("pendingEditsPath is a .pending.json sibling of the session file", () => {
const p = pendingEditsPath("demo");
expect(p).toContain("sessions");
expect(p.endsWith("demo.pending.json")).toBe(true);
});
it("save → load round-trips the queue", () => {
const blocks = [block({ path: "a.ts" }), block({ path: "b.ts", search: "x", replace: "y" })];
savePendingEdits("s1", blocks);
const loaded = loadPendingEdits("s1");
expect(loaded).toEqual(blocks);
});
it("save with an empty array deletes the file", () => {
savePendingEdits("s2", [block()]);
expect(existsSync(pendingEditsPath("s2"))).toBe(true);
savePendingEdits("s2", []);
expect(existsSync(pendingEditsPath("s2"))).toBe(false);
});
it("load returns null when the file doesn't exist", () => {
expect(loadPendingEdits("ghost")).toBeNull();
});
it("load returns null for a corrupt file rather than throwing", () => {
// First create a real checkpoint, then trash its contents.
savePendingEdits("corrupt", [block()]);
const path = pendingEditsPath("corrupt");
writeFileSync(path, "{ not json", "utf8");
expect(loadPendingEdits("corrupt")).toBeNull();
});
it("load drops malformed entries but keeps valid neighbors", () => {
const path = pendingEditsPath("mixed");
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
JSON.stringify([
block({ path: "good.ts" }),
{ path: "bad", search: "", replace: "" }, // missing offset
{ totally: "wrong" },
block({ path: "also-good.ts" }),
]),
"utf8",
);
const loaded = loadPendingEdits("mixed");
expect(loaded?.map((b) => b.path)).toEqual(["good.ts", "also-good.ts"]);
});
it("clearPendingEdits removes the checkpoint file", () => {
savePendingEdits("clr", [block()]);
expect(existsSync(pendingEditsPath("clr"))).toBe(true);
clearPendingEdits("clr");
expect(existsSync(pendingEditsPath("clr"))).toBe(false);
});
it("clearPendingEdits is a no-op when the file is already missing", () => {
expect(() => clearPendingEdits("ghost")).not.toThrow();
});
it("save / load / clear are all no-ops when sessionName is null (ephemeral)", () => {
expect(() => savePendingEdits(null, [block()])).not.toThrow();
expect(loadPendingEdits(null)).toBeNull();
expect(() => clearPendingEdits(null)).not.toThrow();
});
it("deleteSession removes the pending-edits sidecar too", () => {
appendSessionMessage("combo", { role: "user", content: "hi" });
savePendingEdits("combo", [block()]);
expect(existsSync(pendingEditsPath("combo"))).toBe(true);
expect(existsSync(sessionPath("combo"))).toBe(true);
deleteSession("combo");
expect(existsSync(sessionPath("combo"))).toBe(false);
expect(existsSync(pendingEditsPath("combo"))).toBe(false);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { clampBodyByLines } from "../src/cli/ui/PlanConfirm.js";
describe("clampBodyByLines", () => {
it("returns text unchanged when already within the row budget", () => {
const text = "line 1\nline 2\nline 3";
expect(clampBodyByLines(text, 10)).toBe(text);
});
it("trims to the first N lines and appends a truncation marker", () => {
const lines = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`);
const out = clampBodyByLines(lines.join("\n"), 10);
expect(out.split("\n").slice(0, 10)).toEqual(lines.slice(0, 10));
expect(out).toContain("30 more lines truncated");
});
it("shows the dropped-line count for the truncation hint", () => {
const text = Array.from({ length: 25 }, (_, i) => `row${i}`).join("\n");
expect(clampBodyByLines(text, 5)).toContain("20 more lines truncated");
});
it("handles an empty body without throwing", () => {
expect(clampBodyByLines("", 5)).toBe("");
});
it("does not trim when exactly at the budget", () => {
const text = "a\nb\nc";
expect(clampBodyByLines(text, 3)).toBe(text);
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* Preflight context-size check tests.
*
* Reactive auto-compact keys off the PREVIOUS turn's prompt_tokens —
* too late to save a fresh request whose buildMessages already exceeds
* the model's context window. The preflight estimates locally before
* sending and compacts first.
*
* We set a tiny context budget on a synthetic model id so modestly-
* sized test content can trip the 95% threshold without churning
* through ~120k tokens of fake text.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { DeepSeekClient } from "../src/client.js";
import { CacheFirstLoop } from "../src/loop.js";
import { ImmutablePrefix } from "../src/memory.js";
import { DEEPSEEK_CONTEXT_TOKENS } from "../src/telemetry.js";
import type { ChatMessage } from "../src/types.js";
interface FakeResponseShape {
content?: string;
usage?: Record<string, number>;
}
function fakeFetch(responses: FakeResponseShape[]): typeof fetch {
let i = 0;
return vi.fn(async () => {
const resp = responses[i++] ?? responses[responses.length - 1]!;
return new Response(
JSON.stringify({
choices: [
{
index: 0,
message: { role: "assistant", content: resp.content ?? "", tool_calls: undefined },
finish_reason: "stop",
},
],
usage: resp.usage ?? {
prompt_tokens: 100,
completion_tokens: 10,
total_tokens: 110,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 100,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
}
function makeClient(responses: FakeResponseShape[]) {
return new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch(responses) });
}
describe("preflight context-size check", () => {
const TEST_MODEL = "test-tiny-ctx";
afterEach(() => {
delete DEEPSEEK_CONTEXT_TOKENS[TEST_MODEL];
});
it("auto-compacts when the estimated request exceeds 95% of the context window", async () => {
// Tiny 1000-token budget so modest content can overflow.
DEEPSEEK_CONTEXT_TOKENS[TEST_MODEL] = 1000;
const client = makeClient([{ content: "ack" }]);
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "be brief" }),
stream: false,
model: TEST_MODEL,
});
// Seed the log with a PROPERLY paired (assistant.tool_calls ↔
// tool) turn so buildMessages doesn't strip the tool result as
// an orphan. The tool result is oversized enough to push the
// preflight estimate past 95% of the 1000-token budget. Realistic
// log-line content to avoid the tokenizer's BPE O(n²) pathological
// path on pure-repeat inputs.
loop.log.append({ role: "user", content: "prior request" });
loop.log.append({
role: "assistant",
content: "",
tool_calls: [{ id: "prior", type: "function", function: { name: "probe", arguments: "{}" } }],
});
loop.log.append({
role: "tool",
tool_call_id: "prior",
content: "ERROR: step failed with trailing detail\n".repeat(500),
});
const events: { role: string; content?: string }[] = [];
for await (const ev of loop.step("follow-up")) {
events.push({ role: ev.role, content: ev.content });
}
// Preflight fires BEFORE the request — expect a warning that names
// the preflight path and reports tokens saved from compaction.
const warn = events.find((e) => e.role === "warning" && /^preflight:/.test(e.content ?? ""));
expect(warn).toBeDefined();
expect(warn!.content).toMatch(/pre-compacted \d+ tool result/);
expect(warn!.content).toMatch(/saved [\d,]+ tokens/);
// Loop still completed normally (no forced summary, no error).
expect(events.find((e) => e.role === "error")).toBeUndefined();
const finals = events.filter((e) => e.role === "assistant_final");
expect(finals.length).toBe(1);
});
it("does NOT fire when the estimate is comfortably under 95%", async () => {
// Keep the real 131k budget — a normal conversation won't trip.
const client = makeClient([{ content: "ok" }]);
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "be brief" }),
stream: false,
});
const events: { role: string; content?: string }[] = [];
for await (const ev of loop.step("hi")) {
events.push({ role: ev.role, content: ev.content });
}
const anyPreflight = events.find(
(e) => e.role === "warning" && /^preflight:/.test(e.content ?? ""),
);
expect(anyPreflight).toBeUndefined();
});
it("warns (but does not block) when over 95% with nothing to compact", async () => {
// Tiny budget AND a system prompt that alone overwhelms it. No
// tool messages exist, so compact has nothing to shrink — the
// preflight should still surface a warning so the failure isn't
// mysterious; the request goes out regardless and DeepSeek decides.
DEEPSEEK_CONTEXT_TOKENS[TEST_MODEL] = 500;
const bulkyPrompt = "You are a careful assistant. ".repeat(300);
const client = makeClient([{ content: "ack" }]);
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: bulkyPrompt }),
stream: false,
model: TEST_MODEL,
});
const events: { role: string; content?: string }[] = [];
for await (const ev of loop.step("hi")) {
events.push({ role: ev.role, content: ev.content });
}
const warn = events.find((e) => e.role === "warning" && /^preflight:/.test(e.content ?? ""));
expect(warn).toBeDefined();
expect(warn!.content).toMatch(/nothing to auto-compact/);
// Run still reaches the final step — the user sees the warning
// and can react, but we don't short-circuit on our own.
const finals = events.filter((e) => e.role === "assistant_final");
expect(finals.length).toBe(1);
});
});
+21
View File
@@ -2,6 +2,9 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DeepSeekClient } from "../src/client.js";
import { CacheFirstLoop } from "../src/loop.js";
import { ImmutablePrefix } from "../src/memory.js";
import {
appendSessionMessage,
deleteSession,
@@ -103,4 +106,22 @@ describe("session persistence", () => {
expect(existsSync(sessionsDir())).toBe(true);
expect(existsSync(dirname(sessionPath("s")))).toBe(true);
});
it("loop.appendAndPersist writes bang-style messages to the session file", () => {
// Regression: before 0.5.14 the bang handler called loop.log.append which
// only touched memory, so `!cmd` output was lost on session resume.
const client = new DeepSeekClient({
apiKey: "sk-test",
fetch: (async () => new Response()) as any,
});
const loop = new CacheFirstLoop({
client,
prefix: new ImmutablePrefix({ system: "s" }),
stream: false,
session: "bang-persist",
});
loop.appendAndPersist({ role: "user", content: "[!ls]\n$ ls\n[exit 0]\nfile1 file2" });
const reloaded = loadSessionMessages("bang-persist");
expect(reloaded).toEqual([{ role: "user", content: "[!ls]\n$ ls\n[exit 0]\nfile1 file2" }]);
});
});
+92
View File
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ToolRegistry } from "../src/tools.js";
import {
NeedsConfirmationError,
detectShellOperator,
formatCommandResult,
injectPowerShellUtf8,
isAllowed,
@@ -48,6 +49,97 @@ describe("tokenizeCommand", () => {
});
});
describe("detectShellOperator", () => {
it("flags a standalone pipe", () => {
expect(detectShellOperator("dir /b *.ts | findstr foo")).toBe("|");
});
it("flags standalone redirects (>, >>, <, <<)", () => {
expect(detectShellOperator("echo hi > out.txt")).toBe(">");
expect(detectShellOperator("echo hi >> out.txt")).toBe(">>");
expect(detectShellOperator("sort < in.txt")).toBe("<");
expect(detectShellOperator("cat << EOF")).toBe("<<");
});
it("flags &&, ||, and single &", () => {
expect(detectShellOperator("a && b")).toBe("&&");
expect(detectShellOperator("a || b")).toBe("||");
expect(detectShellOperator("long_task &")).toBe("&");
});
it("flags fd-prefixed redirects", () => {
expect(detectShellOperator("cmd 2> err.log")).toBe("2>");
expect(detectShellOperator("cmd 2>> err.log")).toBe("2>>");
expect(detectShellOperator("cmd 2>&1")).toBe("2>&1");
expect(detectShellOperator("cmd &> all.log")).toBe("&>");
});
it("flags redirects stuck to the next token", () => {
expect(detectShellOperator("echo hi >out.txt")).toBe(">");
expect(detectShellOperator("sort <in.txt")).toBe("<");
});
it("does NOT flag operators inside double quotes", () => {
expect(detectShellOperator('grep "a|b" file.txt')).toBeNull();
expect(detectShellOperator('echo "a > b"')).toBeNull();
expect(detectShellOperator('git commit -m "feat & fix"')).toBeNull();
});
it("does NOT flag operators inside single quotes", () => {
expect(detectShellOperator("grep 'a|b' file.txt")).toBeNull();
expect(detectShellOperator("awk '{print $1 > $2}' file")).toBeNull();
});
it("does NOT flag operator characters embedded in larger unquoted tokens", () => {
// `--flag=1&2` is a single token; the `&` is a literal byte, not a
// shell operator. Same for regex-style args passed without quotes.
expect(detectShellOperator("cargo run -- --flag=1&2")).toBeNull();
expect(detectShellOperator("grep a|b file")).toBeNull();
});
it("returns null for plain commands", () => {
expect(detectShellOperator("git status")).toBeNull();
expect(detectShellOperator("ls -la")).toBeNull();
expect(detectShellOperator("")).toBeNull();
});
it("returns the FIRST operator when multiple are present", () => {
expect(detectShellOperator("a | b > c")).toBe("|");
});
});
describe("runCommand pipe/redirect rejection", () => {
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "reasonix-shell-pipe-"));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("rejects a pipe command with a helpful error", async () => {
await expect(runCommand("echo hi | cat", { cwd: tmp })).rejects.toThrow(
/shell operator "\|" is not supported/,
);
});
it("rejects a redirect command with a helpful error", async () => {
await expect(runCommand("echo hi > out.txt", { cwd: tmp })).rejects.toThrow(
/shell operator ">" is not supported/,
);
});
it("error message points the model at splitting the call", async () => {
try {
await runCommand("a && b", { cwd: tmp });
} catch (err) {
expect((err as Error).message).toMatch(/Split into separate run_command calls/i);
return;
}
throw new Error("expected runCommand to reject");
});
});
describe("isAllowed", () => {
it("matches exact prefix and prefix+args", () => {
expect(isAllowed("git status")).toBe(true);
+209 -11
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
SLASH_COMMANDS,
detectSlashArgContext,
handleSlash,
parseSlash,
suggestSlashCommands,
@@ -103,6 +104,43 @@ describe("handleSlash", () => {
expect(loop.model).toBe("deepseek-reasoner");
});
it("/model soft-warns when id is not in the fetched catalog but still switches", () => {
const loop = makeLoop();
const r = handleSlash("model", ["deepseek-made-up"], loop, {
models: ["deepseek-chat", "deepseek-reasoner"],
});
expect(loop.model).toBe("deepseek-made-up");
expect(r.info).toMatch(/not in the fetched catalog/);
expect(r.info).toMatch(/\/models/);
});
it("/model with no arg and loaded list hints at available ids", () => {
const loop = makeLoop();
const r = handleSlash("model", [], loop, {
models: ["deepseek-chat", "deepseek-reasoner"],
});
expect(r.info).toMatch(/deepseek-chat \| deepseek-reasoner/);
});
it("/models renders the fetched catalog and marks the current one", () => {
const loop = makeLoop();
loop.configure({ model: "deepseek-reasoner" });
const r = handleSlash("models", [], loop, {
models: ["deepseek-chat", "deepseek-reasoner"],
});
expect(r.info).toMatch(/deepseek-chat/);
expect(r.info).toMatch(/▸ deepseek-reasoner\s+\(current\)/);
expect(r.info).toMatch(/\/model <id>/);
});
it("/models triggers a refresh and reports fetching when the list hasn't loaded yet", () => {
const loop = makeLoop();
const refresh = vi.fn();
const r = handleSlash("models", [], loop, { models: null, refreshModels: refresh });
expect(refresh).toHaveBeenCalledOnce();
expect(r.info).toMatch(/fetching \/models/);
});
it("/harvest on/off toggles", () => {
const loop = makeLoop();
handleSlash("harvest", ["on"], loop);
@@ -189,29 +227,43 @@ describe("handleSlash", () => {
loop.log.append({ role: "tool", tool_call_id: "t1", content: "short result" });
const r = handleSlash("compact", [], loop);
expect(r.info).toMatch(/nothing to compact/);
expect(r.info).toMatch(/tokens/);
});
it("/compact shrinks oversized tool results and reports chars saved", () => {
it("/compact shrinks oversized tool results and reports tokens saved", () => {
const loop = makeLoop();
loop.log.append({ role: "user", content: "read a big file" });
loop.log.append({ role: "tool", tool_call_id: "t1", content: "Z".repeat(20_000) });
// Realistic log-shape content — avoids the BPE O(n²) pathological
// path on pure-repeat inputs while still tokenizing well over the
// default 4000-token cap.
loop.log.append({
role: "tool",
tool_call_id: "t1",
content: "ERROR: line failed with detail and context\n".repeat(2000),
});
const r = handleSlash("compact", [], loop);
expect(r.info).toMatch(/compacted 1 tool result/);
expect(r.info).toMatch(/saved/);
// After compaction the tool message length should be below the default 4k cap + envelope.
expect(r.info).toMatch(/saved [\d,]+ tokens/);
// After compaction the tool content should be much smaller than
// the original 84KB, comfortably under the cap's char worst-case.
const toolEntry = loop.log.entries.find((m) => m.role === "tool");
expect(typeof toolEntry?.content).toBe("string");
expect((toolEntry?.content as string).length).toBeLessThan(5_000);
expect((toolEntry?.content as string).length).toBeLessThan(20_000);
});
it("/compact honors a custom cap argument", () => {
it("/compact honors a custom token cap argument", () => {
const loop = makeLoop();
loop.log.append({ role: "tool", tool_call_id: "t1", content: "A".repeat(10_000) });
// 2000-char cap should shrink the 10k message
const r = handleSlash("compact", ["2000"], loop);
loop.log.append({
role: "tool",
tool_call_id: "t1",
content: "INFO: event ok\n".repeat(1500),
});
// 500-token cap should shrink the message hard.
const r = handleSlash("compact", ["500"], loop);
expect(r.info).toMatch(/compacted 1/);
expect(r.info).toMatch(/500 tokens each/);
const toolEntry = loop.log.entries.find((m) => m.role === "tool");
expect((toolEntry?.content as string).length).toBeLessThan(2_500);
expect((toolEntry?.content as string).length).toBeLessThan(3_000);
});
it("/help mentions /compact", () => {
@@ -449,6 +501,111 @@ describe("handleSlash", () => {
expect(r.info).toMatch(/\/tool/);
});
describe("/keys", () => {
it("lists the major keyboard shortcuts", () => {
const r = handleSlash("keys", [], makeLoop());
expect(r.info).toMatch(/Enter\s+submit/);
expect(r.info).toMatch(/Shift\+Enter/);
expect(r.info).toMatch(/Ctrl\+J/);
expect(r.info).toMatch(/Esc\s+abort/);
});
it("documents the three prompt prefixes", () => {
const r = handleSlash("keys", [], makeLoop());
expect(r.info).toMatch(/\/<name>/);
expect(r.info).toMatch(/@<path>/);
expect(r.info).toMatch(/!<cmd>/);
});
it("mentions the pickers", () => {
const r = handleSlash("keys", [], makeLoop());
expect(r.info).toMatch(/[Pp]icker/);
expect(r.info).toMatch(/Tab/);
});
});
it("/help mentions /keys", () => {
const r = handleSlash("help", [], makeLoop());
expect(r.info).toMatch(/\/keys/);
});
describe("detectSlashArgContext", () => {
it("returns null before the user commits to a slash name", () => {
expect(detectSlashArgContext("/pr")).toBeNull();
expect(detectSlashArgContext("/preset")).toBeNull();
});
it("returns null when the command doesn't exist", () => {
expect(detectSlashArgContext("/nope foo")).toBeNull();
});
it("returns null on plain prose (no slash at all)", () => {
expect(detectSlashArgContext("just some text")).toBeNull();
});
it("activates enum picker for /preset", () => {
const ctx = detectSlashArgContext("/preset fa");
expect(ctx).not.toBeNull();
expect(ctx!.kind).toBe("picker");
expect(ctx!.spec.argCompleter).toEqual(["fast", "smart", "max"]);
expect(ctx!.partial).toBe("fa");
// Offset is the char index where the partial starts in the buffer.
expect(ctx!.partialOffset).toBe("/preset ".length);
});
it("activates model picker for /model", () => {
const ctx = detectSlashArgContext("/model deep");
expect(ctx).not.toBeNull();
expect(ctx!.kind).toBe("picker");
expect(ctx!.spec.argCompleter).toBe("models");
});
it("activates enum picker for /plan in code mode", () => {
const ctx = detectSlashArgContext("/plan o", true);
expect(ctx).not.toBeNull();
expect(ctx!.kind).toBe("picker");
expect(ctx!.spec.argCompleter).toEqual(["on", "off"]);
});
it("hides /plan outside code mode (command is contextual)", () => {
expect(detectSlashArgContext("/plan on", false)).toBeNull();
});
it("surfaces a hint-only row once the user types a space inside the partial", () => {
// "/preset fast foo" — typed past the one enum slot.
const ctx = detectSlashArgContext("/preset fast foo");
expect(ctx).not.toBeNull();
expect(ctx!.kind).toBe("hint");
});
it("returns picker with empty partial when the user just hit space", () => {
const ctx = detectSlashArgContext("/preset ");
expect(ctx).not.toBeNull();
expect(ctx!.kind).toBe("picker");
expect(ctx!.partial).toBe("");
});
it("returns hint for commands without a completer", () => {
// `/commit "msg"` — free-form argument, no picker data.
const ctx = detectSlashArgContext('/commit "', true);
expect(ctx).not.toBeNull();
expect(ctx!.kind).toBe("hint");
expect(ctx!.spec.cmd).toBe("commit");
});
it("still surfaces picker kind when partial exactly matches an enum value", () => {
// Detector itself is kind-only — it doesn't know whether the
// partial is a complete match. The App's slashArgMatches memo
// is responsible for hiding the picker on exact match so Enter
// submits; this test documents that the detector's contract is
// "we're in picker mode" regardless of match state.
const ctx = detectSlashArgContext("/preset smart");
expect(ctx).not.toBeNull();
expect(ctx!.kind).toBe("picker");
expect(ctx!.partial).toBe("smart");
});
});
it("SLASH_COMMANDS registry contains every handler switch case", () => {
// Spot-check a handful so the registry doesn't silently drift
// from `handleSlash`. If a new case lands in handleSlash, it
@@ -476,6 +633,7 @@ describe("handleSlash", () => {
"commit",
"plan",
"apply-plan",
"keys",
]) {
expect(names, `registry missing /${required}`).toContain(required);
}
@@ -592,12 +750,52 @@ describe("handleSlash", () => {
pendingEditCount: 3,
});
expect(r.info).toMatch(/model\s+deepseek-/);
expect(r.info).toMatch(/ctx\s+\d+\.?\d*k?\/\d+k/);
expect(r.info).toMatch(/ctx\s+\d+\.?\d*K?\/\d+K/);
expect(r.info).toMatch(/mcp\s+2 server\(s\)/);
expect(r.info).toMatch(/session.*\(ephemeral|session\s+"/);
expect(r.info).toMatch(/edits\s+3 pending/);
});
it("/context breaks down tokens across system / tools / log, and flags the heaviest tool results", () => {
const loop = makeLoop();
// Seed a realistic log: two turns, one with a large tool result.
loop.log.append({ role: "user", content: "list me the files" });
loop.log.append({
role: "assistant",
content: "",
tool_calls: [
{
id: "c1",
type: "function",
function: { name: "list_directory", arguments: '{"path":"."}' },
},
],
});
loop.log.append({
role: "tool",
tool_call_id: "c1",
name: "list_directory",
content: "README.md\npackage.json\nsrc/\n".repeat(200),
});
loop.log.append({ role: "assistant", content: "here are the files" });
loop.log.append({ role: "user", content: "now read package.json" });
const r = handleSlash("context", [], loop);
expect(r.info).toMatch(/Next-request estimate/);
expect(r.info).toMatch(/system prompt/);
expect(r.info).toMatch(/tool specs/);
expect(r.info).toMatch(/log \(all turns\)/);
// Heaviest-tool section must surface the list_directory result.
expect(r.info).toMatch(/Top tool results/);
expect(r.info).toMatch(/list_directory/);
});
it("/context handles an empty log without crashing", () => {
const r = handleSlash("context", [], makeLoop());
expect(r.info).toMatch(/Next-request estimate/);
expect(r.info).not.toMatch(/Top tool results/);
});
it("/status with pendingEditCount=0 hides the edits line", () => {
const r = handleSlash("status", [], makeLoop(), { pendingEditCount: 0 });
expect(r.info).not.toMatch(/pending/);
+7
View File
@@ -126,6 +126,13 @@ describe("registerSubagentTool", () => {
expect(end.summary).toBe("done");
expect(end.turns).toBe(1);
expect(end.error).toBeUndefined();
// 0.5.14: end event also carries cost, model, and aggregate usage
// so the sink can write a subagent row to the usage log without
// recomputing anything.
expect(end.model).toBeTruthy();
expect(end.usage).toBeDefined();
expect(end.usage?.promptTokens).toBeGreaterThan(0);
expect(end.costUsd).toBeGreaterThan(0);
});
it("emits a progress event for each tool result inside the child loop", async () => {
+38 -10
View File
@@ -1,6 +1,18 @@
import { describe, expect, it } from "vitest";
import { Usage } from "../src/client.js";
import { SessionStats, costUsd, inputCostUsd, outputCostUsd } from "../src/telemetry.js";
import {
DEEPSEEK_PRICING,
SessionStats,
costUsd,
inputCostUsd,
outputCostUsd,
} from "../src/telemetry.js";
// Derive expected figures from the pricing table so the tests don't
// re-bake stale constants every time DeepSeek updates the price sheet.
// The `costUsd` formula under test is:
// (hitT * hit + missT * miss + outT * out) / 1e6
const CHAT = DEEPSEEK_PRICING["deepseek-chat"]!;
describe("Usage.cacheHitRatio", () => {
it("computes hit ratio", () => {
@@ -16,8 +28,10 @@ describe("costUsd", () => {
it("applies DeepSeek pricing tiers", () => {
const u = new Usage(1000, 100, 0, 800, 200);
const c = costUsd("deepseek-chat", u);
// (800 * 0.07 + 200 * 0.27 + 100 * 1.10) / 1e6
expect(c).toBeCloseTo((800 * 0.07 + 200 * 0.27 + 100 * 1.1) / 1_000_000, 10);
expect(c).toBeCloseTo(
(800 * CHAT.inputCacheHit + 200 * CHAT.inputCacheMiss + 100 * CHAT.output) / 1_000_000,
10,
);
});
it("returns 0 for unknown model", () => {
@@ -56,12 +70,16 @@ describe("SessionStats", () => {
const stats = new SessionStats();
stats.record(1, "deepseek-chat", new Usage(1000, 100, 1100, 800, 200));
const s = stats.summary();
// input: (800 * 0.07 + 200 * 0.27) / 1e6 = 0.000056 + 0.000054 = 0.00011
// output: 100 * 1.10 / 1e6 = 0.00011
expect(s.totalInputCostUsd).toBeCloseTo((800 * 0.07 + 200 * 0.27) / 1_000_000, 10);
expect(s.totalOutputCostUsd).toBeCloseTo((100 * 1.1) / 1_000_000, 10);
// `summary()` rounds USD figures to 6 decimals, so we match at 6 —
// the raw formula at higher precision is exercised by the
// `inputCostUsd` / `outputCostUsd` tests below.
expect(s.totalInputCostUsd).toBeCloseTo(
(800 * CHAT.inputCacheHit + 200 * CHAT.inputCacheMiss) / 1_000_000,
6,
);
expect(s.totalOutputCostUsd).toBeCloseTo((100 * CHAT.output) / 1_000_000, 6);
// Sum of input+output equals total (within rounding).
expect(s.totalInputCostUsd + s.totalOutputCostUsd).toBeCloseTo(s.totalCostUsd, 9);
expect(s.totalInputCostUsd + s.totalOutputCostUsd).toBeCloseTo(s.totalCostUsd, 6);
});
});
@@ -69,13 +87,23 @@ describe("inputCostUsd / outputCostUsd", () => {
it("input cost covers cache-hit + cache-miss but NOT completion", () => {
const u = new Usage(1000, 100, 1100, 800, 200);
const i = inputCostUsd("deepseek-chat", u);
expect(i).toBeCloseTo((800 * 0.07 + 200 * 0.27) / 1_000_000, 10);
expect(i).toBeCloseTo((800 * CHAT.inputCacheHit + 200 * CHAT.inputCacheMiss) / 1_000_000, 10);
});
it("output cost covers completion only", () => {
const u = new Usage(1000, 100, 1100, 800, 200);
const o = outputCostUsd("deepseek-chat", u);
expect(o).toBeCloseTo((100 * 1.1) / 1_000_000, 10);
expect(o).toBeCloseTo((100 * CHAT.output) / 1_000_000, 10);
});
it("chat and reasoner are unified at the same price", () => {
// Post-unification (2026-04): DeepSeek charges identically for both
// models. If this test starts failing, either DeepSeek reintroduced
// tiered pricing (update the constants) or someone accidentally
// edited only one entry — catch that before shipping.
const chat = DEEPSEEK_PRICING["deepseek-chat"]!;
const reasoner = DEEPSEEK_PRICING["deepseek-reasoner"]!;
expect(reasoner).toEqual(chat);
});
it("both return 0 for an unknown model", () => {
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { countTokens, encode, estimateConversationTokens } from "../src/tokenizer.js";
describe("DeepSeek V3 tokenizer — golden cases", () => {
// These IDs were captured from the pure-TS port running against the
// bundled `data/deepseek-tokenizer.json.gz`. They match what DeepSeek's
// official Python tokenizer produces (HF LlamaTokenizerFast on the
// same tokenizer.json). If a case regresses, check that the data
// file wasn't accidentally truncated or the pre_tokenizer Sequence
// wasn't reordered.
it("empty string is zero tokens", () => {
expect(encode("")).toEqual([]);
expect(countTokens("")).toBe(0);
});
it("ASCII words tokenize compactly", () => {
expect(encode("Hello!")).toEqual([19923, 3]);
expect(encode("Hello, world!")).toEqual([19923, 14, 2058, 3]);
});
it("common CJK collocation is a single token", () => {
expect(encode("你好")).toEqual([30594]);
});
it("CJK sentence splits on punctuation", () => {
expect(encode("你好,世界!")).toEqual([30594, 303, 3427, 1175]);
});
it("digit run is isolated by the \\p{N}{1,3} pre-tokenizer rule", () => {
// "1 + 1 = 2" → numbers get their own tokens; spaces/operators
// fold into byte-level pieces.
expect(encode("1 + 1 = 2")).toEqual([19, 940, 223, 19, 438, 223, 20]);
});
it("recognizes <think>/</think> as atomic added tokens", () => {
// 128798 = <think>, 128799 = </think> per tokenizer.json added_tokens.
const ids = encode("<think>reasoning here</think>");
expect(ids[0]).toBe(128798);
expect(ids[ids.length - 1]).toBe(128799);
expect(ids.length).toBe(5);
});
it("mixed English+CJK follows the right pre-tokenizer branches", () => {
const ids = encode("mixed 中文 and english 混合");
expect(ids).toEqual([122545, 223, 21134, 305, 33010, 223, 14769]);
});
it("round-trips a code snippet at a reasonable compression ratio", () => {
const src = "function add(a, b) { return a + b; }";
const n = countTokens(src);
// 37 chars → expected ~12-14 tokens for a ByteLevel BPE trained on
// code. Assert a loose band so a future tokenizer refresh (vocab
// shift ±5%) doesn't break the test suite.
expect(n).toBeGreaterThanOrEqual(10);
expect(n).toBeLessThanOrEqual(16);
});
it("Chinese prose gets the expected ~0.6 tokens/char rate", () => {
const text = "深度求索是一家专注于人工智能基础技术研究的公司";
const n = countTokens(text);
// 22 CJK chars → DeepSeek's doc claims ~0.6 tokens/char ≈ 13, our
// V3 tokenizer's CJK compression is tighter; allow 8-16 as the
// sanity range.
expect(n).toBeGreaterThanOrEqual(8);
expect(n).toBeLessThanOrEqual(16);
});
});
describe("estimateConversationTokens", () => {
it("sums content across roles", () => {
const n = estimateConversationTokens([
{ content: "you are helpful" },
{ content: "你好" },
{ content: "Hello!" },
]);
const manual = countTokens("you are helpful") + countTokens("你好") + countTokens("Hello!");
expect(n).toBe(manual);
});
it("counts tool_calls JSON bytes when present", () => {
const withCalls = estimateConversationTokens([
{
content: null,
tool_calls: [{ id: "c1", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }],
},
]);
// The tool_calls serialization itself has weight; should be > 0.
expect(withCalls).toBeGreaterThan(0);
});
it("ignores missing / empty content without crashing", () => {
expect(
estimateConversationTokens([{ content: null }, { content: "" }, { content: undefined }]),
).toBe(0);
});
});
describe("performance sanity", () => {
it("tokenizes 10k chars of typical mixed content in under 200 ms", () => {
const block = "Hello world! 你好 deepseek ".repeat(400); // ~9,600 chars
const t0 = performance.now();
const n = countTokens(block);
const t1 = performance.now();
expect(n).toBeGreaterThan(1000);
expect(t1 - t0).toBeLessThan(200);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Tool-call "ready" progress signal during streaming.
*
* Matches the 0.5.19 UX fix: as each tool_call's arguments stream into
* valid JSON we emit `tool_call_delta` with an incrementing
* `toolCallReadyCount`, so the UI can render "N ready · building call
* M" instead of a context-free spinner. Dispatch still happens after
* the stream ends — this is purely a visibility improvement.
*/
import { describe, expect, it } from "vitest";
import { looksLikeCompleteJson } from "../src/loop.js";
describe("looksLikeCompleteJson", () => {
it("empty / whitespace → false", () => {
expect(looksLikeCompleteJson("")).toBe(false);
expect(looksLikeCompleteJson(" ")).toBe(false);
});
it("partial JSON → false (the common streaming case)", () => {
expect(looksLikeCompleteJson("{")).toBe(false);
expect(looksLikeCompleteJson('{"path"')).toBe(false);
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hel')).toBe(false);
});
it("complete JSON object → true", () => {
expect(looksLikeCompleteJson("{}")).toBe(true);
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hello"}')).toBe(true);
});
it("complete JSON array → true", () => {
expect(looksLikeCompleteJson("[]")).toBe(true);
expect(looksLikeCompleteJson('[{"a": 1}]')).toBe(true);
});
it("primitive JSON values → true", () => {
expect(looksLikeCompleteJson("true")).toBe(true);
expect(looksLikeCompleteJson("42")).toBe(true);
expect(looksLikeCompleteJson('"text"')).toBe(true);
});
it("valid JSON with trailing whitespace → true", () => {
expect(looksLikeCompleteJson('{"a":1}\n')).toBe(true);
});
});
+25
View File
@@ -145,6 +145,31 @@ describe("ToolRegistry", () => {
expect(received).toEqual({ a: { b: { c: "hi" } } });
});
it("truncates oversized results when maxResultChars is set", async () => {
const reg = new ToolRegistry();
const big = "x".repeat(50_000);
reg.register({
name: "bloat",
parameters: { type: "object", properties: {} },
fn: () => big,
});
const out = await reg.dispatch("bloat", "{}", { maxResultChars: 1000 });
expect(out.length).toBeLessThan(2000);
expect(out).toMatch(/truncated/);
});
it("passes results through unchanged when maxResultChars is absent", async () => {
const reg = new ToolRegistry();
const big = "x".repeat(50_000);
reg.register({
name: "bloat",
parameters: { type: "object", properties: {} },
fn: () => big,
});
const out = await reg.dispatch("bloat", "{}");
expect(out.length).toBe(50_000);
});
it("autoFlatten:false opts out", () => {
const reg = new ToolRegistry({ autoFlatten: false });
reg.register({
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_MAX_RESULT_TOKENS, truncateForModelByTokens } from "../src/mcp/registry.js";
import { countTokens } from "../src/tokenizer.js";
describe("truncateForModelByTokens", () => {
it("returns the string unchanged when it fits the budget", () => {
const s = "Hello, world! This is a short string.";
expect(truncateForModelByTokens(s, 1000)).toBe(s);
});
it("returns the string unchanged when length <= budget (fast path)", () => {
// Fast path: every token is ≥ 1 char, so length ≤ budget implies
// tokens ≤ budget. No tokenize call should be needed.
const s = "a".repeat(50);
expect(truncateForModelByTokens(s, 100)).toBe(s);
});
it("truncates when tokens exceed the budget", () => {
// 2000 "hello " tokens → well above 100-token budget
const s = "hello ".repeat(2000);
const out = truncateForModelByTokens(s, 100);
expect(out).not.toBe(s);
// Output includes the head, a truncation marker, and a short tail
expect(out).toMatch(/…truncated ~?\d+ tokens \(\d+ chars\)/);
// Final token count stays reasonably close to (at or below) budget
// plus the marker's ~48-token overhead — we allow a small slack
// because the slice refinement is char-based and can overshoot by
// a few tokens before the retry loop settles.
expect(countTokens(out)).toBeLessThanOrEqual(250);
});
it("caps CJK text at the same token footprint as English", () => {
// 8000 chars of Chinese — roughly 5000-8000 tokens depending on
// which chars; old char-based cap at 32000 would let this through
// at 2× the token cost. Token cap pulls it down.
const s = "你好世界".repeat(2000); // 8000 chars
const out = truncateForModelByTokens(s, 500);
expect(countTokens(out)).toBeLessThanOrEqual(700);
expect(out).toMatch(/…truncated/);
});
it("preserves both the head and the tail of the content", () => {
const head = "START-OF-RESULT\n";
const middle = "filler line\n".repeat(5000);
const tail = "\nEND-OF-RESULT";
const s = head + middle + tail;
const out = truncateForModelByTokens(s, 500);
// Head leading sentinel is preserved at the start
expect(out.startsWith("START-OF-RESULT")).toBe(true);
// Tail trailing sentinel survives via the short tail window
expect(out.endsWith("END-OF-RESULT")).toBe(true);
expect(out).toMatch(/…truncated/);
});
it("returns the empty string when budget is zero or negative", () => {
expect(truncateForModelByTokens("anything", 0)).toBe("");
expect(truncateForModelByTokens("anything", -5)).toBe("");
});
it("handles the empty string", () => {
expect(truncateForModelByTokens("", 100)).toBe("");
expect(truncateForModelByTokens("", 0)).toBe("");
});
it("default token budget is 8000", () => {
expect(DEFAULT_MAX_RESULT_TOKENS).toBe(8_000);
});
it("truncated marker reports a positive dropped-tokens count", () => {
const s = "A".repeat(40_000);
const out = truncateForModelByTokens(s, 200);
const match = /truncated ~?(\d+) tokens \((\d+) chars\)/.exec(out);
expect(match).not.toBeNull();
const droppedTokens = Number(match![1]);
const droppedChars = Number(match![2]);
expect(droppedTokens).toBeGreaterThan(0);
expect(droppedChars).toBeGreaterThan(0);
});
});
+96
View File
@@ -262,6 +262,102 @@ describe("renderDashboard", () => {
expect(out).toContain("tracked since:");
});
it("surfaces a subagent activity section when subagent records are present", () => {
const agg = aggregateUsage(
[
{
ts: 1_700_000_000_000,
session: "s",
model: "deepseek-chat",
promptTokens: 500,
completionTokens: 60,
cacheHitTokens: 400,
cacheMissTokens: 100,
costUsd: 0.002,
claudeEquivUsd: 0.02,
kind: "subagent",
subagent: {
skillName: "explore",
taskPreview: "find foo",
toolIters: 3,
durationMs: 2_500,
},
},
{
ts: 1_700_000_000_000,
session: "s",
model: "deepseek-chat",
promptTokens: 200,
completionTokens: 30,
cacheHitTokens: 100,
cacheMissTokens: 100,
costUsd: 0.0015,
claudeEquivUsd: 0.015,
kind: "subagent",
subagent: {
skillName: "explore",
taskPreview: "find bar",
toolIters: 2,
durationMs: 1_500,
},
},
],
{ now: 1_700_000_000_000 },
);
expect(agg.subagents?.total).toBe(2);
expect(agg.subagents?.bySkill[0]?.skillName).toBe("explore");
expect(agg.subagents?.bySkill[0]?.count).toBe(2);
const out = renderDashboard(agg, "/tmp/fake.jsonl");
expect(out).toContain("subagent activity:");
expect(out).toContain("explore");
expect(out).toContain("2 run");
});
it("groups subagent records without a skillName under (adhoc)", () => {
const agg = aggregateUsage(
[
{
ts: 1_700_000_000_000,
session: null,
model: "deepseek-chat",
promptTokens: 10,
completionTokens: 2,
cacheHitTokens: 0,
cacheMissTokens: 10,
costUsd: 0.001,
claudeEquivUsd: 0.01,
kind: "subagent",
subagent: { taskPreview: "raw call", toolIters: 1, durationMs: 500 },
},
],
{ now: 1_700_000_000_000 },
);
expect(agg.subagents?.bySkill[0]?.skillName).toBe("(adhoc)");
});
it("includes subagent cost in the main buckets (it's real spend)", () => {
const agg = aggregateUsage(
[
{
ts: 1_700_000_000_000 - 60_000,
session: "s",
model: "deepseek-chat",
promptTokens: 0,
completionTokens: 0,
cacheHitTokens: 0,
cacheMissTokens: 0,
costUsd: 0.5,
claudeEquivUsd: 5,
kind: "subagent",
subagent: { skillName: "explore", taskPreview: "x", toolIters: 1, durationMs: 100 },
},
],
{ now: 1_700_000_000_000 },
);
expect(agg.buckets[0]?.costUsd).toBeCloseTo(0.5);
expect(agg.buckets[0]?.turns).toBe(1);
});
it("renders em-dashes for empty buckets rather than $0.000000", () => {
const agg = aggregateUsage(
[