Compare commits

...
162 Commits
Author SHA1 Message Date
reasonix c404bfe60e chore(release): 0.48.1 — dsnix alias, lifecycle observability, composer redesign
Headline themes:
- dsnix: short alias package + lockfile/readme sync
- Lifecycle: strict observability + multi-file refactor and config-migration corpus tests
- Approval prompt: unified PauseRequest data model across ACP/CLI/Desktop (#1322 Phase 3)
- i18n: builtin skill descriptions + MCP error toasts + status labels (zh-CN)
- UI: composer redesign with Ctrl+P shortcut help modal
- Security: drop baked metaso key + trust project hooks
- Perf: fold thresholds 50→75% / 70→78%

See CHANGELOG for the full list.
2026-05-21 06:16:16 -07:00
Tianjiu d7fb4daca5 fix(security): remove baked metaso key and trust project hooks (#1488) 2026-05-21 06:05:18 -07:00
YHHandreasonix fa916e8603 fix(yolo): auto-resolve run_command + run_background gates in YOLO mode (#1489)
Reported in #1448. With YOLO mode enabled, file edits skip confirmation
(editModeRef path) but shell commands still prompt — defeating the "fully
unattended" promise.

Two layers were supposed to bypass shell prompts in YOLO:

  1. shell.ts's `isAllowAll()` check — should skip `gate.ask` entirely.
  2. `pause-policy.ts:autoResolveVerdict` — should auto-resolve any
     gate.ask that does slip through.

Layer 2 only handled `plan_checkpoint` and `path_access`. `run_command`
and `run_background` fell through to the UI for a manual prompt. That
gap matters because shell.ts's `allowAll` closure reads on-disk config
via `loadEditMode()`, but:

  - `reasonix acp --yolo` sets `opts.yolo = true` in-memory without
    calling `saveEditMode`, so shell.ts can't see it.
  - Anywhere a desync between the in-UI editMode and the persisted
    config can occur (e.g. mid-toggle races), shell.ts will let the
    gate.ask through.

Extend `autoResolveVerdict` to return `{ type: "run_once" }` for
`run_command` / `run_background` when `editMode === "yolo"` — same
pattern as `path_access`. `run_once` (not `always_allow`) so a YOLO
session doesn't pollute the persisted allowlist with every transient
command. Matches the documented intent ("yolo mirrors shell.ts's
allowAll bypass").

Tests in tests/acp-yolo.test.ts:
  - Replaced the previous assertion that `run_command` falls through to
    the bridge under --yolo (that was the old design that's now the
    bug) with one that asserts auto-resolve to `run_once`.
  - Added the same coverage for `run_background`.
  - Added a regression that `run_command` in auto mode still bridges to
    the client (only yolo bypasses).

Closes #1448

Co-authored-by: reasonix <[email protected]>
2026-05-21 05:43:10 -07:00
YHHandreasonix 8429754d1c fix(desktop): make error cards dismissable + tone-down recoverable ones (#1487)
User report: storm-repair / repeat-loop warnings pile up as red error
cards in the desktop transcript with no way to close them. A handful
of self-repaired loops in a long session leaves a wall of "错误" cards
permanently sitting in the chat, even though the loop already worked
around the underlying problem.

The kernel-side already classifies these correctly — KernelErrorEvent
carries `recoverable: true` for storm-repair / repeat-loop notices and
`false` for hard failures. The desktop just wasn't using either signal:

  - Every error event got the same red tone with no dismiss UI.

This commit:

  - Adds an id to each error chat message so dismiss can target one
    without taking out unrelated messages.
  - New `dismiss_error` action + reducer case removes by id.
  - Each error card now renders a close (×) button. Click dispatches
    `dismiss_error` for that id.
  - When the kernel event marks the error `recoverable: true`, the
    card renders with warn tone (warningLabel + tone-warn border/text)
    instead of error tone — softer signal for things the loop already
    handled.
  - Protocol `$error` events (RPC-layer hard failures) keep the red
    error tone.

i18n: added `app.warningLabel` and `app.dismissError` for en + zh-CN.

3539 tests pass (+4: dismiss_error by id; no-op for missing id;
recoverable=true from kernel error; recoverable=false from \$error).

Co-authored-by: reasonix <[email protected]>
2026-05-21 05:27:32 -07:00
sorghum 9e46fe4746 feat: optimize java tool (#1485)
* feat: make jarKeyword a required parameter[2]

* feat: Optimize Java Tool.
2026-05-21 05:05:20 -07:00
YHHandreasonix 99281682a6 fix(tui): keep SGR mouse on Windows — alternate-scroll breaks wheel on WT (#1486)
Follow-up to #1477. The alternate-scroll switch fixed native text
selection / right-click on terminals that honor `?1007h` properly, but
Windows Terminal turns out NOT to be one of them — it advertises the
mode but doesn't reliably translate wheel events into keys, so reasonix
sees nothing and the wheel goes dead in-app. That's exactly the
regression #514 was originally fixed by `?1000h` mode in the first
place. Tested on a fresh checkout: pre-#1477 Windows Terminal wheel
worked; post-#1477 it doesn't.

Pick the protocol by platform:
  - `process.platform === "win32"` → `?1000h+?1006h` (proven on
    Windows Terminal / conhost / ConEmu, restores wheel for everyone
    who had it before #1477)
  - otherwise → `?1007h` (still gives back native text-select and
    right-click on iTerm2, GNOME Terminal, kitty, Alacritty, recent
    xterm — the platforms whose users asked for #1337 / #677)

`REASONIX_MOUSE_MODE=sgr|alternate-scroll|off` still overrides for the
edge cases (e.g. a Linux user whose terminal also breaks under `?1007h`,
or a Windows user who wants the new selection behavior at the cost of
wheel scroll).

Tests use platform-conditional expected sequences so both branches are
verified on CI (Ubuntu hits the alternate-scroll path, Windows hits
SGR). New test specifically covers
`REASONIX_MOUSE_MODE=alternate-scroll` as an override on Windows.

Co-authored-by: reasonix <[email protected]>
2026-05-21 05:04:38 -07:00
Bernardxu123andReasonix Dev 19430d225d feat(ui): redesign input area and integrate Ctrl+P shortcut help modal (#1483)
* feat(ui): redesign input area and integrate Ctrl+P shortcut help modal

- Remove border lines from PromptInput, keep blue left bar + #1e1e1e background with 5-row internal padding
- Add ShortcutsHelpModal as floating overlay triggered by Ctrl+P, styled with round border and #649ed2 accent
- Remove Ctrl+P from multiline-keys ↑ alias (was conflicting with shortcut panel)
- Mode display in PromptInput now uses i18n (t('statsPanel.modeYolo/Auto/Review'))
- StatusRow: remove PresetPill ('▴ auto'), fix balance order to '剩余 balance', i18n Ctrl+P hint
- Remove edit-mode status bar (ModeStatusBar) from ComposerArea (duplicated with shortcut modal)
- InputAreaWithHistoryHint restyled with blue left bar + #1e1e1e to match input box
- All shortcut descriptions in ShortcutsHelpModal use i18n via t() calls
- Add i18n keys for shortcutsHelp + statusBar.shortcutsHint in EN / zh-CN / types

* fix(tests): update multiline-keys tests for Ctrl+P removal as arrow alias

Ctrl+P now handled by App.tsx shortcut modal, no longer maps to arrow-up in multiline-keys.ts. Tests updated to verify NOOP behavior for Ctrl+P and moved arrow-up cursor tests to use upArrow key.

---------

Co-authored-by: Reasonix Dev <[email protected]>
2026-05-21 04:51:15 -07:00
YHHandreasonix 330a75b8fb fix(desktop): clear orphaned pause-gate modals on \$turn_complete (#1484)
Reported in #1456. Repro:

  1. /plan → agent generates plan → plan approval card renders below
     the assistant message
  2. User types follow-up guidance and presses Enter → queued (busy=true)
  3. User clicks what looks like the send button — but with busy=true the
     composer swaps it for the stop/abort button. The turn aborts.
  4. \`$turn_complete\` fires (busy=false), the queue drains, send_user
     appends the user's text to state.messages. But pendingPlans was
     never cleared, so the orphaned plan card is still rendered AFTER
     all messages. Result: the new user message visibly lands ABOVE
     the plan card, exactly the layout the issue screenshots show.

By the time the loop emits \`$turn_complete\` any modal still in a
pending* array is orphaned — the tool call that opened it has either
resolved already (so the array entry is gone) or the turn aborted
(so the model isn't coming back for it). Clear all six pending arrays
at that point.

The error toast the user also saw is from the aborted submit_plan call
unwinding through pauseGate; that path is unchanged.

Closes #1456

Co-authored-by: reasonix <[email protected]>
2026-05-21 04:21:15 -07:00
YHHandreasonix a91faab615 fix(cost): show session-aggregate cache hit so /cost matches the status bar (#1482)
Reported in #1479. The bottom status bar shows the session-aggregate cache
hit (intentional, per the comment in state/events.ts — same number as the
web dashboard). `/cost` was showing the LAST TURN's ratio, so on any
session where one turn ran cold (e.g. the first one, or right after a
fold) the two numbers diverged and looked like a bug.

Switch `/cost`'s Usage card to `summary.cacheHitRatio`. The card also
shows `sessionCost`, so a session-aggregate cache hit reads cleanly
alongside it. Per-turn metrics in the card (prompt/output tokens, turn
cost) are unchanged.

Closes #1479

Co-authored-by: reasonix <[email protected]>
2026-05-21 04:12:36 -07:00
Evan-Pycraft 962774be14 feat(i18n): localize MCP error toasts and status labels with zh-CN (#1481) 2026-05-21 03:50:09 -07:00
YHHandreasonix 2821aeb95f fix(plan): Accept executes immediately when the plan has no open questions (#1480)
Reported in #1475. After pressing Esc on the plan picker (entering the
reject staged input), pressing Esc again restored the picker but choosing
"Accept" then surfaced ANOTHER staged input — the "approve mode" optional-
guidance prompt. That input was visually close enough to the regular
composer that users assumed they were back in normal chat and typed
follow-up instructions (e.g. "执行计划") into it, which actually went
through as the approve verdict's `feedback` field. The user's mental
model — "Accept means run it" — kept breaking.

The optional-guidance staged input only makes sense when the plan has
open questions that the user needs to address before approving. When
the plan is fully specified, Accept should resolve the pause gate
directly. Updated handlePlanConfirm so:

  - `approve` with no open-questions section → resolve the gate immediately
    via the same handleStagedInputSubmit override path used by /dashboard,
    so plan-card lifecycle hooks (recordPlanApproved, showPlan(active),
    persistPlanState) all fire identically to the explicit-feedback path
  - `approve` with open questions → staged input still appears so the user
    can answer them
  - `refine` → unchanged, always shows the input (feedback is the point)
  - `/apply-plan` slash fallback (no pending plan, no gate) → unchanged

The Esc-back restoration loop now terminates the moment the user picks
Accept, instead of dropping them into a deceptively-similar second input.

Closes #1475

Co-authored-by: reasonix <[email protected]>
2026-05-21 03:25:17 -07:00
TaoMu 604ab2451e test(lifecycle): cover config migration corpus (#1473) 2026-05-21 03:07:36 -07:00
Tianjiu 8ccb0f575c feat(desktop): allow renaming session titles from sidebar (#1478)
Adds a session_rename IPC command that writes to meta.summary, plus an inline edit affordance in the sidebar (pencil icon, Enter/Esc/blur). Reorders the header title to prefer meta.summary over the first user message so a rename is reflected immediately.
2026-05-21 02:46:36 -07:00
YHHandreasonix 29608dfe78 fix(tui): switch default mouse mode to alternate-scroll (?1007h) (#1477)
The TUI never reacts to mouse clicks or drags — only the wheel — but the
old `?1000h+?1006h` default made the terminal capture ALL button events
and route them to reasonix. Reasonix then dropped them, which silently
broke native text selection and right-click context menus for every user
just to get wheel scrolling working. The same pattern shows up across at
least four bug reports (#1266 wheel broken on Linux, #1337 left-drag and
right-click broken on Windows Terminal, #677 PowerShell long answers
can't be selected, #1419 wheel too sensitive + selection broken).

`?1007h` (DECSET alternate scroll) is the correct protocol for an app
that only cares about wheel events: the terminal translates the wheel
into cursor/PageUp/PageDown keys (which reasonix's chat-scroll handler
already understands) and leaves clicks/drags alone for the terminal to
handle natively. Supported by Windows Terminal, iTerm2, GNOME Terminal,
kitty, Alacritty, recent xterm. Old emulators that don't recognise it
silently ignore the sequence — wheel falls back to the host terminal's
own scrollback, still functional.

Escape hatch via `REASONIX_MOUSE_MODE=sgr|alternate-scroll|off` for the
small slice of users on edge-case terminals; default is alternate-scroll
so the common case needs zero configuration.

Closes #1266

Co-authored-by: reasonix <[email protected]>
2026-05-21 02:33:47 -07:00
YHHandreasonix 09406f2932 fix(desktop): make /btw visibly work — echo input, surface busy, use side-question prompt (#1472)
Three bugs piled up on the desktop /btw path so the user saw nothing after
pressing enter until a delayed error showed up:

1. The composer never dispatched send_user for the /btw line, so the typed
   text never landed in the message stream and the user had no confirmation
   the command was received. Mirrors the CLI shape (log.pushUser(text))
   now: the full /btw line is echoed and busy flips on while the side call
   runs.

2. $btw_result didn't clear busy on arrival, so even after the answer
   showed, the composer would have stayed disabled. Reducer now flips
   busy back off when the result lands (matches $error which already
   clears busy on the failure branch).

3. The backend sent the question to the model with the FULL coding system
   prompt (tab.system) instead of the brief side-question prompt the CLI
   uses. That put the model into coding-agent mode for an unrelated side
   question — it would either hang trying to call tools that weren't
   wired up for the side-channel or take a long time to produce an
   answer, which is exactly what the bug report described
   ("过了一段时间出现一个报错提示"). Backend now mirrors the CLI's
   one-shot side-question prompt.

Closes #1470

Co-authored-by: reasonix <[email protected]>
2026-05-21 01:35:31 -07:00
Evan-Pycraft 6bc7a7cdba feat(i18n): localize builtin skill descriptions with zh-CN (#1468) 2026-05-21 01:06:39 -07:00
YHHandreasonix 9a156e88fe fix(tests): bump chat-mcp-startup timeout to 30s for Windows + coverage (#1471)
The first test in `chat-mcp-startup-summary.test.ts` timed out at 15s
on Windows CI during the #1439 post-merge run. Vitest's coverage (v8)
instrumentation roughly doubles cold dynamic-import cost, and the first
`captureStartupState` call eats most of that — the SECOND test in the
same describe was back to ~400ms once the worker was warm.

Bump the describe-level timeout to 30s. Happy-path runs are unchanged
(each test still finishes in well under 500ms after the first cold
load); only a hung/genuinely-broken test gets a longer chance to fail.

Co-authored-by: reasonix <[email protected]>
2026-05-21 00:44:06 -07:00
YoungDan-hero 50f38c77c7 fix: resolve root-relative edit block paths (#1452) 2026-05-21 00:20:17 -07:00
TaoMu 68449b81fc fix(desktop): autosize composer for multiline input (#1439) 2026-05-21 00:19:21 -07:00
TaoMu 485a18ee51 test(lifecycle): cover multi-file refactor corpus (#1427) 2026-05-21 00:18:24 -07:00
TaoMu 6e68735e0a feat(lifecycle): surface strict lifecycle observability (#1426) 2026-05-21 00:17:23 -07:00
paradoxSCHandClaude Sonnet 4.6 3a09a2b9c5 feat(approval-prompt): unified PauseRequest→UI data model for ACP, CLI, Desktop (#1322 Phase 3) (#1443)
* fix(desktop): sync context token meter after /compact (#1305)

Problem: Desktop ContextPanel showed stale token counts after /compact
because it relies on per-turn API usage events (model.final) to update
cacheHitTokens/cacheMissTokens. /compact is a local operation with no
API call, so no usage event fires and the meter never refreshes.

Fix (high-cohesion):
1. ContextManager.getLogTokens() — real-time token count of current log.
2. Loop.getCurrentLogTokens() — forwards to ContextManager.
3. Extend \$ctx_breakdown event with optional logTokens field.
4. emitCtxBreakdown() now computes + pushes logTokens alongside
   reservedTokens, becoming the unified context-refresh entrypoint.
5. compact_history handler calls emitCtxBreakdown(tab) on success so
   the frontend immediately receives the compressed token count.
6. App.tsx \$ctx_breakdown handler resets cacheHitTokens=0 and
   cacheMissTokens=logTokens when logTokens is present, calibrating
   the meter to the actual current context size.

This keeps all computation in ContextManager, all emitting in
emitCtxBreakdown, and all consumption in App.tsx — no new event types.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* feat(core-utils): introduce ApprovalPrompt unified data model (#1322 Phase 3 PR 1)

- Add `toApprovalPrompt()` and `resolveApprovalPrompt()` in `@reasonix/core-utils`
  to serve as the single source of truth for PauseRequest → UI metadata mapping.
- Define `ApprovalPrompt`, `ApprovalAction`, `ApprovalTone`, `ApprovalPromptKind`
  types with English fallback labels.
- Move `derivePrefix` computation into `toApprovalPrompt` so all surfaces share
  one implementation.
- Add `secondaryInput` to `ApprovalAction` to describe two-phase deny flows.
- Replace ACP `gates.ts` inline mapping with calls to the new unified helpers,
  deleting duplicated `permissionOptionsFor`, `permissionTitleFor`,
  `permissionKindFor`, `verdictFor`, and all `ID_*` constants.
- Update `acp.test.ts` to use new semantic action IDs (`always_allow`, etc.).
- Add `packages/core-utils/tests/**/*.test.ts` to vitest include pattern.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* feat(cli): migrate ShellConfirm and PathConfirm to ApprovalPrompt model (#1322 Phase 3 PR 2)

- Update `ShellConfirm.tsx` and `PathConfirm.tsx` to accept `prompt: ApprovalPrompt`
  instead of individual raw fields.
- Drive `SingleSelect` items from `prompt.actions` instead of hardcoded options.
- Trigger deny phase via `action.secondaryInput` detection instead of hardcoded
  "deny" value check.
- Remove `clampCommand`, `tildeify`, and `derivePrefix` from ShellConfirm/PathConfirm
  (computed centrally in `toApprovalPrompt`).
- In `App.tsx`, call `toApprovalPrompt()` at render time for both shell and path
  confirmations, removing inline `derivePrefix` at the component boundary.
- Update `shell-confirm-render.test.tsx` to use `ApprovalPrompt` fixtures.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* feat(desktop): migrate ConfirmApprovalCard and PathAccessApprovalCard to ApprovalPrompt (#1322 Phase 3 PR 3)

- Add `@reasonix/core-utils` workspace dependency to `desktop/package.json`.
- Extend `ConfirmRequiredEvent` and `PathAccessRequiredEvent` with optional
  `prompt: ApprovalPrompt` for backward-compatible RPC.
- In `src/cli/commands/desktop.ts`, call `toApprovalPrompt()` when emitting
  `$confirm_required` and `$path_access_required` events.
- Update `ConfirmApprovalCard` and `PathAccessApprovalCard` in
  `desktop/src/ui/thread.tsx` to consume `prompt` instead of raw fields.
  - Drive primary/secondary/tertiary buttons from `prompt.actions` mapped by
    `kind` (`allow_once`, `allow_always`, `reject`).
  - Map `ApprovalPrompt.tone` to Desktop `ApprovalTone` (warn/danger/info/brand).
  - Remove inline `derivePrefix` from card components.
- Update `PendingConfirm` and `PendingPathAccess` types in `desktop/src/App.tsx`
  to store `prompt`, and pass it to the card components at render time.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* test(desktop): add ApprovalPrompt reducer and component tests

- Add desktop/src/App.test.ts (5 reducer integration tests)
- Add desktop/src/ui/thread.test.tsx (6 component rendering tests)
- Install @testing-library/react and jsdom for Desktop component testing
- Configure vitest with React aliases to prevent duplicate React copies
- Fix tildeify double-slash normalization bug on Windows
- Fix ACP gates comment length to pass policy check

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(desktop-tests): mock Tauri and lucide-react imports for CI

CI installs only root dependencies; desktop-specific deps like
@tauri-apps/api and lucide-react are not available in test runners.
Add vi.mock() calls to App.test.ts and thread.test.tsx so vitest
can resolve these imports without the desktop node_modules.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(tests): add vitest aliases for desktop-only dependencies in CI

CI installs only root dependencies; desktop-specific packages like
lucide-react and @tauri-apps/api are not available. Add mock modules
in tests/mocks/ and wire them via resolve.alias in vitest.config.ts
so desktop tests can resolve these imports without desktop/node_modules.

Also remove redundant vi.mock() calls from App.test.ts and thread.test.tsx
since aliases now handle module resolution globally.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(desktop-tests): mock local desktop modules to avoid transitive dep loading

App.test.ts imports reduce from App.tsx, which transitively loads
CommandPalette, Markdown, and theme modules with desktop-only deps.
thread.test.tsx imports from thread.tsx, which loads cards.tsx,
which loads Markdown.tsx with react-markdown and other deps.

Add vi.mock() for these local module boundaries so tests resolve
without needing the full desktop/node_modules dependency tree.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-05-21 00:14:57 -07:00
YHHandreasonix 1090980452 perf(fold): raise normal threshold 50%→75%, aggressive 70%→78% (#1461)
End-to-end benchmark across 12 runs (3 repeats × 4 thresholds on the
"audit src/ and write an architecture overview" task, ctxMax forced to
120k to make folds reachable) showed normal fold at 0.5 is a stable net
loss:

  config         folds  hit%        miss          cost
  no-fold        0.0    92.7±1.3    75k±4k        $0.0169±0.0006
  current 50/70  2.3    83.0±9.5    127k±48k      $0.0221±0.0054
  late 75/90     0.0    92.7±1.7    82k±26k       $0.0184±0.0040
  agg 90/95      0.0    92.6±0.3    57k±8k        $0.0142±0.0014

Every fold wipes the prefix cache: in current_50_70 prompt tokens drop
63k→16k at the fold, then the next ~9 turns rebuild a fresh prefix and
miss tokens climb 2-3×. Task quality is unchanged across configs (all
12 runs completed the audit), so fold was buying nothing in exchange.

Raising HISTORY_FOLD_THRESHOLD to 0.75 keeps fold available as a
last-mile-before-force-summary mechanism without firing on mid-context
turns. Aggressive lifted to 0.78 to stay in the same band.

The benchmarks/compression-eval/ harness is included so future tuning
can re-run on the same task.

Co-authored-by: reasonix <[email protected]>
2026-05-20 20:41:47 -07:00
YHHandreasonix cdb4b1c7b2 fix(preflight): also gate on JSON body bytes, not just tokens (#1451)
A user reported a cryptic `DeepSeek 400: messages[156].content: unexpected
end of hex escape at line 1 column 884819` mid-session. Reproducing against
real session logs: Node's `JSON.stringify` can't emit a malformed `\u`
escape, and no lone surrogates exist in any of 10 saved sessions — the
gateway is truncating the request body mid-escape because it exceeds the
DeepSeek API's body-byte limit, which is independent of the 1 M-token
context window the existing preflight checks.

Token preflight stays at 95% of ctxMax. New byte preflight trips when the
stringified messages array exceeds `MAX_BODY_BYTES` (700 KB, ~80% of the
observed 884 KB failure point). Either signal triggers the existing
`mechanicalTruncate` path; the truncate now bounds output by tokens AND
bytes so byte-triggered folds actually shrink the body.

The `trigger` field on `PreflightDecision` records which signal fired so
follow-up diagnostics (and the user-facing warning, now showing body KB)
can distinguish the two paths.

Two `tests/loop.test.ts` fold cases used 2 MB seed logs and a 1 M-token
ctxMax to verify fold tier behavior. With the new 700 KB byte gate the
preflight emptied those logs before fold could run; switched both to a
100 K test ctx + proportionally scaled seed sizes so they exercise fold
tiers within both windows.

Two reusable tools added under `tools/` (`analyze-session-body.mjs`,
`scan-all-sessions.mjs`) so future "DeepSeek 400 from a long session"
reports can be diagnosed from the user's local session files without
guessing.

Co-authored-by: reasonix <[email protected]>
2026-05-20 18:28:41 -07:00
YHHandreasonix 77e9c69a9d fix(dsnix): sync package-lock for new workspace + document alias in README (#1442)
The dsnix workspace landed in #1440 but its addition wasn't reflected
in package-lock.json, so `npm ci` fails on every fresh checkout — main
CI has been red since the merge. Regenerate the lock so workspace
entries match the new package.json shape.

Also extend the README install section with the dsnix one-liner so the
shorter command is discoverable, not just a hidden bin alias.

Co-authored-by: reasonix <[email protected]>
2026-05-20 09:25:00 -07:00
YHHandreasonix ce61452e45 feat(dsnix): add shim package for shorter CLI command (#1440)
`reasonix` is nine characters; users asked for a shorter form. Ship
a thin alias under `dsnix` that resolves to the same CLI:

  - bin alias on the main package so global installs expose both
  - dedicated shim package on npm so `npx dsnix@latest` works
  - independent publish workflow keyed on `dsnix-v*` tags

Version tracks reasonix 1:1; first release at 0.48.0.

Co-authored-by: reasonix <[email protected]>
2026-05-20 09:18:35 -07:00
reasonix 90d2c33d3a chore(release): 0.48.0 — NO_PROXY, code intelligence, lifecycle round two
Headline themes:
- Network: full NO_PROXY support + per-host routing visible in /doctor
- Code intel: tree-sitter get_symbols/find_in_code + Java source/decompile + Perplexity/Exa search
- Lifecycle: gate mutations during checkpoints, preserve state across plan revisions
- Subagent: live byte progress + top-level explore/research/review
- Desktop: QQ sessions usable, custom fonts, lots of reliability fixes

See CHANGELOG for the full list.
2026-05-20 07:32:30 -07:00
Evan-Pycraft 7fdd219c0e feat(i18n): extract remaining hardcoded strings in edit history and MCP browse (#1433) 2026-05-20 07:28:54 -07:00
YHHandreasonix 8061478f87 feat(slash): add /about — version, website, GitHub, license (#1434)
Surfaces basic project metadata inside the chat without sending the user
to the docs site or package.json. Lives in the info group next to
/feedback so the discoverability path is obvious from the bare-slash
picker.

Output (EN):
  Reasonix vX.Y.Z — a cache-first DeepSeek coding agent

    Website  https://esengine.github.io/DeepSeek-Reasonix/
    GitHub   https://github.com/esengine/DeepSeek-Reasonix
    License  MIT

URLs + license tag are constants in the handler (language-agnostic);
header + row labels go through i18n so the zh-CN bundle matches the rest
of the TUI.

Co-authored-by: reasonix <[email protected]>
2026-05-20 07:08:50 -07:00
YHHandreasonix af1e52bed1 fix(config): tolerate BOM in config.json + retire preset-trace diagnostic (#1431)
Two changes:

1. `readConfig()` strips a leading UTF-8 BOM before `JSON.parse`. Windows
   PowerShell 5's `Set-Content -Encoding UTF8` and several text editors
   emit `EF BB BF` at the head of the file; `JSON.parse` rejects that
   and throws, which used to fall through to the `return {}` branch
   and silently nuke every saved field on the next read-modify-write —
   you'd lose `apiKey`, `preset`, `mcp`, the whole config after one
   `saveEditMode` or `markEditModeHintShown`. Reproduced reliably in
   diagnostic-probe sessions; verified fixed by injecting a BOM and
   confirming all 13 fields survive a normal run.

2. The trace hooks added in #1429 (`debugLogConfigWrite`,
   `debugLogPresetWrite`, `debugLogPresetWriteFromDashboard`) did their
   job: they confirmed every `writeConfig` callsite preserves fields,
   and that no hidden bypass exists. The "preset flipped to pro after
   relaunch" report was a BOM-corruption cascade, not a stealth save.
   Drop the diagnostic now that the underlying bug is fixed at the
   source.

Test plan:
- `npx tsc --noEmit` clean
- `npx biome check src/config.ts src/server/api/settings.ts` clean
- Manual: inject `EF BB BF` head into config.json, run `reasonix code`,
  exit, confirm all 13 fields preserved (was: collapsed to 3 fields)

Co-authored-by: reasonix <[email protected]>
2026-05-20 06:45:07 -07:00
YHHandreasonix a99b120902 fix(ui): elide reasoning + streaming + diff content beyond the recent window (#1432)
PR #1055 capped tool-card output but left reasoning.text, streaming.text,
and diff.hunks on every card forever. On thinking-model sessions
reasoning.text dominates the cards array: 50KB-1MB per turn x thousands
of turns drags it into the GB range until Mark-Compact gives up. The
heap snapshot in #1031 is the symptom.

Generalize elideOldToolOutputs into elideOldCardContent. Same window
(200 cards), same idempotent stub prefix, same on-disk-log pointer
message; now also stubs reasoning + streaming text and clears
diff.hunks (stats stay so the badge still renders). Streaming cards
mid-flight (streaming===true / done===false) skip elision so chunks
don't land on a stub. UserCard text is left full — user input is
small and precious.

End-to-end budget test (cards-memory-budget.test.ts) drives 1000-turn
realistic sessions and asserts state.cards retains under 10% of raw
input bytes. Measured: 5.4 MB retained vs 110 MB raw (95% off); V8
heap delta 3.4 MB with --expose-gc.

Co-authored-by: reasonix <[email protected]>
2026-05-20 06:36:03 -07:00
YHHandreasonix 3f1db467ee fix(desktop): swallow F5 / Ctrl+R in packaged builds (#1430)
WebView reload flashes a white frame and drops React state (open
composer, in-flight gates) — packaged apps shouldn't expose browser
shortcuts users hit by reflex. Gate on !import.meta.env.DEV so the
dev workflow still has the escape hatch if HMR ever gets stuck.

Co-authored-by: reasonix <[email protected]>
2026-05-20 06:07:23 -07:00
YHHandreasonix a654c97279 debug(preset): trace writeConfig + savePreset + dashboard preset writes (#1429)
REASONIX_DEBUG_PRESET=<path> turns on a timestamp + stack trace at three
points: the writeConfig() sink (catches any path that goes through it),
the savePreset() callsite, and the dashboard /api/settings PATCH branch
that mutates cfg.preset directly. Zero-cost when the env var is unset.

Co-authored-by: reasonix <[email protected]>
2026-05-20 05:57:13 -07:00
YHHandreasonix 5517a3af78 fix(desktop): make rpc_spawn idempotent + add desktop_resync rehydrate path (#1428)
Windows users reported the desktop window booting into a near-black
screen with `rpc_spawn failed: rpc already spawned` in DevTools. Two
issues conspiring:

1. The Rust `rpc_spawn` command errors out if the Node child is already
   running. Any path that remounts the React effect (WebView reload via
   DevTools F5, host-side respawn, transient re-render) hits this and
   logs to console.

2. Node's bootstrap events (`$tab_opened`, `$settings`, `$needs_setup`,
   `$ready`) are fire-once during `bootstrapTab`. A reloaded React app
   never receives them — the Setup view stays in its initial state and
   structural copy renders against unset CSS variables, so the user
   sees a mostly-empty dark window.

Make `rpc_spawn` idempotent (a second call is a no-op for the spawn
itself) and have the frontend follow up with a new `desktop_resync`
command. The Node handler walks every live tab and re-fires the
bootstrap events; the reducers already dedupe by tab id so an extra
emit on the fresh-spawn path is harmless.

Co-authored-by: reasonix <[email protected]>
2026-05-20 05:33:01 -07:00
YHHandreasonix a7c1640e7a fix(settings): allow clearing baseUrl from the dashboard (#1425)
The POST /settings handler rejected an empty baseUrl with 400, so once
a user had typed a base URL into the dashboard they could only edit it,
never clear it — the only way back to the default was hand-editing the
JSON config. Treat empty / whitespace-only as "clear" instead: drop the
key from disk so loadBaseUrl() falls through to DEEPSEEK_BASE_URL or the
built-in default on next start. GET continues to surface null when the
key is absent, which the dashboard already renders as an empty input.

Closes #1409

Co-authored-by: reasonix <[email protected]>
2026-05-20 05:12:23 -07:00
TaoMu 3e8cf9440c test(lifecycle): add strict e2e harness (#1424) 2026-05-20 05:01:50 -07:00
YHHandreasonix edb90cd7e4 fix(ui): less-sensitive wheel scroll + collapse per-card stdout resize listeners (#1423)
* fix(ui): map mouse wheel to single-row scroll, not page

Wheel ticks were routed to scrollPageUp / scrollPageDown (5 rows each).
Most mice emit 2-5 SGR mouse reports per physical notch, so one flick of
the wheel jumped the buffer by 10-25 rows — what the issue describes as
"abnormally sensitive". Splits wheel into its own action backed by
SCROLL_WHEEL_ROWS=1 and leaves PgUp / PgDn on the page step. PlanConfirm
detail pane gets the same treatment so the modal scroll matches.

Closes #1419 (first half).

* fix(ui): collapse per-card stdout resize listeners into one broadcaster

Ink's useBoxMetrics calls stdout.on('resize', ...) once per mounted
card. A long chat legitimately holds dozens, so any fixed cap is a
band-aid — the previous setMaxListeners(200) just delayed the
MaxListenersExceededWarning, it didn't fix it.

Shim stdout.on / .off for the 'resize' event into a single subscriber
Set with one real native listener. Ink's per-card subscribe path is
unchanged but no longer touches Node's EventEmitter accounting. Install
before render() so Ink's own constructor-time subscribe is shimmed too.

Closes #1419 (second half).

---------

Co-authored-by: reasonix <[email protected]>
2026-05-20 04:59:23 -07:00
YoungDan-heroandCursor f339f668ad fix: reject ambiguous search-replace edit blocks (#1414)
Refuse text edit blocks when the SEARCH content matches more than once in the target file. This keeps the `/apply` path aligned with `edit_file` behavior and prevents silently modifying the first matching occurrence when the model did not provide enough context.

Update the edit block test to assert that ambiguous SEARCH text leaves the file unchanged.

Co-authored-by: Cursor <[email protected]>
2026-05-20 04:47:31 -07:00
YHHandreasonix d304a7607d feat(subagent): live byte progress + top-level explore/research/review tools (#1422)
* chore(desktop): document writeAllSync + drop redundant optional chain on caught err

* feat(subagent): live byte progress + top-level explore/research/review tools

The "subagent looks frozen" UX problem had two roots — fixed both, with
one architectural change and one set of progress affordances.

1. Sink wiring (the actual bug): `buildCodeToolset` runs before the TUI
   mounts, so its `subagentRunner` closure can't capture a sink ref.
   Introduce `SHARED_SUBAGENT_SINK` — a process-wide `{current: null}`
   singleton both sides reach through. Setup reads `.current` at
   dispatch time; `useSubagent` writes `.current` on mount. The wiring
   resolves itself in time order without prop-drilling through
   `chatCommand`. Tests / library callers can override via the new
   `subagentSink` option on `buildCodeToolset`.

2. Live byte counters: `spawnSubagent` now emits throttled
   `stream-progress` events carrying monotonic `outputChars`,
   `reasoningChars`, and `toolReadChars`. `SubagentRow` renders these
   as `↓ read X · ↑ out Y · ◆ think Z`. `OngoingToolRow` carries the
   same line as a fallback (so the bytes show even if the rich
   SubagentRow is off-screen) and adds `args N B` for any in-flight
   tool dispatch.

Two unrelated improvements bundled:

3. Top-level subagent tools: `explore` / `research` / `review` /
   `security_review` registered as siblings to `read_file`. Same
   `subagentRunner` path as `run_skill(name="explore", ...)` — the
   surface change is purely affordance. Models pick the tool whose
   name matches the verb in the question; the `run_skill` meta-API
   was too indirect. Empirically lifts the model's "spawn vs chained
   read_file" decision toward the right choice on broad audit
   queries. User-defined skills still flow through `run_skill`.

4. `forceSummaryAfterIterLimit` now passes `thinking: "disabled"` —
   the task is "paraphrase tool results into prose", which needs zero
   reasoning. Pinning was `thinking: enabled` + `reasoningEffort:
   high` and burning reasoning tokens on every storm-breaker /
   context-guard summary.

Verification:
- `npx tsc --noEmit` clean
- `npx biome check src` clean
- Full suite: 3420 passed / 9 skipped
- New tests: 7 cases for the top-level wrappers (registration,
  dispatch, model→skill name mapping for security_review, no-runner
  error, empty-task guard, inline-override bounce)

---------

Co-authored-by: reasonix <[email protected]>
2026-05-20 04:46:11 -07:00
YHHandreasonix 8d82bb573d fix(loop): reset _turnAbort in finally so consumer break can't lock the session (#1421)
Desktop runTurn checks its own outer aborter after each yielded event and
break's out of the for-await on stop. That calls generator.return() on
loop.step(), which throws into the suspended yield and skips any
straight-line code after it. Both abort handlers (iter-start and
in-flight) had `_turnAbort = new AbortController()` after the bail-out
yields, so the reset never ran when the consumer broke early. The next
step() then carried the stale aborted state via the carry-abort check at
the top and re-fired the "在第 0 次工具调用处中断" warning at iter 0
without making any API call — every subsequent user message in the
session was effectively dead until restart.

Move the reset into a finally block in both handlers so generator.return()
still runs it. Existing turn-bleed test only drained events to completion
and didn't catch the consumer-break path; added a regression that mirrors
the desktop pattern (break right after the abort warning yield).

Co-authored-by: reasonix <[email protected]>
2026-05-20 04:43:28 -07:00
TaoMu 32674bdd48 fix(lifecycle): keep strict rails cache-neutral (#1407) 2026-05-20 03:21:48 -07:00
YHHandreasonix e5b6f817b0 chore(desktop): document writeAllSync + drop redundant optional chain on caught err (#1412)
Co-authored-by: reasonix <[email protected]>
2026-05-20 03:20:06 -07:00
Xingxing Zhouand粥 5161dd3c84 Fix desktop stdout partial writes (#1405)
Co-authored-by: 粥 <[email protected]>
2026-05-20 02:55:56 -07:00
YHHandreasonix f7a5b5a79a chore(docs,scripts): drop obsolete MCO RFC + spawn-cost probes (#1411)
End-to-end validation showed model-driven spawn_subagent loses 5× vs
no-spawn baseline due to spawn storms; the orchestrator topology RFC
is moot since production already routes subagents through named skills
(/explore, /research, /review, /security-review) rather than a
free-form model-callable tool. The 9 probe scripts existed to measure
the deferred-cost case for an unshipped model-spawn surface and no
longer have a referent.

Co-authored-by: reasonix <[email protected]>
2026-05-20 02:51:19 -07:00
TaoMu bae312ce7a feat(lifecycle): gate mutations during checkpoints (#1389)
Refs #1285 / #1306.

The lifecycle state machine in `src/code/lifecycle.ts` already exposed a `checkpoint` state but the runtime never entered it — checkpoint pauses ran through `approved`/`executing` instead. That meant high-risk mutations weren't gated while a checkpoint was waiting on continue/revise/stop, and a stopped checkpoint could leave the lifecycle in an execution-shaped state instead of cleanly cancelling.

- New `EngineeringLifecycleRuntime.recordCheckpointReached()` — flips `approved` / `executing` → `checkpoint`. `guardToolCall` already blocks high-risk mutations when state isn't `approved` or `executing`, so the new transition picks up gating for free.
- `App.tsx` plan-checkpoint hook calls it as soon as the gate fires.
- If the user picks `stop` at the checkpoint, `App.tsx` now calls `lifecycle.cancel()` so the lifecycle ends cleanly instead of staying mid-flight.

Tests cover the gated-mutation path and the clean-cancel path.
2026-05-20 02:37:19 -07:00
YHH 0f7a0beac7 fix(test): switch vitest to forks pool to stop heap OOM on many-core hosts (#1408)
`npm run verify` was OOMing on 16-core hosts mid-run with V8 `Zone Allocation failed`, causing cascading flake (`spawn UNKNOWN`, dashboard 21s timeouts, `jobs.tailLines` mismatches).

Root cause: vitest defaults to `pool: "threads"`, so all worker threads share one Node process's ~4GB heap. With 16 cores, ~15 workers × ~300MB each — each loading the tokenizer BPE + tree-sitter wasms + sqlite handles — blows past the heap cap. `retry: 1` doubles transient state on flakes.

Switch to `pool: "forks"` with `maxForks: 8` so each test file runs in its own process with its own heap; process exit frees everything. Costs ~20% wall-clock (62s → 74s on this box) for full isolation.

3412 tests / 244 files pass, no OOM, no `spawn UNKNOWN`, no zone-allocation failures.
2026-05-20 02:30:33 -07:00
YHH 633984124a test(shell-tools): raise run_background dispatch test timeout to 15s (#1406)
The end-to-end `run_background → list_jobs → wait_for_job → job_output → stop_job` test added in #1400 (`tests/shell-tools.test.ts:647`) used the default 5s vitest timeout. On Windows the full chain — Node child spawn + 500ms `wait_for_job` + SIGTERM grace inside `stop_job` — runs ~3-8s depending on load. Verified failing on plain main under moderate load.

Raise the per-test timeout to 15s. No functional change.
2026-05-20 02:15:53 -07:00
TaoMu cc7a2bf71e fix(tools): guard repeated gate rejections (#1392)
Refs #1285 / #1301 / #1306.

The repeated-rejection guard in `ToolRegistry.dispatch` already sharpens the error message when the model retries the same lifecycle interceptor rejection. Two gaps:

1. **Sensitive to JSON key order.** `{command, cwd}` vs `{cwd, command}` fingerprinted as different calls, so the guard didn't fire on a real retry.
2. **Only triggered on structured `rejectedReason` payloads.** Plain-text host denials (`run_command` / `run_background` `user denied:` and `edit_file` / `write_file` `User rejected this edit`) flew under the guard, so the model could keep submitting the same shell or edit until the user gave up.

- `fingerprintArgs` now runs JSON through a recursive key-sort before stringify; `rawFingerprintArgs` keeps the literal string path for invalid-JSON malformed feedback.
- New `plainTextRejectedReason()` recognises shell-gate and edit-gate text patterns and tags them with a `rejectedReason` for the storm guard.
- `rejectionRecoveryHint()` returns per-reason guidance instead of a single generic hint, so the second-rejection error message names the actual escape hatch (allowlisted command vs different edit vs revise plan).
- Internal `_lastInterceptorRejection` → `_lastGateRejection` rename reflects the broader scope.

Test coverage: lifecycle gate with reordered keys; shell-gate corpus across `run_command` / `run_background`; edit-gate corpus.
2026-05-20 01:59:57 -07:00
TaoMu 5a46f7277b feat(plan): surface step evidence summaries (#1391)
Refs #1285 / #1306 / #1302.

The plan store already persists structured step evidence from `mark_step_complete`, but the scrollback only shows generic "step N/M complete" progress. During an active engineering lifecycle that means verification / diff / checkpoint / manual evidence stays buried in the plan state and only appears via `/plans show`.

Adds a compact ghost-styled scrollback line right after the step-progress line when `mark_step_complete` returns structured evidence:

```
evidence: verification - lifecycle tests passed (npm test -- tests/lifecycle.test.ts); diff - runtime transition added (src/code/lifecycle.ts, tests/lifecycle.test.ts)
```

- New `formatStepEvidenceSummary` / `formatStepEvidenceItem` helpers in `src/cli/ui/hooks/handle-tool-event.ts`.
- Path list capped at first 3 to keep the line tight.
- Plan store format unchanged.

New `tests/handle-tool-event.test.ts` covers the formatting path with a mocked Scrollback.
2026-05-20 01:59:15 -07:00
TaoMu d5ac6a3cef feat(lifecycle): preserve state across plan revisions (#1388)
Refs #1285 / #1306.

Previously the App handled an accepted plan revision by replaying state through `recordPlanApproved(merged)` + a loop of `recordStepCompleted()` calls. That round-trip resets approval state and can clear mutation-evidence obligations earned during the steps that were already done — which weakens the reliability rail right after a redirected step.

- New `EngineeringLifecycleRuntime.recordPlanRevised(remainingSteps)` — first-class lifecycle transition for accepted revisions.
- Keeps already-completed steps in `_planSteps`, replaces only the remaining tail.
- Preserves `mutatedSinceLastStep` so the next `mark_step_complete` still needs evidence.
- App `applyPlanRevision` switches from the replay loop to the single API call.

Test coverage: revision keeps completed steps + new tail; revision after a mutation still requires evidence for the next step.
2026-05-20 01:54:37 -07:00
TaoMu f0eb2a474c fix(lifecycle): let slash plan off release rails (#1386)
Refs #1285 / #1306.

After #1306, strict lifecycle is opt-in, but the App had no way to tell `/plan off` (an explicit user request to leave) apart from the internal plan-mode shutdown that happens after plan approval. Result: lifecycle rails could stay armed even after the user explicitly asked to leave.

Threads a `PlanModeToggleSource` through `togglePlanMode` so the slash-driven path can release the lifecycle while the internal post-approval path preserves it.

- `/plan off` → release strict lifecycle immediately
- internal plan-mode shutdown after approval → keep lifecycle execution state intact
2026-05-20 01:53:58 -07:00
wade1999 5be617e513 docs(qq): add desktop quick start and trim README surface (#1401)
Refs #1343 — clearer desktop QQ path and a slimmer top-level README.

- README.md / README.zh-CN.md: trim QQ section to a short overview + entry points, link out to the dedicated guide. The full tutorial belonged in `docs/qq-connect.md`, not the top-level README.
- docs/qq-connect.md: new "Desktop quick start" with the actual button flow (`Settings -> General -> QQ Channel -> Configure -> Save and connect`) + the active-tab binding note, plus a troubleshooting case for "configured but no round-trip."
- docs/qq-connect.zh-CN.md: mirrors the English changes; replaces English desktop labels with the real Chinese i18n labels (`设置 -> 通用 -> QQ 通道`, `配置`, `沙箱` / `正式`, `保存并连接`). Verified all label strings exist in `desktop/src/i18n/zh-CN.ts`.
2026-05-20 01:46:04 -07:00
paradoxSCH 661fbd2bd9 fix(desktop): sync context token meter after /compact (#1399)
Fixes #1305 — Desktop ContextPanel shows stale token counts after `/compact`.

`/compact` is a local operation (no API call), so the `model.final` usage event that normally refreshes `cacheHitTokens` / `cacheMissTokens` never fires. The meter stays pinned to pre-compact numbers until the next real API turn.

- New `ContextManager.getLogTokens()` — sums `countTokensBounded(content)` per message (plus assistant `tool_calls` JSON when present).
- `Loop.getCurrentLogTokens()` forwards to it.
- `$ctx_breakdown` event gains an optional `logTokens` field; `emitCtxBreakdown()` populates it.
- `compact_history` handler emits `$ctx_breakdown` on success so the panel refreshes immediately.
- Frontend resets `cacheHitTokens=0`, `cacheMissTokens=logTokens` when `logTokens` is present — a transient calibration; the next real API turn overwrites both with actual usage numbers.

The token count is approximate (skips role markers + DSML wrappers), which is fine for a UI meter — directionally correct is what matters here.
2026-05-20 01:43:36 -07:00
YHH 3a7c15a4bb chore(shell): trim run_command param description (saves 119 tokens of cached prefix) (#1403)
The top-level `run_command` description already enumerates the chain operators / redirects / rejected forms. The `command` parameter's own description repeated the same list verbatim — 432 chars of duplicated syntax docs the LLM was paying for on every uncached turn.

Schema token cost: 415 → 296 (−119, −28.7% on this tool). Total fixed-prefix tool tokens: 6997 → 6878. All 106 `tests/shell-tools.test.ts` cases pass — the trimmed text contained no decision-making content, only restated syntax the top-level already covered.

Verified via `scripts/measure-tool-token-cost.mts`.
2026-05-20 01:40:32 -07:00
YHH 5adce512d4 chore(scripts): measure per-tool token cost end-to-end (#1402)
`scripts/measure-tool-sizes.mts` reports byte counts — useful for compression planning but not what gets billed. The prompt-token cost is what the V4 BPE charges, and byte count is only a rough proxy. This script measures the real thing.

`scripts/measure-tool-token-cost.mts` registers the full tool suite in a tmp workspace, dispatches each with a representative input, and reports schema / args / output token counts using `src/tokenizer.ts`. Gate-bound tools (`submit_plan`, `ask_choice`, …) are skipped because they block on the user. Every non-skipped tool executes against real filesystem / shell / mocked-fetch state, so the table doubles as a boundary smoke check.

Current snapshot: 35 tools, ~7000 schema tokens fixed per turn (cache-miss rendering — actual cost is ~120× lower once cache is warm). Top spenders: `run_command`, `submit_plan`, `glob`, `install_skill`, `create_skill`.
2026-05-20 01:40:23 -07:00
YHH 6a3d9c15af test(tools): close dispatch-level E2E gaps for shell jobs, web, and scaffold (#1400)
Closes dispatch-level E2E gaps in the tool suite — 7 registered tools had unit coverage of their `fn` but were never exercised through `ToolRegistry.dispatch()`, so the boundary itself (plan-mode gating, parallel-safe checks, error wrapping, result augmentation) wasn't tested for them.

- `shell-tools`: new dispatch tests for `run_background` / `job_output` / `stop_job` / `list_jobs` — full start → list → read → stop round-trip, plus not-found and `cwd`-escapes-workspace cases.
- `web-tools`: dispatch tests for `web_search` (mocked Mojeek fetch) and `web_fetch` (mocked HTML).
- `tools-scaffold`: switched the in-file `call()` helper from `def.fn(args)` to `reg.dispatch(name, JSON.stringify(args))` — all 16 existing `create_skill` / `add_mcp_server` tests now flow through dispatch with no per-test churn.

Net: 3388 → 3394 passing tests.
2026-05-20 00:58:54 -07:00
sorghum 9ac2294bec feat(java-source): strip javap noise + raise buffer to save tokens (#1390)
Strip constant pool, debug tables (`LineNumberTable`, `LocalVariableTable`, `StackMapTable`), and `Compiled from` header from `javap` output — large generated classes (protobuf, ANTLR) were hitting the 10MB buffer limit, and the constant pool accounted for most of the output (useless for an AI reading it). Roughly 60–80% token reduction on those classes.

Also raises `javap` `maxBuffer` from 10MB to 15MB as a safety margin, and reorders the tool description so `className + jarKeyword` is the documented default — encourages the LLM to pass a keyword instead of scanning the entire `~/.m2/repository`. The schema is unchanged; `jarKeyword` remains optional.
2026-05-20 00:17:52 -07:00
YHH e5580e4d30 refactor(config): sanitize string[] fields at readConfig boundary (#1396)
readConfig() previously cast JSON.parse output to ReasonixConfig with no runtime validation. Hand-edited config.json could put objects, numbers, or null into fields typed as `string[]` (mcp, mcpDisabled, recentWorkspaces, skills.paths), corrupting downstream consumers — see #1295 / #1394 where non-string mcp items reached JSX and crashed the desktop tools panel.

- New `sanitizeStringArrayField` in `src/config.ts` drops non-string items at the boundary and warns once per field with the file path + dropped count.
- Removes the now-dead downstream defenses (#1394's filter in `emitMcpSpecs`, `typeof` ternaries in `context-panel.tsx` / `settings.tsx`, `summarizeMcpSpec`'s non-string catch). `summarizeMcpSpec` no longer needs to be exported.
- Test file renamed to `config-sanitize-string-arrays.test.ts` and rewritten against `readConfig` (where the validation actually lives).

Closes #1395
2026-05-20 00:16:11 -07:00
paradoxSCH ba9fb3c8d2 fix(desktop): prevent React #31 when MCP config contains non-string items (#1394)
Fixes #1295 — Desktop MCP tools panel crashes with React error #31 ("Objects are not valid as a React child") when `config.json` contains non-string items in the `mcp` array.

Root cause: `readConfig()` does `JSON.parse` without runtime validation, so `cfg.mcp` can contain objects even though it's typed as `string[]`. When `summarizeMcpSpec()` threw on these object inputs, its catch block returned `summary: raw` directly, leaking objects into JSX.

Fix:
- `summarizeMcpSpec` catch block — stringify non-string `raw` before returning.
- `emitMcpSpecs` — filter `cfg.mcp` to string items before mapping.
- `CtxTools` / `PageMCP` — render `summary` / `name` only when string.
- Regression test in `tests/mcp-desktop-react31.test.ts`.

Follow-up tracked separately: tighten `readConfig` with zod schema validation so this boundary check lives in one place.
2026-05-20 00:02:23 -07:00
YHHandreasonix 401c6b8cb0 feat(code-query): tree-sitter get_symbols + find_in_code (TS/JS/Python/Go/Rust/Java) (#1387)
* feat(code-query): tree-sitter get_symbols + find_in_code tools for TS/TSX/JS

Two grammar-aware tools for single-file code structure queries, scoped
per the #556 RFC's "narrowly-scoped one-tier pilot" constraint.

- get_symbols: outlines top-level + nested symbols (function, class,
  method, interface, type, enum, namespace) with 1-based positions
- find_in_code: identifier search with role classification (call /
  definition / reference), AST-filtered so comments + string literals
  do not match; handles `new X()` and `obj.X()` as calls on X

Bundles tree-sitter-typescript + tree-sitter-javascript wasm from the
official npm packages (~3.4 MB in dist/grammars/). Non-TS/JS files
return an "unsupported; use search_content" hint. Within-file only —
cross-file resolution stays out of scope per the RFC.

Closes #556

* feat(code-query): extend to Python / Go / Rust / Java

Adds 4 grammars beyond the TS/TSX/JS/JSX pilot: tree-sitter-python,
tree-sitter-go, tree-sitter-rust, tree-sitter-java. Total bundled
grammar wasm now 5.5 MB.

Per-language symbol queries land function / class / method / interface /
type / enum / namespace / property kinds with consistent shape. Methods
detected via post-process when a `function_definition` / `function_item`
sits inside a class, impl, or trait container; Rust impl blocks set the
parent name from impl's `type` field so `impl Foo` and `impl Trait for
Foo` both attribute methods to Foo.

find_in_code's classifier learns each language's call shape:
- Python `call`, Java `method_invocation`, `object_creation_expression`
- Go selector_expression, Rust field_expression, Python attribute, JS
  member_expression — all handled uniformly via field-name lookups so
  obj.X() is a call on X across languages

---------

Co-authored-by: reasonix <[email protected]>
2026-05-19 23:39:22 -07:00
YHHandreasonix dc979239c0 fix(desktop): surface /btw usage hint on empty payload instead of dropping the keystroke (#1383)
`/btw` with no question silently returned in desktop/src/App.tsx:1267,
matching the slash-command picker's behavior of pre-filling "/btw " —
users who hit Enter without typing a question saw nothing happen at all
(#1370).

Push a status message with the usage string when the payload is empty,
and re-fill the draft with "/btw " so the user can immediately type
the question without retyping the slash. New `push_status` reducer
action backs this and stays available for other client-side hints.

Closes #1370.

Co-authored-by: reasonix <[email protected]>
2026-05-19 21:46:16 -07:00
YHHandreasonix 51832e9661 feat(doctor): surface resolved NO_PROXY + per-host routing decisions (Tier 3) (#1375)
The final tier of the proxy UX work. After Tier 1/2 wired NO_PROXY,
the DeepSeek-bypass default, the CLI flag, the env override, and the
config field, users still had no way to see what actually applied —
`reasonix doctor` just printed the proxy URL.

Add a separate `proxy routing` doctor check below the `http proxy`
line that shows:

- Pattern-count summary by source: defaults, env NO_PROXY,
  REASONIX_NO_PROXY, cfg.proxy.noProxy.
- Routing simulation for three probe hosts (api.deepseek.com /
  github.com / api.github.com) — "direct" or "via proxy" — so a
  CN user with clash sees at a glance that DeepSeek bypasses but
  GitHub still routes through, and they can immediately tell whether
  their NO_PROXY layering matches intent.

The proxy-routing simulator and installProxyIfConfigured both go
through a new shared `resolveNoProxy()` helper so the two paths
can't drift apart.

Co-authored-by: reasonix <[email protected]>
2026-05-19 21:10:59 -07:00
summerandSummer Su 2b87a6e10d feat(desktop): 添加自定义字体与文件链接优化 (#1376)
Co-authored-by: Summer Su <[email protected]>
2026-05-19 21:09:51 -07:00
YHHandreasonix aa8c82a606 feat(net): --no-proxy CLI flag + REASONIX_NO_PROXY env + cfg.proxy field (Tier 2) (#1374)
Tier 1 (#1373) honored NO_PROXY at the env level and baked the DeepSeek
bypass into the default whitelist. Tier 2 layers app-specific overrides
on top so users don't have to fight system-wide env vars:

- `--no-proxy` CLI flag on the top-level program, code, chat, and run
  subcommands. Detected by argv peek before commander runs so it takes
  effect for the same boot that argv set it.
- `REASONIX_NO_PROXY` env var — parsed with curl-style syntax, additive
  on top of system `NO_PROXY`. Pattern matches GitHub CLI's
  `GH_HTTPS_PROXY` shape (app override > system env).
- `cfg.proxy.disabled` / `cfg.proxy.noProxy` config fields — persistent
  per-user override, loaded via new `loadProxyConfig()` reader.

Precedence: --no-proxy / cfg.proxy.disabled short-circuit the install
entirely. Otherwise default whitelist + env NO_PROXY + REASONIX_NO_PROXY
+ cfg.proxy.noProxy all merge additively.

Co-authored-by: reasonix <[email protected]>
2026-05-19 19:58:40 -07:00
YHHandreasonix 2a5e24848b feat(net): honor NO_PROXY + bake DeepSeek bypass into default whitelist (Tier 1) (#1373)
Reasonix's proxy hookup (#646) installed undici's ProxyAgent globally
without checking NO_PROXY. CN users with HTTPS_PROXY pointed at clash /
v2ray would route api.deepseek.com through their foreign-exit pool, land
on an abused shared IP, and 403 — even though the API host is in CN and
shouldn't need a proxy at all.

Route around it: ship a SelectiveProxyDispatcher that picks the proxy or
a direct Agent per-request based on a curl-style NO_PROXY pattern list.
Default whitelist (always applied) covers api.deepseek.com / *.deepseek.com
/ localhost / 127.0.0.1 / ::1; user-set NO_PROXY entries layer on top.

Startup now logs '[proxy] using <url> (NO_PROXY: ...)' so the routing
decision is visible without /doctor.

Co-authored-by: reasonix <[email protected]>
2026-05-19 19:45:21 -07:00
YHHandreasonix c26a5500cf fix(tools): walk .reasonix/ from FS tools so user skills stay reachable (#1371)
src/tools/filesystem.ts aliased SKIP_DIR_NAMES directly to the semantic
indexer's exclude list (DEFAULT_INDEX_EXCLUDES.dirs), which contains
'.reasonix'. The indexer correctly skips that path so session logs and
caches don't get embedded — but the same alias made search_files,
search_content, directory_tree, and glob blind to user skills living
at <root>/.reasonix/skills/ and ~/.reasonix/skills/. Agents would see
the skill in /skills but report 'no skill found' when search_files
went looking for it.

Decouple the two: filter '.reasonix' out of SKIP_DIR_NAMES while
leaving DEFAULT_INDEX_EXCLUDES.dirs untouched. Indexer behavior is
unchanged; FS tools now reach into .reasonix/ on a normal walk.

Closes #1357.

Co-authored-by: reasonix <[email protected]>
2026-05-19 19:24:00 -07:00
YHHandreasonix e1f144d743 fix(desktop): apply USD→CNY conversion to cost meter and session-cost card (#1369)
Both desktop renders swap in the ¥ symbol when the wallet currency is
CNY but forget to multiply the underlying USD value by the 7.2 FX
factor:

- desktop/src/ui/statusbar.tsx — formatMoney() emitted '¥ <usd>'
- desktop/src/ui/settings.tsx:1023 — billing card emitted '¥ <usd>'

Result: a turn that costs ~$0.14 (the actual DeepSeek deduction is
~¥1.03) showed as '¥ 0.1428' — wrong by roughly the FX rate. The
TUI's tokens.ts formatCost has done this conversion correctly all
along; only the desktop forgot. The costCurrencyHint copy already
claimed the conversion happens, which is what masked the regression.

Closes #1354.
Closes #1367.

Co-authored-by: reasonix <[email protected]>
2026-05-19 19:01:17 -07:00
YHHandreasonix eb06c32366 test(web): cover Perplexity + Exa backends; strip section-banner comments (#1366)
Two follow-ups to #1350:

1. Perplexity / Exa landed without unit tests, while tavily has 5
   covering missing-key / 401 / 429 / parse-error / body shape. Mirror
   that pattern so a fetch-shape change in either backend can't regress
   silently. Also add a formatSearchResults case for the new "answer +
   sources" dual-section branch.

2. Drop the `// ── Perplexity ──` / `// ── Exa ──` / `// ── API-key
   engines ──` section-banner separators — CLAUDE.md bans them; group
   by export instead.

Co-authored-by: reasonix <[email protected]>
2026-05-19 18:32:41 -07:00
dacec354 db3a0d0d7e Feat/web-search perplexity exa (#1350)
* feat: add Perplexity and Exa as web_search engines

* fix: biome lint errors and update public API snapshot
2026-05-19 18:24:39 -07:00
YHHandreasonix 6cc7a8ea66 chore(java): clean up comment style + isolate searchProject tests (#1363)
* chore(java): strip section-banner comments and module-header essays

#1344 followed conventions from a different codebase — `// ── types ──`
banner separators, multi-line module-header essays, and JSDoc-style
`@param`/`@returns` text restating type signatures. CLAUDE.md bans all
three. Trim to plain code with comments only on hidden constraints or
non-obvious invariants.

No behavior change; all 41 java-source tests still pass.

* test(java-source): isolate project-search tests from real ~/.m2 cache

Three searchProject tests ("returns not-found", "skips common non-source
directories", "handles empty project root gracefully") were walking the
real ~/.m2 / ~/.gradle defaults — fine on a fresh CI runner, flaky on a
dev machine with a populated cache under full-suite I/O contention.

- Pass repoPaths: [] so the not-found path skips the jar scan entirely.
- Bump per-test timeout to 30s as a defensive cap; the searches now run
  in ms, but Windows AV scanning of fresh tmp dirs can still spike.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-19 18:23:07 -07:00
J3y0r 71b0ffa80e fix(cli): add opt-out for terminal mouse tracking (#1345)
Closes #1337.
2026-05-19 18:21:58 -07:00
sorghum b0d8fea64c feat: Add Java source code search and decompilation tools. (#1344) 2026-05-19 18:02:46 -07:00
YHHandreasonix 95864a9cc7 fix(desktop): restore augmentProcessPath() lost in #1334 rebase (#1361)
#1331 added augmentProcessPath() in desktopCommand so the Tauri-spawned
Node could see nvm/asdf/fnm/volta/mise PATH entries — the GUI process
never runs the user's shell init. #1334 was branched before #1331 landed
and the rebase dropped the import + call without it being part of the
QQ-runtime work.

Re-add the import and the call site, no behavior change beyond restoring
the previous fix.

Co-authored-by: reasonix <[email protected]>
2026-05-19 17:58:36 -07:00
wade1999 18ee05d0f1 feat(desktop): make QQ sessions usable from desktop (#1334)
Closes #1317.
2026-05-19 17:51:54 -07:00
reasonix c41c9ed0cf fix(release): move @reasonix/core-utils to devDependencies (0.47.2)
0.47.1 published with @reasonix/core-utils as a runtime dependency
pinned to workspace:*. Because the workspace package is private:true,
npm publish did not rewrite the protocol literal, breaking install with
EUNSUPPORTEDPROTOCOL on consumer machines.

The bundled dist/ already inlines core-utils at build time — it is not
needed at runtime. Move it under devDependencies.
2026-05-19 06:12:21 -07:00
reasonix 08008a02ea chore(release): 0.47.1 — desktop shakeout + prompt budget round one 2026-05-19 05:44:10 -07:00
YHH a5654c5af1 fix(skill): surface /skill in welcome hints + drop stale built-ins line (#1333)
Closes #1213
2026-05-19 05:20:20 -07:00
Tianjiu 3f80f74208 fix(ui): highlight selected picker rows in light themes (#1330)
Closes #1311

Co-authored-by: T1anjiu <[email protected]>
2026-05-19 05:05:40 -07:00
paradoxSCHandparadoxSCH 4b5b7a2927 feat(core-utils): introduce @reasonix/core-utils workspace package (Phase 1) (#1328)
Closes #1322 (Phase 1)

Co-authored-by: paradoxSCH <[email protected]>
2026-05-19 05:04:27 -07:00
YHH 8355c6f37b fix(desktop): inherit login-shell PATH so nvm/asdf/fnm reach run_command (#1331)
Closes #1252
2026-05-19 04:58:19 -07:00
YHH 8735359f14 fix(desktop): stop claiming "Connected" — desktop QQ is config-editor-only (#1326)
Closes #1317
2026-05-19 04:37:57 -07:00
YHHandT1anjiu 5f13757d7a fix(multi_edit): roll back files that may have been modified on write failure (#1325)
Co-authored-by: T1anjiu <[email protected]>
2026-05-19 04:36:45 -07:00
YHHandreasonix fd162a3636 feat(read_file): lower outline threshold 512 KiB → 64 KiB (#1324)
#1043 (in 0.46.0) flipped read_file from "head+tail+outline preview"
to "full content for files ≤ 512 KiB" on the theory that the prompt
cache amortizes the cost. Multiple users have reported significantly
higher token consumption since — and they are right: cache hits cost
~10% of input price, so a 10× bigger tool result still pays 10× more
on the hit path, plus full miss price on first read.

Drop the default to 64 KiB, which covers ~99% of source files in
real-world repos while pushing generated bundles, lockfiles, novels,
and other genuinely large files into outline mode (head + symbol map
+ drill-in hints). Users who really want the old behavior can set
`filesystem.outlineThresholdBytes: 524288` in config.

config.ts gets a new `filesystem.outlineThresholdBytes` field plus a
`loadFilesystemOutlineThresholdBytes` loader that returns undefined
on unset / non-positive / non-numeric values, letting the registered
default win. setup.ts wires the loader into registerFilesystemTools.

Expected savings: 5-15k tokens per typical session, depending on how
many >64 KiB files the user touches. read_file's own description was
already updated to reflect "FULL CONTENT for files ≤ N KiB" via the
existing interpolation, so the tool description shifts to "≤ 64 KiB"
automatically.

Co-authored-by: reasonix <[email protected]>
2026-05-19 04:11:45 -07:00
YHHandreasonix e7fb66996f refactor(prompt): compress codeSystemPrompt (-51%, ~3.1k tokens/request) (#1323)
The system prompt was 24,387 bytes (≈ 6,100 tokens) — much of it
overlapping with the tool descriptions sitting right next to it in
the cache prefix. Sections like "When to propose a plan", "When to
ask the user to pick", and "When to track multi-step intent" each
recited rules that the tool's own description already carried.

Aggressive dedup pass:
- Drop the redundant "you have these filesystem tools" opening
  sentence — the API ships the tool list separately.
- Merge the three independent submit_plan / ask_choice / todo_write
  sections into one short "Picking the right tool" block.
- Fold "Exploration", "Trust what you already know", and "When the
  user wants to switch project" into shorter equivalents — same
  rules, no narrative.
- Collapse the foreground/background section. The full how-to lives
  in the run_command / run_background tool descriptions; the prompt
  only needs the picking rule.
- Compress the audit-mode rails (#610) prose around the six rails
  themselves. Every rail's load-bearing phrase is preserved verbatim
  so tests/code-prompt.test.ts still asserts on them.

Result: 24,387 → 11,956 bytes (-51%, ≈ 3,100 tokens per request).
Combined with PR #1320 / #1321 the cache-prefix tax per request is
now ~16k tokens instead of ~36k.

Behaviour unchanged — every rail / gate / mode constraint is still
asserted by the existing prompt tests.

Co-authored-by: reasonix <[email protected]>
2026-05-19 04:10:16 -07:00
YHHandreasonix 6e5fa83fa3 refactor(tools): compress tool spec descriptions (-28%, ~2.7k tokens/request) (#1321)
Every byte of a tool's description and JSON-schema parameter blob ships
in every request. The spec list had grown to 39,377 bytes (≈ 10k tokens)
across 35 tools — much of it teaching guidance that was already covered
in the system prompt, plus verbose schema property descriptions that
restated what the parameter name already conveys.

Compress the heaviest twelve tools while preserving the actually
load-bearing rules (don't-use-for-A/B/C-menus on submit_plan, plan-mode
gate behavior on run_command, prefix-stable warning on remember):

  submit_plan          3517 → 1648  (-53%)
  revise_plan          2554 → 1482  (-42%)
  create_skill         2379 → 1367  (-43%)
  install_skill        2293 → 1510  (-34%)
  search_content       2167 → 1263  (-42%)
  mark_step_complete   1988 → 1037  (-48%)
  add_mcp_server       1898 → 1213  (-36%)
  run_command          1862 → 1376  (-26%)
  todo_write           1675 →  872  (-48%)
  ask_choice           1807 → 1313  (-27%)
  remember             1812 → 1306  (-28%)
  run_background       1611 → 1186  (-26%)

Total: 39,377 → 28,412 bytes (-28%, ≈ 2,740 tokens per request).

Behaviour unchanged — guidance that lived only in tool descriptions
(plan-mode interaction, ChoiceRequestedError stop semantics, prefix
re-load timing) is preserved verbatim. The system prompt and tool
error messages already carry the longer-form teaching content.

scripts/measure-tool-sizes.mts added so future audits don't have to
re-derive per-tool byte counts by hand.

Co-authored-by: reasonix <[email protected]>
2026-05-19 04:09:36 -07:00
YHHandreasonix 548568b5e3 test(prompt-budget): lock cache-prefix byte budget (#1320)
Every byte of the system prompt and tool spec list ships in every
request — paid at full miss price the first time and ~10% on every
subsequent cache hit. Recent PRs have grown both without anyone
tracking the cumulative cost.

Lock the current values:
- CODE_SYSTEM_PROMPT.length ≤ 24_500 bytes
- code-mode tool spec list ≤ 40_000 bytes
- no single tool description > 8 KiB

A failing assertion forces the PR author to either compress an
equivalent section or raise the budget explicitly with a
justification in the commit message — visible in code review.

Co-authored-by: reasonix <[email protected]>
2026-05-19 04:05:45 -07:00
paradoxSCH 1e6b28f7e4 fix(client): skip extra_body for Azure endpoints (#1316)
Closes #1299
2026-05-19 03:59:43 -07:00
reasonix e105394f67 feat(desktop): re-enable devtools and context menu in release builds
Restores the `devtools` Cargo feature dropped in #1188 and removes the
contextmenu suppression. The original lockdown treated end users as
needing protection from the inspector; for an open-source dev tool
that argument inverts. Users can right-click → Inspect or use the
standard Cmd+Option+I / F12 shortcut to get a real console for bug
reports — which we lost any cheap path to after #1188 + #1297-class
issues started landing without reproducer info.

Reverts #1187 / #1188.
2026-05-19 02:32:03 -07:00
paradoxSCH 6cdc59ffca fix(permissions): strip literal '*' from always-allow prefix in Desktop/ACP (#1312) 2026-05-19 02:29:33 -07:00
Tianjiu 0c765e0f49 fix(desktop): prevent browser Select All when composer is not focused (#1314) 2026-05-19 02:26:51 -07:00
TaoMu 2bb40b2704 feat(lifecycle): add opt-in engineering lifecycle (#1306) 2026-05-19 02:24:44 -07:00
reasonix 0097cde077 fix(search): honor tavily engine + read engine at call time
`/search-engine tavily` silently fell back to Mojeek and got 403s,
two bugs stacked:

1. `webSearchEngine()` only branched on `searxng` / `metaso`; `tavily`
   slipped to the `mojeek` default. Add the missing arm.
2. `registerWebTools` cached the engine + endpoint at registration
   time via opts, so a runtime `/search-engine` switch never reached
   the tool fn. Drop both opts; read fresh inside the dispatch.

Closes #1309
2026-05-19 02:06:34 -07:00
reasonix 6df5791fc5 fix(tui): drop spinner + stream tick rate on legacy Windows conhost
`powershell.exe` runs under conhost, which repaints each Ink frame
visibly — at 8Hz spinner + 20Hz streaming flush, a maximized window
shows continuous flicker and content "jumping" during model reasoning.
Windows Terminal double-buffers, so this is conhost-specific.

Detect legacy conhost (win32 && !WT_SESSION && !TERM_PROGRAM) and
drop FAST_TICK_MS 120→250 and the default FLUSH_INTERVAL_MS 50→150
on that path. Modern terminals see no change. Reasoning streaming
goes from ~28Hz total repaints to ~11Hz on conhost.

Closes #1300
2026-05-19 01:20:00 -07:00
Tianjiu a0622c776f fix(desktop): use updater plugin for update check to bypass CSP (#1298)
About-modal's "Check for updates" was a raw WebView `fetch()` to `api.github.com`, which the Tauri CSP (`connect-src 'self' ipc: http://ipc.localhost`) blocks before the request leaves the browser. Switch to `check()` from `@tauri-apps/plugin-updater` — same R2 endpoint as the background auto-check, IPC → Rust, not bound by CSP. Drops the bespoke `cmpSemver` and the orphaned `checkNoRelease` i18n key.
2026-05-19 01:09:36 -07:00
TaoMu fbc38dd0cc fix(cli): resolve heap reexec entrypoint (#1303)
Switch heap re-exec to resolve the child entrypoint from `import.meta.url` instead of `process.argv.slice(1)`, preserve the parent's `execArgv` (so `--inspect` survives the relaunch), and treat Vitest workers as already-reexec'd so tests don't get process-replaced.
2026-05-19 01:08:09 -07:00
TaoMu 27e8137f88 feat(plan): persist step evidence metadata (#1302)
Adds optional `targets`/`acceptance`/`verification` to plan steps, structured `evidence` on `mark_step_complete`, and a v2 plan-store schema with v1 read fall-through. Final-step checkpoint UX flips to a finish/summarize label. Exposes `requireStepEvidence` and `onPlanStepCompleted` as host hooks; no caller wired here.
2026-05-19 01:06:45 -07:00
TaoMu 89e9c752aa feat(tools): add ordered interceptor chain (#1301)
Adds `ToolRegistry.addToolInterceptor(id, fn)` for ordered host-side interceptors; legacy single `setToolInterceptor` keeps working and runs after the chain. Intercepted short-circuit results now flow through the existing result augmenter, matching the normal tool-result path.
2026-05-19 01:04:22 -07:00
Orchard eaae901d27 style(desktop): add unified button:disabled style (#1304)
Thanks @Orchardxyz — neat one-rule fix, and the local opacity:1 override keeps the composer-attach visual intact. Merging.
2026-05-19 00:59:42 -07:00
reasonix 7965801689 style(i18n): join helpShellGate onto one line for biome formatter 2026-05-19 00:35:54 -07:00
Dimas D. Angga ac2c5cee88 fix(tui): surface per-call shell-exec approval flow on first run (#1278)
Thanks @dimasd-angga — surfacing the per-call gate in three first-run spots is exactly the right scope, and the tests pin it down well. CI tripped on a one-line biome wrap in zh-CN.ts; fixing that on main in a follow-up.
2026-05-19 00:35:17 -07:00
Richie 424838c00d feat(desktop): add selectable theme styles (#1291)
Thanks @lisniuse — the style/mode split is a clean abstraction and the four palettes look great. Merging.
2026-05-19 00:26:47 -07:00
Tianjiu f94de83550 fix(slash): reject // line-comment input as slash command (#1288)
Thanks @T1anjiu — clean fix with a focused test. Merging.
2026-05-19 00:24:21 -07:00
reasonix 8973327ebb fix(docs): point download mirrors at desktop-v* tag namespace
R2 path and GitHub release-asset URL were still built as `v${version}`,
but desktop bundles now live under `desktop-v${version}` after the
release-namespace split (#1153). Every Download-page link 404'd.
2026-05-18 23:29:40 -07:00
reasonix 520ed5c933 chore(release): 0.47.0 2026-05-18 22:39:47 -07:00
reasonix a6fb3a2608 fix(docs): guard mirror result lookup before probe results land
results is empty on the first render after the version resolves, so
results.find() returned undefined and reading .done crashed the page.
2026-05-18 22:33:22 -07:00
YHHandreasonix c636332251 chore(docs): pull version from the R2 mirror, drop the manual bump (#1280)
The docs site hardcoded `window.REASONIX_VERSION = "0.45.1"` in
i18n.jsx. Every release that didn't remember to bump that line left
the install commands, mirror grid, footer badge, terminal demo and
hero badge stuck at a stale value — and the bump was easy to forget
because no test catches it. By the time CLI was at 0.46.1 the docs
still read 0.45.1.

Read it dynamically from R2's `latest/latest.json` (which the release
mirror already keeps up to date, and which is desktop-release-owned
since the latest.json artifact only comes from desktop bundles). The
fetch kicks off as i18n.jsx loads, sets `window.REASONIX_VERSION` and
`window.REASONIX_VERSION_STATUS`, dispatches a `reasonix:version`
event when it settles.

`useVersion()` (a new hook in i18n.jsx) subscribes to that event and
returns `{ version, status: "loading" | "ok" | "failed" }`. Each
consumer renders a localised "正在获取…" / "fetching…" string while
status === "loading"; the mirror grid swaps its whole panel for a
"fetching latest version…" line because its URLs aren't usable yet.
On hard failure (5s timeout or non-2xx) we say so explicitly rather
than fall through to a stale literal — the previous behaviour was
already a stale literal, so "fetch failed" is a strict improvement.

No hardcoded fallback in i18n.jsx anymore; the value either comes
from R2 or doesn't come at all. The synchronous version.js shim I
prototyped first turned out worse than this — it would have meant a
console 404 on every page load until the next desktop release, and
no loading state for the in-between window.

Co-authored-by: reasonix <[email protected]>
2026-05-18 22:18:22 -07:00
YHHandreasonix f0f5799880 chore: drop pr-preview screenshot from repo root (#1281)
Squash-merge of #1274 inadvertently included `效果图.png`, the preview
image the contributor attached to the PR. GitHub renders PR-body
attachments without needing them tracked; in-repo binaries just bloat
git history and rot fast.

Co-authored-by: reasonix <[email protected]>
2026-05-18 22:17:26 -07:00
Bernardxu123 414b915f58 feat(tui): add background fill to input box and user card left stripe (#1274)
PromptInput:
- Add backgroundColor={SURFACE.bgInput} to the round-bordered input
  box, giving it a subtle dark fill that visually separates it from
  the chat area above.

UserCard:
- Replace the generic Card wrapper with a custom layout:
  - Left stripe: 1-column Box with backgroundColor={CARD.user.color}
    (brand blue), matching the input border color.
  - Card body: flexDirection="column" Box with
    backgroundColor={SURFACE.bgElev} (elevated surface) and
    paddingLeft={1}, creating a dark background panel.
- This gives user messages the same "blue stripe on dark panel"
  visual identity as the input box.

Performance: backgroundColor does not affect Yoga layout. One extra
1-wide Box per user card adds ~1 Yoga node per message — negligible.
2026-05-18 22:14:27 -07:00
Orchard 219afe8f79 style(desktop): replace pill-tag status labels with compact icons (#1277) 2026-05-18 21:52:44 -07:00
YHHandreasonix 080b385feb feat(desktop): show download progress in the update banner (#1276)
Before: the banner flipped to "Installing…" the moment the user clicked
Install and stayed there until the app relaunched. On a 30+ MB install
over a slow link that looked like a hang and the user had no signal
about what was happening.

Wire `pendingUpdate.downloadAndInstall((evt) => …)` to the Tauri
updater's per-chunk event stream:

  - Started — capture `contentLength` if the server sent Content-Length
  - Progress — accumulate `chunkLength` into a running byte counter
  - Finished — pin progress to total so we don't undershoot at the end

`UpdateBanner` now renders "Downloading · 4.2 MB / 31.7 MB (13%)" plus
the existing `.meter-mini` progress bar (already styled in styles.css,
just unused). When the server doesn't advertise a content length we
fall back to "Downloading · 4.2 MB" with no bar — better honesty than
a fake ratio.

The Later button disables during install so the user can't accidentally
dismiss a half-completed download. Failure state still shows the same
"Installation failed" line as before.

i18n strings (`downloading`, `downloadingUnknown`) added in en + zh-CN.

Co-authored-by: reasonix <[email protected]>
2026-05-18 21:49:25 -07:00
YHHandreasonix 06e449b277 chore(desktop): point updater at R2 mirror so CLI releases can't break it (#1275)
`tauri.conf.json` was pointing the updater at
`github.com/.../releases/latest/download/latest.json`. GitHub's "latest"
is a single global pointer — every CLI patch release moves it, leaving
the desktop updater chasing a 404 (or worse, the wrong artifact set).
With desktop and CLI on independent release cadences this would only
get more painful as CLI ships more often.

Two coordinated changes:

1. `tauri.conf.json` — list the R2 manifest first
   (`https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/latest.json`)
   and keep the GitHub URL as a fallback. R2 is owned by the desktop
   release pipeline, so it only changes when the desktop ships; CLI
   releases don't touch the `latest/latest.json` object.

2. `release-mirror.yml` — rewrite the platform `.url` fields inside
   `latest.json` to `<R2_BASE>/<TAG>/<file>` before uploading to R2.
   Without this the updater would fetch the small JSON from R2 but
   still pull the heavy installer from github.com, defeating the
   mirror. minisign signatures are bit-identical content checksums,
   so swapping the URL doesn't invalidate them. Skips gracefully when
   `latest.json` isn't in the release (CLI-only tags).

Public R2 base URL matches the one already hardcoded in
`docs/src/mirrors.jsx`, so the download page and the in-app updater
agree on where bundles live.

Co-authored-by: reasonix <[email protected]>
2026-05-18 21:37:17 -07:00
YHHandreasonix 48d2d9dd0c fix(tui): gate u undo to the 5s banner window so stale taps can't revert (#1272)
`u` used to fire whenever any non-undone edit batch sat in history, not
just while the auto-apply banner was visible. That meant a stray `u`
tap minutes (or hours) after a batch landed quietly rolled back files
the user had already moved on from. Issue #1249.

Drop the `|| hasUndoable()` half of the gate; only `undoBanner` keeps
the keybind live. Older batches now go through `/undo` (already wired,
takes an optional index), so nothing is taken away — just the
zero-friction destructive path.

The existing i18n copy already says "within the 5s banner" / "press u
within 5s", so no string changes are needed; the keybind now matches
what the UI was already telling users.

Co-authored-by: reasonix <[email protected]>
2026-05-18 21:15:17 -07:00
YHHandreasonix 34f44540eb fix(tui): sync system cursor after ink render so IME popups track ▌ (#1270)
Ink writes a full ANSI frame each render; the terminal's system cursor
ends up at the bottom of that output. IMEs query the system cursor for
candidate-window placement, so fcitx5 / ibus / Microsoft Pinyin draw
their popup at screen bottom instead of next to the visual ▌. Closes
#1261.

PromptInput now emits an absolute CUP escape (`\x1b[<row>;<col>H`)
inside a render-effect, landing the system cursor on the same cell as
the visual block:

- absolute row = stdout.rows − (linesBelow + largeHint + 3 + rowsAfter),
  where the constant 3 covers the blank marginTop, HintRow, and bottom
  border of the prompt box; rowsAfter is parent-supplied.
- absolute col = paddingX(1) + prefix(2) + cell width of the typed
  prefix on the cursor line, computed via stringCells so CJK and
  paste-sentinel widths land correctly.

ComposerArea passes rowsAfter that accounts for LoopStatusRow,
ModeStatusBar, and StatusRow rendered below the prompt box.

Cross-platform: no `process.platform` guard. The earlier Windows-only
attempt (closed PRs #1222 / #1225 / #1232) used relative CUU+CUF
without `\r`, which drifted on CJK columns and skipped Linux entirely.

Co-authored-by: reasonix <[email protected]>
2026-05-18 20:59:21 -07:00
wade1999 402e7fd2e7 docs(qq): tighten setup guide and readmes (#1269) 2026-05-18 20:51:48 -07:00
YHHandreasonix fba0b421f9 feat(tui): ctrl+r verbose toggle + esc-esc rewind picker (#1268)
Two more claude-code-parity bindings:

ctrl+r — verbose mode
  - New VerboseContext provided at App.tsx, toggled by global ctrl+r
    keystroke. Persists across turn boundaries; resets on restart.
  - ReasoningCard skips head/tail elision while verbose, rendering the
    full body via the existing BodyLines slice.
  - ToolCard drops the per-tool tail cap while verbose; full stdout
    flows through instead of the last 2-5 lines.
  - Status info row toasts the new state on every toggle.

esc esc — rewind to a previous user turn
  - New loop.rewindToUserTurn(N) — mirrors retryLastUser but takes a
    0-based user-turn ordinal so the picker can target any earlier
    turn, not just the last one. Reuses rewriteSession for disk
    persistence; in-memory log compacts even if the disk write fails.
  - New session.fork reducer event splices state.cards at a given
    cardId; everything from that card onward is dropped.
  - New EditPicker overlay component lists user cards with previews;
    ↑↓ to focus, Enter to pick, Esc to cancel.
  - App.tsx tracks the previous Esc timestamp; a second Esc inside
    500 ms with an empty composer + no other modal collects the user
    turns and opens the picker. On selection, the loop rewinds, the
    cards splice, and the composer pre-fills with the original text.

/keys reference + i18n strings updated in EN + zh-CN.

Co-authored-by: reasonix <[email protected]>
2026-05-18 20:44:21 -07:00
YHHandreasonix 86b7a8a4ae docs(readme): note Claude-format skill paths load, with OpenSpec example (#1267)
Spell out that .claude/skills/<name>/SKILL.md loads alongside .reasonix/skills/
(landed in #1259) and show the OpenSpec recipe — `openspec init --tools claude`
drops openspec-* skills that invoke through /skill openspec-propose, no upstream
adapter needed. Closes #1184.

Co-authored-by: reasonix <[email protected]>
2026-05-18 20:34:16 -07:00
Bernardxu123 03812d9f65 fix(tui): center WelcomeBanner vertically when chat is empty (#1264)
When the chat has no messages and the terminal window is resized
taller, CardStream's flexGrow=1 inner Box expands to fill all
available vertical space above the composer. Since the stream has
no cards to render, this creates a large blank gap that pushes the
WelcomeBanner just above the input — making the UI feel top-heavy
and uncentered on tall terminals.

Fix: wrap the WelcomeBanner in a flexGrow=1 Box with
justifyContent="center". This Box competes with CardStream for
the remaining space in the parent flex column. When CardStream is
empty (0 intrinsic height), the two flexGrow children split the
available space equally — so the WelcomeBanner renders near the
vertical center instead of flush against the composer.

This has no effect when the chat has messages — the WelcomeBanner
is not rendered (hasConversation is true), and CardStream retains
its full flexGrow for scrolling as before.

Refs #743
2026-05-18 20:11:59 -07:00
YHHandreasonix fac3046495 feat(tui): align composer keys + visuals with Claude Code (#1263)
* feat(tui): surface /copy in the scroll hint + full-row background on history hints

Two complementary discoverability nudges for the existing copy-mode
entry point and the "scrolled up" state:

- CardStream's ScrollIndicator now appends `· /copy to select` after the
  PgUp/wheel/↑ keys, so anyone who notices the scroll affordance sees
  the copy entry next to it without digging through /help.
- Both `<ScrollIndicator>` and the two history-hint paths
  (InputAreaWithHistoryHint + ComposerArea.HistoryHint) pad to the full
  terminal width with `SURFACE.bgElev` background, so the row reads as a
  status band instead of a stray faint line. `string-width` handles the
  CJK + arrow glyphs in the localized text correctly.

New i18n key `cardStream.scrollCopy` (EN / zh-CN / types).

* feat(tui): align composer keys + visuals with claude code

- ↑/↓ now navigate prompt history (single-line) or move the per-line
  cursor (multi-line). Ctrl+P / Ctrl+N kept as readline aliases.
  Chat scroll is owned by PgUp/PgDn + mouse wheel + End; arrows only
  scroll when the composer is hidden (chat scrolled up).
- Drop the duplicate ↑/↓ branches from the App-level picker handler
  — picker selection now flows through PromptInput → historyHandoff
  → handleHistoryPrev/Next which already checks pickers first.
- Composer border: top + bottom rule only (no left/right verticals),
  muted FG.meta color when active.
- StatusRow: each segment wraps in a Pill with bgElev background +
  1-space padding, separated by a 1-cell gap. Dot separators removed.
- UserCard body now carries a subtle bgElev background so the user
  can spot their own messages while scrolling.
- HintRow shows ↑↓ instead of ^P/^N for prompt history.
- /keys + tipMouseClipboard + scrollPgUp + scrollCopy strings updated
  in EN + zh-CN. /copy reads "enters copy mode" / "进入复制模式" to
  avoid the "did that just copy?" misread.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-18 20:11:11 -07:00
YHHandreasonix e4a2c9fa5e feat(tui): re-enable SGR mouse wheel by default (revisit #514) (#1262)
#514 dropped mouse tracking because it broke native drag-select copy
and right-click. The trade was made on the assumption that every terminal
in alt-screen translates wheel→arrow for free. In practice (Windows
Terminal + PowerShell at least) that translation doesn't always fire, so
the wheel just scrolls the parent terminal's scrollback while the TUI
sees nothing. Users have been complaining about exactly this.

Re-enables `\x1b[?1000h\x1b[?1006h` at chat-command startup; the existing
SGR parser in stdin-reader.ts already maps `mouseScrollUp/Down` to
chat scroll. Disable escape fires on the `finally` cleanup, plus
`process.once("exit"|"SIGINT"|"SIGTERM")` so the sequence can't leak into
the parent shell on a hard kill.

Trade-off acknowledged: native drag-select stops working with mouse
tracking on. Shift+drag still selects in most terminals (Windows Terminal,
iTerm2, GNOME Terminal all honor the shift bypass). For users who'd
rather have native select than wheel, `mouseTracking: false` in
~/.reasonix/config.json opts out.

Co-authored-by: reasonix <[email protected]>
2026-05-18 19:12:25 -07:00
YHHandreasonix 607839b4b3 feat(search): add Tavily as web_search backend — escape hatch for Mojeek 403s (#1260)
* feat(search): add Tavily as web_search backend — escape hatch for Mojeek 403s

Users hit "frequent 403s" with the default Mojeek backend; it's HTML scraping
with no official API and gets anti-bot'd routinely. Tavily is the LLM-agent
ecosystem's de facto answer: real JSON API, 1000 searches/month free per
account, returns LLM-ready snippets out of the box.

- `webSearchEngine` accepts `"tavily"` alongside mojeek/searxng/metaso.
- New `tavilyApiKey?: string` in config + `loadTavilyApiKey()` (env > config >
  undefined). No baked-in default — the free quota wouldn't survive being
  shared across the user base; missing key throws a setup-pointing error.
- `searchTavily` POSTs to api.tavily.com/search and maps 401/403/429 to
  actionable hints.
- `/search-engine tavily` slash entry + EN/zh-CN strings.
- 5 new test cases — missing-key error, body wire shape, 401/403, 429,
  empty-results.

Default is still Mojeek; switching is one slash command. Users who get
fed up with 403s now have a free, no-scraping path that doesn't require
self-hosting (SearXNG) or a Chinese phone number (Metaso).

* test(web): use plain-string assertion for tavily.com signup URL message

CodeQL's `js/regex/missing-regexp-anchor` rule flags `/tavily\.com/` as a
URL check that could match `evil-tavily.com.attacker.org` — it can't tell
the regex is matching inside an error-message string, not a URL field.
Switching to `.toThrow("tavily.com")` preserves the substring-match
intent (does the error mention the signup URL?) without the regex.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-18 18:04:30 -07:00
YHHandreasonix 1d867554b4 feat(compat): read Claude .mcp.json + .claude/skills; alias type/context/agent fields (#1259)
Stage 1 of the Claude-compat layer — the cheapest 80% of "users can't copy
their Claude config / skills folder over" complaints. No behaviour change for
existing Reasonix users; new paths and aliases are additive.

MCP:
- `McpServerConfig` accepts `type` as an alias for `transport`; `"http"` maps
  to `"streamable-http"` so Claude's shorter spelling Just Works.
- New `src/mcp/dot-mcp-json.ts` reads project-level `.mcp.json` from cwd; its
  `mcpServers` block merges into the resolved config with project entries
  winning on name collision (same precedence Claude uses for shared,
  git-committed team configs).

Skills:
- `~/.claude/skills/` and `<project>/.claude/skills/` join the existing
  Reasonix + .agents search roots, so a user can drop a Claude skill folder
  in and it loads.
- Frontmatter aliases: `context: fork` and any non-empty `agent:` both flip
  runAs to `subagent` — same semantics, different field names.
- Missing `description:` now warns to console instead of silently dropping
  the skill from the prefix index (the most common "why doesn't my skill
  show up" footgun).

Co-authored-by: reasonix <[email protected]>
2026-05-18 17:31:16 -07:00
wade1999 e4f77af083 fix(qq): improve first-connect onboarding and i18n (#1257) 2026-05-18 17:03:21 -07:00
Xie'Sheng 315acfc461 ui(composer): scroll active slash command into view (#1255)
When navigating / and @ popups with arrow keys, the active item can
fall outside the visible area because the popup-list has overflow-y:
auto but no programmatic scroll. This makes keyboard navigation
difficult when the item count exceeds the container height.
2026-05-18 16:56:56 -07:00
YHHandreasonix e9a7890950 chore(search): bump regex worker timeout 5s → 60s, walk deadline 15s → 120s (#1254)
V8 finishes any non-catastrophic regex on the 2 MiB per-file cap in seconds
even on slow hardware; the previous 5 s default risked false-positives on
WSL / low-end laptops while only marginally improving the catastrophic
case (ESC tears the worker down immediately anyway). 60 s gives generous
headroom for legitimate slow patterns; ReDoS still gets caught — just after
a longer unattended wait.

Walk deadline must stay above the per-file timeout so one timed-out file
doesn't always trip the walk guard immediately after; 120 s leaves room for
a second slow file plus the rest of the walk.

Co-authored-by: reasonix <[email protected]>
2026-05-18 16:55:31 -07:00
Richie af44361eab feat(desktop): restore window, tabs, sessions and scroll on relaunch (#1253)
Relaunching only kept the set of open tab workspaces (#933); window
geometry, the focused tab, each tab's conversation and scroll position
were all lost.

- Window — replace the hand-rolled useWindowBounds hook (debounced
  save, no flush on close, unreliable window.screenX/innerWidth) with
  tauri-plugin-window-state, which persists size/position/maximized
  and restores them cross-platform.
- Per-tab session — desktopOpenTabs persists { dir, session, active }
  instead of a bare dir; bootstrapTab reopens the conversation when
  its jsonl is still readable.
- Active tab — the frontend reports focus via a tab_activate message;
  $tab_opened carries an `active` flag so restore reopens on the
  previously focused tab, not the last one created.
- Scroll — the transcript offset is saved per session and restored on
  boot via useAutoScroll, which stays un-pinned so growth doesn't yank
  it back to the bottom.
- Sidebar and context panel collapse state persist to localStorage.

Closes #1244
2026-05-18 16:51:39 -07:00
YHHandreasonix 9f9af5e3c5 feat(search): isolate regex in a worker thread so ReDoS can be terminated (#1250)
V8's regex engine is uninterruptible and exponential for catastrophic
patterns (`(a+)+!`, `(.+)*x`); the walk-level 15 s deadline can't help if a
single re.test wedges inside one file. Moves the per-line regex match into a
worker thread and arms a 5 s timeout per file: a wedged worker is killed via
worker.terminate(), the file is recorded as `regex timed out` in the result
footer, and the walk continues with a freshly spawned worker.

- new src/tools/fs/regex-runner.ts: singleton Worker manager with eval-mode
  source so nothing has to resolve a path at runtime, AbortSignal-aware so
  ESC tears the worker down immediately, and a respawn-on-timeout policy
  that keeps the singleton useful across the rest of the session.
- search.ts: regex path now delegates to the runner; substring fallback
  (invalid regex) stays inline. Regex-timed-out files are summarised in a
  single footer line.

Zero new deps — uses Node's built-in node:worker_threads. Cold-start cost is
amortised across calls in the same TUI session.

Closes the catastrophic-regex limitation called out in #1246 / #1236.

Co-authored-by: reasonix <[email protected]>
2026-05-18 09:28:39 -07:00
Richie 91231eb436 test(code-prompt): bound .gitignore truncation against a baseline (#1248)
The truncation test asserted out.length < CODE_SYSTEM_PROMPT.length +
5000. The builtin Skills index injected by applyMemoryStack has since
grown past that 5000-char slack, so the test fails even though
truncation still works.

Compare against a prompt built with a tiny .gitignore instead, which
isolates the truncation delta from fixed-size, unrelated prompt
additions — the bound no longer goes stale as the prompt grows.
2026-05-18 09:27:47 -07:00
Xie'Sheng 5f1a710d9b fix(desktop): enable skill execution via slash command syntax (#1247)
- Implement regex-based slash command detection in `TabRuntime` to trigger skills
- Add `insertOnly` property to `SlashCmd` to support command insertion without immediate execution
- Update `Composer` to handle conditional execution based on `insertOnly` flag
- Include `state.skills` in `TabRuntime` dependency array for correct command matching
2026-05-18 09:25:52 -07:00
wade1999 f9d56f7cc3 feat(desktop): add QQ settings entry (#1241) 2026-05-18 09:23:00 -07:00
J3y0r 3ae7dad3dc fix(cli): preserve flash preset auto-escalate semantics (#1240)
Pass resolved preset metadata through chat/code startup so flash remains pinned without auto escalation and the UI initializes with the correct preset label.
2026-05-18 09:20:13 -07:00
YHHandreasonix 85face1624 fix(tools): walk-level deadline for search_content + ESC preempts queued tools (#1246)
* fix(tools): bound search_content runtime and let ESC preempt queued tools

Three small hardening steps for the "ESC sometimes can't interrupt" complaint:

1. search_content now feeds at most 8 KiB of each line to the matcher.
   A minified-JS one-liner can be MBs on a single line; both the lowercase
   pass and the regex hot loop chewed through the whole thing per file, and
   `throwIfAborted` only ran *between* lines so ESC was useless. Truncating
   the matcher input doesn't change real-world matches (displayed slice is
   already capped at 200 chars).

2. search_content honours a 15-second soft deadline across the whole walk,
   checked at every directory entry. A pathological regex that wedges inside
   `re.test` is still uninterruptible (V8 doesn't yield), but any sane walk
   that's just slow now fails loudly instead of pinning the turn forever.

3. tools.ts central dispatch refuses to start a tool whose signal is already
   aborted, returning a uniform `{ rejectedReason: "aborted" }` error. ESC
   during a queued sequence (e.g. five read_file calls in flight) now stops
   calls 2-5 from running to completion after the user gave up — each tool
   needn't re-implement the entry-time check.

Closes #1236 — wait, #1236 is already closed; this is the follow-up the
maintainer's audit identified.

* fix(search): drop per-line truncation — accuracy regression for narrow benefit

The 8 KiB LINE_MATCH_BUDGET was the wrong axis: catastrophic regex
backtracking is exponential, so 8 KiB still hangs V8's regex engine; meanwhile
legitimate long-line files (single-line JSON dumps, source maps, generated
data) silently lost matches past the first 8 KiB. Reverts the truncation;
the 2 MiB per-file cap (pre-existing) is the real upper bound on matcher
input, and the 15 s walk deadline catches slow-but-not-infinite searches.

Catastrophic regex (`(a+)+!` shape) remains uninterruptible inside re.test —
that's a separate problem that needs a worker-thread or RE2 swap, not input
truncation.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-18 08:44:57 -07:00
YHHandreasonix 6c1d7e91fe fix(mcp): let ESC abort hung MCP handshakes during startup (#1243)
The ESC handler in App.tsx was gated on `submittingRef.current || isLoopActive()`
— neither is true during the deferred MCP bridge that runs in a mount-time
useEffect, so a server stuck in `initialize()` would pin boot for the full 60s
per-request timeout with no escape. Ctrl+C still killed the whole process, but
ESC silently swallowed.

Threads an AbortSignal from App.tsx → mcpRuntime.addSpec → McpClient.initialize
→ request, then adds an ESC condition that fires before the turn/loop branch:
when no turn is active and there are pending MCP aborters, abort them all and
log a hint pointing at /mcp for retry. The underlying client is closed in the
existing catch branch in addSpec, so the stdio child is killed cleanly.

Closes #1236

Co-authored-by: reasonix <[email protected]>
2026-05-18 08:03:50 -07:00
YHHandreasonix 124c1ac4a8 feat(desktop): render LaTeX math via KaTeX in chat messages (#1239)
Adds remark-math + rehype-katex + katex so `$E = mc^2$` and `$$ ... $$`
blocks typeset as real equations instead of leaking raw markup. Inline
math comes from `$...$`, display math from `$$...$$`; math inside fenced
code blocks stays as code because the `pre > code` override runs first.

throwOnError: false on rehype-katex so a half-typed `$` in a mid-stream
chunk degrades to plain source instead of throwing — important because
the streaming pipeline can split tokens anywhere.

KaTeX CSS is imported once in main.tsx; Vite picks up the woff2/woff/ttf
font URLs and emits them as bundled assets, so first render is offline-
safe (no CDN fetch at runtime) — that matters for a Tauri app.

Closes #1235

Co-authored-by: reasonix <[email protected]>
2026-05-18 07:48:03 -07:00
YHHandreasonix 426366131f fix(desktop): bundle universal Node on macOS so Intel Macs don't blank-screen (#1237)
The macos-universal release shard runs on an ARM64 runner, so bundle-node.mjs
was downloading darwin-arm64 Node and shipping it inside a binary advertised
as universal. Intel Macs couldn't exec the sidecar, Tauri's child spawn failed
silently, and the WebView came up blank.

Bundle script now downloads both arm64 and x64 Node distributions on darwin
and lipo's them into one universal Mach-O. CI verifies the result with
`lipo -archs` before tauri-action runs, and asserts `desktop/dist/index.html`
exists so a silent frontend build failure can't ship an empty bundle either.

Co-authored-by: reasonix <[email protected]>
2026-05-18 07:45:25 -07:00
YHHandreasonix 473c62e83e chore: add Support section with PayPal + WeChat Pay donation channels (#1238)
International donors can send via PayPal (paypal.me/yuhuahui); domestic users
scan the WeChat Pay QR. README copy frames it as "coffee, not contract" —
donations don't buy feature priority or change issue triage, which keeps the
project's cheap-and-narrow positioning intact.

Co-authored-by: reasonix <[email protected]>
2026-05-18 07:37:22 -07:00
Bernardxu123 87428159d7 feat(desktop): add /compact, /retry, /btw + /feedback slash commands (#1231)
Brings the desktop's slash-command list to parity with four CLI
commands that were already in src/cli/ui/slash/commands.ts but not
plumbed through to the desktop sidecar.

Backend (src/cli/commands/desktop.ts) — three new RPC handlers
wiring to existing loop methods:
- compact_history → loop.compactHistory()
- retry          → loop.retryLastUser(), emits $retry_result
- btw            → side-channel loop.client.chat() with a fresh
                   {system, user} payload (NOT the running log),
                   emits $btw_result

Frontend (desktop/src/App.tsx):
- /feedback opens the GitHub new-issue URL in the system browser
- /compact, /retry fire their RPC and rely on the existing $error
  event for failures
- /btw sets draft to "/btw " and the send() handler intercepts the
  prefix and routes to the btw RPC. The answer appends as a status
  message (UI-only, never persisted) — matches "side question, never
  in context" semantics.
- $retry_result drives a retryNonce counter so the useEffect that
  fills the composer fires exactly once per retry.

Refs #1220 (desktop ↔ CLI parity tracking).
2026-05-18 06:05:10 -07:00
Orchard a24ea7298f feat(desktop): split export into dedicated copy and file-export actions (#1229)
The header "Export" button only copied the conversation to the
clipboard — the label promised something different than the action
delivered. Split it into two:

- Copy → full conversation Markdown to clipboard (same content
  as before).
- Export → native save dialog, writes a UTF-8 .md file via a new
  write_text_file Tauri command. Gated by the existing capability
  system; the path comes from the OS save dialog, so it's
  user-chosen.

Factor the shared Markdown rendering into formatConversationMarkdown
to drop duplication between the titlebar menu, the main-head buttons,
the command palette, and the slash-action surface.

en + zh-CN i18n updated consistently; new dialog:allow-save Tauri
capability + a copy icon added.
2026-05-18 05:52:27 -07:00
Tianjiu 87961386d8 feat(desktop): persist window size and position across sessions (#1224)
The desktop already persists theme / font scale / font family /
currency via localStorage, but the window itself reset to the
default 1024×720 centered bounds on every launch.

New useWindowBounds hook follows the existing useAutoScroll /
useDisableTextAssist extraction pattern: restores
reasonix.windowBounds on mount, debounces 300 ms saves on
tauri://resize + tauri://move, skips saving while maximized (that
state belongs to the OS), and gates restore on a 400×300 minimum
so corrupted data can't reopen the window in an unusable size.
App.tsx calls the hook once alongside the other persistence hooks.

decorations: false in tauri.conf.json means the WebView viewport
equals the outer window, so innerWidth/innerHeight round-trip
cleanly through setSize without frame-chrome drift.

Closes #1206
2026-05-18 05:24:00 -07:00
Bernardxu123 0b5df530e4 fix(desktop): remove Geist sans-serif from serif font stack (#1223)
The serif font stack in theme.ts had `"Geist"` as its first entry,
but Geist is a geometric sans-serif (same one the Sans option uses).
Latin glyphs matched Geist immediately and never fell through to
Georgia — selecting Serif in Settings looked identical to Sans on
English text. CJK text changed only because neither Geist nor
Georgia covers CJK and the OS picks something up the chain.

Drop Geist from the serif stack; Georgia + Times New Roman are
built-in on macOS / Windows and widely available on Linux.

Closes #1212
2026-05-18 05:21:55 -07:00
YHH 77895624e8 feat(dashboard): new / switch / delete sessions in-place, no CLI bounce (#1226)
The dashboard /sessions panel could only browse — to actually create,
swap, or delete a session you had to bounce to the CLI. Users got
stuck wondering why nothing was clickable.

Wire the existing onSwitchSession path through to the dashboard
context, then expose:
- POST /api/sessions/new           → mint fresh
- POST /api/sessions/<name>/switch → swap into existing
- DELETE /api/sessions/<name>      → unlink (409 on the active
  session, 404 if missing)

GET response gains currentSession + canSwitch so the SPA degrades
gracefully in standalone mode. Panel: + New button, per-row switch
+ delete (disabled on the active row), active-session pill, falls
back to the "resume in TUI" hint when no CLI is attached.

9 new tests cover both attached + standalone paths.

Closes #1216
2026-05-18 05:18:48 -07:00
YHH 150b8b6d65 chore(desktop): i18n the per-message copy button tooltips (#1221)
Two hardcoded title attributes from #1208 routed through thread.copyMessage / thread.copyResponse so the tooltips swap with the rest of the UI.
2026-05-18 04:59:53 -07:00
Tianjiu ffd5294f73 fix(desktop): per-message copy button + relabel "Clear messages" → "Clear screen" (#1208)
Two desktop UX fixes:

Per-message copy. The Ctrl+K palette only offered "copy last reply" or
"export everything"; there was no way to grab a single turn in the
middle of a long thread. UserMsg and AssistantMsg each gain a small
copy button at the bottom-right of the body. UserMsg copies raw text;
AssistantMsg concatenates `.kind === "text"` segments only (reasoning
and tool calls excluded). 1.2s "copied" indicator reuses the existing
.copy-btn pattern.

Relabel "Clear messages" → "Clear screen" (清屏). The clear action
only zeroes the local UI — session data persists on disk and reloads
from the sidebar. The "Clear messages" label implied permanent
deletion; "Clear screen" matches what the action actually does. The
hint (clearChatHint) already correctly stated "Clear the UI only —
subprocess stays alive".
2026-05-18 04:52:44 -07:00
wade1999 1ea15305b9 fix(desktop): tighten slash picker labels (#1195)
Shorten preset pill labels (`deepseek-v4-flash` / `deepseek-v4-pro`
→ `v4-flash` / `v4-pro`) so they fit the composer bottom row without
truncation.

When a skill ships without a description, fall back to a localized
`{scope} skill · {runAs}` string instead of the previous hardcoded
`skill · ${s.scope}`. Both en + zh-CN get scope + runAs entries.

Add `text-overflow: ellipsis` to `.popup-item .desc` so an overly
long skill description can't dominate the slash picker.
2026-05-18 04:47:05 -07:00
YHH 0f19a88be4 fix(desktop): drop regex lookbehind so the bundle parses on macOS Monterey WKWebView (#1215)
Tauri's WKWebView on macOS 12 (Monterey) ships with WebKit older than
16.4 — no lookbehind support. Hitting (?<=...) falls into the
named-capture-group parser, sees `=` as the first character of the
name, and throws "invalid group specifier name" at script parse time
before any JS runs. User sees a black window.

Two lookbehinds were shipping:

- FILE_PATH_RE in Markdown.tsx — rewritten as a leading capture group;
  splitFilePaths advances past the captured prefix to keep FilePill
  boundaries unchanged.
- mdast-util-gfm-autolink-literal's email regex — the package's
  previous() callback already validates the preceding character after
  the match, so the lookbehind was just a fast-fail. Stripped at
  build time via a small Vite transform plugin scoped to that
  module's id.

Verified: zero (?< occurrences in the rebuilt bundle.

Closes #1209
2026-05-18 04:23:58 -07:00
YHH e8df4b46d5 fix(desktop): surface "session loaded empty" instead of looking like a no-op click (#1210)
The original #1179 report ("click a session, nothing happens, terminal
shows no logs") had a quieter cause than the slow-list path #1207
addressed. If loadSessionMessages returns [] (file 0 bytes,
all-malformed lines, etc.), the sidecar emits $session_loaded with
messages:[] and the renderer re-renders an empty chat — click looks
dead, stderr stays silent because nothing failed.

Three changes:
- session_load now stderr-logs empty/failed loads AND emits a new
  $session_empty event with the byte count
- App.tsx renders $session_empty as a visible inline error message
  in the chat ("Session 'X' loaded with no messages (N bytes)…"),
  with restore-from-.bak guidance
- the rpc-dispatch unknown-tab path stops silently dropping events
  (the previous emit had no tabId, so the renderer's per-tab router
  ignored it) — switched to stderr.write so it's observable

Closes #1179 (follow-up to #1207)
2026-05-18 04:01:07 -07:00
YHH f26c39333d perf(session): skip cross-workspace jsonls + byte-scan line count, ~10× faster sidebar list (#1207)
The desktop sidebar's session list was reading every .jsonl in
~/.reasonix/sessions/ in full just to count lines, then filtering by
workspace — multi-second stalls on every list refresh once any
session got large.

Two changes:
- listSessions takes an optional workspaceFilter and reads meta.json
  before statSync + countLines. Sessions outside the current
  workspace skip the (potentially multi-MB) jsonl read entirely.
- countLines drops the UTF-8 decode + regex split + filter chain for
  a raw Buffer byte-scan. ~10x faster on multi-MB jsonls. Still
  picks up a dangling final line on hand-edited files.

Also incidentally addresses #1179 — the "click doesn't load" report
was the post-click emitSessions round-trip blocking on every-jsonl
re-read, not the click itself.

Closes #1179
2026-05-18 03:47:22 -07:00
YHH dbcd2f2271 feat(memory): inject ancestor AGENTS.md into list_directory, not just read_file (#1203)
Subdir AGENTS.md / REASONIX.md / AGENT.md has worked since #1033 but
only via read_file. If the agent runs list_directory pkg/foo and then
picks what to read next, the guidance in pkg/foo/AGENTS.md never
surfaces — the picking decision is blind.

Add findDirMemory(absDir, rootDir) that walks the listed dir +
ancestors innermost-first. Wire it into list_directory with the same
once-per-session dedupe as read_file. findSubdirMemoryAncestors stays
as a thin wrapper for read_file. Root REASONIX.md still excluded —
it's already in the system prompt.

7 new tests cover the new walker plus the list_directory integration.

Closes #1160
2026-05-18 03:25:52 -07:00
Richie 3378b0e221 feat(desktop): add platform-aware keyboard shortcut rendering (#1199)
Replace hardcoded Mac-only keyboard symbols (⌘, ⇧, etc.) with a
platform-aware <Shortcut> component. macOS keeps ⌘ / ⇧; Windows and
Linux render Ctrl, Shift, etc. Platform detection in main.tsx now
recognises Windows in addition to macOS, setting data-platform on
<html>.

Adds desktop/src/ui/shortcut.tsx with:
- Shortcut component for inline kbd rendering
- shortcutText() for plain-text shortcut strings
- localizeShortcutText() to swap ⌘ inside already-translated strings

Updates every hardcoded <kbd> across App, CommandPalette, cards,
composer, jobs-pop, settings, sidebar, statusbar, workdir-pop.

Closes #1095
2026-05-18 02:49:44 -07:00
Tianjiu ee15e9fe0d fix(mcp): add runtime schema validation for MCP server responses (#1198)
MCP responses cross a network boundary — TypeScript types are
compile-time only. validateResultShape() now runs at the top of
flattenMcpResult() (the single funnel every MCP result passes through
before reaching the model), and rejects malformed payloads with a
useful error string instead of letting them bubble up as opaque
`.map is not a function` or similar crashes downstream.

Covers: non-object result, missing/non-array content, non-object
block, unknown block type, non-string text, non-string image data,
non-string image mimeType. 8 unit tests in tests/mcp.test.ts cover
every rejection path plus a valid-result passthrough.
2026-05-18 02:47:37 -07:00
YHH 2a890c5061 fix(desktop): catch context-panel render errors instead of white-screening the app (#1200)
A user reported the desktop window went white when opening the right
panel's Tools tab after configuring an MCP server (#1197). With no
error boundary in place, a render error in any panel sub-tree
unmounted the whole React tree.

Add a small PanelErrorBoundary, wrap the context-panel tab body with
it (keyed by tab so switching resets), and harden the $mcp_specs
reducer against malformed payloads. Future render errors now show an
inline message with the actual error string instead of white-screening
the app — the rest of the UI stays usable and the user can copy the
error back to us.

Closes #1197
2026-05-18 02:42:59 -07:00
YHH 9288fb2a93 chore(desktop): swap placeholder R icon for the repo brand mark (#1196)
The desktop client shipped with a purple-gradient "R" placeholder
that didn't match the brand on the docs site or npm page.

Regenerate every platform icon (macOS .icns, Windows .ico, Windows
Store squares, Linux PNG sizes, Android adaptive, iOS app icon set,
master icon.png) from docs/brand/reasonix-logo-square.png via
`tauri icon`, and replace desktop/icons/source.svg so future
regenerations stay consistent.

Closes #1117
2026-05-18 02:08:09 -07:00
reasonix 8638530792 docs: dedupe contributors wall, keep one in Acknowledgments
The Community section used to carry its own contrib.rocks avatar wall;
the new Acknowledgments section at the bottom now does too. Drop the
upper one, point the Community NOTE callout at the bottom wall, and
upgrade the remaining wall to the wider max=100 / 12-column layout.
2026-05-18 01:51:33 -07:00
reasonix 05705dcb97 docs: add Acknowledgments section thanking top contributors
Calls out nine contributors selected by both commit count and code
volume, plus separate thanks for the logo designer and the XiaoHongShu
promoter. Listed alphabetically with an explicit "no ordering of
importance" note. Also adds a contrib.rocks avatar mosaic and fixes
the footer link to the canonical esengine/DeepSeek-Reasonix path.
2026-05-18 01:41:15 -07:00
Richie e3a56c14e9 fix(desktop): localize remaining hardcoded UI copy (#1194)
The desktop language switch still left some UI in hardcoded English or
Chinese — context panel tabs, sidebar relative times, status bar
labels, approval / plan / checkpoint cards, markdown copy + open
tooltips, composer footer, and exported markdown summaries.

Route the remaining user-facing strings through the existing
desktop i18n dictionaries and fill matching keys on both sides. The
zh-CN file's `typeof en` constraint locks both halves in sync.

Closes #1192
2026-05-18 01:39:10 -07:00
reasonix 3ef9dde09c docs: surface Discord community link in README hero
Bold the Discord nav link and add an IMPORTANT callout right before
the Install section. Drowned out among 7 nav links + 9 badges, the
Discord invite was effectively invisible on first scan; the callout
puts it at the same level as the cache-stability TIP and case-study
NOTE, with the channel layout and contributor-verify hook visible
in the first screen.
2026-05-18 01:28:10 -07:00
YHH f65677e070 fix(mcp): surface actual bridge-failure reason in dashboard, not just "restart" (#1191)
When an MCP server in config.json failed to bridge (wrong URL,
network 404, smithery streamable endpoint unreachable, missing auth
header, etc.), the dashboard showed a generic "未桥接 / unbridged" with
a "restart reasonix code" hint that didn't help — the bridge had
already run, errored, and we dropped the reason.

Track the last bridge failure per spec in mcp-runtime, expose it via
runtime.failures(), pipe through /mcp/specs, and render the actual
error reason inline in the dashboard MCP panel.

Adds 3 unit tests for the new failure tracker.
2026-05-18 01:25:28 -07:00
YHH bf64097412 fix(desktop): also re-pin auto-scroll on turn start, not just turn end (#1189)
Until now, only busy→idle re-pinned to the bottom. If the user had
scrolled up earlier (un-pinned) and then sent a new message, the new
turn rendered off-screen until the turn completed.

Treat both busy edges the same: turn start = user just acted and
expects to see the new turn; turn end = settle on the final answer.

Closes #1182
2026-05-18 00:54:47 -07:00
Richie 87f233a389 feat(desktop): disable right-click and DevTools in production builds (#1188)
Drop the `devtools` Cargo feature so the inspector is compiled out of
release builds entirely; Tauri auto-enables it on debug, so `tauri dev`
is unchanged.

Add a small prod-guard that suppresses the `contextmenu` event in
production via `import.meta.env.DEV`, exempting input / textarea /
contenteditable so native copy-paste still works.

Closes #1187
2026-05-18 00:39:50 -07:00
LeekJay 7856ac9172 feat(embedding): batch OpenAI-compat embedding requests to respect provider batch limits (#1175)
embedAllOpenAICompat sent ALL texts in a single API call. Providers like
Aliyun Bailian's text-embedding-v4 cap batch size at 10 items/request,
so files with more chunks than the cap got HTTP 400.

Split input into batches of batchSize (default 10) and thread the option
through config types, builder, semantic tool, server API, and the
dashboard form.
2026-05-18 00:37:20 -07:00
YHHandreasonix 601dd4c8d4 fix(desktop): Ctrl+K → About opens the modal, not a one-line toast (#1186)
The command-palette `about` handler was wired to `flashToast("Reasonix · …")`,
which only flashed the version string for a second. With the new AboutModal
in place, route the palette entry to the same `setAboutOpen(true)` the
sidebar row uses.

Co-authored-by: reasonix <[email protected]>
2026-05-17 23:56:21 -07:00
YHHandreasonix 0188d05a62 feat(desktop): add About modal with version, repo link, and update check (#1183)
Add an About entry in the sidebar foot (under 审批规则, above 设置)
that opens a new modal showing:

- App name + tagline.
- Current version, read at build time from desktop/package.json via
  Vite's `define` and exposed as the `__APP_VERSION__` ambient
  constant.
- Repository link — clicks open https://github.com/esengine/DeepSeek-Reasonix
  via @tauri-apps/plugin-opener (already a dep).
- "Check for updates" button — queries the GitHub releases API,
  prefers the latest `desktop-v*` tag (per the split-namespace
  release scheme in #1153), falls back to bare `v*` if the desktop
  track hasn't cut its own tag yet. Surfaces up-to-date / outdated /
  failed states inline, and offers a "View releases" link on outdated.

EN + zh-CN strings added under `t("about.*")`.

Refs maintainer request — wire a single click-to-open About panel.

Co-authored-by: reasonix <[email protected]>
2026-05-17 23:36:48 -07:00
YHHandreasonix 329f6c96c6 fix(desktop): replace right-click-to-delete with a discoverable hover X (#1177)
Right-clicking a session item in the sidebar opened a delete-confirmation
popover (#906). Users in #1116 hit it accidentally — right-click on a
list item doesn't read as "delete" in any standard desktop UI, and the
popover obscured the item enough that some users perceived the session
as "disappeared". One reporter explicitly asked whether right-click-to-
delete was a bug or intentional.

Remove the right-click hijack and surface deletion via a small X icon
inside each session-item, hidden by default and revealed on
hover / focus-within. Clicking the icon opens the same confirmation
popover anchored to the icon's bounding rect. stopPropagation keeps
the click from also firing the row's onClick (which loads the session).

The popover, its dismiss-on-outside-click + Esc handling, and the
auto-focused Cancel button all stay — only the entry gesture changes.

Closes #1116

Co-authored-by: reasonix <[email protected]>
2026-05-17 22:19:08 -07:00
366 changed files with 23504 additions and 6222 deletions

No files matched your search

+2
View File
@@ -1 +1,3 @@
github: esengine
custom:
- https://paypal.me/yuhuahui
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+63
View File
@@ -0,0 +1,63 @@
name: Publish dsnix
# Tag namespace: `dsnix-v<semver>` triggers npm publish for the
# dsnix shim package only. Plain `v<semver>` tags publish the main
# `reasonix` CLI (see publish-npm.yml); the two are decoupled so the
# alias can be released independently when the shim itself changes.
on:
push:
tags: ["dsnix-v*"]
workflow_dispatch:
inputs:
tag:
description: "Tag to publish (e.g. dsnix-v0.48.0). Required when running manually."
required: true
type: string
permissions:
contents: read
id-token: write
jobs:
publish:
name: publish dsnix to npm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
registry-url: https://registry.npmjs.org/
- name: Verify tag matches packages/dsnix/package.json version
shell: bash
run: |
tag="${{ github.event.inputs.tag || github.ref_name }}"
tag_version="${tag#dsnix-v}"
pkg_version=$(node -p "require('./packages/dsnix/package.json').version")
if [ "$tag_version" != "$pkg_version" ]; then
echo "::error::tag $tag (version $tag_version) does not match packages/dsnix/package.json version $pkg_version"
exit 1
fi
echo "tag $tag matches packages/dsnix/package.json $pkg_version"
- name: Verify reasonix@<version> is already published on npm
shell: bash
run: |
dep_version=$(node -p "require('./packages/dsnix/package.json').dependencies.reasonix")
if ! npm view "reasonix@${dep_version}" version >/dev/null 2>&1; then
echo "::error::dsnix depends on reasonix@${dep_version} but that version is not published yet"
exit 1
fi
echo "reasonix@${dep_version} is published — safe to publish dsnix"
- name: Publish dsnix to npm
run: npm publish --access public
working-directory: packages/dsnix
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+29
View File
@@ -41,6 +41,35 @@ jobs:
-R "${{ github.repository }}" \
-D assets
# Rewrite bundle URLs inside latest.json (Tauri updater manifest)
# so the desktop pulls bundles from R2, not GitHub. Without this the
# updater fetches the small JSON from R2 but still downloads the
# heavy installer from github.com — defeating the mirror.
# No-op when latest.json is absent (CLI-only releases).
- name: Rewrite latest.json URLs → R2
if: env.HAS_R2 == 'true'
env:
R2_PUBLIC_BASE: https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev
TAG: ${{ steps.tag.outputs.name }}
run: |
file=assets/latest.json
if [ ! -f "$file" ]; then
echo "no latest.json in release — skipping rewrite"
exit 0
fi
# GitHub URLs look like:
# https://github.com/<owner>/<repo>/releases/download/<TAG>/<file>
# Rewrite to:
# <R2_PUBLIC_BASE>/<TAG>/<file>
jq --arg base "$R2_PUBLIC_BASE" --arg tag "$TAG" '
.platforms |= with_entries(
.value.url |= sub("https://github.com/[^/]+/[^/]+/releases/download/[^/]+/"; "\($base)/\($tag)/")
)
' "$file" > "$file.new"
mv "$file.new" "$file"
echo "rewrote latest.json:"
cat "$file"
- name: Configure AWS CLI for R2
if: env.HAS_R2 == 'true'
run: |
+17
View File
@@ -96,6 +96,23 @@ jobs:
working-directory: desktop
run: npm run bundle:node
- name: Verify bundled Node is universal (macOS)
if: startsWith(matrix.target.os, 'macos-')
working-directory: desktop
run: |
archs=$(lipo -archs src-tauri/binaries/node)
echo "node archs: $archs"
echo "$archs" | grep -q "arm64"
echo "$archs" | grep -q "x86_64"
- name: Build & verify desktop frontend
working-directory: desktop
shell: bash
run: |
npm run build
test -f dist/index.html
ls dist/assets/*.js >/dev/null
# tauri-action signs OS bundles whenever the corresponding env vars are
# defined — including when they're defined to the empty string, which is
# what `secrets.UNSET` evaluates to. Gate each signing block on its
+255
View File
@@ -3,6 +3,261 @@
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.48.1] — 2026-05-21
**`dsnix` — short alias.** A new `dsnix` shim package lands alongside
`reasonix` (#1440), so `npx dsnix` is now equivalent to `npx reasonix`
for users who'd rather type five characters than eight. The shim is
just a forwarding binary — same CLI, same release cadence — and ships
on npm with the same version pinning (#1442). The root README documents
the alias.
**Lifecycle — observability + corpus tests.** Strict lifecycle now
surfaces step-level evidence and rail decisions through a structured
observability channel (#1426), so what the loop is doing at each step
is legible instead of inferred. Two corpus tests lock the behavior in:
multi-file refactor flow (#1427) and config-migration replay (#1473).
**Approval prompt unification.** Phase 3 of the pause-request work
(#1322) lands a single `PauseRequest` → UI data model shared by ACP,
CLI, and Desktop (#1443) — the three surfaces now render the same
prompt from the same payload instead of each translating in their own
way. Net: pause prompts stay in sync across surfaces when fields are
added.
**i18n.** Builtin skill descriptions are now translated (#1468), and
MCP error toasts + status labels carry zh-CN (#1481). The remaining
hardcoded English surface in skill picker and MCP error toasts is gone.
**Composer redesign + Ctrl+P shortcut modal.** The input area is
rebuilt with a denser layout, a clearer affordance for multi-line
input, and a Ctrl+P shortcut-help modal that lists every key binding
in one place (#1483). The desktop composer also autosizes for
multi-line input properly (#1439).
**Java tool — cheaper.** The Java source/decompile tool description
and output are pruned (#1485), shaving cached prefix tokens. Pairs with
the #1390 buffer raise from 0.48.0.
**Security.** `metaso` web-search no longer ships with a baked API
key — it now requires the user-configured key like every other backend
(#1488). Project-level hooks are trusted without an extra prompt
provided they live inside the working directory.
**YOLO mode polish.** `run_command` and `run_background` approval
gates now auto-resolve in YOLO mode (#1489) so power users don't get a
modal for every shell call after explicitly opting in.
**Perf — folding thresholds.** Normal fold threshold raised 50% → 75%,
aggressive 70% → 78% (#1461) — the loop now spends less time folding
context that didn't need to be folded.
**Preflight.** The cap that previously only counted tokens now also
gates on raw JSON body bytes (#1451), catching oversized payloads that
weren't reflected in the token count.
**Desktop.** Error cards are dismissable and recoverable variants
toned down (#1487); orphaned pause-gate modals are cleared on
`$turn_complete` so a stale gate can't block the next turn (#1484);
session titles are renameable from the sidebar (#1478); `/btw` now
echoes input, surfaces busy state, and routes through the side-question
prompt instead of silently dropping the keystroke (#1472).
**TUI.** SGR mouse mode stays on for Windows Terminal (#1486) —
alternate-scroll silently breaks the wheel there. The default for
non-WT terminals switches to alternate-scroll (`?1007h`) (#1477) to
avoid the click-eats-selection problem on conhost-class hosts.
**Other:**
- `fix(cost)` show session-aggregate cache hit so `/cost` matches the
status-bar number (#1482)
- `fix(plan)` Accept executes immediately when the plan has no open
questions instead of waiting on a non-existent answer (#1480)
- `fix` resolve root-relative edit-block paths (#1452)
- `fix(tests)` bump chat-mcp-startup timeout to 30s for Windows test
matrix (#1471)
## [0.48.0] — 2026-05-20
**Proxy-aware networking.** `NO_PROXY` is now honored end-to-end and
the DeepSeek host is baked into the default bypass whitelist so a
machine-wide `HTTPS_PROXY` no longer routes API traffic through an
unwanted proxy (#1373). A `--no-proxy` CLI flag, `REASONIX_NO_PROXY`
env, and `cfg.proxy` field give per-invocation control (#1374), and
`/doctor` surfaces the resolved `NO_PROXY` value plus the routing
decision Reasonix made for each host (#1375). If proxy behavior ever
looks wrong, `/doctor` will tell you why in one screen.
**Code intelligence boost.** Two new tree-sitter–powered tools land:
`get_symbols` and `find_in_code` cover TS/JS/Python/Go/Rust/Java
(#1387), giving Reasonix structural code navigation instead of plain
grep. Java gets a dedicated `java_source` tool that reads `.java`
sources and decompiles `.class` / inner jar entries via `javap`
(#1344), with output stripped of noise and the read buffer raised so
large classes fit in one round (#1390). Web search adds Perplexity
and Exa backends alongside the existing Mojeek / Tavily set (#1350).
**Lifecycle round two.** Checkpoints now gate mutating tools during
restore so an in-flight edit can't race a rollback (#1389), state is
preserved across plan revisions (#1388), and step evidence is
summarized at the plan card so progress is legible without expanding
each step (#1391). `/plan` is excluded from the strict-rails cache
constraint that would otherwise force a re-prefix on every revision
(#1407, #1386), and a strict end-to-end harness covers the whole
lifecycle path (#1424).
**Subagent UX.** Long-running `explore` / `research` / `review` are
now top-level slash commands with live byte-progress in the status
row instead of a frozen spinner (#1422). The agent loop is hardened:
`_turnAbort` resets in `finally` so a consumer break can't lock the
session (#1421), ambiguous search-replace edit blocks are rejected
instead of silently miss-applying (#1414), repeated gate rejections
no longer ping-pong (#1392), and FS tools walk `.reasonix/` so user
skills are reachable from any subdir (#1371).
**Desktop.** QQ sessions are now usable from the desktop app — the
QQ Adapter routes events through the same RPC bridge as CLI tabs
(#1334). Custom font support + file-link rendering improvements ship
(#1376). Reliability fixes: `rpc_spawn` is idempotent and a
`desktop_resync` rehydrate path recovers cleanly after a renderer
reload (#1428); F5 / Ctrl+R are swallowed in packaged builds so a
stray refresh can't wipe the chat (#1430); React error #31 with
non-string MCP config items is fixed (#1394); the context-token
meter syncs after `/compact` (#1399); the cost meter and session
cost card apply USD→CNY conversion (#1369); `augmentProcessPath()`
lost in the #1334 rebase is restored (#1361); desktop stdout partial
writes are handled (#1405); `/btw` on empty payload now surfaces a
usage hint instead of dropping the keystroke (#1383).
**TUI / UI.** `/about` is a new slash command showing version,
website, GitHub, and license (#1434). Remaining hardcoded strings in
edit history and MCP browse are now i18n'd (#1433). Reasoning,
streaming, and diff content beyond the recent window are elided so
long sessions don't tank scroll perf (#1432). Wheel scroll is less
sensitive and per-card stdout resize listeners collapse to one (#1423);
terminal mouse tracking now has a CLI opt-out for users on
mouse-hostile terminals (#1345).
**Settings / config.** `config.json` with a UTF-8 BOM is tolerated
instead of crashing the loader (#1431); preset-trace diagnostic
retired (#1431). The dashboard can now clear `baseUrl` to fall back
to the bundled default (#1425). `string[]` config fields are
sanitized at the `readConfig` boundary so a malformed entry can't
propagate into the running session (#1396).
**Other:**
- `fix(test)` switch vitest to forks pool to stop heap OOM on
many-core hosts (#1408)
- `chore(shell)` trim `run_command` param description (saves 119
tokens of cached prefix) (#1403)
- `chore(scripts)` measure per-tool token cost end-to-end (#1402)
- `test(tools)` close dispatch-level E2E gaps for shell jobs, web,
and scaffold (#1400)
- `test(shell-tools)` raise `run_background` dispatch test timeout
to 15s (#1406)
- `test(web)` cover Perplexity + Exa backends (#1366)
- `docs(qq)` add desktop quick start and trim README surface (#1401)
## [0.47.2] — 2026-05-19
**Hotfix — `npx reasonix@latest` install.** `0.47.1` shipped with
`@reasonix/core-utils` listed under runtime `dependencies` as
`workspace:*`. Because the workspace package is `private: true`,
`npm publish` did not rewrite the protocol, so consumers hit
`EUNSUPPORTEDPROTOCOL: Unsupported URL Type "workspace:"` during
install. The bundled `dist/` already inlines core-utils at build
time, so the dependency belongs under `devDependencies`. Moved.
`0.47.1` is deprecated on npm; please install `0.47.2`.
## [0.47.1] — 2026-05-19
**Desktop shakeout.** Follow-ups to the 0.47.0 desktop launch:
inherit login-shell `PATH` so `nvm`/`asdf`/`fnm`-installed Node is
visible to `run_command` (#1331); stop claiming "Connected" on the QQ
settings page since the desktop is config-editor-only, not a live
bridge (#1326); re-enable devtools + right-click context menu in
release builds; route the in-app update check through the updater
plugin so CSP doesn't block it (#1298); prevent the browser-level
Select-All when the composer isn't focused (#1314); add unified
`button:disabled` style; ship selectable theme styles (#1291); a few
follow-up bug fixes from the release.
**Prompt budget — round one.** `codeSystemPrompt` compressed -51%
(~3.1k tokens/request, #1323) and tool spec descriptions -28% (~2.7k
tokens/request, #1321), with a byte-budget regression test locked in
(#1320). The cache-prefix shrinks accordingly — cheaper first turn,
same behavior.
**TUI / shell-exec.** Per-call shell-exec approval flow now surfaces
on the first run instead of failing silently (#1278); spinner +
stream tick rate dropped on legacy Windows conhost; light-theme
picker rows highlight on selection (#1330); `//` line-comment input
no longer parses as a slash command (#1288).
**Other:**
- `feat(core-utils)` new `@reasonix/core-utils` workspace package —
Phase 1 internal split, no public-API change (#1328)
- `feat(lifecycle)` opt-in engineering lifecycle (#1306)
- `feat(tools)` ordered interceptor chain (#1301)
- `feat(plan)` persist step evidence metadata (#1302)
- `feat(read_file)` lower outline threshold 512 KiB → 64 KiB (#1324)
- `fix(multi_edit)` roll back files that may have been modified on
write failure (#1325)
- `fix(permissions)` strip literal `*` from always-allow prefix in
Desktop/ACP (#1312)
- `fix(client)` skip `extra_body` for Azure endpoints (#1316)
- `fix(search)` honor `tavily` engine + read engine at call time
- `fix(cli)` resolve heap reexec entrypoint (#1303)
- `fix(docs)` point download mirrors at `desktop-v*` tag namespace
## [0.47.0] — 2026-05-18
**Desktop matures.** The Tauri app picks up the polish it was missing —
About modal with one-click update check, KaTeX math rendering in chat,
window/tab/session/scroll restore across relaunch, /compact /retry /btw
/feedback slash commands, per-message copy + file-export actions, QQ
settings entry, platform-aware keyboard shortcut hints. The auto-updater
now reads `latest.json` from the R2 mirror so an npm-only `v*` release
can no longer brick the in-app update banner; the banner also surfaces
download progress while the bundle pulls. Universal Node is bundled on
macOS so Intel Macs stop blank-screening at launch.
**TUI — composer round two.** Composer keys and visuals re-align with
Claude Code: rounded border, status row at the bottom, input-box
background fill, left stripe on user card. `ctrl+r` toggles verbose
(reasoning + raw tool I/O); `esc-esc` opens a rewind picker over the
last 5s; `u` undo is now gated to the same 5s window so stale taps
can't revert. SGR mouse wheel is on by default again, IME cursor stays
glued to `▌`, WelcomeBanner centers vertically on empty chats.
**Search hardening.** Regex now runs in a worker thread so a ReDoS
pattern can be terminated cleanly; worker deadline 5s → 60s and walk
deadline 15s → 120s for legitimately large repos. Tavily added as a
`web_search` backend — escape hatch when Mojeek 403s. `search_content`
gets a walk-level deadline and ESC preempts queued tool calls.
**Claude-ecosystem compat.** Reasonix now reads `.mcp.json` and
`.claude/skills/` from the repo so existing Claude Code setups work
without a second copy; common skill fields (`type`/`context`/`agent`)
are aliased.
**Other:**
- `feat(embedding)` batch OpenAI-compat embedding requests to respect
provider batch limits
- `feat(memory)` inject ancestor `AGENTS.md` into `list_directory`,
not only `read_file`
- `perf(session)` skip cross-workspace jsonls + byte-scan line count
— sidebar list ~10× faster
- `feat(config)` support pricing overrides and per-model rpm limits
- `feat(dashboard)` new / switch / delete sessions in-place, no CLI
bounce
- `fix(mcp)` runtime schema validation for MCP server responses; ESC
aborts hung handshakes during startup; surface the real bridge-
failure reason in the dashboard
- `fix(cli)` preserve flash-preset auto-escalate semantics
- `ci` split desktop + npm releases into separate tag namespaces:
`v*` → npm, `desktop-v*` → Tauri bundles
## [0.46.0] — 2026-05-17
**Breaking — Rust renderer removed.** Reasonix is back to a pure Ink/Node
+70 -33
View File
@@ -15,7 +15,7 @@
&nbsp;·&nbsp;
<a href="./benchmarks/">Benchmarks</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/XF78rEME2D">Discord</a>
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
<p align="center">
@@ -55,6 +55,9 @@
> [!NOTE]
> **Real user, single day (2026-05-01):** 435M input tokens, **99.82% cache hit**, ~$12 instead of the ~$61 the same workload would cost with no cache on `v4-flash` — see the [case study](./benchmarks/real-world-cache/README.md). DeepSeek provides the cacheable bytes; the four mechanisms in [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) are how Reasonix keeps them cacheable across long sessions.
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord with channels for setup help (`#help` / `#求助`), workflow showcases, feature ideas, and contributor-only PR coordination. Verify your GitHub in-server to get the **Contributor** role automatically. → **<https://discord.gg/XF78rEME2D>**
<br/>
## Install
@@ -79,6 +82,15 @@ Grab a [DeepSeek API key →](https://platform.deepseek.com/api_keys) · `reason
If you use Reasonix daily, global install is the simplest path. If you just want to try it, use `npx`.
**Prefer fewer keystrokes?** The shorter `dsnix` alias resolves to the same CLI:
~~~bash
npm install -g dsnix # exposes `dsnix` on PATH, depends on reasonix
npx dsnix@latest code # one-shot via the shorter command
~~~
A global `npm install -g reasonix` also drops a `dsnix` shim on PATH, so the two are interchangeable.
Bare `reasonix` (no subcommand) launches `code` in the current directory — typing `reasonix` and `reasonix code` are equivalent.
| Command | When |
@@ -93,31 +105,15 @@ Other subcommands (`replay` · `diff` · `events` · `stats` · `index` · `mcp`
### QQ channel
Reasonix can attach QQ as a remote communication channel for existing `chat` and `code` sessions. It is not a separate runtime mode.
QQ can extend an existing `chat`, `code`, or desktop session as a remote channel. It is part of the current session flow, not a separate runtime mode.
Start a session first:
- CLI: start a session, then run `/qq connect`
- Desktop: open `Settings -> General -> QQ Channel`
~~~bash
reasonix code
# or
reasonix chat
~~~
Once connected, QQ messages can enter the current session, assistant replies route back to QQ, and follow-up interactions can continue remotely.
Then connect QQ from inside the session:
For full setup, desktop quick start, and troubleshooting, see [QQ channel setup](./docs/qq-connect.md).
~~~text
/qq connect
~~~
Available commands:
- `/qq connect`
- `/qq status`
- `/qq disconnect`
Once enabled, later `chat` / `code` sessions auto-start the QQ channel. Slash commands, confirmation prompts, and follow-up assistant replies can continue through QQ without terminal-side input.
See [QQ channel setup](./docs/qq-connect.md) for setup details and QQ Open Platform bot registration.
### Desktop client (prerelease)
A native Tauri client for users who want a GUI over the same loop. Multi-tab, the right-panel shows files the agent has read or edited this session, the same cost / cache / token meters live at the bottom. Same DeepSeek API key, same `~/.reasonix` config — the desktop bundles its own Node runtime, no separate `npm install` step.
@@ -159,6 +155,13 @@ npx reasonix code --dir /path/to/project
/skill new my-skill --global # ~/.reasonix/skills for cross-project use
~~~
**Claude-format skills also load.** `<project>/.claude/skills/<name>/SKILL.md` and `~/.claude/skills/` are read alongside Reasonix's native paths, so tooling that emits Claude-format skills works out of the box. Example — drop OpenSpec workflows in without an upstream adapter:
~~~bash
npx openspec init --tools claude # writes .claude/skills/openspec-*/SKILL.md
/skill openspec-propose <task> # then invoke from Reasonix
~~~
</details>
<br/>
@@ -224,7 +227,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
- [**Architecture**](./docs/ARCHITECTURE.md) — three pillars: cache-first loop, tool-call repair, cost control
- [**CLI Reference**](./docs/CLI-REFERENCE.md) — every shell subcommand, every slash command, every keybinding
- [**QQ channel setup**](./docs/qq-connect.md) — what it is, commands, setup flow, and QQ Open Platform credentials
- [**QQ channel setup**](./docs/qq-connect.md) — CLI first-connect flow, desktop entry, and QQ Open Platform credentials
- [**Benchmarks**](./benchmarks/) — τ-bench-lite harness, transcripts, cost methodology
- [**Website**](https://esengine.github.io/DeepSeek-Reasonix/) — getting started, dashboard mockup, TUI mockup
- [**Contributing**](./CONTRIBUTING.md) — comment policy, error-handling rules, library-over-hand-rolled
@@ -235,7 +238,7 @@ For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmar
## Community
> [!NOTE]
> Reasonix is open source and community-developed. The contributors wall below isn't decoration — every avatar is a real PR that shipped.
> Reasonix is open source and community-developed. Every avatar in the Acknowledgments wall at the bottom of this file is a real PR that shipped.
Scoped starter tickets — each with background, code pointers, acceptance criteria, and hints — live under the [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) label. Pick anything open.
@@ -255,12 +258,6 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
**Before your first PR**: read [`CONTRIBUTING.md`](./CONTRIBUTING.md) — short, strict rules (comments, errors, libraries-over-hand-rolled). `tests/comment-policy.test.ts` enforces the comment ones; `npm run verify` is the pre-push gate. By participating you agree to the [Code of Conduct](./CODE_OF_CONDUCT.md). Security issues → [SECURITY.md](./SECURITY.md).
<p align="center">
<a href="https://github.com/esengine/reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
</a>
</p>
<br/>
## Non-goals
@@ -287,12 +284,52 @@ Scoped starter tickets — each with background, code pointers, acceptance crite
<br/>
## Support
If Reasonix has been useful and you'd like to say thanks, you can. It stays a coffee, not a contract — donations don't buy feature priority or change how issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="240"/>
</p>
<br/>
## Acknowledgments
A small list of folks whose work has shaped Reasonix the most — measured
by both commit count and code volume. **Listed alphabetically, no ordering
of importance.** The full contributor graph is on
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors).
- [**ctharvey**](https://github.com/ctharvey)
- [**dimasd-angga**](https://github.com/dimasd-angga) (Dimas D. Angga)
- [**Evan-Pycraft**](https://github.com/Evan-Pycraft)
- [**ForeverYoungPp**](https://github.com/ForeverYoungPp)
- [**GTC2080**](https://github.com/GTC2080) (TaoMu)
- [**kabaka9527**](https://github.com/kabaka9527)
- [**lisniuse**](https://github.com/lisniuse) (Richie)
- [**wade19990814-hue**](https://github.com/wade19990814-hue)
- [**wviana**](https://github.com/wviana) (Wesley Viana)
Also a separate thank-you to [**Bernardxu123**](https://github.com/Bernardxu123)
for designing the project logo (see [`docs/brand/`](./docs/brand/)), and to
[AIGC Link](https://xhslink.com/m/80ngts127cA) for promoting the project on XiaoHongShu.
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>Built by the community at <a href="https://github.com/esengine/reasonix/graphs/contributors">esengine/reasonix</a></sub>
<br/>
<sub>Special thanks to <a href="https://xhslink.com/m/80ngts127cA">AIGC Link</a> for the promotions on XiaoHongShu.</sub>
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
</p>
+52 -31
View File
@@ -15,7 +15,7 @@
&nbsp;·&nbsp;
<a href="./benchmarks/">基准测试</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/XF78rEME2D">Discord</a>
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
<p align="center">
@@ -52,6 +52,9 @@
> [!TIP]
> **缓存稳定不是开关,而是循环要围绕设计的不变量。** 这就是 Reasonix 只支持 DeepSeek 的根本原因 —— 每一层都为 DeepSeek 字节稳定的前缀缓存机制调过。
> [!IMPORTANT]
> **加入社区 · Community** — 中英双语 Discord,频道包括 `#求助` / `#help`、`#分享` / `#showcase`、`#想法反馈`、贡献者专属 PR 协调区。在群内绑定 GitHub 后自动识别贡献者身份。→ **<https://discord.gg/XF78rEME2D>**
<br/>
## 安装
@@ -77,31 +80,15 @@ npx reasonix code # 首次运行粘贴 DeepSeek API Key,之后会记住
### QQ 通道
Reasonix 可以把 QQ 挂到现有的 `chat` / `code` 会话上,作为远程通信通道使用。它不是一个独立的新模式。
Reasonix 可以把现有的 `chat`、`code` 或桌面端会话延伸到 QQ 上,作为远程通道使用;它扩展的是当前会话,不是独立的新运行模式。
先启动一个会话:
- CLI:先启动会话,再执行 `/qq connect`
- 桌面端:打开 `设置 -> 通用 -> QQ通道`
~~~bash
reasonix code
# 或
reasonix chat
~~~
连接成功后,QQ 消息可以进入当前会话,助手回复会回到 QQ,后续确认和跟进交互也可以继续在 QQ 上完成。
然后在会话里连接 QQ:
完整配置、桌面端快速上手与排障说明见:[QQ 连接指南](./docs/qq-connect.zh-CN.md)。
~~~text
/qq connect
~~~
可用命令:
- `/qq connect`
- `/qq status`
- `/qq disconnect`
启用后,后续的 `chat` / `code` 会话会自动启动 QQ 通道。斜杠命令、确认提示,以及后续助手回复都可以通过 QQ 继续完成,不需要回到终端交互。
详细说明见 [QQ 连接指南](./docs/qq-connect.zh-CN.md)。
<details>
<summary><strong>切换工作区 · chat vs. code · 写第一个 Skill</strong></summary>
@@ -194,7 +181,7 @@ npx reasonix code --dir /path/to/project
- [**架构**](./docs/ARCHITECTURE.md) —— 四大支柱、缓存优先循环、思维提取、脚手架
- [**CLI 参考**](./docs/CLI-REFERENCE.md) —— 每个 shell 子命令、每个 slash 命令、每个快捷键
- [**QQ 连接指南**](./docs/qq-connect.zh-CN.md) —— 通道定位、命令、配置流程和 QQ 开放平台凭据
- [**QQ 连接指南**](./docs/qq-connect.zh-CN.md) —— CLI 首次连接流程、桌面端入口和 QQ 开放平台凭据
- [**基准测试**](./benchmarks/) —— τ-bench-lite harness、transcript、成本方法论
- [**官方网站**](https://esengine.github.io/DeepSeek-Reasonix/) —— 入门、Dashboard 设计稿、TUI 设计稿
- [**贡献指南**](./CONTRIBUTING.md) —— 注释规则、错误处理、用现成库不手写
@@ -205,7 +192,7 @@ npx reasonix code --dir /path/to/project
## 社区
> [!NOTE]
> Reasonix 是开源、社区共建的项目。下面贡献者墙不是装饰 —— 每一个头像都对应一次真实合并的 PR。
> Reasonix 是开源、社区共建的项目。文末"致谢"那面贡献者墙上的每一个头像,都对应一次真实合并的 PR。
给新手准备的入门 issue —— 每个都带背景说明、代码定位、验收标准、提示 —— 全部挂在 [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) 标签下。挑任意一个还没人认领的就行。
@@ -225,12 +212,6 @@ npx reasonix code --dir /path/to/project
**第一次提 PR 之前**:先读 [`CONTRIBUTING.md`](./CONTRIBUTING.md) —— 短小、严格的项目规则(注释、错误处理、用现成库不手写)。`tests/comment-policy.test.ts` 静态强制执行注释那部分,`npm run verify` 是 push 前的闸。参与本项目即同意 [行为准则](./CODE_OF_CONDUCT.md)。安全相关问题请走 [SECURITY.md](./SECURITY.md)。
<p align="center">
<a href="https://github.com/esengine/reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
</a>
</p>
<br/>
## 不做的事
@@ -257,10 +238,50 @@ npx reasonix code --dir /path/to/project
<br/>
## 支持本项目
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature 优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="240"/>
</p>
<br/>
## 致谢
下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 综合 commit 数和代码量两个维度。
**按字母顺序排列,排名不分先后。** 完整贡献者列表在
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors)。
- [**ctharvey**](https://github.com/ctharvey)
- [**dimasd-angga**](https://github.com/dimasd-angga)(Dimas D. Angga)
- [**Evan-Pycraft**](https://github.com/Evan-Pycraft)
- [**ForeverYoungPp**](https://github.com/ForeverYoungPp)
- [**GTC2080**](https://github.com/GTC2080)(TaoMu)
- [**kabaka9527**](https://github.com/kabaka9527)
- [**lisniuse**](https://github.com/lisniuse)(Richie)
- [**wade19990814-hue**](https://github.com/wade19990814-hue)
- [**wviana**](https://github.com/wviana)(Wesley Viana)
另外特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo
(见 [`docs/brand/`](./docs/brand/)),以及 [AIGC Link](https://xhslink.com/m/80ngts127cA) 在小红书上的推广。
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT —— 见 <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>由 <a href="https://github.com/esengine/reasonix/graphs/contributors">esengine/reasonix</a> 社区共建</sub>
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
</p>
+1
View File
@@ -0,0 +1 @@
runs/
+165
View File
@@ -0,0 +1,165 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolvePreset } from "../../src/cli/ui/presets.js";
import { DeepSeekClient } from "../../src/client.js";
import { codeSystemPrompt } from "../../src/code/prompt.js";
import { buildCodeToolset } from "../../src/code/setup.js";
import { loadApiKey, loadBaseUrl, loadPreset } from "../../src/config.js";
import { loadDotenv } from "../../src/env.js";
import { CacheFirstLoop } from "../../src/loop.js";
import { ImmutablePrefix } from "../../src/memory/runtime.js";
import { DEEPSEEK_CONTEXT_TOKENS } from "../../src/telemetry/stats.js";
interface DriverOptions {
taskFile: string;
transcriptOut: string;
budgetUsd: number;
rootDir: string;
/** Override DEEPSEEK_CONTEXT_TOKENS to force fold triggers on short tasks. */
fakeCtxMax: number | null;
}
function parseArgs(argv: string[]): DriverOptions {
let taskFile = "";
let transcriptOut = "";
let budgetUsd = 2.0;
let rootDir = process.cwd();
let fakeCtxMax: number | null = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--task-file") taskFile = argv[++i]!;
else if (a === "--transcript-out") transcriptOut = argv[++i]!;
else if (a === "--budget") budgetUsd = Number(argv[++i]);
else if (a === "--root") rootDir = argv[++i]!;
else if (a === "--fake-ctxmax") fakeCtxMax = Number(argv[++i]);
}
if (!taskFile || !transcriptOut) {
console.error(
"usage: driver.ts --task-file <p> --transcript-out <p> [--budget 2] [--root <dir>] [--fake-ctxmax N]",
);
process.exit(1);
}
return { taskFile, transcriptOut, budgetUsd, rootDir, fakeCtxMax };
}
interface TurnRecord {
turn: number;
role: string;
prompt_tokens?: number;
cache_hit_tokens?: number;
cache_miss_tokens?: number;
cost_usd?: number;
tool?: string;
content_preview?: string;
}
async function main(): Promise<void> {
loadDotenv();
const opts = parseArgs(process.argv.slice(2));
const key = loadApiKey();
if (!key) {
console.error("DEEPSEEK_API_KEY missing — run `reasonix setup` first");
process.exit(1);
}
process.env.DEEPSEEK_API_KEY = key;
const presetSettings = resolvePreset(loadPreset());
const model = presetSettings.model;
if (opts.fakeCtxMax !== null && opts.fakeCtxMax > 0) {
for (const k of Object.keys(DEEPSEEK_CONTEXT_TOKENS)) {
DEEPSEEK_CONTEXT_TOKENS[k] = opts.fakeCtxMax;
}
console.log(`ctxMax override (ALL deepseek models): -> ${opts.fakeCtxMax}`);
}
console.log(`model: ${model}`);
console.log(`root: ${opts.rootDir}`);
console.log(`budget: $${opts.budgetUsd}`);
const task = readFileSync(opts.taskFile, "utf8").trim();
console.log(`task: ${task.length} chars`);
const toolset = await buildCodeToolset({ rootDir: opts.rootDir });
console.log(`tools: ${toolset.tools.specs().length} registered`);
const client = new DeepSeekClient({ baseUrl: loadBaseUrl() });
const prefix = new ImmutablePrefix({
system: codeSystemPrompt(opts.rootDir),
toolSpecs: toolset.tools.specs(),
});
const loop = new CacheFirstLoop({
client,
prefix,
tools: toolset.tools,
model,
budgetUsd: opts.budgetUsd,
});
const records: TurnRecord[] = [];
const t0 = Date.now();
let lastTurn = 0;
try {
for await (const ev of loop.step(task)) {
if (ev.role === "assistant_final" && ev.stats?.usage) {
const u = ev.stats.usage;
const rec: TurnRecord = {
turn: ev.turn,
role: "assistant_final",
prompt_tokens: u.promptTokens,
cache_hit_tokens: u.promptCacheHitTokens,
cache_miss_tokens: u.promptCacheMissTokens,
cost_usd: ev.stats.cost,
content_preview: (ev.content ?? "").slice(0, 200),
};
records.push(rec);
lastTurn = ev.turn;
const ratio = u.promptCacheHitTokens / (u.promptTokens || 1);
process.stdout.write(
` turn ${ev.turn}: pt=${u.promptTokens} hit=${(ratio * 100).toFixed(1)}% cost=$${ev.stats.cost.toFixed(4)}\n`,
);
} else if (ev.role === "tool" && ev.toolName) {
records.push({
turn: ev.turn,
role: "tool",
tool: ev.toolName,
content_preview: (ev.content ?? "").slice(0, 200),
});
} else if (ev.role === "warning" || ev.role === "status") {
const lc = (ev.content ?? "").toLowerCase();
const isFold = lc.includes("fold") || lc.includes("compact") || lc.includes("summar");
records.push({
turn: ev.turn,
role: isFold ? "fold_event" : `${ev.role}_event`,
content_preview: ev.content,
});
if (isFold) {
process.stdout.write(
` ⟳ ${ev.role.toUpperCase()} at turn ${ev.turn}: ${ev.content.slice(0, 120)}\n`,
);
}
} else if (ev.role === "error") {
records.push({ turn: ev.turn, role: "error", content_preview: ev.error });
process.stdout.write(` ✗ error turn ${ev.turn}: ${ev.error}\n`);
} else if (ev.role === "done") {
process.stdout.write(` ✓ done (turn ${ev.turn})\n`);
}
}
} catch (err) {
process.stdout.write(` ✗ loop threw: ${(err as Error).message}\n`);
}
const elapsed = Math.round((Date.now() - t0) / 1000);
const s = loop.stats.summary();
console.log(
`\nDONE turns=${lastTurn} elapsed=${elapsed}s cache=${(s.cacheHitRatio * 100).toFixed(1)}% cost=$${s.totalCostUsd.toFixed(4)}`,
);
writeFileSync(
opts.transcriptOut,
`${JSON.stringify({ _meta: { model, task_length: task.length, elapsed_s: elapsed, summary: s } })}\n${records.map((r) => JSON.stringify(r)).join("\n")}\n`,
);
console.log(`transcript: ${opts.transcriptOut}`);
}
main().catch((err) => {
console.error("driver crashed:", err);
process.exit(1);
});
+325
View File
@@ -0,0 +1,325 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
interface RunSpec {
name: string;
foldThreshold: number;
aggressiveThreshold: number;
}
interface RunSummary {
name: string;
repeat: number;
turns: number;
cacheHitRatio: number;
costUsd: number;
totalInputTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
foldCount: number;
exitCode: number;
}
const RUNS: RunSpec[] = [
{ name: "no-fold", foldThreshold: 0.99, aggressiveThreshold: 0.99 },
{ name: "current_50_70", foldThreshold: 0.5, aggressiveThreshold: 0.7 },
{ name: "very_late_75_90", foldThreshold: 0.75, aggressiveThreshold: 0.9 },
{ name: "aggressive_90_95", foldThreshold: 0.9, aggressiveThreshold: 0.95 },
];
function parseArgs(argv: string[]): {
taskFile: string;
budget: number;
outDir: string;
only: string | null;
fakeCtxMax: number | null;
repeats: number;
} {
let taskFile = "";
let budget = 2.0;
let outDir = "";
let only: string | null = null;
let fakeCtxMax: number | null = null;
let repeats = 1;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--task-file") taskFile = argv[++i]!;
else if (a === "--budget") budget = Number(argv[++i]);
else if (a === "--out-dir") outDir = argv[++i]!;
else if (a === "--only") only = argv[++i]!;
else if (a === "--fake-ctxmax") fakeCtxMax = Number(argv[++i]);
else if (a === "--repeats") repeats = Number(argv[++i]);
}
if (!taskFile) {
console.error(
"usage: tsx e2e.ts --task-file <p> [--budget N] [--out-dir <p>] [--only <n>] [--fake-ctxmax N] [--repeats N]",
);
process.exit(1);
}
if (!outDir) {
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
outDir = `benchmarks/compression-eval/runs/${ts}`;
}
return { taskFile, budget, outDir, only, fakeCtxMax, repeats };
}
function loadTranscriptSummary(path: string): Partial<RunSummary> {
let turns = 0;
let inputTokens = 0;
let cacheHit = 0;
let cacheMiss = 0;
let costUsd = 0;
let foldCount = 0;
const promptSeq: number[] = [];
try {
const lines = readFileSync(path, "utf8").split("\n");
for (const ln of lines) {
const t = ln.trim();
if (!t) continue;
let rec: {
role?: string;
prompt_tokens?: number;
cache_hit_tokens?: number;
cache_miss_tokens?: number;
cost_usd?: number;
};
try {
rec = JSON.parse(t);
} catch {
continue;
}
if (rec.role === "assistant_final") {
turns++;
const pt = rec.prompt_tokens ?? 0;
inputTokens += pt;
cacheHit += rec.cache_hit_tokens ?? 0;
cacheMiss += rec.cache_miss_tokens ?? 0;
costUsd += rec.cost_usd ?? 0;
if (pt > 0) promptSeq.push(pt);
} else if (rec.role === "fold_event") {
foldCount++;
}
}
} catch {
/* file missing — caller will see exitCode and zeros */
}
if (foldCount === 0) {
for (let i = 1; i < promptSeq.length; i++) {
if (promptSeq[i]! < promptSeq[i - 1]! * 0.6) foldCount++;
}
}
return {
turns,
totalInputTokens: inputTokens,
cacheHitTokens: cacheHit,
cacheMissTokens: cacheMiss,
cacheHitRatio: inputTokens > 0 ? cacheHit / inputTokens : 0,
costUsd,
foldCount,
};
}
function runOne(
spec: RunSpec,
task: string,
budget: number,
outDir: string,
fakeCtxMax: number | null,
repeat: number,
): RunSummary {
const runName = repeat > 1 ? `${spec.name}_r${repeat}` : spec.name;
const runDir = join(outDir, runName);
mkdirSync(runDir, { recursive: true });
const transcript = join(runDir, "transcript.jsonl");
const logPath = join(runDir, "stdout.log");
console.log(
`\n>>> [${spec.name}] fold=${spec.foldThreshold} agg=${spec.aggressiveThreshold}`,
);
const tsxCli = join("node_modules", "tsx", "dist", "cli.mjs");
const taskFilePath = join(runDir, "task.txt");
writeFileSync(taskFilePath, task);
const t0 = Date.now();
const res = spawnSync(
process.execPath,
[
tsxCli,
"benchmarks/compression-eval/driver.ts",
"--task-file",
taskFilePath,
"--transcript-out",
transcript,
"--budget",
String(budget),
...(fakeCtxMax ? ["--fake-ctxmax", String(fakeCtxMax)] : []),
],
{
env: {
...process.env,
REASONIX_FOLD_THRESHOLD: String(spec.foldThreshold),
REASONIX_FOLD_AGGRESSIVE_THRESHOLD: String(spec.aggressiveThreshold),
},
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
shell: false,
maxBuffer: 1024 * 1024 * 128,
},
);
const exitCode = res.status ?? -1;
const stdout = res.stdout ?? "";
const stderr = res.stderr ?? "";
const elapsedS = Math.round((Date.now() - t0) / 1000);
writeFileSync(logPath, `${stdout}\n--- stderr ---\n${stderr}`);
console.log(` exit=${exitCode} elapsed=${elapsedS}s log=${logPath}`);
const parsed = loadTranscriptSummary(transcript);
return {
name: spec.name,
repeat,
turns: parsed.turns ?? 0,
cacheHitRatio: parsed.cacheHitRatio ?? 0,
costUsd: parsed.costUsd ?? 0,
totalInputTokens: parsed.totalInputTokens ?? 0,
cacheHitTokens: parsed.cacheHitTokens ?? 0,
cacheMissTokens: parsed.cacheMissTokens ?? 0,
foldCount: parsed.foldCount ?? 0,
exitCode,
};
}
function pct(n: number): string {
return `${(n * 100).toFixed(1)}%`;
}
function fmtTok(n: number): string {
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
return String(n);
}
interface AggregateRow {
name: string;
n: number;
meanCost: number;
stdCost: number;
meanMiss: number;
stdMiss: number;
meanHitPct: number;
stdHitPct: number;
meanTurns: number;
meanFolds: number;
}
function meanStd(xs: number[]): [number, number] {
if (xs.length === 0) return [0, 0];
const m = xs.reduce((a, b) => a + b, 0) / xs.length;
const v = xs.reduce((a, b) => a + (b - m) ** 2, 0) / xs.length;
return [m, Math.sqrt(v)];
}
function aggregateByConfig(rows: RunSummary[]): AggregateRow[] {
const groups = new Map<string, RunSummary[]>();
for (const r of rows) {
if (!groups.has(r.name)) groups.set(r.name, []);
groups.get(r.name)!.push(r);
}
const result: AggregateRow[] = [];
for (const [name, group] of groups) {
const [meanCost, stdCost] = meanStd(group.map((r) => r.costUsd));
const [meanMiss, stdMiss] = meanStd(group.map((r) => r.cacheMissTokens));
const [meanHitPct, stdHitPct] = meanStd(group.map((r) => r.cacheHitRatio * 100));
const [meanTurns] = meanStd(group.map((r) => r.turns));
const [meanFolds] = meanStd(group.map((r) => r.foldCount));
result.push({
name,
n: group.length,
meanCost,
stdCost,
meanMiss,
stdMiss,
meanHitPct,
stdHitPct,
meanTurns,
meanFolds,
});
}
return result;
}
function reportAggregate(rows: AggregateRow[]): string {
const cols: Array<[string, (r: AggregateRow) => string]> = [
["config", (r) => r.name],
["n", (r) => String(r.n)],
["turns", (r) => r.meanTurns.toFixed(1)],
["folds", (r) => r.meanFolds.toFixed(1)],
["hit%", (r) => `${r.meanHitPct.toFixed(1)}±${r.stdHitPct.toFixed(1)}`],
["miss", (r) => `${fmtTok(r.meanMiss)}±${fmtTok(r.stdMiss)}`],
["cost$", (r) => `${r.meanCost.toFixed(4)}±${r.stdCost.toFixed(4)}`],
];
const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
const head = cols.map(([h], i) => h.padEnd(widths[i]!)).join("│ ");
const sep = "─".repeat(widths.reduce((a, b) => a + b + 2, 0));
const body = rows
.map((r) => cols.map(([_, f], i) => f(r).padEnd(widths[i]!)).join("│ "))
.join("\n");
return `${head}\n${sep}\n${body}`;
}
function reportTable(rows: RunSummary[]): string {
const cols: Array<[string, (r: RunSummary) => string]> = [
["config", (r) => r.name],
["turns", (r) => String(r.turns)],
["input", (r) => fmtTok(r.totalInputTokens)],
["hit", (r) => fmtTok(r.cacheHitTokens)],
["hit%", (r) => pct(r.cacheHitRatio)],
["miss", (r) => fmtTok(r.cacheMissTokens)],
["folds", (r) => String(r.foldCount)],
["cost", (r) => `$${r.costUsd.toFixed(4)}`],
["exit", (r) => String(r.exitCode)],
];
const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
const head = cols.map(([h], i) => h.padEnd(widths[i]!)).join("│ ");
const sep = "─".repeat(widths.reduce((a, b) => a + b + 2, 0));
const body = rows
.map((r) => cols.map(([_, f], i) => f(r).padEnd(widths[i]!)).join("│ "))
.join("\n");
return `${head}\n${sep}\n${body}`;
}
function main(): void {
const { taskFile, budget, outDir, only, fakeCtxMax, repeats } = parseArgs(process.argv.slice(2));
const task = readFileSync(taskFile, "utf8").trim();
mkdirSync(outDir, { recursive: true });
const specs = only ? RUNS.filter((r) => r.name === only) : RUNS;
if (specs.length === 0) {
console.error(`no config matches --only=${only}; available: ${RUNS.map((r) => r.name).join(", ")}`);
process.exit(1);
}
console.log(`task: ${taskFile} (${task.length} chars)`);
console.log(`out: ${outDir}`);
console.log(`budget per run: $${budget}`);
console.log(`configs: ${specs.map((s) => s.name).join(", ")}`);
console.log(`repeats: ${repeats}`);
if (fakeCtxMax) console.log(`fake ctxMax: ${fakeCtxMax}`);
const summaries: RunSummary[] = [];
for (let r = 1; r <= repeats; r++) {
for (const spec of specs) {
summaries.push(runOne(spec, task, budget, outDir, fakeCtxMax, r));
}
}
const table = reportTable(summaries);
console.log(`\n=== Raw runs ===\n${table}`);
if (repeats > 1) {
const agg = aggregateByConfig(summaries);
const aggTable = reportAggregate(agg);
console.log(`\n=== Aggregate (mean ± std) ===\n${aggTable}`);
writeFileSync(join(outDir, "aggregate.txt"), aggTable);
}
writeFileSync(join(outDir, "summary.json"), JSON.stringify(summaries, null, 2));
writeFileSync(join(outDir, "summary.txt"), table);
console.log(`\nsaved: ${join(outDir, "summary.json")}`);
}
main();
+89
View File
@@ -0,0 +1,89 @@
import { basename } from "node:path";
import { loadSession, simulate, type RunResult, type ThresholdConfig } from "./simulator.js";
const BASELINE: ThresholdConfig = {
ctxMax: 1_000_000,
foldThreshold: 0.5,
tailFraction: 0.2,
aggressiveThreshold: 0.7,
aggressiveTailFraction: 0.1,
minSavingsFraction: 0.3,
summaryRatio: 0.05,
};
interface NamedConfig {
name: string;
config: ThresholdConfig;
}
function configs(ctxMax: number): NamedConfig[] {
const mk = (over: Partial<ThresholdConfig>): ThresholdConfig => ({ ...BASELINE, ...over, ctxMax });
return [
{ name: "no-fold", config: mk({ foldThreshold: 999, aggressiveThreshold: 999 }) },
{ name: "current (50/70)", config: mk({ foldThreshold: 0.5, aggressiveThreshold: 0.7 }) },
{ name: "late (60/80)", config: mk({ foldThreshold: 0.6, aggressiveThreshold: 0.8 }) },
{ name: "very-late (75/90)", config: mk({ foldThreshold: 0.75, aggressiveThreshold: 0.9 }) },
{ name: "early (35/55)", config: mk({ foldThreshold: 0.35, aggressiveThreshold: 0.55 }) },
];
}
function parseArgs(argv: string[]): { sessions: string[]; ctxMax: number } {
const sessions: string[] = [];
let ctxMax = 1_000_000;
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === "--ctx-max") ctxMax = Number(argv[++i]);
else if (a === "--session") sessions.push(argv[++i]!);
else if (!a.startsWith("--")) sessions.push(a);
}
return { sessions, ctxMax };
}
function fmt(n: number): string {
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}G`;
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
return String(n);
}
function pct(n: number): string {
return `${(n * 100).toFixed(1)}%`;
}
function report(label: string, rows: Array<{ name: string; result: RunResult }>): void {
console.log(`\n=== ${label} ===`);
const w = [22, 10, 10, 8, 10, 6, 10];
const headers = ["config", "input", "cache_hit", "hit%", "miss", "folds", "peak"];
console.log(headers.map((h, i) => h.padEnd(w[i]!)).join("│ "));
console.log("─".repeat(w.reduce((a, b) => a + b + 2, 0)));
for (const { name, result: r } of rows) {
const peak = r.perTurn.reduce((m, t) => Math.max(m, t.ratio), 0);
console.log(
[
name.padEnd(w[0]!),
fmt(r.totalInputTokens).padEnd(w[1]!),
fmt(r.totalCacheHitTokens).padEnd(w[2]!),
pct(r.cacheHitRatio).padEnd(w[3]!),
fmt(r.totalCacheMissTokens).padEnd(w[4]!),
String(r.foldCount).padEnd(w[5]!),
pct(peak).padEnd(w[6]!),
].join("│ "),
);
}
}
function main(): void {
const { sessions, ctxMax } = parseArgs(process.argv.slice(2));
if (sessions.length === 0) {
console.error("usage: tsx run.ts [--ctx-max N] <session.jsonl> [more...]");
process.exit(1);
}
for (const path of sessions) {
const msgs = loadSession(path);
const cfgs = configs(ctxMax);
const rows = cfgs.map(({ name, config }) => ({ name, result: simulate(msgs, config, path) }));
report(`${basename(path)} (${msgs.length} msgs, ctxMax=${fmt(ctxMax)})`, rows);
}
}
main();
+197
View File
@@ -0,0 +1,197 @@
import { readFileSync } from "node:fs";
import {
countTokensBounded,
estimateConversationTokens,
estimateRequestTokens,
} from "../../src/tokenizer.js";
import type { ChatMessage } from "../../src/types.js";
export interface ThresholdConfig {
ctxMax: number;
foldThreshold: number;
tailFraction: number;
aggressiveThreshold: number;
aggressiveTailFraction: number;
minSavingsFraction: number;
summaryRatio: number;
}
export interface FoldEvent {
turn: number;
ratioBeforeFold: number;
promptTokensBefore: number;
headMessages: number;
tailMessages: number;
headTokens: number;
summaryTokens: number;
aggressive: boolean;
}
export interface TurnRecord {
turn: number;
promptTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
ratio: number;
folded: boolean;
}
export interface RunResult {
config: ThresholdConfig;
sessionPath: string;
totalTurns: number;
totalInputTokens: number;
totalCacheHitTokens: number;
totalCacheMissTokens: number;
cacheHitRatio: number;
foldCount: number;
folds: FoldEvent[];
perTurn: TurnRecord[];
}
export function loadSession(path: string): ChatMessage[] {
const lines = readFileSync(path, "utf8").split("\n");
const out: ChatMessage[] = [];
for (const ln of lines) {
const t = ln.trim();
if (!t) continue;
try {
out.push(JSON.parse(t) as ChatMessage);
} catch {
continue;
}
}
return out;
}
function messageTokens(m: ChatMessage): number {
return estimateConversationTokens([m], true);
}
function messageKey(m: ChatMessage): string {
return JSON.stringify({
r: m.role,
c: m.content ?? "",
tc: m.tool_calls ?? null,
tcid: m.tool_call_id ?? null,
});
}
function commonPrefixTokens(curr: ChatMessage[], prev: ChatMessage[]): number {
const n = Math.min(curr.length, prev.length);
let tokens = 0;
for (let i = 0; i < n; i++) {
if (messageKey(curr[i]!) !== messageKey(prev[i]!)) break;
tokens += messageTokens(curr[i]!);
}
return tokens;
}
function findFoldBoundary(log: ChatMessage[], tailBudget: number): number {
let cum = 0;
let boundary = log.length;
for (let i = log.length - 1; i >= 0; i--) {
const t = messageTokens(log[i]!);
if (cum + t > tailBudget) break;
cum += t;
if (log[i]!.role === "user") boundary = i;
}
return boundary;
}
function synthesizeSummary(head: ChatMessage[], targetTokens: number): ChatMessage {
const userBits: string[] = [];
for (const m of head) {
if (m.role === "user" && typeof m.content === "string") userBits.push(m.content);
}
const seed = userBits.join("\n") || "earlier turns folded";
let text = `[CONVERSATION HISTORY SUMMARY — fold of ${head.length} messages]\n\n${seed}`;
const currentTokens = countTokensBounded(text);
if (currentTokens > targetTokens) {
const ratio = targetTokens / currentTokens;
text = text.slice(0, Math.max(80, Math.floor(text.length * ratio)));
}
return { role: "assistant", content: text, reasoning_content: "" };
}
function turnBoundaries(messages: ChatMessage[]): number[] {
const ends: number[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i]!.role === "assistant") ends.push(i);
}
return ends;
}
export function simulate(messages: ChatMessage[], config: ThresholdConfig, sessionPath: string): RunResult {
const ends = turnBoundaries(messages);
const log: ChatMessage[] = [];
let prevSnapshot: ChatMessage[] = [];
const perTurn: TurnRecord[] = [];
const folds: FoldEvent[] = [];
let totalInput = 0;
let totalHit = 0;
let totalMiss = 0;
let cursor = 0;
for (let turn = 0; turn < ends.length; turn++) {
const end = ends[turn]!;
for (let i = cursor; i < end; i++) log.push(messages[i]!);
cursor = end;
const promptTokens = estimateRequestTokens(log, null, true);
const ratio = promptTokens / config.ctxMax;
const hit = commonPrefixTokens(log, prevSnapshot);
const miss = Math.max(0, promptTokens - hit);
let folded = false;
if (ratio > config.foldThreshold) {
const aggressive = ratio > config.aggressiveThreshold;
const tailFrac = aggressive ? config.aggressiveTailFraction : config.tailFraction;
const tailBudget = Math.floor(config.ctxMax * tailFrac);
const boundary = findFoldBoundary(log, tailBudget);
if (boundary > 0) {
const head = log.slice(0, boundary);
const tail = log.slice(boundary);
const headTokens = head.reduce((a, m) => a + messageTokens(m), 0);
const totalTokens = log.reduce((a, m) => a + messageTokens(m), 0);
if (headTokens >= totalTokens * config.minSavingsFraction) {
const summaryTokens = Math.max(64, Math.floor(headTokens * config.summaryRatio));
const summary = synthesizeSummary(head, summaryTokens);
const next = [summary, ...tail];
log.length = 0;
for (const m of next) log.push(m);
folds.push({
turn,
ratioBeforeFold: ratio,
promptTokensBefore: promptTokens,
headMessages: head.length,
tailMessages: tail.length,
headTokens,
summaryTokens,
aggressive,
});
folded = true;
}
}
}
totalInput += promptTokens;
totalHit += hit;
totalMiss += miss;
perTurn.push({ turn, promptTokens, cacheHitTokens: hit, cacheMissTokens: miss, ratio, folded });
prevSnapshot = log.slice();
}
return {
config,
sessionPath,
totalTurns: ends.length,
totalInputTokens: totalInput,
totalCacheHitTokens: totalHit,
totalCacheMissTokens: totalMiss,
cacheHitRatio: totalInput > 0 ? totalHit / totalInput : 0,
foldCount: folds.length,
folds,
perTurn,
};
}
+13
View File
@@ -0,0 +1,13 @@
为这个仓库的 src/ 目录写一份完整的架构总览文档。
要求:
1. 用 read_file / directory_tree / grep_search 等工具,**逐个**读取 src/ 下每个子目录(cli/, mcp/, memory/, telemetry/, transcript/, tools/, ui/, i18n/, loop/ 等)的代表性文件。每个子目录至少读 3-5 个核心 .ts 文件。
2. 不要只 grep 关键词就下结论 —— 必须打开文件读真实内容,从代码里抽出"这个模块的职责 / 它的核心数据结构 / 它对外暴露什么"。
3. 边读边在心里搭建依赖图。
4. 全部读完后,输出一份 markdown 报告,包含:
- 整体架构图(mermaid 或 ASCII,模块之间的依赖关系)
- 按模块列出:职责 / 关键文件 / 对外接口 / 内部依赖
- 至少 8 个具体的代码引用(路径:行号),证明你真读了
- 结尾给出"如果新人加入项目,应该按什么顺序读代码"的建议
这是一个真实的代码审计任务,请不要偷懒、不要瞎编、不要用通用模板。文件读得不够多就直接说"我读得不够"。
+15
View File
@@ -304,6 +304,16 @@ export const en = {
loadingTranscript: "loading transcript…",
emptyTranscript: "empty transcript.",
messages: "{count} message{s}",
newBtn: "New session",
newHint: "Archive the current conversation and start a fresh one",
switchBtn: "Switch to this session",
deleteBtn: "Delete",
deleteConfirm: "Delete session \"{name}\"? This removes the transcript file and cannot be undone.",
cantDeleteActive: "Switch to a different session before deleting this one.",
attachRequired:
"Live session operations need an attached CLI session. Launch via reasonix chat or open the dashboard from inside a TUI session.",
activeChip: "active",
activePill: "active",
},
tools: {
loading: "loading tools…",
@@ -431,6 +441,10 @@ export const en = {
whyUnbridgedDesc:
"This spec lives in your config.json but isn't bridged into the live session. MCP servers attach when reasonix code starts; the dashboard alone can't spawn the child process.",
whyUnbridgedHint: "To activate: restart reasonix code, then refresh this dashboard.",
bridgeFailed: "Bridge failed",
bridgeFailedTitle: "bridge failed · in config",
bridgeFailedHint:
"Reasonix tried to bridge this server and the attempt errored. Common causes: wrong URL, missing auth header, upstream 404/5xx, missing local command. Fix and restart reasonix code to retry.",
pickHint: "Pick an MCP server on the left to inspect tools / resources / prompts.",
toolsTitle: "Tools · {count}",
resourcesTitle: "Resources · {count}",
@@ -614,6 +628,7 @@ export const en = {
customRequestBodyMustBeObject: "Custom request body must be a JSON object.",
saveBeforeIndex: "Save semantic settings before starting an index.",
extraBody: "extra body",
batchSize: "batch size",
keepExistingKey: "leave blank to keep existing key",
remoteProvider: "Remote embedding provider",
remoteProviderDesc:
+15
View File
@@ -300,6 +300,16 @@ export const zhCN = {
loadingTranscript: "加载转录稿…",
emptyTranscript: "空的转录稿。",
messages: "{count} 条消息",
newBtn: "新建会话",
newHint: "归档当前会话并开启新会话",
switchBtn: "切换到此会话",
deleteBtn: "删除",
deleteConfirm: "确定删除会话「{name}」?转录文件将被移除,无法撤销。",
cantDeleteActive: "请先切换到其他会话,再删除当前会话。",
attachRequired:
"实时会话操作需要已连接的 CLI 会话。请通过 reasonix chat 启动,或在 TUI 会话中打开仪表盘。",
activeChip: "当前",
activePill: "当前",
},
tools: {
loading: "加载工具…",
@@ -407,6 +417,10 @@ export const zhCN = {
whyUnbridgedDesc:
"此规格存在于您的 config.json 中,但未桥接到实时会话。MCP 服务器在 reasonix code 启动时连接;仪表盘本身无法生成子进程。",
whyUnbridgedHint: "激活方法:重启 reasonix code,然后刷新此仪表盘。",
bridgeFailed: "桥接失败",
bridgeFailedTitle: "桥接失败 · 在配置中",
bridgeFailedHint:
"已尝试桥接但失败。常见原因:URL 错误、需要鉴权、上游 404 / 5xx、本地命令缺失。修复后重启 reasonix code 重试。",
pickHint: "选择左侧的 MCP 服务器以检查工具 / 资源 / 提示。",
toolsTitle: "工具 · {count}",
resourcesTitle: "资源 · {count}",
@@ -587,6 +601,7 @@ export const zhCN = {
customRequestBodyMustBeObject: "自定义请求体必须是 JSON 对象。",
saveBeforeIndex: "请先保存语义设置,再启动索引。",
extraBody: "扩展请求体",
batchSize: "批次大小",
keepExistingKey: "留空则保留现有 Key",
remoteProvider: "远程向量服务",
remoteProviderDesc:
+59 -19
View File
@@ -85,10 +85,18 @@ interface RegistryListResponse {
type McpFilter = "all" | "live" | "unbridged" | "marketplace";
interface McpFailure {
spec: string;
name: string;
reason: string;
at: number;
}
export function McpPanel() {
useLang();
const [data, setData] = useState<McpData | null>(null);
const [specs, setSpecs] = useState<string[] | null>(null);
const [failures, setFailures] = useState<McpFailure[]>([]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [newSpec, setNewSpec] = useState("");
@@ -171,11 +179,27 @@ export function McpPanel() {
spec: normalizeMcpSpec(server.spec) ?? "",
})),
});
const specResponse = await api<{ specs?: unknown[] }>("/mcp/specs");
const specResponse = await api<{ specs?: unknown[]; failures?: unknown[] }>("/mcp/specs");
const normalized = (Array.isArray(specResponse.specs) ? specResponse.specs : [])
.map(normalizeMcpSpec)
.filter((spec): spec is string => spec !== null && spec.length > 0);
setSpecs(normalized);
const rawFailures = Array.isArray(specResponse.failures) ? specResponse.failures : [];
const validFailures: McpFailure[] = [];
for (const f of rawFailures) {
if (typeof f !== "object" || f === null) continue;
const o = f as Record<string, unknown>;
const rawSpec = typeof o.spec === "string" ? o.spec : "";
const norm = normalizeMcpSpec(rawSpec);
if (!norm) continue;
validFailures.push({
spec: norm,
name: typeof o.name === "string" ? o.name : "",
reason: typeof o.reason === "string" ? o.reason : "",
at: typeof o.at === "number" ? o.at : 0,
});
}
setFailures(validFailures);
} catch (err) {
setError((err as Error).message);
}
@@ -341,8 +365,9 @@ export function McpPanel() {
}
${
showUnbridged
? unbridgedSpecs.map(
(spec) => html`
? unbridgedSpecs.map((spec) => {
const failure = failures.find((f) => f.spec === spec);
return html`
<div
class=${`ssl-row ${openUnbridged === spec ? "sel" : ""}`}
onClick=${() => {
@@ -350,12 +375,12 @@ export function McpPanel() {
setOpen(null);
}}
>
<span class="name">${specLabel(spec)} <span class="pill">${t("mcp.unbridged")}</span></span>
<span class="name">${specLabel(spec)} <span class=${`pill ${failure ? "err" : ""}`}>${failure ? t("mcp.bridgeFailed") : t("mcp.unbridged")}</span></span>
<span class="preview">${specCommand(spec)}</span>
<span class="meta"><span class="dim">${t("mcp.inConfig")}</span></span>
<span class="meta"><span class=${failure ? "" : "dim"} style=${failure ? "color:var(--c-err)" : ""}>${failure ? failure.reason : t("mcp.inConfig")}</span></span>
</div>
`,
)
`;
})
: null
}
</div>
@@ -376,10 +401,12 @@ export function McpPanel() {
onClose: () => setOpenRegistry(null),
})
: openUnbridged != null
? html`
? (() => {
const failure = failures.find((f) => f.spec === openUnbridged);
return html`
<div class="sessions-detail-h">
<span class="name">${specLabel(openUnbridged)}</span>
<span class="ws"><span class="pill">${t("mcp.unbridgedTitle")}</span></span>
<span class="ws"><span class="pill">${failure ? t("mcp.bridgeFailedTitle") : t("mcp.unbridgedTitle")}</span></span>
<span class="actions">
<button class="btn" disabled=${busy} onClick=${() => removeSpec(openUnbridged)}
style="border-color:var(--c-err);color:var(--c-err)">${t("mcp.removeBtn")}</button>
@@ -390,16 +417,29 @@ export function McpPanel() {
<div class="card-h"><span class="title">${t("mcp.spec")}</span></div>
<code class="mono" style="font-size:11.5px;color:var(--fg-2);word-break:break-all">${openUnbridged}</code>
</div>
<div class="card accent-warn">
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("mcp.whyUnbridged")}</span></div>
<div class="card-b" style="font-size:13px;line-height:1.6">
${t("mcp.whyUnbridgedDesc")}
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
${t("mcp.whyUnbridgedHint")}
</div>
</div>
</div>
`
${
failure
? html`<div class="card accent-err">
<div class="card-h"><span class="title" style="color:var(--c-err)">${t("mcp.bridgeFailed")}</span></div>
<div class="card-b" style="font-size:13px;line-height:1.6">
<code class="mono" style="font-size:12px;color:var(--fg-1);word-break:break-word;white-space:pre-wrap">${failure.reason}</code>
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
${t("mcp.bridgeFailedHint")}
</div>
</div>
</div>`
: html`<div class="card accent-warn">
<div class="card-h"><span class="title" style="color:var(--c-warn)">${t("mcp.whyUnbridged")}</span></div>
<div class="card-b" style="font-size:13px;line-height:1.6">
${t("mcp.whyUnbridgedDesc")}
<div style="margin-top:10px;color:var(--fg-3);font-size:12px">
${t("mcp.whyUnbridgedHint")}
</div>
</div>
</div>`
}
`;
})()
: open == null
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
${showMarketplace ? t("mcp.marketplacePickHint") : t("mcp.pickHint")}
+27
View File
@@ -16,6 +16,7 @@ interface SemanticConfigView {
apiKeySet: boolean;
model: string;
extraBody: Record<string, unknown>;
batchSize: number;
};
}
@@ -44,6 +45,7 @@ interface SemanticData {
apiKeySet: boolean;
model: string;
extraBodyKeys: string[];
batchSize: number;
};
index?: IndexInfo;
job?: SemanticJob | null;
@@ -106,6 +108,7 @@ interface SemanticConfigDraft {
apiKey: string;
model: string;
extraBodyText: string;
batchSize: number;
apiKeySet: boolean;
};
}
@@ -243,6 +246,7 @@ export function SemanticPanel() {
apiKey: draft.openaiCompat.apiKey,
model: draft.openaiCompat.model,
extraBody,
batchSize: draft.openaiCompat.batchSize,
},
},
});
@@ -414,6 +418,27 @@ export function SemanticPanel() {
}}
/>
</div>
<div class="form-row">
<span class="lbl">${t("semantic.batchSize")}</span>
<input
class="input mono"
type="number"
min="1"
value=${draft.openaiCompat.batchSize}
onInput=${(e: Event) => {
const v = Number.parseInt((e.target as HTMLInputElement).value, 10);
draftDirtyRef.current = true;
setDraftDirty(true);
setDraft({
...draft,
openaiCompat: {
...draft.openaiCompat,
batchSize: Number.isInteger(v) && v > 0 ? v : 10,
},
});
}}
/>
</div>
<details style="margin-top:10px">
<summary style="cursor:pointer;color:var(--fg-2);font-size:12px">${t("semantic.customRequestBody")}</summary>
<div class="form-row" style="margin-top:10px">
@@ -600,6 +625,7 @@ export function SemanticPanel() {
<div class="rail-kv"><span class="k">${t("semantic.apiKey")}</span><span class="v">${remote?.apiKeySet ? html`<span class="pill ok">${t("semantic.found")}</span>` : html`<span class="pill warn">${t("semantic.missing")}</span>`}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.model")}</span><span class="v" style="font-size:11px">${remote?.model ?? draft.openaiCompat.model}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.extraBody")}</span><span class="v">${fmtNum(remote?.extraBodyKeys.length ?? 0)}</span></div>
<div class="rail-kv"><span class="k">${t("semantic.batchSize")}</span><span class="v">${remote?.batchSize ?? 10}</span></div>
`
}
</div>
@@ -622,6 +648,7 @@ function toConfigDraft(config: SemanticConfigView): SemanticConfigDraft {
apiKey: "",
model: config.openaiCompat.model,
extraBodyText: JSON.stringify(config.openaiCompat.extraBody ?? {}, null, 2),
batchSize: config.openaiCompat.batchSize,
apiKeySet: config.openaiCompat.apiKeySet,
},
};
+135 -33
View File
@@ -15,6 +15,8 @@ interface SessionEntry {
interface SessionsData {
sessions?: SessionEntry[];
currentSession?: string | null;
canSwitch?: boolean;
}
interface OpenSession {
@@ -25,10 +27,12 @@ interface OpenSession {
export function SessionsPanel() {
useLang();
const { data, error, loading } = usePoll<SessionsData>("/sessions", 5000);
const { data, error, loading, refresh } = usePoll<SessionsData>("/sessions", 5000);
const [open, setOpen] = useState<OpenSession | null>(null);
const [openLoading, setOpenLoading] = useState(false);
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const view = useCallback(async (name: string) => {
setOpen({ name, messages: null });
@@ -43,13 +47,62 @@ export function SessionsPanel() {
}
}, []);
const newSession = useCallback(async () => {
setBusy("new");
setActionError(null);
try {
await api("/sessions/new", { method: "POST" });
setOpen(null);
await refresh();
} catch (err) {
setActionError((err as Error).message);
} finally {
setBusy(null);
}
}, [refresh]);
const switchTo = useCallback(
async (name: string) => {
setBusy(`switch:${name}`);
setActionError(null);
try {
await api(`/sessions/${encodeURIComponent(name)}/switch`, { method: "POST" });
setOpen(null);
await refresh();
} catch (err) {
setActionError((err as Error).message);
} finally {
setBusy(null);
}
},
[refresh],
);
const remove = useCallback(
async (name: string) => {
if (!confirm(t("sessions.deleteConfirm", { name }))) return;
setBusy(`delete:${name}`);
setActionError(null);
try {
await api(`/sessions/${encodeURIComponent(name)}`, { method: "DELETE" });
if (open?.name === name) setOpen(null);
await refresh();
} catch (err) {
setActionError((err as Error).message);
} finally {
setBusy(null);
}
},
[open, refresh],
);
if (loading && !data)
return html`<div class="card" style="color:var(--fg-3)">${t("sessions.loading")}</div>`;
if (error) return html`<div class="card accent-err">${t("common.loadingFailed", { name: "sessions", error: error.message })}</div>`;
if (error)
return html`<div class="card accent-err">${t("common.loadingFailed", { name: "sessions", error: error.message })}</div>`;
const sessions = data?.sessions ?? [];
if (sessions.length === 0)
return html`<div class="card" style="color:var(--fg-3)">${t("sessions.noSessions")}</div>`;
const currentSession = data?.currentSession ?? null;
const canSwitch = data?.canSwitch ?? false;
const filtered = filter.trim()
? sessions.filter((s) => s.name.toLowerCase().includes(filter.toLowerCase()))
@@ -58,7 +111,7 @@ export function SessionsPanel() {
return html`
<div class="sessions-grid">
<div class="sessions-list">
<div class="ssl-h">
<div class="ssl-h" style="display:flex;gap:6px">
<input
type="text"
placeholder=${t("sessions.filterPlaceholder")}
@@ -66,27 +119,54 @@ export function SessionsPanel() {
onInput=${(e: Event) => setFilter((e.target as HTMLInputElement).value)}
style="flex:1"
/>
<button
class="btn primary"
disabled=${!canSwitch || busy === "new"}
title=${canSwitch ? t("sessions.newHint") : t("sessions.attachRequired")}
onClick=${newSession}
>
${busy === "new" ? t("common.loading") : `+ ${t("sessions.newBtn")}`}
</button>
</div>
${
!canSwitch
? html`<div style="padding:0 12px 6px;font-size:11.5px;color:var(--fg-3)">${t("sessions.attachRequired")}</div>`
: null
}
${
actionError
? html`<div class="card accent-err" style="margin:0 12px 8px;padding:6px 10px;font-size:12px">${actionError}</div>`
: null
}
<div class="chips" style="padding:0 12px 8px">
<span class="chip-f static active">${t("common.all")} <span class="ct">${sessions.length}</span></span>
${currentSession ? html`<span class="chip-f static">${t("sessions.activeChip")} <span class="ct">${currentSession}</span></span>` : null}
</div>
<div class="ssl-rows">
${filtered.map(
(s) => html`
<div
class=${`ssl-row ${open?.name === s.name ? "sel" : ""}`}
onClick=${() => view(s.name)}
>
<span class="name">${s.name}</span>
<span class="meta">
<span><span class="v">${fmtNum(s.messageCount)}</span> ${t("sessions.msgs")}</span>
<span><span class="v">${fmtBytes(s.size)}</span></span>
<span>${fmtRelativeTime(s.mtime)}</span>
</span>
</div>
`,
)}
</div>
${
sessions.length === 0
? html`<div class="ctx-empty" style="padding:24px 12px;color:var(--fg-3)">${t("sessions.noSessions")}</div>`
: html`<div class="ssl-rows">
${filtered.map((s) => {
const isCurrent = currentSession === s.name;
return html`
<div
class=${`ssl-row ${open?.name === s.name ? "sel" : ""}`}
onClick=${() => view(s.name)}
>
<span class="name">
${isCurrent ? html`<span class="pill ok" style="margin-right:6px">${t("sessions.activePill")}</span>` : null}
${s.name}
</span>
<span class="meta">
<span><span class="v">${fmtNum(s.messageCount)}</span> ${t("sessions.msgs")}</span>
<span><span class="v">${fmtBytes(s.size)}</span></span>
<span>${fmtRelativeTime(s.mtime)}</span>
</span>
</div>
`;
})}
</div>`
}
</div>
<div class="sessions-detail">
@@ -95,9 +175,14 @@ export function SessionsPanel() {
? html`<div style="color:var(--fg-3);font-size:13px;text-align:center;padding:60px 20px">
${t("sessions.pickHint")}
</div>`
: html`
: (() => {
const isCurrent = currentSession === open.name;
return html`
<div class="sessions-detail-h">
<span class="name">${open.name}</span>
<span class="name">
${isCurrent ? html`<span class="pill ok" style="margin-right:6px">${t("sessions.activePill")}</span>` : null}
${open.name}
</span>
<span class="ws">
${
open.messages
@@ -106,16 +191,32 @@ export function SessionsPanel() {
}
</span>
<span class="actions">
${
canSwitch && !isCurrent
? html`<button class="btn primary" disabled=${busy === `switch:${open.name}`} onClick=${() => switchTo(open.name)}>${busy === `switch:${open.name}` ? t("common.loading") : t("sessions.switchBtn")}</button>`
: null
}
<button
class="btn"
disabled=${isCurrent || busy === `delete:${open.name}`}
title=${isCurrent ? t("sessions.cantDeleteActive") : t("sessions.deleteBtn")}
style="border-color:var(--c-err);color:var(--c-err)"
onClick=${() => remove(open.name)}
>${busy === `delete:${open.name}` ? t("common.loading") : t("sessions.deleteBtn")}</button>
<button class="btn ghost" onClick=${() => setOpen(null)}>${t("common.back")}</button>
</span>
</div>
<div class="card accent-brand" style="margin-bottom:10px">
<div class="card-h"><span class="title">${t("sessions.resumeTitle")}</span></div>
<div class="card-b" style="font-size:12.5px;color:var(--fg-2)">
${t("sessions.resumeDesc")}
<code class="mono" style="display:block;margin-top:8px;padding:8px 10px;background:var(--bg-input);border-radius:var(--r);color:var(--fg-0);font-size:12px;user-select:all">reasonix chat --session ${open.name}</code>
</div>
</div>
${
!canSwitch
? html`<div class="card accent-brand" style="margin-bottom:10px">
<div class="card-h"><span class="title">${t("sessions.resumeTitle")}</span></div>
<div class="card-b" style="font-size:12.5px;color:var(--fg-2)">
${t("sessions.resumeDesc")}
<code class="mono" style="display:block;margin-top:8px;padding:8px 10px;background:var(--bg-input);border-radius:var(--r);color:var(--fg-0);font-size:12px;user-select:all">reasonix chat --session ${open.name}</code>
</div>
</div>`
: null
}
${
openLoading
? html`<div style="color:var(--fg-3)">${t("sessions.loadingTranscript")}</div>`
@@ -147,7 +248,8 @@ export function SessionsPanel() {
</div>`
: html`<div style="color:var(--fg-3)">${t("sessions.emptyTranscript")}</div>`
}
`
`;
})()
}
</div>
</div>
+29 -21
View File
@@ -1,24 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1254 1254">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8b7cff"/>
<stop offset="100%" stop-color="#5b3df0"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.22"/>
<stop offset="38%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<filter id="innerShadow" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feOffset dy="-12" result="off"/>
<feComposite in="off" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1" result="inner"/>
<feFlood flood-color="#1a0e7a" flood-opacity="0.35"/>
<feComposite in2="inner" operator="in"/>
</filter>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #0153e5;
}
</style>
</defs>
<rect x="64" y="64" width="896" height="896" rx="220" ry="220" fill="url(#g)"/>
<rect x="64" y="64" width="896" height="896" rx="220" ry="220" fill="url(#sheen)"/>
<g fill="#ffffff" font-family="JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, monospace" font-weight="800" text-anchor="middle">
<text x="512" y="700" font-size="640" letter-spacing="-12">R</text>
<rect class="cls-1" x="6.03" y="6.03" width="1241.94" height="1241.94" rx="212.47" ry="212.47"/>
<g>
<path class="cls-2" d="M365.68,979.74l-25.49-38.96c-6.04-.37-11.31-.37-17.43.02l-.32,38.89-25.45-.07.17-116.94,41.97-.09c8.63-.02,17.06-.36,25.04,2.45,16,4.84,25.56,18.48,25.9,34.98s-8.99,30.24-24.61,36.54l29.2,42.77-28.97.42ZM349.06,918.57c10.12-1.14,15.92-8.96,15.42-18.1-.45-8.24-7.17-15.04-16.43-15.27-8.58-.22-16.84-.16-25.42.11l.2,33.63c9-.35,17.44.61,26.23-.38Z"/>
<path class="cls-2" d="M575.08,979.32c-7.43,1.03-14.99.28-22.9.38l-.74-9.77c-7.73,8.95-17.92,13.2-29.47,11.93-14.76-1.61-26.67-11.64-31.95-25.58-4.4-11.6-4.2-24.54.48-36.11,5.71-14.09,18.45-24.15,33.47-25.24,11.06-.8,20.62,2.91,27.6,11.13l.68-9.23,22.6-.13.22,82.61ZM552.67,938.39c0-11.9-9.65-21.55-21.55-21.55s-21.55,9.65-21.55,21.55,9.65,21.55,21.55,21.55,21.55-9.65,21.55-21.55Z"/>
<path class="cls-2" d="M458.55,951.89c6.66,3,12.37,7.13,18.78,10.99-8.73,13.37-23.6,19.69-39.12,19.17-25.4-.85-43.92-21.32-42.46-46.43,1.26-21.79,17.21-38.77,39.14-40.79,15.8-1.45,31.24,4.92,39.65,18.61,5.72,9.31,6.94,20.05,5.86,30.58l-60.66.08c.84,8.87,8.11,15.02,15.57,16.66,8.84,1.95,17.43-.49,23.24-8.87ZM458.14,928.74c-2.47-9.92-10.62-14.65-19.52-14.49-8.25.15-16.52,5.45-18.32,14.48h37.83Z"/>
<path class="cls-2" d="M750.19,938.44c0,24.46-19.83,44.3-44.3,44.3s-44.3-19.83-44.3-44.3,19.83-44.3,44.3-44.3,44.3,19.83,44.3,44.3ZM727.57,938.41c0-12-9.72-21.72-21.72-21.72s-21.72,9.72-21.72,21.72,9.72,21.72,21.72,21.72,21.72-9.72,21.72-21.72Z"/>
<path class="cls-2" d="M801.56,916.44c-9.85-.29-18.14,6.26-18.21,15.86l-.36,47.37-23.37-.11.03-82.78,22.91.21c.25,3.4-.62,5.99.87,9.46,11.74-15.92,38.04-15.05,49.82-.69,5.53,6.74,8.09,15.39,8.08,24.25l-.04,49.62-23.39.09-.42-47.61c-.08-8.96-6.5-15.4-15.94-15.67Z"/>
<path class="cls-2" d="M973.16,979.4l-26.93.31-17.96-25.12-18.33,25.11-26-.32,30.46-42.06-28.9-40.27c9.78-1.08,18.2-.09,27.28-.01l15.65,23.31,16.3-23.6c8.92-.23,17.29-.37,26.73.41l-28.8,40.03,30.5,42.21Z"/>
<path class="cls-2" d="M638.97,978.82c-18.03,6.92-47.26,4.18-56.68-16.59l17.95-8.65c2.7,1.18,3.3,4.09,5.67,5.7,9.87,6.71,26.42,5.86,26.64-2.77.07-2.84-2.08-5.34-5.15-6.18l-21.93-5.98c-7.07-1.93-13.56-5.89-16.89-12.73-6.16-12.65.07-27.35,12.99-33.01,17.43-7.64,46.57-4.97,53.87,15.09l-17.45,7.58c-6.29-7.68-16.61-10.11-25.61-6.26-2.11.9-3.33,3.62-3.17,5.29.21,2.22,2.03,4.63,4.56,5.32l24.3,6.66c5.87,1.61,11.42,4.7,14.59,10.07,8.12,13.79,1.39,30.66-13.68,36.45Z"/>
<rect class="cls-2" x="823.88" y="926.58" width="83.07" height="23.31" transform="translate(-73.46 1802.96) rotate(-89.96)"/>
<circle class="cls-2" cx="865.58" cy="871.74" r="14.34"/>
</g>
</svg>
<g>
<path class="cls-2" d="M657.07,556.02c4.59,3.86,10.93,5.3,17.45,2.32,2.59-1.18,4.3-4.19,3.22-7.14-8.6-23.51-32.86-40.71-42.55-27.41-1.18,1.63-1.14,3.74-.24,5.03.71,1.02,2.14,1.26,3.99.88,5.89-1.22,13.21,2.87,12.52,9.44-.72,6.85.66,12.72,5.61,16.89Z"/>
<path class="cls-2" d="M798.75,598.8c22.21-24.86,36.14-54.9,40.99-87.98,11.07-72.3-24.83-138.35-92.45-166.33-23.77-9.56-48.19-13.62-74.13-13.37l-251.41.07.02,481.78,123.58-.1v-169.44c-4.09-1.52-6.91-2.78-6.91-2.78-26.86-12.28-47.42-33.58-58.71-60.25-14.41-34.05-9.96-72.23,12.69-101.26,22.56-28.2,53.39-37.16,88.72-32.2,13.05,1.83,27-13.49,53.57-10.57,1.79.2,3.58,1.54,3.76,2.6.62,3.63-9.65,5.57-9.66,15.48,0,4.17,1.87,9,5.81,11.76,13.54,9.5,25.27,20.18,37.54,31.22,5.96,5.36,26.19,20.45,31.34,8.16,3-7.17,4.99-14.67,6.98-22.28.98-3.77-1.1-6.02-4.14-7.82-20.87-12.35-28.73-38.11-20.57-60.92.79-2.2,2.82-3.27,4.56-3.34,7.01-.3,3.28,12.7,20.59,18.03,16.44,5.06,16.94,18.73,24.93,13.29,18.63-12.7,25.34-2.3,42.61-18.58,1.78-1.67,4.83-1.79,6.68-.51,1.14.79,2.12,2.84,2.03,5.22-.51,13.75-5.87,26.88-15.26,36.97-17.76,19.08-32.69,9.95-33.65,23.31-2.83,39.22-15.66,78.37-43.49,106.85-.89.91-1.43,2.22-1.28,3.1.16.92,1.26,1.82,2.46,2.23l23.45,7.97c3.32,1.13,5.72,4.1,5.3,7.27-.37,2.76-2.57,5.2-5.74,6.25-17.21,5.7-35.7,4.7-53.23-.6-23.19,19.53-51.68,29.59-82.74,28.38l45.08,62.23,73.54,100.4,146.79-.2-124.45-168.04c28.97-8.55,54.42-23.87,74.8-45.98Z"/>
<circle class="cls-2" cx="640.36" cy="538.21" r="3.47"/>
<path class="cls-2" d="M504.6,517.71c-7.75.94-12.41,6.5-11.66,14.8,5.25,57.86,58.62,105.22,118.44,93.8,12.73-2.43,24.33-7.49,33.74-16.99-20.35-17.53-30.27-35.39-47.01-52.99-23.95-25.18-58.04-42.91-93.5-38.62Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+312 -1
View File
@@ -16,14 +16,17 @@
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"katex": "^0.16.47",
"lucide-react": "^0.469.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^9.0.1",
"react-virtuoso": "^4.12.5",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0"
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
@@ -1482,6 +1485,12 @@
"@types/unist": "*"
}
},
"node_modules/@types/katex": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -1702,6 +1711,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1774,6 +1792,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1876,6 +1906,101 @@
"node": ">=6.9.0"
}
},
"node_modules/hast-util-from-dom": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
"integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
"license": "ISC",
"dependencies": {
"@types/hast": "^3.0.0",
"hastscript": "^9.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.1.0",
"hast-util-from-parse5": "^8.0.0",
"parse5": "^7.0.0",
"vfile": "^6.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html-isomorphic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
"integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-dom": "^5.0.0",
"hast-util-from-html": "^2.0.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -1903,6 +2028,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unist-util-find-after": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -1916,6 +2057,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -2021,6 +2179,22 @@
"node": ">=6"
}
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -2201,6 +2375,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-math": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
"integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"longest-streak": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.1.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -2534,6 +2727,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-math": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
"integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
"license": "MIT",
"dependencies": {
"@types/katex": "^0.16.0",
"devlop": "^1.0.0",
"katex": "^0.16.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -2964,6 +3176,18 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3091,6 +3315,25 @@
"react-dom": ">=16 || >=17 || >= 18 || >=19"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
"integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/katex": "^0.16.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.0",
"katex": "^0.16.0",
"unist-util-visit-parents": "^6.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
@@ -3124,6 +3367,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-math": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
"integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-math": "^3.0.0",
"micromark-extension-math": "^3.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -3338,6 +3597,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-find-after": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
@@ -3364,6 +3637,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-remove-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -3451,6 +3738,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -3525,6 +3826,16 @@
}
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "reasonix-desktop",
"private": true,
"version": "0.43.0",
"version": "0.48.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,19 +14,23 @@
"@fontsource/geist": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@reasonix/core-utils": "workspace:*",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"katex": "^0.16.47",
"lucide-react": "^0.469.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^9.0.1",
"react-virtuoso": "^4.12.5",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0"
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
+86 -60
View File
@@ -1,12 +1,5 @@
/**
* Cross-platform Node 22 downloader. Detects host platform + arch and pulls
* the matching official Node distribution from nodejs.org, then extracts the
* node binary into desktop/src-tauri/binaries/. Tauri's per-platform conf
* overlay (tauri.<platform>.conf.json) picks it up at bundle time.
*
* Usage: `npm run bundle:node` from desktop/.
* Re-run when bumping NODE_VERSION. The download is gitignored.
*/
// Pulls Node 22 from nodejs.org into desktop/src-tauri/binaries/.
// Mac always produces a universal Mach-O so Intel + Apple Silicon ship from one .dmg (issue #1234).
import { execSync } from "node:child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
import https from "node:https";
@@ -20,32 +13,35 @@ const binDir = join(here, "..", "src-tauri", "binaries");
const PLAT = process.platform;
const ARCH_RAW = process.arch;
const ARCH = ARCH_RAW === "arm64" ? "arm64" : ARCH_RAW === "x64" ? "x64" : null;
const HOST_ARCH = ARCH_RAW === "arm64" ? "arm64" : ARCH_RAW === "x64" ? "x64" : null;
if (!ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
if (!HOST_ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
console.error(`Unsupported host: ${PLAT}/${ARCH_RAW}. Supported: win32 / darwin / linux × x64 / arm64.`);
process.exit(1);
}
const isWin = PLAT === "win32";
const triple =
PLAT === "win32" ? `win-${ARCH}` : PLAT === "darwin" ? `darwin-${ARCH}` : `linux-${ARCH}`;
const archiveExt = PLAT === "win32" ? "zip" : PLAT === "darwin" ? "tar.gz" : "tar.xz";
const archiveBase = `node-v${NODE_VERSION}-${triple}`;
const archiveFile = `${archiveBase}.${archiveExt}`;
const url = `https://nodejs.org/dist/v${NODE_VERSION}/${archiveFile}`;
const targetExe = join(binDir, isWin ? "node.exe" : "node");
const archivePath = join(binDir, archiveFile);
const extractDir = join(binDir, "_extract");
if (existsSync(targetExe)) {
const size = statSync(targetExe).size;
if (size > 1024 * 1024) {
console.log(`${targetExe} already present (${(size / 1024 / 1024).toFixed(1)} MB) — delete to refetch`);
if (existsSync(targetExe) && statSync(targetExe).size > 1024 * 1024) {
if (PLAT === "darwin") {
try {
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
if (archs.includes("arm64") && archs.includes("x86_64")) {
console.log(`${targetExe} already universal (${archs}) — delete to refetch`);
process.exit(0);
}
console.log(`${targetExe} present but not universal (${archs}) — rebuilding`);
rmSync(targetExe);
} catch {
console.log(`${targetExe} present but not verifiable as Mach-O — rebuilding`);
rmSync(targetExe);
}
} else {
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
console.log(`${targetExe} already present (${mb} MB) — delete to refetch`);
process.exit(0);
}
// < 1 MB = placeholder, refetch.
}
mkdirSync(binDir, { recursive: true });
@@ -82,43 +78,73 @@ function follow(url, dest, redirects = 5) {
});
}
console.log(`Downloading ${archiveFile} (Node v${NODE_VERSION} ${triple}) ...`);
await follow(url, archivePath);
process.stdout.write("\n");
async function fetchAndExtract(arch) {
const triple =
PLAT === "win32" ? `win-${arch}` : PLAT === "darwin" ? `darwin-${arch}` : `linux-${arch}`;
const archiveExt = PLAT === "win32" ? "zip" : PLAT === "darwin" ? "tar.gz" : "tar.xz";
const archiveBase = `node-v${NODE_VERSION}-${triple}`;
const archiveFile = `${archiveBase}.${archiveExt}`;
const url = `https://nodejs.org/dist/v${NODE_VERSION}/${archiveFile}`;
const archivePath = join(binDir, archiveFile);
const extractDir = join(binDir, `_extract_${arch}`);
rmSync(extractDir, { recursive: true, force: true });
mkdirSync(extractDir, { recursive: true });
console.log(`Downloading ${archiveFile} ...`);
await follow(url, archivePath);
process.stdout.write("\n");
console.log("Extracting ...");
if (isWin) {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${extractDir}'"`,
{ stdio: "inherit" },
);
} else {
// tar handles both .tar.gz (with -z auto) and .tar.xz (needs xz-utils on host).
execSync(`tar -xf "${archivePath}" -C "${extractDir}"`, { stdio: "inherit" });
}
rmSync(extractDir, { recursive: true, force: true });
mkdirSync(extractDir, { recursive: true });
const inner = isWin
? join(extractDir, archiveBase, "node.exe")
: join(extractDir, archiveBase, "bin", "node");
if (!existsSync(inner)) {
console.error(`Extracted binary not found at expected path: ${inner}`);
process.exit(1);
}
if (existsSync(targetExe)) rmSync(targetExe);
renameSync(inner, targetExe);
if (!isWin) {
try {
chmodSync(targetExe, 0o755);
} catch {
/* ignore */
console.log(`Extracting ${arch} ...`);
if (isWin) {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${extractDir}'"`,
{ stdio: "inherit" },
);
} else {
execSync(`tar -xf "${archivePath}" -C "${extractDir}"`, { stdio: "inherit" });
}
}
rmSync(extractDir, { recursive: true, force: true });
rmSync(archivePath);
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
console.log(`Done: ${targetExe} (${mb} MB)`);
const inner = isWin
? join(extractDir, archiveBase, "node.exe")
: join(extractDir, archiveBase, "bin", "node");
if (!existsSync(inner)) {
console.error(`Extracted binary not found at expected path: ${inner}`);
process.exit(1);
}
rmSync(archivePath);
return { inner, extractDir };
}
if (PLAT === "darwin") {
const [arm, x64] = await Promise.all([fetchAndExtract("arm64"), fetchAndExtract("x64")]);
console.log("Creating universal binary with lipo ...");
if (existsSync(targetExe)) rmSync(targetExe);
execSync(`lipo -create "${arm.inner}" "${x64.inner}" -output "${targetExe}"`, { stdio: "inherit" });
chmodSync(targetExe, 0o755);
rmSync(arm.extractDir, { recursive: true, force: true });
rmSync(x64.extractDir, { recursive: true, force: true });
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
console.log(`Done: ${targetExe} (${mb} MB, archs: ${archs})`);
} else {
const { inner, extractDir } = await fetchAndExtract(HOST_ARCH);
if (existsSync(targetExe)) rmSync(targetExe);
renameSync(inner, targetExe);
if (!isWin) {
try {
chmodSync(targetExe, 0o755);
} catch {
/* ignore */
}
}
rmSync(extractDir, { recursive: true, force: true });
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
console.log(`Done: ${targetExe} (${mb} MB)`);
}
+17 -1
View File
@@ -2776,7 +2776,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "reasonix-desktop"
version = "0.43.0"
version = "0.48.1"
dependencies = [
"anyhow",
"parking_lot",
@@ -2788,6 +2788,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"which",
]
@@ -3865,6 +3866,21 @@ dependencies = [
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.1",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.11.1"
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "reasonix-desktop"
version = "0.43.0"
version = "0.48.1"
edition = "2021"
publish = false
@@ -13,6 +13,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
parking_lot = "0.12"
@@ -16,6 +16,7 @@
"updater:default",
"process:allow-restart",
"dialog:allow-open",
"dialog:allow-save",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "https://*" }, { "url": "http://*" }]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 964 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

+8 -1
View File
@@ -203,6 +203,11 @@ fn open_in_editor(command: String, path: String, line: Option<u32>) -> Result<()
Ok(())
}
#[tauri::command]
fn write_text_file(path: String, content: String) -> Result<(), String> {
std::fs::write(&path, &content).map_err(|e| format!("write failed: {e}"))
}
fn main() {
#[cfg(target_os = "linux")]
linux_webkit_compat();
@@ -212,6 +217,7 @@ fn main() {
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(RpcState::default())
.invoke_handler(tauri::generate_handler![
rpc_spawn,
@@ -219,7 +225,8 @@ fn main() {
rpc_kill,
open_in_editor,
list_workspace_tree,
git_status
git_status,
write_text_file
])
.setup(|app| {
use tauri::Manager;
+4 -1
View File
@@ -141,7 +141,10 @@ fn find_real_node() -> Result<PathBuf> {
pub fn rpc_spawn(app: AppHandle, state: State<'_, RpcState>) -> Result<(), String> {
let mut guard = state.inner.lock();
if guard.is_some() {
return Err("rpc already spawned".into());
// Idempotent — a second call (effect re-run, WebView reload) keeps the
// existing Node child. The frontend follows up with `desktop_resync`
// so a reloaded React app catches up on bootstrap events it missed.
return Ok(());
}
let (program, args) = resolve_cli(&app).map_err(|e| e.to_string())?;
+6 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Reasonix",
"version": "0.43.0",
"version": "0.48.1",
"identifier": "dev.reasonix.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -46,7 +46,10 @@
"createUpdaterArtifacts": true,
"windows": {
"nsis": {
"languages": ["English", "SimpChinese"],
"languages": [
"English",
"SimpChinese"
],
"customLanguageFiles": {
"English": "nsis/English.nsh",
"SimpChinese": "nsis/SimpChinese.nsh"
@@ -58,6 +61,7 @@
"updater": {
"active": true,
"endpoints": [
"https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev/latest/latest.json",
"https://github.com/esengine/reasonix/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDI5RkE3RDJDQTgzMkFDNkMKUldSc3JES29MSDM2S1pwcjR2MnZZMW15eG5ydnQrRUJtdjdteFJiaHF6cnE0NzYyb09Wc2k0ZHEK"
+235
View File
@@ -0,0 +1,235 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./CommandPalette", () => ({
CommandPalette: () => null,
Toast: () => null,
buildCommands: vi.fn(() => []),
useCommandPalette: vi.fn(() => ({ open: false, setOpen: vi.fn() })),
}));
vi.mock("./Markdown", () => ({
WorkspaceProvider: ({ children }: { children?: unknown }) => children ?? null,
}));
vi.mock("./theme", () => ({
FONT_FAMILY: "sans-serif",
FONT_FAMILY_STACK: "sans-serif",
FONT_SCALE: 1,
FONT_SCALE_ZOOM: 1,
THEME: "dark",
defaultStyleForTheme: vi.fn(() => ({
bg: "#000",
surface: "#111",
border: "#222",
text: "#fff",
muted: "#888",
accent: "#0af",
danger: "#f00",
warn: "#fa0",
success: "#0f0",
brand: "#0af",
})),
isFontFamily: vi.fn(() => true),
isFontScale: vi.fn(() => true),
isTheme: vi.fn(() => true),
isThemeStyle: vi.fn(() => true),
themeForStyle: vi.fn(() => "dark"),
}));
import { reduce } from "./App";
function initialState(): Parameters<typeof reduce>[0] {
return {
ready: false,
needsSetup: false,
busy: false,
messages: [],
pendingConfirms: [],
pendingPathAccess: [],
pendingChoices: [],
pendingPlans: [],
pendingCheckpoints: [],
pendingRevisions: [],
activePlan: null,
usage: { totalCostUsd: 0, tokens: { input: 0, output: 0, cacheHit: 0, cacheMiss: 0 } },
sessions: [],
settings: null,
qq: null,
balance: null,
mentionResults: null,
mentionPreview: null,
mcpSpecs: [],
mcpBridged: false,
skills: [],
sessionFiles: [],
memory: [],
jobs: [],
activeSkill: null,
queuedSends: [],
retryNonce: 0,
};
}
function makeShellPrompt(command: string): import("@reasonix/core-utils").ApprovalPrompt {
return {
id: 1,
kind: "shell",
tone: "warn",
title: "Run command",
subtitle: command,
preview: command,
meta: {},
actions: [
{ id: "run_once", label: "Run once", kind: "allow_once" },
{ id: "always_allow", label: "Always allow", kind: "allow_always" },
{
id: "deny",
label: "Deny",
kind: "reject",
secondaryInput: { hint: "Reason", required: false },
},
],
data: { prefix: command.split(" ")[0] ?? "" },
};
}
function makePathPrompt(
path: string,
intent: "read" | "write",
): import("@reasonix/core-utils").ApprovalPrompt {
return {
id: 2,
kind: "path",
tone: "warn",
title: `Access path — ${intent}`,
subtitle: path,
preview: `tool → ${path}`,
meta: { sandboxRoot: "/workspace" },
actions: [
{
id: "run_once",
label: intent === "write" ? "Allow write" : "Allow read",
kind: "allow_once",
},
{ id: "always_allow", label: "Always allow", kind: "allow_always" },
{
id: "deny",
label: "Deny",
kind: "reject",
secondaryInput: { hint: "Reason", required: false },
},
],
data: { prefix: "/workspace", intent },
};
}
describe("Desktop App reducer — ApprovalPrompt integration", () => {
it("stores shell confirm with prompt on $confirm_required", () => {
const state = initialState();
const prompt = makeShellPrompt("git status");
const next = reduce(state, {
t: "incoming",
event: {
type: "$confirm_required",
id: 7,
kind: "run_command",
command: "git status",
prompt,
},
});
expect(next.pendingConfirms).toHaveLength(1);
expect(next.pendingConfirms[0]).toMatchObject({
id: 7,
kind: "run_command",
command: "git status",
});
expect(next.pendingConfirms[0].prompt).toEqual(prompt);
});
it("stores path access with prompt on $path_access_required", () => {
const state = initialState();
const prompt = makePathPrompt("/etc/passwd", "read");
const next = reduce(state, {
t: "incoming",
event: {
type: "$path_access_required",
id: 8,
path: "/etc/passwd",
intent: "read",
toolName: "read_file",
sandboxRoot: "/workspace",
allowPrefix: "/workspace",
prompt,
},
});
expect(next.pendingPathAccess).toHaveLength(1);
expect(next.pendingPathAccess[0]).toMatchObject({
id: 8,
path: "/etc/passwd",
intent: "read",
});
expect(next.pendingPathAccess[0].prompt).toEqual(prompt);
});
it("removes confirm on resolve_confirm", () => {
const prompt = makeShellPrompt("ls");
const state = {
...initialState(),
pendingConfirms: [
{ id: 1, kind: "run_command" as const, command: "ls", prompt },
{
id: 2,
kind: "run_command" as const,
command: "pwd",
prompt: { ...prompt, id: 2, subtitle: "pwd" },
},
],
};
const next = reduce(state, { t: "resolve_confirm", id: 1 });
expect(next.pendingConfirms).toHaveLength(1);
expect(next.pendingConfirms[0].id).toBe(2);
});
it("removes path access on resolve_path_access", () => {
const prompt = makePathPrompt("/tmp", "write");
const state = {
...initialState(),
pendingPathAccess: [
{
id: 3,
path: "/tmp",
intent: "write" as const,
toolName: "write_file",
sandboxRoot: "/workspace",
allowPrefix: "/workspace",
prompt,
},
],
};
const next = reduce(state, { t: "resolve_path_access", id: 3 });
expect(next.pendingPathAccess).toHaveLength(0);
});
it("clears all pending on clear action", () => {
const shellPrompt = makeShellPrompt("echo hi");
const pathPrompt = makePathPrompt("/x", "read");
const state = {
...initialState(),
pendingConfirms: [
{ id: 1, kind: "run_command" as const, command: "echo hi", prompt: shellPrompt },
],
pendingPathAccess: [
{
id: 2,
path: "/x",
intent: "read" as const,
toolName: "read_file",
sandboxRoot: "/ws",
allowPrefix: "/ws",
prompt: pathPrompt,
},
],
};
const next = reduce(state, { t: "clear" });
expect(next.pendingConfirms).toHaveLength(0);
expect(next.pendingPathAccess).toHaveLength(0);
});
});
+601 -120
View File
File diff suppressed because it is too large. Load diff
+22 -10
View File
@@ -14,6 +14,7 @@ import {
} from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { t, useLang } from "./i18n";
import { Shortcut, type ShortcutKey } from "./ui/shortcut";
export type CommandGroup = "nav" | "action" | "workspace" | "settings";
@@ -22,7 +23,7 @@ export type Command = {
label: string;
hint?: string;
icon: ReactNode;
shortcut?: string[];
shortcut?: ShortcutKey[];
group: CommandGroup;
run: () => void;
};
@@ -55,6 +56,7 @@ export type CommandHandlers = {
about: () => void;
abort: () => void;
copyLast: () => void;
conversationCopy: () => void;
exportMarkdown: () => void;
pickWorkspace: () => void;
newTab: () => void;
@@ -72,7 +74,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
label: t("palette.newChat"),
hint: t("palette.newChatHint"),
icon: <FilePlus size={13} />,
shortcut: ["⌘", "N"],
shortcut: ["mod", "N"],
run: handlers.newChat,
},
{
@@ -81,7 +83,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
label: t("palette.newTab"),
hint: t("palette.newTabHint"),
icon: <Plus size={13} />,
shortcut: ["⌘", "T"],
shortcut: ["mod", "T"],
run: handlers.newTab,
},
];
@@ -92,7 +94,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
label: t("palette.closeTab"),
hint: t("palette.closeTabHint"),
icon: <SquareX size={13} />,
shortcut: ["⌘", "W"],
shortcut: ["mod", "W"],
run: handlers.closeTab,
});
}
@@ -101,7 +103,7 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
group: "nav",
label: t("palette.focusComposer"),
icon: <FocusIcon size={13} />,
shortcut: ["⌘", "L"],
shortcut: ["mod", "L"],
run: handlers.focusComposer,
});
if (handlers.busy) {
@@ -124,6 +126,14 @@ export function buildCommands(handlers: CommandHandlers): Command[] {
icon: <ClipboardCopy size={13} />,
run: handlers.copyLast,
});
list.push({
id: "copy-conv",
group: "action",
label: t("palette.copyConv"),
hint: t("palette.copyConvHint"),
icon: <ClipboardCopy size={13} />,
run: handlers.conversationCopy,
});
list.push({
id: "export-md",
group: "action",
@@ -262,7 +272,7 @@ export function CommandPalette({
}}
/>
<span className="hint">
<kbd>esc</kbd>
<Shortcut keys={["esc"]} />
</span>
</div>
<div className="cmdk-body" ref={listRef}>
@@ -287,7 +297,9 @@ export function CommandPalette({
<span className="l">{c.label}</span>
<span className="g">{groupLabel(c.group)}</span>
{c.shortcut ? (
<span className="kb">{c.shortcut.join("")}</span>
<span className="kb">
<Shortcut keys={c.shortcut} />
</span>
) : (
<span className="kb-empty" />
)}
@@ -299,15 +311,15 @@ export function CommandPalette({
</div>
<div className="cmdk-foot">
<span>
<kbd>↑↓</kbd>
<Shortcut keys={["updown"]} />
{t("palette.footMove")}
</span>
<span>
<kbd>⏎</kbd>
<Shortcut keys={["enter"]} />
{t("palette.footRun")}
</span>
<span>
<kbd>esc</kbd>
<Shortcut keys={["esc"]} />
{t("palette.footClose")}
</span>
<span style={{ marginLeft: "auto", color: "var(--muted)" }}>
+98 -15
View File
@@ -13,9 +13,12 @@ import {
useState,
} from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeView } from "./CodeView";
import { t, useLang } from "./i18n";
async function openWithEditor(
editor: string | undefined,
@@ -43,20 +46,79 @@ function resolveAgainstWorkspace(rel: string, ws: string | undefined): string {
const KNOWN_EXTS =
"ts|tsx|mts|cts|js|jsx|mjs|cjs|py|pyi|rs|go|json|jsonc|md|mdx|css|scss|less|html|htm|xml|svg|yaml|yml|toml|sh|bash|zsh|fish|sql|rb|java|kt|swift|c|cpp|cc|cxx|h|hpp|hxx|cs|php|lua|dart|ex|exs|erl|hs|clj|cljs|zig|vue|svelte|graphql|gql|proto";
const PATH_SEG = "[\\w.@()+~%#=-]+";
const FILE_NAME_SOURCE = `${PATH_SEG}\\.(?:${KNOWN_EXTS})`;
const FILE_REF_SOURCE = [
`[A-Za-z]:[\\\\/](?:${PATH_SEG}[\\\\/])*${FILE_NAME_SOURCE}`,
`/(?:${PATH_SEG}[\\\\/])*${FILE_NAME_SOURCE}`,
`(?:\\.{1,2}[\\\\/])?(?:${PATH_SEG}[\\\\/])+${FILE_NAME_SOURCE}`,
FILE_NAME_SOURCE,
].join("|");
const LINE_REF_SOURCE = "(?::(\\d+(?::\\d+)?(?:-\\d+)?))?";
// No lookbehind here — Tauri's WKWebView on macOS Monterey (Safari < 16.4)
// can't parse `(?<=...)` and the whole bundle fails to load with an
// "invalid group specifier name" error. Capture the leading char as
// group 1 instead and let splitFilePaths skip past it. Issue #1209.
const FILE_PATH_RE = new RegExp(
`(?:^|(?<=[\\s\`'"(\\[]))((?:[\\w.-]+\\/)+[\\w.-]+\\.(?:${KNOWN_EXTS}))(?::(\\d+(?:-\\d+)?))?(?=[\\s.,;!?\\]\\)'"\`]|$)`,
`(^|[\\s\`'"(\\[])(${FILE_REF_SOURCE})${LINE_REF_SOURCE}(?=[\\s.,;!?\\]\\)'"\`]|$)`,
"g",
);
const EXACT_FILE_REF_RE = new RegExp(`^(${FILE_REF_SOURCE})${LINE_REF_SOURCE}$`);
type ParsedFileRef = { path: string; line?: string };
function firstLine(line?: string): number | undefined {
if (!line) return undefined;
const parsed = Number.parseInt(line.split(/[:-]/)[0] ?? line, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseFileRef(value: string): ParsedFileRef | null {
const trimmed = value.trim();
const m = EXACT_FILE_REF_RE.exec(trimmed);
if (!m) return null;
return { path: m[1]!, line: m[2] };
}
function decodeMaybeUri(value: string): string {
try {
return decodeURI(value);
} catch {
return value;
}
}
function stripFileScheme(value: string): string {
if (!/^file:\/\//i.test(value)) return value;
let raw = decodeMaybeUri(value.replace(/^file:\/\//i, ""));
if (/^\/[a-zA-Z]:[\\/]/.test(raw)) raw = raw.slice(1);
return raw;
}
function protocolScheme(value: string): string | null {
if (/^[a-zA-Z]:[\\/]/.test(value)) return null;
return /^([a-z][\w+.-]*):/i.exec(value)?.[1]?.toLowerCase() ?? null;
}
function parseFileHref(value: string): ParsedFileRef | null {
const stripped = stripFileScheme(value);
const decoded = decodeMaybeUri(stripped);
const hashLine = /#L?(\d+)/i.exec(decoded)?.[1];
const clean = decoded.split("#")[0]!.split("?")[0]!;
const parsed = parseFileRef(clean);
if (!parsed) return null;
return { ...parsed, line: parsed.line ?? hashLine };
}
function FilePill({ path, line }: { path: string; line?: string }) {
useLang();
const ctx = useContext(WorkspaceContext);
const [done, setDone] = useState<"open" | "copy" | null>(null);
const display = line ? `${path}:${line}` : path;
const openInEditor = async () => {
try {
const abs = resolveAgainstWorkspace(path, ctx.dir);
const lineNum = line ? Number.parseInt(line.split("-")[0] ?? line, 10) : undefined;
await openWithEditor(ctx.editor, abs, Number.isFinite(lineNum) ? lineNum : undefined);
await openWithEditor(ctx.editor, abs, firstLine(line));
setDone("open");
setTimeout(() => setDone(null), 1200);
} catch {
@@ -95,7 +157,7 @@ function FilePill({ path, line }: { path: string; line?: string }) {
void openInEditor();
}
}}
title="click to open · right-click to copy"
title={t("markdown.filePillTitle")}
>
<FileText size={10} className="file-pill-icon" />
<span className="file-pill-path">{path}</span>
@@ -111,9 +173,13 @@ function splitFilePaths(text: string): ReactNode[] | string {
let last = 0;
let m: RegExpExecArray | null = FILE_PATH_RE.exec(text);
while (m !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
out.push(<FilePill key={`fp-${m.index}`} path={m[1]!} line={m[2]} />);
last = m.index + m[0].length;
const prefix = m[1] ?? "";
const path = m[2]!;
const line = m[3];
const pillStart = m.index + prefix.length;
if (pillStart > last) out.push(text.slice(last, pillStart));
out.push(<FilePill key={`fp-${pillStart}`} path={path} line={line} />);
last = pillStart + path.length + (line ? line.length + 1 : 0);
m = FILE_PATH_RE.exec(text);
}
if (out.length === 0) return text;
@@ -127,6 +193,9 @@ function withFilePills(children: ReactNode): ReactNode {
return Children.map(children, (child) => {
if (typeof child === "string") return splitFilePaths(child);
if (isValidElement(child)) {
if (typeof child.type === "string" && ["a", "code", "pre"].includes(child.type)) {
return child;
}
const props = child.props as AnyProps;
if (props.children !== undefined) {
return cloneElement(child, undefined, withFilePills(props.children));
@@ -140,7 +209,8 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
return (
<div className="markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm, remarkBreaks, remarkMath]}
rehypePlugins={[[rehypeKatex, { throwOnError: false }]]}
components={{
pre: ({ children }) => {
const codeEl = Children.toArray(children).find(
@@ -152,7 +222,12 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
const lang = /language-([\w-]+)/.exec(codeEl.props.className ?? "")?.[1] ?? "text";
return <CodeBlock lang={lang} text={text} />;
},
code: ({ className, children }) => <code className={className}>{children}</code>,
code: ({ className, children }) => {
const text = String(children ?? "");
const parsed = !className ? parseFileRef(text.trim()) : null;
if (parsed) return <FilePill path={parsed.path} line={parsed.line} />;
return <code className={className}>{children}</code>;
},
a: ({ href, children }) => <SafeLink href={href}>{children}</SafeLink>,
p: ({ children }) => <p>{withFilePills(children)}</p>,
li: ({ children }) => <li>{withFilePills(children)}</li>,
@@ -166,9 +241,11 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
});
function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
useLang();
const ctx = useContext(WorkspaceContext);
const [done, setDone] = useState(false);
const isExternal = !!href && /^https?:\/\//i.test(href);
const scheme = href ? protocolScheme(href) : null;
const isExternal = !!scheme && scheme !== "file";
const onClick = async (e: React.MouseEvent) => {
e.preventDefault();
if (!href) return;
@@ -181,9 +258,10 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
return;
}
try {
const stripped = href.replace(/^file:\/\//, "");
const abs = resolveAgainstWorkspace(stripped, ctx.dir);
await openWithEditor(ctx.editor, abs);
const parsed = parseFileHref(href);
const target = parsed ?? { path: decodeMaybeUri(stripFileScheme(href)) };
const abs = resolveAgainstWorkspace(target.path, ctx.dir);
await openWithEditor(ctx.editor, abs, firstLine(target.line));
} catch {
try {
await navigator.clipboard.writeText(href);
@@ -199,7 +277,11 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
href={href ?? "#"}
onClick={onClick}
className={`md-link ${isExternal ? "external" : "local"} ${done ? "done" : ""}`}
title={isExternal ? `open ${href} in browser` : `open ${href}`}
title={
isExternal
? t("markdown.externalLinkTitle", { href: href ?? "" })
: t("markdown.localLinkTitle", { href: href ?? "" })
}
>
{children}
{isExternal ? (
@@ -212,6 +294,7 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
}
function CodeBlock({ lang, text }: { lang: string; text: string }): ReactNode {
useLang();
const [copied, setCopied] = useState(false);
const onCopy = async () => {
try {
@@ -228,7 +311,7 @@ function CodeBlock({ lang, text }: { lang: string; text: string }): ReactNode {
<span className="codeblock-lang">{lang}</span>
<button type="button" className={`copy-btn ${copied ? "done" : ""}`} onClick={onCopy}>
{copied ? <Check size={11} /> : <Copy size={11} />}
{copied ? "copied" : "copy"}
{copied ? t("markdown.copied") : t("markdown.copy")}
</button>
</div>
<CodeView text={text} lang={lang} />
+254 -4
View File
@@ -15,9 +15,11 @@ export const en = {
abortHint: "Interrupt the model mid-generation",
copyLast: "Copy last reply",
copyLastHint: "Copy the latest assistant reply",
copyConv: "Copy conversation as Markdown",
copyConvHint: "Copy the whole conversation to the clipboard",
exportMd: "Export conversation as Markdown",
exportMdHint: "Copy the whole conversation to the clipboard",
clearChat: "Clear messages",
exportMdHint: "Save as .md file",
clearChat: "Clear screen",
clearChatHint: "Clear the UI only — subprocess stays alive",
focusComposer: "Focus composer",
pickWorkspace: "Switch workspace…",
@@ -31,6 +33,19 @@ export const en = {
groupSettings: "Settings",
countSuffix: "items",
},
about: {
sidebarLabel: "About",
tagline: "DeepSeek-native coding agent",
version: "Version",
repo: "Repository",
checkUpdates: "Check for updates",
checking: "Checking…",
upToDate: "Up to date ({version})",
updateAvailable: "Update available: {version}",
openReleases: "View releases",
checkFailed: "Check failed: {message}",
close: "Close",
},
workdir: {
title: "Switch workspace",
searchPlaceholder: "Search recent paths…",
@@ -74,6 +89,9 @@ export const en = {
confirm: "confirm",
close: "close",
imageFilterName: "Images",
commandsLabel: "commands",
mentionLabel: "mention",
workspaceRoot: "workspace root",
},
dragDrop: {
overlay: "Drop to attach as @-mention",
@@ -98,6 +116,16 @@ export const en = {
themeLight: "light",
themeDarkDesc: "default · low-light",
themeLightDesc: "high-contrast · daytime",
themeStyle: "Style",
themeStyleHint: "Choose the visual palette for the selected light or dark mode.",
themeStyleGraphite: "Graphite",
themeStyleGraphiteDesc: "Original dark palette with neutral graphite panels.",
themeStyleSandstone: "Sandstone",
themeStyleSandstoneDesc: "Original warm light palette.",
themeStylePorcelain: "Porcelain",
themeStylePorcelainDesc: "Clean Codex-like light palette with quiet contrast.",
themeStyleMidnight: "Midnight",
themeStyleMidnightDesc: "Navy dark palette with cool blue surfaces.",
fontScale: "Font scale",
fontScaleHint: "Scales the whole UI; pick larger for high-DPI screens.",
fontScaleSmall: "small",
@@ -108,6 +136,9 @@ export const en = {
fontFamilySans: "sans",
fontFamilySystem: "system",
fontFamilySerif: "serif",
fontFamilyCustom: "custom",
customFontFamily: "Custom font stack",
customFontFamilyHint: "CSS font-family value for the whole desktop UI.",
reasoningEffort: "Reasoning effort",
reasoningEffortHint: "Applies live to the running session.",
effortHigh: "high",
@@ -169,6 +200,39 @@ export const en = {
workspaceSection: "Workspace",
behaviorSection: "Behavior",
apiSection: "DeepSeek API",
qqSection: "QQ Channel",
qqTitle: "QQ bot integration",
qqConnected: "Connected",
qqConnecting: "Connecting",
qqEnabled: "Enabled",
qqFailed: "Failed",
qqDisconnected: "Disconnected",
qqSummaryMissing: "Credentials not configured yet",
qqSummaryAppId: "App ID {appId}",
qqSummaryDetail: "{appId} · {environment} · {access}",
qqConnect: "Connect",
qqConfigure: "Configure…",
qqDisconnect: "Disconnect",
qqConfigureTitle: "Configure QQ Channel",
qqConfigureHint: "Register a QQ bot to receive and reply to messages.",
qqEnvironment: "QQ environment",
qqEnvironmentHint:
"Choose the QQ Open Platform environment for this bot. Use Sandbox for testing and Production for live use.",
qqBack: "Back",
qqAppId: "App ID",
qqAppSecret: "App Secret",
qqSandbox: "Sandbox",
qqProduction: "Production",
qqAccess: "Access",
qqAccessOwner: "Owner {openId}",
qqAccessOwnerWithAllowlist: "Owner {openId} + allowlist {count}",
qqAccessAllowlist: "Allowlist {count}",
qqAccessRuntime: "First sender (runtime only, {openId})",
qqAccessOpen: "Open (unbound)",
qqApplyLabel: "Apply for QQ Bot",
qqApplyAction: "Open apply page",
qqSave: "Save",
qqSaveAndConnect: "Save and connect",
mcpSection: "Configured",
currentWorkspace: "Current workspace",
notSelected: "(not selected)",
@@ -248,6 +312,13 @@ export const en = {
autoHint: "Commands matching the allowlist are auto-approved",
yoloHint: "All auto-approved · Use with caution",
},
markdown: {
filePillTitle: "click to open · right-click to copy",
externalLinkTitle: "open {href} in browser",
localLinkTitle: "open {href}",
copy: "copy",
copied: "copied",
},
preset: {
autoDesc: "Flash → Pro auto-upgrade",
flashDesc: "Fast, cheap, long context",
@@ -277,6 +348,9 @@ export const en = {
cleared: "Cleared",
copied: "Copied",
copiedMd: "Copied as Markdown",
exportedMd: "Exported as Markdown file",
exportFailed: "Export failed: {error}",
exportDialogTitle: "Export conversation",
emptySession: "Session is empty",
langSwitched: "Switched to {lang}",
modelSwitched: "Switched to {model}",
@@ -284,7 +358,7 @@ export const en = {
},
cmd: {
newSession: "New session",
clearChat: "Clear current conversation",
clearChat: "Clear screen",
abort: "Stop streaming output",
copyLast: "Copy last reply",
switchModel: "Switch model",
@@ -293,6 +367,22 @@ export const en = {
toggleLang: "Toggle language (CN / EN)",
exportMd: "Copy session as Markdown",
help: "Show all commands",
feedback: "Submit feedback on GitHub (opens browser)",
compact: "Fold older turns into a summary (save context)",
retry: "Truncate and resend your last message",
btw: "Ask a side question (never added to context)",
},
skill: {
generic: "{scope} skill · {runAs}",
scope: {
builtin: "Builtin",
global: "Global",
project: "Project",
},
runAs: {
inline: "inline",
subagent: "subagent",
},
},
yolo: {
banner1: "All tool calls, shell commands, and file edits will be ",
@@ -301,7 +391,10 @@ export const en = {
switchBack: "Switch to Review",
toast: "All operations will be auto-approved without asking",
},
btwUsage: "▸ /btw <question> — ask a side question without polluting the conversation context.",
errorLabel: "Error",
warningLabel: "Warning",
dismissError: "Dismiss",
jumpToBottom: "Jump to bottom",
splashSubtitle: "DeepSeek Agents",
connecting: "Connecting to reasonix core…",
@@ -312,8 +405,9 @@ export const en = {
contextPanel: "Context panel",
more: "More",
commandPalette: "Command palette",
copyMd: "Copy conversation as Markdown",
exportMd: "Export Markdown",
clearChat: "Clear conversation",
clearChat: "Clear screen",
settings: "Settings",
windowControls: "Window controls",
close: "Close",
@@ -329,8 +423,10 @@ export const en = {
noWorkspace: "No workspace selected",
running: "Running",
clickToSelect: "Click to select workspace",
copy: "Copy",
copyMd: "Copy conversation as Markdown",
export: "Export",
exportMd: "Export as Markdown file",
newChat: "New chat",
abort: "Stop",
},
@@ -355,12 +451,16 @@ export const en = {
update: {
available: "New version available · {current} → {latest}",
installing: "Installing…",
downloading: "Downloading · {downloaded} / {total} ({pct}%)",
downloadingUnknown: "Downloading · {downloaded}",
failed: "Installation failed",
clickToInstall: "Click to install & restart",
install: "Install",
later: "Later",
},
exportUserLabel: "You",
exportReasoningSummary: "Reasoning",
exportToolLabel: "Tool",
session: {
new: "{workspace} · New session",
format: "Session {month}-{day} {hour}:{minute}",
@@ -396,6 +496,20 @@ export const en = {
memoryCountSuffix: "items",
checkpointRewind: "Rewind here",
planDefaultTitle: "Plan",
streaming: "streaming…",
failed: "failed",
done: "done",
running: "Running",
error: "error",
result: "result",
hits: "hits",
applied: "applied",
cacheHit: "cache hit",
prompt: "prompt",
reasoning: "reasoning",
output: "output",
cost: "cost",
elapsed: "elapsed",
},
statusbar: {
offline: "Offline",
@@ -403,6 +517,20 @@ export const en = {
online: "Online",
jobs: "jobs",
jobsTip: "Background processes · ⌘J",
cacheHit: "cache hit",
cache: "cache",
tokens: "tokens",
thisTurn: "this turn",
switchWorkspace: "Switch workspace · {workspace}",
switchCurrency: "Switch currency (CNY / USD)",
balance: "balance",
switchTheme: "Switch theme",
themeDark: "dark",
themeLight: "light",
themeStyleGraphite: "Graphite",
themeStyleSandstone: "Sandstone",
themeStylePorcelain: "Porcelain",
themeStyleMidnight: "Midnight",
},
jobs: {
title: "Background jobs",
@@ -419,15 +547,137 @@ export const en = {
contextPanel: {
reservedKey: "reserved",
usedKey: "used",
filesTab: "Files",
toolsTab: "Tools",
memoryTab: "Memory",
rulesTab: "Rules",
contextTokens: "Context · tokens",
cacheKey: "cache",
freeKey: "free",
filesTitle: "Files in context",
filesCount: "{count} files",
fileModified: "modified",
fileInContext: "in context",
mcpTitle: "MCP servers",
mcpReadyAll: "{count} ready",
mcpReadySome: "{ready}/{count} ready",
mcpEmpty: "No MCP servers configured",
mcpTools: "{count} tools",
mcpReady: "ready",
mcpConnecting: "connecting",
mcpDisabled: "disabled",
mcpFailed: "failed",
mcpConfigured: "configured",
memoryTitle: "Long-term memory",
itemCount: "{count} items",
scopeProject: "project",
scopeGlobal: "global",
autoApproveTitle: "Auto approval",
ruleYolo: "YOLO mode · all tool calls auto-approved",
ruleReadOnly: "Read-only tools auto-approved",
ruleShellAllowlist: "Shell commands matching the allowlist auto-approved",
ruleWritesAsk: "Writes and unknown shell commands require confirmation",
ruleReview: "Review mode · every tool call requires confirmation",
allow: "ALLOW",
ask: "ASK",
noFilesMsg: "No files in context yet.",
noMemoriesMsg: "No memories saved yet.",
},
sidebarPanel: {
sessionTitle: "Session {month}-{day} {hour}:{minute}",
justNow: "just now",
minutesAgo: "{n}m ago",
hoursAgo: "{n}h ago",
daysAgo: "{n}d ago",
weeksAgo: "{n}w ago",
newChat: "New chat",
commandPalette: "Command palette",
searchSessions: "Search sessions…",
recent: "Recent",
noSessions: "No sessions yet",
noMatches: "No matches",
messageCount: "{count} messages",
deleteSession: "Delete session",
renameSession: "Rename session",
renamePlaceholder: "Session title",
approvalRules: "Approval rules",
settings: "Settings",
cancel: "Cancel",
delete: "Delete",
},
extraCards: {
sessionUsage: "Session usage",
sessionCost: "Cost {costLabel}",
approve: "Approve",
reject: "Reject",
doctorTitle: "/doctor · system check",
balance: "balance {balanceLabel}",
prompt: "prompt",
reasoning: "reasoning",
output: "output",
cacheHit: "cache hit",
cache: "cache",
contextWindow: "Context window",
topToolsUsage: "Top tools — usage",
memoryUser: "USER · preferences",
memoryFeedback: "FEEDBACK · corrections",
memoryProject: "PROJECT · project rules",
memoryReference: "REFERENCE · references",
unknownKind: "# unknown card kind — fallback render",
},
thread: {
keepSteps: "Keep {n} remaining step(s)",
keepOriginal: "Keep original",
you: "You",
planRunning: "Plan running · step {step} / {total}",
collapse: "Collapse",
activePlan: "Active plan",
risk: "risk",
planStepCount: "{count} step(s)",
planConfirmationKind: "plan confirmation",
startPlan: "Start plan",
approve: "Approve",
cancel: "Cancel",
refine: "Refine",
checkpointKind: "plan checkpoint",
checkpointTitle: "step {completed} / {total} complete",
checkpointSub: "checkpoint · {completed} / {total}",
continue: "Continue",
stop: "Stop",
revise: "Revise",
planRevisionKind: "plan revision",
rewritePlan: "Plan rewrite",
revisionMeta: "reason · runtime constraint",
approveRewrite: "Approve rewrite",
shellConfirmationKind: "shell confirmation",
runCommand: "Run command",
runBackgroundCommand: "Run background command",
riskMedium: "risk · medium · {kind}",
execute: "Execute",
reject: "Reject",
alwaysAllow: "Always allow \"{prefix}\"",
pathAccessKind: "path access",
read: "read",
write: "write",
readPathOutsideSandbox: "Read path outside sandbox",
writePathOutsideSandbox: "Write path outside sandbox",
allowRead: "Allow read",
allowWrite: "Allow write",
alwaysAllowPrefix: "Always allow {prefix}",
userChoiceKind: "user choice",
optionCount: "{count} options",
subagent: "subagent",
copyMessage: "Copy this message",
copyResponse: "Copy this response",
},
live: {
reasoning: "live · reasoning",
running: "running",
you: "You",
secondsAgo: "{seconds}s ago",
deliveredWaiting: "Delivered · waiting for agent",
},
panel: {
renderError: "{panel} panel render error",
},
};
+253 -4
View File
@@ -17,9 +17,11 @@ export const zhCN: typeof en = {
abortHint: "打断模型当前生成",
copyLast: "复制最近回复",
copyLastHint: "复制最近一条助手回复",
copyConv: "复制对话为 Markdown",
copyConvHint: "整段对话复制到剪贴板",
exportMd: "导出对话为 Markdown",
exportMdHint: "整段对话复制到剪贴板",
clearChat: "清空消息",
exportMdHint: "保存为 .md 文件",
clearChat: "清屏",
clearChatHint: "只清当前 UI,subprocess 不重启",
focusComposer: "聚焦输入框",
pickWorkspace: "切换工作目录…",
@@ -33,6 +35,19 @@ export const zhCN: typeof en = {
groupSettings: "设置",
countSuffix: "项",
},
about: {
sidebarLabel: "关于",
tagline: "DeepSeek 原生编码助手",
version: "版本",
repo: "代码仓库",
checkUpdates: "检查更新",
checking: "检查中…",
upToDate: "已是最新版本({version})",
updateAvailable: "新版本可用:{version}",
openReleases: "查看发布页",
checkFailed: "检查失败:{message}",
close: "关闭",
},
workdir: {
title: "切换工作目录",
searchPlaceholder: "搜索最近路径…",
@@ -76,6 +91,9 @@ export const zhCN: typeof en = {
confirm: "确认",
close: "关闭",
imageFilterName: "图片",
commandsLabel: "命令",
mentionLabel: "提及",
workspaceRoot: "工作区根目录",
},
dragDrop: {
overlay: "松开以作为 @ 提及附加",
@@ -99,6 +117,16 @@ export const zhCN: typeof en = {
themeLight: "浅色",
themeDarkDesc: "默认 · 暗光环境",
themeLightDesc: "高对比 · 白天",
themeStyle: "风格",
themeStyleHint: "为当前浅色或深色模式选择具体视觉配色。",
themeStyleGraphite: "Graphite",
themeStyleGraphiteDesc: "原有深色风格,中性石墨面板。",
themeStyleSandstone: "Sandstone",
themeStyleSandstoneDesc: "原有浅色风格,温暖明亮。",
themeStylePorcelain: "Porcelain",
themeStylePorcelainDesc: "类似 Codex App 的干净浅色风格。",
themeStyleMidnight: "Midnight",
themeStyleMidnightDesc: "偏藏青色的深色风格,冷静克制。",
fontScale: "字体大小",
fontScaleHint: "整体缩放,高分屏可选大档。",
fontScaleSmall: "小",
@@ -109,6 +137,9 @@ export const zhCN: typeof en = {
fontFamilySans: "无衬线",
fontFamilySystem: "系统",
fontFamilySerif: "衬线",
fontFamilyCustom: "自定义",
customFontFamily: "自定义字体栈",
customFontFamilyHint: "桌面端整体 UI 使用的 CSS font-family 值。",
reasoningEffort: "思考深度",
reasoningEffortHint: "立即生效。",
effortHigh: "high",
@@ -166,6 +197,38 @@ export const zhCN: typeof en = {
workspaceSection: "工作区",
behaviorSection: "行为",
apiSection: "DeepSeek API",
qqSection: "QQ 通道",
qqTitle: "QQ机器人集成",
qqConnected: "已连接",
qqConnecting: "连接中",
qqEnabled: "已启用",
qqFailed: "连接失败",
qqDisconnected: "已断开",
qqSummaryMissing: "尚未配置凭据",
qqSummaryAppId: "App ID {appId}",
qqSummaryDetail: "{appId} · {environment} · {access}",
qqConnect: "连接",
qqConfigure: "配置",
qqDisconnect: "断开连接",
qqConfigureTitle: "配置 QQ 通道",
qqConfigureHint: "注册 QQ 机器人以接收和回复消息。",
qqEnvironment: "QQ 环境",
qqEnvironmentHint: "选择此 QQ Bot 的开放平台环境。测试用沙箱,正式使用选正式。",
qqBack: "返回",
qqAppId: "App ID",
qqAppSecret: "App Secret",
qqSandbox: "沙箱",
qqProduction: "正式",
qqAccess: "访问控制",
qqAccessOwner: "所有者 {openId}",
qqAccessOwnerWithAllowlist: "所有者 {openId} + 白名单 {count}",
qqAccessAllowlist: "白名单 {count}",
qqAccessRuntime: "首个私聊用户(仅本次运行,{openId})",
qqAccessOpen: "未绑定",
qqApplyLabel: "申请 QQ Bot",
qqApplyAction: "去申请",
qqSave: "保存",
qqSaveAndConnect: "保存并连接",
mcpSection: "已配置",
currentWorkspace: "当前工作目录",
notSelected: "未选择",
@@ -236,6 +299,13 @@ export const zhCN: typeof en = {
autoHint: "命中白名单的命令自动批准",
yoloHint: "全部自动批准 · 谨慎使用",
},
markdown: {
filePillTitle: "点击打开 · 右键复制",
externalLinkTitle: "在浏览器中打开 {href}",
localLinkTitle: "打开 {href}",
copy: "复制",
copied: "已复制",
},
preset: {
autoDesc: "Flash → Pro 自动升级",
flashDesc: "快、便宜、长上下文",
@@ -265,6 +335,20 @@ export const zhCN: typeof en = {
online: "在线",
jobs: "任务",
jobsTip: "后台任务 · ⌘J",
cacheHit: "缓存命中",
cache: "缓存",
tokens: "tokens",
thisTurn: "本次",
switchWorkspace: "切换工作区 · {workspace}",
switchCurrency: "切换货币 (CNY / USD)",
balance: "余额",
switchTheme: "切换主题",
themeDark: "深色",
themeLight: "浅色",
themeStyleGraphite: "Graphite",
themeStyleSandstone: "Sandstone",
themeStylePorcelain: "Porcelain",
themeStyleMidnight: "Midnight",
},
jobs: {
title: "后台任务",
@@ -284,6 +368,9 @@ export const zhCN: typeof en = {
cleared: "已清空",
copied: "已复制",
copiedMd: "已复制 Markdown",
exportedMd: "已导出 Markdown 文件",
exportFailed: "导出失败:{error}",
exportDialogTitle: "导出对话",
emptySession: "会话为空",
langSwitched: "已切换到{lang}",
modelSwitched: "已切换到 {model}",
@@ -291,7 +378,7 @@ export const zhCN: typeof en = {
},
cmd: {
newSession: "新建会话",
clearChat: "清空当前对话",
clearChat: "清屏",
abort: "中断流式输出",
copyLast: "复制最后一条回复",
switchModel: "切换模型",
@@ -300,6 +387,22 @@ export const zhCN: typeof en = {
toggleLang: "切换界面语言 (中 / 英)",
exportMd: "复制本会话为 Markdown",
help: "查看所有命令",
feedback: "在 GitHub 提交反馈(打开浏览器)",
compact: "折叠旧轮次为摘要(节省上下文)",
retry: "截断并重发你的最后一条消息",
btw: "旁路提问(不写入会话上下文)",
},
skill: {
generic: "{scope}技能 · {runAs}",
scope: {
builtin: "内置",
global: "全局",
project: "项目",
},
runAs: {
inline: "内联",
subagent: "子代理",
},
},
yolo: {
banner1: "所有工具调用、shell 命令、文件编辑都会",
@@ -308,7 +411,10 @@ export const zhCN: typeof en = {
switchBack: "切回 Review",
toast: "所有操作将自动批准,不再询问",
},
btwUsage: "▸ /btw <问题> — 顺便问个题外话,不会写入当前会话上下文。",
errorLabel: "错误",
warningLabel: "提示",
dismissError: "关闭",
jumpToBottom: "回到底部",
splashSubtitle: "DeepSeek Agents",
connecting: "正在连接 reasonix 内核…",
@@ -319,8 +425,9 @@ export const zhCN: typeof en = {
contextPanel: "上下文面板",
more: "更多",
commandPalette: "命令面板",
copyMd: "复制对话为 Markdown",
exportMd: "导出 Markdown",
clearChat: "清空对话",
clearChat: "清屏",
settings: "设置",
windowControls: "窗口控制",
close: "关闭",
@@ -336,8 +443,10 @@ export const zhCN: typeof en = {
noWorkspace: "未选择工作区",
running: "运行中",
clickToSelect: "点击选择工作区",
copy: "复制",
copyMd: "复制对话为 Markdown",
export: "导出",
exportMd: "导出为 Markdown 文件",
newChat: "新会话",
abort: "中断",
},
@@ -361,12 +470,16 @@ export const zhCN: typeof en = {
update: {
available: "新版本可用 · {current} → {latest}",
installing: "正在安装…",
downloading: "下载中 · {downloaded} / {total}({pct}%)",
downloadingUnknown: "下载中 · {downloaded}",
failed: "安装失败",
clickToInstall: "点击安装并重启",
install: "安装",
later: "稍后",
},
exportUserLabel: "你",
exportReasoningSummary: "思考",
exportToolLabel: "工具",
session: {
new: "{workspace} · 新会话",
format: "会话 {month}-{day} {hour}:{minute}",
@@ -402,19 +515,155 @@ export const zhCN: typeof en = {
memoryCountSuffix: "项",
checkpointRewind: "回到此处",
planDefaultTitle: "计划",
streaming: "流式输出中…",
failed: "失败",
done: "完成",
running: "运行中",
error: "错误",
result: "结果",
hits: "条结果",
applied: "已应用",
cacheHit: "缓存命中",
prompt: "输入",
reasoning: "思考",
output: "输出",
cost: "花费",
elapsed: "耗时",
},
contextPanel: {
reservedKey: "预留",
usedKey: "已用",
filesTab: "文件",
toolsTab: "工具",
memoryTab: "记忆",
rulesTab: "规则",
contextTokens: "上下文 · tokens",
cacheKey: "缓存",
freeKey: "余",
filesTitle: "上下文中的文件",
filesCount: "{count} 个文件",
fileModified: "已修改",
fileInContext: "在上下文中",
mcpTitle: "MCP 服务器",
mcpReadyAll: "{count} 个已就绪",
mcpReadySome: "{ready}/{count} 已就绪",
mcpEmpty: "未配置 MCP 服务器",
mcpTools: "{count} 个工具",
mcpReady: "就绪",
mcpConnecting: "连接中",
mcpDisabled: "已禁用",
mcpFailed: "失败",
mcpConfigured: "已配置",
memoryTitle: "长期记忆",
itemCount: "{count} 项",
scopeProject: "项目",
scopeGlobal: "全局",
autoApproveTitle: "自动批准",
ruleYolo: "YOLO 模式 · 所有工具调用自动批准",
ruleReadOnly: "只读工具自动批准",
ruleShellAllowlist: "命中 shell 白名单的命令自动批准",
ruleWritesAsk: "写入与未知 shell 命令需确认",
ruleReview: "Review 模式 · 每个工具调用都需确认",
allow: "ALLOW",
ask: "ASK",
noFilesMsg: "上下文里还没有文件。",
noMemoriesMsg: "还没保存任何记忆。",
},
sidebarPanel: {
sessionTitle: "会话 {month}-{day} {hour}:{minute}",
justNow: "刚刚",
minutesAgo: "{n} 分钟前",
hoursAgo: "{n} 小时前",
daysAgo: "{n} 天前",
weeksAgo: "{n} 周前",
newChat: "新会话",
commandPalette: "命令面板",
searchSessions: "搜索会话…",
recent: "近期",
noSessions: "暂无会话",
noMatches: "无匹配结果",
messageCount: "{count} 条",
deleteSession: "删除会话",
renameSession: "重命名会话",
renamePlaceholder: "会话标题",
approvalRules: "审批规则",
settings: "设置",
cancel: "取消",
delete: "删除",
},
extraCards: {
sessionUsage: "会话用量",
sessionCost: "花费 {costLabel}",
approve: "批准",
reject: "拒绝",
doctorTitle: "/doctor · 系统体检",
balance: "余额 {balanceLabel}",
prompt: "输入",
reasoning: "思考",
output: "输出",
cacheHit: "缓存命中",
cache: "缓存",
contextWindow: "上下文窗口",
topToolsUsage: "Top tools — 占用",
memoryUser: "USER · 用户偏好",
memoryFeedback: "FEEDBACK · 反馈与纠正",
memoryProject: "PROJECT · 项目规范",
memoryReference: "REFERENCE · 引用资料",
unknownKind: "# 未知卡片类型 — fallback 渲染",
},
thread: {
keepSteps: "保留剩余 {n} 步",
keepOriginal: "保留原计划",
you: "你",
planRunning: "计划执行中 · step {step} / {total}",
collapse: "收起",
activePlan: "活动计划",
risk: "risk",
planStepCount: "{count} 步",
planConfirmationKind: "计划确认",
startPlan: "开始执行计划",
approve: "批准",
cancel: "取消",
refine: "细化",
checkpointKind: "计划检查点",
checkpointTitle: "step {completed} / {total} 完成",
checkpointSub: "检查点 · {completed} / {total}",
continue: "继续",
stop: "停止",
revise: "修订",
planRevisionKind: "计划修订",
rewritePlan: "计划重写",
revisionMeta: "原因 · 运行约束",
approveRewrite: "批准重写",
shellConfirmationKind: "命令确认",
runCommand: "运行命令",
runBackgroundCommand: "后台运行命令",
riskMedium: "risk · 中 · {kind}",
execute: "执行",
reject: "拒绝",
alwaysAllow: "始终允许 \"{prefix}\"",
pathAccessKind: "路径访问",
read: "读取",
write: "写入",
readPathOutsideSandbox: "读取沙盒外的路径",
writePathOutsideSandbox: "写入沙盒外的路径",
allowRead: "允许读取",
allowWrite: "允许写入",
alwaysAllowPrefix: "始终允许 {prefix}",
userChoiceKind: "用户选择",
optionCount: "{count} 个选项",
subagent: "subagent",
copyMessage: "复制这条消息",
copyResponse: "复制这条回复",
},
live: {
reasoning: "live · reasoning",
running: "运行中",
you: "你",
secondsAgo: "{seconds}s 前",
deliveredWaiting: "已送达 · 等待 agent 响应",
},
panel: {
renderError: "{panel} 面板渲染错误",
},
};
+2
View File
@@ -29,6 +29,7 @@ export const I = {
chevR: (p: IconProps) => (<Ic {...p}><path d="m9 6 6 6-6 6" /></Ic>),
check: (p: IconProps) => (<Ic {...p}><path d="m5 12 5 5L20 7" /></Ic>),
x: (p: IconProps) => (<Ic {...p}><path d="M6 6l12 12M18 6 6 18" /></Ic>),
pencil: (p: IconProps) => (<Ic {...p}><path d="m4 20 4-1L20 7l-3-3L5 16l-1 4Z" /><path d="m14 6 4 4" /></Ic>),
terminal: (p: IconProps) => (<Ic {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="m7 9 3 3-3 3M13 15h4" /></Ic>),
brain: (p: IconProps) => (
<Ic {...p}>
@@ -75,6 +76,7 @@ export const I = {
warn: (p: IconProps) => (<Ic {...p}><path d="M12 3 2 21h20Z" /><path d="M12 10v5M12 18h.01" /></Ic>),
help: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></Ic>),
refresh: (p: IconProps) => (<Ic {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 4v4h-4M21 12a9 9 0 0 1-15 6.7L3 16M3 20v-4h4" /></Ic>),
copy: (p: IconProps) => (<Ic {...p}><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" /></Ic>),
};
export type IconKey = keyof typeof I;
+32 -3
View File
@@ -9,19 +9,48 @@ import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "katex/dist/katex.min.css";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { isTheme } from "./theme";
import {
defaultStyleForTheme,
isTheme,
isThemeStyle,
themeForStyle,
} from "./theme";
const stored = localStorage.getItem("reasonix.theme");
if (isTheme(stored)) {
const storedStyle = localStorage.getItem("reasonix.themeStyle");
if (isThemeStyle(storedStyle)) {
document.documentElement.dataset.themeStyle = storedStyle;
document.documentElement.dataset.theme = themeForStyle(storedStyle);
} else if (isTheme(stored)) {
document.documentElement.dataset.theme = stored;
document.documentElement.dataset.themeStyle = defaultStyleForTheme(stored);
}
const platform = /Mac|macOS/i.test(navigator.userAgent) ? "macos" : "default";
const platform = /Mac|macOS/i.test(navigator.userAgent)
? "macos"
: /Windows/i.test(navigator.userAgent)
? "windows"
: "default";
document.documentElement.dataset.platform = platform;
document.body.dataset.platform = platform;
// Packaged builds: block F5 / Ctrl+R — webview reload drops React state
// and flashes white. Dev keeps the shortcuts for HMR fallback.
if (!import.meta.env.DEV) {
window.addEventListener(
"keydown",
(e) => {
if (e.key === "F5" || ((e.ctrlKey || e.metaKey) && (e.key === "r" || e.key === "R"))) {
e.preventDefault();
}
},
{ capture: true },
);
}
const host = document.getElementById("root");
if (!host) throw new Error("#root missing");
+49 -4
View File
@@ -9,6 +9,7 @@ export type PathAccessRequiredEvent = {
toolName: string;
sandboxRoot: string;
allowPrefix: string;
prompt?: import("@reasonix/core-utils").ApprovalPrompt;
};
export type ConfirmRequiredEvent = {
@@ -16,6 +17,7 @@ export type ConfirmRequiredEvent = {
id: number;
kind: "run_command" | "run_background";
command: string;
prompt?: import("@reasonix/core-utils").ApprovalPrompt;
};
export type ConfirmationChoice =
@@ -86,10 +88,7 @@ export type RevisionRequiredEvent = {
summary?: string;
};
export type RevisionVerdict =
| { type: "accepted" }
| { type: "rejected" }
| { type: "cancelled" };
export type RevisionVerdict = { type: "accepted" } | { type: "rejected" } | { type: "cancelled" };
export type StepCompletedEvent = {
type: "$step_completed";
@@ -124,6 +123,8 @@ export type MentionPreviewEvent = {
export type TabOpenedEvent = {
type: "$tab_opened";
workspaceDir: string;
/** True when the frontend should focus this tab (user-opened, or the restored focused tab). */
active?: boolean;
};
export type TabClosedEvent = {
@@ -168,6 +169,8 @@ export type SkillsEvent = {
export type CtxBreakdownEvent = {
type: "$ctx_breakdown";
reservedTokens: number;
/** Current log token count (real-time) — sent after /compact to refresh the meter. */
logTokens?: number;
};
export type MemoryEntryInfo = {
@@ -181,6 +184,10 @@ export type MemoryEvent = {
entries: MemoryEntryInfo[];
};
export type RetryResultEvent = { type: "$retry_result"; text: string };
export type BtwResultEvent = { type: "$btw_result"; question: string; answer: string };
export type JobInfo = {
id: number;
tabId: string;
@@ -231,6 +238,12 @@ export type SessionLoadedEvent = {
};
};
export type SessionEmptyEvent = {
type: "$session_empty";
name: string;
sizeBytes: number;
};
export type NeedsSetupEvent = {
type: "$needs_setup";
reason: "no_api_key";
@@ -255,6 +268,19 @@ export type SettingsEvent = {
version: string;
};
export type QQSettingsEvent = {
type: "$qq_settings";
appId?: string;
appSecret?: string;
sandbox: boolean;
enabled: boolean;
configured: boolean;
runtimeState: "disconnected" | "connecting" | "connected" | "failed";
lastError?: string;
appIdPreview?: string;
access: string;
};
export type BalanceEvent = {
type: "$balance";
currency: string;
@@ -272,6 +298,12 @@ export type SettingsPatch = {
editor?: string;
};
export type QQConfigPatch = {
appId?: string;
appSecret?: string;
sandbox: boolean;
};
export type UserMessageEvent = {
type: "user.message";
id: number;
@@ -374,8 +406,10 @@ export type IncomingEvent = { tabId?: string } & (
| PlanRequiredEvent
| SessionsEvent
| SessionLoadedEvent
| SessionEmptyEvent
| NeedsSetupEvent
| SettingsEvent
| QQSettingsEvent
| BalanceEvent
| CheckpointRequiredEvent
| RevisionRequiredEvent
@@ -399,6 +433,8 @@ export type IncomingEvent = { tabId?: string } & (
| ToolResultEvent
| StatusEvent
| KernelErrorEvent
| RetryResultEvent
| BtwResultEvent
);
export type OutgoingCommand = { tabId?: string } & (
@@ -412,15 +448,21 @@ export type OutgoingCommand = { tabId?: string } & (
| { cmd: "session_list" }
| { cmd: "session_delete"; name: string }
| { cmd: "session_load"; name: string }
| { cmd: "session_rename"; name: string; title: string }
| { cmd: "new_chat" }
| { cmd: "setup_save_key"; key: string }
| { cmd: "settings_get" }
| ({ cmd: "settings_save" } & SettingsPatch)
| { cmd: "qq_status_get" }
| { cmd: "qq_connect" }
| { cmd: "qq_disconnect" }
| ({ cmd: "qq_config_save" } & QQConfigPatch)
| { cmd: "mention_query"; query: string; nonce: number }
| { cmd: "mention_preview"; path: string; nonce: number }
| { cmd: "mention_picked"; path: string }
| { cmd: "tab_open"; workspaceDir?: string }
| { cmd: "tab_close" }
| { cmd: "tab_activate"; tabId: string }
| { cmd: "mcp_specs_get" }
| { cmd: "mcp_specs_add"; spec: string }
| { cmd: "mcp_specs_remove"; spec: string }
@@ -429,4 +471,7 @@ export type OutgoingCommand = { tabId?: string } & (
| { cmd: "jobs_list" }
| { cmd: "jobs_stop"; jobId: number }
| { cmd: "jobs_stop_all" }
| { cmd: "compact_history" }
| { cmd: "retry" }
| { cmd: "btw"; text: string }
);
+63
View File
@@ -0,0 +1,63 @@
import { t } from "./i18n";
export interface QQDesktopSettingsState {
appId?: string;
appSecret?: string;
sandbox: boolean;
enabled: boolean;
configured: boolean;
runtimeState: "disconnected" | "connecting" | "connected" | "failed";
lastError?: string;
appIdPreview?: string;
access: string;
}
export function maskQQAppId(appId: string | undefined): string | undefined {
if (!appId) return undefined;
return appId.length > 6 ? `${appId.slice(0, 6)}...` : appId;
}
export function getQQConnectIntent(qq: QQDesktopSettingsState): "configure" | "connect" {
return qq.configured ? "connect" : "configure";
}
export function getQQStatusLabel(qq: QQDesktopSettingsState): string {
switch (qq.runtimeState) {
case "connected":
return t("settings.qqConnected");
case "connecting":
return t("settings.qqConnecting");
case "failed":
return t("settings.qqFailed");
default:
return t("settings.qqDisconnected");
}
}
export function describeQQAccessLabel(access: string): string {
const owner = /^owner (.+?)(?:, allowlist (\d+))?$/.exec(access);
if (owner) {
const openId = owner[1] ?? "unknown";
const count = owner[2];
if (count) return t("settings.qqAccessOwnerWithAllowlist", { openId, count });
return t("settings.qqAccessOwner", { openId });
}
const allowlist = /^allowlist (\d+)$/.exec(access);
if (allowlist) return t("settings.qqAccessAllowlist", { count: allowlist[1] ?? "0" });
const runtime = /^first-sender \(runtime only, (.+)\)$/.exec(access);
if (runtime) return t("settings.qqAccessRuntime", { openId: runtime[1] ?? "unknown" });
return t("settings.qqAccessOpen");
}
export function describeQQRowSummary(qq: QQDesktopSettingsState): string {
if (!qq.configured) return t("settings.qqSummaryMissing");
const appId = qq.appIdPreview ?? maskQQAppId(qq.appId) ?? "unknown";
return t("settings.qqSummaryDetail", {
appId: t("settings.qqSummaryAppId", { appId }),
environment: qq.sandbox ? t("settings.qqSandbox") : t("settings.qqProduction"),
access: describeQQAccessLabel(qq.access),
});
}
+688 -26
View File
@@ -51,7 +51,8 @@ body[data-drag-over="1"]::after {
/* ---------- THEME TOKENS ---------- */
:root,
[data-theme="dark"] {
[data-theme="dark"],
[data-theme-style="graphite"] {
--bg: oklch(17% 0.005 280);
--bg-2: oklch(20% 0.006 275);
--panel: oklch(21.5% 0.007 275);
@@ -85,7 +86,8 @@ body[data-drag-over="1"]::after {
--radius-lg: 12px;
}
[data-theme="light"] {
[data-theme="light"],
[data-theme-style="sandstone"] {
--bg: oklch(97.5% 0.008 80);
--bg-2: oklch(95.5% 0.011 78);
--panel: oklch(98.5% 0.005 80);
@@ -118,6 +120,70 @@ body[data-drag-over="1"]::after {
--shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.20);
}
[data-theme-style="porcelain"] {
--bg: oklch(99.2% 0.001 100);
--bg-2: oklch(96.8% 0.001 100);
--panel: oklch(99.8% 0 0);
--panel-2: oklch(95.6% 0.001 100);
--card: oklch(100% 0 0);
--card-hover: oklch(97.6% 0.001 100);
--border: oklch(91% 0.002 100);
--border-strong: oklch(84% 0.003 100);
--fg: oklch(24% 0.006 260);
--fg-2: oklch(37% 0.006 260);
--muted: oklch(54% 0.005 260);
--muted-2: oklch(68% 0.004 260);
--accent: oklch(58% 0.18 36);
--accent-soft: oklch(58% 0.18 36 / 0.1);
--accent-strong: oklch(52% 0.2 34);
--success: oklch(48% 0.13 155);
--success-soft: oklch(48% 0.13 155 / 0.1);
--warning: oklch(58% 0.14 78);
--warning-soft: oklch(58% 0.14 78 / 0.12);
--danger: oklch(54% 0.2 22);
--danger-soft: oklch(54% 0.2 22 / 0.1);
--violet: oklch(56% 0.12 285);
--violet-soft: oklch(56% 0.12 285 / 0.1);
--shadow-sm: 0 1px 0 oklch(20% 0.01 260 / 0.04);
--shadow-md: 0 8px 24px -12px oklch(20% 0.01 260 / 0.1);
--shadow-lg: 0 24px 60px -22px oklch(20% 0.01 260 / 0.18);
}
[data-theme-style="midnight"] {
--bg: oklch(19% 0.024 265);
--bg-2: oklch(17% 0.026 260);
--panel: oklch(21.5% 0.028 265);
--panel-2: oklch(25% 0.03 265);
--card: oklch(23% 0.03 265);
--card-hover: oklch(27% 0.032 265);
--border: oklch(32% 0.032 265);
--border-strong: oklch(40% 0.035 265);
--fg: oklch(91% 0.02 265);
--fg-2: oklch(78% 0.028 265);
--muted: oklch(64% 0.03 265);
--muted-2: oklch(50% 0.03 265);
--accent: oklch(73% 0.12 285);
--accent-soft: oklch(73% 0.12 285 / 0.16);
--accent-strong: oklch(80% 0.13 286);
--success: oklch(70% 0.13 150);
--success-soft: oklch(70% 0.13 150 / 0.14);
--warning: oklch(78% 0.13 86);
--warning-soft: oklch(78% 0.13 86 / 0.16);
--danger: oklch(66% 0.17 25);
--danger-soft: oklch(66% 0.17 25 / 0.16);
--violet: oklch(76% 0.12 285);
--violet-soft: oklch(76% 0.12 285 / 0.16);
--shadow-sm: 0 1px 0 oklch(0% 0 0 / 0.38);
--shadow-md: 0 8px 24px -8px oklch(0% 0 0 / 0.52);
--shadow-lg: 0 24px 60px -16px oklch(0% 0 0 / 0.68);
}
button,
input,
textarea,
@@ -132,6 +198,11 @@ button {
cursor: pointer;
padding: 0;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
::-webkit-scrollbar {
width: 4px;
@@ -758,7 +829,6 @@ html[data-platform="macos"] .titlebar .tb-left {
background: var(--accent-strong);
}
.side-head .new-btn kbd {
margin-left: auto;
font-family: inherit;
font-size: 14px;
background: oklch(100% 0 0 / 0.18);
@@ -766,6 +836,21 @@ html[data-platform="macos"] .titlebar .tb-left {
border-radius: 3px;
font-weight: 500;
}
.side-head .new-btn .shortcut {
margin-left: auto;
}
.side-head .new-btn .shortcut kbd {
min-width: 0;
font-size: 14px;
font-weight: 500;
line-height: inherit;
color: inherit;
background: oklch(100% 0 0 / 0.18);
border: none;
border-radius: 3px;
padding: 1px 5px;
box-shadow: none;
}
.side-head .icon-btn {
width: 30px;
height: 30px;
@@ -1061,6 +1146,77 @@ html[data-platform="macos"] .titlebar .tb-left {
.session-item .body .meta .sep {
color: var(--border-strong);
}
.session-item .delete-btn {
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms;
}
.session-item .rename-btn {
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
opacity: 0;
transition: opacity 120ms, background 120ms, color 120ms;
}
.session-item:hover .delete-btn,
.session-item:focus-within .delete-btn,
.session-item .delete-btn:focus-visible,
.session-item:hover .rename-btn,
.session-item:focus-within .rename-btn,
.session-item .rename-btn:focus-visible {
opacity: 1;
}
.session-item .delete-btn:hover {
background: var(--panel-2, rgba(255, 80, 80, 0.12));
color: var(--danger, #e25555);
}
.session-item .delete-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--accent-soft);
}
.session-item .rename-btn:hover {
background: var(--panel-2, rgba(255, 255, 255, 0.06));
color: var(--fg);
}
.session-item .rename-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--accent-soft);
}
.session-item[data-editing="true"] {
cursor: default;
}
.session-item .body .title-edit {
width: 100%;
font: inherit;
color: var(--fg);
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 2px 6px;
outline: none;
}
.session-item .body .title-edit:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
/* ---- right-click session menu ---- */
.session-menu {
@@ -1297,6 +1453,32 @@ html[data-platform="macos"] .titlebar .tb-left {
color: var(--muted);
}
.shortcut {
display: inline-flex;
align-items: center;
gap: 2px;
vertical-align: baseline;
white-space: nowrap;
}
.shortcut kbd {
min-width: 18px;
text-align: center;
line-height: 1.25;
font-variant-numeric: tabular-nums;
font-family: inherit;
font-size: 12px;
color: var(--fg-2);
background: linear-gradient(180deg, var(--card), var(--panel));
border: 1px solid var(--border);
border-bottom-color: var(--border-strong);
border-radius: 4px;
padding: 1px 5px;
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.05);
}
html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
min-width: 30px;
}
/* ---------- MAIN ---------- */
.main {
@@ -1642,6 +1824,12 @@ html[data-platform="macos"] .titlebar .tb-left {
background: var(--card-hover);
}
.msg-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.markdown pre {
margin: 14px 0;
padding: 14px 16px;
@@ -1737,28 +1925,29 @@ html[data-platform="macos"] .titlebar .tb-left {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 5px;
background: var(--panel-2);
border: 1px solid var(--border);
padding: 0 2px;
border-radius: 3px;
background: transparent;
border: 0;
color: var(--accent);
font-family: "Geist Mono", monospace;
font-size: 0.82em;
font-size: 0.86em;
line-height: 1.35;
cursor: pointer;
vertical-align: baseline;
text-decoration: none;
transition: background 0.12s, border-color 0.12s;
transition:
background 0.12s,
color 0.12s;
white-space: nowrap;
}
.file-pill:hover {
background: var(--accent-soft);
border-color: oklch(from var(--accent) l c h / 0.4);
color: var(--accent-strong);
}
.file-pill.done {
color: var(--success);
border-color: var(--success-soft);
background: var(--success-soft);
background: transparent;
}
.file-pill-icon {
opacity: 0.6;
@@ -2649,8 +2838,8 @@ html[data-platform="macos"] .titlebar .tb-left {
padding: 12px 14px 6px;
font-size: 14px;
line-height: 1.55;
min-height: 24px;
max-height: 220px;
min-height: calc(5 * 1.55em + 18px);
overflow-y: hidden;
font-family: inherit;
}
.composer textarea::placeholder {
@@ -2726,6 +2915,7 @@ html[data-platform="macos"] .titlebar .tb-left {
background: var(--panel-2);
color: var(--muted-2);
cursor: not-allowed;
opacity: 1;
}
/* hint row above composer */
@@ -2752,6 +2942,13 @@ html[data-platform="macos"] .titlebar .tb-left {
padding: 0 4px;
font-size: 14px;
}
.hint-row .shortcut kbd {
min-width: 0;
font-size: 14px;
padding: 0 4px;
border: 1px solid var(--border);
background: var(--panel);
}
/* ModeSwitch inside hint-row — slightly smaller to sit inline with text */
.hint-row .mode-switch {
font-size: 13px;
@@ -2835,8 +3032,12 @@ html[data-platform="macos"] .titlebar .tb-left {
margin-right: 6px;
}
.popup-item .desc {
display: block;
font-size: 14px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popup-item .kb {
font-family: inherit;
@@ -2846,6 +3047,17 @@ html[data-platform="macos"] .titlebar .tb-left {
padding: 1px 5px;
border-radius: 4px;
}
.popup-item .kb .shortcut kbd,
.cmdk-row .kb .shortcut kbd {
min-width: 0;
padding: 0;
border: 0;
background: transparent;
}
html:not([data-platform="macos"]) .popup-item .kb .shortcut kbd[data-key="mod"],
html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
min-width: 24px;
}
.popup-foot {
padding: 6px 12px;
@@ -3147,6 +3359,10 @@ html[data-platform="macos"] .titlebar .tb-left {
background: var(--panel);
color: var(--fg);
}
.statusbar .seg.theme-trigger.active {
background: var(--accent-soft);
color: var(--accent);
}
.statusbar .seg .v {
color: var(--fg);
}
@@ -3191,6 +3407,77 @@ html[data-platform="macos"] .titlebar .tb-left {
font-size: 14px;
z-index: 20;
}
.theme-pop {
position: fixed;
right: 10px;
bottom: 32px;
width: 280px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow-lg);
z-index: 30;
overflow: hidden;
animation: rise 0.16s ease-out;
}
.theme-pop-head {
display: flex;
align-items: baseline;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.theme-pop-head .tt {
font-size: 14px;
font-weight: 600;
color: var(--fg);
}
.theme-pop-head .ss {
margin-left: auto;
color: var(--muted);
font-size: 12px;
}
.theme-pop-list {
display: grid;
gap: 2px;
padding: 6px;
}
.theme-pop-item {
min-width: 0;
display: grid;
grid-template-columns: 52px 1fr 16px;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
color: var(--fg);
text-align: left;
}
.theme-pop-item:hover,
.theme-pop-item[data-on="true"] {
background: var(--panel);
}
.theme-pop-item[data-on="true"] {
color: var(--accent);
}
.theme-pop-item .style-swatches {
height: 22px;
}
.theme-pop-item .txt {
min-width: 0;
display: grid;
gap: 1px;
}
.theme-pop-item .nm {
font-size: 14px;
font-weight: 500;
color: var(--fg);
}
.theme-pop-item .md {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
}
/* utility */
.row {
@@ -3220,23 +3507,49 @@ html[data-platform="macos"] .titlebar .tb-left {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pill-tag.run {
background: var(--accent-soft);
color: var(--accent);
}
.pill-tag.ok {
background: var(--success-soft);
color: var(--success);
}
.pill-tag.warn {
background: var(--warning-soft);
color: var(--warning);
}
.pill-tag.err {
background: var(--danger-soft);
color: var(--danger);
}
/* status icons for card meta — replaces most text pill labels */
.spin-meta {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--accent-soft);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
display: inline-block;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.status-dot.warn { background: var(--warning); }
.meta-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1;
color: var(--muted);
}
.meta-dur {
font-size: 10px;
color: var(--muted-2);
font-variant-numeric: tabular-nums;
}
/* spinner */
.spin {
width: 12px;
@@ -3436,7 +3749,7 @@ html[data-platform="macos"] .titlebar .tb-left {
.skel-card .h .timer {
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 14px;
font-size: 12px;
}
.skel-card .body {
padding: 10px 12px 12px;
@@ -3893,6 +4206,121 @@ html[data-platform="macos"] .titlebar .tb-left {
box-shadow: var(--shadow-sm);
}
.theme-style-row {
align-items: start;
}
.style-grid {
display: grid;
grid-template-columns: repeat(2, minmax(150px, 1fr));
gap: 8px;
width: min(360px, 42vw);
}
.style-card {
text-align: left;
border: 1px solid var(--border);
background: var(--panel);
border-radius: 8px;
padding: 10px;
display: grid;
gap: 8px;
color: var(--fg);
min-height: 108px;
}
.style-card[data-on="true"] {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
background: var(--card);
}
.style-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.style-name {
font-size: 14px;
font-weight: 600;
}
.style-mode {
margin-left: auto;
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
}
.style-swatches {
display: grid;
grid-template-columns: 1.2fr 1fr 0.7fr;
gap: 4px;
height: 24px;
}
.style-swatches span {
border: 1px solid oklch(0% 0 0 / 0.12);
border-radius: 5px;
}
.style-desc {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.style-card[data-style="graphite"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(1) {
background: oklch(17% 0.005 280);
}
.style-card[data-style="graphite"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(2) {
background: oklch(24.5% 0.008 275);
}
.style-card[data-style="graphite"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(3) {
background: oklch(68% 0.16 38);
}
.style-card[data-style="sandstone"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="sandstone"] .style-swatches span:nth-child(1) {
background: oklch(97.5% 0.008 80);
}
.style-card[data-style="sandstone"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="sandstone"] .style-swatches span:nth-child(2) {
background: oklch(93.5% 0.014 76);
}
.style-card[data-style="sandstone"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="sandstone"] .style-swatches span:nth-child(3) {
background: oklch(60% 0.19 38);
}
.style-card[data-style="porcelain"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(1) {
background: oklch(99.2% 0.001 100);
}
.style-card[data-style="porcelain"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(2) {
background: oklch(95.6% 0.001 100);
}
.style-card[data-style="porcelain"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(3) {
background: oklch(56% 0.12 285);
}
.style-card[data-style="midnight"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="midnight"] .style-swatches span:nth-child(1) {
background: oklch(17% 0.026 260);
}
.style-card[data-style="midnight"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="midnight"] .style-swatches span:nth-child(2) {
background: oklch(25% 0.03 265);
}
.style-card[data-style="midnight"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="midnight"] .style-swatches span:nth-child(3) {
background: oklch(73% 0.12 285);
}
@media (max-width: 760px) {
.theme-style-row,
.setting-row {
grid-template-columns: 1fr;
}
.style-grid {
width: 100%;
grid-template-columns: 1fr;
}
}
.field {
width: 200px;
padding: 5px 8px;
@@ -3908,6 +4336,87 @@ html[data-platform="macos"] .titlebar .tb-left {
.field.mono {
font-family: inherit;
}
.field.font-family-field {
width: min(360px, 100%);
}
.qq-setting-row {
align-items: flex-start;
}
.qq-row-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.qq-status-btn {
min-width: 88px;
justify-content: center;
font-weight: 600;
}
.qq-status-btn.qq-status-on {
color: var(--success);
background: var(--success-soft);
border-color: color-mix(in srgb, var(--success) 40%, var(--border));
}
.qq-status-btn.qq-status-on:hover {
background: color-mix(in srgb, var(--success) 18%, transparent);
}
.qq-status-btn.qq-status-off {
color: var(--danger);
background: var(--danger-soft);
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
}
.qq-status-btn.qq-status-off:hover {
background: color-mix(in srgb, var(--danger) 16%, transparent);
}
.qq-status-btn.qq-status-connecting {
color: var(--accent);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
}
.qq-status-btn.qq-status-failed {
color: var(--danger);
background: var(--danger-soft);
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
}
.qq-config-card .h {
font-size: 13px;
line-height: 1.5;
}
.qq-config-card {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
padding: 12px 14px;
}
.qq-config-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.qq-config-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding-top: 10px;
}
/* card list (mcp, skills) */
.scard {
@@ -4098,13 +4607,13 @@ html[data-platform="macos"] .titlebar .tb-left {
border-radius: 4px;
text-transform: uppercase;
}
.mem-edit .scope[data-s="项目"], .mem-edit .scope[data-s="project"] {
.mem-edit .scope[data-s="project"] {
background: var(--accent-soft); color: var(--accent);
}
.mem-edit .scope[data-s="用户"], .mem-edit .scope[data-s="user"] {
.mem-edit .scope[data-s="user"] {
background: var(--violet-soft); color: var(--violet);
}
.mem-edit .scope[data-s="全局"], .mem-edit .scope[data-s="global"] {
.mem-edit .scope[data-s="global"] {
background: var(--success-soft); color: var(--success);
}
.mem-edit .txt {
@@ -5893,3 +6402,156 @@ html[data-platform="macos"] .splash {
.statusbar .seg {
transition: background 0.12s ease, color 0.12s ease;
}
/* ---- about modal ---- */
.about-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fade 0.16s ease-out;
}
.about-modal {
position: relative;
background: var(--card);
border: 1px solid var(--border-strong);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: 28px 32px 24px;
min-width: 380px;
max-width: 480px;
color: var(--fg);
font-size: 14px;
animation: rise 0.18s ease-out;
}
.about-close {
position: absolute;
top: 12px;
right: 12px;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.about-close:hover {
background: var(--panel);
color: var(--fg);
}
.about-brand {
text-align: center;
margin-bottom: 20px;
}
.about-name {
font-size: 22px;
font-weight: 600;
letter-spacing: 0.2px;
margin-bottom: 4px;
}
.about-tagline {
font-size: 12px;
color: var(--muted);
}
.about-meta {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 0;
border-top: 1px solid var(--border-strong);
border-bottom: 1px solid var(--border-strong);
}
.about-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 13px;
}
.about-row .about-label {
color: var(--muted);
}
.about-row .about-value {
font-family: "Geist Mono", monospace;
font-size: 12px;
color: var(--fg);
background: var(--panel);
padding: 2px 8px;
border-radius: 4px;
}
.about-link {
display: inline-flex;
align-items: center;
gap: 5px;
border: none;
background: transparent;
color: var(--accent);
font-family: inherit;
font-size: 13px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: background 0.12s;
}
.about-link:hover {
background: var(--accent-soft);
}
.about-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
}
.about-check {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--fg);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.about-check:hover:not(:disabled) {
background: var(--card);
border-color: var(--accent);
}
.about-check:disabled {
opacity: 0.6;
cursor: default;
}
.about-status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
font-size: 12px;
}
.about-status.ok {
background: var(--accent-soft);
color: var(--fg);
}
.about-status.warn {
background: var(--panel);
color: var(--fg);
border: 1px solid var(--accent);
justify-content: space-between;
}
.about-status.err {
background: var(--panel);
color: var(--muted);
}
+55 -3
View File
@@ -9,6 +9,51 @@ export function isTheme(value: unknown): value is Theme {
return value === THEME.DARK || value === THEME.LIGHT;
}
export const THEME_STYLE = {
GRAPHITE: "graphite",
SANDSTONE: "sandstone",
PORCELAIN: "porcelain",
MIDNIGHT: "midnight",
} as const;
export type ThemeStyle = (typeof THEME_STYLE)[keyof typeof THEME_STYLE];
export const DEFAULT_THEME_STYLE: Record<Theme, ThemeStyle> = {
dark: THEME_STYLE.GRAPHITE,
light: THEME_STYLE.SANDSTONE,
};
export const THEME_STYLE_THEME: Record<ThemeStyle, Theme> = {
graphite: THEME.DARK,
sandstone: THEME.LIGHT,
porcelain: THEME.LIGHT,
midnight: THEME.DARK,
};
export const THEME_STYLES = [
THEME_STYLE.GRAPHITE,
THEME_STYLE.SANDSTONE,
THEME_STYLE.PORCELAIN,
THEME_STYLE.MIDNIGHT,
] as const;
export function isThemeStyle(value: unknown): value is ThemeStyle {
return (
value === THEME_STYLE.GRAPHITE ||
value === THEME_STYLE.SANDSTONE ||
value === THEME_STYLE.PORCELAIN ||
value === THEME_STYLE.MIDNIGHT
);
}
export function themeForStyle(style: ThemeStyle): Theme {
return THEME_STYLE_THEME[style];
}
export function defaultStyleForTheme(theme: Theme): ThemeStyle {
return DEFAULT_THEME_STYLE[theme];
}
export const FONT_SCALE = {
SMALL: "small",
MEDIUM: "medium",
@@ -31,16 +76,23 @@ export const FONT_FAMILY = {
SANS: "sans",
SYSTEM: "system",
SERIF: "serif",
CUSTOM: "custom",
} as const;
export type FontFamily = (typeof FONT_FAMILY)[keyof typeof FONT_FAMILY];
export function isFontFamily(value: unknown): value is FontFamily {
return value === FONT_FAMILY.SANS || value === FONT_FAMILY.SYSTEM || value === FONT_FAMILY.SERIF;
return (
value === FONT_FAMILY.SANS ||
value === FONT_FAMILY.SYSTEM ||
value === FONT_FAMILY.SERIF ||
value === FONT_FAMILY.CUSTOM
);
}
export const FONT_FAMILY_STACK: Record<FontFamily, string> = {
sans: '"Geist", system-ui, sans-serif',
system: '-apple-system, system-ui, "Segoe UI", Roboto, sans-serif',
serif: '"Geist", Georgia, serif',
};
serif: 'Georgia, "Times New Roman", serif',
custom: '"Geist", system-ui, sans-serif',
};
+110
View File
@@ -0,0 +1,110 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { useCallback, useState } from "react";
import { t } from "../i18n";
import { I } from "../icons";
const REPO_URL = "https://github.com/esengine/DeepSeek-Reasonix";
const RELEASES_PAGE = `${REPO_URL}/releases`;
type CheckState =
| { kind: "idle" }
| { kind: "checking" }
| { kind: "up-to-date"; latest: string }
| { kind: "outdated"; latest: string }
| { kind: "error"; message: string };
export function AboutModal({ onClose }: { onClose: () => void }) {
const [check, setCheck] = useState<CheckState>({ kind: "idle" });
const openGitHub = useCallback(() => {
void openUrl(REPO_URL).catch(() => undefined);
}, []);
const openReleases = useCallback(() => {
void openUrl(RELEASES_PAGE).catch(() => undefined);
}, []);
const checkForUpdates = useCallback(async () => {
setCheck({ kind: "checking" });
try {
const update = await checkUpdate();
if (!update) {
setCheck({ kind: "up-to-date", latest: __APP_VERSION__ });
} else {
setCheck({ kind: "outdated", latest: update.version });
}
} catch (err) {
setCheck({ kind: "error", message: (err as Error).message });
}
}, []);
return (
<div className="about-mask" onClick={onClose}>
<div className="about-modal" onClick={(e) => e.stopPropagation()}>
<button type="button" className="about-close" onClick={onClose} aria-label={t("about.close")}>
<I.x size={14} />
</button>
<div className="about-brand">
<div className="about-name">Reasonix</div>
<div className="about-tagline">{t("about.tagline")}</div>
</div>
<div className="about-meta">
<div className="about-row">
<span className="about-label">{t("about.version")}</span>
<code className="about-value">{__APP_VERSION__}</code>
</div>
<div className="about-row">
<span className="about-label">{t("about.repo")}</span>
<button type="button" className="about-link" onClick={openGitHub}>
<I.link size={12} />
<span>esengine/DeepSeek-Reasonix</span>
</button>
</div>
</div>
<div className="about-actions">
<button
type="button"
className="about-check"
onClick={checkForUpdates}
disabled={check.kind === "checking"}
>
<I.rotate size={12} />
<span>{check.kind === "checking" ? t("about.checking") : t("about.checkUpdates")}</span>
</button>
<CheckStatus check={check} onOpenReleases={openReleases} />
</div>
</div>
</div>
);
}
function CheckStatus({
check,
onOpenReleases,
}: { check: CheckState; onOpenReleases: () => void }) {
if (check.kind === "idle" || check.kind === "checking") return null;
if (check.kind === "up-to-date") {
return (
<div className="about-status ok">
<I.check size={12} />
<span>{t("about.upToDate", { version: check.latest })}</span>
</div>
);
}
if (check.kind === "outdated") {
return (
<div className="about-status warn">
<span>{t("about.updateAvailable", { version: check.latest })}</span>
<button type="button" className="about-link" onClick={onOpenReleases}>
<I.download size={12} />
<span>{t("about.openReleases")}</span>
</button>
</div>
);
}
return (
<div className="about-status err">
<span>{t("about.checkFailed", { message: check.message })}</span>
</div>
);
}
+74 -45
View File
@@ -2,6 +2,7 @@ import { memo, useState, type ReactNode } from "react";
import { I } from "../icons";
import { Markdown } from "../Markdown";
import { t, useLang } from "../i18n";
import { Shortcut } from "./shortcut";
type Tone = "default" | "success" | "warning" | "danger" | "accent" | "violet";
@@ -69,12 +70,27 @@ export type PlanItem = {
note?: string;
};
function derivePlanBadge(items: PlanItem[]): { cls: "run" | "ok" | "warn" | "err"; label: string } {
if (items.some((x) => x.status === "failed")) return { cls: "err", label: t("planBadge.failed") };
if (items.some((x) => x.status === "blocked")) return { cls: "warn", label: t("planBadge.blocked") };
if (items.some((x) => x.status === "active")) return { cls: "run", label: t("planBadge.running") };
if (items.length > 0 && items.every((x) => x.status === "done")) return { cls: "ok", label: t("planBadge.done") };
return { cls: "run", label: t("planBadge.pending") };
function derivePlanBadge(items: PlanItem[]): { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string } {
if (items.some((x) => x.status === "failed")) return { state: "failed", label: t("planBadge.failed") };
if (items.some((x) => x.status === "blocked")) return { state: "blocked", label: t("planBadge.blocked") };
if (items.some((x) => x.status === "active")) return { state: "running", label: t("planBadge.running") };
if (items.length > 0 && items.every((x) => x.status === "done")) return { state: "done", label: t("planBadge.done") };
return { state: "waiting", label: t("planBadge.pending") };
}
function StatusIcon({ state, label }: { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string }) {
switch (state) {
case "running":
return <span className="spin-meta" role="img" aria-label={label} title={label} />;
case "done":
return <I.check size={10} style={{ color: "var(--success)" }} aria-label={label} />;
case "failed":
return <I.x size={10} style={{ color: "var(--danger)" }} aria-label={label} />;
case "waiting":
return <span className="status-dot warn" role="img" aria-label={label} title={label} />;
case "blocked":
return <I.slash size={10} style={{ color: "var(--warning)" }} aria-label={label} />;
}
}
export function PlanCardView({ items, title }: { items: PlanItem[]; title?: string }) {
@@ -93,7 +109,8 @@ export function PlanCardView({ items, title }: { items: PlanItem[]; title?: stri
<span>
{done}/{items.length}
</span>
<span className={`pill-tag ${badge.cls}`}>{badge.label}</span>
<StatusIcon state={badge.state} label={badge.label} />
<span className="meta-label">{badge.label}</span>
</>
}
>
@@ -150,11 +167,9 @@ export function ReasoningCard({
</span>
) : null}
{streaming ? (
<span className="pill-tag warn">
<span className="shimmer">streaming…</span>
</span>
<StatusIcon state="running" label={t("cards.streaming")} />
) : (
<span className="pill-tag ok">{t("cards.reasoningComplete")}</span>
<StatusIcon state="done" label={t("cards.reasoningComplete")} />
)}
</>
}
@@ -178,12 +193,12 @@ export function ReasoningCard({
<div className="meta">
{model ? (
<span>
<span className="k">model</span> {model}
<span className="k">{t("settings.model")}</span> {model}
</span>
) : null}
{tokens !== undefined ? (
<span>
<span className="k">tokens</span> {tokens.toLocaleString()}
<span className="k">{t("statusbar.tokens")}</span> {tokens.toLocaleString()}
</span>
) : null}
</div>
@@ -214,7 +229,6 @@ export function ShellCard({
}) {
useLang();
const tone: Tone = state === "failed" ? "danger" : state === "done" ? "success" : "warning";
const durationLabel = durationMs ? ` · ${(durationMs / 1000).toFixed(2)}s` : "";
return (
<Card
tone={tone}
@@ -223,15 +237,20 @@ export function ShellCard({
name="shell"
compact
meta={
state === "await" ? (
<span className="pill-tag warn">{t("cards.shellAwaiting")}</span>
) : state === "running" ? (
<span className="pill-tag run">{t("cards.shellRunning")}</span>
) : state === "failed" ? (
<span className="pill-tag err">failed{durationLabel}</span>
) : (
<span className="pill-tag ok">done{durationLabel}</span>
)
<>
{state === "await" ? (
<StatusIcon state="waiting" label={t("cards.shellAwaiting")} />
) : state === "running" ? (
<StatusIcon state="running" label={t("cards.shellRunning")} />
) : state === "failed" ? (
<StatusIcon state="failed" label={t("cards.failed")} />
) : (
<StatusIcon state="done" label={t("cards.done")} />
)}
{(state === "done" || state === "failed") && durationMs ? (
<span className="meta-dur">{(durationMs / 1000).toFixed(2)}s</span>
) : null}
</>
}
>
<div className="shell">
@@ -271,11 +290,11 @@ export function ShellCard({
) : null}
{onReject ? (
<button type="button" className="btn" onClick={onReject}>
{t("cards.shellReject")} <kbd>⌘.</kbd>
{t("cards.shellReject")} <Shortcut keys={["mod", "."]} />
</button>
) : null}
<button type="button" className="btn primary" onClick={onApprove}>
{t("cards.shellRun")} <kbd>⌘⏎</kbd>
{t("cards.shellRun")} <Shortcut keys={["mod", "enter"]} />
</button>
</div>
</div>
@@ -300,9 +319,9 @@ export function ToolCard({
ok?: boolean;
durationMs?: number;
}) {
useLang();
const running = result === undefined;
const tone: Tone = running ? "default" : ok === false ? "danger" : "success";
const dur = durationMs ? `${durationMs} ms` : "—";
return (
<Card
tone={tone}
@@ -312,13 +331,18 @@ export function ToolCard({
defaultOpen={false}
compact
meta={
running ? (
<span className="pill-tag run">running</span>
) : ok === false ? (
<span className="pill-tag err">error · {dur}</span>
) : (
<span className="pill-tag ok">done · {dur}</span>
)
<>
{running ? (
<StatusIcon state="running" label={t("cards.running")} />
) : ok === false ? (
<StatusIcon state="failed" label={t("cards.error")} />
) : (
<StatusIcon state="done" label={t("cards.done")} />
)}
{!running && durationMs !== undefined ? (
<span className="meta-dur">{durationMs} ms</span>
) : null}
</>
}
>
<div className="tool-call">
@@ -332,7 +356,7 @@ export function ToolCard({
) : null}
{result !== undefined ? (
<div className="row">
<span className="k">{ok === false ? "error" : "result"}</span>
<span className="k">{ok === false ? t("cards.error") : t("cards.result")}</span>
<span className="v">
<span className={ok === false ? "num" : "str"}>
{result.length > 1200 ? `${result.slice(0, 1200)}…` : result}
@@ -379,7 +403,11 @@ export function DiffCard({
<>
<span style={{ color: "var(--success)" }}>+{adds}</span>
<span style={{ color: "var(--danger)" }}>−{rms}</span>
{applied ? <span className="pill-tag ok">applied</span> : <span className="pill-tag warn">{t("cards.diffAwaiting")}</span>}
{applied ? (
<StatusIcon state="done" label={t("cards.applied")} />
) : (
<StatusIcon state="waiting" label={t("cards.diffAwaiting")} />
)}
</>
}
>
@@ -420,7 +448,7 @@ export function DiffCard({
) : null}
{onApply ? (
<button type="button" className="btn primary" onClick={onApply}>
{t("cards.diffApply")} <kbd>⌘⏎</kbd>
{t("cards.diffApply")} <Shortcut keys={["mod", "enter"]} />
</button>
) : null}
</div>
@@ -466,7 +494,7 @@ export function WebSearchCard({ query, results }: { query: string; results: Sear
meta={
<>
<span>"{query}"</span>
<span className="pill-tag ok">{results.length} hits</span>
<span className="pill-tag ok">{results.length} {t("cards.hits")}</span>
</>
}
>
@@ -518,11 +546,11 @@ export function SubagentCard({
{done} / {children.length} {t("cards.subagentDoneProgress")}
</span>
{status === "done" ? (
<span className="pill-tag ok">{t("cards.subagentDone")}</span>
<StatusIcon state="done" label={t("cards.subagentDone")} />
) : status === "failed" ? (
<span className="pill-tag err">{t("cards.subagentFailed")}</span>
<StatusIcon state="failed" label={t("cards.subagentFailed")} />
) : (
<span className="pill-tag run">{t("cards.subagentRunning")}</span>
<StatusIcon state="running" label={t("cards.subagentRunning")} />
)}
</>
}
@@ -586,6 +614,7 @@ export function AttachCard({
meta: string;
preview?: string;
}) {
useLang();
return (
<Card
tone="default"
@@ -628,32 +657,32 @@ export function MetricStrip({
{cacheHit !== undefined ? (
<span className="item">
<I.zap size={11} style={{ color: "var(--accent)" }} />
<span>cache_hit</span>
<span>{t("cards.cacheHit")}</span>
<span className="v acc">{cacheHit}%</span>
</span>
) : null}
{promptTokens !== undefined ? (
<span className="item">
<span>prompt</span>
<span>{t("cards.prompt")}</span>
<span className="v">{promptTokens.toLocaleString()} t</span>
</span>
) : null}
{outputTokens !== undefined ? (
<span className="item">
<span>output</span>
<span>{t("cards.output")}</span>
<span className="v">{outputTokens.toLocaleString()} t</span>
</span>
) : null}
{costLabel ? (
<span className="item">
<I.coin size={11} />
<span>cost</span>
<span>{t("cards.cost")}</span>
<span className="v ok">{costLabel}</span>
</span>
) : null}
{elapsed ? (
<span className="item">
<span>elapsed</span>
<span>{t("cards.elapsed")}</span>
<span className="v">{elapsed}</span>
</span>
) : null}
+46
View File
@@ -0,0 +1,46 @@
export const DEFAULT_COMPOSER_ROWS = 5;
export const MAX_COMPOSER_ROWS = 15;
export type ComposerTextareaSizing = {
heightPx: number;
overflowY: "hidden" | "auto";
};
export function getComposerTextareaSizing({
contentRows,
lineHeightPx,
verticalPaddingPx,
}: {
contentRows: number;
lineHeightPx: number;
verticalPaddingPx: number;
}): ComposerTextareaSizing {
const safeRows = Math.max(1, Math.ceil(contentRows));
const visibleRows = Math.min(Math.max(safeRows, DEFAULT_COMPOSER_ROWS), MAX_COMPOSER_ROWS);
return {
heightPx: visibleRows * lineHeightPx + verticalPaddingPx,
overflowY: safeRows > MAX_COMPOSER_ROWS ? "auto" : "hidden",
};
}
export function applyComposerTextareaAutosize(textarea: HTMLTextAreaElement) {
const style = window.getComputedStyle(textarea);
const lineHeightPx = Number.parseFloat(style.lineHeight);
const paddingTopPx = Number.parseFloat(style.paddingTop);
const paddingBottomPx = Number.parseFloat(style.paddingBottom);
const verticalPaddingPx = paddingTopPx + paddingBottomPx;
const measuredLineHeight = Number.isFinite(lineHeightPx) ? lineHeightPx : 20;
textarea.style.height = "auto";
const contentRows = Math.ceil(
Math.max(textarea.scrollHeight - verticalPaddingPx, measuredLineHeight) / measuredLineHeight,
);
const sizing = getComposerTextareaSizing({
contentRows,
lineHeightPx: measuredLineHeight,
verticalPaddingPx,
});
textarea.style.height = `${sizing.heightPx}px`;
textarea.style.overflowY = sizing.overflowY;
}
+55 -23
View File
@@ -3,6 +3,7 @@ import {
type KeyboardEvent,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -11,24 +12,29 @@ import type React from "react";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { t, type TKey } from "../i18n";
import { I } from "../icons";
import {
DEFAULT_COMPOSER_ROWS,
applyComposerTextareaAutosize,
} from "./composer-sizing";
import { fmtElapsed } from "./live";
import { Shortcut } from "./shortcut";
export type PresetName = "auto" | "flash" | "pro";
export type EditMode = "review" | "auto" | "yolo";
type PresetEntry = { label: string; badge: string; desc: TKey };
type ModeEntry = { k: EditMode; label: string; icon: React.ReactNode; hint: TKey };
type ModeEntry = { k: EditMode; label: TKey; icon: React.ReactNode; hint: TKey };
const PRESET_INFO: Record<PresetName, PresetEntry> = {
auto: { label: "auto", badge: "AUTO", desc: "preset.autoDesc" },
flash: { label: "deepseek-v4-flash", badge: "FLASH", desc: "preset.flashDesc" },
pro: { label: "deepseek-v4-pro", badge: "PRO", desc: "preset.proDesc" },
flash: { label: "v4-flash", badge: "FLASH", desc: "preset.flashDesc" },
pro: { label: "v4-pro", badge: "PRO", desc: "preset.proDesc" },
};
const MODE_INFO: ModeEntry[] = [
{ k: "review", label: "Review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
{ k: "auto", label: "Auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
{ k: "yolo", label: "YOLO", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
{ k: "review", label: "editMode.review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
{ k: "auto", label: "editMode.auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
{ k: "yolo", label: "editMode.yolo", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
];
export function ModeSwitch({
@@ -51,7 +57,7 @@ export function ModeSwitch({
title={t(m.hint)}
>
{m.icon}
<span>{m.label}</span>
<span>{t(m.label)}</span>
</button>
))}
</div>
@@ -63,6 +69,7 @@ export type SlashCmd = {
desc: string;
run: () => void;
kb?: string;
insertOnly?: boolean;
};
export type MentionItem = {
name: string;
@@ -173,6 +180,12 @@ export function Composer({
const nonceRef = useRef(0);
const modelWrapRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
applyComposerTextareaAutosize(textarea);
});
// Programmatic draft transitions to "/" (e.g. /help suggestion in EmptyState, #929) must open the slash popup, since handleChange only fires on actual user input.
const prevDraftRef = useRef(draft);
useEffect(() => {
@@ -248,7 +261,7 @@ export function Composer({
base.unshift({
name: "..",
kind: "dir",
desc: parent ? `↑ ${parent}` : "↑ workspace root",
desc: parent ? `↑ ${parent}` : `↑ ${t("composer.workspaceRoot")}`,
});
}
return base;
@@ -291,10 +304,17 @@ export function Composer({
if (!it || !popup) return;
if (popup.kind === "slash") {
const cmd = (it as SlashCmd).cmd;
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
setDraft(next);
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
(it as SlashCmd).run();
const insertOnly = (it as SlashCmd).insertOnly === true;
if (insertOnly) {
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
setDraft(next ? `${next} ${cmd} ` : `${cmd} `);
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
} else {
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
setDraft(next);
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
(it as SlashCmd).run();
}
} else {
const mention = it as MentionItem;
if (mention.name === "..") {
@@ -418,20 +438,23 @@ export function Composer({
<ModeSwitch mode={editMode} onChange={onEditModeChange} />
<span className="hint-sep" />
<span>
<kbd>⏎</kbd> {t("composer.queue")} &nbsp;·&nbsp; <kbd>esc</kbd> {t("composer.interrupt")}
<Shortcut keys={["enter"]} /> {t("composer.queue")} &nbsp;·&nbsp;{" "}
<Shortcut keys={["esc"]} /> {t("composer.interrupt")}
</span>
</>
) : (
<>
<span>
<kbd>/</kbd> {t("composer.commands")} &nbsp;·&nbsp; <kbd>@</kbd> {t("composer.mentionFiles")}
&nbsp;·&nbsp; <kbd>⌘K</kbd> {t("composer.commandPalette")}
<Shortcut keys={["/"]} /> {t("composer.commands")} &nbsp;·&nbsp;{" "}
<Shortcut keys={["@"]} /> {t("composer.mentionFiles")}
&nbsp;·&nbsp; <Shortcut keys={["mod", "K"]} /> {t("composer.commandPalette")}
</span>
<span className="grow" />
<ModeSwitch mode={editMode} onChange={onEditModeChange} />
<span className="hint-sep" />
<span>
<kbd>⏎</kbd> {t("composer.send")} &nbsp; <kbd>⇧⏎</kbd> {t("composer.newline")}
<Shortcut keys={["enter"]} /> {t("composer.send")} &nbsp;{" "}
<Shortcut keys={["shift", "enter"]} /> {t("composer.newline")}
</span>
</>
)}
@@ -467,7 +490,7 @@ export function Composer({
placeholder={t("composer.placeholder")}
onChange={handleChange}
onKeyDown={handleKeyDown}
rows={2}
rows={DEFAULT_COMPOSER_ROWS}
disabled={disabled}
/>
@@ -500,7 +523,7 @@ export function Composer({
<span className="ico">
<I.slash size={14} />
</span>
<span className="label">commands</span>
<span className="label">{t("composer.commandsLabel")}</span>
</button>
<button
type="button"
@@ -513,7 +536,7 @@ export function Composer({
<span className="ico">
<I.at size={14} />
</span>
<span className="label">mention</span>
<span className="label">{t("composer.mentionLabel")}</span>
</button>
<span className="grow" />
@@ -604,6 +627,15 @@ function Popup({
onClose: () => void;
onHover: (i: number, item: SlashCmd | MentionItem) => void;
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
requestAnimationFrame(() => {
const el = listRef.current?.querySelector<HTMLElement>(`[data-active="true"]`);
el?.scrollIntoView({ block: "nearest" });
});
}, [activeIdx]);
return (
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
<div className="ph">
@@ -618,7 +650,7 @@ function Popup({
<I.x size={11} />
</span>
</div>
<div className="popup-list">
<div className="popup-list" ref={listRef}>
{items.length === 0 ? (
<div
style={{
@@ -667,13 +699,13 @@ function Popup({
</div>
<div className="popup-foot">
<span>
<kbd>↑↓</kbd> {t("composer.select")}
<Shortcut keys={["updown"]} /> {t("composer.select")}
</span>
<span>
<kbd>⏎</kbd> {t("composer.confirm")}
<Shortcut keys={["enter"]} /> {t("composer.confirm")}
</span>
<span>
<kbd>esc</kbd> {t("composer.close")}
<Shortcut keys={["esc"]} /> {t("composer.close")}
</span>
</div>
</div>
+43 -34
View File
@@ -3,6 +3,7 @@ import type { SessionFile, Settings, UsageStats } from "../App";
import { t, useLang } from "../i18n";
import { I } from "../icons";
import type { McpSpecInfo, MemoryEntryInfo } from "../protocol";
import { PanelErrorBoundary } from "./error-boundary";
type Tab = "files" | "tools" | "memory" | "rules";
@@ -39,23 +40,23 @@ export function ContextPanel({
<aside className="ctx">
<div className="ctx-tabs">
<div className="ctx-tab" data-active={tab === "files"} onClick={() => setTab("files")}>
文件
{t("contextPanel.filesTab")}
</div>
<div className="ctx-tab" data-active={tab === "tools"} onClick={() => setTab("tools")}>
工具
{t("contextPanel.toolsTab")}
</div>
<div className="ctx-tab" data-active={tab === "memory"} onClick={() => setTab("memory")}>
记忆
{t("contextPanel.memoryTab")}
</div>
<div className="ctx-tab" data-active={tab === "rules"} onClick={() => setTab("rules")}>
规则
{t("contextPanel.rulesTab")}
</div>
</div>
<div className="ctx-body">
<div className="ctx-block">
<div className="h">
<span>上下文 · tokens</span>
<span>{t("contextPanel.contextTokens")}</span>
<span className="right">
{(reserved + used + cached).toLocaleString()} /{" "}
{CONTEXT_MAX_TOKENS.toLocaleString()}
@@ -73,22 +74,24 @@ export function ContextPanel({
</span>
<span className="l">
<span className="sw c" />
缓存 <span className="v">{cached.toLocaleString()}</span>
{t("contextPanel.cacheKey")} <span className="v">{cached.toLocaleString()}</span>
</span>
<span className="l">
<span className="sw u" />
{t("contextPanel.usedKey")} <span className="v">{used.toLocaleString()}</span>
</span>
<span className="l">
余 <span className="v">{free.toLocaleString()}</span>
{t("contextPanel.freeKey")} <span className="v">{free.toLocaleString()}</span>
</span>
</div>
</div>
{tab === "files" && <CtxFiles files={sessionFiles} />}
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
{tab === "memory" && <CtxMemory entries={memory} />}
{tab === "rules" && <CtxRules settings={settings} />}
<PanelErrorBoundary key={tab} label={tab}>
{tab === "files" && <CtxFiles files={sessionFiles} />}
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
{tab === "memory" && <CtxMemory entries={memory} />}
{tab === "rules" && <CtxRules settings={settings} />}
</PanelErrorBoundary>
</div>
</aside>
);
@@ -133,8 +136,10 @@ function CtxFiles({ files }: { files: SessionFile[] }) {
return (
<div className="ctx-block">
<div className="h">
<span>上下文中的文件</span>
<span className="right">{files.length === 0 ? "—" : `${files.length} files`}</span>
<span>{t("contextPanel.filesTitle")}</span>
<span className="right">
{files.length === 0 ? "—" : t("contextPanel.filesCount", { count: files.length })}
</span>
</div>
<div className="tree">
{files.length === 0 ? (
@@ -163,7 +168,7 @@ function CtxFiles({ files }: { files: SessionFile[] }) {
<span
className="dot"
data-s={n.status}
title={n.status === "m" ? "modified" : "in context"}
title={n.status === "m" ? t("contextPanel.fileModified") : t("contextPanel.fileInContext")}
/>
</div>
),
@@ -179,17 +184,17 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
return (
<div className="ctx-block">
<div className="h">
<span>MCP 服务器</span>
<span>{t("contextPanel.mcpTitle")}</span>
<span className="right">
{specs.length === 0
? "—"
: bridged
? `${specs.length} ready`
: `${readyCount}/${specs.length} ready`}
? t("contextPanel.mcpReadyAll", { count: specs.length })
: t("contextPanel.mcpReadySome", { ready: readyCount, count: specs.length })}
</span>
</div>
{specs.length === 0 ? (
<div className="ctx-empty">未配置 MCP 服务器</div>
<div className="ctx-empty">{t("contextPanel.mcpEmpty")}</div>
) : (
specs.map((s) => {
const dot =
@@ -202,15 +207,15 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
? ` · ${s.statusReason}`
: s.status === "connected"
? typeof s.toolCount === "number"
? ` · ${s.toolCount} tools`
: " · ready"
? ` · ${t("contextPanel.mcpTools", { count: s.toolCount })}`
: ` · ${t("contextPanel.mcpReady")}`
: s.status === "handshake"
? " · connecting"
? ` · ${t("contextPanel.mcpConnecting")}`
: s.status === "disabled"
? " · disabled"
? ` · ${t("contextPanel.mcpDisabled")}`
: s.status === "failed"
? " · failed"
: " · configured";
? ` · ${t("contextPanel.mcpFailed")}`
: ` · ${t("contextPanel.mcpConfigured")}`;
return (
<div className="mcp-row" key={s.raw}>
<span className="ico">
@@ -236,8 +241,10 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
return (
<div className="ctx-block">
<div className="h">
<span>长期记忆</span>
<span className="right">{entries.length === 0 ? "—" : `${entries.length} 项`}</span>
<span>{t("contextPanel.memoryTitle")}</span>
<span className="right">
{entries.length === 0 ? "—" : t("contextPanel.itemCount", { count: entries.length })}
</span>
</div>
{entries.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
@@ -246,7 +253,7 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
{entries.map((m) => (
<div className="mem-row" key={`${m.scope}/${m.name}`}>
<span className="scope" data-s={m.scope}>
{m.scope === "project" ? "项目" : "全局"}
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
</span>
<span className="txt">{m.description || m.name}</span>
</div>
@@ -261,27 +268,29 @@ function CtxRules({ settings }: { settings: Settings | null }) {
const editMode = settings?.editMode ?? "review";
const items: { p: string; allow: boolean; desc: string }[] =
editMode === "yolo"
? [{ p: "*", allow: true, desc: "YOLO 模式 · 所有工具调用自动批准" }]
? [{ p: "*", allow: true, desc: t("contextPanel.ruleYolo") }]
: editMode === "auto"
? [
{ p: "read_file, list_directory, search_files, *", allow: true, desc: "只读工具自动批准" },
{ p: "run_command (allowlist)", allow: true, desc: "命中 shell 白名单的命令自动批准" },
{ p: "edit_file, write_file, run_command (其他)", allow: false, desc: "写入与未知 shell 命令需确认" },
{ p: "read_file, list_directory, search_files, *", allow: true, desc: t("contextPanel.ruleReadOnly") },
{ p: "run_command (allowlist)", allow: true, desc: t("contextPanel.ruleShellAllowlist") },
{ p: "edit_file, write_file, run_command (other)", allow: false, desc: t("contextPanel.ruleWritesAsk") },
]
: [
{ p: "*", allow: false, desc: "Review 模式 · 每个工具调用都需确认" },
{ p: "*", allow: false, desc: t("contextPanel.ruleReview") },
];
return (
<div className="ctx-block">
<div className="h">
<span>自动批准</span>
<span>{t("contextPanel.autoApproveTitle")}</span>
<span className="right">{editMode}</span>
</div>
{items.map((r) => (
<div className="rule" key={r.p}>
<div className="top">
<span className={`pat ${r.allow ? "" : "deny"}`}>{r.p}</span>
<span className={`sw ${r.allow ? "" : "deny"}`}>{r.allow ? "ALLOW" : "ASK"}</span>
<span className={`sw ${r.allow ? "" : "deny"}`}>
{r.allow ? t("contextPanel.allow") : t("contextPanel.ask")}
</span>
</div>
<div className="desc">{r.desc}</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
import { Component, type ReactNode } from "react";
import { t } from "../i18n";
type Props = { label: string; children: ReactNode };
type State = { error: Error | null };
export class PanelErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: { componentStack?: string | null }): void {
console.error(`[panel:${this.props.label}] render error`, error, info);
}
render(): ReactNode {
if (!this.state.error) return this.props.children;
return (
<div
className="ctx-empty"
style={{
color: "var(--danger, #e25555)",
padding: 12,
fontSize: 12,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{t("panel.renderError", { panel: this.props.label })}
{": "}
{this.state.error.message}
</div>
);
}
}
+31 -24
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { I } from "../icons";
import { t } from "../i18n";
import { t, useLang } from "../i18n";
export type ApprovalTone = "ok" | "warn" | "danger" | "info" | "brand" | "ghost";
@@ -12,8 +12,8 @@ export function ApprovalCard({
body,
preview,
meta,
primaryLabel = "批准",
secondaryLabel = "拒绝",
primaryLabel,
secondaryLabel,
tertiaryLabel,
onPrimary,
onSecondary,
@@ -33,6 +33,7 @@ export function ApprovalCard({
onSecondary?: () => void;
onTertiary?: () => void;
}) {
useLang();
return (
<div className="approval" data-tone={tone}>
<div className="ap-head">
@@ -50,12 +51,12 @@ export function ApprovalCard({
<div className="ap-foot">
{onPrimary ? (
<button type="button" className="btn primary" onClick={onPrimary}>
{primaryLabel}
{primaryLabel ?? t("extraCards.approve")}
</button>
) : null}
{onSecondary ? (
<button type="button" className="btn ghost" onClick={onSecondary}>
{secondaryLabel}
{secondaryLabel ?? t("extraCards.reject")}
</button>
) : null}
{onTertiary && tertiaryLabel ? (
@@ -89,6 +90,7 @@ export function TaskCard({
subtitle?: string;
steps: TaskStepView[];
}) {
useLang();
const done = steps.filter((x) => x.state === "done").length;
const pct = steps.length ? (done / steps.length) * 100 : 0;
return (
@@ -183,6 +185,7 @@ export function TipCard({
export type DoctorRow = { s: "ok" | "warn" | "fail"; nm: string; sub: string; v: string };
export function DoctorCard({ rows, headerSubtitle }: { rows: DoctorRow[]; headerSubtitle?: string }) {
useLang();
const c = { ok: 0, warn: 0, fail: 0 };
for (const r of rows) c[r.s]++;
return (
@@ -192,7 +195,7 @@ export function DoctorCard({ rows, headerSubtitle }: { rows: DoctorRow[]; header
<I.shield size={13} />
</span>
<div>
<div className="tt">/doctor · 系统体检</div>
<div className="tt">{t("extraCards.doctorTitle")}</div>
{headerSubtitle ? (
<div
className="ss"
@@ -252,6 +255,7 @@ export function UsageFull({
balanceLabel?: string;
range?: string;
}) {
useLang();
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
const total = promptTokens + reasoningTokens + outputTokens + cacheHitTokens || 1;
const pct = (n: number) => Math.round((n / total) * 100);
@@ -266,22 +270,22 @@ export function UsageFull({
</div>
<div className="ub">
<div className="ucol">
<div className="l">prompt</div>
<div className="l">{t("extraCards.prompt")}</div>
<div className="v acc">{fmt(promptTokens)}</div>
<div className="pct">{pct(promptTokens)}%</div>
</div>
<div className="ucol">
<div className="l">reasoning</div>
<div className="l">{t("extraCards.reasoning")}</div>
<div className="v vio">{fmt(reasoningTokens)}</div>
<div className="pct">{pct(reasoningTokens)}%</div>
</div>
<div className="ucol">
<div className="l">output</div>
<div className="l">{t("extraCards.output")}</div>
<div className="v ok">{fmt(outputTokens)}</div>
<div className="pct">{pct(outputTokens)}%</div>
</div>
<div className="ucol">
<div className="l">cache hit</div>
<div className="l">{t("extraCards.cacheHit")}</div>
<div className="v">{fmt(cacheHitTokens)}</div>
<div className="pct">{pct(cacheHitTokens)}%</div>
</div>
@@ -295,23 +299,23 @@ export function UsageFull({
<div className="uf">
<span className="x">
<span className="sw" style={{ background: "var(--accent)" }} />
prompt
{t("extraCards.prompt")}
</span>
<span className="x">
<span className="sw" style={{ background: "var(--violet)" }} />
reasoning
{t("extraCards.reasoning")}
</span>
<span className="x">
<span className="sw" style={{ background: "var(--tone-ok)" }} />
output
{t("extraCards.output")}
</span>
<span className="x">
<span className="sw" style={{ background: "var(--border-strong)" }} />
cache
{t("extraCards.cache")}
</span>
<span style={{ marginLeft: "auto" }}>
{t("extraCards.sessionCost", { costLabel })}
{balanceLabel ? ` · 余额 ${balanceLabel}` : ""}
{balanceLabel ? ` · ${t("extraCards.balance", { balanceLabel })}` : ""}
</span>
</div>
</div>
@@ -332,10 +336,11 @@ export function CtxCard({
parts: CtxPart[];
topTools: CtxTopRow[];
}) {
useLang();
return (
<div className="ctx-card">
<div className="h">
<span className="tt">上下文窗口</span>
<span className="tt">{t("extraCards.contextWindow")}</span>
<span className="grow" />
<span className="v">{totalLabel}</span>
</div>
@@ -365,7 +370,7 @@ export function CtxCard({
</div>
{topTools.length > 0 ? (
<div className="ttop">
<div className="stt">Top tools — 占用</div>
<div className="stt">{t("extraCards.topToolsUsage")}</div>
{topTools.map((t, i) => (
<div className="row" key={i}>
<span className="n">{t.name}</span>
@@ -387,14 +392,15 @@ export type MemGroupKey = "user" | "feedback" | "project" | "reference";
export type MemEntry = { text: string; meta?: string };
export type MemGroups = Partial<Record<MemGroupKey, MemEntry[]>>;
const GROUP_LABELS: Record<MemGroupKey, string> = {
user: "USER · 用户偏好",
feedback: "FEEDBACK · 反馈与纠正",
project: "PROJECT · 项目规范",
reference: "REFERENCE · 引用资料",
const GROUP_LABELS: Record<MemGroupKey, Parameters<typeof t>[0]> = {
user: "extraCards.memoryUser",
feedback: "extraCards.memoryFeedback",
project: "extraCards.memoryProject",
reference: "extraCards.memoryReference",
};
export function MemoryGroups({ data }: { data: MemGroups }) {
useLang();
const keys: MemGroupKey[] = ["user", "feedback", "project", "reference"];
return (
<div className="mem-groups">
@@ -405,7 +411,7 @@ export function MemoryGroups({ data }: { data: MemGroups }) {
<div key={g}>
<div className="gh" data-g={g}>
<span className="sw" />
<span>{GROUP_LABELS[g]}</span>
<span>{t(GROUP_LABELS[g])}</span>
<span className="grow" />
<span className="cnt">{rows.length}</span>
</div>
@@ -426,9 +432,10 @@ export function MemoryGroups({ data }: { data: MemGroups }) {
// ---- Fallback ----
export function FallbackCard({ kindLabel, payload }: { kindLabel: string; payload: Record<string, string> }) {
useLang();
return (
<div className="fallback-card">
<div className="hd"># unknown card kind — fallback render</div>
<div className="hd">{t("extraCards.unknownKind")}</div>
<div className="kv">
<span className="k">kind</span>
<span className="v">"{kindLabel}"</span>
+3 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { I } from "../icons";
import { t, useLang } from "../i18n";
import type { JobInfo } from "../protocol";
import { Shortcut } from "./shortcut";
export function JobsPop({
open,
@@ -96,7 +97,8 @@ export function JobsPop({
</span>
<span className="grow" />
<span>
<kbd>⌘J</kbd> {t("jobs.kbToggle")} · <kbd>esc</kbd> {t("jobs.kbClose")}
<Shortcut keys={["mod", "J"]} /> {t("jobs.kbToggle")} ·{" "}
<Shortcut keys={["esc"]} /> {t("jobs.kbClose")}
</span>
</div>
</div>
+11 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { I } from "../icons";
import { t, useLang } from "../i18n";
export function fmtElapsed(ms: number): string {
const s = ms / 1000;
@@ -55,10 +56,11 @@ export function ThinkingPill({
}
export function LiveReasoning({ lines }: { lines: string[] }) {
useLang();
return (
<div className="live-reason">
<div className="head">
<span className="dot" /> live · reasoning
<span className="dot" /> {t("live.reasoning")}
</div>
{lines.map((line, i) => (
<div key={i}>
@@ -81,6 +83,7 @@ export function ToolRunningCard({
elapsedMs: number;
logLines?: { text: string; tone?: "ok" | "dim" }[];
}) {
useLang();
const ic =
kind === "shell" ? <I.terminal size={12} /> : kind === "fetch" ? <I.globe size={12} /> : kind === "search" ? <I.search size={12} /> : <I.wrench size={12} />;
return (
@@ -90,7 +93,7 @@ export function ToolRunningCard({
<span className="kind">{kind}</span>
<span style={{ color: "var(--fg)", fontWeight: 500 }}>{name}</span>
<span className="grow" />
<span className="pill-tag run">running</span>
<span className="spin-meta" role="img" aria-label={t("live.running")} title={t("live.running")} />
<span className="timer">{fmtElapsed(elapsedMs)}</span>
</div>
{logLines && logLines.length > 0 ? (
@@ -117,18 +120,21 @@ export function ToolRunningCard({
}
export function PendingUserMsg({ text, elapsedMs }: { text: string; elapsedMs: number }) {
useLang();
return (
<div className="msg user">
<div className="avatar">YOU</div>
<div className="body">
<div className="who">
<span className="name">你</span>
<span className="time">{(elapsedMs / 1000).toFixed(1)}s ago</span>
<span className="name">{t("live.you")}</span>
<span className="time">
{t("live.secondsAgo", { seconds: (elapsedMs / 1000).toFixed(1) })}
</span>
</div>
<div className="msg-text user-pending">{text}</div>
<div className="user-status">
<span className="spin" />
<span>已送达 · 等待 agent 响应</span>
<span>{t("live.deliveredWaiting")}</span>
</div>
</div>
</div>
Loaded 100 of 366 files, more files were not shown because too many files have changed in this diff. Show more