Compare commits

...
106 Commits
Author SHA1 Message Date
reasonix 50b1bf4a50 fix(cli): don't crash session hydration on a tool_call missing its function
Resuming a session (reasonix code) whose persisted assistant message holds a
tool_call without a function — e.g. a streaming delta saved before its function
landed — threw "Cannot read properties of undefined (reading 'arguments')" in
hydrateCardsFromMessages, taking down the whole UI at startup. Skip the partial
tool_call; the orphaned tool-output message already no-ops when its card is
absent.
2026-06-03 03:05:05 -07:00
esengine 8907573f09 docs(v1): mark the TypeScript line as legacy, point to the Go rewrite (main-v2) 2026-05-31 01:29:19 -07:00
512e3dd66e chore(cache): localize /status cache-detail line and make tool sort locale-independent (#2320)
Follow-up polish to the cache-efficiency guardrails (#2314):

- /status "cache detail" line was hard-coded English; route it through i18n
  (statusCacheDetail / statusCacheChurn) so it matches every other status row.
  EN + zh-CN translated; ja/de/ru inherit EN like the rest of their
  observability block.
- sortToolSpecs used localeCompare, which is locale-sensitive and could let
  the host locale reshuffle the serialized tool prefix and reintroduce the
  very cache churn the sort is meant to prevent. Switch to a stable
  codepoint compare. No change for ASCII tool names (all existing tests pass).

Co-authored-by: yhh <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 17:44:02 -07:00
Sivan fccea10236 Cache Efficiency Guardrails and Diagnostics / 缓存效率守卫与诊断 (#2314)
* feat(cache): add cache efficiency guardrails

* fix(cache): hash sent tool snapshot for diagnostics
2026-05-29 17:29:40 -07:00
HUQIANTAOandHUQIANTAO 72e89ff7c3 fix(desktop): clean up animations — remove breathing lights, fix broken keyframes (#2267)
- Fix undefined @keyframes 'caret' → 'stream-caret' (shell running caret was broken)
- Fix undefined @keyframes 'fade' → 'fade-in' (about modal mask was broken)
- Remove shimmer animation from 'Running' label and ThinkingPill text (double animation with dots)
- Remove pulse-soft breathing from PendingUserMsg background
- Remove yolo-pulse breathing from YOLO mode border
- Unify duplicate @keyframes shimmer-bg into shim
- Remove dead keyframes: pulse-soft, yolo-pulse
- Wire tab status dots to actual busy/idle state (was hardcoded to 'running')

Co-authored-by: HUQIANTAO <[email protected]>
2026-05-29 02:26:38 -07:00
nianyi(likai) d83a0bd630 fix(web): apply 15s timeout to all search engines (#2252 follow-up) (#2259)
#2252 added a 15s AbortSignal.timeout to searchBing; the other engines
(Searxng, Metaso, Baidu, Tavily, Perplexity, Exa, Ollama, Brave) still
relied on opts.signal only and could hang indefinitely.

Extract searchSignal() helper that combines the caller's abort signal with
a per-request SEARCH_TIMEOUT_MS cap, and apply it uniformly.
2026-05-29 01:56:24 -07:00
Porun b1f7d1ab15 feat(weixin): render ilink qr in terminal (#2246) 2026-05-29 01:43:58 -07:00
YapuandClaude Opus 4.7 bd98ef3fe3 fix(web): 给 searchBing 添加 15s 超时防止无限挂起 (#2252)
Bing 搜索缺少 AbortSignal.timeout(),响应慢或被墙时请求会无限挂起,
导致 web search 工具卡死在 research 步骤。添加 15 秒超时并与外层 signal 组合。

fix(web): add 15s timeout to searchBing to prevent infinite hangs

Bing search lacked AbortSignal.timeout(), causing infinite hangs on slow
responses or blocked connections. This left the web search tool stuck in
the research step. Add a 15s timeout combined with the outer signal.

Closes #2241

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-29 01:10:12 -07:00
621aad4d87 fix(desktop): increase sidebar minimum drag width from 160px to 200px (#2248)
侧边栏拖拽最小宽度从 160px 调整为 200px,防止拖拽时「新建会话」文字被隐藏(CSS 在 ≤190px 时隐藏文字)。

Increase sidebar minimum drag width from 160px to 200px to prevent the "New Chat" text from being hidden when dragging (CSS hides text at ≤190px container width).

Closes #2166

Co-authored-by: Claude Opus 4.7 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-05-29 01:09:51 -07:00
J. F. Zhang 4e4faf2eae fix(desktop): persist prompt history across sessions (#2068)
* fix(desktop): persist prompt history across sessions

* fix(desktop): use session logs for prompt history
2026-05-29 00:56:56 -07:00
nianyi(likai) c0f2320224 feat(session): add /session-persist to toggle auto-resume on launch (#2238) (#2243)
Users who want a fresh session each time (e.g. agents calling reasonix
in loops) had to manually pass --no-session every invocation. Add:

- config.autoResumeSession (default true = existing behaviour)
- /session-persist on|off slash command to toggle and persist the setting
- resolveSession() respects the new flag: when false, skips the 'default'
  session fallback so each launch starts with a new timestamped session
2026-05-29 00:53:42 -07:00
TulgaandTulga Battogtokh b58c505c84 fix: restore browser dashboard file mentions (#2242)
Co-authored-by: Tulga Battogtokh <[email protected]>
2026-05-29 00:51:31 -07:00
QiuYa e57ca88473 feat(cli): split-pane plan panel in TUI (#2227)
## What
New unified \`PlanPanel\` right-side component replacing the modal-overlay pattern.
Covers five plan states: pending / refining / executing / checkpoint / revising.

Layout changes:
- App.tsx: side-by-side flex layout — left conversation column at 35% width,
  right PlanPanel at flexGrow=1 when plan is active
- LiveActivityArea: new \`suppressPlanLiveRow\` prop avoids duplicate plan card
- Refine flow: inline plan body display + text input instead of separate modal

## Why
The current modal-overlay pattern replaces the composer area with plan modals,
hiding the conversation. A persistent right panel keeps the conversation visible
while showing plan detail, and the inline refine flow lets users reference the
plan body while typing feedback instead of switching to a blank text input.

## How to verify
1. Start plan mode, let model submit a plan — panel opens on right
2. Click refine — plan body stays visible, input appears below
3. Click approve — panel persists showing step progress
4. Complete all steps — panel auto-closes
5. Confirm conversation still scrollable, composer accessible at bottom-left
2026-05-29 00:20:50 -07:00
e3b31be66c fix(ui): apply themed background to input area and app surface (#2182)
The theme system defines SURFACE.bg and SURFACE.bgInput but no
component applied them — the entire UI rendered on the terminal's
default background, causing:
- Input text invisible when terminal bg differs from theme
- No visual separation between card stream and input area
- Theme surface colors completely unused

Add backgroundColor to three layout containers:
- App root Box → SURFACE.bg (app canvas)
- ComposerArea wrapper → SURFACE.bgInput (input dock)
- PromptInput bordered Box → SURFACE.bgInput (input field)

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 23:47:45 -07:00
Porun f3082b91d0 feat(weixin): add trusted iLink channel (#2240)
* feat(weixin): add trusted iLink channel

* fix(weixin): constrain ilink endpoint
2026-05-28 23:42:08 -07:00
Xy 08eaac5ed4 fix(loop): keep headless NEEDS_PRO escalation one-shot (#2236) 2026-05-28 23:36:51 -07:00
nianyi(likai) 744d1f93eb feat(permissions): global shell allowlist that cascades with per-project (#2059) (#2091)
Adds a top-level `shellAllowedGlobal` config list, auto-approved across ALL
projects and merged (union) with the per-project allowlist when deciding
whether a shell command needs approval. Manage it via a `--global` (or `-g`)
flag on the existing `/permissions` subcommands:

  /permissions add --global <prefix>
  /permissions remove --global <prefix-or-N>
  /permissions clear --global confirm
  /permissions list                        # now shows a Global section above Project

The flag is parsed as a flag (not a positional keyword), so command prefixes
like `g` or `global` remain manageable from the slash UI.

`shellAllowedGlobal` is registered in `STRING_ARRAY_FIELDS` so a malformed
hand-edited value is sanitized on read instead of crashing the merge in
`code/setup.ts`'s `extraAllowed` getter.

Config accessors (load/add/remove/clearGlobalShellAllowed) mirror the
project ones; EN/zh-CN/ja/de strings added (ru falls back to EN).
2026-05-28 23:34:46 -07:00
nianyi(likai) 893c1c7f9f fix(desktop): follow streaming output when user is at bottom (#2159) (#2235)
The scroll-follow useEffect had no dependency array, so it ran on every
render but did not check atBottomRef — meaning it would try to re-pin
even when the user had scrolled up to read earlier content, and conversely
the missing dep array caused React to skip the effect in some batched
renders during fast streaming.

Add a guard on atBottomRef.current and a proper [messageItems] dependency
so the effect only fires when new messages arrive and only scrolls when
the user is already pinned to the bottom.
2026-05-28 23:33:29 -07:00
nianyi(likai) e95c93f2dc feat(mcp): expand ${VAR} env vars in mcpServers header values (#2148) (#2233)
Claude Code .mcp.json files commonly use ${TOKEN_NAME} or ${env:TOKEN_NAME}
in header values (e.g. Authorization: Bearer ${API_KEY}). Reasonix was
passing these through as literal strings, making migration from Claude Code
require manual header editing.

Add expandEnvInRecord() which resolves ${VAR} and ${env:VAR} patterns in
header records at normalization time. Unset variables are left as-is.
2026-05-28 23:33:06 -07:00
YHHandyhh bdbafa7b7c fix(test): skip #2137 symlink skill tests on Windows (EPERM without admin) (#2239)
The two symlinked-skill tests added in #2137 — "loads skills from
symlinked directories (#2104)" and "skips broken symlinks gracefully" —
call symlinkSync with no Windows guard, so `npm run verify` fails with
EPERM for contributors on Windows that lack Developer Mode / admin. CI's
Windows runner has the privilege so it slipped through. Add
`.skipIf(process.platform === "win32")`, matching the #2124 symlink tests
already in this file.

Co-authored-by: yhh <[email protected]>
2026-05-28 23:25:45 -07:00
nianyi(likai) 868b61b4ac feat(skills): inline references/ files at load time for Anthropic Skills compat (#2214) (#2230)
Skills following the Anthropic Skills spec ship a references/ subdirectory
with depth material. Reasonix only loaded SKILL.md, leaving [[references/...]]
wikilinks as dead text and making the depth content invisible to the model.

Append each references/*.md file to the skill body at load time under a
'## Reference: <slug>' header (sorted alphabetically). This follows Option A
from the issue: zero protocol overhead, all depth material available immediately.

Only dir-layout skills (those with a SKILL.md) can have a sibling references/;
flat <name>.md skills are unaffected.
2026-05-28 22:50:01 -07:00
nianyi(likai) 991ad37959 fix(proxy): hint for corporate firewalls where DeepSeek requires proxy (#2215) (#2229)
By default api.deepseek.com is in NO_PROXY so clash/v2ray users avoid 403s
from shared exit IPs. Corporate users whose firewall blocks direct egress get
a silent 'fetch failed' with no guidance.

Add a stderr hint when api.deepseek.com is bypassed, pointing users to
REASONIX_PROXY_DEEPSEEK_DIRECT=0 or bypassDeepSeekDirect:false in config.
2026-05-28 22:43:28 -07:00
nianyi(likai) 4811857f6b fix(session): warn when system prompt changes on session resume (#2212) (#2228)
When REASONIX.md or a global memory file is edited between sessions, the
system prompt is rebuilt on next launch and the resumed session gets a full
cache miss with no explanation. This adds a one-time warning on resume
when the system prompt fingerprint differs from what was saved.

- session.ts: add hashSystemPrompt(); add systemFingerprint to SessionMeta
- App.tsx: save fingerprint on every turn; compare on resume and warn
- i18n/types.ts + EN + zh-CN: systemPromptChanged + systemPromptChangedDetail
2026-05-28 22:43:05 -07:00
QiuYa 1739231050 fix(desktop): abort agent loop on plan_response cancel (#2225)
## What
Add abortTurn(tab) in the plan_response cancel handler.

## Why
When the user cancels a plan, the agent loop continues running. The
model receives the cancellation signal via pauseGate but the in-progress
turn is not aborted, causing the model to re-submit the plan or continue
generating output that the user already rejected.

## How to verify
1. Start a session with plan mode enabled
2. Let the model submit a plan via submit_plan
3. Click Cancel in the plan approval
4. Confirm the agent loop stops immediately — no further streaming or
   tool calls from the rejected turn
2026-05-28 22:42:43 -07:00
CVEngineerandwufengfan 0a7f9ed4a1 fix(desktop): Virtuoso initial scroll to bottom + JumpBar onScrollToTurn callback (#2056 hotfix) (#2073)
* feat(desktop): collapse intermediate reasoning + group consecutive tool calls

* fix: open tool cards inside group when expanded

* fix: independent collapse state per tool group

* fix: lift ToolGroupShell to module scope, preserve shell live-streaming, rename defaultOpen prop

* perf(desktop): replace messages.map with backwards-walk index replace in streaming handlers

* perf(desktop): reduce transcript window 120->60 to halve rendered message nodes

* perf(desktop): virtual list with Virtuoso for message thread — only render visible messages

* Revert "perf(desktop): reduce transcript window 120->60 to halve rendered message nodes"

This reverts commit 6d90d659ed.

* perf(core): windowed AppendOnlyLog (200 msg) with lazy file fallback; add readTailMessages for backward JSONL scan

* style: fix biome formatting + comment cleanup

* fix: use fstatSync after openSync to avoid TOCTOU race (CodeQL)

* fix: sort import order (biome)

* fix: remove existsSync before openSync (TOCTOU race, CodeQL)

* fix: toMessages() returns window only, add toFullHistory(); restore length to entries.length; update callers

* style: method chain formatting

* fix: add toFullHistory mock to ctx-breakdown test

* fix: replace useAutoScroll with Virtuoso followOutput + scrollToIndex for scroll handling

* fix: proper Virtuoso height via CSS height:0 + flex; move jump button outside thread; use position:fixed instead of sticky

* Revert "fix: proper Virtuoso height via CSS height:0 + flex; move jump button outside thread; use position:fixed instead of sticky"

This reverts commit c061398079.

* Revert "fix: replace useAutoScroll with Virtuoso followOutput + scrollToIndex for scroll handling"

This reverts commit ffd48d60b8.

* fix: replace useAutoScroll with Virtuoso followOutput + scrollToIndex; fix JumpBar onScrollToTurn callback

* fix: initial scroll to bottom on session load with proper dep

* fix: position absolute Virtuoso inside relative thread to fix height calc in flex layout

* fix: move jump button outside .thread for correct positioning

* fix: add minHeight 0 to Virtuoso; set thread height 100%

* fix: use Virtuoso initialTopMostItemIndex + scrollerRef for scroll-to-bottom and persistence

* fix: add key to Virtuoso assistant item for correct DiffStats re-render

* fix: initialTopMostItemIndex for Virtuoso initial scroll to bottom

* restore to upstream/main with single initialTopMostItemIndex fix

* fix(jumpbar): onScrollToTurn callback uses Virtuoso scrollToIndex instead of querySelector (broken with virtual list)

* fix: replace Virtuoso followOutput with atBottomRef + scrollToIndex for reliable streaming auto-scroll

* Revert "fix: replace Virtuoso followOutput with atBottomRef + scrollToIndex for reliable streaming auto-scroll"

This reverts commit 6060719365.

* fix: move error/warning render + pending-approval Footer back inside Virtuoso

* Revert "fix: move error/warning render + pending-approval Footer back inside Virtuoso"

This reverts commit a2477d7c7b.

* sync hotfix

---------

Co-authored-by: wufengfan <[email protected]>
2026-05-28 22:31:13 -07:00
Yapu b5f884a61d fix(ink): scroll jitter on rapid mouse wheel (#2076) (#2095)
* fix(ink): add dependency arrays to useBoxMetrics and useTerminalViewport to prevent scroll jitter (#2076)

- useBoxMetrics: add [ref.current] deps so measurement only runs when element changes
- useTerminalViewport: add [elementRef.current, terminalSize] deps to skip expensive parent-chain walk on every render
- App: memoize TerminalSizeContext.Provider value to keep reference stable

During rapid mouse wheel scrolling (10-20 events/100ms on Windows), the
missing dependency arrays caused each wheel event to trigger 3-5 cascading
renders (30-80 renders/100ms), producing visible text jitter.

* fix(ink): address PR #2095 review — remove ref.current anti-pattern from hook deps

- useBoxMetrics: remove [ref.current] dep array (React anti-pattern);
  add 16ms throttle to limit measurement frequency during rapid scrolling
- useTerminalViewport: remove elementRef.current from deps, keep only
  [terminalSize] as the reactive dependency
- Add regression test verifying useBoxMetrics re-measures on parent resize
2026-05-28 22:29:41 -07:00
DawnCloud1213 5f03643efd feat(prompt): prefer MCP search tools over built-in defaults (#2066)
* feat(prompt): prefer MCP search tools over built-in defaults

Add a `# Tool Selection` section to the default system prompt that
instructs the model to prefer MCP-provided tools (e.g. Exa) over
the built-in web_search/web_fetch. MCP tools typically offer
higher quality — semantic search over keyword matching, date and
domain filtering. The instruction includes a fallback to built-in
tools when MCP tools are unavailable.

This is a low-risk change: one paragraph of prompt text, no
runtime overhead, and fully backward-compatible.

* feat(prompt): prefer MCP search tools over built-in defaults

* revert: cli/index.ts back to upstream original

* feat(prompt): add MCP tool selection guidance to code system prompt
2026-05-28 22:29:19 -07:00
249e18f425 fix(server): convert sync fs I/O to async across server API and session memory (#2219)
* fix(server): convert sync fs I/O to async across server API layer and session memory

Replace blocking readFileSync/writeFileSync/readdirSync/statSync/existsSync
with non-blocking readFile/writeFile/readdir/stat from node:fs/promises in
all server API handlers and session.ts. This prevents the event loop from
blocking on disk I/O during concurrent HTTP requests.

- assets.ts: async cached reads (resolveAssetDir stays sync for module load)
- skills.ts, memory.ts, hooks.ts, browse.ts, project-tree.ts, files.ts,
  checkpoint-diffs.ts, health.ts, sessions.ts: async handlers
- session.ts: added async variants (listSessionsAsync, deleteSessionAsync,
  loadSessionMessagesAsync, readTailMessagesAsync, etc.)
- atomic-write.ts: added async atomicWrite alongside sync atomicWriteSync
- index.ts: await renderIndexHtml/serveAsset in dispatch

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* style: fix biome formatting and lint errors

- files.ts: add explicit Stats type annotation (noImplicitAnyLet)
- browse.ts: organize imports
- assets.ts, skills.ts, memory.ts, sessions.ts, session.ts,
  atomic-write.ts: biome format (line length, trailing commas)

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(test): await async serveAsset/renderIndexHtml in dashboard smoke tests

These functions now return Promises after the sync→async conversion,
so the test calls must await them.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* style: remove banner separator comments in session.ts

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix: eliminate TOCTOU race in memory file read

Read the file directly instead of stat-then-read to avoid a window
where the file could be deleted between the two calls. CodeQL flagged
this as a potential file system race condition.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 22:24:29 -07:00
9b1e01bea9 fix(ui): replace ANSI dim with explicit FG.faint colors; WCAG contrast fixes (#2221)
* fix(theme): meet WCAG AA contrast for fg.faint and fg.meta across all themes

fg.faint failed WCAG on all 4 dark themes (ratios 1.63-2.56:1, need 4.5:1).
fg.meta failed on midnight (2.35-2.91:1) and was borderline on others.
Dark theme bgElev (#151d2f) was too close to bgInput for surface distinction.

Changes per theme:
- dark: meta #778294→#9aa5b5, faint #4d5666→#8791a3, bgElev #151d2f→#1c2844
- light: meta #6b7280→#5c6371, faint #9ca3af→#666d7b
- midnight: meta #565f89→#9da5bb, faint #414868→#8a92a8
- deep-blue: meta #808080→#909090, faint #606060→#8c8c8c
- high-contrast: already passes, no changes

* fix(ui): replace dim with explicit colors in ReasoningCard and ToolCard

- ReasoningCard: replace `dim` prop with explicit `FG.sub` color
- ToolCard: replace conditional `dim` with explicit `FG.faint`/`errColor`

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(ui): replace all remaining ANSI dim with explicit FG.faint colors

Replace every remaining `dim` prop across 22 UI components with explicit
`color={FG.faint}` (or conditional color logic where dim was used as a
boolean toggle). The ANSI `dim` attribute (SGR 2) halves brightness of
the foreground color, causing near-invisible text on dark terminals
where the default foreground is already low-contrast.

Components fixed:
- mcp-browse, AtMentionSuggestions, DiffApp, EditConfirm
- McpBrowser, McpHub, McpMarketplace
- PlanReviseConfirm, PlanStepList
- RecordView, ReplayApp, Select, Setup
- SlashArgPicker, SlashSuggestions, SplitDiff
- StatsPanel, Wizard, char-bar, ctx-breakdown
- markdown-view (SpanText ambientDim), primitives (Bar, ContextCell)

For conditional dim (e.g. `dim={isDone}`), replaced with conditional
color logic (`color={isDone ? FG.faint : originalColor}`). The Bar
component's `dim` prop was removed entirely — callers now pass the
appropriate color directly.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 22:10:21 -07:00
Sivan e472735144 feat: cache diagnostics v1 — /cache-miss-report, doctor --cache, prefix hash evidence | 缓存诊断 v1 (#2188)
* feat(cache): add prompt cache diagnostics

* fix(cache): hash sent tool snapshot in diagnostics

* perf(cache): memoize prefix diagnostic hashes
2026-05-28 22:01:59 -07:00
Porun 583054feaf fix(telegram): bind callback confirmations (#2222)
* fix(telegram): bind callback confirmations

* docs(telegram): explain remote setup
2026-05-28 22:00:59 -07:00
a6613c6708 fix(i18n): convert ja.ts files to spread-fallback pattern across all three layers (#2216)
- dashboard/src/i18n/ja.ts: rewrite from standalone 1333-line file to spread
  pattern with `...en` fallback; keep only 5 translated sections (app, changes,
  common, settings, chat), remove ~30 English-copy sections
- desktop/src/i18n/ja.ts: add `...en.sectionName` spreads to all 30+ sections
  including nested objects (app.toast, app.cmd, app.skill, app.titlebar, etc.)
- src/i18n/JA.ts: add `import { EN } from "./EN.js"`, `...EN` top-level spread,
  and `...EN.sectionName` spreads to all 46+ sections including deeply nested
  objects (slash.*, handlers.*, planFlow.*, ui.*, wizard.themeCaption,
  modelPicker.effortDesc)

Missing keys now fall back to English instead of returning undefined.

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 21:59:50 -07:00
88114f2f78 perf(i18n): replace regex in t() with replaceAll (#2176)
Replace per-key `new RegExp(\`\\{${k}\\}\`, "g")` with native
`replaceAll(\`{${k}}\`, String(v))` — avoids regex construction overhead
on every translation lookup. ReadConfig cache already merged in #2162.

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 21:49:36 -07:00
Sivan 0a5bc5402b feat(tools): add AST-aware delete_symbol (#2191) 2026-05-28 21:48:24 -07:00
df5776c5a4 feat(i18n): add Russian (ru) locale to desktop and dashboard (#2217)
- Create desktop/src/i18n/ru.ts and dashboard/src/i18n/ru.ts using the
  spread-fallback pattern (...en), so all keys fall back to English
- Register "ru" in both index.ts: Lang type, SUPPORTED_LANGS, LANG_LABELS
  ("Русский"), dicts, and detectDefault (navigator.language starts with "ru")

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 21:35:39 -07:00
Zeng Renchao 2e7ff04711 fix(desktop): resolve FilePill click path against session-tracked files (#2186)
* fix(desktop): resolve FilePill click path against session-tracked files

When clicking a file reference (FilePill) in the desktop chat view, the
path was resolved by blindly joining workspaceDir + displayText. This
fails when the AI writes a short path in conversation (e.g. 'config.yaml')
but the actual file lives in a deeper subdirectory or outside the workspace.

Now resolveAgainstWorkspace first attempts suffix matching against
sessionFiles — paths tracked from actual tool call arguments (read_file,
edit_file, write_file, multi_edit). If a match is found, the tracked path
is used as the base; if absolute it's used directly, if relative it's
resolved against the workspace. Falls back to the old behavior when no
match is found.

Also excludes src-tauri/target/ from Vite's file watcher to prevent
EBUSY crashes on Windows.

* fix(desktop): positional matching for ambiguous file pill resolution

When multiple session files have the same basename (e.g. a/1.yaml and
b/1.yaml), suffix matching alone cannot distinguish them. Add positional
matching: the Nth FilePill in the conversation text maps to the Nth
tool-call path in currentToolPaths (per-message context).

Also pass currentToolPaths through WorkspaceContext so each assistant
message's text segments can resolve file references against the tool
calls that happened in the same message.
2026-05-28 21:34:35 -07:00
nianyi(likai) 0711fada71 fix(skills): follow symlinked entries in SkillStore.readEntry (#2104) (#2124)
`readEntry` used `Dirent.isDirectory()/isFile()` to classify entries, but
both return false for symlinks even when the target is a directory or file
— so a `skills.sh`-style `~/.agents/skills/` layout that symlinks every
skill into a central repo silently saw zero entries with no error or
warning. Resolve symlinks via `statSync` to get the real target type
before branching; a broken symlink is skipped silently instead of
aborting the whole scan.
2026-05-28 21:31:59 -07:00
7704b77344 perf: skip healing pipeline and token count when log is unchanged (#2180)
Add a dirty flag to AppendOnlyLog that tracks mutations (append, extend,
compactInPlace, initWindow). The two expensive full-scan consumers —
healActiveLogBeforeSend() and getLogTokens() — now check+clear this flag
and return a cached result when the log hasn't changed since the last call.

This eliminates 3-4 full array passes with tokenization of every tool-call
argument JSON on every agent-loop iteration when no new messages were
appended — the common case between the model's response and the next
tool-call dispatch.

Changes:
- AppendOnlyLog: add _dirty flag, consumeDirty() method, set on mutations
- CacheFirstLoop: cache healActiveLogBeforeSend() result, skip when clean
- ContextManager: cache getLogTokens() result, skip when clean

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 21:29:53 -07:00
Sivan 02d0ece98a feat: expand Reasonix theme palettes / 扩展 Reasonix 主题配色 (#2134)
* feat: expand Reasonix theme palettes

* fix: align localized CLI theme choices

Show localized theme labels while preserving stable theme IDs for config and slash commands.

Expose the same public theme list in CLI, dashboard, and desktop, while keeping legacy theme aliases accepted.

* fix: preserve resolved theme picker selection

Use the normalized visible theme as the picker initial value so legacy saved values such as dark select Graphite instead of falling back to auto.

* fix: keep porcelain accent additive
2026-05-28 21:08:58 -07:00
Porun 17bc1aa4ab feat(telegram): improve remote rendering and ingress (#2213)
* fix(telegram): preserve Markdown rendering in replies

* feat(telegram): use buttons for remote confirmations

* feat(telegram): register slash commands with bot

* test(telegram): update expectations for remote commands

* fix(telegram): harden remote ingress
2026-05-28 21:04:51 -07:00
Sivan c3670c973c feat(cli): add ssh remote dry-run RFC (#2192) 2026-05-28 20:55:03 -07:00
nianyi(likai) 96199134e7 feat(loop): /max-tokens slash command to cap output tokens per turn (#2196) (#2207)
Adds /max-tokens <N|off> slash command and persisted maxOutputTokens config
field so users can limit output tokens per turn, preventing runaway reasoning.

- config: maxOutputTokens field, load/saveMaxOutputTokens accessors
- loop: stored on loop instance, passed as max_tokens to chat and stream calls
- loop/streaming.ts: maxTokens forwarded through StreamModelOptions
- slash: new /max-tokens setup-group command with handler + i18n (EN/zh-CN/de)
- App.tsx: seeded from config on loop construction
2026-05-28 20:50:44 -07:00
Sivan 55fbb3338d feat(mcp): canonicalize tool schemas for cache stability (#2189) 2026-05-28 20:50:22 -07:00
4980ecdf7f fix(ui): theme Pill component colors via ThemeTokens proxy (#2183)
Replace hardcoded PILL_SECTION, PILL_PATH, PILL_MODEL constants with
theme-aware functions (pillSection, pillPath, pillModel) that read from
a new `pill` field in ThemeTokens. This ensures Pill components respect
the active theme instead of using static colors that ignore user theme
choices.

Adds `pill()` derivation function in tokens.ts following the existing
CARD proxy pattern, and exports PILL via proxyTokens().

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 20:49:39 -07:00
jimmy 7336a112b1 fix: classify Baidu and Metaso HTTP errors before JSON parse (#2206) 2026-05-28 20:47:53 -07:00
TaoMu 6b5ae1ea40 fix(lifecycle): show plan suggestion once per session (#2208) 2026-05-28 20:46:31 -07:00
Sivan 7cf019559d feat(tools): add anchor-based delete_range (#2190) 2026-05-28 20:43:36 -07:00
Sivan 84fb462b6d feat: enrich MCP features with cc-switch migration / 丰富 MCP 功能,支持 cc-switch 迁移 (#2135)
* feat: enrich MCP features with cc-switch migration

* fix: address mcp review feedback

* fix: resolve mcp preflight cwd paths

* test: cover desktop MCP import reconciliation

* fix(desktop): refocus MCP tools tab on repeated status
2026-05-28 20:40:31 -07:00
TaoMu 96ae010fac fix(ui): show mention filenames before paths (#2170) 2026-05-28 19:52:15 -07:00
nianyi(likai) 28ea853730 fix(tests): sync assertions broken by #2173 and #2168 (#2200)
- auto-git-rollback: write_file output changed from 'wrote N chars' to
  'edited ... (M→N chars)' unified-diff format (#2173); update regex.
- slash + ui-slash-suggestions: Telegram /telegram command added by #2168
  brings the release command count from 43 to 44; update both assertions.
2026-05-28 19:24:30 -07:00
wade1999 119d880575 fix(qq): polish busy ingress and setup reminders (#2178) 2026-05-28 19:00:10 -07:00
Bernardxu123andBernardxu123 745a7e54c8 fix(desktop): render write_file results as DiffCard in thread view (#2177)
Co-authored-by: Bernardxu123 <[email protected]>
2026-05-28 18:48:46 -07:00
Bernardxu123andBernardxu123 8131e24102 feat(cli): unified diff display for edit_file / write_file / multi_edit (#2173)
Add configurable diff display mode for file-editing tools in CLI,
addressing issue #1251 (edit_file/write_file diff without git).

## Changes

### Tool output enhancement
- write_file now returns unified diff format matching edit_file:
  - New file: \created path (N chars)\
  - Overwrite: \edited path (old→new chars)\ + unified diff
  - Large file (>5000 lines / >100KB): summary only, no diff
- All three edit tools (edit_file, write_file, multi_edit) now produce
  consistent output that Desktop's parseEditResult can also parse

### /diff command
- \/diff\ — show current mode (default: summary)
- \/diff full\ — render unified diff with +/- colors in ToolCard
- \/diff summary\ — show path + stats line (edited path +N -M)
- \/diff none\ — show only tool name and checkmark

### Config
- New DiffDisplay type in config.ts (\summary | full | none\)
- Persisted to ~/.reasonix/config.json via /diff command
- loadDiffDisplay / saveDiffDisplay functions

### ToolCard renderer
- In full mode, parse diff from tool output and render with colored
  +/- lines, hunk headers, and file stats in the card meta
- Summary and none modes preserve existing behavior

### Files modified
- src/tools/fs/edit.ts — add generateWriteDiff()
- src/tools/filesystem.ts — write_file uses generateWriteDiff
- src/config.ts — DiffDisplay type + load/save
- src/cli/ui/cards/ToolCard.tsx — diff-aware rendering
- src/cli/ui/slash/handlers/diff.ts — new /diff handler
- src/cli/ui/slash/commands.ts — register /diff command
- src/cli/ui/slash/dispatch.ts — import diffHandlers
- src/i18n/EN.ts — diff handler + slash translations
- src/i18n/zh-CN.ts — Chinese translations

Co-authored-by: Bernardxu123 <[email protected]>
2026-05-28 18:20:19 -07:00
4cf52b3653 perf: eliminate sync I/O from agent loop hot paths (#2162)
* perf: eliminate sync I/O from agent loop hot paths

Three independent cache layers that together remove redundant disk reads
and deep-cloning from the per-iteration fast path:

1. readConfig() mtime cache — 37+ calls per session each did readFileSync
   + JSON.parse; now statSync (~0.005ms) short-circuits on mtime hit.
   writeConfig() invalidates so writes are immediately visible.

2. ImmutablePrefix.tools() — every loop iteration deep-cloned the full
   tool-spec array via structuredClone (~hundreds of KB for 20+ tools).
   Now returns a frozen shallow copy, invalidated only by addTool/removeTool.

3. AppendOnlyLog.toFullHistory() — buildMessages() calls this 2-3x per
   iteration; when the log window doesn't cover full history, each call
   did sync openSync/readSync/closeSync in 64KB chunks. Now caches the
   parsed result keyed by totalLength, invalidated by append/compactInPlace.

* fix(config): eliminate TOCTOU race in readConfig via fd-based atomic read

Use openSync → fstatSync(fd) → readFileSync(fd) → closeSync so the
mtime check and content read operate on the same file descriptor,
removing the CodeQL-flagged race window.

Also:
- Add immutability + mtime caveat comments on _configCache
- Type tools() return as readonly ToolSpec[]; widen ChatRequestOptions
  and StreamModelOptions to accept readonly arrays

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix: use cryptographically random temp file names (CodeQL)

Replace predictable temp file names (based on process.pid, Date.now,
Math.random) with crypto.randomBytes in all 3 atomic-write call sites
to prevent symlink-following attacks on temp files.

Fixes CodeQL "Insecure temporary file" alert on PR #2162.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 18:09:10 -07:00
Porun 7854d42410 feat: add Telegram channel support (#2168) 2026-05-28 18:08:00 -07:00
Sivan 823ae7ffca fix(desktop): keep sessions alive when closing window (#2144)
* fix(desktop): keep sessions alive when closing window

* fix(desktop): make close-to-tray opt-in

* chore: ignore local agent notes
2026-05-28 18:07:37 -07:00
bf69ccbd8c perf: optimize tokenizer hot paths and prompt builder (#2163)
* perf: optimize tokenizer hot paths and prompt builder

Four independent optimizations targeting CPU and allocation pressure in
the token-counting and prompt-formatting pipeline:

1. byteLevelEncode: O(n²) += loop → pre-allocated array + join("")
   For a 10KB text chunk this eliminates ~10K intermediate string copies.

2. countTokens: skip number[] allocation — encode(text).length built a
   full token-ID array just to read .length. New version counts inline,
   saving ~60KB of transient heap per 50KB input string.

3. formatDeepSeekPrompt: += string concatenation → parts[] + join("")
   The prompt can be 100K+ chars in long sessions; += copies the entire
   accumulator on each iteration, making it O(n²). Array push + join is O(n).

4. cachedBoundedTokens: skip caching strings >10KB in the content-token LRU.
   Without this guard, 4096 entries of 50KB+ tool outputs can retain 200MB+
   of string keys. Small strings (the common case) still cache normally.

* test(tokenizer): pin countTokens ↔ encode equivalence across a corpus

Add a regression test that asserts countTokens(x) === encode(x).length
for 17 inputs covering: empty string, plain ASCII, CJK, emoji (surrogate
pairs), special/added tokens, code, JSON, repeated text, whitespace, and
a >10KB blob. This is the safety net that makes the countTokens
duplication (which avoids allocating the full number[] array) safe to
live with — any future tokenizer drift will be caught immediately.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-05-28 18:01:51 -07:00
AtmoOmen a6b32c0638 fix(desktop): composer inline mentions, command chips, and text highlighting (#2143)
* fix(desktop): composer inline mentions, command chips, and text highlighting

- Allow @ and / insertion at any cursor position (not just end-of-text)
  by replacing $ regex anchor with cursor-position-based active-range
  detection via findActiveRange / activeRangeRef.

- Remove redundant chips from non-insertOnly slash commands (they
  execute immediately and the chip added confusion).

- Derive mention/command chips from draft text (useMemo) instead of
  imperative state; remove X dismiss button so chips are read-only
  indicators that always match the text.

- Add textarea overlay (composer-backdrop) for @path / /cmd
  highlighting via highlightTokens + transparent textarea technique.

- Add onSelect handler so clicking or arrowing into an existing @word
  or /word opens the popup for inline editing.

- Sync backdrop scroll position with textarea via onScroll handler.

* fix(desktop): truncate long chip labels to filename, themed scrollbar

* fix(desktop): hide textarea scrollbar to keep backdrop alignment
2026-05-28 17:54:10 -07:00
TaoMu 58b01976ae fix(tools): enforce auto git rollback checkpoints (#2169)
* fix(tools): enforce auto git rollback checkpoints

* fix(tools): require explicit rollback marker
2026-05-28 17:46:32 -07:00
TaoMuandReasonix Test 1bcec032a6 fix(cli): commit settled cards to native scrollback (#2151)
* fix(cli): commit settled cards to native scrollback

* fix(cli): freeze verbose state for static history

---------

Co-authored-by: Reasonix Test <[email protected]>
2026-05-28 17:46:10 -07:00
Yapu 47f6ba2fa5 fix: batch store notifications to prevent React max update depth (#2123) (#2133)
* fix: batch store notifications to prevent React max update depth (#2123)

During rapid streaming, dozens of chunks arrive within a single
event-loop drain. Each dispatch() synchronously notified all
useSyncExternalStore listeners, causing React to re-render on
every single token. When renders couldn't keep up with the
streaming speed, nested updates exceeded React's 50-update limit
and threw 'Maximum update depth exceeded'.

Defer state-listener notification via setTimeout(0) so all
synchronous dispatches within the same tick coalesce into a single
notification burst. Event listeners (non-React) are still notified
synchronously for transcript/logging consumers.

Closes #2123

* fix: correct misleading RAF comment, add store batch-notify test

- Replace 'RAF-batched' with 'Macrotask-batched' in store.ts comment
  (code uses setTimeout(0), not requestAnimationFrame)
- Add focused test covering dispatch→stateListeners batching path:
  state listeners coalesced to one macrotask, event listeners remain
  synchronous per-dispatch
2026-05-28 17:41:13 -07:00
Yapu 03a3ae2a58 fix(ink): reduce VS Code terminal lag by throttling drain and debouncing damage (#2080) (#2130)
* fix(ink): reduce VS Code terminal lag by throttling drain and debouncing damage (#2080)

- Increase scroll drain interval from 4ms (~250fps) to 16ms (~60fps) to
  stop overwhelming xterm.js single-threaded ANSI parser
- Only trigger full-screen damage on selection/highlight TRANSITION frames
  (activate/deactivate), not every frame while continuously active, cutting
  ~50% diff work in VS Code terminal
- Coalesce microtask queueing to prevent leading+trailing throttle edges
  from double-firing onRender ~0.1ms apart (acknowledged jank in comments)

* fix(ink): remove no-op selTransition/hlTransition change per review

Keep drain interval fix (4ms→16ms) and microtask coalescing.
Remove selection/highlight transition tracking which was a no-op:
prevFrameContaminated already forces full-screen damage every frame
while selection/highlight is active, making the transition tracking
redundant.
2026-05-28 17:40:50 -07:00
AtmoOmen 00ae400175 fix(desktop): auto-switch to new session after deleting active session (#2127)
* fix(desktop): auto-switch to new session after deleting active session

When the user deletes the currently-active session via the sidebar,
the UI remained stuck on the now-deleted session. Now the backend
detects when the deleted session is the active one and:

1. Aborts any in-flight turn
2. Mints a fresh session for the workspace (reusing startNewChatInTab)
3. Emits \$session_loaded to the frontend so it navigates to the new session

* fix(desktop): emit ctx_breakdown after deleting active session

Add emitCtxBreakdown(tab) call after $session_loaded in the
session_delete active-session branch, matching the canonical
session_load path. Without it, the frontend context-usage bar
reads 0 until the next turn fires a fresh $ctx_breakdown.
2026-05-28 17:40:28 -07:00
Tianjiu 0678972729 fix(desktop): use native macOS fullscreen zoom (#2164) 2026-05-28 08:21:38 -07:00
AtmoOmen 638f230f40 feat(desktop/dashboard): show cache percentage with 2 decimal places + details on hover (#2158)
- StatusBar: display cache hit ratio to 2 decimal places (e.g. 72.13%)
- StatusBar: show hit/total token details on hover (tooltip)
- StatusBar: cap displayed value at 99.99% unless truly 100.00%
- UsageFull: update pct() helper to return 2-decimal precision
- MetricStrip: accept pre-formatted string for cacheHit prop

Applies to both desktop/ and dashboard/.
2026-05-28 08:12:05 -07:00
jimmy 8e9b7d4689 Add Baidu AI Search backend (#2147) 2026-05-28 08:01:30 -07:00
TaoMu b063966b92 feat(lifecycle): suggest plan rails for high-risk tasks (#2119) 2026-05-28 07:30:58 -07:00
Yapu 55a8f62c4d fix: load skills from symlinked directories (#2104) (#2137)
SkillStore.readEntry() used entry.isDirectory() to detect skill
directories, but Node.js Dirent.isDirectory() returns false for
symlinks. This caused symlinked skill directories to be silently
skipped with no error or warning.

Added isSymbolicLink() check with statSync fallback to resolve
symlinks and verify the target is a directory. Broken symlinks
are safely skipped without throwing.

Closes #2104
2026-05-28 07:09:27 -07:00
Yapu 0b64723128 feat: add contextTokens config for custom model context window (#2075, #2074) (#2085)
Add a user-configurable per-model context-window override so third-party
models (e.g. mimo, qwen) no longer fall back to the 131 K default that
triggers premature auto-compact.

- config: new contextTokens field (Record<string, number>)
- stats: new resolveContextTokens(model) — user config > built-in > 131K
- context-manager / observability / ctx-breakdown / StatusRow: use it
- desktop settings: context-window size input below custom model ID
- desktop protocol: contextTokens in SettingsPatch
- i18n: en / zh-CN / ja / de strings
- tests: 4 new loadContextTokens round-trip + validation cases
2026-05-28 07:02:51 -07:00
nianyi(likai)andYapu 17725071e5 fix(shell): demote allowlisted commands when path args escape workspace (#2081) (#2089) (#2138)
Commands like \`find / -name "xxx"\` bypassed the sandbox because
path arguments were never checked against the workspace boundary.
Only redirect targets and sensitive-path prefixes were validated.

Add a workspace-escape check in \`isAllowed\`: when \`projectRoot\`
is provided, any non-flag argument that resolves to an absolute
path outside the project root demotes the command back to the
confirm gate (user approval required).

Affected patterns:
- find / —name ...
- tree /etc
- grep foo /etc/passwd
- cat /opt/external/file

Workspace-relative commands (find ., tree src, cat README.md)
remain auto-allowed as before.

Co-authored-by: Yapu <[email protected]>
2026-05-28 06:57:53 -07:00
wade1999 2eec3a9f53 feat(desktop): extend QQ remote commands with model and mode controls (#2062)
* feat(desktop): add /retry to QQ remote commands

* feat(desktop): add model and mode QQ commands

* fix(desktop): gate QQ model and plan changes behind busy state
2026-05-28 06:52:33 -07:00
Yapu 5c3ba5313a feat: configurable maxIterPerTurn per-turn iteration cap (#2037) (#2046)
* feat: configurable maxIterPerTurn per-turn iteration cap (#2037)

The main loop had no hard iteration limit — a runaway model could consume unlimited API budget. Added maxIterPerTurn config (default 9) with env override REASONIX_MAX_ITER. Plumbed through all loop construction sites (App, desktop, run, acp). Updated i18n strings.

* fix: address review feedback — default 50, drop per-iter env lookup, add iterLimitReached type

- Change DEFAULT_MAX_ITER_PER_TURN from 9 to 50 (real workloads need 15-30 calls)
- Remove redundant parsePositiveIntEnv() inside the loop; use this.maxIterPerTurn directly
  (loadMaxIterPerTurn() already resolves config > env > default at construction time)
- Add iterLimitReached to i18n types (fixes CI build failure)
- Move cap check before model call to avoid wasting an API request on the cap-hit iter
- Add test for custom maxIterPerTurn option

* fix: reconcile field doc default to 50
2026-05-28 06:52:07 -07:00
Yapu 984e830df4 fix(shell): demote allowlisted commands when path args escape workspace (#2081) (#2089)
Commands like \`find / -name "xxx"\` bypassed the sandbox because
path arguments were never checked against the workspace boundary.
Only redirect targets and sensitive-path prefixes were validated.

Add a workspace-escape check in \`isAllowed\`: when \`projectRoot\`
is provided, any non-flag argument that resolves to an absolute
path outside the project root demotes the command back to the
confirm gate (user approval required).

Affected patterns:
- find / —name ...
- tree /etc
- grep foo /etc/passwd
- cat /opt/external/file

Workspace-relative commands (find ., tree src, cat README.md)
remain auto-allowed as before.
2026-05-27 19:14:39 -07:00
TaoMu abb1088d3d feat(lifecycle): detect explicit plan-first intent (#2070) 2026-05-27 19:06:40 -07:00
lightfrontandXingbo Zhao 08883ce211 fix(i18n): add missing sendFeedback/skipFeedback/cancelFeedbackLabel/refineFeedbackLabel keys to ja locale (#2069)
Co-authored-by: Xingbo Zhao <[email protected]>
2026-05-27 19:04:46 -07:00
reasonix 3e6cee2a6a fix(desktop): drop debug stderr log left in #2025 recentWorkspaces handler
Caused biome lint failure on main.
2026-05-27 18:50:52 -07:00
Harlans 0a83ed0937 feat: 工作目录增加删除按钮 (#2025) 2026-05-27 18:48:22 -07:00
nianyi(likai) 9569024c9b fix(i18n): i18n-ify Ollama errors, add missing ollama to engine hints, flatten ternaries (#1992) 2026-05-27 18:45:34 -07:00
CVEngineerandwufengfan dc4a5dcfcf perf(core): windowed AppendOnlyLog (200 messages) with lazy file fallback (#2057)
* perf(core): windowed AppendOnlyLog (200 msg) with lazy file fallback; add readTailMessages for backward JSONL scan

* style: fix biome formatting + comment cleanup

* fix: use fstatSync after openSync to avoid TOCTOU race (CodeQL)

* fix: sort import order (biome)

* fix: remove existsSync before openSync (TOCTOU race, CodeQL)

* fix: toMessages() returns window only, add toFullHistory(); restore length to entries.length; update callers

* style: method chain formatting

* fix: add toFullHistory mock to ctx-breakdown test

---------

Co-authored-by: wufengfan <[email protected]>
2026-05-27 07:59:53 -07:00
CVEngineerandwufengfan fa66bc4d21 perf(desktop): virtual list with Virtuoso for message thread — only render visible messages (#2056)
Co-authored-by: wufengfan <[email protected]>
2026-05-27 07:59:47 -07:00
J. F. Zhang 7cbedd0b6a fix(release): split macOS dmg by arch (#2050) 2026-05-27 07:24:26 -07:00
Murasameand王锦坤 b13bcbcd77 fix(todo): hide completed items from rendered output (#2049)
Completed tasks were rendered as `[x]` markdown which appears as
strikethrough text — still visible and cluttering the display. Filter
them out so only pending and in_progress items are shown, while the
header line still reports the done count.

Co-authored-by: 王锦坤 <[email protected]>
2026-05-27 07:24:05 -07:00
wade1999 6a5d5b1a13 feat(desktop): support desktop QQ remote commands (#2058) 2026-05-27 07:23:58 -07:00
AtmoOmen 9ec2ecae02 feat(desktop): add feedback text input for plan cancel/refine actions (#2053)
* feat(desktop): add feedback text input for plan cancel/refine actions

Desktop 端 PlanApprovalCard 在用户点击「取消」或「细化」时,
展示文本输入框允许用户写下反馈原因再发送,与 CLI 端保持一致。

- PlanApprovalCard 新增 feedbackMode 状态,点击 Cancel/Refine
  后切换到文本输入模式,支持 Enter 发送、Escape 跳过
- PlanVerdict.feedback 字段现在通过 onRefine/onCancel 回调正确传递
- i18n 新增 sendFeedback / skipFeedback / cancelFeedbackLabel /
  refineFeedbackLabel 键值(en/de/ja/zh-CN)
- 同步更新 modal.planFeedbackPlaceholder / choiceCustomPlaceholder
  文本至四种语言
- 补全 ja/de 缺失的 toolCalls / oneToolCall i18n 键值
- styles.css 新增反馈输入模式下的 approval 卡片样式

* fix(desktop): typo
2026-05-27 07:23:52 -07:00
CVEngineerandwufengfan 7f67bc29ce perf(desktop): replace messages.map with backwards-walk index replace in streaming handlers (#2055)
Co-authored-by: wufengfan <[email protected]>
2026-05-27 07:21:50 -07:00
lightfrontandXingbo Zhao 280ef69cd1 fix(i18n): add missing toolCalls/oneToolCall keys to ja locale (#2048)
Co-authored-by: Xingbo Zhao <[email protected]>
2026-05-27 07:21:44 -07:00
arata dbaefa766c fix(desktop): polish sidebar for narrow widths (#2034)
- New-chat button: preserve #2016 layout, hide shortcut/label at compact widths

- Search row: consistent padding/gap, placeholder ellipsis

- Session items: meta truncation, absolute action buttons
2026-05-27 07:21:38 -07:00
reasonix e19d4df68e fix(i18n): backfill ja keys missed by recent merges
Dashboard + desktop ja.ts missed webSearchEngineBingIntl from #1990;
root JA.ts missed builtinSkills.qq from #2004. Both broke the
required-key check on the strict TranslationSchema, failing CI
typecheck on main.
2026-05-27 05:21:20 -07:00
Bernardxu123andBernardxu123 909b5cced5 fix(desktop): CSP connect-src and Porcelain theme accent color (#2038)
* fix(desktop): add DeepSeek API and updater domains to CSP connect-src

The desktop app's Content Security Policy was missing required domains
in the connect-src directive, causing requests to fail:

- https://api.deepseek.com — DeepSeek API calls
- https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev — updater R2 CDN
- https://github.com — updater GitHub Releases fallback

Changes:
- Added only essential domains (no wildcards, no dev-only localhost)
- Added regression test to prevent future removal

Comparison with PR #2033:
- More restrictive: uses specific R2 URL instead of *.r2.dev wildcard
- Removes unnecessary domains: *.githubusercontent.com, localhost, ws://
- Better security posture for production builds

Closes #2020

* fix(desktop): change Porcelain theme accent color from orange to purple

The Porcelain (light) theme had --accent set to orange (hue 36) instead
of purple (hue 285), causing buttons to appear orange instead of purple.

Changes:
- Changed --accent from oklch(58% 0.18 36) to oklch(56% 0.12 285)
- Changed --accent-soft and --accent-strong to match purple hue
- Now consistent with Midnight theme which uses purple accent

Closes #2022

---------

Co-authored-by: Bernardxu123 <[email protected]>
2026-05-27 05:14:03 -07:00
QiuYa 6960630283 feat(desktop): 计划正文和内存正文支持 Markdown 渲染 (#2042)
- PlanBanner / PlanApprovalCard 中的计划正文和摘要从原始 <div>/<pre>
  改为 <Markdown> 渲染,支持标题、代码块、列表、行内样式等 GFM 语法。
- CtxMemory 中的内存正文(REASONIX.md)从 <pre> 改为 <Markdown> 渲染。
- 上下文面板布局重构为 flex column,内存详情区域自动撑满侧边栏剩余高度。
2026-05-27 05:13:56 -07:00
TaoMu 7042722fb0 fix: keep compact sidebar new chat label visible (#2016)
* fix compact sidebar shortcut fallback

* fix desktop compact sidebar shortcut fallback
2026-05-27 05:13:50 -07:00
nianyi(likai) ad6ecf0dda feat(desktop): grant pending tool/path approval with Enter key (#2015)
The permission approval prompts (ConfirmApprovalCard / PathAccessApprovalCard)
in the desktop and dashboard UIs could previously only be granted by clicking.
Wire the global key handler so Enter grants the pending authorization (run
once) when focus is outside an editable field and no modal is open, mirroring
the TUI where Enter confirms the highlighted choice.

Closes #1962
2026-05-27 05:13:45 -07:00
wade1999 db651d51da feat(skill): add builtin qq helper (#2004) 2026-05-27 05:13:18 -07:00
CVEngineerandwufengfan e54a99df7b feat(desktop): collapse intermediate reasoning, group consecutive tool calls (#1929)
* feat(desktop): collapse intermediate reasoning + group consecutive tool calls

* fix: open tool cards inside group when expanded

* fix: independent collapse state per tool group

* fix: lift ToolGroupShell to module scope, preserve shell live-streaming, rename defaultOpen prop

---------

Co-authored-by: wufengfan <[email protected]>
2026-05-27 05:13:12 -07:00
nianyi(likai) 0d3c6e20e2 fix(hooks): add last_assistant_message to Stop payload for ecosystem compat (#1989) 2026-05-27 05:13:05 -07:00
nianyi(likai) a0bd169cd0 feat(web-search): add bing-intl engine for international index (#1990) 2026-05-27 05:12:21 -07:00
TaoMu b51cb03a23 fix(desktop): bound long-session transcript payloads (#2019) 2026-05-27 05:12:14 -07:00
zhangyapu1 d8e896a8b5 fix: include subagent token usage in session stats (#2008) (#2041)
Subagent child-loop usage (cost, cache hit/miss, completion tokens) was written to the global usage.jsonl log but never folded back into the parent session SessionStats. The TUI stats panel and session meta only saw the parent loop API calls.

Added SessionStats.recordExternal() to fold external usage into carryover without creating a turn entry. Wired it through useSubagent onSubagentEnd callback so subagent completion feeds usage back to the parent stats.

Fixes #2008.
2026-05-27 05:12:07 -07:00
zhangyapu1 69f5147629 feat(mcp): support requestTimeoutMs per-server config (#2023) (#2036)
MCP servers default to a 60s per-request timeout. Some servers need more. Added requestTimeoutMs to McpServerConfig, McpServerSpec, and all McpClient construction sites so the timeout is configurable per-server in .reasonix/config.json mcpServers section. Plumbed through: mcp-runtime, run, acp, mcp-inspect, reconnect.
2026-05-27 05:12:00 -07:00
paradoxSCHandClaude Sonnet 4.6 f0a831b931 fix(mcp): bridge mcpServers in Desktop and unify cfg.mcp reads across CLI (#2024)
- Desktop now bridges both legacy cfg.mcp and canonical cfg.mcpServers.
- Empty-MCP hint, doctor report, install collision checks, dashboard API
  all updated to use normalizeMcpConfig() instead of cfg.mcp directly.
- Add regression tests: desktop-mcp-servers + mcp-tui-survival.

Fixes #1993
Refs #2002

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-05-27 05:11:30 -07:00
Ikko Eltociear Ashimine 547228ae8c docs: add Japanese README (#2011) 2026-05-27 05:11:23 -07:00
enderzcx ef40665371 fix(cli): preserve bundled CLI version metadata (#1997) 2026-05-27 05:11:16 -07:00
nianyi(likai) 9df95d75e7 fix(desktop): backfill missing keys in Japanese (ja) locale (#2018)
The ja locale added in #1915 was missing keys that broke `tsc --noEmit`
in desktop/: app.cmd.skill, the sidebarPanel workspace + session-import
strings, settings.webSearchEngine{Brave,Ollama}, and the matching
webSearchApiKey.{brave,ollama} entries. Backfill them with Japanese
translations so the desktop package typechecks again.
2026-05-27 05:11:09 -07:00
lightfrontandXingbo Zhao c4a0dc5121 fix(desktop): replace | with \vert in math to avoid GFM table breakage (#2017)
* fix(desktop): protect \[4pt] spacing and replace | with \vert in math

* fix(desktop): protect \[4pt] spacing and replace | with \vert in math to avoid GFM table breakage

---------

Co-authored-by: Xingbo Zhao <[email protected]>
2026-05-27 05:10:49 -07:00
cwatanabandreasonix a0816a02f5 feat(i18n): add Japanese (ja) locale (#1915)
* feat(i18n): add Japanese (ja) locale

- src/i18n/JA.ts: full JA translation for CLI/TUI (~1900 lines)
- desktop/src/i18n/ja.ts: full JA translation for desktop shell (~740 lines)
- dashboard/src/i18n/ja.ts: JA translation for dashboard SPA (~1320 lines)
- All three index.ts: register 'ja' in Lang type, dicts, detection, labels
- Wizard.tsx: add 日本語 to language picker

* chore(i18n-ja): fix registration casing + missing keys + lint

- src/i18n/index.ts: register translations.ja with JA (uppercase
  export), not the undefined `ja` identifier
- src/i18n/JA.ts: add braveMissingKey / braveUnauthorized /
  braveRateLimit / braveServerError / braveParseError (added on
  main in #1916 after this branch forked)
- dashboard/src/i18n/ja.ts: add webSearchEngineBrave /
  webSearchEngineOllama (added on main in #1916 / #1900)
- tests/i18n-detect.test.ts: ja-* variants map to ja; allow ja+ru
  in the Intl-default-arg check
- biome --write across the new ja files and index registrations

---------

Co-authored-by: reasonix <[email protected]>
2026-05-27 00:18:18 -07:00
YHHandreasonix 3b4fd7dca0 fix(ink): defer useBoxMetrics measure off the React commit batch (#2003)
useBoxMetrics ran a useEffect with no dependency array and called
setSize inside, so any Box whose content shape depended on its own
measurement (directly, or transitively via a store-reported height)
oscillated within a single React commit batch and crashed with
"Maximum update depth exceeded". The crash surfaced in two stacks:

  1. Direct: setSize inside useBoxMetrics' useEffect.
  2. Indirect: MeasuredCard.useEffect reports a new height after each
     oscillating measurement → chat-scroll-store's debounced
     flushShrink fires → set() notifies → React's nestedUpdateCount
     overflows on the next forceStoreRerender.

Both point to the same root cause: synchronous setState chains
ratcheting React's depth counter without macrotask separation.

Each measure is now scheduled via setTimeout(0). The store update
that follows starts a fresh root render instead of chaining inside
the depth counter, so oscillating call sites still don't converge to
a single value (the underlying anti-pattern in the caller is unchanged)
but the user no longer sees a crash.

Update the existing oscillator repro test to assert the new property:
no max-depth crash + heights still alternate (proving we haven't
silently converged the anti-pattern, just stopped the crash).

Co-authored-by: reasonix <[email protected]>
2026-05-27 00:02:10 -07:00
292 changed files with 25855 additions and 2006 deletions

No files matched your search

+5
View File
@@ -3,3 +3,8 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com
# DEEPSEEK_API_BASE_URL is accepted as an alias of DEEPSEEK_BASE_URL.
REASONIX_LOG_LEVEL=INFO
REASONIX_TRANSCRIPT_DIR=./transcripts
# Optional Telegram remote channel
# TELEGRAM_BOT_TOKEN=123456:botfather-token
# TELEGRAM_OWNER_USER_ID=123456789
# TELEGRAM_ALLOWLIST=123456789,987654321
+11 -9
View File
@@ -24,13 +24,11 @@ jobs:
fail-fast: false
matrix:
target:
# `rust` is the comma-separated list installed by dtolnay/rust-toolchain;
# `tauri` is what we pass to `cargo tauri build --target`. They differ
# only for the universal Mac shard, which produces one fat binary from
# two real Rust targets via lipo. Intel-only macos-13 runner is dropped
# — GH retired most of that pool, queue waits ran to hours.
# macOS now ships two single-arch shards so Apple Silicon and Intel
# users get separate DMGs. `rust` and `tauri` stay aligned per shard.
- { os: ubuntu-22.04, label: "linux-x64", rust: "x86_64-unknown-linux-gnu", tauri: "x86_64-unknown-linux-gnu" }
- { os: macos-14, label: "macos-universal", rust: "aarch64-apple-darwin,x86_64-apple-darwin", tauri: "universal-apple-darwin" }
- { os: macos-15-intel, label: "macos-x64", rust: "x86_64-apple-darwin", tauri: "x86_64-apple-darwin" }
- { os: macos-14, label: "macos-arm64", rust: "aarch64-apple-darwin", tauri: "aarch64-apple-darwin" }
- { os: windows-latest, label: "windows-x64", rust: "x86_64-pc-windows-msvc", tauri: "x86_64-pc-windows-msvc" }
runs-on: ${{ matrix.target.os }}
# secrets.* isn't accessible from step-level if:, so expose presence as a
@@ -96,14 +94,18 @@ jobs:
working-directory: desktop
run: npm run bundle:node
- name: Verify bundled Node is universal (macOS)
- name: Verify bundled Node matches host arch (macOS)
if: startsWith(matrix.target.os, 'macos-')
working-directory: desktop
run: |
expected_arch=$(uname -m)
archs=$(lipo -archs src-tauri/binaries/node)
echo "node archs: $archs"
echo "$archs" | grep -q "arm64"
echo "$archs" | grep -q "x86_64"
echo "expected arch: $expected_arch"
if [ "$archs" != "$expected_arch" ]; then
echo "Expected $expected_arch Node, got: $archs" >&2
exit 1
fi
codesign -d --entitlements :- src-tauri/binaries/node 2>&1 | grep -q "com.apple.security.inherit"
- name: Build & verify desktop frontend
+2
View File
@@ -25,6 +25,8 @@ sessions/
.turbo/
# Local-only Claude Code settings — personal overrides, never committed.
.claude/settings.local.json
# Local agent notes — personal workflow reminders, never committed.
agent.md
# Per-user Reasonix state under .reasonix/ — committable team-level
# files (settings.json, skills/) stay tracked, but the user-private
# bits (semantic index, sessions, opt-out markers) never should.
+339
View File
@@ -0,0 +1,339 @@
<p align="center">
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
> [!WARNING]
> **これはレガシーの TypeScript 系列(Reasonix 0.x)で、メンテナンスのみです。**
> 開発は **Go 版の書き直し** に移行しました —— 新しいデフォルトの [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) ブランチと[移行ガイド](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md)を参照してください。ここではバグ修正のみ反映されます。
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<strong>日本語</strong>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">公式サイト</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">ガイド</a>
&nbsp;·&nbsp;
<a href="./docs/ARCHITECTURE.md">アーキテクチャ</a>
&nbsp;·&nbsp;
<a href="./benchmarks/">ベンチマーク</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="./package.json"><img src="https://img.shields.io/node/v/reasonix.svg?style=flat-square&color=5fa04e&labelColor=161b22&logo=nodedotjs&logoColor=white" alt="node"/></a>
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
</p>
<br/>
<h3 align="center">ターミナルで動く DeepSeek ネイティブの AI コーディングエージェント。</h3>
<p align="center">プレフィックスキャッシュの安定性を中心に設計されているため、長時間のセッションでもトークンコストを低く保ち、動かしっぱなしにできます。</p>
<br/>
<p align="center">
<img src="docs/assets/hero-terminal.svg" alt="Reasonix code mode — assistant proposes a SEARCH/REPLACE edit; nothing on disk until /apply" width="860"/>
</p>
<br/>
> [!TIP]
> **キャッシュの安定性はオンにする「機能」ではなく、ループそのものが前提として設計された不変条件です。** これこそが Reasonix が DeepSeek 専用である理由であり、すべてのレイヤーがバイト単位で安定したプレフィックスキャッシュの仕組みに合わせて調整されています。
> [!NOTE]
> **実ユーザーの1日(2026-05-01):** 入力トークン 4億3,500万、**キャッシュヒット率 99.82%**、同じワークロードを `v4-flash` でキャッシュなしに実行した場合の約 \$61 に対して、わずか約 \$12 で済んでいます — 詳しくは[ケーススタディ](./benchmarks/real-world-cache/README.md)を参照。キャッシュ可能なバイト列を提供するのは DeepSeek ですが、それを長時間のセッションにわたってキャッシュ可能に保つのが [Pillar 1](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) の4つの仕組みです。
> [!IMPORTANT]
> **Community · 加入社区** — セットアップのヘルプ(`#help` / `#求助`)、ワークフローのショーケース、機能アイデア、コントリビューター限定の PR 調整用チャンネルを備えたバイリンガル Discord。サーバー内で GitHub を認証すると、自動的に **Contributor** ロールが付与されます。→ **<https://discord.gg/XF78rEME2D>**
<br/>
## インストール
Node ≥ 22 が必要です。macOS · Linux · Windows(PowerShell · Git Bash · Windows Terminal)で動作します。
`reasonix` コマンドを `PATH` 上で使えるようにしたい場合は、Reasonix をグローバルにインストールします:
~~~bash
npm install -g reasonix
reasonix code my-project # 初回実行時に DeepSeek API キーを貼り付け。以降は保存される
~~~
または、グローバルインストールせずに一度だけ実行することもできます:
~~~bash
cd my-project
npx reasonix code # デフォルトでは常に最新のパッケージを使用
~~~
[DeepSeek API キーを取得 →](https://platform.deepseek.com/api_keys) · フラグは `reasonix code --help` を参照。
Reasonix を毎日使うなら、グローバルインストールが最もシンプルな方法です。とりあえず試してみたいだけなら `npx` を使ってください。
**キー入力を減らしたい?** より短い `dsnix` エイリアスが同じ CLI を指します:
~~~bash
npm install -g dsnix # PATH 上に `dsnix` を公開。reasonix に依存
npx dsnix@latest code # より短いコマンドで一度だけ実行
~~~
`npm install -g reasonix` でグローバルインストールすると `dsnix` のシムも PATH 上に配置されるため、2つは相互に置き換え可能です。
サブコマンドなしの `reasonix`(裸の `reasonix`)はカレントディレクトリで `code` を起動します — `reasonix` と入力するのは `reasonix code` と等価です。
| コマンド | 用途 |
|---|---|
| `reasonix` / `reasonix code [dir]` | コーディングエージェント。**ここから始めてください。** |
| `reasonix chat` | プレーンなチャット — ファイルシステムやシェルのツールなし。 |
| `reasonix run "task"` | 一度だけ実行し、stdout にストリーミング。パイプ向き。 |
| `reasonix doctor` | ヘルスチェック: Node、API キー、MCP の配線。 |
| `reasonix update` | Reasonix 自体をアップグレード。 |
その他のサブコマンド(`replay` · `diff` · `events` · `stats` · `index` · `mcp` · `prune-sessions`)は `reasonix --help` と [CLI リファレンス](https://esengine.github.io/DeepSeek-Reasonix/#cli)にあります。
### QQ チャンネル
QQ は既存の `chat`、`code`、またはデスクトップのセッションをリモートチャンネルとして拡張できます。これは独立したランタイムモードではなく、現在のセッションフローの一部です。
- CLI: セッションを開始し、`/qq connect` を実行
- デスクトップ: `Settings -> General -> QQ Channel` を開く
接続すると、QQ のメッセージを現在のセッションに取り込み、アシスタントの返信を QQ に転送し、その後のやり取りをリモートで継続できます。
詳しいセットアップ、デスクトップのクイックスタート、トラブルシューティングは [QQ チャンネルのセットアップ](./docs/qq-connect.md)を参照してください。
### デスクトップクライアント(プレリリース)
同じループの GUI が欲しいユーザー向けのネイティブ Tauri クライアント。マルチタブ対応で、右パネルにはこのセッションでエージェントが読み取り・編集したファイルが表示され、下部には同じコスト / キャッシュ / トークンのメーターがリアルタイムで表示されます。同じ DeepSeek API キー、同じ `~/.reasonix` 設定を使い、デスクトップ版は独自の Node ランタイムを同梱するため、別途 `npm install` する必要はありません。
各プラットフォームのインストーラーは [GitHub Releases](https://github.com/esengine/DeepSeek-Reasonix/releases) からダウンロードできます。デスクトップ版は **プレリリース** として提供されます: ループとプロトコルは CLI と同じですが、UI はまだ磨き込み中で、インストーラーはまだコード署名されていません。
- **macOS** — 初回起動時に Gatekeeper に引っかかります。一度きりの対処: `xattr -dr com.apple.quarantine /Applications/Reasonix.app`(または右クリック → 開く → 確認)。
- **Windows** — SmartScreen が「不明な発行元」と警告します。**詳細情報 → 実行** をクリックしてください。
- **Linux** — `.deb` と `.AppImage` はそのまま起動でき、追加の手順は不要です。
CLI が引き続き正規のインターフェースです。CLI に入った機能はすべてデスクトップのコンポーザーからも利用できます。
<details>
<summary><strong>別のフォルダで作業する · chat と code の違い · スキルを書く</strong></summary>
**別のフォルダで作業する。** Reasonix はファイルシステムツールを起動ディレクトリにスコープします。対象を変えるには `--dir` を渡してください。セッション途中での切り替えは設計上サポートされていません(メモリのパスが古いルートと絡まってしまうため)— 一度終了して再起動してください。
~~~bash
npx reasonix code --dir /path/to/project
~~~
**`chat` と `code` の選び方。** `code` がデフォルトであり、ファイルシステム / シェルツールと SEARCH/REPLACE レビューを備えた唯一のモードです。`chat` はより軽量でツールをオフにしたシェルです — MCP は接続しつつディスクアクセスのない思考のパートナーが欲しいときに使ってください。
| 得られるもの | `code` | `chat` |
|---|---|---|
| ファイルシステムツール + `edit_file` | ✓ | — |
| SEARCH/REPLACE → `/apply` レビュー | ✓ | — |
| シェルツール(ゲート付き) | ✓ | — |
| プランモード · `/todo` · `/skill new` · `/mcp add` | ✓ | — |
| メモリ(`remember` / `recall_memory`) | プロジェクト + グローバル | グローバルのみ |
| 設定からの MCP サーバー · ウェブ検索 · `ask_choice` | ✓ | ✓ |
| コーディング用システムプロンプト | ✓ | 汎用 |
| セッションのスコープ | ディレクトリごと | 共有デフォルト |
**最初のスキルを書く。** リモートレジストリはありません — 直接書きます。ファイル(`description:` フロントマター + 本文)を編集し、`/skill list` を実行します。本文をインライン展開する代わりに分離したサブエージェントループを生成するには `runAs: subagent` を追加します。
~~~bash
/skill new my-skill # <project>/.reasonix/skills/my-skill.md
/skill new my-skill --global # プロジェクト横断で使うなら ~/.reasonix/skills
~~~
**Claude 形式のスキルも読み込まれます。** `<project>/.claude/skills/<name>/SKILL.md` と `~/.claude/skills/` が Reasonix のネイティブパスと並んで読み込まれるため、Claude 形式のスキルを出力するツールはそのまま動きます。例 — 上流アダプターなしで OpenSpec ワークフローを投入する:
~~~bash
npx openspec init --tools claude # .claude/skills/openspec-*/SKILL.md を書き出す
/skill openspec-propose <task> # その後 Reasonix から呼び出す
~~~
</details>
<br/>
## 設定
`~/.reasonix/config.json` の1つの JSON ファイルと、`<project>/.reasonix/` 配下のプロジェクトごとのオーバーライドで設定します。すべてのキー、すべてのスラッシュコマンド、スキル / メモリ / フックのオンディスク構造を網羅した完全なバイリンガルリファレンスはこちら:
> 📘 **[設定ガイド](https://esengine.github.io/DeepSeek-Reasonix/configuration.html)** · [中文](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh)
| トピック | 概要 |
|---|---|
| [MCP サーバー](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#mcp) | stdio · SSE · Streamable HTTP。1つの仕様形式が `config.json` と `--mcp` の両方で動作。 |
| [スキル](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#skills) | モデルが呼び出せる Markdown のプレイブック。`inline` または `subagent` モード。 |
| [メモリ](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#memory) | プレフィックスにピン留めされるユーザー専用の知識。`user` / `feedback` / `project` / `reference` の型。 |
| [フック](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#hooks) | ライフサイクルイベント時のシェルコマンド。`PreToolUse`(ゲート) · `PostToolUse` · `UserPromptSubmit` · `Stop`。 |
| [権限](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#permissions) | ワークスペースごとのシェル許可リスト。完全な前方一致。 |
| [ウェブ検索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | デフォルトは Mojeek。`/search-engine` でセルフホストの SearXNG や Metaso に切り替え可能。 |
| [セマンティックインデックス](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#index) | `reasonix index` — ローカルの Ollama または任意の OpenAI 互換の埋め込みエンドポイント。 |
<br/>
## Reasonix の何が違うのか
ループは3つの柱を中心に構成されています。それぞれが、汎用的なエージェントフレームワークには見えてすらいない問題を解決します — それらは別のキャッシュの仕組み向けに設計されているからです。
<sub align="center">
完全なアーキテクチャ解説はこちらから → [Pillar 1 — キャッシュファーストのループ](./docs/ARCHITECTURE.md#pillar-1--cache-first-loop) · [Pillar 2 — ツール呼び出しの修復](./docs/ARCHITECTURE.md#pillar-2--tool-call-repair) · [Pillar 3 — コスト制御](./docs/ARCHITECTURE.md#pillar-3--cost-control-v06)
</sub>
<br/>
## 機能
<p align="center">
<img src="docs/assets/feature-grid.svg" alt="Reasonix capabilities — cell-diff renderer, MCP, plan mode, permissions, dashboard, persistent sessions, hooks/skills/memory, semantic search, auto-checkpoints, /effort knob, transcript replay, event log" width="880"/>
</p>
<br/>
## 比較
| | Reasonix | Claude Code | Cursor | Aider |
|-----------------------------------|------------------|-------------------|---------------------|--------------------|
| バックエンド | DeepSeek | Anthropic | OpenAI / Anthropic | 任意(OpenRouter) |
| ライセンス | **MIT** | クローズド | クローズド | Apache 2 |
| コストプロファイル | **タスクあたり低コスト** | プレミアム | サブスク + 従量 | まちまち |
| DeepSeek プレフィックスキャッシュ | **設計込み** | 該当なし | 該当なし | 偶発的 |
| 組み込みウェブダッシュボード | あり | — | 該当なし(IDE) | — |
| 設定可能なウェブ検索エンジン | `/search-engine` | — | — | — |
| ワークスペースごとの永続セッション | あり | 一部 | 該当なし | — |
| プランモード · MCP · フック · スキル | あり | あり | あり | 一部 |
| ウェブ検索(Mojeek + SearXNG + Metaso) | あり | あり | あり | あり |
| オープンなコミュニティ開発 | あり | — | — | あり |
リアルタイムのキャッシュヒット率、コスト、手法については [`benchmarks/`](./benchmarks/) を参照してください — 数値はモデルの料金とともに変動するため、README ではなくハーネスとともに置いています。
<br/>
## ドキュメント
- [**アーキテクチャ**](./docs/ARCHITECTURE.md) — 3つの柱: キャッシュファーストのループ、ツール呼び出しの修復、コスト制御
- [**CLI リファレンス**](./docs/CLI-REFERENCE.md) — すべてのシェルサブコマンド、すべてのスラッシュコマンド、すべてのキーバインド
- [**QQ チャンネルのセットアップ**](./docs/qq-connect.md) — CLI の初回接続フロー、デスクトップのエントリ、QQ Open Platform の認証情報
- [**ベンチマーク**](./benchmarks/) — τ-bench-lite ハーネス、トランスクリプト、コスト算出手法
- [**公式サイト**](https://esengine.github.io/DeepSeek-Reasonix/) — はじめに、ダッシュボードのモックアップ、TUI のモックアップ
- [**コントリビューション**](./CONTRIBUTING.md) — コメントポリシー、エラーハンドリングのルール、自作よりライブラリを優先
- [**行動規範**](./CODE_OF_CONDUCT.md) · [**セキュリティポリシー**](./SECURITY.md)
<br/>
## コミュニティ
> [!NOTE]
> Reasonix はオープンソースであり、コミュニティによって開発されています。このファイル末尾の謝辞ウォールにあるすべてのアバターは、実際にマージされた PR です。
スコープが切られた入門用チケット — それぞれ背景、コードの参照ポイント、受け入れ基準、ヒント付き — は [`good first issue`](https://github.com/esengine/reasonix/labels/good%20first%20issue) ラベルの下にあります。オープンなものなら何でも選んでください。
**オープンな Discussions — 意見募集中:**
- [#20 · CLI / TUI デザイン](https://github.com/esengine/reasonix/discussions/20) — 何が壊れていて、何が足りず、何を変えますか?
- [#21 · ダッシュボードデザイン](https://github.com/esengine/reasonix/discussions/21) — [提案中のモックアップ](https://esengine.github.io/DeepSeek-Reasonix/design/agent-dashboard.html)への反応をどうぞ
- [#22 · 今後の機能ウィッシュリスト](https://github.com/esengine/reasonix/discussions/22) — 次に Reasonix に組み込みたいものは?
**すでに Reasonix を使っていて、他の人に広める手伝いをしてもいい?** ブログ記事、記事、スクリーンショット、講演、動画を [**Show and tell**](https://github.com/esengine/reasonix/discussions/categories/show-and-tell) に公開してください。このプロジェクトにはマーケティング予算がありません — 新しいユーザーは口コミで見つけてくれます。継続的に応援してくれる人は、以下のバッジを獲得でき、付与されるとコントリビューターウォールの横に表示されます:
<p align="center">
<a href="https://github.com/esengine/reasonix/discussions/categories/show-and-tell">
<img src="https://img.shields.io/badge/REASONIX-📣%20ADVOCATE-c4b5fd?style=for-the-badge&labelColor=0d1117" alt="Reasonix Advocate badge — earned by sustained advocates"/>
</a>
</p>
**最初の PR の前に**: [`CONTRIBUTING.md`](./CONTRIBUTING.md) を読んでください — 短く厳格なルール(コメント、エラー、自作よりライブラリを優先)。`tests/comment-policy.test.ts` がコメントのルールを強制します。`npm run verify` がプッシュ前のゲートです。参加することで [行動規範](./CODE_OF_CONDUCT.md) に同意したものとみなされます。セキュリティの問題は → [SECURITY.md](./SECURITY.md)。
<br/>
## 非目標
> [!IMPORTANT]
> Reasonix には明確な方針があります。あえて *やらない* ことがいくつかあります — あなたの作業に合ったツールを選べるよう、ここに挙げておきます。
- **マルチプロバイダーの柔軟性。** あえて DeepSeek 専用にしています。1つのバックエンドに密結合していることは制限ではなく機能です。
- **IDE 統合。** ターミナルファースト。差分は `git diff` に、ファイルツリーは `ls` にあります。ダッシュボードはあくまで補助であり、Cursor の代替ではありません。
- **最難関リーダーボードの推論。** Claude Opus はいまだに一部のベンチマークで勝っています。DeepSeek はコーディングで競争力がありますが、もしあなたの作業が「この認証バグを直す」ではなく「この博士課程レベルの証明を解く」ものなら、Claude から始めてください。
- **エアギャップ / 完全無料。** Reasonix には有料の DeepSeek API キーが必要です。エアギャップ環境やゼロコストでの実行には Aider + Ollama または [Continue](https://continue.dev) を参照してください。
<br/>
## スター履歴
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=esengine/DeepSeek-Reasonix&type=date&legend=top-left" />
</picture>
</a>
<br/>
## サポート
Reasonix が役に立って、お礼を言いたいと思ったらどうぞ。これはあくまでコーヒー一杯であって契約ではありません — 寄付で機能の優先順位が買えるわけでも、issue のトリアージのされ方が変わるわけでもありません。
- **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/>
## 謝辞
Reasonix を最も形づくってくれた人々の小さなリストです — コミット数とコード量の両方で測っています。**重要度の順位付けはなく、アルファベット順に記載しています。** 完全なコントリビューターグラフは
[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)([`docs/brand/`](./docs/brand/) を参照)と、小紅書(XiaoHongShu)でプロジェクトを宣伝してくれた [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="Contributors to 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/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> のコミュニティによって構築</sub>
</p>
+9 -2
View File
@@ -2,11 +2,18 @@
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
> [!WARNING]
> **This is the legacy TypeScript line (Reasonix 0.x), in maintenance mode.**
> Active development moved to the **Go rewrite** — see the [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) branch (the new default) and the [migration guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md). Only bug fixes land here.
<p align="center">
<strong>English</strong>
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="./README.ja-JP.md">日本語</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">Guide</a>
@@ -180,7 +187,7 @@ One JSON file at `~/.reasonix/config.json` plus per-project overrides under `<pr
| [Memory](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#memory) | User-private knowledge pinned into the prefix. `user` / `feedback` / `project` / `reference` types. |
| [Hooks](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#hooks) | Shell commands on lifecycle events. `PreToolUse` (gating) · `PostToolUse` · `UserPromptSubmit` · `Stop`. |
| [Permissions](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#permissions) | Per-workspace shell allowlist. Exact-prefix match. |
| [Web search](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | Mojeek by default; switch to self-hosted SearXNG or Metaso with `/search-engine`. |
| [Web search](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | Bing by default; switch to Baidu AI Search, self-hosted SearXNG, Metaso, Tavily, Perplexity, Exa, Brave, or Ollama with `/search-engine`. |
| [Semantic index](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#index) | `reasonix index` — local Ollama or any OpenAI-compatible embedding endpoint. |
<br/>
@@ -217,7 +224,7 @@ Click through to the full architecture writeup → [Pillar 1 — Cache-first loo
| Configurable web search engine | `/search-engine` | — | — | — |
| Persistent per-workspace sessions | yes | partial | n/a | — |
| Plan mode · MCP · hooks · skills | yes | yes | yes | partial |
| Web search (Mojeek + SearXNG + Metaso) | yes | yes | yes | yes |
| Web search (Bing + Baidu + SearXNG + API engines) | yes | yes | yes | yes |
| Open community development | yes | — | — | yes |
For live cache-hit rates, costs, and methodology, see [`benchmarks/`](./benchmarks/) — the numbers move with model pricing, so they live with the harness, not in the README.
+8 -1
View File
@@ -2,11 +2,18 @@
<img src="docs/logo.svg" alt="Reasonix" width="640"/>
</p>
> [!WARNING]
> **这是 legacy TypeScript 线(Reasonix 0.x),仅维护。**
> 开发已转移到 **Go 重写版** —— 见 [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) 分支(新的默认分支)与[迁移指南](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/MIGRATING.md)。此处仅修复 bug。
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="./README.ja-JP.md">日本語</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh">配置指南</a>
@@ -136,7 +143,7 @@ npx reasonix code --dir /path/to/project
| [Memory](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#memory) | 用户私有的知识,钉进前缀。`user` / `feedback` / `project` / `reference` 四类。 |
| [Hooks](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#hooks) | 生命周期事件触发的 shell 命令。`PreToolUse`(拦截)· `PostToolUse` · `UserPromptSubmit` · `Stop`。 |
| [权限](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#permissions) | 按工作区的 shell 白名单,精确前缀匹配。 |
| [Web 搜索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#search) | 默认 Mojeek;用 `/search-engine` 可切到自托管的 SearXNG 或 Metaso。 |
| [Web 搜索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#search) | 默认 Bing;可用 `/search-engine` 切到百度 AI Search、自托管 SearXNG、Metaso、Tavily、Perplexity、Exa、Brave 或 Ollama。 |
| [语义索引](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#index) | `reasonix index` —— 本地 Ollama,或任何 OpenAI 兼容的 embedding 接口。 |
<br/>
+3 -2
View File
@@ -59,8 +59,9 @@ hit rates:
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
the cached prefix so it never poisons the next hit.
4. **Auto-compact** — when context approaches the cap, older turns fold into
a summary message *appended* to the prefix; the prefix itself isn't
rewritten, so the cache survives the fold.
a summary message. The summary request is shaped to reuse the main agent's
already-cached system/tool/history prefix; the following main-agent request
still pays a cold miss for the newly synthesized summary segment.
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
the bytes cacheable.
+69 -2
View File
@@ -69,6 +69,7 @@ import { WorkdirPop } from "./ui/workdir-pop";
import { WorkdirInputModal } from "./ui/workdir-input-modal";
import { useAutoScroll } from "./ui/useAutoScroll";
import { useDisableTextAssist } from "./ui/useDisableTextAssist";
import { elideTranscriptMessages } from "./ui/transcript-elision";
import { openUrl } from "@tauri-apps/plugin-opener";
export type AssistantSegment =
@@ -193,7 +194,20 @@ export type Settings = {
recentWorkspaces: string[];
model: string;
editor?: string;
webSearchEngine?: "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
webSearchEngine?:
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa"
| "brave"
| "ollama";
webSearchApiKeys?: {
baidu?: string;
};
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
version: string;
@@ -306,6 +320,10 @@ function nextMessageTurn(messages: ChatMessage[]): number {
}
function reduce(state: State, action: Action): State {
return withElidedTranscript(reduceRaw(state, action));
}
function reduceRaw(state: State, action: Action): State {
switch (action.t) {
case "send_user": {
return {
@@ -470,6 +488,11 @@ function reduce(state: State, action: Action): State {
}
}
function withElidedTranscript(state: State): State {
const messages = elideTranscriptMessages(state.messages);
return messages === state.messages ? state : { ...state, messages };
}
const READING_TOOLS = new Set(["read_file"]);
const MODIFYING_TOOLS = new Set(["edit_file", "write_file"]);
@@ -548,6 +571,10 @@ function appendTextSegment(
}
export function applyIncoming(state: State, ev: IncomingEvent): State {
return withElidedTranscript(applyIncomingRaw(state, ev));
}
function applyIncomingRaw(state: State, ev: IncomingEvent): State {
switch (ev.type) {
case "user.message": {
const last = state.messages[state.messages.length - 1];
@@ -804,6 +831,7 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
model: ev.model,
editor: ev.editor,
webSearchEngine: ev.webSearchEngine,
webSearchApiKeys: ev.webSearchApiKeys,
subagentModels: ev.subagentModels,
showSystemEvents: ev.showSystemEvents,
version: ev.version,
@@ -1586,11 +1614,50 @@ function TabRuntime({
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
e.preventDefault();
abort();
} else if (e.key === "Enter" && !mod && !e.shiftKey && !e.altKey) {
// Defer to any control that already handles Enter — native inputs/buttons,
// ARIA button/link widgets (sidebar rows, file pills), or anything that called
// preventDefault — so we only grant when focus is on inert layout (#2015).
if (e.defaultPrevented) return;
const target = e.target as HTMLElement | null;
if (
target?.isContentEditable ||
target?.closest('input, textarea, button, select, a, [role="button"], [role="link"]')
) {
return;
}
if (settingsOpen || jobsOpen || wdOpen) return;
// Enter grants the pending authorization prompt (run once), matching the
// TUI where Enter confirms the highlighted choice (#1962).
const confirm = state.pendingConfirms.at(-1);
if (confirm) {
e.preventDefault();
resolveConfirm(confirm.id, { type: "run_once" });
return;
}
const pathAccess = state.pendingPathAccess.at(-1);
if (pathAccess) {
e.preventDefault();
resolvePathAccess(pathAccess.id, { type: "run_once" });
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active, state.busy, abort, newChat, settingsOpen, openSettingsAt]);
}, [
active,
state.busy,
state.pendingConfirms,
state.pendingPathAccess,
resolveConfirm,
resolvePathAccess,
settingsOpen,
jobsOpen,
wdOpen,
abort,
newChat,
openSettingsAt,
]);
const commands = buildCommands({
newChat: () => {
+19
View File
@@ -250,14 +250,21 @@ export const de: typeof en = {
webSearchNote: "web_fetch + web_search Werkzeuge",
webSearchEngine: "Suchmaschine",
webSearchEngineBing: "bing — Standard, funktioniert von CN ohne Proxy",
webSearchEngineBingIntl: "bing-intl — internationaler Index über www.bing.com",
webSearchEngineSearxng: "searxng — selbst gehostet (Endpunkt via /se searxng <url>)",
webSearchEngineMetaso: "metaso — 100/Tag kostenlos (CN-freundlich, kostenloser Schlüssel auf metaso.cn)",
webSearchEngineBaidu: "baidu — Baidu AI Search (BAIDU_API_KEY oder QIANFAN_API_KEY setzen)",
webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)",
webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)",
webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)",
webSearchEngineBrave: "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)",
webSearchEngineOllama: "ollama — Ollama Cloud-Websuche (OLLAMA_API_KEY setzen)",
webSearchEngineNote: "gilt für den nächsten web_search-Aufruf",
webSearchApiKeySignup: "(Schlüssel holen)",
webSearchApiKeyClear: "Löschen",
webSearchApiKey: {
baidu: "Baidu API-Schlüssel",
},
sectionBudget: "Budget",
budget: "Budget",
budgetHint: "Ausgabengrenzen festlegen",
@@ -323,10 +330,18 @@ export const de: typeof en = {
themeStyleHint: "Theme-Stil wählen",
themeStyleGraphite: "Graphit",
themeStyleGraphiteDesc: "Kühle dunkle Neutrale — am besten für lange Sitzungen.",
themeStyleEmber: "Ember",
themeStyleEmberDesc: "Warmes Dunkel mit stärkerem Reasonix-Orange.",
themeStyleAurora: "Aurora",
themeStyleAuroraDesc: "Türkisgrünes Dunkel für weicheres Arbeiten bei wenig Licht.",
themeStyleSandstone: "Sandstein",
themeStyleSandstoneDesc: "Warme helle Töne — angenehm in hellen Räumen.",
themeStylePorcelain: "Porzellan",
themeStylePorcelainDesc: "Sauberes Hell mit extra Kontrast.",
themeStyleLinen: "Leinen",
themeStyleLinenDesc: "Editoriales warmes Hell mit papierartigen Flächen.",
themeStyleGlacier: "Gletscher",
themeStyleGlacierDesc: "Kühles Hell mit klaren blauen Akzenten.",
themeStyleMidnight: "Mitternacht",
themeStyleMidnightDesc: "Tiefes Dunkel mit kühleren Highlights.",
pageGeneralLabel: "Allgemein",
@@ -1283,8 +1298,12 @@ export const de: typeof en = {
themeDark: "Dunkel",
themeLight: "Hell",
themeStyleGraphite: "Graphit",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstein",
themeStylePorcelain: "Porzellan",
themeStyleLinen: "Leinen",
themeStyleGlacier: "Gletscher",
themeStyleMidnight: "Mitternacht",
thisTurn: "Dieser Turn",
tokens: "Tokens",
+19
View File
@@ -232,14 +232,21 @@ export const en = {
webSearchNote: "web_fetch + web_search tools",
webSearchEngine: "search engine",
webSearchEngineBing: "bing — default, works from CN without proxy",
webSearchEngineBingIntl: "bing-intl — international index via www.bing.com",
webSearchEngineSearxng: "searxng — self-hosted (set endpoint via /se searxng <url>)",
webSearchEngineMetaso: "metaso — 100/day free (CN-friendly, free key at metaso.cn)",
webSearchEngineBaidu: "baidu — Baidu AI Search (set BAIDU_API_KEY or QIANFAN_API_KEY)",
webSearchEngineTavily: "tavily — 1000/mo free (set TAVILY_API_KEY)",
webSearchEnginePerplexity: "perplexity — AI-native (set PERPLEXITY_API_KEY)",
webSearchEngineExa: "exa — AI-native 1000/mo free (set EXA_API_KEY)",
webSearchEngineBrave: "brave — independent index, 2000/mo free (set BRAVE_SEARCH_API_KEY)",
webSearchEngineOllama: "ollama — Ollama cloud web search (set OLLAMA_API_KEY)",
webSearchEngineNote: "applies to the next web_search call",
webSearchApiKeySignup: "(get key)",
webSearchApiKeyClear: "Clear",
webSearchApiKey: {
baidu: "Baidu API key",
},
sectionBudget: "Budget",
budget: "Budget",
budgetHint: "Set spending limits",
@@ -305,10 +312,18 @@ export const en = {
themeStyleHint: "Choose theme style",
themeStyleGraphite: "Graphite",
themeStyleGraphiteDesc: "Cool dark neutrals — best for long sessions.",
themeStyleEmber: "Ember",
themeStyleEmberDesc: "Warm dark with a stronger Reasonix orange accent.",
themeStyleAurora: "Aurora",
themeStyleAuroraDesc: "Teal-green dark for a softer low-light workspace.",
themeStyleSandstone: "Sandstone",
themeStyleSandstoneDesc: "Warm light tones — easy on bright rooms.",
themeStylePorcelain: "Porcelain",
themeStylePorcelainDesc: "Clean light with extra contrast.",
themeStyleLinen: "Linen",
themeStyleLinenDesc: "Editorial warm light with paper-like surfaces.",
themeStyleGlacier: "Glacier",
themeStyleGlacierDesc: "Cool light with crisp blue accents.",
themeStyleMidnight: "Midnight",
themeStyleMidnightDesc: "Deep dark with cooler highlights.",
pageGeneralLabel: "General",
@@ -1235,8 +1250,12 @@ export const en = {
themeDark: "Dark",
themeLight: "Light",
themeStyleGraphite: "Graphite",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstone",
themeStylePorcelain: "Porcelain",
themeStyleLinen: "Linen",
themeStyleGlacier: "Glacier",
themeStyleMidnight: "Midnight",
thisTurn: "This turn",
tokens: "Tokens",
+14 -4
View File
@@ -1,13 +1,21 @@
import { useEffect, useState } from "react";
import { de } from "./de";
import { en } from "./en";
import { ja } from "./ja";
import { ru } from "./ru";
import { zhCN } from "./zh-CN";
export type Lang = "en" | "zh-CN" | "de";
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de"];
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
const LANG_LABELS: Record<Lang, string> = { en: "English", "zh-CN": "简体中文", de: "Deutsch" };
const LANG_LABELS: Record<Lang, string> = {
en: "English",
"zh-CN": "简体中文",
de: "Deutsch",
ja: "日本語",
ru: "Русский",
};
const STORAGE_KEY = "reasonix.lang";
type Listener = () => void;
@@ -24,6 +32,8 @@ function detectDefault(): Lang {
const lower = nav.toLowerCase();
if (lower.startsWith("zh")) return "zh-CN";
if (lower.startsWith("de")) return "de";
if (lower.startsWith("ja")) return "ja";
if (lower.startsWith("ru")) return "ru";
return "en";
}
@@ -77,7 +87,7 @@ export function useLang(): Lang {
return currentLang;
}
const dicts = { en, "zh-CN": zhCN, de } as const;
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
type Dict = typeof en;
type Path<T, K extends keyof T = keyof T> = K extends string
+507
View File
@@ -0,0 +1,507 @@
import { en } from "./en";
export const ja: typeof en = {
...en,
app: {
...en.app,
sectionWorkspace: "ワークスペース",
sectionObserve: "監視",
sectionConfigure: "設定",
tabChat: "チャット",
tabPlans: "プラン",
tabSessions: "セッション",
tabOverview: "概要",
tabUsage: "使用量",
tabSystem: "システム",
tabSemantic: "セマンティック",
tabTools: "ツール",
tabPermissions: "権限",
tabMcp: "MCP",
tabSkills: "スキル",
tabMemory: "メモリ",
tabHooks: "フック",
tabSettings: "設定",
sectionChanges: "変更",
tabChanges: "変更",
footer: "127.0.0.1 のみ · トークン認証",
themeToggle: "テーマ切替",
themeLight: "ライト",
themeDark: "ダーク",
skill: {
...en.app.skill,
scope: {
...en.app.skill.scope,
builtin: "ビルトイン",
global: "グローバル",
project: "プロジェクト",
},
runAs: {
...en.app.skill.runAs,
subagent: "サブエージェント",
inline: "インライン",
},
generic: "スキル",
},
exportReasoningSummary: "推論サマリー",
exportToolLabel: "ツール呼び出し",
exportUserLabel: "ユーザーメッセージ",
empty: {
...en.app.empty,
currentWorkspace: "現在のワークスペース",
selectWorkspace: "ワークスペースを選択して開始",
suggestion0: "新しい会話を始める",
suggestion1: "設定を開く",
suggestion2: "概要を表示",
suggestion3: "セッションを閲覧",
welcome: "Reasonix へようこそ",
},
errorLabel: "エラー",
connecting: "接続中…",
session: {
...en.app.session,
format: "セッション",
new: "新しいセッション",
},
setup: {
...en.app.setup,
choose: "ワークスペースを選択",
description: "作業を開始するワークスペースを選択",
notSelected: "ワークスペース未選択",
saveAndStart: "保存して開始",
welcome: "ようこそ",
workspace: "ワークスペース",
},
tab: {
...en.app.tab,
newTab: "+ 新しいタブ",
newTabTitle: "新しいタブを開く",
},
titlebar: {
...en.app.titlebar,
clearChat: "チャットクリア",
close: "閉じる",
commandPalette: "コマンドパレット",
contextPanel: "コンテキストパネル",
copyMd: "Markdownでコピー",
exportMd: "Markdownでエクスポート",
maximize: "最大化",
minimize: "最小化",
more: "その他",
restore: "元に戻す",
settings: "設定",
sidebar: "サイドバー",
windowControls: "ウィンドウ操作",
},
toast: {
...en.app.toast,
cleared: "チャットをクリアしました",
copied: "クリップボードにコピーしました",
copiedMd: "Markdownでコピーしました",
emptySession: "エクスポートするメッセージがありません",
exportDialogTitle: "チャットをエクスポート",
exportedMd: "Markdownでエクスポートしました",
exportFailed: "エクスポートに失敗しました",
langSwitched: "言語を切り替えました",
modelSwitched: "モデル → {model}",
effortSwitched: "努力 → {effort}",
modeSwitched: "モードを切り替えました",
newSession: "新しいセッションを作成しました",
},
yolo: {
...en.app.yolo,
toast: "YOLOモードが有効です",
},
cmd: {
...en.app.cmd,
abort: "中断",
btw: "BTW",
clearChat: "チャットクリア",
compact: "圧縮",
copyLast: "最後をコピー",
exportMd: "Markdownでエクスポート",
feedback: "フィードバック",
help: "ヘルプ",
newSession: "新しいセッション",
retry: "再試行",
switchModel: "モデル切替",
toggleCurrency: "通貨切替",
toggleLang: "言語切替",
toggleTheme: "テーマ切替",
},
header: {
...en.app.header,
abort: "中断",
clickToSelect: "クリックで選択",
copy: "コピー",
copyMd: "Markdownでコピー",
export: "エクスポート",
exportMd: "Markdownでエクスポート",
newChat: "新しいチャット",
noWorkspace: "ワークスペースなし",
running: "実行中…",
},
jumpToBottom: "最下部にジャンプ",
langEN: "English",
langZH: "中文",
splashSubtitle: "AI搭載コーディングアシスタント",
},
changes: {
...en.changes,
chatPlaceholder: "コードについて質問…",
chatWelcome: "変更 — プロジェクトファイルについて質問してください。",
chatSend: "送信",
viewerPlaceholder: "表示するファイルを選択",
treeEmpty: "(空)",
tabClose: "タブを閉じる",
newConversation: "新規",
clearConversation: "クリア",
newTitle: "/new — 会話コンテキストを消去",
clearTitle: "/clear — 表示スクロールバックを消去",
newConfirmBusy: "ターンが実行中です。中断して新しい会話を始めますか?",
newConfirm: "現在の会話をクリアして新しく始めますか?",
newToast: "新しい会話",
clearToast: "スクロールバックをクリアしました",
newFailed: "/new 失敗: {error}",
clearFailed: "/clear 失敗: {error}",
chatSendBtn: "送信",
fileTreeTitle: "ファイル",
codeViewerTitle: "コードビューア",
chatPanelTitle: "チャット",
loadingFiles: "プロジェクトファイルを読み込み中…",
review: "レビュー",
allFiles: "すべてのファイル",
changes: "変更",
commentLabel: "行にコメント",
commentPlaceholder: "コメントを追加…",
commentCancel: "キャンセル",
commentSubmit: "コメント",
commentLine: "行",
commentEdit: "編集",
commentDelete: "削除",
diffSourceGit: "Gitの変更",
diffSourceSession: "前回のセッション",
diffSourceCheckpoint: "チェックポイント",
checkpointEmpty: "このワークスペースにチェックポイントはまだありません。",
restoreBtn: "復元",
restoreConfirm: '"{name}" を復元しますか?現在のファイルが上書きされます。',
deleteBtn: "削除",
deleteConfirm:
'チェックポイント "{name}" を削除しますか?スナップショットは削除され、ファイルはそのままです。',
createBtn: "スナップショット",
createPlaceholder: "スナップショット名…",
backToList: "リストに戻る",
reviewEmpty: "保留中のレビューはありません。",
diffStyleUnified: "統合",
diffStyleSplit: "分割",
expandAll: "すべて展開",
collapseAll: "すべて折りたたみ",
createFailed: "作成に失敗しました",
restoreFailed: "復元に失敗しました",
deleteFailed: "削除に失敗しました",
},
common: {
...en.common,
loading: "読み込み中…",
loadingFailed: "{name} 失敗: {error}",
back: "← 戻る",
save: "保存",
remove: "削除",
cancel: "キャンセル",
delete: "削除",
add: "追加",
confirm: "確認",
noData: "まだ {name} がありません。",
all: "すべて",
yes: "はい",
no: "いいえ",
on: "オン",
off: "オフ",
enabled: "有効",
disabled: "無効",
},
settings: {
...en.settings,
title: "設定",
loading: "設定を読み込み中…",
saved: "保存: {fields}",
sectionApi: "DeepSeek API",
apiKey: "APIキー",
apiKeyNotSet: "未設定",
apiKeySave: "キーを保存",
apiKeySet: "設定済み",
apiSection: "API設定",
notSet: "(未設定)",
replace: "置換",
pasteKey: "新しい sk-… トークンを貼り付け",
saveKey: "キーを保存",
baseUrl: "ベースURL",
baseUrlPlaceholder: "https://api.deepseek.com (デフォルト)",
baseUrlHint: "APIリクエストのベースURL",
sectionDefaults: "デフォルト",
effortSection: "推論努力",
modelCustom: "カスタムモデルID",
modelCustomHint: "エンドポイントが提供するOpenAI互換ID (vLLM, Ollama, Together, …)。",
modelCustomActive: "現在カスタムIDで実行中: {model}",
appliesNextTurn: "次のターンから適用",
effort: "努力",
effortMax: "max (DeepSeek拡張 — 標準OpenAI / vLLMでは拒否)",
effortHigh: "high (安全なデフォルト — vLLM / Azure互換)",
effortMedium: "medium",
effortLow: "low (最速)",
webSearch: "Web検索",
webSearchNote: "web_fetch + web_search ツール",
webSearchEngine: "検索エンジン",
webSearchEngineBing: "bing — デフォルト、プロキシなしで中国から利用可",
webSearchEngineBingIntl: "bing-intl — www.bing.com 経由の国際版インデックス",
webSearchEngineSearxng: "searxng — セルフホスト (/se searxng <url> でエンドポイント設定)",
webSearchEngineMetaso: "metaso — 100回/日無料 (中国対応、metaso.cnで無料キー取得)",
webSearchEngineBaidu: "baidu — Baidu AI Search (BAIDU_API_KEY または QIANFAN_API_KEY を設定)",
webSearchEngineTavily: "tavily — 1000回/月無料 (TAVILY_API_KEY を設定)",
webSearchEnginePerplexity: "perplexity — AIネイティブ (PERPLEXITY_API_KEY を設定)",
webSearchEngineExa: "exa — AIネイティブ 1000回/月無料 (EXA_API_KEY を設定)",
webSearchEngineBrave: "brave — 独立インデックス、2000回/月無料 (BRAVE_SEARCH_API_KEY を設定)",
webSearchEngineOllama: "ollama — Ollama クラウド Web 検索 (OLLAMA_API_KEY を設定)",
webSearchEngineNote: "次の web_search 呼び出しに適用",
webSearchApiKeySignup: "(キーを取得)",
webSearchApiKeyClear: "クリア",
webSearchApiKey: {
...en.settings.webSearchApiKey,
baidu: "Baidu APIキー",
},
sectionBudget: "予算",
budget: "予算",
budgetHint: "支出制限を設定",
budgetPlaceholder: "予算額を入力",
budgetOf: "/",
budgetSetCap: "上限を設定",
budgetCustom: "カスタム",
budgetBumpHint: "上限を引き上げて続行",
budgetClear: "上限をクリア",
budgetIdleLine: "80%で警告 · 100%超過で拒否",
budgetWarnLine: "上限接近 — ループは100%超過で拒否",
budgetRefusing: "上限到達 — 引き上げまたはクリアまで次のターンは拒否",
sectionLoop: "ループ",
loopIdleHint: "固定間隔でプロンプトを自動再送信。",
loopCostHint: "各イテレーションのコスト ~{cost} (前回ターン)。",
loopInterval: "間隔",
loopCustom: "カスタム",
loopRangeError: "間隔は5秒〜6時間の範囲である必要があります",
loopPrompt: "プロンプト",
loopPromptPlaceholder: "例: デプロイ状態を確認してエラーを報告",
loopStart: "ループ開始",
loopStop: "停止",
loopRunning: "実行中",
loopIter: "反復 {iter}",
loopFiresIn: "{remaining} 後に実行",
sectionSkills: "スキル",
skillPaths: "スキルパス",
skillPathsPlaceholder: "/path/to/skills-a, /path/to/skills-b",
skillPathsNote: "カンマ区切り · 次の /new または新規セッション",
skillsEmpty: "スキルが読み込まれていません",
skillsLoaded: "スキル読み込み済み",
subagentModelFlash: "flash",
subagentModelPro: "pro",
subagentModelHint: "このスキルが分離サブエージェントを生成するときに使用するモデル",
sectionRuntime: "ランタイム",
activeModel: "アクティブモデル",
model: "モデル",
modelFlashDesc: "Flash — 常にflash",
modelProDesc: "Pro — 常にpro",
modelPricingLine: "${hit} ヒット · ${miss} ミス · ${out} 出力 / 1M tok",
editMode: "編集モード",
editModeNote: "チャットタブヘッダーから切り替え",
editModeHint: "編集モードを設定",
showSystemEvents: "システムイベント",
showSystemEventsHint: "コンテキスト圧縮、中断、レート制限の通知を表示。",
shown: "表示",
hidden: "非表示",
sectionLanguage: "言語",
language: "言語",
languageHint: "言語を切り替え",
langEn: "English",
langZhCn: "简体中文",
appMode: "アプリモード",
appearanceSection: "外観",
behaviorSection: "動作",
ruleAutoApprovalHint: "自動承認ルールを設定",
ruleAutoApprovalSection: "自動承認ルール",
theme: "テーマ",
themeDark: "ダーク",
themeHint: "ライト/ダークテーマを切り替え",
themeLight: "ライト",
themeStyle: "テーマスタイル",
themeStyleHint: "テーマスタイルを選択",
themeStyleGraphite: "グラファイト",
themeStyleGraphiteDesc: "クールなダークニュートラル — 長時間のセッションに最適。",
themeStyleEmber: "エンバー",
themeStyleEmberDesc: "Reasonixのオレンジを強めた暖かいダーク。",
themeStyleAurora: "オーロラ",
themeStyleAuroraDesc: "低照度でも柔らかいティールグリーンのダーク。",
themeStyleSandstone: "サンドストーン",
themeStyleSandstoneDesc: "温かみのあるライトトーン — 明るい部屋に最適。",
themeStylePorcelain: "ポーセリン",
themeStylePorcelainDesc: "クリーンなライト、高コントラスト。",
themeStyleLinen: "リネン",
themeStyleLinenDesc: "紙のような表面を持つ編集調の暖かいライト。",
themeStyleGlacier: "グレイシャー",
themeStyleGlacierDesc: "くっきりしたブルーアクセントのクールなライト。",
themeStyleMidnight: "ミッドナイト",
themeStyleMidnightDesc: "深いダーク、クールなハイライト。",
pageGeneralLabel: "一般",
pageGeneralDesc: "テーマ、フォント、言語、ワークスペース。",
pageModelsLabel: "モデル",
pageModelsDesc: "デフォルトモデルと推論努力。",
pageMcpLabel: "MCPサーバー",
pageMcpDesc: "外部ツールサーバーに接続。",
pageSkillsLabel: "スキル",
pageSkillsDesc: "/skill で実行できるユーザー定義スキル。",
pageMemoryLabel: "メモリ",
pageMemoryDesc: "セッションをまたいで保持される長期メモ。",
pageRulesLabel: "ルール",
pageRulesDesc: "シェル+パスアクセスの自動承認ルール。",
pageBillingLabel: "請求",
pageBillingDesc: "APIキー、残高、セッションごとの予算。",
pageShortcutsLabel: "ショートカット",
pageShortcutsDesc: "キーボードショートカットリファレンス。",
fontFamily: "フォントファミリー",
fontFamilyHint: "フォントファミリーを選択",
fontFamilySans: "サンセリフ",
fontFamilySerif: "セリフ",
fontFamilySystem: "システム",
fontScale: "フォントスケール",
fontScaleHint: "フォントサイズを調整",
fontScaleLarge: "大",
fontScaleMedium: "中",
fontScaleSmall: "小",
currentWorkspace: "現在のワークスペース",
defaultModelCurrent: "現在のモデル",
editor: "エディタ",
editorHint: "エディタ設定を構成",
maxOutput: "最大出力",
reasoningEffort: "推論努力",
reasoningEffortHint: "推論の深さを調整",
sessionCost: "セッションコスト",
balanceAvailable: "利用可能",
balanceLabel: "残高",
balanceLow: "残高不足",
cacheHitRate: "キャッシュヒット率",
ctxWindow: "コンテキストウィンドウ",
notSelected: "未選択",
parseError: "パースエラー",
workspaceChange: "変更",
workspaceSection: "ワークスペース",
mcpAdd: "追加",
mcpAddSection: "MCPサーバーを追加",
mcpBridged: "ブリッジ済み",
mcpConfigured: "設定済み",
mcpEmpty: "MCPサーバーなし",
mcpNotBridged: "ブリッジ未接続",
mcpRemove: "削除",
mcpSpecFormat: "JSON形式",
mcpSpecLabel: "サーバー仕様",
memoryDesc: "メモリ設定を構成",
memorySection: "メモリ",
shortcutAbort: "中断",
shortcutCloseTab: "タブを閉じる",
shortcutCommandPalette: "コマンドパレット",
shortcutFocusComposer: "入力欄にフォーカス",
shortcutNewChat: "新しいチャット",
shortcutNewTab: "新しいタブ",
shortcutSettings: "設定",
shortcutSwitchTab: "タブ切替",
qqAccessAllowlist: "許可リスト",
qqAccessOpen: "オープン",
qqAccessOwner: "所有者",
qqAccessOwnerWithAllowlist: "所有者 + 許可リスト",
qqAccessRuntime: "ランタイム",
qqAppId: "App ID",
qqApplyAction: "申請",
qqApplyLabel: "申請",
qqAppSecret: "App Secret",
qqBack: "戻る",
qqConfigure: "設定",
qqConfigureHint: "QQ統合を設定",
qqConfigureTitle: "QQ設定",
qqConnected: "接続済み",
qqConnecting: "接続中…",
qqDisconnected: "切断済み",
qqEnvironment: "環境",
qqFailed: "失敗",
qqProduction: "本番",
qqSandbox: "サンドボックス",
qqSave: "保存",
qqSaveAndConnect: "保存して接続",
qqSection: "QQ統合",
qqSummaryAppId: "App ID",
qqSummaryDetail: "詳細",
qqSummaryMissing: "未設定",
qqTitle: "QQボット",
},
chat: {
...en.chat,
modeMirror: "TUIミラー",
modeView: "セッションビュー",
placeholder: "プロンプトを入力 — Enterで送信、Shift+Enterで改行 · / @ でピッカー",
placeholderBusy: "現在のターンの完了を待っています…",
placeholderSteerBusy: "入力で操作 — ビジー中はコマンドとピッカーが無効です",
steerHint: "メッセージが現在のターンを操作します(指示として追加)",
send: "送信",
new: "新規",
clear: "クリア",
newTitle: "/new — 会話コンテキストを消去(ループログ + スクロールバック)",
clearTitle: "/clear — 表示スクロールバックのみ消去(コンテキストは保持)",
noConversation: "まだ会話がありません。下のプロンプトを送信して開始してください。",
newConfirmBusy: "ターンが実行中です。中断して新しい会話を始めますか?",
newConfirm: "現在の会話をクリアして新しく始めますか?",
newToast: "新しい会話",
clearToast: "スクロールバックをクリアしました",
newFailed: "/new 失敗: {error}",
clearFailed: "/clear 失敗: {error}",
eventStreamError: "イベントストリームが中断されました — 再接続中…",
semanticBanner: "このプロジェクトではセマンティック検索が有効になっていません。",
semanticBannerDesc:
"インデックスを一度構築すると、モデルは正確な文字列のgrepではなく意味でコードを見つけられるようになります(「認証失敗をどのように処理しているか?」など)。",
semanticBannerBtn: "構築する →",
semanticBannerDismiss: "閉じる(再表示しない)",
slashCommands: "スラッシュコマンド",
projectFiles: "プロジェクトファイル",
effortTitle: "reasoning_effort — 次のターンから適用",
effortMaxTitle: "max (DeepSeek拡張)",
effortHighTitle: "high (安全なデフォルト — vLLM / Azure互換)",
effortMediumTitle: "medium",
effortLowTitle: "low (最速)",
editGateTitle: "編集ゲート — TUIではShift+Tabで切替",
editReviewTitle: "review — 編集と未許可シェルの両方が最初に確認",
editAutoTitle: "auto — 編集は自動適用、シェルは確認あり",
editYoloTitle: "yolo — 編集とシェルの両方が自動実行、許可リストバイパス",
railSession: "セッション",
railTurns: "ターン",
railPromptTok: "プロンプトtok",
railCost: "コスト",
railCacheHit: "キャッシュヒット",
railToolBudget: "ツール予算",
railSpend: "消費",
railActivePlan: "アクティブプラン",
railProgress: "進捗",
statusModel: "モデル",
statusCtx: "ctx",
statusCache: "キャッシュ",
statusTurn: "ターン",
statusSession: "セッション",
statusBalance: "残高",
statusTurns: "{count} ターン",
waitingStats: "· · · ライブ統計待ち",
inflightPhase: "{phase}",
inflightRunning: "実行中",
inflightThinking: "思考中",
inflightStreaming: "ストリーミング中",
inflightWaiting: "待機中",
inflightReasoning: "推論 {count} ch",
inflightOut: "出力 {count} ch",
abortBtn: "中断 (Esc)",
confirmBtn: "適用 (y)",
rejectBtn: "拒否 (n)",
applyRestBtn: "残りを適用 (a)",
flipAutoBtn: "AUTOに切替 (A)",
},
};
+5
View File
@@ -0,0 +1,5 @@
import { en } from "./en";
export const ru: typeof en = {
...en,
};
+19
View File
@@ -232,14 +232,21 @@ export const zhCN = {
webSearchNote: "web_fetch + web_search 工具",
webSearchEngine: "搜索引擎",
webSearchEngineBing: "bing — 默认,国内裸 IP 直连,无需代理",
webSearchEngineBingIntl: "bing-intl — 国际版索引,走 www.bing.com",
webSearchEngineSearxng: "searxng — 自托管(端点用 /se searxng <url> 配置)",
webSearchEngineMetaso: "metaso — 每日 100 次免费(国内友好,metaso.cn 可领免费 key)",
webSearchEngineBaidu: "baidu — 百度 AI Search(设置 BAIDU_API_KEY 或 QIANFAN_API_KEY)",
webSearchEngineTavily: "tavily — 每月 1000 次免费(设置 TAVILY_API_KEY)",
webSearchEnginePerplexity: "perplexity — AI 直接回答(设置 PERPLEXITY_API_KEY)",
webSearchEngineExa: "exa — AI 直接回答,每月 1000 次免费(设置 EXA_API_KEY)",
webSearchEngineBrave: "brave — 独立索引,每月 2000 次免费(设置 BRAVE_SEARCH_API_KEY)",
webSearchEngineOllama: "ollama — Ollama 云端网页搜索(设置 OLLAMA_API_KEY)",
webSearchEngineNote: "下一次 web_search 调用生效",
webSearchApiKeySignup: "(获取 key)",
webSearchApiKeyClear: "清除",
webSearchApiKey: {
baidu: "百度 API key",
},
sectionBudget: "预算",
budget: "预算",
budgetHint: "设置支出限额",
@@ -305,10 +312,18 @@ export const zhCN = {
themeStyleHint: "选择主题风格",
themeStyleGraphite: "石墨",
themeStyleGraphiteDesc: "冷色深色调,适合长时间使用。",
themeStyleEmber: "余烬",
themeStyleEmberDesc: "暖黑深色调,强化 Reasonix 橙色品牌感。",
themeStyleAurora: "极光",
themeStyleAuroraDesc: "青绿色深色调,低光环境更柔和。",
themeStyleSandstone: "砂岩",
themeStyleSandstoneDesc: "暖色浅色调,明亮环境更舒适。",
themeStylePorcelain: "瓷白",
themeStylePorcelainDesc: "高对比度浅色,简洁清爽。",
themeStyleLinen: "亚麻",
themeStyleLinenDesc: "偏纸张质感的温暖浅色调。",
themeStyleGlacier: "冰川",
themeStyleGlacierDesc: "冷白浅色调,蓝色强调更清爽。",
themeStyleMidnight: "午夜",
themeStyleMidnightDesc: "深色配冷色高光,更深邃。",
pageGeneralLabel: "通用",
@@ -1210,8 +1225,12 @@ export const zhCN = {
themeDark: "深色",
themeLight: "浅色",
themeStyleGraphite: "石墨",
themeStyleEmber: "余烬",
themeStyleAurora: "极光",
themeStyleSandstone: "砂岩",
themeStylePorcelain: "瓷白",
themeStyleLinen: "亚麻",
themeStyleGlacier: "冰川",
themeStyleMidnight: "午夜",
thisTurn: "本轮",
tokens: "Tokens",
+1
View File
@@ -331,6 +331,7 @@ function emitServerSettings(settings: any, overview?: any): void {
model: overview?.model ?? settings?.model ?? "deepseek-v4-flash",
editor: "code",
webSearchEngine: settings?.webSearchEngine ?? "bing",
webSearchApiKeys: settings?.webSearchApiKeys ?? {},
subagentModels: settings?.subagentModels ?? {},
version: overview?.version ?? "",
baseUrl: settings?.baseUrl ?? "",
+7
View File
@@ -291,8 +291,10 @@ export type ReasoningEffort = "low" | "medium" | "high" | "max";
export type WebSearchEngineName =
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa"
@@ -311,6 +313,9 @@ export type SettingsEvent = {
model: string;
editor?: string;
webSearchEngine?: WebSearchEngineName;
webSearchApiKeys?: {
baidu?: string;
};
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
version: string;
@@ -342,9 +347,11 @@ export type SettingsPatch = {
budgetUsd?: number | null;
baseUrl?: string;
workspaceDir?: string;
recentWorkspaces?: string[];
model?: string;
editor?: string;
webSearchEngine?: WebSearchEngineName;
baiduApiKey?: string | null;
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
};
+194 -2
View File
@@ -86,6 +86,70 @@ body[data-drag-over="1"]::after {
--radius-lg: 12px;
}
[data-theme-style="ember"] {
--bg: oklch(15% 0.018 38);
--bg-2: oklch(18% 0.018 38);
--panel: oklch(20.5% 0.018 38);
--panel-2: oklch(23.5% 0.02 38);
--card: oklch(22.5% 0.018 38);
--card-hover: oklch(26% 0.022 38);
--border: oklch(31% 0.026 38);
--border-strong: oklch(39% 0.032 38);
--fg: oklch(96% 0.012 72);
--fg-2: oklch(82% 0.014 72);
--muted: oklch(64% 0.014 70);
--muted-2: oklch(50% 0.014 68);
--accent: oklch(70% 0.18 38);
--accent-soft: oklch(70% 0.18 38 / 0.15);
--accent-strong: oklch(75% 0.19 36);
--success: oklch(69% 0.13 145);
--success-soft: oklch(69% 0.13 145 / 0.14);
--warning: oklch(78% 0.14 82);
--warning-soft: oklch(78% 0.14 82 / 0.16);
--danger: oklch(66% 0.18 25);
--danger-soft: oklch(66% 0.18 25 / 0.16);
--violet: oklch(72% 0.12 315);
--violet-soft: oklch(72% 0.12 315 / 0.16);
--shadow-sm: 0 1px 0 oklch(0% 0 0 / 0.38);
--shadow-md: 0 8px 24px -8px oklch(0% 0 0 / 0.54);
--shadow-lg: 0 24px 60px -16px oklch(0% 0 0 / 0.7);
}
[data-theme-style="aurora"] {
--bg: oklch(16% 0.018 190);
--bg-2: oklch(18.5% 0.02 190);
--panel: oklch(21% 0.022 190);
--panel-2: oklch(24.5% 0.024 190);
--card: oklch(23% 0.023 190);
--card-hover: oklch(27% 0.026 190);
--border: oklch(32% 0.028 190);
--border-strong: oklch(41% 0.033 190);
--fg: oklch(94% 0.012 185);
--fg-2: oklch(80% 0.018 185);
--muted: oklch(63% 0.02 185);
--muted-2: oklch(50% 0.02 185);
--accent: oklch(72% 0.13 175);
--accent-soft: oklch(72% 0.13 175 / 0.16);
--accent-strong: oklch(78% 0.14 170);
--success: oklch(73% 0.14 145);
--success-soft: oklch(73% 0.14 145 / 0.14);
--warning: oklch(79% 0.13 85);
--warning-soft: oklch(79% 0.13 85 / 0.16);
--danger: oklch(67% 0.17 25);
--danger-soft: oklch(67% 0.17 25 / 0.16);
--violet: oklch(72% 0.12 250);
--violet-soft: oklch(72% 0.12 250 / 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);
}
[data-theme="light"],
[data-theme-style="sandstone"] {
--bg: oklch(97.5% 0.008 80);
@@ -152,6 +216,70 @@ body[data-drag-over="1"]::after {
--shadow-lg: 0 24px 60px -22px oklch(20% 0.01 260 / 0.18);
}
[data-theme-style="linen"] {
--bg: oklch(98% 0.018 78);
--bg-2: oklch(95.5% 0.022 76);
--panel: oklch(99% 0.012 78);
--panel-2: oklch(93.5% 0.026 75);
--card: oklch(99.2% 0.01 78);
--card-hover: oklch(96% 0.018 76);
--border: oklch(87% 0.028 72);
--border-strong: oklch(76% 0.036 68);
--fg: oklch(22% 0.018 45);
--fg-2: oklch(36% 0.018 45);
--muted: oklch(53% 0.016 50);
--muted-2: oklch(67% 0.014 58);
--accent: oklch(57% 0.18 34);
--accent-soft: oklch(57% 0.18 34 / 0.1);
--accent-strong: oklch(51% 0.2 32);
--success: oklch(47% 0.13 150);
--success-soft: oklch(47% 0.13 150 / 0.1);
--warning: oklch(57% 0.14 72);
--warning-soft: oklch(57% 0.14 72 / 0.12);
--danger: oklch(53% 0.2 22);
--danger-soft: oklch(53% 0.2 22 / 0.1);
--violet: oklch(55% 0.13 315);
--violet-soft: oklch(55% 0.13 315 / 0.1);
--shadow-sm: 0 1px 0 oklch(30% 0.05 50 / 0.05);
--shadow-md: 0 8px 24px -10px oklch(30% 0.05 50 / 0.13);
--shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.2);
}
[data-theme-style="glacier"] {
--bg: oklch(98.2% 0.01 235);
--bg-2: oklch(95.8% 0.012 235);
--panel: oklch(99.6% 0.004 235);
--panel-2: oklch(94.5% 0.014 235);
--card: oklch(100% 0 0);
--card-hover: oklch(96.6% 0.012 235);
--border: oklch(88% 0.018 235);
--border-strong: oklch(78% 0.026 235);
--fg: oklch(23% 0.018 255);
--fg-2: oklch(37% 0.018 255);
--muted: oklch(54% 0.016 250);
--muted-2: oklch(68% 0.014 245);
--accent: oklch(56% 0.13 215);
--accent-soft: oklch(56% 0.13 215 / 0.1);
--accent-strong: oklch(50% 0.15 215);
--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.02 240 / 0.04);
--shadow-md: 0 8px 24px -12px oklch(20% 0.02 240 / 0.1);
--shadow-lg: 0 24px 60px -22px oklch(20% 0.02 240 / 0.18);
}
[data-theme-style="midnight"] {
--bg: oklch(19% 0.024 265);
--bg-2: oklch(17% 0.026 260);
@@ -849,6 +977,7 @@ html[data-platform="macos"] .titlebar .tb-left {
font-weight: 500;
}
.side-head .new-btn .shortcut {
display: none;
flex: 0 0 auto;
margin-left: auto;
}
@@ -864,9 +993,9 @@ html[data-platform="macos"] .titlebar .tb-left {
padding: 1px 5px;
box-shadow: none;
}
@container sidebar (max-width: 190px) {
@container sidebar (min-width: 191px) {
.side-head .new-btn .shortcut {
display: none;
display: inline-flex;
}
}
.side-head .icon-btn {
@@ -3024,6 +3153,9 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
flex-direction: column;
z-index: 50;
}
.popup.at-popup {
width: min(720px, calc(100vw - 32px));
}
.popup .ph {
padding: 8px 12px;
font-family: inherit;
@@ -3045,8 +3177,12 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
}
.popup-list {
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
.at-popup-list .popup-item {
min-width: 0;
}
.popup-item {
display: grid;
grid-template-columns: 24px 1fr auto;
@@ -3074,12 +3210,20 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
.popup-item .nm {
font-weight: 500;
color: var(--fg);
min-width: 0;
overflow: hidden;
}
.popup-item .nm .cmd {
font-family: inherit;
color: var(--accent);
margin-right: 6px;
}
.popup-item .mention-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popup-item .desc {
display: block;
font-size: 14px;
@@ -4456,6 +4600,30 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(3) {
background: oklch(68% 0.16 38);
}
.style-card[data-style="ember"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="ember"] .style-swatches span:nth-child(1) {
background: oklch(15% 0.018 38);
}
.style-card[data-style="ember"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="ember"] .style-swatches span:nth-child(2) {
background: oklch(23.5% 0.02 38);
}
.style-card[data-style="ember"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="ember"] .style-swatches span:nth-child(3) {
background: oklch(70% 0.18 38);
}
.style-card[data-style="aurora"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="aurora"] .style-swatches span:nth-child(1) {
background: oklch(16% 0.018 190);
}
.style-card[data-style="aurora"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="aurora"] .style-swatches span:nth-child(2) {
background: oklch(24.5% 0.024 190);
}
.style-card[data-style="aurora"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="aurora"] .style-swatches span:nth-child(3) {
background: oklch(72% 0.13 175);
}
.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);
@@ -4480,6 +4648,30 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(3) {
background: oklch(56% 0.12 285);
}
.style-card[data-style="linen"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="linen"] .style-swatches span:nth-child(1) {
background: oklch(98% 0.018 78);
}
.style-card[data-style="linen"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="linen"] .style-swatches span:nth-child(2) {
background: oklch(93.5% 0.026 75);
}
.style-card[data-style="linen"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="linen"] .style-swatches span:nth-child(3) {
background: oklch(57% 0.18 34);
}
.style-card[data-style="glacier"] .style-swatches span:nth-child(1),
.theme-pop-item[data-style="glacier"] .style-swatches span:nth-child(1) {
background: oklch(98.2% 0.01 235);
}
.style-card[data-style="glacier"] .style-swatches span:nth-child(2),
.theme-pop-item[data-style="glacier"] .style-swatches span:nth-child(2) {
background: oklch(94.5% 0.014 235);
}
.style-card[data-style="glacier"] .style-swatches span:nth-child(3),
.theme-pop-item[data-style="glacier"] .style-swatches span:nth-child(3) {
background: oklch(56% 0.13 215);
}
.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);
+16
View File
@@ -11,8 +11,12 @@ export function isTheme(value: unknown): value is Theme {
export const THEME_STYLE = {
GRAPHITE: "graphite",
EMBER: "ember",
AURORA: "aurora",
SANDSTONE: "sandstone",
PORCELAIN: "porcelain",
LINEN: "linen",
GLACIER: "glacier",
MIDNIGHT: "midnight",
} as const;
@@ -25,23 +29,35 @@ export const DEFAULT_THEME_STYLE: Record<Theme, ThemeStyle> = {
export const THEME_STYLE_THEME: Record<ThemeStyle, Theme> = {
graphite: THEME.DARK,
ember: THEME.DARK,
aurora: THEME.DARK,
sandstone: THEME.LIGHT,
porcelain: THEME.LIGHT,
linen: THEME.LIGHT,
glacier: THEME.LIGHT,
midnight: THEME.DARK,
};
export const THEME_STYLES = [
THEME_STYLE.GRAPHITE,
THEME_STYLE.EMBER,
THEME_STYLE.AURORA,
THEME_STYLE.SANDSTONE,
THEME_STYLE.PORCELAIN,
THEME_STYLE.LINEN,
THEME_STYLE.GLACIER,
THEME_STYLE.MIDNIGHT,
] as const;
export function isThemeStyle(value: unknown): value is ThemeStyle {
return (
value === THEME_STYLE.GRAPHITE ||
value === THEME_STYLE.EMBER ||
value === THEME_STYLE.AURORA ||
value === THEME_STYLE.SANDSTONE ||
value === THEME_STYLE.PORCELAIN ||
value === THEME_STYLE.LINEN ||
value === THEME_STYLE.GLACIER ||
value === THEME_STYLE.MIDNIGHT
);
}
+3 -2
View File
@@ -668,7 +668,8 @@ export function MetricStrip({
costLabel,
elapsed,
}: {
cacheHit?: number;
/** Pre-formatted cache percentage string (e.g. "72.12%"). */
cacheHit?: string;
promptTokens?: number;
outputTokens?: number;
costLabel?: string;
@@ -680,7 +681,7 @@ export function MetricStrip({
<span className="item">
<I.zap size={11} style={{ color: "var(--accent)" }} />
<span>{t("cards.cacheHit")}</span>
<span className="v acc">{cacheHit}%</span>
<span className="v acc">{cacheHit}</span>
</span>
) : null}
{promptTokens !== undefined ? (
+37 -13
View File
@@ -64,6 +64,7 @@ export type SlashCmd = {
};
export type MentionItem = {
name: string;
path?: string;
kind: "file" | "dir" | "url" | "agent" | "clip";
desc?: string;
};
@@ -103,6 +104,20 @@ function parentOfAtQuery(query: string): string | null {
return parentIdx >= 0 ? `${dirContext.slice(0, parentIdx)}/` : "";
}
function displayMentionPath(path: string): { name: string; desc?: string } {
const normalized = path.replace(/\\/g, "/");
const isDir = /[\\/]$/.test(path);
const trimmed = normalized.replace(/\/+$/, "");
if (!trimmed) return { name: path };
const slash = trimmed.lastIndexOf("/");
const base = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
const parent = slash >= 0 ? `${trimmed.slice(0, slash)}/` : "";
return {
name: isDir ? `${base}/` : base,
desc: parent || undefined,
};
}
function atIcon(k: MentionItem["kind"]) {
if (k === "file") return <I.file size={12} />;
if (k === "dir") return <I.folder size={12} />;
@@ -240,11 +255,15 @@ export function Composer({
const atItems = useMemo<MentionItem[]>(() => {
if (!popup || popup.kind !== "at") return [];
if (!mentionResults || mentionResults.nonce !== popup.nonce) return [];
const base: MentionItem[] = mentionResults.results.map((path) => ({
name: path,
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
desc: path,
}));
const base: MentionItem[] = mentionResults.results.map((path) => {
const display = displayMentionPath(path);
return {
name: display.name,
path,
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
desc: display.desc,
};
});
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
const parent = parentOfAtQuery(popup.query);
if (parent !== null) {
@@ -317,9 +336,10 @@ export function Composer({
return;
}
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
setDraft(next ? `${next} @${mention.name} ` : `@${mention.name} `);
setChips((c) => [...c, { kind: "at", label: mention.name }]);
onMentionPicked?.(mention.name);
const mentionPath = mention.path ?? mention.name;
setDraft(next ? `${next} @${mentionPath} ` : `@${mentionPath} `);
setChips((c) => [...c, { kind: "at", label: mentionPath }]);
onMentionPicked?.(mentionPath);
}
setPopup(null);
textareaRef.current?.focus();
@@ -361,7 +381,7 @@ export function Composer({
setPopup({ kind: "at", query: parent, nonce });
return;
}
const dirPath = mention.name.replace(/\/+$/, "");
const dirPath = (mention.path ?? mention.name).replace(/[\\/]+$/, "");
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
setDraft(next);
const nonce = ++nonceRef.current;
@@ -603,7 +623,8 @@ export function Composer({
onHover={(i, item) => {
setActiveIdx(i);
if (popup.kind === "at" && onMentionPreview) {
const path = (item as MentionItem).name;
const mention = item as MentionItem;
const path = mention.path ?? mention.name;
onMentionPreview(path, popup.nonce);
}
}}
@@ -640,7 +661,10 @@ function Popup({
}, [activeIdx]);
return (
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
<div
className={kind === "at" ? "popup at-popup" : "popup"}
onMouseDown={(e) => e.preventDefault()}
>
<div className="ph">
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
<span>
@@ -653,7 +677,7 @@ function Popup({
<I.x size={11} />
</span>
</div>
<div className="popup-list" ref={listRef}>
<div className={kind === "at" ? "popup-list at-popup-list" : "popup-list"} ref={listRef}>
{items.length === 0 ? (
<div
style={{
@@ -687,7 +711,7 @@ function Popup({
</>
) : (
<>
<span>{(it as MentionItem).name}</span>
<span className="mention-name">{(it as MentionItem).name}</span>
{(it as MentionItem).desc ? (
<div className="desc">{(it as MentionItem).desc}</div>
) : null}
+7 -1
View File
@@ -258,7 +258,13 @@ export function UsageFull({
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);
const pct = (n: number): number => {
const raw = (n / total) * 100;
if (raw === 100) return 100;
const rounded = Math.round(raw * 100) / 100;
if (rounded >= 100) return 99.99;
return rounded;
};
return (
<div className="usage-full">
<div className="uh">
+83
View File
@@ -1,3 +1,4 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { type ReactNode, useEffect, useState } from "react";
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
@@ -669,8 +670,10 @@ function PageGeneral({
onSave({
webSearchEngine: e.target.value as
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa"
@@ -680,8 +683,10 @@ function PageGeneral({
}
>
<option value="bing">{t("settings.webSearchEngineBing")}</option>
<option value="bing-intl">{t("settings.webSearchEngineBingIntl")}</option>
<option value="searxng">{t("settings.webSearchEngineSearxng")}</option>
<option value="metaso">{t("settings.webSearchEngineMetaso")}</option>
<option value="baidu">{t("settings.webSearchEngineBaidu")}</option>
<option value="tavily">{t("settings.webSearchEngineTavily")}</option>
<option value="perplexity">{t("settings.webSearchEnginePerplexity")}</option>
<option value="exa">{t("settings.webSearchEngineExa")}</option>
@@ -689,11 +694,89 @@ function PageGeneral({
<option value="ollama">{t("settings.webSearchEngineOllama")}</option>
</select>
</div>
<WebSearchEngineCredentials settings={settings} onSave={onSave} />
</section>
</>
);
}
function WebSearchEngineCredentials({
settings,
onSave,
}: {
settings: SettingsType;
onSave: (patch: SettingsPatch) => void;
}) {
if (settings.webSearchEngine !== "baidu") return null;
return (
<WebSearchApiKeyRow
prefix={settings.webSearchApiKeys?.baidu}
signupUrl="https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy"
onSave={onSave}
/>
);
}
function WebSearchApiKeyRow({
prefix,
signupUrl,
onSave,
}: {
prefix?: string;
signupUrl: string;
onSave: (patch: SettingsPatch) => void;
}) {
const [draft, setDraft] = useState("");
return (
<div className="setting-row">
<div className="l">
<div className="n">{t("settings.webSearchApiKey.baidu")}</div>
<div className="h">
{prefix ? t("settings.apiKeySet", { prefix }) : t("settings.apiKeyNotSet")}{" "}
<a
href={signupUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => {
e.preventDefault();
void openUrl(signupUrl).catch(() => undefined);
}}
>
{t("settings.webSearchApiKeySignup")}
</a>
</div>
</div>
<div style={{ display: "flex", gap: 6 }}>
<input
className="field mono"
type="password"
value={draft}
placeholder={prefix ?? ""}
onChange={(e) => setDraft(e.target.value)}
/>
<button
type="button"
className="btn primary"
disabled={!draft.trim()}
onClick={() => {
const trimmed = draft.trim();
if (!trimmed) return;
onSave({ baiduApiKey: trimmed });
setDraft("");
}}
>
{t("settings.apiKeySave")}
</button>
{prefix ? (
<button type="button" className="btn" onClick={() => onSave({ baiduApiKey: null })}>
{t("settings.webSearchApiKeyClear")}
</button>
) : null}
</div>
</div>
);
}
function ApiKeySection({
baseUrl,
apiKeyPrefix,
+18 -3
View File
@@ -19,6 +19,16 @@ function tokenLabel(n: number): string {
return `${n}`;
}
/** Format a 0–100 percentage to 2 decimal places.
* 100.00 is shown only when the value is exactly 100; values that would round
* to 100.00 are capped at 99.99. */
function formatPct(value: number): string {
if (value === 100) return "100.00";
const rounded = Math.round(value * 100) / 100;
if (rounded >= 100) return "99.99";
return rounded.toFixed(2);
}
export function StatusBar({
settings,
balance,
@@ -53,7 +63,12 @@ export function StatusBar({
onOpenWorkdir?: (anchor: { bottom: number; left: number }) => void;
}) {
const totalTokens = usage.cacheHitTokens + usage.cacheMissTokens;
const cacheHitPct = totalTokens > 0 ? Math.round((usage.cacheHitTokens / totalTokens) * 100) : 0;
const cacheHitRaw = totalTokens > 0 ? (usage.cacheHitTokens / totalTokens) * 100 : 0;
const cacheHitPctDisplay = formatPct(cacheHitRaw);
const cacheHitDetail =
totalTokens > 0
? `${usage.cacheHitTokens.toLocaleString()} / ${totalTokens.toLocaleString()} tokens (${cacheHitPctDisplay}%)`
: "";
const runningJobs = jobs.filter((j) => j.running).length;
const spent = formatMoney(usage.totalCostUsd, currency);
const balanceLabel = balance
@@ -85,10 +100,10 @@ export function StatusBar({
<span>{settings?.baseUrl?.replace(/^https?:\/\//, "") ?? "api.deepseek.com"}</span>
<span className="v">{!ready ? t("statusbar.offline") : busy ? t("statusbar.busy") : t("statusbar.online")}</span>
</span>
<span className="seg" title={t("statusbar.cacheHit")}>
<span className="seg" title={cacheHitDetail || t("statusbar.cacheHit")}>
<I.zap size={11} style={{ color: "var(--accent)" }} />
<span>{t("statusbar.cache")}</span>
<span className="v acc">{cacheHitPct}%</span>
<span className="v acc">{cacheHitPctDisplay}%</span>
</span>
<span className="seg">
<I.cpu size={11} />
+96
View File
@@ -0,0 +1,96 @@
type TextSegmentLike = {
kind: "text" | "reasoning";
text: string;
};
type ToolSegmentLike = {
kind: "tool";
args: string;
result?: string;
};
type SegmentLike = TextSegmentLike | ToolSegmentLike | { kind: string };
type AssistantMessageLike = {
kind: "assistant";
pending?: boolean;
segments: SegmentLike[];
};
export const TRANSCRIPT_RECENT_MESSAGE_WINDOW = 120;
const MIN_ELIDE_CHARS = 4096;
const ELIDED_PREFIX = "[elided -- older than the last ";
function isAssistantMessage(value: unknown): value is AssistantMessageLike {
return (
typeof value === "object" &&
value !== null &&
(value as { kind?: unknown }).kind === "assistant" &&
Array.isArray((value as { segments?: unknown }).segments)
);
}
function isElided(value: unknown): boolean {
return typeof value === "string" && value.startsWith(ELIDED_PREFIX);
}
function elidedStub(chars: number): string {
return `${ELIDED_PREFIX}${TRANSCRIPT_RECENT_MESSAGE_WINDOW} messages; ${chars.toLocaleString()} chars dropped to keep long sessions responsive. Full content remains in the session log.]`;
}
function elideField(value: string): string {
if (value.length <= MIN_ELIDE_CHARS || isElided(value)) return value;
return elidedStub(value.length);
}
function elideSegment<S extends SegmentLike>(segment: S): S {
if (
(segment.kind === "text" || segment.kind === "reasoning") &&
typeof (segment as TextSegmentLike).text === "string"
) {
const textSegment = segment as TextSegmentLike;
const text = elideField(textSegment.text);
return text === textSegment.text ? segment : ({ ...segment, text } as S);
}
if (segment.kind !== "tool") return segment;
const toolSegment = segment as ToolSegmentLike;
let next: S | null = null;
const args = elideField(toolSegment.args);
if (args !== toolSegment.args) next = { ...segment, args } as S;
if (typeof toolSegment.result === "string") {
const result = elideField(toolSegment.result);
if (result !== toolSegment.result) next = { ...(next ?? segment), result } as S;
}
return next ?? segment;
}
function elideSegments<S extends SegmentLike>(segments: S[]): S[] {
let next: S[] | null = null;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (!segment) continue;
const elided = elideSegment(segment);
if (elided !== segment) {
if (next === null) next = segments.slice();
next[i] = elided;
}
}
return next ?? segments;
}
export function elideTranscriptMessages<T>(messages: T[]): T[] {
if (messages.length <= TRANSCRIPT_RECENT_MESSAGE_WINDOW) return messages;
const cutoff = messages.length - TRANSCRIPT_RECENT_MESSAGE_WINDOW;
let next: T[] | null = null;
for (let i = 0; i < cutoff; i++) {
const message = messages[i];
if (!isAssistantMessage(message) || message.pending) continue;
const segments = elideSegments(message.segments);
if (segments !== message.segments) {
if (next === null) next = messages.slice();
next[i] = { ...message, segments } as T;
}
}
return next ?? messages;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "reasonix-desktop",
"version": "0.50.0",
"version": "0.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "reasonix-desktop",
"version": "0.50.0",
"version": "0.52.0",
"dependencies": {
"@fontsource/geist": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
+19 -29
View File
@@ -1,5 +1,5 @@
// 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).
// macOS downloads the host-arch binary so arm64 and x64 releases stay split.
import { execSync } from "node:child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
import https from "node:https";
@@ -22,6 +22,7 @@ if (!HOST_ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
const isWin = PLAT === "win32";
const targetExe = join(binDir, isWin ? "node.exe" : "node");
const MAC_ARCH = HOST_ARCH === "x64" ? "x86_64" : HOST_ARCH;
function adhocSignMac(binPath) {
// #1611: child node needs com.apple.security.inherit so it inherits the
@@ -53,16 +54,16 @@ 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")) {
if (archs === MAC_ARCH) {
if (!hasMacTccInheritance(targetExe)) {
console.log(`${targetExe} universal but missing TCC inheritance — applying ad-hoc sign`);
console.log(`${targetExe} ${MAC_ARCH} build missing TCC inheritance — applying ad-hoc sign`);
adhocSignMac(targetExe);
} else {
console.log(`${targetExe} already universal + TCC-inheriting (${archs}) — delete to refetch`);
console.log(`${targetExe} already ${MAC_ARCH} + TCC-inheriting — delete to refetch`);
}
process.exit(0);
}
console.log(`${targetExe} present but not universal (${archs}) — rebuilding`);
console.log(`${targetExe} present but not ${MAC_ARCH} (${archs}) — rebuilding`);
rmSync(targetExe);
} catch {
console.log(`${targetExe} present but not verifiable as Mach-O — rebuilding`);
@@ -148,36 +149,25 @@ async function fetchAndExtract(arch) {
return { inner, extractDir };
}
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 });
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 });
adhocSignMac(targetExe);
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)`);
}
+75
View File
@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -962,6 +974,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1450,6 +1474,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1465,6 +1498,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -2014,6 +2056,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2852,7 +2905,9 @@ name = "reasonix-desktop"
version = "0.52.0"
dependencies = [
"anyhow",
"dirs",
"parking_lot",
"rusqlite",
"serde",
"serde_json",
"tauri",
@@ -3012,6 +3067,20 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -4603,6 +4672,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
+3 -1
View File
@@ -8,7 +8,7 @@ publish = false
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["devtools", "macos-private-api"] }
tauri = { version = "2", features = ["devtools", "macos-private-api", "tray-icon"] }
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
@@ -20,6 +20,8 @@ serde_json = "1"
parking_lot = "0.12"
anyhow = "1"
which = "7"
dirs = "6"
rusqlite = { version = "0.32", features = ["bundled"] }
[features]
custom-protocol = ["tauri/custom-protocol"]
+445
View File
@@ -0,0 +1,445 @@
use rusqlite::{Connection, OpenFlags};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportedMcpServer {
pub name: String,
pub transport: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportCcSwitchMcpResult {
pub source: String,
pub path: String,
pub servers: Vec<ImportedMcpServer>,
}
#[tauri::command]
pub fn import_cc_switch_mcp() -> Result<ImportCcSwitchMcpResult, String> {
let base_dir = cc_switch_dir();
let db_path = base_dir.join("cc-switch.db");
let cfg_path = base_dir.join("config.json");
if db_path.exists() {
match load_from_db(&db_path) {
Ok(servers) if !servers.is_empty() => {
return Ok(ImportCcSwitchMcpResult {
source: "db".to_string(),
path: db_path.display().to_string(),
servers,
});
}
Ok(_) => {
return Err(format!("No MCP servers found in {}", db_path.display()));
}
Err(db_err) => {
if cfg_path.exists() {
let fallback = load_from_config(&cfg_path).map_err(|cfg_err| {
format!(
"Failed to read {} ({db_err}) and {} ({cfg_err})",
db_path.display(),
cfg_path.display()
)
})?;
if fallback.is_empty() {
return Err(format!(
"No MCP servers found in {} or {}",
db_path.display(),
cfg_path.display()
));
}
return Ok(ImportCcSwitchMcpResult {
source: "config".to_string(),
path: cfg_path.display().to_string(),
servers: fallback,
});
}
return Err(format!("Failed to read {}: {db_err}", db_path.display()));
}
}
}
if cfg_path.exists() {
let servers = load_from_config(&cfg_path)?;
if servers.is_empty() {
return Err(format!("No MCP servers found in {}", cfg_path.display()));
}
return Ok(ImportCcSwitchMcpResult {
source: "config".to_string(),
path: cfg_path.display().to_string(),
servers,
});
}
Err(format!(
"cc-switch config not found. Expected {} or {}",
db_path.display(),
cfg_path.display()
))
}
fn cc_switch_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".cc-switch")
}
fn load_from_db(path: &Path) -> Result<Vec<ImportedMcpServer>, String> {
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|err| err.to_string())?;
let columns = mcp_server_columns(&conn)?;
let config_column = if columns.iter().any(|column| column == "server") {
"server"
} else if columns.iter().any(|column| column == "server_config") {
"server_config"
} else {
return Err("mcp_servers table has no server or server_config column".to_string());
};
let has_name_column = columns.iter().any(|column| column == "name");
let name_column = if has_name_column { "name" } else { "id" };
let order_by = if has_name_column {
"name ASC, id ASC"
} else {
"id ASC"
};
let query =
format!("SELECT id, {name_column}, {config_column} FROM mcp_servers ORDER BY {order_by}");
let mut stmt = conn.prepare(&query).map_err(|err| err.to_string())?;
let rows = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let config: String = row.get(2)?;
Ok((id, name, config))
})
.map_err(|err| err.to_string())?;
let mut servers = Vec::new();
for row in rows {
let (id, name, config) = row.map_err(|err| err.to_string())?;
let parsed: Value = serde_json::from_str(&config).map_err(|err| err.to_string())?;
let display_name = name.trim();
let import_name = if display_name.is_empty() {
id.as_str()
} else {
display_name
};
if let Some(server) = parse_server(import_name, &parsed) {
servers.push(server);
}
}
Ok(servers)
}
fn mcp_server_columns(conn: &Connection) -> Result<Vec<String>, String> {
let mut stmt = conn
.prepare("PRAGMA table_info(mcp_servers)")
.map_err(|err| err.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|err| err.to_string())?;
let mut columns = Vec::new();
for row in rows {
columns.push(row.map_err(|err| err.to_string())?);
}
Ok(columns)
}
fn load_from_config(path: &Path) -> Result<Vec<ImportedMcpServer>, String> {
let raw = fs::read_to_string(path).map_err(|err| err.to_string())?;
let parsed: Value = serde_json::from_str(&raw).map_err(|err| err.to_string())?;
let Some(mcp_root) = parsed.get("mcp") else {
return Ok(Vec::new());
};
let Some(servers) = mcp_root.get("servers").and_then(Value::as_object) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for (id, entry) in servers {
let Some(spec) = entry.get("server") else {
continue;
};
if let Some(server) = parse_server(id, spec) {
out.push(server);
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn parse_server(id: &str, value: &Value) -> Option<ImportedMcpServer> {
let name = id.trim();
if name.is_empty() {
return None;
}
let obj = value.as_object()?;
let transport = detect_transport(obj);
match transport.as_str() {
"stdio" => {
let command = obj.get("command")?.as_str()?.trim().to_string();
if command.is_empty() {
return None;
}
Some(ImportedMcpServer {
name: name.to_string(),
transport,
command: Some(command),
args: Some(read_string_array(obj.get("args"))),
env: read_string_map(obj.get("env")),
cwd: read_optional_string(obj.get("cwd")),
url: None,
headers: None,
disabled: read_optional_bool(obj.get("disabled")),
request_timeout_ms: read_request_timeout_ms(obj),
})
}
"sse" | "streamable-http" => {
let url = obj.get("url")?.as_str()?.trim().to_string();
if url.is_empty() {
return None;
}
Some(ImportedMcpServer {
name: name.to_string(),
transport,
command: None,
args: None,
env: None,
cwd: None,
url: Some(url),
headers: read_string_map(obj.get("headers")),
disabled: read_optional_bool(obj.get("disabled")),
request_timeout_ms: read_request_timeout_ms(obj),
})
}
_ => None,
}
}
fn detect_transport(obj: &serde_json::Map<String, Value>) -> String {
let declared = obj
.get("transport")
.or_else(|| obj.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
match declared {
"http" | "streamable-http" => "streamable-http".to_string(),
"sse" => "sse".to_string(),
"stdio" => "stdio".to_string(),
_ => match obj.get("url").and_then(Value::as_str) {
Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
"sse".to_string()
}
_ => "stdio".to_string(),
},
}
}
fn read_optional_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(ToString::to_string)
}
fn read_optional_bool(value: Option<&Value>) -> Option<bool> {
match value {
Some(Value::Bool(flag)) => Some(*flag),
Some(Value::String(text)) => match text.trim().to_ascii_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
},
_ => None,
}
}
fn read_request_timeout_ms(obj: &serde_json::Map<String, Value>) -> Option<u64> {
obj.get("requestTimeoutMs")
.or_else(|| obj.get("timeoutMs"))
.and_then(read_optional_u64)
}
fn read_optional_u64(value: &Value) -> Option<u64> {
match value {
Value::Number(num) => num.as_u64(),
Value::String(text) => text.trim().parse::<u64>().ok(),
_ => None,
}
}
fn read_string_array(value: Option<&Value>) -> Vec<String> {
let Some(items) = value.and_then(Value::as_array) else {
return Vec::new();
};
items
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect()
}
fn read_string_map(value: Option<&Value>) -> Option<HashMap<String, String>> {
let Some(entries) = value.and_then(Value::as_object) else {
return None;
};
let mut out = HashMap::new();
for (key, entry) in entries {
let Some(text) = entry.as_str() else { continue };
if !text.is_empty() {
out.insert(key.clone(), text.to_string());
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_db_path(label: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"reasonix-cc-switch-{label}-{}-{suffix}.db",
std::process::id()
))
}
#[test]
fn parse_stdio_server_preserves_disabled_and_timeout() {
let value = json!({
"command": "npx",
"args": ["-y", "pkg"],
"disabled": true,
"requestTimeoutMs": 120000
});
let server = parse_server("memory", &value).expect("server should parse");
assert_eq!(server.name, "memory");
assert_eq!(server.transport, "stdio");
assert_eq!(server.disabled, Some(true));
assert_eq!(server.request_timeout_ms, Some(120000));
}
#[test]
fn parse_http_server_accepts_timeout_string() {
let value = json!({
"transport": "streamable-http",
"url": "https://example.test/mcp",
"disabled": "false",
"timeoutMs": "90000"
});
let server = parse_server("remote", &value).expect("server should parse");
assert_eq!(server.transport, "streamable-http");
assert_eq!(server.disabled, Some(false));
assert_eq!(server.request_timeout_ms, Some(90000));
}
#[test]
fn load_from_db_reads_current_server_column() {
let path = temp_db_path("server-column");
{
let conn = Connection::open(&path).expect("db should open");
conn.execute_batch(
r#"
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server TEXT NOT NULL
);
"#,
)
.expect("schema should be created");
let server = json!({
"transport": "streamable-http",
"url": "https://example.test/mcp"
})
.to_string();
conn.execute(
"INSERT INTO mcp_servers (id, name, server) VALUES (?1, ?2, ?3)",
params!["123e4567-e89b-12d3-a456-426614174000", "Remote", server],
)
.expect("row should insert");
}
let servers = load_from_db(&path).expect("db should load");
fs::remove_file(&path).ok();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "Remote");
assert_eq!(servers[0].transport, "streamable-http");
assert_eq!(servers[0].url.as_deref(), Some("https://example.test/mcp"));
}
#[test]
fn load_from_db_falls_back_to_legacy_server_config_column() {
let path = temp_db_path("server-config-column");
{
let conn = Connection::open(&path).expect("db should open");
conn.execute_batch(
r#"
CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_config TEXT NOT NULL
);
"#,
)
.expect("schema should be created");
let server = json!({
"command": "npx",
"args": ["-y", "pkg"]
})
.to_string();
conn.execute(
"INSERT INTO mcp_servers (id, name, server_config) VALUES (?1, ?2, ?3)",
params!["stdio", "Stdio", server],
)
.expect("row should insert");
}
let servers = load_from_db(&path).expect("db should load");
fs::remove_file(&path).ok();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "Stdio");
assert_eq!(servers[0].transport, "stdio");
assert_eq!(servers[0].command.as_deref(), Some("npx"));
}
}
+162 -14
View File
@@ -1,16 +1,103 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod cc_switch;
mod rpc;
use rpc::{RpcState, rpc_kill, rpc_send, rpc_spawn};
use cc_switch::import_cc_switch_mcp;
use rpc::{rpc_kill, rpc_send, rpc_spawn, RpcState};
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::menu::MenuBuilder;
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::Manager;
const TRAY_MENU_SHOW: &str = "show";
const TRAY_MENU_QUIT: &str = "quit";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DesktopCloseBehavior {
CloseToTray,
CloseToQuit,
}
fn pasted_images_dir() -> PathBuf {
std::env::temp_dir().join("reasonix-pasted-images")
}
fn parse_desktop_close_behavior(value: &serde_json::Value) -> DesktopCloseBehavior {
match value
.get("desktopCloseBehavior")
.and_then(serde_json::Value::as_str)
{
Some("closeToTray") => DesktopCloseBehavior::CloseToTray,
_ => DesktopCloseBehavior::CloseToQuit,
}
}
fn config_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".reasonix").join("config.json"))
}
fn desktop_close_behavior() -> DesktopCloseBehavior {
let Some(path) = config_path() else {
return DesktopCloseBehavior::CloseToQuit;
};
let Ok(raw) = std::fs::read_to_string(path) else {
return DesktopCloseBehavior::CloseToQuit;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else {
return DesktopCloseBehavior::CloseToQuit;
};
parse_desktop_close_behavior(&value)
}
fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
fn install_tray(app: &mut tauri::App) -> tauri::Result<()> {
let menu = MenuBuilder::new(app)
.text(TRAY_MENU_SHOW, "Show Window")
.separator()
.text(TRAY_MENU_QUIT, "Quit Reasonix")
.build()?;
let mut tray = TrayIconBuilder::with_id("main")
.tooltip("Reasonix")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
TRAY_MENU_SHOW => show_main_window(app),
TRAY_MENU_QUIT => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}
| TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
} => show_main_window(tray.app_handle()),
_ => {}
});
if let Some(icon) = app.default_window_icon().cloned() {
tray = tray.icon(icon);
}
tray.build(app)?;
Ok(())
}
/// #892: bundled libwayland in AppImage can ABI-mismatch the host Wayland
/// compositor → WebKitWebProcess `abort()`s on EGL display creation. Redirect
/// the child to the host's libwayland via LD_PRELOAD before WebKit forks.
@@ -88,7 +175,9 @@ fn walk_dir(dir: &Path, depth: u32, max_depth: u32, out: &mut Vec<FileEntry>) {
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_str()) {
continue;
}
let Ok(file_type) = entry.file_type() else { continue };
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path().to_string_lossy().into_owned();
if file_type.is_dir() {
out.push(FileEntry {
@@ -134,7 +223,10 @@ fn git_status(root: String) -> Result<Vec<GitStatusEntry>, String> {
return Err(format!("not a directory: {root}"));
}
let mut cmd = Command::new("git");
cmd.arg("status").arg("--porcelain").arg("-z").current_dir(root_path);
cmd.arg("status")
.arg("--porcelain")
.arg("-z")
.current_dir(root_path);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
@@ -205,7 +297,9 @@ fn open_in_editor(command: String, path: String, line: Option<u32>) -> Result<()
cmd.arg(&path);
}
}
cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd.spawn().map_err(|e| format!("spawn {trimmed}: {e}"))?;
Ok(())
}
@@ -222,7 +316,11 @@ fn sanitize_image_extension(raw: Option<&str>) -> String {
let ok = !cleaned.is_empty()
&& cleaned.len() <= 8
&& cleaned.chars().all(|c| c.is_ascii_alphanumeric());
if ok { cleaned } else { "png".to_string() }
if ok {
cleaned
} else {
"png".to_string()
}
}
#[tauri::command]
@@ -241,12 +339,20 @@ fn save_clipboard_image(bytes: Vec<u8>, extension: Option<String>) -> Result<Str
fn purge_old_pasted_images(max_age: Duration) {
let dir = pasted_images_dir();
let Ok(entries) = std::fs::read_dir(&dir) else { return };
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let cutoff = SystemTime::now().checked_sub(max_age);
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else { continue };
if !metadata.is_file() { continue }
let Ok(modified) = metadata.modified() else { continue };
let Ok(metadata) = entry.metadata() else {
continue;
};
if !metadata.is_file() {
continue;
}
let Ok(modified) = metadata.modified() else {
continue;
};
if cutoff.is_some_and(|t| modified < t) {
let _ = std::fs::remove_file(entry.path());
}
@@ -264,11 +370,23 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.on_window_event(|window, event| {
if window.label() != "main" {
return;
}
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if desktop_close_behavior() == DesktopCloseBehavior::CloseToTray {
api.prevent_close();
let _ = window.hide();
}
}
})
.manage(RpcState::default())
.invoke_handler(tauri::generate_handler![
rpc_spawn,
rpc_send,
rpc_kill,
import_cc_switch_mcp,
open_in_editor,
list_workspace_tree,
git_status,
@@ -276,8 +394,8 @@ fn main() {
save_clipboard_image
])
.setup(|app| {
use tauri::Manager;
std::thread::spawn(|| purge_old_pasted_images(Duration::from_secs(24 * 60 * 60)));
install_tray(app)?;
if let Some(w) = app.get_webview_window("main") {
// HiDPI fit: the JSON config asks for 1024x720 logical px.
// On Windows laptops at 200% scale (1920x1080 → 960x540
@@ -309,21 +427,27 @@ fn main() {
})
.build(tauri::generate_context!())
.expect("tauri build failed")
.run(|app, event| {
.run(|app, event| match event {
// Tauri 2 normally exits the process via Exit; managed-state drops
// don't always run. ExitRequested fires before that, so we kill the
// Node child here too — belt-and-braces vs the Drop on RpcHandle.
if let tauri::RunEvent::ExitRequested { .. } = event {
use tauri::Manager;
tauri::RunEvent::ExitRequested { .. } => {
let state = app.state::<RpcState>();
let _ = rpc::rpc_kill(state);
}
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen {
has_visible_windows: false,
..
} => show_main_window(app),
_ => {}
});
}
#[cfg(test)]
mod tests {
use super::sanitize_image_extension;
use super::{parse_desktop_close_behavior, sanitize_image_extension, DesktopCloseBehavior};
use serde_json::json;
#[test]
fn accepts_alphanumeric_extensions() {
@@ -352,4 +476,28 @@ mod tests {
fn rejects_overlong_extensions() {
assert_eq!(sanitize_image_extension(Some("verylongext")), "png");
}
#[test]
fn desktop_close_behavior_defaults_to_quit() {
assert_eq!(
parse_desktop_close_behavior(&json!({})),
DesktopCloseBehavior::CloseToQuit
);
}
#[test]
fn desktop_close_behavior_accepts_tray_mode() {
assert_eq!(
parse_desktop_close_behavior(&json!({ "desktopCloseBehavior": "closeToTray" })),
DesktopCloseBehavior::CloseToTray
);
}
#[test]
fn desktop_close_behavior_accepts_quit_mode() {
assert_eq!(
parse_desktop_close_behavior(&json!({ "desktopCloseBehavior": "closeToQuit" })),
DesktopCloseBehavior::CloseToQuit
);
}
}
+14 -6
View File
@@ -6,12 +6,12 @@ use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use anyhow::{anyhow, Context, Result};
use parking_lot::Mutex;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
#[cfg(not(debug_assertions))]
use tauri::Manager;
use tauri::{AppHandle, Emitter, State};
use which::which_all;
#[derive(Default)]
@@ -69,7 +69,10 @@ fn resolve_cli(app: &AppHandle) -> Result<(String, Vec<String>)> {
if is_real_node && cli_path.exists() {
return Ok((
node_path.to_string_lossy().into_owned(),
vec![cli_path.to_string_lossy().into_owned(), "desktop".to_string()],
vec![
cli_path.to_string_lossy().into_owned(),
"desktop".to_string(),
],
));
}
}
@@ -85,7 +88,6 @@ fn resolve_cli(app: &AppHandle) -> Result<(String, Vec<String>)> {
let entry = candidates
.into_iter()
.find(|p| p.exists())
.map(PathBuf::from)
.ok_or_else(|| anyhow!("dist/cli/index.js not found — run `npm run build` at repo root"))?;
let node_path = find_real_node().context("node not found")?;
@@ -123,7 +125,11 @@ fn find_real_node() -> Result<PathBuf> {
"{} ({} bytes{})",
p.display(),
size,
if is_ms_store_shim { ", MS Store shim" } else { "" },
if is_ms_store_shim {
", MS Store shim"
} else {
""
},
));
}
}
@@ -263,7 +269,9 @@ fn kill_process_tree(pid: u32) {
#[tauri::command]
pub fn rpc_kill(state: State<'_, RpcState>) -> Result<(), String> {
let handle_opt = state.inner.lock().take();
let Some(handle) = handle_opt else { return Ok(()) };
let Some(handle) = handle_opt else {
return Ok(());
};
drop(handle.stdin);
+1 -1
View File
@@ -26,7 +26,7 @@
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data: asset: https://asset.localhost; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
"csp": "default-src 'self'; img-src 'self' data: asset: https://asset.localhost; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://api.deepseek.com https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev https://github.com"
}
},
"bundle": {
+43 -1
View File
@@ -34,7 +34,7 @@ vi.mock("./theme", () => ({
themeForStyle: vi.fn(() => "dark"),
}));
import { reduce } from "./App";
import { readWindowExpanded, reduce, toggleWindowExpanded } from "./App";
import { getThreadMaxWidth } from "./ui/thread-layout";
function initialState(): Parameters<typeof reduce>[0] {
@@ -68,6 +68,7 @@ function initialState(): Parameters<typeof reduce>[0] {
balance: null,
mentionResults: null,
mentionPreview: null,
promptHistoryResult: null,
mcpSpecs: [],
mcpBridged: false,
skills: [],
@@ -213,6 +214,47 @@ describe("Desktop App reducer — usage", () => {
});
});
describe("Desktop App window controls", () => {
it("treats macOS zoom state as fullscreen", async () => {
const win = {
isFullscreen: vi.fn(async () => true),
isMaximized: vi.fn(async () => false),
setFullscreen: vi.fn(async () => {}),
toggleMaximize: vi.fn(async () => {}),
};
await expect(readWindowExpanded(win, true)).resolves.toBe(true);
expect(win.isFullscreen).toHaveBeenCalledTimes(1);
expect(win.isMaximized).not.toHaveBeenCalled();
});
it("uses native fullscreen for macOS zoom button", async () => {
const win = {
isFullscreen: vi.fn(async () => false),
isMaximized: vi.fn(async () => false),
setFullscreen: vi.fn(async () => {}),
toggleMaximize: vi.fn(async () => {}),
};
await toggleWindowExpanded(win, true, false);
expect(win.setFullscreen).toHaveBeenCalledWith(true);
expect(win.toggleMaximize).not.toHaveBeenCalled();
});
it("keeps maximize behavior off macOS", async () => {
const win = {
isFullscreen: vi.fn(async () => false),
isMaximized: vi.fn(async () => false),
setFullscreen: vi.fn(async () => {}),
toggleMaximize: vi.fn(async () => {}),
};
await toggleWindowExpanded(win, false, false);
expect(win.toggleMaximize).toHaveBeenCalledTimes(1);
expect(win.setFullscreen).not.toHaveBeenCalled();
});
});
describe("Desktop App reducer — ApprovalPrompt integration", () => {
it("stores shell confirm with prompt on $confirm_required", () => {
const state = initialState();
+580 -292
View File
File diff suppressed because it is too large. Load diff
+121 -16
View File
@@ -3,11 +3,11 @@ import { openPath, openUrl } from "@tauri-apps/plugin-opener";
import { Check, Copy, ExternalLink, FileText } from "lucide-react";
import {
Children,
type ReactNode,
cloneElement,
createContext,
isValidElement,
memo,
type ReactNode,
useContext,
useState,
} from "react";
@@ -24,26 +24,113 @@ async function openWithEditor(
abs: string,
line?: number,
): Promise<void> {
if (editor && editor.trim()) {
if (editor?.trim()) {
await invoke("open_in_editor", { command: editor, path: abs, line: line ?? null });
return;
}
await openPath(abs);
}
type WorkspaceCtx = { dir?: string; editor?: string };
type TrackedFile = { path: string; status: string };
type WorkspaceCtx = { dir?: string; editor?: string; sessionFiles?: TrackedFile[]; currentToolPaths?: string[] };
const WorkspaceContext = createContext<WorkspaceCtx>({});
export const WorkspaceProvider = WorkspaceContext.Provider;
function resolveAgainstWorkspace(rel: string, ws: string | undefined): string {
if (!ws) return rel;
const isWindows = ws.includes("\\");
if (/^[a-zA-Z]:[\\/]/.test(rel) || rel.startsWith("/")) {
return isWindows ? rel.replace(/\//g, "\\") : rel;
/**
* Wrap children with a WorkspaceProvider that merges currentToolPaths
* into the existing context (preserving dir/editor/sessionFiles).
*/
export function WithToolPaths({ toolPaths, children }: { toolPaths: string[]; children: React.ReactNode }) {
const ctx = useContext(WorkspaceContext);
return <WorkspaceProvider value={{ ...ctx, currentToolPaths: toolPaths }}>{children}</WorkspaceProvider>;
}
/**
* Try to find a tracked session file whose path ends with the displayed text.
* This handles cases where the AI writes a short/relative path in conversation
* but the actual file lives at a different absolute path (which is known from
* the tool call args in sessionFiles).
*/
/**
* Try to match displayPath against a list of paths (exact → suffix).
* Returns the best matching path, or null if none match.
*/
function matchPathList(
displayPath: string,
paths: string[],
matchIndex: number = 0,
): string | null {
if (!paths || paths.length === 0) return null;
const normalizedDisplay = displayPath.replace(/\\/g, "/");
let exact: string | null = null;
const suffixMatches: string[] = [];
for (const raw of paths) {
const n = raw.replace(/\\/g, "/");
if (n === normalizedDisplay) { exact = raw; continue; }
if (n.endsWith("/" + normalizedDisplay) || n.endsWith("\\" + normalizedDisplay)) {
suffixMatches.push(raw);
}
}
// Exact match — always correct
if (exact) return exact;
// Unique suffix match
if (suffixMatches.length === 1) return suffixMatches[0]!;
// Ambiguous — use positional matching:
// The Nth file pill maps to the Nth tool call path.
if (suffixMatches.length > 1) {
const idx = matchIndex % suffixMatches.length;
return suffixMatches[idx]!;
}
return null;
}
function resolveBySessionFiles(
displayPath: string,
sessionFiles: TrackedFile[] | undefined,
currentToolPaths?: string[],
matchIndex: number = 0,
): string | null {
// Step 1: Try the current message's tool paths first (most specific context)
if (currentToolPaths && currentToolPaths.length > 0) {
const local = matchPathList(displayPath, currentToolPaths, matchIndex);
if (local) return local;
}
// Step 2: Try global session files
if (!sessionFiles || sessionFiles.length === 0) return null;
const globalPaths = sessionFiles.map((sf) => sf.path);
return matchPathList(displayPath, globalPaths, matchIndex);
}
function resolveAgainstWorkspace(
rel: string,
ws: string | undefined,
sessionFiles?: TrackedFile[],
currentToolPaths?: string[],
matchIndex: number = 0,
): string {
// Step 1: Try suffix matching against tracked session files.
// If found, use the tracked path as the base instead of the display text.
const sessionResolved = resolveBySessionFiles(rel, sessionFiles, currentToolPaths, matchIndex);
const basePath = sessionResolved ?? rel;
// Step 2: If basePath is already absolute, return it normalized
if (/^[a-zA-Z]:[\\/]/.test(basePath) || basePath.startsWith("/")) {
return ws?.includes("\\") ? basePath.replace(/\//g, "\\") : basePath;
}
// Step 3: Resolve relative basePath against workspace
if (!ws) return basePath;
const isWindows = ws.includes("\\");
const sep = isWindows ? "\\" : "/";
const trimmed = ws.replace(/[\\/]$/, "");
const relative = rel.replace(/^\.[\\/]/, "").replace(/\//g, sep);
const relative = basePath.replace(/^\.[\\/]/, "").replace(/\//g, sep);
return `${trimmed}${sep}${relative}`;
}
@@ -63,7 +150,7 @@ const LINE_REF_SOURCE = "(?::(\\d+(?::\\d+)?(?:-\\d+)?))?";
// "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\`'"(\\[])(${FILE_REF_SOURCE})${LINE_REF_SOURCE}(?=[\\s.,;!?\\]\\)'"\`]|$)`,
`(^|[\\s\`'"(\\[])(${FILE_REF_SOURCE})${LINE_REF_SOURCE}(?=[\\s.,;!?\\(\\[\\]\\)'"\`]|$)`,
"g",
);
const EXACT_FILE_REF_RE = new RegExp(`^(${FILE_REF_SOURCE})${LINE_REF_SOURCE}$`);
@@ -113,14 +200,14 @@ function parseFileHref(value: string): ParsedFileRef | null {
return { ...parsed, line: parsed.line ?? hashLine };
}
function FilePill({ path, line }: { path: string; line?: string }) {
function FilePill({ path, line, matchIndex }: { path: string; line?: string; matchIndex: number }) {
useLang();
const ctx = useContext(WorkspaceContext);
const [done, setDone] = useState<"open" | "copy" | null>(null);
const display = line ? `${path}:${line}` : path;
const abs = resolveAgainstWorkspace(path, ctx.dir, ctx.sessionFiles, ctx.currentToolPaths, matchIndex);
const openInEditor = async () => {
try {
const abs = resolveAgainstWorkspace(path, ctx.dir);
await openWithEditor(ctx.editor, abs, firstLine(line));
setDone("open");
setTimeout(() => setDone(null), 1200);
@@ -181,7 +268,8 @@ function splitFilePaths(text: string): ReactNode[] | string {
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} />);
out.push(<FilePill key={`fp-${pillStart}`} path={path} line={line} matchIndex={_filePillIndex} />);
_filePillIndex++;
last = pillStart + path.length + (line ? line.length + 1 : 0);
m = FILE_PATH_RE.exec(text);
}
@@ -231,10 +319,26 @@ function normalizeMathDelimiters(source: string): string {
.replace(/\\\)/g, "$$");
// Restore protected sequences
result = result.replace(/\x00LB\x00/g, "\\\\[");
// Replace | with \vert inside math to prevent GFM table column splitting.
// \vert renders identically to | in KaTeX — it's the same vertical-bar
// glyph — but the markdown parser won't mistake it for a table separator.
result = result.replace(
/\$\$([\s\S]*?)\$\$/g,
(_: string, m: string) => `$$${m.replace(/\|/g, "\\vert ")}$$`,
);
result = result.replace(
/\$([^$\n]+)\$/g,
(_: string, m: string) => `$${m.replace(/\|/g, "\\vert ")}$`,
);
return result;
}
let _filePillIndex = 0;
export const Markdown = memo(function Markdown({ source }: { source: string }) {
_filePillIndex = 0;
return (
<div className="markdown">
<ReactMarkdown
@@ -249,7 +353,7 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
code: ({ className, children }) => {
const text = String(children ?? "");
const parsed = !className ? parseFileRef(text.trim()) : null;
if (parsed) return <FilePill path={parsed.path} line={parsed.line} />;
if (parsed) return <FilePill path={parsed.path} line={parsed.line} matchIndex={_filePillIndex++} />;
return <code className={className}>{children}</code>;
},
a: ({ href, children }) => <SafeLink href={href}>{children}</SafeLink>,
@@ -289,7 +393,7 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) {
try {
const parsed = parseFileHref(href);
const target = parsed ?? { path: decodeMaybeUri(stripFileScheme(href)) };
const abs = resolveAgainstWorkspace(target.path, ctx.dir);
const abs = resolveAgainstWorkspace(target.path, ctx.dir, ctx.sessionFiles, ctx.currentToolPaths, 0);
await openWithEditor(ctx.editor, abs, firstLine(target.line));
} catch {
try {
@@ -338,7 +442,8 @@ export function extractFencedLang(children: ReactNode): string {
function flattenChildText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(flattenChildText).join("");
if (isValidElement(node)) return flattenChildText((node.props as { children?: ReactNode }).children);
if (isValidElement(node))
return flattenChildText((node.props as { children?: ReactNode }).children);
return "";
}
+80 -4
View File
@@ -57,6 +57,7 @@ export const de: typeof en = {
searchPlaceholder: "Letzte Pfade suchen...",
empty: "Keine letzten Arbeitsbereiche",
browse: "Lokal durchsuchen...",
removeRecent: "Aus Liste entfernen",
},
sidebar: {
...en.sidebar,
@@ -73,6 +74,9 @@ export const de: typeof en = {
copiedMd: "Unterhaltung als Markdown kopiert",
newSession: "Neue Sitzung gestartet",
cleared: "UI geleert",
mcpStatusOpened: "MCP-Statusleiste geöffnet",
mcpRetryNone: "Keine fehlgeschlagenen MCP-Server zum Wiederholen",
mcpRetryQueued: "{count} fehlgeschlagene MCP-Server werden erneut versucht",
aboutLine: "Reasonix v{version} · Cache-First-DeepSeek-Agent",
},
composer: {
@@ -132,10 +136,18 @@ export const de: typeof en = {
themeStyleHint: "Wähle die visuelle Palette für den gewählten hellen oder dunklen Modus.",
themeStyleGraphite: "Graphit",
themeStyleGraphiteDesc: "Originale dunkle Palette mit neutralen Graphit-Panels.",
themeStyleEmber: "Ember",
themeStyleEmberDesc: "Warme dunkle Palette mit stärkerem Reasonix-Orange.",
themeStyleAurora: "Aurora",
themeStyleAuroraDesc: "Türkisgrüne dunkle Palette für weichere Arbeit bei wenig Licht.",
themeStyleSandstone: "Sandstein",
themeStyleSandstoneDesc: "Originale warme helle Palette.",
themeStylePorcelain: "Porzellan",
themeStylePorcelainDesc: "Saubere Codex-ähnliche helle Palette mit ruhigem Kontrast.",
themeStyleLinen: "Leinen",
themeStyleLinenDesc: "Editoriale warme helle Palette mit papierartigen Flächen.",
themeStyleGlacier: "Gletscher",
themeStyleGlacierDesc: "Kühle helle Palette mit klaren blauen Akzenten.",
themeStyleMidnight: "Mitternacht",
themeStyleMidnightDesc: "Marineblaue dunkle Palette mit kühlen blauen Oberflächen.",
fontScale: "Schriftgröße",
@@ -168,6 +180,11 @@ export const de: typeof en = {
"Zeige leise Inline-Hinweise zur Kontextkomprimierung, Abbruch und Ratelimit-Pausen.",
shown: "angezeigt",
hidden: "ausgeblendet",
desktopCloseBehavior: "Fenster schließen",
desktopCloseBehaviorHint:
"Standardmäßig beendet Schließen die App. In die Ablage ausblenden hält laufende Sitzungen aktiv.",
closeToQuit: "beenden",
closeToTray: "in Ablage",
editModeReviewDesc: "jeden Schreibzugriff genehmigen",
editModeAutoDesc: "Befehle genehmigen, Edits auto-anwenden",
editModeYoloDesc: "nichts genehmigen — Vollautomatik",
@@ -176,9 +193,12 @@ export const de: typeof en = {
budgetPlaceholder: "keine Grenze",
webSearchEngine: "Suchmaschine",
webSearchEngineBing: "bing — Standard, funktioniert von CN ohne Proxy",
webSearchEngineBingIntl: "bing-intl — internationaler Index über www.bing.com",
webSearchEngineSearxng: "searxng — selbst gehostet (Endpunkt via /se searxng <url>)",
webSearchEngineMetaso:
"metaso — 100/Tag kostenlos (CN-freundlich, kostenloser Schlüssel auf metaso.cn)",
webSearchEngineBaidu:
"baidu — Baidu AI Search, laut Baidu-Doku 1500/Monat kostenlos (BAIDU_API_KEY oder QIANFAN_API_KEY setzen)",
webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)",
webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)",
webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)",
@@ -191,6 +211,7 @@ export const de: typeof en = {
webSearchApiKeyClear: "Löschen",
webSearchApiKey: {
metaso: "Metaso API-Key",
baidu: "Baidu API-Key",
tavily: "Tavily API-Key",
perplexity: "Perplexity API-Key",
exa: "Exa API-Key",
@@ -282,20 +303,61 @@ export const de: typeof en = {
modelCustomHint:
"Jede OpenAI-kompatible ID, die dein Endpunkt bereitstellt (vLLM, Ollama, Together, …).",
modelCustomActive: "Läuft aktuell auf benutzerdefinierter ID: {model}",
contextTokensLabel: "Kontextfenstergröße",
contextTokensHint: "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.",
contextTokensPlaceholder: "Automatisch",
effortSection: "Reasoning-Effort",
ctxWindow: "Kontext",
maxOutput: "Ausgabe",
mcpConfigured: "Konfiguriert · {count}",
mcpBridged: "· Verbunden",
mcpNotBridged:
"· Aktuelle Desktop-Sitzung nicht verbunden — starte reasonix code (TUI) neu, um wirksam zu werden",
mcpNotBridged: "· Wird auf die aktuelle Desktop-Sitzung angewendet",
mcpStatusConnected: "· Verbunden {connected}/{total}",
mcpStatusConnecting:
"· Verbinde {connected}/{total}; Konfigurationsänderungen werden automatisch angewendet",
mcpStatusFailed: "· Verbunden {connected}/{total}; {failed} fehlgeschlagen",
mcpStatusDisabled: "· {disabled}/{total} deaktiviert",
mcpStatusPending: "· Wartet auf Anwendung",
mcpStatus: {
configured: "Wartet",
handshake: "Verbindet",
connected: "Verbunden",
failed: "Fehlgeschlagen",
disabled: "Deaktiviert",
},
mcpFailure: {
auth: "Authentifizierung fehlgeschlagen · Authorization oder x-api-key in headers konfigurieren",
missingToken: "Bearer-Token fehlt · Authorization in headers konfigurieren",
command: "Befehl nicht verfügbar · command/args oder lokale Abhängigkeiten prüfen",
network: "Netzwerkfehler · Endpoint, Proxy oder Verbindung prüfen",
unknown: "Verbindung fehlgeschlagen · Details für den Originalfehler öffnen",
},
mcpEmpty: "Noch keine MCP-Server konfiguriert. Gib unten eine Spec ein, um einen hinzuzufügen.",
mcpRemove: "Entfernen",
mcpRetry: "Erneut versuchen",
mcpDetail: "Details",
mcpHideDetail: "Ausblenden",
mcpDetailTitle: "Originalfehler",
mcpEditConfig: "Bearbeiten",
parseError: "Parse fehlgeschlagen: {error}",
mcpAddSection: "Server hinzufügen",
mcpSpecLabel: "Spec-String",
mcpSpecFormat: "Format: <code>name=befehl args</code> oder <code>name=https://host/sse</code>",
mcpAdd: "Hinzufügen",
mcpImport: "Aus cc-switch importieren",
mcpImporting: "Importiere…",
mcpEdit: "Bearbeiten",
mcpEditTitle: "MCP bearbeiten",
mcpEditBack: "Zurück",
mcpEditName: "MCP-Titel (eindeutig)",
mcpEditTransport: "Transport",
mcpEditJson: "Vollständige JSON-Konfiguration",
mcpEditFormat: "Formatieren",
mcpEditSave: "Speichern",
mcpEditNameRequired: "MCP-Titel ist erforderlich",
mcpEditJsonObjectRequired: "JSON-Konfiguration muss ein Objekt sein",
mcpEditCommandRequired: "stdio-Konfiguration benötigt command",
mcpEditUrlRequired: "Remote-MCP-Konfiguration benötigt url",
skillsLoaded: "Geladen · {count} · aufrufbar über /-Befehle",
skillsEmpty:
"Keine Skills verfügbar. Erstelle SKILL.md unter ~/.reasonix/skills/ oder .reasonix/skills/ in deinem Projekt-Root.",
@@ -326,8 +388,8 @@ export const de: typeof en = {
},
modal: {
...en.modal,
planFeedbackPlaceholder: "Optional: Feedback — schreibe hier, wenn du Änderungen möchtest",
choiceCustomPlaceholder: "Oder gib deine eigene Antwort ein…",
planFeedbackPlaceholder: "Welche Probleme hat der Plan? Was muss verbessert werden?",
choiceCustomPlaceholder: "Wo kann der Plan weiter verfeinert werden?",
},
plan: {
...en.plan,
@@ -418,6 +480,10 @@ export const de: typeof en = {
abort: "Streaming stoppen",
copyLast: "Letzte Antwort kopieren",
switchModel: "Modell wechseln",
mcp: "MCP-Server-Einstellungen öffnen",
mcpStatus: "MCP-Verbindungsstatus anzeigen",
mcpRetry: "Fehlgeschlagene MCP-Verbindungen erneut versuchen",
mcpImport: "MCP-Server aus cc-switch importieren",
searchEngine: "Suchmaschine + API-Keys konfigurieren",
skill: "Skills auflisten / verwalten (Projekt + Custom + Global + Eingebaut)",
setMode: "Modus auf {mode} setzen",
@@ -608,8 +674,12 @@ export const de: typeof en = {
themeDark: "dunkel",
themeLight: "hell",
themeStyleGraphite: "Graphit",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstein",
themeStylePorcelain: "Porzellan",
themeStyleLinen: "Leinen",
themeStyleGlacier: "Gletscher",
themeStyleMidnight: "Mitternacht",
},
jobs: {
@@ -732,6 +802,8 @@ export const de: typeof en = {
},
thread: {
...en.thread,
toolCalls: "{count} Tool-Aufrufe",
oneToolCall: "1 Tool-Aufruf",
keepSteps: "{n} verbleibende(n) Schritt(e) behalten",
keepOriginal: "Original behalten",
you: "Du",
@@ -745,6 +817,10 @@ export const de: typeof en = {
approve: "Genehmigen",
cancel: "Abbrechen",
refine: "Verfeinern",
sendFeedback: "Senden",
skipFeedback: "Überspringen",
cancelFeedbackLabel: "Abbrechen — was ist falsch?",
refineFeedbackLabel: "Verfeinern — was soll geändert werden?",
checkpointKind: "Plan-Checkpoint",
checkpointTitle: "Schritt {completed} / {total} abgeschlossen",
checkpointSub: "Checkpoint · {completed} / {total}",
+99 -4
View File
@@ -51,6 +51,7 @@ export const en = {
searchPlaceholder: "Search recent paths…",
empty: "No recent workspaces",
browse: "Browse local…",
removeRecent: "Remove from recent",
},
sidebar: {
newChat: "New chat",
@@ -65,6 +66,14 @@ export const en = {
copiedMd: "Conversation copied as Markdown",
newSession: "Started a new session",
cleared: "UI cleared",
mcpImported: "Imported {imported} MCP server(s) from cc-switch",
mcpImportedWithSkipped:
"Imported {imported} MCP server(s), skipped {skipped} duplicate name(s)",
mcpImportNone: "No new MCP servers available to import",
mcpImportFailed: "Failed to import cc-switch MCP servers: {error}",
mcpStatusOpened: "Opened MCP status panel",
mcpRetryNone: "No failed MCP servers to retry",
mcpRetryQueued: "Retrying {count} failed MCP server(s)",
aboutLine: "Reasonix v{version} · cache-first DeepSeek agent",
},
composer: {
@@ -121,10 +130,18 @@ export const en = {
themeStyleHint: "Choose the visual palette for the selected light or dark mode.",
themeStyleGraphite: "Graphite",
themeStyleGraphiteDesc: "Original dark palette with neutral graphite panels.",
themeStyleEmber: "Ember",
themeStyleEmberDesc: "Warm dark palette with a stronger Reasonix orange accent.",
themeStyleAurora: "Aurora",
themeStyleAuroraDesc: "Teal-green dark palette for a softer low-light workspace.",
themeStyleSandstone: "Sandstone",
themeStyleSandstoneDesc: "Original warm light palette.",
themeStylePorcelain: "Porcelain",
themeStylePorcelainDesc: "Clean Codex-like light palette with quiet contrast.",
themeStyleLinen: "Linen",
themeStyleLinenDesc: "Editorial warm light palette with paper-like surfaces.",
themeStyleGlacier: "Glacier",
themeStyleGlacierDesc: "Cool light palette with crisp blue accents.",
themeStyleMidnight: "Midnight",
themeStyleMidnightDesc: "Navy dark palette with cool blue surfaces.",
fontScale: "Font scale",
@@ -156,6 +173,10 @@ export const en = {
"Show quiet inline notices for context compaction, abort, and rate-limit pauses.",
shown: "shown",
hidden: "hidden",
desktopCloseBehavior: "Window close",
desktopCloseBehaviorHint: "Close quits by default. Hide to tray keeps running sessions alive.",
closeToQuit: "quit",
closeToTray: "hide to tray",
editModeReviewDesc: "approve every write",
editModeAutoDesc: "approve commands, auto edits",
editModeYoloDesc: "approve nothing — full auto",
@@ -164,8 +185,11 @@ export const en = {
budgetPlaceholder: "no cap",
webSearchEngine: "search engine",
webSearchEngineBing: "bing — default, works from CN without proxy",
webSearchEngineBingIntl: "bing-intl — international index via www.bing.com",
webSearchEngineSearxng: "searxng — self-hosted (set endpoint via /se searxng <url>)",
webSearchEngineMetaso: "metaso — 100/day free (CN-friendly, free key at metaso.cn)",
webSearchEngineBaidu:
"baidu — Baidu AI Search, 1500/mo free in Baidu docs (set BAIDU_API_KEY or QIANFAN_API_KEY)",
webSearchEngineTavily: "tavily — 1000/mo free (set TAVILY_API_KEY)",
webSearchEnginePerplexity: "perplexity — AI-native (set PERPLEXITY_API_KEY)",
webSearchEngineExa: "exa — AI-native 1000/mo free (set EXA_API_KEY)",
@@ -178,6 +202,7 @@ export const en = {
webSearchApiKeyClear: "Clear",
webSearchApiKey: {
metaso: "Metaso API key",
baidu: "Baidu API key",
tavily: "Tavily API key",
perplexity: "Perplexity API key",
exa: "Exa API key",
@@ -270,21 +295,61 @@ export const en = {
modelCustom: "Custom model id",
modelCustomHint: "Any OpenAI-compatible id your endpoint serves (vLLM, Ollama, Together, …).",
modelCustomActive: "Currently running on a custom id: {model}",
contextTokensLabel: "Context window size",
contextTokensHint: "Override the prompt-side token cap for the current model (e.g. 1000000 for 1M). Leave empty to use the built-in default.",
contextTokensPlaceholder: "auto",
effortSection: "Reasoning effort",
ctxWindow: "Context",
maxOutput: "Output",
// MCP
mcpConfigured: "Configured · {count}",
mcpBridged: "· Bridged",
mcpNotBridged:
"· Current desktop session not bridged — restart reasonix code (TUI) to take effect",
mcpNotBridged: "· Applying to the current desktop session",
mcpStatusConnected: "· Connected {connected}/{total}",
mcpStatusConnecting: "· Connecting {connected}/{total}; config changes apply automatically",
mcpStatusFailed: "· Connected {connected}/{total}; {failed} failed",
mcpStatusDisabled: "· {disabled}/{total} disabled",
mcpStatusPending: "· Waiting to apply",
mcpStatus: {
configured: "Waiting",
handshake: "Connecting",
connected: "Connected",
failed: "Failed",
disabled: "Disabled",
},
mcpFailure: {
auth: "Authentication failed · configure Authorization or x-api-key in headers",
missingToken: "Missing Bearer token · configure Authorization in headers",
command: "Command unavailable · check command/args or local dependencies",
network: "Network failed · check endpoint, proxy, or connectivity",
unknown: "Connection failed · open details for the raw error",
},
mcpEmpty: "No MCP servers configured yet. Enter a spec below to add one.",
mcpRemove: "Remove",
mcpRetry: "Retry",
mcpDetail: "Details",
mcpHideDetail: "Hide",
mcpDetailTitle: "Raw error",
mcpEditConfig: "Edit",
parseError: "Parse failed: {error}",
mcpAddSection: "Add server",
mcpSpecLabel: "Spec string",
mcpSpecFormat: "Format: <code>name=command args</code> or <code>name=https://host/sse</code>",
mcpAdd: "Add",
mcpImport: "Import from cc-switch",
mcpImporting: "Importing…",
mcpEdit: "Edit",
mcpEditTitle: "Edit MCP",
mcpEditBack: "Back",
mcpEditName: "MCP title (unique)",
mcpEditTransport: "Transport",
mcpEditJson: "Full JSON config",
mcpEditFormat: "Format",
mcpEditSave: "Save",
mcpEditNameRequired: "MCP title is required",
mcpEditJsonObjectRequired: "JSON config must be an object",
mcpEditCommandRequired: "stdio config requires command",
mcpEditUrlRequired: "Remote MCP config requires url",
// Skills
skillsLoaded: "Loaded · {count} · invoke via / commands",
skillsEmpty:
@@ -318,8 +383,8 @@ export const en = {
shortcutSettings: "Settings",
},
modal: {
planFeedbackPlaceholder: "Optional feedback — write here if you want changes",
choiceCustomPlaceholder: "Or type your own answer…",
planFeedbackPlaceholder: "What problems does the plan have? What needs to be improved?",
choiceCustomPlaceholder: "Where can the plan be further refined?",
},
plan: {
activeTitle: "Active plan",
@@ -400,6 +465,10 @@ export const en = {
abort: "Stop streaming output",
copyLast: "Copy last reply",
switchModel: "Switch model",
mcp: "Open MCP server settings",
mcpStatus: "Show MCP connection status",
mcpRetry: "Retry failed MCP connections",
mcpImport: "Import MCP servers from cc-switch",
searchEngine: "Configure web search engine + API keys",
skill: "List and manage user skills (project + custom + global + builtin)",
setMode: "Set mode to {mode}",
@@ -576,10 +645,20 @@ export const en = {
themeDark: "dark",
themeLight: "light",
themeStyleGraphite: "Graphite",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstone",
themeStylePorcelain: "Porcelain",
themeStyleLinen: "Linen",
themeStyleGlacier: "Glacier",
themeStyleMidnight: "Midnight",
},
mcpCard: {
showTools: "Tools",
hideTools: "Hide tools",
availableTools: "{count} available tools",
noTools: "Connected, but this server did not expose callable tools",
},
jobs: {
title: "Background jobs",
running: "running",
@@ -618,6 +697,16 @@ export const en = {
mcpDisabled: "disabled",
mcpFailed: "failed",
mcpConfigured: "configured",
mcpHealthTotal: "MCP {count}",
mcpHealthReady: "ready {count}",
mcpHealthFailed: "failed {count}",
mcpHealthTools: "tools {count}",
mcpFilterAll: "All",
mcpFilterReady: "Ready",
mcpFilterFailed: "Failed",
mcpFilterEmpty: "No servers in this filter",
mcpRetryAll: "Retry failed",
mcpSettings: "Settings",
memoryTitle: "Long-term memory",
itemCount: "{count} items",
scopeProject: "project",
@@ -699,6 +788,8 @@ export const en = {
unknownKind: "# unknown card kind — fallback render",
},
thread: {
toolCalls: "{count} tool calls",
oneToolCall: "1 tool call",
keepSteps: "Keep {n} remaining step(s)",
keepOriginal: "Keep original",
you: "You",
@@ -712,6 +803,10 @@ export const en = {
approve: "Approve",
cancel: "Cancel",
refine: "Refine",
sendFeedback: "Send",
skipFeedback: "Skip",
cancelFeedbackLabel: "Cancel — what went wrong?",
refineFeedbackLabel: "Refine — what should change?",
checkpointKind: "plan checkpoint",
checkpointTitle: "step {completed} / {total} complete",
checkpointSub: "checkpoint · {completed} / {total}",
+14 -4
View File
@@ -1,13 +1,21 @@
import { useEffect, useState } from "react";
import { de } from "./de";
import { en } from "./en";
import { ja } from "./ja";
import { ru } from "./ru";
import { zhCN } from "./zh-CN";
export type Lang = "en" | "zh-CN" | "de";
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de"];
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
const LANG_LABELS: Record<Lang, string> = { en: "English", "zh-CN": "简体中文", de: "Deutsch" };
const LANG_LABELS: Record<Lang, string> = {
en: "English",
"zh-CN": "简体中文",
de: "Deutsch",
ja: "日本語",
ru: "Русский",
};
const STORAGE_KEY = "reasonix.lang";
type Listener = () => void;
@@ -24,6 +32,8 @@ function detectDefault(): Lang {
const lower = nav.toLowerCase();
if (lower.startsWith("zh")) return "zh-CN";
if (lower.startsWith("de")) return "de";
if (lower.startsWith("ja")) return "ja";
if (lower.startsWith("ru")) return "ru";
return "en";
}
@@ -77,7 +87,7 @@ export function useLang(): Lang {
return currentLang;
}
const dicts = { en, "zh-CN": zhCN, de } as const;
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
type Dict = typeof en;
type Path<T, K extends keyof T = keyof T> = K extends string
+889
View File
@@ -0,0 +1,889 @@
import { en } from "./en";
export const ja: typeof en = {
...en,
palette: {
...en.palette,
searchPlaceholder: "コマンドを検索…",
empty: "一致なし",
footMove: "移動",
footRun: "実行",
footClose: "閉じる",
newChat: "新しいチャット",
newChatHint: "現在の会話を破棄して新規開始",
newTab: "新しいタブ",
newTabHint: "別のワークスペースタブを開く",
closeTab: "タブを閉じる",
closeTabHint: "現在のタブを閉じる",
abort: "現在のターンを停止",
abortHint: "モデルの生成を中断",
copyLast: "最後の返信をコピー",
copyLastHint: "最新のアシスタント返信をコピー",
copyConv: "会話をMarkdownでコピー",
copyConvHint: "会話全体をクリップボードにコピー",
exportMd: "会話をMarkdownでエクスポート",
exportMdHint: ".mdファイルとして保存",
clearChat: "画面クリア",
clearChatHint: "UIのみクリア — サブプロセスは存続",
focusComposer: "入力欄にフォーカス",
pickWorkspace: "ワークスペース切替…",
pickWorkspaceHint: "別の作業ディレクトリを選択",
settings: "設定",
settingsHint: "モデル、予算、テーマ…",
about: "Reasonixについて",
groupNav: "ナビゲーション",
groupAction: "アクション",
groupWorkspace: "ワークスペース",
groupSettings: "設定",
countSuffix: "項目",
},
about: {
...en.about,
sidebarLabel: "概要",
tagline: "DeepSeekネイティブコーディングエージェント",
version: "バージョン",
repo: "リポジトリ",
checkUpdates: "更新を確認",
checking: "確認中…",
upToDate: "最新です ({version})",
updateAvailable: "更新あり: {version}",
openReleases: "リリースを見る",
checkFailed: "確認に失敗: {message}",
close: "閉じる",
},
workdir: {
...en.workdir,
title: "ワークスペース切替",
searchPlaceholder: "最近のパスを検索…",
empty: "最近のワークスペースはありません",
browse: "ローカルを参照…",
removeRecent: "履歴から削除",
},
sidebar: {
...en.sidebar,
newChat: "新しいチャット",
searchCommands: "コマンドを検索…",
recent: "最近",
noSessions: "セッションなし",
wallet: "ウォレット",
footHint: "キャッシュファーストエージェント",
},
toast: {
...en.toast,
copied: "コピーしました",
copiedMd: "会話をMarkdownでコピーしました",
newSession: "新しいセッションを開始しました",
cleared: "UIをクリアしました",
mcpStatusOpened: "MCPステータスパネルを開きました",
mcpRetryNone: "再試行する失敗MCPサーバーはありません",
mcpRetryQueued: "失敗したMCPサーバー {count} 件を再試行しています",
aboutLine: "Reasonix v{version} · キャッシュファーストDeepSeekエージェント",
},
composer: {
...en.composer,
busy: "reasonixが考え中…",
idle: "質問する",
send: "送信",
newline: "改行",
commands: "コマンド",
placeholder: "エージェントに質問 / タスクを説明…",
queueCount: "{n}件キュー",
queue: "キュー",
interrupt: "停止",
mentionFiles: "ファイルをメンション",
commandPalette: "コマンドパレット",
insertFile: "ファイルを挿入 (@メンション)",
insertImage: "画像を挿入 (@メンション)",
switchModel: "モデル切替",
switchEffort: "推論努力切替",
slashHeader: "コマンド — エージェント、モデル、セッションを制御",
atHeader: "メンション — ワークスペース内のファイル",
noMatches: "一致なし",
select: "選択",
confirm: "確定",
close: "閉じる",
imageFilterName: "画像",
commandsLabel: "コマンド",
mentionLabel: "メンション",
workspaceRoot: "ワークスペースルート",
},
dragDrop: {
...en.dragDrop,
overlay: "ドロップして@メンションとして添付",
},
settings: {
...en.settings,
title: "設定",
close: "閉じる",
language: "言語",
languageHint: "UIのみ — エージェントのプロンプトには影響しません。",
langEn: "English",
langZhCn: "简体中文",
langEnDesc: "英語UI",
langZhCnDesc: "中国語UI",
costCurrency: "コスト通貨",
costCurrencyHint:
"表示のみ。内部コストはトークンあたりUSD換算で計算され、固定レート7.2で変換されます。",
cnyDesc: "DeepSeekダッシュボードに一致",
usdDesc: "トークンあたりのリファレンスレートに一致",
theme: "テーマ",
themeHint: "このデバイスのみ、TUIとは同期されません。",
themeDark: "ダーク",
themeLight: "ライト",
themeDarkDesc: "デフォルト · 低照度",
themeLightDesc: "高コントラスト · 昼間",
themeStyle: "スタイル",
themeStyleHint: "選択したライト/ダークモードのビジュアルパレットを選択します。",
themeStyleGraphite: "グラファイト",
themeStyleGraphiteDesc: "ニュートラルなグラファイトパネルのオリジナルダークパレット。",
themeStyleEmber: "エンバー",
themeStyleEmberDesc: "Reasonixのオレンジを強めた暖かいダークパレット。",
themeStyleAurora: "オーロラ",
themeStyleAuroraDesc: "低照度でも柔らかいティールグリーンのダークパレット。",
themeStyleSandstone: "サンドストーン",
themeStyleSandstoneDesc: "オリジナルの温かみのあるライトパレット。",
themeStylePorcelain: "ポーセリン",
themeStylePorcelainDesc: "静かなコントラストのクリーンなCodexライクなライトパレット。",
themeStyleLinen: "リネン",
themeStyleLinenDesc: "紙のような表面を持つ編集調の暖かいライトパレット。",
themeStyleGlacier: "グレイシャー",
themeStyleGlacierDesc: "くっきりしたブルーアクセントのクールなライトパレット。",
themeStyleMidnight: "ミッドナイト",
themeStyleMidnightDesc: "クールなブルーの表面を持つネイビーダークパレット。",
fontScale: "フォントスケール",
fontScaleHint: "UI全体をスケーリング; 高DPI画面では大きめを選択。",
fontScaleSmall: "小",
fontScaleMedium: "中",
fontScaleLarge: "大",
fontFamily: "フォントファミリー",
fontFamilyHint: "本文用; コードブロックは常に等幅です。",
fontFamilySans: "サンセリフ",
fontFamilySystem: "システム",
fontFamilySerif: "セリフ",
fontFamilyCustom: "カスタム",
customFontFamily: "カスタムフォント",
customFontFamilyHint: "デスクトップUI全体のCSS font-family値。",
reasoningEffort: "推論努力",
reasoningEffortHint: "実行中のセッションに即時適用されます。",
effortHigh: "high",
effortMax: "max",
effortHighDesc: "高速、思考少なめ",
effortMaxDesc: "深い推論、低速",
editMode: "編集ゲート",
editModeHint:
"ツール承認動作 — 変更はディスクに保存され、次のツール呼び出しから有効になります。",
editModeReview: "review",
editModeAuto: "auto",
editModeYolo: "yolo",
showSystemEvents: "システムイベント",
showSystemEventsHint:
"コンテキスト圧縮、中断、レート制限の一時停止に関する静かなインライン通知を表示します。",
shown: "表示",
hidden: "非表示",
desktopCloseBehavior: "ウィンドウを閉じる",
desktopCloseBehaviorHint:
"既定では閉じると終了します。トレイに隠すと実行中のセッションを維持します。",
closeToQuit: "終了",
closeToTray: "トレイに隠す",
editModeReviewDesc: "すべての書き込みを承認",
editModeAutoDesc: "コマンドを承認、編集は自動",
editModeYoloDesc: "すべて自動承認 — 完全自動",
budget: "予算上限",
budgetHint: "セッションあたりUSD。空欄 = 上限なし。",
budgetPlaceholder: "上限なし",
webSearchEngine: "検索エンジン",
webSearchEngineBing: "bing — デフォルト、プロキシなしで中国から利用可",
webSearchEngineBingIntl: "bing-intl — www.bing.com 経由の国際版インデックス",
webSearchEngineSearxng: "searxng — セルフホスト (/se searxng <url> でエンドポイント設定)",
webSearchEngineMetaso: "metaso — 100回/日無料 (中国対応、metaso.cnで無料キー取得)",
webSearchEngineBaidu:
"baidu — Baidu AI Search、公式ドキュメントでは1500回/月無料 (BAIDU_API_KEY または QIANFAN_API_KEY を設定)",
webSearchEngineTavily: "tavily — 1000回/月無料 (TAVILY_API_KEY を設定)",
webSearchEnginePerplexity: "perplexity — AIネイティブ (PERPLEXITY_API_KEY を設定)",
webSearchEngineExa: "exa — AIネイティブ 1000回/月無料 (EXA_API_KEY を設定)",
webSearchEngineBrave: "brave — 独立インデックス、2000回/月無料 (BRAVE_SEARCH_API_KEY を設定)",
webSearchEngineOllama: "ollama — Ollama クラウド web 検索 (OLLAMA_API_KEY を設定)",
webSearchEngineNote: "次の web_search 呼び出しに適用されます",
webSearchEndpoint: "SearXNG エンドポイント",
webSearchEndpointHint: "SearXNGインスタンスのベースURL。デフォルトは http://localhost:8080。",
webSearchApiKeySignup: "(キーを取得)",
webSearchApiKeyClear: "クリア",
webSearchApiKey: {
metaso: "Metaso APIキー",
baidu: "Baidu APIキー",
tavily: "Tavily APIキー",
perplexity: "Perplexity APIキー",
exa: "Exa APIキー",
brave: "Brave APIキー",
ollama: "Ollama APIキー",
},
baseUrl: "DeepSeek ベースURL",
baseUrlHint: "プロキシ使用時のみ上書き。空欄 = 公式エンドポイント。再起動が必要です。",
workspace: "ワークスペース",
workspaceHint:
"エージェントツールが操作するルートディレクトリ。切り替えは設定に保存され、ツールが再読み込みされます。",
workspaceChange: "変更…",
editor: "エディタ",
editorHint:
"ファイルリンク/パスをクリックしたときに使用。システムデフォルト = OSのファイル関連付け。その他はPATHに必要。",
editorSystem: "システムデフォルト",
editorCustom: "カスタム",
editorPlaceholder: '例: "code", "subl", または実行ファイルのフルパス',
editorDefault: "(デフォルト)",
apiKey: "APIキー",
apiKeyHint: "DeepSeekキー — ~/.reasonix/config.json (0600) に保存。",
apiKeyNotSet: "(未設定)",
apiKeyCancel: "キャンセル",
apiKeySave: "保存",
apiKeyShow: "表示",
apiKeyHide: "非表示",
environment: "環境",
model: "モデル",
pageGeneralLabel: "一般",
pageGeneralDesc: "外観、言語、動作",
pageModelsLabel: "モデル",
pageModelsDesc: "デフォルトモデルとサンプリングパラメータ",
pageMcpLabel: "MCPサーバー",
pageMcpDesc: "MCPプロトコルツールサーバーの管理",
pageSkillsLabel: "スキル",
pageSkillsDesc: "/ コマンド用の再利用可能なプロンプトセット",
pageMemoryLabel: "メモリ",
pageMemoryDesc: "CLAUDE.md / AGENTS.md インジェクション",
pageRulesLabel: "承認ルール",
pageRulesDesc: "コマンドパターンの自動承認、拒否、確認",
pageBillingLabel: "アカウントと請求",
pageBillingDesc: "残高、使用量、請求書",
pageShortcutsLabel: "ショートカット",
pageShortcutsDesc: "キーボードショートカット概要",
appearanceSection: "外観",
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オープンプラットフォーム環境を選択します。テストにはSandbox、本番にはProductionを使用してください。",
qqBack: "戻る",
qqAppId: "App ID",
qqAppSecret: "App Secret",
qqSandbox: "Sandbox",
qqProduction: "Production",
qqAccess: "アクセス",
qqAccessOwner: "所有者 {openId}",
qqAccessOwnerWithAllowlist: "所有者 {openId} + 許可リスト {count}",
qqAccessAllowlist: "許可リスト {count}",
qqAccessRuntime: "最初の送信者(ランタイムのみ, {openId})",
qqAccessOpen: "オープン(制限なし)",
qqApplyLabel: "QQボットを申請",
qqApplyAction: "申請ページを開く",
qqSave: "保存",
qqSaveAndConnect: "保存して接続",
mcpSection: "設定済み",
currentWorkspace: "現在のワークスペース",
notSelected: "(未選択)",
apiKeySet: "設定済み · {prefix}…",
defaultModelCurrent: "デフォルトモデル · 現在 {model}",
modelCustom: "カスタムモデルID",
modelCustomHint: "エンドポイントが提供するOpenAI互換ID (vLLM, Ollama, Together, …)。",
modelCustomActive: "現在カスタムIDで実行中: {model}",
contextTokensLabel: "コンテキストウィンドウサイズ",
contextTokensHint:
"現在のモデルのプロンプト側トークン上限を上書きします(例: 1000000 で 1M)。空欄なら既定値を使用。",
contextTokensPlaceholder: "自動",
effortSection: "推論努力",
ctxWindow: "コンテキスト",
maxOutput: "出力",
mcpConfigured: "設定済み · {count}",
mcpBridged: "· ブリッジ済み",
mcpNotBridged: "· 現在のデスクトップセッションに適用中",
mcpStatusConnected: "· 接続済み {connected}/{total}",
mcpStatusConnecting: "· 接続中 {connected}/{total}、設定変更は自動適用されます",
mcpStatusFailed: "· 接続済み {connected}/{total}、{failed} 件失敗",
mcpStatusDisabled: "· {disabled}/{total} 件無効",
mcpStatusPending: "· 適用待ち",
mcpStatus: {
configured: "待機中",
handshake: "接続中",
connected: "接続済み",
failed: "接続失敗",
disabled: "無効",
},
mcpFailure: {
auth: "認証失敗 · headers に Authorization または x-api-key を設定してください",
missingToken: "Bearer Token がありません · headers に Authorization を設定してください",
command: "起動コマンドが利用できません · command/args またはローカル依存を確認してください",
network: "ネットワーク失敗 · エンドポイント、プロキシ、接続を確認してください",
unknown: "接続失敗 · 詳細で元のエラーを確認してください",
},
mcpEmpty: "MCPサーバーがまだ設定されていません。以下に仕様を入力して追加してください。",
mcpRemove: "削除",
mcpRetry: "再試行",
mcpDetail: "詳細",
mcpHideDetail: "閉じる",
mcpDetailTitle: "元のエラー",
mcpEditConfig: "編集",
parseError: "パース失敗: {error}",
mcpAddSection: "サーバーを追加",
mcpSpecLabel: "仕様文字列",
mcpSpecFormat: "形式: <code>name=command args</code> または <code>name=https://host/sse</code>",
mcpAdd: "追加",
mcpImport: "cc-switch からインポート",
mcpImporting: "インポート中…",
mcpEdit: "編集",
mcpEditTitle: "MCP を編集",
mcpEditBack: "戻る",
mcpEditName: "MCP タイトル(一意)",
mcpEditTransport: "トランスポート",
mcpEditJson: "完全な JSON 設定",
mcpEditFormat: "整形",
mcpEditSave: "保存",
mcpEditNameRequired: "MCP タイトルは必須です",
mcpEditJsonObjectRequired: "JSON 設定はオブジェクトである必要があります",
mcpEditCommandRequired: "stdio 設定には command が必要です",
mcpEditUrlRequired: "リモート MCP 設定には url が必要です",
skillsLoaded: "読み込み済み · {count} · / コマンドで呼び出し",
skillsEmpty:
"利用可能なスキルがありません。~/.reasonix/skills/ またはプロジェクトルートの .reasonix/skills/ に SKILL.md を作成してください。",
subagentModelFlash: "flash",
subagentModelPro: "pro",
subagentModelHint: "このスキルが分離サブエージェントを生成するときに使用するモデル",
memorySection: "長期メモリ",
memoryDesc:
"現在のバージョンはビルトインの CLAUDE.md / AGENTS.md インジェクションに依存しています。プロジェクトレベルのメモリはカーネルレベルで維持されます。",
appMode: "アプリケーションモード",
ruleAutoApprovalSection: "コマンドの自動承認",
ruleAutoApprovalHint:
'ApprovalCardで "Always allow" をクリックすると、コマンドプレフィックスが許可リストに追加され、以降の一致するコマンドはプロンプト表示されなくなります。',
balanceLabel: "ウォレット残高",
balanceLow: "不足",
balanceAvailable: "利用可能",
sessionCost: "このセッションのコスト",
cacheHitRate: "キャッシュヒット率",
shortcutNewChat: "新しいチャット",
shortcutNewTab: "新しいタブ",
shortcutCloseTab: "タブを閉じる",
shortcutCommandPalette: "コマンドパレット",
shortcutFocusComposer: "入力欄にフォーカス",
shortcutSwitchTab: "タブ切替",
shortcutAbort: "ストリーミング中断",
shortcutSettings: "設定",
},
modal: {
...en.modal,
planFeedbackPlaceholder: "任意のフィードバック — 変更が必要な場合はここに記入",
choiceCustomPlaceholder: "または自分の回答を入力…",
},
plan: {
...en.plan,
activeTitle: "アクティブプラン",
progress: "{done} / {total} ステップ",
progressNoTotal: "{done} ステップ",
expand: "展開",
collapse: "折りたたみ",
dismiss: "プランを閉じる",
riskLow: "低",
riskMed: "中",
riskHigh: "高",
},
editMode: {
...en.editMode,
label: "編集ゲート",
plan: "plan",
review: "review",
auto: "auto",
yolo: "yolo",
planDesc: "読み取り専用 · 編集・シェル不可",
reviewDesc: "すべての書き込みを承認",
autoDesc: "編集は自動、コマンドは承認",
yoloDesc: "すべて自動",
planHint: "読み取り専用モード · すべての書き込みツールが拒否されます",
reviewHint: "すべてのツール呼び出しに承認が必要です",
autoHint: "許可リストに一致するコマンドは自動承認されます",
yoloHint: "すべて自動承認 · 注意して使用してください",
},
markdown: {
...en.markdown,
filePillTitle: "クリックで開く · 右クリックでコピー",
externalLinkTitle: "ブラウザで {href} を開く",
localLinkTitle: "{href} を開く",
copy: "コピー",
copied: "コピーしました",
},
effort: {
...en.effort,
lowDesc: "最速 — 最小限の推論",
mediumDesc: "バランス",
highDesc: "デフォルト — vLLM / Azure で安全",
maxDesc: "DeepSeek拡張; 標準OpenAI / vLLMでは拒否されます",
},
checkpoint: {
...en.checkpoint,
title: "ステップ完了",
progress: "{done} / {total}",
notesLabel: "メモ",
continue: "続行",
revise: "修正…",
stop: "停止",
revisePlaceholder: "次のステップの前に変更が必要ですか?(空白のまま続行)",
sendRevise: "修正を送信",
},
revision: {
...en.revision,
title: "プラン修正",
subtitle: "モデルが残りのステップを置き換えようとしています",
remainingHeading: "新しい残りステップ",
accept: "承認",
reject: "拒否",
cancel: "キャンセル",
},
app: {
...en.app,
toast: {
...en.app.toast,
newSession: "新しいセッションを作成しました",
cleared: "クリアしました",
copied: "コピーしました",
copiedMd: "Markdownとしてコピーしました",
exportedMd: "Markdownファイルとしてエクスポートしました",
exportFailed: "エクスポート失敗: {error}",
exportDialogTitle: "会話をエクスポート",
emptySession: "セッションが空です",
langSwitched: "{lang} に切り替えました",
modelSwitched: "モデル → {model}",
effortSwitched: "努力 → {effort}",
modeSwitched: "モード: {mode}",
taskComplete: "タスク完了",
},
cmd: {
...en.app.cmd,
newSession: "新しいセッション",
clearChat: "画面クリア",
abort: "ストリーミング出力を停止",
copyLast: "最後の返信をコピー",
switchModel: "モデル切替",
mcp: "MCPサーバー設定を開く",
mcpStatus: "MCP接続ステータスを表示",
mcpRetry: "失敗したMCP接続を再試行",
mcpImport: "cc-switchからMCPサーバーをインポート",
searchEngine: "Web検索エンジンとAPIキーを設定",
skill: "ユーザースキルの一覧と管理(プロジェクト + カスタム + グローバル + 組み込み)",
setMode: "モードを {mode} に設定",
setEffort: "推論努力を {effort} に設定",
toggleTheme: "テーマ切替",
toggleCurrency: "通貨切替 (CNY / USD)",
toggleLang: "言語切替 (CN / EN)",
exportMd: "セッションをMarkdownでコピー",
help: "全コマンドを表示",
feedback: "GitHubでフィードバックを送信(ブラウザが開きます)",
compact: "古いターンをサマリーに折りたたみ(コンテキスト節約)",
retry: "最後のメッセージを切り詰めて再送",
btw: "脇道の質問をする(コンテキストに追加されません)",
},
skill: {
...en.app.skill,
generic: "{scope} スキル · {runAs}",
scope: {
...en.app.skill.scope,
builtin: "ビルトイン",
global: "グローバル",
project: "プロジェクト",
},
runAs: {
inline: "インライン",
subagent: "サブエージェント",
},
},
yolo: {
...en.app.yolo,
banner1: "すべてのツール呼び出し、シェルコマンド、ファイル編集が",
bannerBold: "自動承認",
banner2: "されます(確認なし)。",
switchBack: "レビューに切り替え",
toast: "すべての操作が確認なしで自動承認されます",
},
btwUsage: "▸ /btw <question> — 会話コンテキストを汚さずに脇道の質問をします。",
errorLabel: "エラー",
warningLabel: "警告",
dismissError: "閉じる",
jumpToBottom: "最下部にジャンプ",
splashSubtitle: "DeepSeek Agents",
connecting: "reasonixコアに接続中…",
startupFailedTitle: "Reasonixを起動できませんでした",
startupFailedMessage: "デスクトップバックエンドがワークスペースを開く前に失敗しました。",
startupFailedRetry: "再試行",
startupFailedUnknown: "不明な起動エラー",
langZH: "中国語",
langEN: "英語",
titlebar: {
...en.app.titlebar,
sidebar: "サイドバー (⌘B)",
contextPanel: "コンテキストパネル",
more: "その他",
commandPalette: "コマンドパレット",
copyMd: "会話をMarkdownでコピー",
exportMd: "Markdownをエクスポート",
clearChat: "画面クリア",
settings: "設定",
windowControls: "ウィンドウ操作",
close: "閉じる",
minimize: "最小化",
maximize: "最大化",
restore: "元に戻す",
},
tab: {
...en.app.tab,
newTabTitle: "新しいタブ ⌘T",
newTab: "新しいタブ",
},
header: {
...en.app.header,
noWorkspace: "ワークスペースが選択されていません",
running: "実行中",
clickToSelect: "クリックしてワークスペースを選択",
copy: "コピー",
copyMd: "会話をMarkdownでコピー",
export: "エクスポート",
exportMd: "Markdownファイルとしてエクスポート",
newChat: "新しいチャット",
abort: "停止",
},
empty: {
...en.app.empty,
welcome: "Reasonixへようこそ",
currentWorkspace: "現在のワークスペース: ",
selectWorkspace: "まずトップバーからワークスペースを選択してください",
suggestion0: "最新のコミット変更をレビュー",
suggestion1: "現在のファイルのTypeScriptエラーをすべて修正",
suggestion2: "READMEを中国語/英語に翻訳",
suggestion3: "このリポジトリのCHANGELOGを生成",
},
setup: {
...en.app.setup,
welcome: "Reasonixへようこそ",
description:
"初回セットアップにはDeepSeek APIキーとワークスペースディレクトリが必要です。キーはローカルのみに保存されます。",
workspace: "ワークスペース",
notSelected: "未選択",
choose: "選択…",
saveAndStart: "保存して開始",
},
update: {
...en.app.update,
available: "新しいバージョンが利用可能 · {current} → {latest}",
installing: "インストール中…",
downloading: "ダウンロード中 · {downloaded} / {total} ({pct}%)",
downloadingUnknown: "ダウンロード中 · {downloaded}",
failed: "インストールに失敗しました",
clickToInstall: "クリックしてインストール & 再起動",
install: "インストール",
later: "後で",
},
exportUserLabel: "あなた",
exportReasoningSummary: "推論",
exportToolLabel: "ツール",
session: {
...en.app.session,
new: "{workspace} · 新しいセッション",
format: "セッション {month}-{day} {hour}:{minute}",
},
},
planBadge: {
...en.planBadge,
failed: "失敗",
blocked: "ブロック",
running: "実行中",
done: "完了",
pending: "保留中",
},
cards: {
...en.cards,
reasoningName: "思考中",
reasoningComplete: "完了",
compactionName: "履歴圧縮",
compactionMeta: "{chars} 文字",
shellAwaiting: "承認待ち",
shellRunning: "実行中",
shellExecuteHint: "このコマンドを実行",
shellAlwaysAllow: "常に許可",
shellReject: "拒否",
shellRun: "実行",
diffApplyChanges: "変更を適用",
diffDiscard: "破棄",
diffApply: "適用",
diffAwaiting: "承認待ち",
errorName: "エラー",
searchName: "Web検索",
subagentDone: "完了",
subagentFailed: "失敗",
subagentRunning: "実行中",
subagentDoneProgress: "完了",
memoryName: "メモリ",
memoryCountSuffix: "項目",
checkpointRewind: "ここに戻す",
planDefaultTitle: "プラン",
streaming: "ストリーミング中…",
failed: "失敗",
done: "完了",
running: "実行中",
error: "エラー",
result: "結果",
hits: "ヒット",
applied: "適用済み",
cacheHit: "キャッシュヒット",
prompt: "プロンプト",
reasoning: "推論",
output: "出力",
cost: "コスト",
elapsed: "経過",
},
statusbar: {
...en.statusbar,
offline: "オフライン",
busy: "実行中",
online: "オンライン",
jobs: "ジョブ",
jobsTip: "バックグラウンドプロセス · ⌘J",
cacheHit: "キャッシュヒット",
cache: "キャッシュ",
tokens: "トークン",
thisTurn: "このターン",
switchWorkspace: "ワークスペース切替 · {workspace}",
switchCurrency: "通貨切替 (CNY / USD)",
balance: "残高",
switchTheme: "テーマ切替",
themeDark: "ダーク",
themeLight: "ライト",
themeStyleGraphite: "グラファイト",
themeStyleEmber: "エンバー",
themeStyleAurora: "オーロラ",
themeStyleSandstone: "サンドストーン",
themeStylePorcelain: "ポーセリン",
themeStyleLinen: "リネン",
themeStyleGlacier: "グレイシャー",
themeStyleMidnight: "ミッドナイト",
},
jobs: {
...en.jobs,
title: "バックグラウンドジョブ",
running: "実行中",
exited: "終了",
empty: "このワークスペースにバックグラウンドジョブはまだありません。",
stopAll: "すべて停止",
stopAllTip: "実行中の全ジョブにSIGTERMを送信",
stop: "停止",
stopOne: "このジョブを停止",
kbToggle: "切替",
kbClose: "閉じる",
},
contextPanel: {
...en.contextPanel,
reservedKey: "予約済み",
usedKey: "使用中",
filesTab: "ファイル",
toolsTab: "ツール",
memoryTab: "メモリ",
rulesTab: "ルール",
contextTokens: "コンテキスト · トークン",
cacheKey: "キャッシュ",
freeKey: "空き",
filesTitle: "コンテキスト内のファイル",
filesCount: "{count} ファイル",
fileModified: "変更あり",
fileInContext: "コンテキスト内",
openFile: "ファイルを開く: {path}",
copyPath: "パスをコピー: {path}",
mcpTitle: "MCPサーバー",
mcpReadyAll: "{count} 準備完了",
mcpReadySome: "{ready}/{count} 準備完了",
mcpEmpty: "MCPサーバーが設定されていません",
mcpTools: "{count} ツール",
mcpReady: "準備完了",
mcpConnecting: "接続中",
mcpDisabled: "無効",
mcpFailed: "失敗",
mcpConfigured: "設定済み",
mcpHealthTotal: "MCP {count}",
mcpHealthReady: "接続済み {count}",
mcpHealthFailed: "失敗 {count}",
mcpHealthTools: "ツール {count}",
mcpFilterAll: "すべて",
mcpFilterReady: "利用可能",
mcpFilterFailed: "失敗",
mcpFilterEmpty: "このフィルターにサーバーはありません",
mcpRetryAll: "失敗項目を再試行",
mcpSettings: "設定",
memoryTitle: "長期メモリ",
itemCount: "{count} 項目",
scopeProject: "プロジェクト",
scopeGlobal: "グローバル",
autoApproveTitle: "自動承認",
ruleYolo: "YOLOモード · すべてのツール呼び出しが自動承認",
ruleReadOnly: "読み取り専用ツールは自動承認",
ruleShellAllowlist: "許可リストに一致するシェルコマンドは自動承認",
ruleWritesAsk: "書き込みと未知のシェルコマンドは確認が必要",
ruleReview: "レビューモード · すべてのツール呼び出しに確認が必要",
allow: "許可",
ask: "確認",
noFilesMsg: "まだコンテキスト内にファイルはありません。",
noMemoriesMsg: "まだメモリは保存されていません。",
},
sidebarPanel: {
...en.sidebarPanel,
sessionTitle: "セッション {month}-{day} {hour}:{minute}",
justNow: "たった今",
minutesAgo: "{n}分前",
hoursAgo: "{n}時間前",
daysAgo: "{n}日前",
weeksAgo: "{n}週間前",
newChat: "新しいチャット",
commandPalette: "コマンドパレット",
workspace: "ワークスペース",
noWorkspace: "ワークスペースなし",
pickWorkspace: "ワークスペースを選択",
switchWorkspace: "ワークスペースを切り替え · {workspace}",
searchSessions: "セッションを検索…",
recent: "最近",
noSessions: "セッションなし",
noMatches: "一致なし",
messageCount: "{count} メッセージ",
deleteSession: "セッションを削除",
importSessions: "セッションをインポート",
importDetected: "検出済み",
importCustom: "カスタム",
importFoundApps: "検出されたアプリ",
importScanning: "ローカルアプリをスキャン中…",
importSessionCount: "{count} セッション · すべてインポート",
importNotFound: "ローカルセッションが見つかりません",
importPrivacyHint: "既存のアプリ設定は変更されません。",
importResult: "{imported} 件のセッションをインポート、{skipped} 件スキップ、{failed} 件失敗。",
continue: "続行",
refresh: "更新",
importSource: "ソース",
importFromClaude: "Claude",
importFromCodex: "Codex",
importPath: "セッションファイルのパス",
importName: "セッション名",
importNamePlaceholder: "任意のカスタム名",
importConfirm: "インポート",
browse: "参照",
renameSession: "セッション名を変更",
renamePlaceholder: "セッションタイトル",
approvalRules: "承認ルール",
settings: "設定",
cancel: "キャンセル",
delete: "削除",
},
extraCards: {
...en.extraCards,
sessionUsage: "セッション使用量",
sessionCost: "コスト {costLabel}",
approve: "承認",
reject: "拒否",
doctorTitle: "/doctor · システムチェック",
balance: "残高 {balanceLabel}",
prompt: "プロンプト",
reasoning: "推論",
output: "出力",
cacheHit: "キャッシュヒット",
cache: "キャッシュ",
contextWindow: "コンテキストウィンドウ",
topToolsUsage: "上位ツール — 使用量",
memoryUser: "USER · 設定",
memoryFeedback: "FEEDBACK · 修正",
memoryProject: "PROJECT · プロジェクトルール",
memoryReference: "REFERENCE · リファレンス",
unknownKind: "# 不明なカードタイプ — フォールバック表示",
},
thread: {
...en.thread,
toolCalls: "{count} 件のツール呼び出し",
oneToolCall: "1 件のツール呼び出し",
keepSteps: "残り {n} ステップを保持",
keepOriginal: "オリジナルを保持",
you: "あなた",
planRunning: "プラン実行中 · ステップ {step} / {total}",
collapse: "折りたたみ",
activePlan: "アクティブプラン",
risk: "リスク",
planStepCount: "{count} ステップ",
planConfirmationKind: "プラン確認",
startPlan: "プラン開始",
approve: "承認",
cancel: "キャンセル",
refine: "修正依頼",
sendFeedback: "送信",
skipFeedback: "スキップ",
cancelFeedbackLabel: "キャンセル — 問題は何ですか?",
refineFeedbackLabel: "修正 — 何を変更すべきですか?",
checkpointKind: "プランチェックポイント",
checkpointTitle: "ステップ {completed} / {total} 完了",
checkpointSub: "チェックポイント · {completed} / {total}",
continue: "続行",
stop: "停止",
revise: "修正",
planRevisionKind: "プラン修正",
rewritePlan: "プラン書き直し",
revisionMeta: "理由 · ランタイム制約",
approveRewrite: "書き直しを承認",
shellConfirmationKind: "シェル確認",
runCommand: "コマンド実行",
runBackgroundCommand: "バックグラウンドコマンド実行",
riskMedium: "リスク · 中 · {kind}",
execute: "実行",
reject: "拒否",
alwaysAllow: '"{prefix}" を常に許可',
pathAccessKind: "パスアクセス",
read: "読み取り",
write: "書き込み",
readPathOutsideSandbox: "サンドボックス外のパスを読み取り",
writePathOutsideSandbox: "サンドボックス外のパスに書き込み",
allowRead: "読み取り許可",
allowWrite: "書き込み許可",
alwaysAllowPrefix: "{prefix} を常に許可",
userChoiceKind: "ユーザー選択",
optionCount: "{count} 選択肢",
subagent: "サブエージェント",
copyMessage: "このメッセージをコピー",
editMessage: "このメッセージを編集して再送信",
copyResponse: "この返信をコピー",
},
live: {
...en.live,
reasoning: "ライブ · 推論",
running: "実行中",
you: "あなた",
secondsAgo: "{seconds}秒前",
deliveredWaiting: "配信済み · エージェント待ち",
},
panel: {
...en.panel,
renderError: "{panel} パネルレンダリングエラー",
},
notifications: {
...en.notifications,
approvalTitle: "Reasonixが承認待ちです",
inputTitle: "Reasonixが入力を待っています",
planApprovalTitle: "Reasonixがプラン承認待ちです",
checkpointApprovalTitle: "Reasonixがチェックポイント承認待ちです",
revisionTitle: "Reasonixがプラン修正を提案しました",
commandBody: "コマンド: {command}",
readBody: "読み取り: {path}",
writeBody: "書き込み: {path}",
turnCompleteTitle: "Reasonixタスク完了",
turnCompleteBody: "現在のタスクが完了し、レビュー可能です。",
},
};
+5
View File
@@ -0,0 +1,5 @@
import { en } from "./en";
export const ru: typeof en = {
...en,
};
+98 -3
View File
@@ -53,6 +53,7 @@ export const zhCN: typeof en = {
searchPlaceholder: "搜索最近路径…",
empty: "暂无最近工作目录",
browse: "浏览本地…",
removeRecent: "移除此记录",
},
sidebar: {
newChat: "新建会话",
@@ -67,6 +68,13 @@ export const zhCN: typeof en = {
copiedMd: "整段对话已复制为 Markdown",
newSession: "已开新会话",
cleared: "已清空 UI",
mcpImported: "已从 cc-switch 导入 {imported} 个 MCP 服务器",
mcpImportedWithSkipped: "已导入 {imported} 个 MCP 服务器,跳过 {skipped} 个同名项",
mcpImportNone: "没有可导入的新 MCP 服务器",
mcpImportFailed: "导入 cc-switch MCP 失败:{error}",
mcpStatusOpened: "已打开 MCP 状态面板",
mcpRetryNone: "没有失败的 MCP 需要重试",
mcpRetryQueued: "已重试 {count} 个失败的 MCP",
aboutLine: "Reasonix v{version} · cache-first DeepSeek agent",
},
composer: {
@@ -122,10 +130,18 @@ export const zhCN: typeof en = {
themeStyleHint: "为当前浅色或深色模式选择具体视觉配色。",
themeStyleGraphite: "Graphite",
themeStyleGraphiteDesc: "原有深色风格,中性石墨面板。",
themeStyleEmber: "Ember",
themeStyleEmberDesc: "暖黑深色风格,强化 Reasonix 橙色品牌感。",
themeStyleAurora: "Aurora",
themeStyleAuroraDesc: "青绿色深色风格,低光环境更柔和。",
themeStyleSandstone: "Sandstone",
themeStyleSandstoneDesc: "原有浅色风格,温暖明亮。",
themeStylePorcelain: "Porcelain",
themeStylePorcelainDesc: "类似 Codex App 的干净浅色风格。",
themeStyleLinen: "Linen",
themeStyleLinenDesc: "偏纸张质感的温暖浅色风格。",
themeStyleGlacier: "Glacier",
themeStyleGlacierDesc: "冷白浅色风格,蓝色强调更清爽。",
themeStyleMidnight: "Midnight",
themeStyleMidnightDesc: "偏藏青色的深色风格,冷静克制。",
fontScale: "字体大小",
@@ -156,6 +172,10 @@ export const zhCN: typeof en = {
showSystemEventsHint: "在对话流中以细线显示压缩 / 中断 / 限速等系统事件",
shown: "显示",
hidden: "隐藏",
desktopCloseBehavior: "关闭窗口",
desktopCloseBehaviorHint: "默认关闭即退出。隐藏到托盘会让运行中的会话继续保活。",
closeToQuit: "退出",
closeToTray: "隐藏到托盘",
editModeReviewDesc: "每次写入都要确认",
editModeAutoDesc: "改文件自动,命令仍要确认",
editModeYoloDesc: "全部放行 — 完全自动",
@@ -164,8 +184,11 @@ export const zhCN: typeof en = {
budgetPlaceholder: "不限",
webSearchEngine: "搜索引擎",
webSearchEngineBing: "bing — 默认,国内裸 IP 直连,无需代理",
webSearchEngineBingIntl: "bing-intl — 国际版索引,走 www.bing.com",
webSearchEngineSearxng: "searxng — 自托管(端点用 /se searxng <url> 配置)",
webSearchEngineMetaso: "metaso — 每日 100 次免费(国内友好,metaso.cn 可领免费 key)",
webSearchEngineBaidu:
"baidu — 百度 AI Search,官方文档写有每月 1500 次免费额度(设置 BAIDU_API_KEY 或 QIANFAN_API_KEY)",
webSearchEngineTavily: "tavily — 每月 1000 次免费(设置 TAVILY_API_KEY)",
webSearchEnginePerplexity: "perplexity — AI 直接回答(设置 PERPLEXITY_API_KEY)",
webSearchEngineExa: "exa — AI 直接回答,每月 1000 次免费(设置 EXA_API_KEY)",
@@ -178,6 +201,7 @@ export const zhCN: typeof en = {
webSearchApiKeyClear: "清除",
webSearchApiKey: {
metaso: "Metaso API key",
baidu: "百度 API key",
tavily: "Tavily API key",
perplexity: "Perplexity API key",
exa: "Exa API key",
@@ -264,19 +288,60 @@ export const zhCN: typeof en = {
modelCustom: "自定义模型 ID",
modelCustomHint: "任何 OpenAI 兼容的 ID(vLLM、Ollama、Together …)。",
modelCustomActive: "当前运行自定义 ID:{model}",
contextTokensLabel: "上下文窗口大小",
contextTokensHint: "为当前模型覆盖提示侧 token 上限(如 1000000 表示 1M)。留空使用内置默认值。",
contextTokensPlaceholder: "自动",
effortSection: "推理强度",
ctxWindow: "上下文",
maxOutput: "输出",
mcpConfigured: "已配置 · {count}",
mcpBridged: "· 已桥接",
mcpNotBridged: "· 当前桌面会话未桥接,重启 reasonix code (TUI) 后生效",
mcpNotBridged: "· 正在应用到当前桌面会话",
mcpStatusConnected: "· 已连接 {connected}/{total}",
mcpStatusConnecting: "· 正在连接 {connected}/{total},配置变更会自动应用",
mcpStatusFailed: "· 已连接 {connected}/{total},{failed} 个失败",
mcpStatusDisabled: "· {disabled}/{total} 个已禁用",
mcpStatusPending: "· 等待应用",
mcpStatus: {
configured: "等待连接",
handshake: "连接中",
connected: "已连接",
failed: "连接失败",
disabled: "已禁用",
},
mcpFailure: {
auth: "鉴权失败 · 请在 headers 中配置 Authorization 或 x-api-key",
missingToken: "缺少 Bearer Token · 请在 headers 中配置 Authorization",
command: "启动命令不可用 · 请检查 command/args 或本机依赖",
network: "网络连接失败 · 请检查地址、代理或网络",
unknown: "连接失败 · 查看详情获取原始错误",
},
mcpEmpty: "还没有配置 MCP 服务器。下面输入 spec 添加。",
mcpRemove: "移除",
mcpRetry: "重试",
mcpDetail: "详情",
mcpHideDetail: "收起",
mcpDetailTitle: "原始错误",
mcpEditConfig: "编辑",
parseError: "解析失败:{error}",
mcpAddSection: "添加服务器",
mcpSpecLabel: "spec 字符串",
mcpSpecFormat: "格式:<code>name=command args</code> 或 <code>name=https://host/sse</code>",
mcpAdd: "添加",
mcpImport: "从 cc-switch 导入",
mcpImporting: "导入中…",
mcpEdit: "编辑",
mcpEditTitle: "编辑 MCP",
mcpEditBack: "返回",
mcpEditName: "MCP 标题(唯一)",
mcpEditTransport: "传输类型",
mcpEditJson: "完整的 JSON 配置",
mcpEditFormat: "格式化",
mcpEditSave: "保存",
mcpEditNameRequired: "MCP 标题不能为空",
mcpEditJsonObjectRequired: "JSON 配置必须是对象",
mcpEditCommandRequired: "stdio 配置需要 command",
mcpEditUrlRequired: "远程 MCP 配置需要 url",
skillsLoaded: "已加载 · {count} · 通过 / 命令调用",
skillsEmpty:
"没有可用技能。可在 ~/.reasonix/skills/ 或 项目根/.reasonix/skills/ 下创建 SKILL.md。",
@@ -304,8 +369,8 @@ export const zhCN: typeof en = {
shortcutSettings: "设置",
},
modal: {
planFeedbackPlaceholder: "可选反馈 — 想要修改时写这里",
choiceCustomPlaceholder: "或者自己写一个答案…",
planFeedbackPlaceholder: "计划存在哪些问题?需要改进哪些地方?",
choiceCustomPlaceholder: "计划可以如何进一步细化?",
},
plan: {
activeTitle: "进行中的计划",
@@ -381,8 +446,12 @@ export const zhCN: typeof en = {
themeDark: "深色",
themeLight: "浅色",
themeStyleGraphite: "Graphite",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstone",
themeStylePorcelain: "Porcelain",
themeStyleLinen: "Linen",
themeStyleGlacier: "Glacier",
themeStyleMidnight: "Midnight",
},
jobs: {
@@ -419,6 +488,10 @@ export const zhCN: typeof en = {
abort: "中断流式输出",
copyLast: "复制最后一条回复",
switchModel: "切换模型",
mcp: "打开 MCP 服务器设置",
mcpStatus: "查看 MCP 连接状态",
mcpRetry: "重试失败的 MCP 连接",
mcpImport: "从 cc-switch 导入 MCP",
searchEngine: "配置搜索引擎 + API key",
skill: "列出 / 管理用户技能(项目 + 自定义 + 全局 + 内置)",
setMode: "切换模式为 {mode}",
@@ -577,6 +650,12 @@ export const zhCN: typeof en = {
cost: "花费",
elapsed: "耗时",
},
mcpCard: {
showTools: "工具",
hideTools: "收起工具",
availableTools: "{count} 个可用工具",
noTools: "已连接,但该服务器未暴露可调用工具",
},
contextPanel: {
reservedKey: "预留",
usedKey: "已用",
@@ -603,6 +682,16 @@ export const zhCN: typeof en = {
mcpDisabled: "已禁用",
mcpFailed: "失败",
mcpConfigured: "已配置",
mcpHealthTotal: "MCP {count}",
mcpHealthReady: "已连接 {count}",
mcpHealthFailed: "失败 {count}",
mcpHealthTools: "工具 {count}",
mcpFilterAll: "全部",
mcpFilterReady: "可用",
mcpFilterFailed: "失败",
mcpFilterEmpty: "当前筛选下没有服务器",
mcpRetryAll: "重试失败项",
mcpSettings: "设置",
memoryTitle: "长期记忆",
itemCount: "{count} 项",
scopeProject: "项目",
@@ -684,6 +773,8 @@ export const zhCN: typeof en = {
unknownKind: "# 未知卡片类型 — fallback 渲染",
},
thread: {
toolCalls: "{count} 个工具调用",
oneToolCall: "1 个工具调用",
keepSteps: "保留剩余 {n} 步",
keepOriginal: "保留原计划",
you: "你",
@@ -697,6 +788,10 @@ export const zhCN: typeof en = {
approve: "批准",
cancel: "取消",
refine: "细化",
sendFeedback: "发送",
skipFeedback: "跳过",
cancelFeedbackLabel: "取消 — 哪里有问题?",
refineFeedbackLabel: "细化 — 还需要什么?",
checkpointKind: "计划检查点",
checkpointTitle: "step {completed} / {total} 完成",
checkpointSub: "检查点 · {completed} / {total}",
+7
View File
@@ -77,6 +77,13 @@ export const I = {
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>),
trash: (p: IconProps) => (
<Ic {...p}>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</Ic>
),
};
export type IconKey = keyof typeof I;
+66 -2
View File
@@ -149,6 +149,20 @@ export type MentionPreviewEvent = {
totalLines: number;
};
export type PromptHistoryCursor = {
sessionName: string;
messageIndex: number;
};
export type PromptHistoryResultEvent = {
type: "$prompt_history_result";
nonce: number;
entry: {
value: string;
cursor: PromptHistoryCursor;
} | null;
};
export type TabOpenedEvent = {
type: "$tab_opened";
workspaceDir: string;
@@ -167,10 +181,19 @@ export type McpSpecInfo = {
name: string | null;
transport: "stdio" | "sse" | "streamable-http";
summary: string;
config?: ImportedMcpServer;
parseError?: string;
status: McpSpecStatus;
statusHint?: "auth" | "missing-token" | "command" | "network" | "unknown";
statusReason?: string;
toolCount?: number;
tools?: McpToolInfo[];
};
export type McpToolInfo = {
name: string;
registeredName: string;
description?: string;
};
export type McpSpecsEvent = {
@@ -179,6 +202,19 @@ export type McpSpecsEvent = {
bridged: boolean;
};
export type ImportedMcpServer = {
name: string;
transport: "stdio" | "sse" | "streamable-http";
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
disabled?: boolean;
requestTimeoutMs?: number;
};
export type SkillScope = "project" | "global" | "builtin";
export type SkillInfo = {
@@ -296,8 +332,17 @@ export type EditMode = "review" | "auto" | "yolo" | "plan";
export type ReasoningEffort = "low" | "medium" | "high" | "max";
export type WebSearchEngineName = "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa"
| "brave" | "ollama";
export type WebSearchEngineName =
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa"
| "brave"
| "ollama";
export type SettingsEvent = {
type: "$settings";
@@ -310,10 +355,12 @@ export type SettingsEvent = {
recentWorkspaces: string[];
model: string;
editor?: string;
desktopCloseBehavior?: "closeToTray" | "closeToQuit";
webSearchEngine?: WebSearchEngineName;
webSearchEndpoint?: string;
webSearchApiKeys?: {
metaso?: string;
baidu?: string;
tavily?: string;
perplexity?: string;
exa?: string;
@@ -359,17 +406,22 @@ export type SettingsPatch = {
budgetUsd?: number | null;
baseUrl?: string;
workspaceDir?: string;
recentWorkspaces?: string[];
model?: string;
editor?: string;
desktopCloseBehavior?: "closeToTray" | "closeToQuit";
webSearchEngine?: WebSearchEngineName;
webSearchEndpoint?: string | null;
metasoApiKey?: string | null;
baiduApiKey?: string | null;
tavilyApiKey?: string | null;
perplexityApiKey?: string | null;
exaApiKey?: string | null;
ollamaApiKey?: string | null;
braveApiKey?: string | null;
subagentModels?: Record<string, "flash" | "pro">;
/** Per-model context-window override (tokens). Keys are model ids; values are the prompt-side token cap. */
contextTokens?: Record<string, number>;
showSystemEvents?: boolean;
};
@@ -503,6 +555,7 @@ export type IncomingEvent = { tabId?: string } & (
| PlanClearedEvent
| MentionResultsEvent
| MentionPreviewEvent
| PromptHistoryResultEvent
| TabOpenedEvent
| TabClosedEvent
| McpSpecsEvent
@@ -552,12 +605,23 @@ export type OutgoingCommand = { tabId?: string } & (
| { cmd: "mention_query"; query: string; nonce: number }
| { cmd: "mention_preview"; path: string; nonce: number }
| { cmd: "mention_picked"; path: string }
| {
cmd: "prompt_history_step";
nonce: number;
direction: "older" | "newer";
cursor?: PromptHistoryCursor | null;
startSessionName?: string;
stopSessionName?: 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 }
| { cmd: "mcp_import_servers"; servers: ImportedMcpServer[] }
| { cmd: "mcp_specs_update"; raw: string; server: ImportedMcpServer }
| { cmd: "mcp_specs_retry"; raw: string }
| { cmd: "skills_get" }
| { cmd: "skill_run"; name: string; args?: string }
| { cmd: "jobs_list" }
+802 -74
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -11,8 +11,12 @@ export function isTheme(value: unknown): value is Theme {
export const THEME_STYLE = {
GRAPHITE: "graphite",
EMBER: "ember",
AURORA: "aurora",
SANDSTONE: "sandstone",
PORCELAIN: "porcelain",
LINEN: "linen",
GLACIER: "glacier",
MIDNIGHT: "midnight",
} as const;
@@ -25,23 +29,35 @@ export const DEFAULT_THEME_STYLE: Record<Theme, ThemeStyle> = {
export const THEME_STYLE_THEME: Record<ThemeStyle, Theme> = {
graphite: THEME.DARK,
ember: THEME.DARK,
aurora: THEME.DARK,
sandstone: THEME.LIGHT,
porcelain: THEME.LIGHT,
linen: THEME.LIGHT,
glacier: THEME.LIGHT,
midnight: THEME.DARK,
};
export const THEME_STYLES = [
THEME_STYLE.GRAPHITE,
THEME_STYLE.EMBER,
THEME_STYLE.AURORA,
THEME_STYLE.SANDSTONE,
THEME_STYLE.PORCELAIN,
THEME_STYLE.LINEN,
THEME_STYLE.GLACIER,
THEME_STYLE.MIDNIGHT,
] as const;
export function isThemeStyle(value: unknown): value is ThemeStyle {
return (
value === THEME_STYLE.GRAPHITE ||
value === THEME_STYLE.EMBER ||
value === THEME_STYLE.AURORA ||
value === THEME_STYLE.SANDSTONE ||
value === THEME_STYLE.PORCELAIN ||
value === THEME_STYLE.LINEN ||
value === THEME_STYLE.GLACIER ||
value === THEME_STYLE.MIDNIGHT
);
}
+9 -4
View File
@@ -218,6 +218,7 @@ export function ShellCard({
onApprove,
onReject,
onAlwaysAllow,
defaultOpen,
}: {
command: string;
output?: string;
@@ -226,6 +227,7 @@ export function ShellCard({
onApprove?: () => void;
onReject?: () => void;
onAlwaysAllow?: () => void;
defaultOpen?: boolean;
}) {
useLang();
const tone: Tone = state === "failed" ? "danger" : state === "done" ? "success" : "warning";
@@ -236,7 +238,7 @@ export function ShellCard({
kind="shell"
name="shell"
compact
defaultOpen={state !== "done"}
defaultOpen={defaultOpen ?? false}
meta={
<>
{state === "await" ? (
@@ -335,12 +337,14 @@ export function ToolCard({
result,
ok,
durationMs,
defaultOpen,
}: {
name: string;
args?: string;
result?: string;
ok?: boolean;
durationMs?: number;
defaultOpen?: boolean;
}) {
useLang();
const running = result === undefined;
@@ -351,7 +355,7 @@ export function ToolCard({
icon={<I.wrench size={12} />}
kind="tool"
name={name}
defaultOpen={false}
defaultOpen={defaultOpen ?? false}
compact
meta={
<>
@@ -733,7 +737,8 @@ export function MetricStrip({
costLabel,
elapsed,
}: {
cacheHit?: number;
/** Pre-formatted cache percentage string (e.g. "72.12%"). */
cacheHit?: string;
promptTokens?: number;
outputTokens?: number;
costLabel?: string;
@@ -745,7 +750,7 @@ export function MetricStrip({
<span className="item">
<I.zap size={11} style={{ color: "var(--accent)" }} />
<span>{t("cards.cacheHit")}</span>
<span className="v acc">{cacheHit}%</span>
<span className="v acc">{cacheHit}</span>
</span>
) : null}
{promptTokens !== undefined ? (
+95
View File
@@ -160,4 +160,99 @@ describe("desktop Composer @ popup", () => {
expect(container.querySelector(".popup-list.at-popup-list")).not.toBeNull();
});
it("delegates ArrowUp prompt-history navigation only at the input start", () => {
const onPromptHistoryNavigate = vi.fn(() => true);
const { container } = renderComposer({
draft: "current prompt",
onPromptHistoryNavigate,
});
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("missing textarea");
textarea.setSelectionRange(3, 3);
fireEvent.keyDown(textarea, { key: "ArrowUp" });
expect(onPromptHistoryNavigate).not.toHaveBeenCalled();
textarea.setSelectionRange(0, 0);
fireEvent.keyDown(textarea, { key: "ArrowUp" });
expect(onPromptHistoryNavigate).toHaveBeenCalledWith("older");
});
it("allows ArrowDown prompt-history navigation while browsing at a boundary", () => {
const onPromptHistoryNavigate = vi.fn(() => true);
const { container } = renderComposer({
draft: "recalled prompt",
promptHistoryBrowsing: true,
onPromptHistoryNavigate,
});
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("missing textarea");
textarea.setSelectionRange("recalled prompt".length, "recalled prompt".length);
fireEvent.keyDown(textarea, { key: "ArrowDown" });
expect(onPromptHistoryNavigate).toHaveBeenCalledWith("newer");
});
it("shows mention filenames first while preserving the full path for preview and pick", async () => {
const setDraft = vi.fn();
const onMentionPicked = vi.fn();
const onMentionPreview = vi.fn();
const { container, rerender, onMentionQuery } = renderComposer({
setDraft,
onMentionPicked,
onMentionPreview,
});
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("missing textarea");
fireEvent.change(textarea, { target: { value: "@foo" } });
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;
rerender(
<Composer
draft="@foo"
setDraft={setDraft}
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
busy={false}
modelLabel="deepseek-v4-flash"
reasoningEffort="high"
onModelChange={vi.fn()}
onEffortChange={vi.fn()}
editMode="review"
onEditModeChange={vi.fn()}
textareaRef={createRef<HTMLTextAreaElement>()}
slashCommands={[]}
onMentionQuery={onMentionQuery}
onMentionPreview={onMentionPreview}
onMentionPicked={onMentionPicked}
mentionResults={{ nonce, query: "foo", results: ["src/very/deep/foo.ts"] }}
workspaceDir="/repo"
/>,
);
const row = await waitFor(() => {
const item = container.querySelector(".popup-item");
expect(item).not.toBeNull();
return item as HTMLElement;
});
expect(row.querySelector(".nm > span")?.textContent).toBe("foo.ts");
expect(row.querySelector(".desc")?.textContent).toBe("src/very/deep/");
fireEvent.mouseEnter(row);
expect(onMentionPreview).toHaveBeenCalledWith("src/very/deep/foo.ts", nonce);
fireEvent.click(row);
expect(onMentionPicked).toHaveBeenCalledWith("src/very/deep/foo.ts");
const draftUpdate = setDraft.mock.calls.at(-1)?.[0];
expect(typeof draftUpdate).toBe("function");
expect((draftUpdate as (current: string) => string)("@foo")).toBe(
"@src/very/deep/foo.ts ",
);
});
});
+249 -112
View File
@@ -70,6 +70,7 @@ export type SlashCmd = {
};
export type MentionItem = {
name: string;
path?: string;
kind: "file" | "dir" | "url" | "agent" | "clip";
desc?: string;
};
@@ -78,11 +79,62 @@ export type Chip =
| { kind: "at"; label: string }
| { kind: "slash"; label: string };
/** For long paths show only the filename; truncate filename if it's still too long. */
function chipLabel(label: string, maxLen = 32): string {
if (label.length <= maxLen) return label;
const sep = label.includes("/") ? "/" : "\\";
const segments = label.split(sep);
const filename = segments[segments.length - 1]!;
if (filename.length <= maxLen) return filename;
return filename.slice(0, maxLen - 1) + "…";
}
type Popup =
| { kind: "slash"; query: string }
| { kind: "at"; query: string; nonce: number }
| null;
type ActiveRange = { start: number; end: number; sigil: string; query: string };
/** Return the @word or /word range at cursorPos, or null. */
function findActiveRange(text: string, cursorPos: number): ActiveRange | null {
if (cursorPos < 0 || cursorPos > text.length) return null;
let wordStart = cursorPos;
while (wordStart > 0 && !/\s/.test(text[wordStart - 1]!)) wordStart--;
let wordEnd = cursorPos;
while (wordEnd < text.length && !/\s/.test(text[wordEnd]!)) wordEnd++;
if (wordStart === wordEnd) return null;
const word = text.slice(wordStart, wordEnd);
if (word.length > 0 && (word[0] === "@" || word[0] === "/")) {
if (wordStart === 0 || /\s/.test(text[wordStart - 1]!)) {
return { start: wordStart, end: wordEnd, sigil: word[0], query: word.slice(1) };
}
}
return null;
}
/** Render draft text with @word and /word tokens wrapped in highlight spans. */
function highlightTokens(text: string): React.ReactNode {
if (!text) return text;
const parts: React.ReactNode[] = [];
let lastIdx = 0;
for (const m of text.matchAll(/(\s|^)([@\/]\S+)/g)) {
const pre = text.slice(lastIdx, m.index);
if (pre) parts.push(pre);
parts.push(
<span key={m.index}>
{m[1]}
<span className="hl-token">{m[2]}</span>
</span>,
);
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) {
parts.push(text.slice(lastIdx));
}
return parts.length > 0 ? parts : text;
}
function slashIcon(cmd: string) {
const m: Record<string, React.ReactNode> = {
"/clear": <I.x size={12} />,
@@ -109,6 +161,20 @@ function parentOfAtQuery(query: string): string | null {
return parentIdx >= 0 ? `${dirContext.slice(0, parentIdx)}/` : "";
}
function displayMentionPath(path: string): { name: string; desc?: string } {
const normalized = path.replace(/\\/g, "/");
const isDir = /[\\/]$/.test(path);
const trimmed = normalized.replace(/\/+$/, "");
if (!trimmed) return { name: path };
const slash = trimmed.lastIndexOf("/");
const base = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
const parent = slash >= 0 ? `${trimmed.slice(0, slash)}/` : "";
return {
name: isDir ? `${base}/` : base,
desc: parent || undefined,
};
}
function atIcon(k: MentionItem["kind"]) {
if (k === "file") return <I.file size={12} />;
if (k === "dir") return <I.folder size={12} />;
@@ -132,6 +198,9 @@ function guessImageExtension(mime: string): string {
export function Composer({
draft,
setDraft,
onDraftUserEdit,
promptHistoryBrowsing,
onPromptHistoryNavigate,
onSend,
onAbort,
disabled,
@@ -157,6 +226,9 @@ export function Composer({
}: {
draft: string;
setDraft: React.Dispatch<React.SetStateAction<string>>;
onDraftUserEdit?: () => void;
promptHistoryBrowsing?: boolean;
onPromptHistoryNavigate?: (direction: "older" | "newer") => boolean | void;
onSend: () => void;
onAbort: () => void;
disabled?: boolean;
@@ -183,29 +255,56 @@ export function Composer({
onQueueWhileBusy?: (text: string) => void;
onDequeueSend?: (index: number) => void;
}) {
const [chips, setChips] = useState<Chip[]>([]);
const [popup, setPopup] = useState<Popup>(null);
const [pickedChips, setPickedChips] = useState<Map<string, Chip["kind"]>>(new Map());
const chips = useMemo(() => {
const result: Chip[] = [];
for (const [label, kind] of pickedChips) {
const sigil = kind === "at" ? "@" : "/";
const escaped = sigil + label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(?:^|\\s)${escaped}(?:\\s|$)`);
if (re.test(draft)) {
result.push({ kind, label });
}
}
return result;
}, [draft, pickedChips]);
const [activeIdx, setActiveIdx] = useState(0);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const backdropContent = useMemo(() => highlightTokens(draft), [draft]);
const nonceRef = useRef(0);
const modelWrapRef = useRef<HTMLDivElement>(null);
// macOS Chinese IME fires compositionend BEFORE the confirm keydown.
const composingRef = useRef(false);
const compositionEndedAtRef = useRef(0);
const historyRef = useRef<string[]>([]);
const [browseIdx, setBrowseIdx] = useState(-1);
const savedDraftRef = useRef("");
const activeRangeRef = useRef<ActiveRange | null>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const insertMention = (picked: string) => {
const rel =
workspaceDir && picked.startsWith(workspaceDir)
? picked.slice(workspaceDir.length).replace(/^[\\/]+/, "")
: picked;
setDraft((current) =>
current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `,
);
setChips((c) => [...c, { kind: "at", label: rel }]);
const range = activeRangeRef.current;
if (range && range.sigil === "@") {
const savedRange = { ...range };
setDraft((current) => {
const before = current.slice(0, savedRange.start);
const after = current.slice(savedRange.end);
const insertion = `@${rel}`;
const spacerBefore = before && !before.endsWith(" ") ? " " : "";
const spacerAfter = after ? (after.startsWith(" ") ? "" : " ") : " ";
return `${before}${spacerBefore}${insertion}${spacerAfter}${after}`;
});
} else {
setDraft((current) =>
current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `,
);
}
activeRangeRef.current = null;
setPickedChips((prev) => new Map(prev).set(rel, "at"));
onMentionPicked?.(rel);
setPopup(null);
textareaRef.current?.focus();
};
@@ -221,6 +320,7 @@ export function Composer({
const prev = prevDraftRef.current;
prevDraftRef.current = draft;
if (draft === "/" && prev !== "/") {
activeRangeRef.current = { start: 0, end: 1, sigil: "/", query: "" };
setPopup({ kind: "slash", query: "" });
}
}, [draft]);
@@ -291,11 +391,15 @@ export function Composer({
const atItems = useMemo<MentionItem[]>(() => {
if (!popup || popup.kind !== "at") return [];
if (!mentionResults || mentionResults.nonce !== popup.nonce) return [];
const base: MentionItem[] = mentionResults.results.map((path) => ({
name: path,
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
desc: path,
}));
const base: MentionItem[] = mentionResults.results.map((path) => {
const display = displayMentionPath(path);
return {
name: display.name,
path,
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
desc: display.desc,
};
});
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
const parent = parentOfAtQuery(popup.query);
if (parent !== null) {
@@ -324,89 +428,107 @@ export function Composer({
onMentionQuery(popup.query, popup.nonce);
}, [popup, onMentionQuery]);
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value;
setDraft(v);
const trail = v.match(/(^|\s)([/@])([^\s]*)$/);
if (trail) {
const sigil = trail[2];
const query = trail[3] ?? "";
if (sigil === "/") {
setPopup({ kind: "slash", query });
const syncPopupFromCursor = (value: string, cursorPos: number) => {
const range = findActiveRange(value, cursorPos);
if (range && (range.sigil === "/" || range.sigil === "@")) {
activeRangeRef.current = range;
if (range.sigil === "/") {
if (popup?.kind !== "slash" || popup.query !== range.query) {
setPopup({ kind: "slash", query: range.query });
}
} else {
const nonce = ++nonceRef.current;
setPopup({ kind: "at", query, nonce });
if (popup?.kind !== "at" || popup.query !== range.query) {
const nonce = ++nonceRef.current;
setPopup({ kind: "at", query: range.query, nonce });
}
}
} else if (popup) {
activeRangeRef.current = null;
setPopup(null);
}
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value;
onDraftUserEdit?.();
setDraft(v);
const cursorPos = e.target.selectionStart ?? v.length;
syncPopupFromCursor(v, cursorPos);
};
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const ta = e.currentTarget;
const cursorPos = ta.selectionStart;
if (cursorPos === null) return;
syncPopupFromCursor(ta.value, cursorPos);
};
const handleTextareaScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
if (backdropRef.current) {
backdropRef.current.scrollTop = e.currentTarget.scrollTop;
}
};
const dismiss = () => setPopup(null);
const pickItem = (idx: number) => {
const it = items[idx];
if (!it || !popup) return;
const range = activeRangeRef.current;
const before = range ? draft.slice(0, range.start) : draft;
const after = range ? draft.slice(range.end) : "";
if (popup.kind === "slash") {
const cmd = (it as SlashCmd).cmd;
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(/^\//, "") }]);
const spacer = before && !before.endsWith(" ") ? " " : "";
setDraft(`${before}${spacer}${cmd} ${after}`);
setPickedChips((prev) => new Map(prev).set(cmd.replace(/^\//, ""), "slash"));
} else {
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
setDraft(next);
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
setDraft(`${before}${after}`);
(it as SlashCmd).run();
}
} else {
const mention = it as MentionItem;
if (mention.name === "..") {
const parent = parentOfAtQuery(popup.query) ?? "";
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
const newText = `@${parent}`;
const next = `${before}${newText}${after}`;
setDraft(next);
activeRangeRef.current = {
start: range?.start ?? 0,
end: (range?.start ?? 0) + newText.length,
sigil: "@",
query: parent,
};
const nonce = ++nonceRef.current;
setPopup({ kind: "at", query: parent, nonce });
textareaRef.current?.focus();
return;
}
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
setDraft(next ? `${next} @${mention.name} ` : `@${mention.name} `);
setChips((c) => [...c, { kind: "at", label: mention.name }]);
onMentionPicked?.(mention.name);
const mentionPath = mention.path ?? mention.name;
insertMention(mentionPath);
return;
}
activeRangeRef.current = null;
setPopup(null);
textareaRef.current?.focus();
};
const recordSendAndReset = () => {
const trimmed = draft.trim();
historyRef.current.push(trimmed);
if (historyRef.current.length > 100) historyRef.current.shift();
setBrowseIdx(-1);
};
const navigateHistory = (dir: -1 | 1) => {
const hist = historyRef.current;
if (hist.length === 0) return;
if (dir === -1) {
const nextIdx = browseIdx + 1;
if (nextIdx < hist.length) {
if (browseIdx === -1) savedDraftRef.current = draft;
setBrowseIdx(nextIdx);
setDraft(hist[hist.length - 1 - nextIdx]);
}
} else {
if (browseIdx > 0) {
const nextIdx = browseIdx - 1;
setBrowseIdx(nextIdx);
setDraft(hist[hist.length - 1 - nextIdx]);
} else if (browseIdx === 0) {
setBrowseIdx(-1);
setDraft(savedDraftRef.current);
}
}
const shouldNavigatePromptHistory = (
e: KeyboardEvent<HTMLTextAreaElement>,
direction: "older" | "newer",
) => {
if (!onPromptHistoryNavigate) return false;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false;
const native = e.nativeEvent as globalThis.KeyboardEvent;
if (native.isComposing) return false;
const ta = textareaRef.current;
if (!ta || ta.selectionStart !== ta.selectionEnd) return false;
const atStart = ta.selectionStart === 0;
const atEnd = ta.selectionStart === draft.length;
if (promptHistoryBrowsing) return atStart || atEnd;
return direction === "older" && atStart;
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
@@ -429,25 +551,40 @@ export function Composer({
return;
}
if (e.key === "Tab" && popup.kind === "at" && items.length > 0) {
// Tab on a directory enters it — replaces `@src` with `@src/`
// and re-queries so the popup shows that directory's children.
e.preventDefault();
// `..` is the synthetic parent-dir entry (#1019); same shape
// but rewrites to the parent path.
const it = items[activeIdx];
if (it && (it as MentionItem).kind === "dir") {
e.preventDefault();
const mention = it as MentionItem;
const range = activeRangeRef.current;
const before = range ? draft.slice(0, range.start) : draft;
const after = range ? draft.slice(range.end) : "";
if (mention.name === "..") {
const parent = parentOfAtQuery(popup.query) ?? "";
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
const newText = `@${parent}`;
const next = `${before}${newText}${after}`;
setDraft(next);
activeRangeRef.current = {
start: range?.start ?? 0,
end: (range?.start ?? 0) + newText.length,
sigil: "@",
query: parent,
};
const nonce = ++nonceRef.current;
setPopup({ kind: "at", query: parent, nonce });
return;
}
const dirPath = mention.name.replace(/\/+$/, "");
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
const dirPath = (mention.path ?? mention.name).replace(/[\\/]+$/, "");
const newText = `@${dirPath}/`;
const next = `${before}${newText}${after}`;
setDraft(next);
activeRangeRef.current = {
start: range?.start ?? 0,
end: (range?.start ?? 0) + newText.length,
sigil: "@",
query: `${dirPath}/`,
};
const nonce = ++nonceRef.current;
setPopup({ kind: "at", query: `${dirPath}/`, nonce });
return;
@@ -463,31 +600,29 @@ export function Composer({
}
}
if (!popup) {
const ta = textareaRef.current;
if (e.key === "ArrowUp" && ta && ta.selectionStart === 0) {
e.preventDefault();
navigateHistory(-1);
return;
}
if (e.key === "ArrowDown" && ta && ta.selectionStart === draft.length) {
e.preventDefault();
navigateHistory(1);
return;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const direction = e.key === "ArrowUp" ? "older" : "newer";
if (shouldNavigatePromptHistory(e, direction)) {
const handled = onPromptHistoryNavigate?.(direction);
if (handled !== false) {
e.preventDefault();
return;
}
}
}
}
if (composingRef.current || Date.now() - compositionEndedAtRef.current < 50) return;
if (composingRef.current || e.nativeEvent.isComposing || Date.now() - compositionEndedAtRef.current < 50) return;
if (e.key === "Enter" && !e.shiftKey && !popup) {
e.preventDefault();
if (busy) {
const text = draft.trim();
if (text && onQueueWhileBusy) {
onQueueWhileBusy(text);
setChips([]);
setPickedChips(new Map());
}
} else if (!disabled && draft.trim()) {
recordSendAndReset();
onSend();
setChips([]);
setPickedChips(new Map());
}
}
};
@@ -553,41 +688,40 @@ export function Composer({
{chips.length > 0 ? (
<div className="composer-tags">
{chips.map((c, i) => (
<span key={i} className={`chip ${c.kind}`}>
<span key={`${c.kind}-${c.label}-${i}`} className={`chip ${c.kind}`} title={c.label}>
{c.kind === "slash" ? (
<I.slash size={11} />
) : (
<I.at size={11} />
)}
<span>{c.label}</span>
<span
className="x"
onClick={() =>
setChips((cs) => cs.filter((_, j) => j !== i))
}
>
<I.x size={10} />
</span>
<span>{chipLabel(c.label)}</span>
</span>
))}
</div>
) : null}
<textarea
ref={textareaRef}
value={draft}
placeholder={t("composer.placeholder")}
onChange={handleChange}
onPaste={(e) => void handlePaste(e)}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={() => {
composingRef.current = false;
compositionEndedAtRef.current = Date.now();
}}
rows={DEFAULT_COMPOSER_ROWS}
disabled={disabled}
/>
<div className="composer-textarea-wrap">
<div className="composer-backdrop" ref={backdropRef} aria-hidden="true">
{backdropContent}
</div>
<textarea
ref={textareaRef}
value={draft}
placeholder={t("composer.placeholder")}
onChange={handleChange}
onSelect={handleSelect}
onScroll={handleTextareaScroll}
onPaste={(e) => void handlePaste(e)}
onKeyDown={handleKeyDown}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={() => {
composingRef.current = false;
compositionEndedAtRef.current = Date.now();
}}
rows={DEFAULT_COMPOSER_ROWS}
disabled={disabled}
/>
</div>
<div className="composer-foot">
<button
@@ -680,9 +814,8 @@ export function Composer({
disabled={disabled || !draft.trim()}
onClick={() => {
if (!disabled && draft.trim()) {
recordSendAndReset();
onSend();
setChips([]);
setPickedChips(new Map());
}
}}
>
@@ -701,7 +834,8 @@ export function Composer({
onHover={(i, item) => {
setActiveIdx(i);
if (popup.kind === "at" && onMentionPreview) {
const path = (item as MentionItem).name;
const mention = item as MentionItem;
const path = mention.path ?? mention.name;
onMentionPreview(path, popup.nonce);
}
}}
@@ -738,7 +872,10 @@ function Popup({
}, [activeIdx]);
return (
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
<div
className={kind === "at" ? "popup at-popup" : "popup"}
onMouseDown={(e) => e.preventDefault()}
>
<div className="ph">
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
<span>
@@ -785,7 +922,7 @@ function Popup({
</>
) : (
<>
<span>{(it as MentionItem).name}</span>
<span className="mention-name">{(it as MentionItem).name}</span>
{(it as MentionItem).desc ? (
<div className="desc">{(it as MentionItem).desc}</div>
) : null}
+39
View File
@@ -90,4 +90,43 @@ describe("ContextPanel files", () => {
expect(screen.getByText("150 / 1,000,000")).toBeTruthy();
expect(screen.getByText("100")).toBeTruthy();
});
it("re-applies the requested tab when activeTabNonce changes", () => {
const { rerender } = render(
<ContextPanel
settings={settings}
usage={usage}
mcpSpecs={[]}
mcpBridged={false}
sessionFiles={[]}
memory={[]}
memoryDetail={null}
activeTab="tools"
activeTabNonce={1}
onReadMemory={() => {}}
/>,
);
expect(screen.getByText("Tools").getAttribute("data-active")).toBe("true");
fireEvent.click(screen.getByText("Memory"));
expect(screen.getByText("Memory").getAttribute("data-active")).toBe("true");
rerender(
<ContextPanel
settings={settings}
usage={usage}
mcpSpecs={[]}
mcpBridged={false}
sessionFiles={[]}
memory={[]}
memoryDetail={null}
activeTab="tools"
activeTabNonce={2}
onReadMemory={() => {}}
/>,
);
expect(screen.getByText("Tools").getAttribute("data-active")).toBe("true");
});
});
+142 -66
View File
@@ -1,13 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
import { openPath } from "@tauri-apps/plugin-opener";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { SessionFile, Settings, UsageStats } from "../App";
import { Markdown } from "../Markdown";
import { t, useLang } from "../i18n";
import { I } from "../icons";
import type { McpSpecInfo, MemoryDetail, MemoryEntryInfo } from "../protocol";
import { PanelErrorBoundary } from "./error-boundary";
import { McpServerCard } from "./mcp-server-card";
type Tab = "files" | "tools" | "memory" | "rules";
export type ContextPanelTab = "files" | "tools" | "memory" | "rules";
const CONTEXT_MAX_TOKENS = 1_000_000;
@@ -19,7 +21,12 @@ export function ContextPanel({
sessionFiles,
memory,
memoryDetail,
activeTab,
activeTabNonce,
onReadMemory,
onOpenMcpSettings,
onEditMcpSpec,
onRetryMcpSpec,
}: {
settings: Settings | null;
usage: UsageStats;
@@ -28,10 +35,18 @@ export function ContextPanel({
sessionFiles: SessionFile[];
memory: MemoryEntryInfo[];
memoryDetail: MemoryDetail | null;
activeTab?: ContextPanelTab;
activeTabNonce?: number;
onReadMemory: (path: string) => void;
onOpenMcpSettings?: () => void;
onEditMcpSpec?: (spec: McpSpecInfo) => void;
onRetryMcpSpec?: (raw: string) => void;
}) {
useLang();
const [tab, setTab] = useState<Tab>("files");
const [tab, setTab] = useState<ContextPanelTab>("files");
useEffect(() => {
if (activeTab) setTab(activeTab);
}, [activeTab, activeTabNonce]);
const reserved = usage.reservedTokens;
const lastHit = usage.lastCallCacheHit ?? 0;
const lastMiss = usage.lastCallCacheMiss ?? 0;
@@ -61,7 +76,7 @@ export function ContextPanel({
</div>
<div className="ctx-body">
<div className="ctx-block">
<div className="ctx-block ctx-body-tokens">
<div className="h">
<span>{t("contextPanel.contextTokens")}</span>
<span className="right">
@@ -93,14 +108,24 @@ export function ContextPanel({
</div>
</div>
<PanelErrorBoundary key={tab} label={tab}>
{tab === "files" && <CtxFiles files={sessionFiles} settings={settings} />}
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
{tab === "memory" && (
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
)}
{tab === "rules" && <CtxRules settings={settings} />}
</PanelErrorBoundary>
<div className="ctx-body-tab">
<PanelErrorBoundary key={tab} label={tab}>
{tab === "files" && <CtxFiles files={sessionFiles} settings={settings} />}
{tab === "tools" && (
<CtxTools
specs={mcpSpecs}
bridged={mcpBridged}
onOpenSettings={onOpenMcpSettings}
onEdit={onEditMcpSpec}
onRetry={onRetryMcpSpec}
/>
)}
{tab === "memory" && (
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
)}
{tab === "rules" && <CtxRules settings={settings} />}
</PanelErrorBoundary>
</div>
</div>
</aside>
);
@@ -236,8 +261,36 @@ function CtxFiles({ files, settings }: { files: SessionFile[]; settings: Setting
);
}
function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }) {
type McpFilter = "all" | "ready" | "failed";
function CtxTools({
specs,
bridged,
onOpenSettings,
onEdit,
onRetry,
}: {
specs: McpSpecInfo[];
bridged: boolean;
onOpenSettings?: () => void;
onEdit?: (spec: McpSpecInfo) => void;
onRetry?: (raw: string) => void;
}) {
const [filter, setFilter] = useState<McpFilter>("all");
const readyCount = specs.filter((s) => s.status === "connected").length;
const failed = specs.filter((s) => s.status === "failed");
const failedCount = failed.length;
const toolCount = specs.reduce((sum, s) => sum + (s.toolCount ?? s.tools?.length ?? 0), 0);
const filtered =
filter === "ready"
? specs.filter((s) => s.status === "connected")
: filter === "failed"
? failed
: [...failed, ...specs.filter((s) => s.status !== "failed")];
const retryAll = () => {
if (!onRetry) return;
for (const spec of failed) onRetry(spec.raw);
};
return (
<div className="ctx-block">
<div className="h">
@@ -253,42 +306,59 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
{specs.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.mcpEmpty")}</div>
) : (
specs.map((s) => {
const dot =
s.status === "connected"
? "ok"
: s.status === "failed" || s.parseError
? "off"
: "pending";
const suffix = s.statusReason
? ` · ${s.statusReason}`
: s.status === "connected"
? typeof s.toolCount === "number"
? ` · ${t("contextPanel.mcpTools", { count: s.toolCount })}`
: ` · ${t("contextPanel.mcpReady")}`
: s.status === "handshake"
? ` · ${t("contextPanel.mcpConnecting")}`
: s.status === "disabled"
? ` · ${t("contextPanel.mcpDisabled")}`
: s.status === "failed"
? ` · ${t("contextPanel.mcpFailed")}`
: ` · ${t("contextPanel.mcpConfigured")}`;
return (
<div className="mcp-row" key={s.raw}>
<span className="ico">
<I.wrench size={12} />
</span>
<div className="body">
<div className="n">{s.name ?? s.summary}</div>
<div className="m">
{s.transport}
{suffix}
</div>
</div>
<span className="status" data-s={dot} />
</div>
);
})
<>
<div className="mcp-health-strip">
<span>{t("contextPanel.mcpHealthTotal", { count: specs.length })}</span>
<span data-kind="ok">{t("contextPanel.mcpHealthReady", { count: readyCount })}</span>
<span data-kind={failedCount > 0 ? "failed" : "muted"}>
{t("contextPanel.mcpHealthFailed", { count: failedCount })}
</span>
<span>{t("contextPanel.mcpHealthTools", { count: toolCount })}</span>
</div>
<div className="mcp-filter-row">
{(["all", "ready", "failed"] as const).map((kind) => (
<button
key={kind}
type="button"
className="mcp-filter"
data-active={filter === kind}
onClick={() => setFilter(kind)}
>
{kind === "all"
? t("contextPanel.mcpFilterAll")
: kind === "ready"
? t("contextPanel.mcpFilterReady")
: t("contextPanel.mcpFilterFailed")}
</button>
))}
<span className="spacer" />
{failedCount > 0 && onRetry ? (
<button type="button" className="mcp-mini-action" onClick={retryAll}>
<I.refresh size={12} />
{t("contextPanel.mcpRetryAll")}
</button>
) : null}
{onOpenSettings ? (
<button type="button" className="mcp-mini-action" onClick={onOpenSettings}>
<I.cog size={12} />
{t("contextPanel.mcpSettings")}
</button>
) : null}
</div>
{filtered.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.mcpFilterEmpty")}</div>
) : (
filtered.map((s) => (
<McpServerCard
key={s.raw}
spec={s}
mode="context"
onRetry={onRetry}
onEdit={onEdit ?? (onOpenSettings ? () => onOpenSettings() : undefined)}
/>
))
)}
</>
)}
</div>
);
@@ -304,7 +374,7 @@ function CtxMemory({
onRead: (path: string) => void;
}) {
return (
<div className="ctx-block">
<div className="ctx-block" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
<div className="h">
<span>{t("contextPanel.memoryTitle")}</span>
<span className="right">
@@ -314,22 +384,28 @@ function CtxMemory({
{entries.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
) : (
<div className="mem">
{entries.map((m) => (
<button
type="button"
className="mem-row"
data-active={detail?.path === m.path}
key={m.path}
onClick={() => onRead(m.path)}
>
<span className="scope" data-s={m.scope}>
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
</span>
<span className="txt">{m.description || m.name}</span>
</button>
))}
{detail ? <pre className="mem-detail">{detail.body}</pre> : null}
<div className="mem" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
<div style={{ flexShrink: 0 }}>
{entries.map((m) => (
<button
type="button"
className="mem-row"
data-active={detail?.path === m.path}
key={m.path}
onClick={() => onRead(m.path)}
>
<span className="scope" data-s={m.scope}>
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
</span>
<span className="txt">{m.description || m.name}</span>
</button>
))}
</div>
{detail ? (
<div className="mem-detail">
<Markdown source={detail.body} />
</div>
) : null}
</div>
)}
</div>
+7 -1
View File
@@ -258,7 +258,13 @@ export function UsageFull({
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);
const pct = (n: number): number => {
const raw = (n / total) * 100;
if (raw === 100) return 100;
const rounded = Math.round(raw * 100) / 100;
if (rounded >= 100) return 99.99;
return rounded;
};
return (
<div className="usage-full">
<div className="uh">
+10 -5
View File
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
interface JumpBarProps {
messages: { kind: string; text?: string; turn?: number }[];
threadEl: HTMLElement | null;
threadEl?: HTMLElement | null;
onScrollToTurn?: (turn: number) => void;
}
export function JumpBar({ messages, threadEl }: JumpBarProps) {
export function JumpBar({ messages, threadEl, onScrollToTurn }: JumpBarProps) {
const [hovered, setHovered] = useState<number | null>(null);
const [active, setActive] = useState<number | null>(null);
const barRef = useRef<HTMLDivElement>(null);
@@ -65,9 +66,13 @@ export function JumpBar({ messages, threadEl }: JumpBarProps) {
const scrollTo = (turn: number) => {
setActive(turn);
threadEl
?.querySelector(`[data-turn="${turn}"]`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
if (onScrollToTurn) {
onScrollToTurn(turn);
} else {
threadEl
?.querySelector(`[data-turn="${turn}"]`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const dotProps = (
+158
View File
@@ -0,0 +1,158 @@
import { useState } from "react";
import { t } from "../i18n";
import { I } from "../icons";
import type { McpSpecInfo } from "../protocol";
import { Tooltip } from "./tooltip";
export type McpServerCardMode = "settings" | "context";
export function mcpStatusLabel(spec: McpSpecInfo): string {
if (spec.status === "failed") return mcpFailureLabel(spec);
switch (spec.status) {
case "connected":
return t("settings.mcpStatus.connected");
case "handshake":
return t("settings.mcpStatus.handshake");
case "disabled":
return t("settings.mcpStatus.disabled");
case "configured":
return t("settings.mcpStatus.configured");
}
}
export function mcpFailureLabel(spec: McpSpecInfo): string {
switch (spec.statusHint) {
case "missing-token":
return t("settings.mcpFailure.missingToken");
case "auth":
return t("settings.mcpFailure.auth");
case "command":
return t("settings.mcpFailure.command");
case "network":
return t("settings.mcpFailure.network");
default:
return t("settings.mcpFailure.unknown");
}
}
export function mcpEditLabel(spec: McpSpecInfo): string {
void spec;
return t("settings.mcpEdit");
}
export function McpServerCard({
spec,
mode = "settings",
onEdit,
onRetry,
onRemove,
}: {
spec: McpSpecInfo;
mode?: McpServerCardMode;
onEdit?: (spec: McpSpecInfo) => void;
onRetry?: (raw: string) => void;
onRemove?: (raw: string) => void;
}) {
const [toolsOpen, setToolsOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const tools = spec.tools ?? [];
const hasTools = tools.length > 0;
const canExpandTools = hasTools || spec.status === "connected";
const displayName = spec.name ?? "(anonymous)";
const status = mcpStatusLabel(spec);
const toolCount = typeof spec.toolCount === "number" ? spec.toolCount : tools.length;
const statusText = `${status}${toolCount ? ` · ${t("contextPanel.mcpTools", { count: toolCount })}` : ""}`;
const statusTooltip =
spec.status === "failed" && spec.statusReason
? `${statusText}\n${spec.statusReason}`
: statusText;
return (
<div className="scard mcp-server-card" data-mode={mode} data-status={spec.status}>
<div className="top">
<span className="ico">
<I.wrench size={14} />
</span>
<div className="mcp-spec-body">
<div className="nm">{displayName}</div>
<div className="sub mcp-spec-summary" title={spec.summary}>
{spec.summary}
</div>
<Tooltip content={statusTooltip} className="mcp-status-tooltip">
<span className="mcp-spec-status" data-status={spec.status}>
{statusText}
</span>
</Tooltip>
</div>
<div className="mcp-card-actions">
{canExpandTools ? (
<button type="button" className="btn ghost" onClick={() => setToolsOpen((v) => !v)}>
<I.chev size={13} className={toolsOpen ? "rot" : ""} />
{toolsOpen ? t("mcpCard.hideTools") : t("mcpCard.showTools")}
</button>
) : null}
{onEdit && !spec.parseError ? (
<button type="button" className="btn ghost" onClick={() => onEdit(spec)}>
<I.pencil size={13} />
{mcpEditLabel(spec)}
</button>
) : null}
{onRetry && spec.status === "failed" ? (
<button type="button" className="btn ghost" onClick={() => onRetry(spec.raw)}>
<I.refresh size={13} />
{t("settings.mcpRetry")}
</button>
) : null}
{spec.status === "failed" && spec.statusReason ? (
<button type="button" className="btn ghost" onClick={() => setDetailOpen((v) => !v)}>
{detailOpen ? t("settings.mcpHideDetail") : t("settings.mcpDetail")}
</button>
) : null}
{onRemove ? (
<button
type="button"
className="btn ghost mcp-remove"
style={{ color: "var(--danger)" }}
onClick={() => onRemove(spec.raw)}
>
{t("settings.mcpRemove")}
</button>
) : null}
</div>
</div>
{spec.parseError ? (
<div className="desc" style={{ color: "var(--danger)" }}>
{t("settings.parseError", { error: spec.parseError })}
</div>
) : null}
{toolsOpen ? (
<div className="mcp-tools-detail">
<div className="mcp-tools-detail-title">
{hasTools
? t("mcpCard.availableTools", { count: tools.length })
: t("mcpCard.noTools")}
</div>
{hasTools ? (
<div className="mcp-tools-grid">
{tools.map((tool) => (
<div className="mcp-tool-chip" key={tool.registeredName} title={tool.description ?? tool.registeredName}>
<span className="mcp-tool-name">{tool.registeredName}</span>
{tool.description ? <span className="mcp-tool-desc">{tool.description}</span> : null}
</div>
))}
</div>
) : null}
</div>
) : null}
{detailOpen && spec.statusReason ? (
<div className="mcp-error-detail">
<div className="mcp-error-detail-title">{t("settings.mcpDetailTitle")}</div>
<pre>{spec.statusReason}</pre>
</div>
) : null}
</div>
);
}
+421 -43
View File
@@ -1,9 +1,10 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
import { I } from "../icons";
import type {
ImportedMcpServer,
McpSpecInfo,
MemoryDetail,
MemoryEntryInfo,
@@ -27,6 +28,7 @@ import {
type ThemeStyle,
themeForStyle,
} from "../theme";
import { McpServerCard } from "./mcp-server-card";
import { Shortcut, type ShortcutKey } from "./shortcut";
export type PageId =
@@ -66,6 +68,8 @@ export function SettingsModal({
customFontFamily,
onSetCustomFontFamily,
initialPage,
initialMcpEditRaw,
initialMcpEditNonce,
mcpSpecs,
mcpBridged,
skills,
@@ -81,8 +85,11 @@ export function SettingsModal({
onSaveQQConfig,
onOpenQQApplyLink,
onPickWorkspace,
onImportCcSwitchMcp,
onAddMcpSpec,
onRemoveMcpSpec,
onUpdateMcpSpec,
onRetryMcpSpec,
onReadMemory,
}: {
settings: SettingsType;
@@ -100,6 +107,8 @@ export function SettingsModal({
customFontFamily: string;
onSetCustomFontFamily: (family: string) => void;
initialPage?: PageId;
initialMcpEditRaw?: string | null;
initialMcpEditNonce?: number;
mcpSpecs: McpSpecInfo[];
mcpBridged: boolean;
skills: SkillInfo[];
@@ -115,12 +124,18 @@ export function SettingsModal({
onSaveQQConfig: (patch: { appId?: string; appSecret?: string; sandbox: boolean }) => void;
onOpenQQApplyLink: () => void;
onPickWorkspace: () => void;
onImportCcSwitchMcp: () => Promise<void>;
onAddMcpSpec: (spec: string) => void;
onRemoveMcpSpec: (spec: string) => void;
onUpdateMcpSpec: (raw: string, server: ImportedMcpServer) => void;
onRetryMcpSpec: (raw: string) => void;
onReadMemory: (path: string) => void;
}) {
const [page, setPage] = useState<PageId>(initialPage ?? "general");
const [qqConfigureOpen, setQQConfigureOpen] = useState(false);
useEffect(() => {
if (initialPage) setPage(initialPage);
}, [initialPage]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
@@ -191,8 +206,13 @@ export function SettingsModal({
<PageMCP
specs={mcpSpecs}
bridged={mcpBridged}
initialEditRaw={initialMcpEditRaw}
initialEditNonce={initialMcpEditNonce}
onImportCcSwitch={onImportCcSwitchMcp}
onAdd={onAddMcpSpec}
onRemove={onRemoveMcpSpec}
onUpdate={onUpdateMcpSpec}
onRetry={onRetryMcpSpec}
/>
)}
{page === "skills" && (
@@ -701,6 +721,28 @@ function PageGeneral({
</button>
</div>
</div>
<div className="setting-row">
<div className="l">
<div className="n">{t("settings.desktopCloseBehavior")}</div>
<div className="h">{t("settings.desktopCloseBehaviorHint")}</div>
</div>
<div className="seg-ctrl">
<button
type="button"
data-on={(settings.desktopCloseBehavior ?? "closeToQuit") === "closeToQuit"}
onClick={() => onSave({ desktopCloseBehavior: "closeToQuit" })}
>
{t("settings.closeToQuit")}
</button>
<button
type="button"
data-on={settings.desktopCloseBehavior === "closeToTray"}
onClick={() => onSave({ desktopCloseBehavior: "closeToTray" })}
>
{t("settings.closeToTray")}
</button>
</div>
</div>
<div className="setting-row">
<div className="l">
<div className="n">{t("settings.budget")}</div>
@@ -729,8 +771,10 @@ function PageGeneral({
onSave({
webSearchEngine: e.target.value as
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa"
@@ -740,8 +784,10 @@ function PageGeneral({
}
>
<option value="bing">{t("settings.webSearchEngineBing")}</option>
<option value="bing-intl">{t("settings.webSearchEngineBingIntl")}</option>
<option value="searxng">{t("settings.webSearchEngineSearxng")}</option>
<option value="metaso">{t("settings.webSearchEngineMetaso")}</option>
<option value="baidu">{t("settings.webSearchEngineBaidu")}</option>
<option value="tavily">{t("settings.webSearchEngineTavily")}</option>
<option value="perplexity">{t("settings.webSearchEnginePerplexity")}</option>
<option value="exa">{t("settings.webSearchEngineExa")}</option>
@@ -756,11 +802,23 @@ function PageGeneral({
}
const SEARCH_ENGINE_API_KEY_FIELDS: ReadonlyArray<{
engine: "metaso" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
patchKey: "metasoApiKey" | "tavilyApiKey" | "perplexityApiKey" | "exaApiKey" | "braveApiKey" | "ollamaApiKey";
engine: "metaso" | "baidu" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
patchKey:
| "metasoApiKey"
| "baiduApiKey"
| "tavilyApiKey"
| "perplexityApiKey"
| "exaApiKey"
| "braveApiKey"
| "ollamaApiKey";
signupUrl: string;
}> = [
{ engine: "metaso", patchKey: "metasoApiKey", signupUrl: "https://metaso.cn/settings/api" },
{
engine: "baidu",
patchKey: "baiduApiKey",
signupUrl: "https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy",
},
{ engine: "tavily", patchKey: "tavilyApiKey", signupUrl: "https://app.tavily.com" },
{
engine: "perplexity",
@@ -780,7 +838,7 @@ function WebSearchEngineCredentials({
onSave: (patch: SettingsPatch) => void;
}) {
const engine = settings.webSearchEngine ?? "bing";
if (engine === "bing") return null;
if (engine === "bing" || engine === "bing-intl") return null;
if (engine === "searxng") {
return <SearxngEndpointRow settings={settings} onSave={onSave} />;
}
@@ -837,8 +895,15 @@ function WebSearchApiKeyRow({
prefix,
onSave,
}: {
engine: "metaso" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
patchKey: "metasoApiKey" | "tavilyApiKey" | "perplexityApiKey" | "exaApiKey" | "braveApiKey" | "ollamaApiKey";
engine: "metaso" | "baidu" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
patchKey:
| "metasoApiKey"
| "baiduApiKey"
| "tavilyApiKey"
| "perplexityApiKey"
| "exaApiKey"
| "braveApiKey"
| "ollamaApiKey";
signupUrl: string;
prefix?: string;
onSave: (patch: SettingsPatch) => void;
@@ -1020,6 +1085,32 @@ function PageModels({
{t("settings.modelCustomActive", { model: settings.model })}
</div>
) : null}
<div className="setting-row" style={{ marginTop: 12 }}>
<div className="l">
<div className="n">{t("settings.contextTokensLabel")}</div>
<div className="h">{t("settings.contextTokensHint")}</div>
</div>
<div style={{ display: "flex", gap: 6 }}>
<input
className="field mono"
type="number"
min={1}
value={settings.contextTokens?.[settings.model] ?? ""}
onChange={(e) => {
const raw = e.target.value.trim();
const num = raw ? parseInt(raw, 10) : 0;
const next = { ...(settings.contextTokens ?? {}) };
if (num > 0 && Number.isFinite(num)) {
next[settings.model] = num;
} else {
delete next[settings.model];
}
onSave({ contextTokens: Object.keys(next).length > 0 ? next : undefined });
}}
placeholder={t("settings.contextTokensPlaceholder")}
/>
</div>
</div>
</section>
<section className="section">
<div className="stitle">{t("settings.effortSection")}</div>
@@ -1048,36 +1139,112 @@ function PageModels({
function PageMCP({
specs,
bridged,
initialEditRaw,
initialEditNonce,
onImportCcSwitch,
onAdd,
onRemove,
onUpdate,
onRetry,
}: {
specs: McpSpecInfo[];
bridged: boolean;
initialEditRaw?: string | null;
initialEditNonce?: number;
onImportCcSwitch: () => Promise<void>;
onAdd: (spec: string) => void;
onRemove: (spec: string) => void;
onUpdate: (raw: string, server: ImportedMcpServer) => void;
onRetry: (raw: string) => void;
}) {
const [draft, setDraft] = useState("");
const [importing, setImporting] = useState(false);
const [editing, setEditing] = useState<McpSpecInfo | null>(null);
const appliedEditNonceRef = useRef<number | null>(null);
useEffect(() => {
if (!initialEditRaw) return;
const nonce = initialEditNonce ?? 0;
if (appliedEditNonceRef.current === nonce) return;
const target = specs.find((s) => s.raw === initialEditRaw);
if (target && !target.parseError) {
appliedEditNonceRef.current = nonce;
setEditing(target);
}
}, [initialEditRaw, initialEditNonce, specs]);
const connectedCount = specs.filter((s) => s.status === "connected").length;
const failedCount = specs.filter((s) => s.status === "failed").length;
const disabledCount = specs.filter((s) => s.status === "disabled").length;
const connectingCount = specs.filter((s) => s.status === "configured" || s.status === "handshake")
.length;
const statusKind =
specs.length === 0
? "empty"
: failedCount > 0
? "failed"
: connectedCount === specs.length
? "connected"
: connectingCount > 0
? "connecting"
: disabledCount > 0
? "disabled"
: "pending";
const statusText =
statusKind === "connected"
? t("settings.mcpStatusConnected", { connected: connectedCount, total: specs.length })
: statusKind === "failed"
? t("settings.mcpStatusFailed", {
connected: connectedCount,
total: specs.length,
failed: failedCount,
})
: statusKind === "connecting"
? t("settings.mcpStatusConnecting", { connected: connectedCount, total: specs.length })
: statusKind === "disabled"
? t("settings.mcpStatusDisabled", { disabled: disabledCount, total: specs.length })
: t("settings.mcpStatusPending");
const submit = () => {
const v = draft.trim();
if (!v) return;
onAdd(v);
setDraft("");
};
if (editing) {
return (
<McpEditPage
spec={editing}
onBack={() => setEditing(null)}
onSave={(raw, server) => {
onUpdate(raw, server);
setEditing(null);
}}
/>
);
}
return (
<>
<section className="section">
<div className="stitle">
{t("settings.mcpConfigured", { count: specs.length })}
{bridged ? (
<span style={{ color: "var(--accent)", marginLeft: 8, fontSize: 11 }}>
{t("settings.mcpBridged")}
<div className="mcp-section-head">
<div className="stitle">
{t("settings.mcpConfigured", { count: specs.length })}
<span className="mcp-status-summary" data-status={statusKind}>
{statusText}
</span>
) : (
<span style={{ color: "var(--muted)", marginLeft: 8, fontSize: 11 }}>
{t("settings.mcpNotBridged")}
</span>
)}
</div>
<button
type="button"
className="btn"
disabled={importing}
onClick={async () => {
setImporting(true);
try {
await onImportCcSwitch();
} finally {
setImporting(false);
}
}}
>
{importing ? t("settings.mcpImporting") : t("settings.mcpImport")}
</button>
</div>
{specs.length === 0 ? (
<div
@@ -1094,32 +1261,14 @@ function PageMCP({
</div>
) : (
specs.map((s) => (
<div className="scard" key={s.raw}>
<div className="top">
<span className="ico">
<I.wrench size={14} />
</span>
<div className="mcp-spec-body">
<div className="nm">{s.name ?? "(anonymous)"}</div>
<div className="sub mcp-spec-summary" title={s.summary}>
{s.summary}
</div>
</div>
<button
type="button"
className="btn ghost mcp-remove"
style={{ color: "var(--danger)" }}
onClick={() => onRemove(s.raw)}
>
{t("settings.mcpRemove")}
</button>
</div>
{s.parseError ? (
<div className="desc" style={{ color: "var(--danger)" }}>
{t("settings.parseError", { error: s.parseError })}
</div>
) : null}
</div>
<McpServerCard
key={s.raw}
spec={s}
mode="settings"
onEdit={setEditing}
onRetry={onRetry}
onRemove={onRemove}
/>
))
)}
</section>
@@ -1150,6 +1299,235 @@ function PageMCP({
);
}
const MCP_NAME_PREFIX = /^([a-zA-Z_][a-zA-Z0-9_-]*)=(.*)$/;
const MCP_STREAMABLE_PREFIX = /^streamable\+(https?:\/\/.+)$/i;
const MCP_HTTP_URL = /^https?:\/\//i;
function splitMcpArgs(body: string): string[] {
const out: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
let escaping = false;
for (const ch of body) {
if (escaping) {
current += ch;
escaping = false;
continue;
}
if (ch === "\\") {
escaping = true;
continue;
}
if (quote) {
if (ch === quote) quote = null;
else current += ch;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current) {
out.push(current);
current = "";
}
continue;
}
current += ch;
}
if (current) out.push(current);
return out;
}
function mcpServerFromRawSpec(spec: McpSpecInfo): ImportedMcpServer | undefined {
const trimmed = spec.raw.trim();
if (!trimmed) return undefined;
const match = MCP_NAME_PREFIX.exec(trimmed);
const name = match?.[1] ?? spec.name ?? "";
const body = (match?.[2] ?? trimmed).trim();
if (!name || !body) return undefined;
const streamable = MCP_STREAMABLE_PREFIX.exec(body);
if (streamable) {
return { name, transport: "streamable-http", url: streamable[1] };
}
if (MCP_HTTP_URL.test(body)) {
return { name, transport: spec.transport === "streamable-http" ? "streamable-http" : "sse", url: body };
}
const argv = splitMcpArgs(body);
const [command, ...args] = argv;
if (!command) return undefined;
return { name, transport: "stdio", command, args };
}
function editableMcpServer(spec: McpSpecInfo): ImportedMcpServer | undefined {
return spec.config ?? mcpServerFromRawSpec(spec);
}
function mcpServerToJson(server: ImportedMcpServer | undefined): Record<string, unknown> {
if (!server) return {};
const out: Record<string, unknown> = {};
if (server.transport === "stdio") {
out.command = server.command ?? "";
out.args = server.args ?? [];
if (server.env && Object.keys(server.env).length > 0) out.env = server.env;
if (server.cwd) out.cwd = server.cwd;
} else {
out.url = server.url ?? "";
if (server.headers && Object.keys(server.headers).length > 0) out.headers = server.headers;
}
if (server.disabled === true) out.disabled = true;
if (typeof server.requestTimeoutMs === "number") out.requestTimeoutMs = server.requestTimeoutMs;
return out;
}
function normalizeStringRecordForMcp(value: unknown): Record<string, string> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const out: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string") out[key] = entry;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function normalizeMcpJsonDraft(
name: string,
transport: ImportedMcpServer["transport"],
value: unknown,
): ImportedMcpServer {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(t("settings.mcpEditJsonObjectRequired"));
}
const raw = value as Record<string, unknown>;
const requestTimeoutMs =
typeof raw.requestTimeoutMs === "number" && Number.isFinite(raw.requestTimeoutMs)
? raw.requestTimeoutMs
: undefined;
const disabled = raw.disabled === true ? true : undefined;
if (transport === "stdio") {
const command = typeof raw.command === "string" ? raw.command.trim() : "";
if (!command) throw new Error(t("settings.mcpEditCommandRequired"));
return {
name,
transport,
command,
args: Array.isArray(raw.args) ? raw.args.filter((a): a is string => typeof a === "string") : [],
env: normalizeStringRecordForMcp(raw.env),
cwd: typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : undefined,
disabled,
requestTimeoutMs,
};
}
const url = typeof raw.url === "string" ? raw.url.trim() : "";
if (!url) throw new Error(t("settings.mcpEditUrlRequired"));
return {
name,
transport,
url,
headers: normalizeStringRecordForMcp(raw.headers),
disabled,
requestTimeoutMs,
};
}
function McpEditPage({
spec,
onBack,
onSave,
}: {
spec: McpSpecInfo;
onBack: () => void;
onSave: (raw: string, server: ImportedMcpServer) => void;
}) {
const initial = editableMcpServer(spec);
const [name, setName] = useState(initial?.name ?? spec.name ?? "");
const [transport, setTransport] = useState<ImportedMcpServer["transport"]>(
initial?.transport ?? spec.transport,
);
const [jsonDraft, setJsonDraft] = useState(() =>
JSON.stringify(mcpServerToJson(initial), null, 2),
);
const [error, setError] = useState<string | null>(null);
const formatJson = () => {
try {
const parsed = JSON.parse(jsonDraft);
setJsonDraft(JSON.stringify(parsed, null, 2));
setError(null);
} catch (err) {
setError((err as Error).message);
}
};
const save = () => {
const nextName = name.trim();
if (!nextName) {
setError(t("settings.mcpEditNameRequired"));
return;
}
try {
const parsed = JSON.parse(jsonDraft);
onSave(spec.raw, normalizeMcpJsonDraft(nextName, transport, parsed));
} catch (err) {
setError((err as Error).message);
}
};
return (
<div className="mcp-edit">
<div className="mcp-edit-top">
<button type="button" className="btn ghost mcp-back-btn" onClick={onBack}>
<I.chevR size={14} className="mcp-back-icon" />
{t("settings.mcpEditBack")}
</button>
<div className="stitle">{t("settings.mcpEditTitle")}</div>
</div>
<section className="section mcp-edit-section">
<label className="mcp-edit-field">
<span>{t("settings.mcpEditName")}</span>
<input className="field mono" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="mcp-edit-field">
<span>{t("settings.mcpEditTransport")}</span>
<select
className="field mono"
value={transport}
onChange={(e) => setTransport(e.target.value as ImportedMcpServer["transport"])}
>
<option value="stdio">stdio</option>
<option value="sse">sse</option>
<option value="streamable-http">streamable-http</option>
</select>
</label>
</section>
<section className="section mcp-edit-section">
<div className="mcp-edit-json-head">
<div className="stitle">{t("settings.mcpEditJson")}</div>
<button type="button" className="btn ghost" onClick={formatJson}>
{t("settings.mcpEditFormat")}
</button>
</div>
<textarea
className="mcp-json-editor"
value={jsonDraft}
spellCheck={false}
onChange={(e) => setJsonDraft(e.target.value)}
/>
{error ? <div className="mcp-edit-error">{error}</div> : null}
</section>
<div className="mcp-edit-footer">
<button type="button" className="btn ghost" onClick={onBack}>
{t("revision.cancel")}
</button>
<button type="button" className="btn primary" onClick={save}>
<I.file size={14} />
{t("settings.mcpEditSave")}
</button>
</div>
</div>
);
}
function PageSkills({
skills,
subagentModels,
+4 -2
View File
@@ -284,9 +284,11 @@ export function Sidebar({
<span className="title">{prettyName(s)}</span>
)}
<span className="meta">
<span>{t("sidebarPanel.messageCount", { count: s.messageCount })}</span>
<span className="count">
{t("sidebarPanel.messageCount", { count: s.messageCount })}
</span>
<span className="sep">·</span>
<span>{updated}</span>
<span className="time">{updated}</span>
</span>
</div>
{editing ? null : (
+18 -3
View File
@@ -19,6 +19,16 @@ function tokenLabel(n: number): string {
return `${n}`;
}
/** Format a 0–100 percentage to 2 decimal places.
* 100.00 is shown only when the value is exactly 100; values that would round
* to 100.00 are capped at 99.99. */
function formatPct(value: number): string {
if (value === 100) return "100.00";
const rounded = Math.round(value * 100) / 100;
if (rounded >= 100) return "99.99";
return rounded.toFixed(2);
}
export function StatusBar({
settings,
balance,
@@ -57,7 +67,12 @@ export function StatusBar({
const liveContextTokens = usage.reservedTokens + usage.liveLogTokens;
const totalTokens = Math.max(sessionPromptTokens, liveContextTokens);
const cacheDenom = usage.cacheHitTokens + usage.cacheMissTokens;
const cacheHitPct = cacheDenom > 0 ? Math.round((usage.cacheHitTokens / cacheDenom) * 100) : 0;
const cacheHitRaw = cacheDenom > 0 ? (usage.cacheHitTokens / cacheDenom) * 100 : 0;
const cacheHitPctDisplay = formatPct(cacheHitRaw);
const cacheHitDetail =
cacheDenom > 0
? `${usage.cacheHitTokens.toLocaleString()} / ${cacheDenom.toLocaleString()} tokens (${cacheHitPctDisplay}%)`
: "";
const runningJobs = jobs.filter((j) => j.running).length;
const spent = formatMoney(usage.totalCostUsd, currency);
const balanceLabel = balance
@@ -89,10 +104,10 @@ export function StatusBar({
<span>{settings?.baseUrl?.replace(/^https?:\/\//, "") ?? "api.deepseek.com"}</span>
<span className="v">{!ready ? t("statusbar.offline") : busy ? t("statusbar.busy") : t("statusbar.online")}</span>
</span>
<span className="seg" title={t("statusbar.cacheHit")}>
<span className="seg" title={cacheHitDetail || t("statusbar.cacheHit")}>
<I.zap size={11} style={{ color: "var(--accent)" }} />
<span>{t("statusbar.cache")}</span>
<span className="v acc">{cacheHitPct}%</span>
<span className="v acc">{cacheHitPctDisplay}%</span>
</span>
<span className="seg">
<I.cpu size={11} />
+231 -94
View File
@@ -2,7 +2,7 @@ import type { ApprovalPrompt } from "@reasonix/core-utils";
import { isCompactionSummary, stripCompactionMarker } from "@reasonix/core-utils/compaction";
import { derivePrefix } from "@reasonix/core-utils/derive-prefix";
import { Copy } from "lucide-react";
import { type ReactNode, memo, useState } from "react";
import { type ReactNode, memo, useEffect, useRef, useState } from "react";
import type {
ActivePlan,
AssistantSegment,
@@ -13,6 +13,7 @@ import type {
PendingRevision,
SkillOrigin,
} from "../App";
import { Markdown, WithToolPaths } from "../Markdown";
import { t, useLang } from "../i18n";
import { I } from "../icons";
import {
@@ -102,6 +103,26 @@ export const UserMsg = memo(function UserMsg({
);
});
function ToolGroupShell({ segs, renderTool: rt }: {
segs: (AssistantSegment & { kind: "tool" })[];
renderTool: (s: AssistantSegment & { kind: "tool" }, idx: number, expanded?: boolean) => ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<div className="tool-group">
<button className="tool-group-header" onClick={() => setOpen((o) => !o)}>
<span>{segs.length > 1 ? t("thread.toolCalls", { count: segs.length }) : t("thread.oneToolCall")}</span>
<span className={`chevron ${open ? "open" : ""}`}>{I.chev({ size: 12 })}</span>
</button>
{open && (
<div className="tool-group-body">
{segs.map((ts) => rt(ts, segs.indexOf(ts), true))}
</div>
)}
</div>
);
}
export const AssistantMsg = memo(function AssistantMsg({
segments,
pending,
@@ -135,6 +156,134 @@ export const AssistantMsg = memo(function AssistantMsg({
/* ignore */
}
};
// Extract file paths from the current message's tool segments
const currentToolPaths: string[] = [];
for (const s of segments) {
if (s.kind !== "tool") continue;
if (s.name === "read_file" || s.name === "edit_file" || s.name === "write_file") {
try {
const path = JSON.parse(s.args)?.path;
if (typeof path === "string") currentToolPaths.push(path);
} catch { /* skip */ }
} else if (s.name === "multi_edit") {
try {
const edits = JSON.parse(s.args)?.edits;
if (Array.isArray(edits)) {
for (const e of edits) {
if (typeof e?.path === "string") currentToolPaths.push(e.path);
}
}
} catch { /* skip */ }
}
}
// Render tool segments — consecutive tools get grouped into a collapsible section
const rendered: ReactNode[] = [];
let firstReasoningShown = false;
let toolGroup: { segments: (AssistantSegment & { kind: "tool" })[]; indices: number[] } | null = null;
function flushToolGroup(): void {
if (!toolGroup) return;
const { segments: tSegs } = toolGroup;
const groupKey = toolGroup.indices[0]!;
if (tSegs.length === 1) {
rendered.push(renderTool(tSegs[0]!, groupKey));
} else {
rendered.push(<ToolGroupShell key={`tool-group-${groupKey}`} segs={tSegs} renderTool={renderTool} />);
}
toolGroup = null;
}
function renderTool(s: AssistantSegment & { kind: "tool" }, idx: number, expanded?: boolean): ReactNode {
const pendingConfirm =
(s.name === "run_command" || s.name === "run_background") && s.result === undefined
? pendingConfirms.find((c) => c.command === extractCommand(s.args))
: undefined;
if (s.name === "run_command" || s.name === "run_background") {
const cmd = extractCommand(s.args) ?? s.args;
const state: "await" | "running" | "done" | "failed" =
s.result === undefined
? pendingConfirm
? "await"
: "running"
: s.ok === false
? "failed"
: "done";
return (
<ShellCard
key={idx}
command={cmd}
output={s.result}
state={state}
durationMs={s.durationMs}
defaultOpen={expanded ?? state !== "done"}
onApprove={pendingConfirm ? () => onApproveConfirm(pendingConfirm.id) : undefined}
onReject={pendingConfirm ? () => onRejectConfirm(pendingConfirm.id) : undefined}
onAlwaysAllow={
pendingConfirm
? () => {
onAlwaysAllowConfirm(pendingConfirm.id, derivePrefix(cmd));
}
: undefined
}
/>
);
}
if (s.result && (s.name === "edit_file" || s.name === "multi_edit" || s.name === "write_file")) {
const files = parseEditResult(s.result);
return files.length > 0 ? (
<>
{files.map((f, fi) => (
<DiffCard key={`${idx}-${fi}`} filename={f.filename} lines={f.lines} applied={s.ok !== false} />
))}
</>
) : (
<ToolCard key={idx} name={s.name} args={s.args} result={s.result} ok={s.ok} durationMs={s.durationMs} defaultOpen={expanded} />
);
}
return (
<ToolCard key={idx} name={s.name} args={s.args} result={s.result} ok={s.ok} durationMs={s.durationMs} defaultOpen={expanded} />
);
}
for (let i = 0; i < segments.length; i++) {
const s = segments[i]!;
if (s.kind === "text") {
flushToolGroup();
if (!s.text.trim()) continue;
if (isCompactionSummary(s.text)) {
rendered.push(<CompactionCard key={`t-${i}`} summary={stripCompactionMarker(s.text)} />);
} else {
rendered.push(
<WithToolPaths key={`t-wrap-${i}`} toolPaths={currentToolPaths}>
<AssistantText text={s.text} />
</WithToolPaths>,
);
}
continue;
}
if (s.kind === "reasoning") {
flushToolGroup();
if (!firstReasoningShown) {
firstReasoningShown = true;
rendered.push(
<ReasoningCard key={`r-${i}`} text={s.text} streaming={pending && i === segments.length - 1} />,
);
}
// Subsequent reasoning segments are intermediate tool-calling thought — hidden
continue;
}
// Tool segment — accumulate into group
if (!toolGroup) {
toolGroup = { segments: [], indices: [] };
}
toolGroup.segments.push(s);
toolGroup.indices.push(i);
}
flushToolGroup();
return (
<div className="msg assistant">
<div className="avatar">DS</div>
@@ -144,92 +293,7 @@ export const AssistantMsg = memo(function AssistantMsg({
{model ? <span className="model">{model}</span> : null}
{time ? <span className="time">{time}</span> : null}
</div>
{segments.map((s, i) => {
if (s.kind === "text") {
if (!s.text.trim()) return null;
if (isCompactionSummary(s.text)) {
return <CompactionCard key={i} summary={stripCompactionMarker(s.text)} />;
}
return <AssistantText key={i} text={s.text} />;
}
if (s.kind === "reasoning") {
return (
<ReasoningCard
key={i}
text={s.text}
streaming={pending && i === segments.length - 1}
/>
);
}
// tool segment
const pendingConfirm =
(s.name === "run_command" || s.name === "run_background") && s.result === undefined
? pendingConfirms.find((c) => c.command === extractCommand(s.args))
: undefined;
if (s.name === "run_command" || s.name === "run_background") {
const cmd = extractCommand(s.args) ?? s.args;
const state: "await" | "running" | "done" | "failed" =
s.result === undefined
? pendingConfirm
? "await"
: "running"
: s.ok === false
? "failed"
: "done";
return (
<ShellCard
key={i}
command={cmd}
output={s.result}
state={state}
durationMs={s.durationMs}
onApprove={pendingConfirm ? () => onApproveConfirm(pendingConfirm.id) : undefined}
onReject={pendingConfirm ? () => onRejectConfirm(pendingConfirm.id) : undefined}
onAlwaysAllow={
pendingConfirm
? () => {
onAlwaysAllowConfirm(pendingConfirm.id, derivePrefix(cmd));
}
: undefined
}
/>
);
}
if (s.result && (s.name === "edit_file" || s.name === "multi_edit")) {
const files = parseEditResult(s.result);
return files.length > 0 ? (
<>
{files.map((f, fi) => (
<DiffCard
key={`${i}-${fi}`}
filename={f.filename}
lines={f.lines}
applied={s.ok !== false}
/>
))}
</>
) : (
<ToolCard
key={i}
name={s.name}
args={s.args}
result={s.result}
ok={s.ok}
durationMs={s.durationMs}
/>
);
}
return (
<ToolCard
key={i}
name={s.name}
args={s.args}
result={s.result}
ok={s.ok}
durationMs={s.durationMs}
/>
);
})}
{rendered}
{content ? (
<div className="msg-actions">
<button
@@ -281,7 +345,7 @@ export function PlanBanner({
{t("thread.planRunning", { step: Math.min(done + 1, total), total })}
{current ? ` — ${current.title}` : ""}
</div>
<div className="s">{plan.plan}</div>
<Markdown source={plan.plan} />
</div>
<div className="prog">
<div className="meter-mini">
@@ -326,12 +390,81 @@ export function PlanApprovalCard({
}: {
p: PendingPlan;
onApprove: () => void;
onRefine: () => void;
onCancel: () => void;
onRefine: (feedback?: string) => void;
onCancel: (feedback?: string) => void;
}) {
useLang();
const [feedbackMode, setFeedbackMode] = useState<"cancel" | "refine" | null>(null);
const [feedbackText, setFeedbackText] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (feedbackMode && inputRef.current) {
inputRef.current.focus();
}
}, [feedbackMode]);
const handleSendFeedback = () => {
const fb = feedbackText.trim() || undefined;
if (feedbackMode === "cancel") onCancel(fb);
else if (feedbackMode === "refine") onRefine(fb);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendFeedback();
} else if (e.key === "Escape") {
e.preventDefault();
if (feedbackMode === "cancel") onCancel();
else if (feedbackMode === "refine") onRefine();
}
};
const stepCount = p.steps?.length ?? 0;
const sub = stepCount > 0 ? t("thread.planStepCount", { count: stepCount }) : undefined;
if (feedbackMode) {
const isCancel = feedbackMode === "cancel";
return (
<div className="approval" data-tone="info">
<div className="ap-head">
<span className="ap-ico">
<I.shield size={13} />
</span>
<div>
<div className="ap-kind">{t("thread.planConfirmationKind")}</div>
<div className="ap-title">
{isCancel ? t("thread.cancelFeedbackLabel") : t("thread.refineFeedbackLabel")}
</div>
</div>
</div>
<div className="ap-body">
<textarea
ref={inputRef}
className="approval-textarea"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("modal.planFeedbackPlaceholder")}
/>
</div>
<div className="ap-foot">
<button type="button" className="btn primary" onClick={handleSendFeedback}>
{t("thread.sendFeedback")}
</button>
<button
type="button"
className="btn ghost"
onClick={() => (isCancel ? onCancel() : onRefine())}
>
{t("thread.skipFeedback")}
</button>
</div>
</div>
);
}
return (
<ApprovalCard
kind={t("thread.planConfirmationKind")}
@@ -340,8 +473,12 @@ export function PlanApprovalCard({
sub={sub}
body={
<>
{p.summary ? <div style={{ marginBottom: 6 }}>{p.summary}</div> : null}
<div style={{ whiteSpace: "pre-wrap" }}>{p.plan}</div>
{p.summary ? (
<div style={{ marginBottom: 6 }}>
<Markdown source={p.summary} />
</div>
) : null}
<Markdown source={p.plan} />
</>
}
meta={`plan/#${p.id}`}
@@ -349,8 +486,8 @@ export function PlanApprovalCard({
secondaryLabel={t("thread.cancel")}
tertiaryLabel={t("thread.refine")}
onPrimary={onApprove}
onSecondary={onCancel}
onTertiary={onRefine}
onSecondary={() => setFeedbackMode("cancel")}
onTertiary={() => setFeedbackMode("refine")}
/>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { type ReactNode, useId, useState } from "react";
import { createPortal } from "react-dom";
type TooltipPlacement = "top" | "bottom";
type TooltipState = {
left: number;
top: number;
placement: TooltipPlacement;
};
export function Tooltip({
content,
children,
className,
disabled,
}: {
content?: string | null;
children: ReactNode;
className?: string;
disabled?: boolean;
}) {
const id = useId();
const [tip, setTip] = useState<TooltipState | null>(null);
const text = content?.trim();
const active = Boolean(text) && !disabled;
const show = (target: HTMLElement) => {
if (!active) return;
const rect = target.getBoundingClientRect();
const placement: TooltipPlacement = rect.top < 88 ? "bottom" : "top";
const left = Math.min(window.innerWidth - 16, Math.max(16, rect.left + rect.width / 2));
const top = placement === "top" ? rect.top - 8 : rect.bottom + 8;
setTip({ left, top, placement });
};
const hide = () => setTip(null);
return (
<span
className={["rx-tooltip-anchor", className].filter(Boolean).join(" ")}
aria-describedby={tip ? id : undefined}
onPointerEnter={(event) => show(event.currentTarget)}
onPointerLeave={hide}
onFocus={(event) => show(event.currentTarget)}
onBlur={hide}
>
{children}
{tip && text
? createPortal(
<span
id={id}
role="tooltip"
className="rx-tooltip"
data-placement={tip.placement}
style={{ left: tip.left, top: tip.top }}
>
{text}
</span>,
document.body,
)
: null}
</span>
);
}
+96
View File
@@ -0,0 +1,96 @@
type TextSegmentLike = {
kind: "text" | "reasoning";
text: string;
};
type ToolSegmentLike = {
kind: "tool";
args: string;
result?: string;
};
type SegmentLike = TextSegmentLike | ToolSegmentLike | { kind: string };
type AssistantMessageLike = {
kind: "assistant";
pending?: boolean;
segments: SegmentLike[];
};
export const TRANSCRIPT_RECENT_MESSAGE_WINDOW = 120;
const MIN_ELIDE_CHARS = 4096;
const ELIDED_PREFIX = "[elided -- older than the last ";
function isAssistantMessage(value: unknown): value is AssistantMessageLike {
return (
typeof value === "object" &&
value !== null &&
(value as { kind?: unknown }).kind === "assistant" &&
Array.isArray((value as { segments?: unknown }).segments)
);
}
function isElided(value: unknown): boolean {
return typeof value === "string" && value.startsWith(ELIDED_PREFIX);
}
function elidedStub(chars: number): string {
return `${ELIDED_PREFIX}${TRANSCRIPT_RECENT_MESSAGE_WINDOW} messages; ${chars.toLocaleString()} chars dropped to keep long sessions responsive. Full content remains in the session log.]`;
}
function elideField(value: string): string {
if (value.length <= MIN_ELIDE_CHARS || isElided(value)) return value;
return elidedStub(value.length);
}
function elideSegment<S extends SegmentLike>(segment: S): S {
if (
(segment.kind === "text" || segment.kind === "reasoning") &&
typeof (segment as TextSegmentLike).text === "string"
) {
const textSegment = segment as TextSegmentLike;
const text = elideField(textSegment.text);
return text === textSegment.text ? segment : ({ ...segment, text } as S);
}
if (segment.kind !== "tool") return segment;
const toolSegment = segment as ToolSegmentLike;
let next: S | null = null;
const args = elideField(toolSegment.args);
if (args !== toolSegment.args) next = { ...segment, args } as S;
if (typeof toolSegment.result === "string") {
const result = elideField(toolSegment.result);
if (result !== toolSegment.result) next = { ...(next ?? segment), result } as S;
}
return next ?? segment;
}
function elideSegments<S extends SegmentLike>(segments: S[]): S[] {
let next: S[] | null = null;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (!segment) continue;
const elided = elideSegment(segment);
if (elided !== segment) {
if (next === null) next = segments.slice();
next[i] = elided;
}
}
return next ?? segments;
}
export function elideTranscriptMessages<T>(messages: T[]): T[] {
if (messages.length <= TRANSCRIPT_RECENT_MESSAGE_WINDOW) return messages;
const cutoff = messages.length - TRANSCRIPT_RECENT_MESSAGE_WINDOW;
let next: T[] | null = null;
for (let i = 0; i < cutoff; i++) {
const message = messages[i];
if (!isAssistantMessage(message) || message.pending) continue;
const segments = elideSegments(message.segments);
if (segments !== message.segments) {
if (next === null) next = messages.slice();
next[i] = { ...message, segments } as T;
}
}
return next ?? messages;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getThreadMaxWidth } from "./thread-layout";
const MIN_WIDTH = 160;
const MIN_WIDTH = 200;
const MAX_WIDTH_PCT = 0.4;
const CSS_VAR = { side: "--side-width", ctx: "--ctx-width" } as const;
const PERSIST_KEY_SIDE = "reasonix.sideWidth";
+15
View File
@@ -13,6 +13,7 @@ export function WorkdirPop({
anchor,
onPick,
onBrowse,
onRemoveRecent,
}: {
open: boolean;
onClose: () => void;
@@ -21,6 +22,7 @@ export function WorkdirPop({
anchor?: Anchor;
onPick: (path: string) => void;
onBrowse: () => void;
onRemoveRecent?: (path: string) => void;
}) {
useLang();
const [query, setQuery] = useState("");
@@ -119,6 +121,19 @@ export function WorkdirPop({
<span className="pin">
<I.check size={11} />
</span>
) : onRemoveRecent ? (
<button
type="button"
className="wd-del"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onRemoveRecent(p);
}}
title={t("workdir.removeRecent")}
>
<I.trash size={11} />
</button>
) : null}
</div>
);
+3
View File
@@ -38,6 +38,9 @@ export default defineConfig({
host: "127.0.0.1",
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/target/**"],
},
},
envPrefix: ["VITE_", "TAURI_"],
define: {
+1 -1
View File
@@ -178,7 +178,7 @@ src/
│ ├── skills.ts # list + invoke SKILL.md playbooks
│ ├── subagent.ts # spawn_subagent — flash+high by default
│ ├── plan.ts # submit_plan (review gate)
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)
│ └── web.ts # web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)
├── mcp/ # MCP client + bridge (stdio + SSE)
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
├── project-memory.ts # REASONIX.md loader
+1 -1
View File
@@ -128,7 +128,7 @@ Type `/` mid-chat to open the picker. Aliases shown in parentheses. Code-mode-on
|---|---|
| `/pro [off]` | Arm v4-pro for the NEXT turn only |
| `/budget [usd\|off]` | Session USD cap |
| `/search-engine <mojeek\|searxng\|metaso>` (`/se`) | Switch web search backend |
| `/search-engine <bing\|bing-intl\|searxng\|metaso\|baidu\|tavily\|perplexity\|exa\|brave\|ollama>` (`/se`) | Switch web search backend |
| `/hooks [reload]` | List / reload hooks |
| `/permissions [list\|add\|remove\|clear]` | Edit shell allowlist |
| `/dashboard [stop]` | Launch / stop the embedded web dashboard |
+1 -1
View File
@@ -433,7 +433,7 @@
│ ├── skills.ts <span class="hash"># list + invoke SKILL.md playbooks</span>
│ ├── subagent.ts <span class="hash"># spawn_subagent — flash+high by default</span>
│ ├── plan.ts <span class="hash"># submit_plan (review gate)</span>
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)</span>
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)</span>
├── mcp/ <span class="hash"># MCP client + bridge (stdio + SSE)</span>
├── memory.ts <span class="hash"># ImmutablePrefix / AppendOnlyLog / VolatileScratch</span>
├── project-memory.ts <span class="hash"># REASONIX.md loader</span>
+1 -1
View File
@@ -531,7 +531,7 @@
<td>Session USD cap</td>
</tr>
<tr>
<td><code>/search-engine &lt;mojeek|searxng|metaso&gt;</code> (<code>/se</code>)</td>
<td><code>/search-engine &lt;bing|bing-intl|searxng|metaso|baidu|tavily|perplexity|exa|brave|ollama&gt;</code> (<code>/se</code>)</td>
<td>Switch web search backend</td>
</tr>
<tr>
+16 -6
View File
@@ -200,7 +200,7 @@
"reasoningEffort": "high", <span class="hash"># <span data-i18n="cfg.k.effort">high | max</span></span>
"theme": "auto", <span class="hash"># <span data-i18n="cfg.k.theme">light | dark | auto</span></span>
"search": false, <span class="hash"># <span data-i18n="cfg.k.search">enable web_search/web_fetch tools</span></span>
"webSearchEngine": "mojeek", <span class="hash"># <span data-i18n="cfg.k.engine">mojeek | searxng | metaso</span></span>
"webSearchEngine": "bing", <span class="hash"># <span data-i18n="cfg.k.engine">bing | baidu | searxng | metaso | ...</span></span>
"webSearchEndpoint": "http://localhost:8080",
"mcp": [], <span class="hash"># <span data-i18n="cfg.k.mcp">MCP server specs (legacy string format)</span></span>
"mcpServers": {}, <span class="hash"># <span data-i18n="cfg.k.mcpsrv">MCP server objects (canonical format)</span></span>
@@ -621,14 +621,16 @@ created: 2026-05-09
<h2 data-i18n="ws.title">Web search</h2>
<p data-i18n="ws.body1">
<code>web_search</code> + <code>web_fetch</code> ship in the box. Default
backend is <strong>Mojeek</strong> (no setup); switch to a self-hosted
<strong>SearXNG</strong> when you want full control over upstream engines, or
use <strong>Metaso</strong> (100 free searches/day, configure your own API key for more).
backend is <strong>Bing</strong> (works from CN without proxy); switch to
<strong>Baidu AI Search</strong> when you want Baidu Cloud Qianfan's official
search API. You can also use self-hosted <strong>SearXNG</strong>,
<strong>Metaso</strong>, or other API engines.
</p>
<pre class="code"><code>/search-engine mojeek
<pre class="code"><code>/search-engine bing
/search-engine searxng <span class="hash"># http://localhost:8080</span>
/search-engine searxng http://192.168.1.5:8888
/search-engine metaso</code></pre>
/search-engine metaso
/search-engine baidu</code></pre>
<p data-i18n="ws.body.json">Equivalent <code>config.json</code>:</p>
<pre class="code"><code>{
"webSearchEngine": "searxng",
@@ -638,6 +640,14 @@ created: 2026-05-09
<pre class="code"><code>{
"webSearchEngine": "metaso",
"metasoApiKey": "mk-..." <span class="hash"># Optional: 100 free searches/day, configure your own API key for more.</span>
}</code></pre>
<p data-i18n="ws.body.baidu">
Or for Baidu AI Search (Baidu's official docs currently list
<strong>1,500 free AI Search calls per month</strong>):
</p>
<pre class="code"><code>{
"webSearchEngine": "baidu",
"baiduApiKey": "bce-v3/..." <span class="hash"># Or set BAIDU_API_KEY / QIANFAN_API_KEY</span>
}</code></pre>
<p data-i18n="ws.body.start">Start a local SearXNG:</p>
<pre class="code"><code>podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng</code></pre>
+8 -4
View File
@@ -40,7 +40,7 @@
"cfg.k.effort": "high | max",
"cfg.k.theme": "light | dark | auto",
"cfg.k.search": "enable web_search/web_fetch tools",
"cfg.k.engine": "mojeek | searxng | metaso",
"cfg.k.engine": "bing | baidu | searxng | metaso | ...",
"cfg.k.mcp": "MCP server specs",
"cfg.k.mcpoff": "names skipped at startup",
"cfg.k.projects": "per-workspace overrides",
@@ -154,10 +154,12 @@
"ws.title": "Web search",
"ws.body1":
"<code>web_search</code> + <code>web_fetch</code> ship in the box. Default backend is <strong>Mojeek</strong> (no setup); switch to a self-hosted <strong>SearXNG</strong> when you want full control over upstream engines, or use <strong>Metaso</strong> (100 free searches/day, configure your own API key for more).",
"<code>web_search</code> + <code>web_fetch</code> ship in the box. Default backend is <strong>Bing</strong> (works from CN without proxy); switch to <strong>Baidu AI Search</strong> when you want Baidu Cloud Qianfan's official search API. You can also use self-hosted <strong>SearXNG</strong>, <strong>Metaso</strong>, or other API engines.",
"ws.body.json": "Equivalent <code>config.json</code>:",
"ws.body.start": "Start a local SearXNG:",
"ws.body.metaso": "Or for Metaso:",
"ws.body.baidu":
"Or for Baidu AI Search (Baidu's official docs currently list <strong>1,500 free AI Search calls per month</strong>):",
"ix.title": "Semantic index",
"ix.body1":
@@ -207,7 +209,7 @@
"cfg.k.effort": "high | max",
"cfg.k.theme": "light | dark | auto",
"cfg.k.search": "启用 web_search / web_fetch 工具",
"cfg.k.engine": "mojeek | searxng | metaso",
"cfg.k.engine": "bing | baidu | searxng | metaso | ...",
"cfg.k.mcp": "MCP 服务器列表",
"cfg.k.mcpoff": "启动时跳过的服务器名",
"cfg.k.projects": "按工作区的覆盖配置",
@@ -327,10 +329,12 @@
"ws.title": "Web 搜索",
"ws.body1":
"<code>web_search</code> + <code>web_fetch</code> 开箱即用。默认走 <strong>Mojeek</strong>(无需配置);想完全控制上游引擎就切到自托管 <strong>SearXNG</strong>,或使用 <strong>Metaso</strong>(每天 100 次免费搜索,配置你自己的 API 密钥可提升限额)。",
"<code>web_search</code> + <code>web_fetch</code> 开箱即用。默认走 <strong>Bing</strong>(国内裸 IP 直连);需要百度智能云千帆官方搜索 API 时可切到 <strong>百度 AI Search</strong>。也可以使用自托管 <strong>SearXNG</strong>、<strong>Metaso</strong> 或其他 API 引擎。",
"ws.body.json": "等价的 <code>config.json</code>:",
"ws.body.start": "本机起一个 SearXNG:",
"ws.body.metaso": "或使用 Metaso:",
"ws.body.baidu":
"或使用百度 AI Search(百度官方文档当前写有 <strong>AI Search 每月 1500 次免费额度</strong>):",
"ix.title": "语义索引",
"ix.body1":
+2 -2
View File
@@ -138,7 +138,7 @@
"Per-workspace shell allowlist. Exact-prefix match. Interactive \"always allow\" persists.",
"conf.ws.title": "Web search",
"conf.ws.body":
"Mojeek by default, no setup. Switch to self-hosted SearXNG or Metaso with <code>/search-engine</code>.",
"Bing by default. Switch to Baidu AI Search, self-hosted SearXNG, Metaso, or other API engines with <code>/search-engine</code>.",
"conf.cta": "Open the configuration guide →",
"cli.title": "CLI at a glance",
@@ -319,7 +319,7 @@
"按工作区的 shell 白名单。精确前缀匹配。交互式“永久允许”会持久化。",
"conf.ws.title": "Web 搜索",
"conf.ws.body":
"默认 Mojeek,零配置。用 <code>/search-engine</code> 切自托管 SearXNG 或 Metaso。",
"默认 Bing。用 <code>/search-engine</code> 切百度 AI Search、自托管 SearXNG、Metaso 或其他 API 引擎。",
"conf.cta": "打开配置指南 →",
"cli.title": "CLI 速览",
+183
View File
@@ -0,0 +1,183 @@
# Telegram channel setup
Reasonix can attach Telegram to an existing `chat` or `code` session as a remote channel. Telegram is not a third runtime mode.
Once connected, Telegram can:
- send normal user messages into the active session
- receive follow-up assistant replies with Telegram Markdown rendering
- continue confirmation, choice, checkpoint, and plan-style follow-up interactions with inline buttons
- expose Reasonix slash commands through the Telegram bot command menu
## Before you start
Prepare these first:
- a recent Reasonix release that already includes Telegram support
- a Telegram account
- a Telegram bot token from BotFather
- the numeric Telegram user id that should be allowed to drive Reasonix
Important:
- Telegram is fail-closed: the channel refuses to start until `telegram.ownerUserId` or `telegram.allowlist` is configured
- keep the bot token private
- only allow Telegram users you trust to run a code-driving bot
## Get your Telegram bot token
The BotFather UI may change, but the flow is usually:
1. Open Telegram and start a chat with `@BotFather`.
2. Send `/newbot`.
3. Follow the prompts for bot name and username.
4. Copy the bot token BotFather returns.
## Get your Telegram user id
Reasonix access control uses Telegram's numeric user id, not the `@username`.
Common ways to get it:
- send a message to a helper bot such as `@userinfobot`
- use Telegram's `getUpdates` API after messaging your bot once
- read it from your own trusted bot tooling
Save this value as `telegram.ownerUserId` or include it in `telegram.allowlist`.
## Connect from the CLI
Start a session first:
~~~bash
reasonix code
# or
reasonix chat
~~~
Configure access control before connecting. For example, edit `~/.reasonix/config.json`:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
Then run:
~~~text
/telegram connect
~~~
First-time behavior:
1. Reasonix asks for the Telegram bot token in the current TUI.
2. Enter `/cancel` to abort.
3. Reasonix saves the token and enables Telegram auto-start after a successful connection.
If a token is already saved, `/telegram connect` reuses it directly.
You can also pass the token inline:
~~~text
/telegram connect <botToken>
~~~
Other Telegram commands:
- `/telegram status`
- `/telegram disconnect`
After the first successful connection, later `chat` and `code` sessions auto-start the Telegram channel while it stays enabled.
## Environment variables
You can also configure Telegram through environment variables:
~~~bash
TELEGRAM_BOT_TOKEN=123456:botfather-token
TELEGRAM_OWNER_USER_ID=123456789
TELEGRAM_ALLOWLIST=123456789,987654321
~~~
Environment values override the saved config for token and access checks. `TELEGRAM_ALLOWLIST` accepts comma- or whitespace-separated ids.
## Typical usage
1. Start `reasonix code` or `reasonix chat`.
2. Configure `telegram.ownerUserId` or `telegram.allowlist`.
3. Complete `/telegram connect` once.
4. Send a message to your Telegram bot.
5. Let the local Reasonix session keep running.
6. Continue replies, approvals, and follow-up interactions from Telegram when needed.
Telegram extends the current session. It does not replace `chat` or `code`.
## Security notes
- Access is fail-closed. With no owner or allowlist, the channel does not start.
- Unauthorized senders and button presses are ignored.
- Confirmation buttons are bound to the current Telegram confirmation message, so stale buttons from older confirmations cannot approve newer requests.
- Authorized users are rate-limited before their messages reach the agent prompt path.
## Troubleshooting
### `/telegram connect` fails with an access control error
Configure at least one allowed Telegram user:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
or:
~~~json
{
"telegram": {
"allowlist": ["123456789", "987654321"]
}
}
~~~
Then reconnect:
~~~text
/telegram connect
~~~
### `/telegram connect` fails with a token error
Check these first:
- the token was copied exactly from BotFather
- there are no extra spaces or shell quoting mistakes
- the bot has not been deleted or regenerated in BotFather
If needed, reconnect with an explicit token:
~~~text
/telegram connect <botToken>
~~~
### Telegram receives the message, but no reply comes back
Check that the local Reasonix session is still running and the channel is still connected:
~~~text
/telegram status
~~~
### Telegram says another poller is active
Only one long-polling process can run for a Telegram bot at a time. Stop the other Reasonix process or any other bot process using the same token, then reconnect.
### `/telegram` commands do not exist in your installed package
Your installed npm version is too old. Upgrade to a release that already includes Telegram support, or use the current repository `main` branch.
+183
View File
@@ -0,0 +1,183 @@
# Telegram 连接指南
Reasonix 可以把现有的 `chat` 或 `code` 会话延伸到 Telegram 上,作为远程通道使用。Telegram 扩展的是当前会话,不是独立的新运行模式。
连接成功后,Telegram 可以:
- 把普通消息送进当前会话
- 接收支持 Telegram Markdown 渲染的助手回复
- 用内联按钮继续确认、选择、checkpoint、plan 这类二次交互
- 把 Reasonix slash 命令注册成 Telegram bot command
## 开始前先准备
请先确认:
- 使用的是已经包含 Telegram 支持的较新 Reasonix 版本
- 已经有 Telegram 账号
- 已经从 BotFather 拿到 Telegram bot token
- 已经知道允许驱动 Reasonix 的 Telegram 数字用户 id
注意:
- Telegram 默认 fail-closed:没有配置 `telegram.ownerUserId` 或 `telegram.allowlist` 时,通道会拒绝启动
- bot token 要妥善保管,不要公开
- 只允许你信任的 Telegram 用户驱动这个代码执行 bot
## 获取 Telegram bot token
BotFather 的界面可能会变化,但通常流程是:
1. 在 Telegram 中打开 `@BotFather`
2. 发送 `/newbot`
3. 按提示填写 bot 名称和 username
4. 复制 BotFather 返回的 bot token
## 获取 Telegram 用户 id
Reasonix 的访问控制使用 Telegram 数字用户 id,不使用 `@username`。
常见获取方式:
- 给 `@userinfobot` 这类辅助 bot 发消息查看
- 给自己的 bot 发一条消息后,通过 Telegram `getUpdates` API 查看
- 从你信任的自有 bot 工具里读取
请把这个 id 保存为 `telegram.ownerUserId`,或加入 `telegram.allowlist`。
## 在 CLI 里连接
先启动一个会话:
~~~bash
reasonix code
# 或
reasonix chat
~~~
连接前先配置访问控制。例如编辑 `~/.reasonix/config.json`:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
然后运行:
~~~text
/telegram connect
~~~
首次连接时会这样引导:
1. 在当前 TUI 里提示你输入 Telegram bot token
2. 输入 `/cancel` 可以取消
3. 连接成功后,Reasonix 会保存 token 并启用后续自动启动
如果本地已经保存过 token,`/telegram connect` 会直接复用。
也可以直接一次性传参:
~~~text
/telegram connect <botToken>
~~~
其他相关命令:
- `/telegram status`
- `/telegram disconnect`
第一次连接成功后,只要 Telegram 保持启用,后续 `chat` 和 `code` 会话都会自动启动 Telegram 通道。
## 环境变量
也可以用环境变量配置 Telegram:
~~~bash
TELEGRAM_BOT_TOKEN=123456:botfather-token
TELEGRAM_OWNER_USER_ID=123456789
TELEGRAM_ALLOWLIST=123456789,987654321
~~~
环境变量会覆盖本地保存的 token 和访问控制配置。`TELEGRAM_ALLOWLIST` 支持用逗号或空白分隔多个 id。
## 典型使用方式
1. 启动 `reasonix code` 或 `reasonix chat`
2. 配置 `telegram.ownerUserId` 或 `telegram.allowlist`
3. 完成一次 `/telegram connect`
4. 从 Telegram 给 bot 发一条消息
5. 本地 Reasonix 会话继续运行
6. 需要时直接在 Telegram 里继续回复、确认或选择
Telegram 只是扩展当前会话,不替代 `chat` 或 `code`。
## 安全说明
- 访问控制默认 fail-closed;没有 owner 或 allowlist 时通道不会启动。
- 未授权用户的消息和按钮点击都会被忽略。
- 确认按钮会绑定到当前 Telegram 确认消息,旧确认里的按钮不能批准新的请求。
- 授权用户的消息在进入 agent prompt 路径前会被限流。
## 排障
### `/telegram connect` 报访问控制错误
至少配置一个允许访问的 Telegram 用户:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
或者:
~~~json
{
"telegram": {
"allowlist": ["123456789", "987654321"]
}
}
~~~
然后重新连接:
~~~text
/telegram connect
~~~
### `/telegram connect` 报 token 错误
优先检查:
- token 是否完整复制自 BotFather
- 是否多了空格或 shell 引号问题
- BotFather 里这个 bot 是否已删除或重新生成 token
必要时可以直接显式传参重试:
~~~text
/telegram connect <botToken>
~~~
### Telegram 能收到消息,但没有后续回复
先确认本地 Reasonix 会话还在运行,而且 Telegram 通道仍然在线:
~~~text
/telegram status
~~~
### Telegram 提示已有另一个 poller
同一个 Telegram bot 同一时间只能有一个 long-polling 进程。请停止其他使用同一个 token 的 Reasonix 进程或 bot 进程,然后重新连接。
### 已安装的 npm 版本里没有 `/telegram` 命令
说明本地包版本太旧。请升级到已经包含 Telegram 支持的发行版,或者直接使用仓库最新 `main` 分支。
+350 -2
View File
@@ -24,6 +24,7 @@
"emoji-regex": "^10.4.0",
"eventsource-parser": "^3.0.0",
"get-east-asian-width": "^1.3.0",
"grammy": "^1.43.0",
"iconv-lite": "^0.7.2",
"ignore": "^7.0.5",
"indent-string": "^5.0.0",
@@ -31,6 +32,7 @@
"lodash-es": "^4.17.21",
"node-html-parser": "^7.1.0",
"picomatch": "^4.0.4",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-reconciler": "^0.32.0",
"semver": "^7.6.3",
@@ -60,6 +62,7 @@
"@testing-library/react": "^16.3.2",
"@types/node": "^22.9.0",
"@types/picomatch": "^4.0.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^2.1.5",
@@ -1384,6 +1387,10 @@
}
}
},
"node_modules/@grammyjs/types": {
"version": "3.27.3",
"license": "MIT"
},
"node_modules/@inquirer/ansi": {
"version": "2.0.5",
"dev": true,
@@ -2364,6 +2371,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"dev": true,
@@ -2544,6 +2561,16 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"dev": true,
@@ -2751,6 +2778,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001792",
"dev": true,
@@ -3094,7 +3130,6 @@
},
"node_modules/debug": {
"version": "4.4.3",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3108,6 +3143,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"dev": true,
@@ -3144,6 +3188,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"dev": true,
@@ -3329,6 +3379,13 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.8",
"license": "MIT",
@@ -3440,6 +3497,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fix-dts-default-cjs-exports": {
"version": "1.0.1",
"dev": true,
@@ -3631,6 +3701,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/grammy": {
"version": "1.43.0",
"license": "MIT",
"dependencies": {
"@grammyjs/types": "3.27.3",
"abort-controller": "^3.0.0",
"debug": "^4.4.3",
"node-fetch": "^2.7.0"
},
"engines": {
"node": "^12.20.0 || >=14.13.1"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"license": "MIT",
@@ -4027,6 +4110,18 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
@@ -4162,7 +4257,6 @@
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
"license": "MIT"
},
"node_modules/mutation-server-protocol": {
@@ -4236,6 +4330,40 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"dev": true,
@@ -4313,6 +4441,42 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"dev": true,
@@ -4344,6 +4508,15 @@
"version": "6.0.1",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"dev": true,
@@ -4418,6 +4591,15 @@
"pathe": "^2.0.1"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"dev": true,
@@ -4560,6 +4742,154 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.1",
"dev": true,
@@ -4711,6 +5041,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-from": {
"version": "5.0.0",
"dev": true,
@@ -4808,6 +5144,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"dev": true,
@@ -6717,6 +7059,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"dev": true,
+3
View File
@@ -91,6 +91,7 @@
"emoji-regex": "^10.4.0",
"eventsource-parser": "^3.0.0",
"get-east-asian-width": "^1.3.0",
"grammy": "^1.43.0",
"iconv-lite": "^0.7.2",
"ignore": "^7.0.5",
"indent-string": "^5.0.0",
@@ -98,6 +99,7 @@
"lodash-es": "^4.17.21",
"node-html-parser": "^7.1.0",
"picomatch": "^4.0.4",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-reconciler": "^0.32.0",
"semver": "^7.6.3",
@@ -123,6 +125,7 @@
"@testing-library/react": "^16.3.2",
"@types/node": "^22.9.0",
"@types/picomatch": "^4.0.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^2.1.5",
+17 -4
View File
@@ -152,11 +152,24 @@ export default class App extends PureComponent<Props, State> {
isRawModeSupported(): boolean {
return this.props.stdin.isTTY;
}
// Memoize terminal size context value to prevent unnecessary re-renders
// in consumers (useTerminalViewport). Without this, a new object is
// created on every render, defeating dependency-array checks.
private _lastTerminalColumns = 0;
private _lastTerminalRows = 0;
private _terminalSizeValue: { columns: number; rows: number } = { columns: 0, rows: 0 };
override render() {
return <TerminalSizeContext.Provider value={{
columns: this.props.terminalColumns,
rows: this.props.terminalRows
}}>
if (this.props.terminalColumns !== this._lastTerminalColumns ||
this.props.terminalRows !== this._lastTerminalRows) {
this._lastTerminalColumns = this.props.terminalColumns;
this._lastTerminalRows = this.props.terminalRows;
this._terminalSizeValue = {
columns: this.props.terminalColumns,
rows: this.props.terminalRows,
};
}
return <TerminalSizeContext.Provider value={this._terminalSizeValue}>
<AppContext.Provider value={{
exit: this.handleExit
}}>
@@ -20,6 +20,12 @@ export function useTerminalViewport(): [
elementRef.current = el
}, [])
// `terminalSize` is the only reactive dependency — it changes when the
// terminal resizes. `elementRef.current` is NOT a valid dependency:
// React doesn't track ref mutations, so `Object.is` comparison against
// the previous render's captured value means the effect runs once on
// mount and never again unless conditional rendering swaps the DOM node.
// The element reference is read imperatively inside the effect.
useLayoutEffect(() => {
const element = elementRef.current
if (!element?.yogaNode || !terminalSize) {
@@ -71,7 +77,7 @@ export function useTerminalViewport(): [
if (visible !== entryRef.current.isVisible) {
entryRef.current = { isVisible: visible }
}
})
}, [terminalSize])
return [setElement, entryRef.current]
}
+33 -6
View File
@@ -127,14 +127,41 @@ export function useBoxMetrics(ref: { current: DOMElement | null }): {
height: number;
} {
const [size, setSize] = useState({ width: 0, height: 0 });
const lastRef = useRef<DOMElement | null>(null);
const scheduledRef = useRef(false);
const lastMeasureTimeRef = useRef(0);
// No dep array — this hook must re-measure when the parent layout
// changes, which can only be detected by re-running on every render.
// Using `ref.current` as a dependency is a React anti-pattern (refs
// are not reactive) and would cause stale dimensions after parent
// resizes.
//
// To prevent excessive measurements during rapid scrolling (10-20
// wheel events/100ms on Windows), we throttle to at most one measure
// per frame via the deferred setTimeout + scheduledRef guard, and
// skip re-measurement if less than 16ms have elapsed since the last
// successful measurement (one frame at 60fps).
useEffect(() => {
if (!ref.current) return;
lastRef.current = ref.current;
const { width, height } = measureElement(ref.current);
setSize((prev) =>
prev.width === width && prev.height === height ? prev : { width, height },
);
if (scheduledRef.current) return;
const now = Date.now();
if (now - lastMeasureTimeRef.current < 16) return;
scheduledRef.current = true;
// Defer measure off the React commit batch — re-measuring
// synchronously in the same depth-counted chain crashes with
// "Maximum update depth exceeded" when Box content doesn't
// converge in one layout pass.
setTimeout(() => {
scheduledRef.current = false;
if (!ref.current) return;
lastMeasureTimeRef.current = Date.now();
const { width, height } = measureElement(ref.current);
setSize((prev) =>
prev.width === width && prev.height === height ? prev : { width, height },
);
}, 0);
});
return size;
}
+18 -5
View File
@@ -105,6 +105,9 @@ export default class Ink {
private backFrame: Frame;
private lastPoolResetTime = performance.now();
private drainTimer: ReturnType<typeof setTimeout> | null = null;
// Microtask coalescing: prevents double-render when leading + trailing
// throttle edges both queue a microtask in the same event-loop tick.
private renderMicrotaskPending = false;
private lastYogaCounters: {
ms: number;
visited: number;
@@ -222,7 +225,14 @@ export default class Ink {
// a one-keystroke lag. Same event-loop tick, so throughput is unchanged.
// Test env uses onImmediateRender (direct onRender, no throttle) so
// existing synchronous lastFrame() tests are unaffected.
const deferredRender = (): void => queueMicrotask(this.onRender);
const deferredRender = (): void => {
if (this.renderMicrotaskPending) return;
this.renderMicrotaskPending = true;
queueMicrotask(() => {
this.renderMicrotaskPending = false;
this.onRender();
});
};
this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, {
leading: true,
trailing: true
@@ -754,11 +764,14 @@ export default class Ink {
// its scheduleRender path fires a render which clears this timer at
// the top of onRender — no double.
//
// Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) so run at
// quarter interval (~250fps, setTimeout practical floor) for max scroll
// speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle.
// Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) but still
// run the full render pipeline (React reconcile + diff + paint).
// xterm.js (VS Code) is single-threaded for ANSI parsing — running at
// 4ms (250fps) overwhelmed it. Use FRAME_INTERVAL_MS (16ms, ~60fps)
// which matches the regular render budget and is well within xterm.js's
// comfortable throughput. Native terminals handle this fine too.
if (frame.scrollDrainPending) {
this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2);
this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS);
}
const yogaMs = getLastYogaMs();
const commitMs = getLastCommitMs();
+8 -2
View File
@@ -1,4 +1,4 @@
import { copyFileSync, mkdirSync, writeFileSync } from "node:fs";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
const targets = [
@@ -15,6 +15,12 @@ for (const [src, dst] of targets) {
// Marks dist/cli/ as ESM so Node skips the CJS-then-ESM reparse warning when
// the bundle is loaded outside its own npm install (e.g. desktop sidecar).
const cliMarker = resolve("dist/cli/package.json");
const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8"));
const cliPackage = {
name: rootPackage.name ?? "reasonix",
version: rootPackage.version ?? "0.0.0-dev",
type: "module",
};
mkdirSync(dirname(cliMarker), { recursive: true });
writeFileSync(cliMarker, `${JSON.stringify({ type: "module" }, null, 2)}\n`);
writeFileSync(cliMarker, `${JSON.stringify(cliPackage, null, 2)}\n`);
console.log(`wrote ${cliMarker}`);
+51
View File
@@ -0,0 +1,51 @@
/**
* Deterministic cache-shape probe.
*
* This does not call DeepSeek. It validates local invariants that keep live
* prefix-cache hit rates high: stable tool-spec ordering, component prefix
* hashes, and explicit log rewrite tracking.
*
* Run: npx tsx scripts/probe-cache-shape.mts
*/
import assert from "node:assert/strict";
import { AppendOnlyLog, ImmutablePrefix } from "../src/memory/runtime.js";
import { ToolRegistry } from "../src/tools.js";
import type { ToolSpec } from "../src/types.js";
function tool(name: string): ToolSpec {
return {
type: "function",
function: {
name,
description: `${name} tool`,
parameters: { type: "object", properties: {} },
},
};
}
const registry = new ToolRegistry();
registry.register({ name: "zeta", fn: async () => "z" });
registry.register({ name: "alpha", fn: async () => "a" });
assert.deepEqual(
registry.specs().map((s) => s.function.name),
["alpha", "zeta"],
"ToolRegistry.specs() must be sorted by tool name",
);
const prefixA = new ImmutablePrefix({ system: "s", toolSpecs: [tool("read"), tool("write")] });
const prefixB = new ImmutablePrefix({ system: "s", toolSpecs: [tool("write"), tool("read")] });
assert.equal(prefixB.fingerprint, prefixA.fingerprint, "reordered tools should hash identically");
assert.equal(
prefixB.componentHashes.tools,
prefixA.componentHashes.tools,
"tool component hash should be stable under reordered input",
);
const log = new AppendOnlyLog();
log.append({ role: "user", content: "hello" });
assert.equal(log.rewriteVersion, 0, "append must not count as a rewrite");
log.compactInPlace([{ role: "assistant", content: "summary" }]);
assert.equal(log.rewriteVersion, 1, "compactInPlace must count as a rewrite");
console.log("PASS: deterministic cache-shape invariants hold");
+91 -31
View File
@@ -7,13 +7,21 @@
* Surfaces: cache trajectory, cost shape, anything degrading over time.
*
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-long-session.mts
* Reads DEEPSEEK_API_KEY from the environment, .env.testbak, or ~/.reasonix/config.json.
*/
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { DeepSeekClient } from "../src/client.js";
import { CacheFirstLoop } from "../src/loop.js";
import { ImmutablePrefix } from "../src/memory/runtime.js";
import { DEEPSEEK_CONTEXT_TOKENS } from "../src/telemetry/stats.js";
import {
DEEPSEEK_CONTEXT_TOKENS,
pricingFor,
type CacheChurnReason,
type TurnStats,
} from "../src/telemetry/stats.js";
import { ToolRegistry } from "../src/tools.js";
// Force a small ctx window so the 50% fold threshold trips in a few
@@ -21,18 +29,42 @@ import { ToolRegistry } from "../src/tools.js";
// id, real API call, just the local gauge is shrunk.
DEEPSEEK_CONTEXT_TOKENS["deepseek-chat"] = 50_000;
function loadDotenv(path: string) {
function loadDotenv(path: string): void {
if (!existsSync(path)) return;
const txt = readFileSync(path, "utf8");
for (const line of txt.split(/\r?\n/)) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
}
loadDotenv("./.env.testbak");
const PRICE_HIT_PER_M = 0.07;
const PRICE_MISS_PER_M = 0.27;
const PRICE_COMPLETION_PER_M = 1.1;
function loadReasonixApiKey(): void {
if (process.env.DEEPSEEK_API_KEY) return;
const path = join(homedir(), ".reasonix", "config.json");
if (!existsSync(path)) return;
const cfg = JSON.parse(readFileSync(path, "utf8")) as {
apiKey?: string;
deepseekApiKey?: string;
endpoint?: { apiKey?: string };
};
const key = cfg.apiKey ?? cfg.deepseekApiKey ?? cfg.endpoint?.apiKey ?? "";
if (key) process.env.DEEPSEEK_API_KEY = key;
}
loadDotenv("./.env.testbak");
loadReasonixApiKey();
function cacheRatio(usage: {
promptCacheHitTokens: number;
promptCacheMissTokens: number;
}): number {
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
return total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
}
function formatReasons(reasons: readonly CacheChurnReason[]): string {
return reasons.length > 0 ? reasons.join(",") : "-";
}
const docLine = (i: number, sec: string) =>
`[${sec}#${i}] section ${sec} entry ${i}: requirement traces to constraint ${(i % 7) + 1}, status ${i % 3 === 0 ? "open" : "closed"}, owner team-${(i % 5) + 1}, last touched 2026-04-${(i % 28) + 1}.`;
@@ -69,11 +101,11 @@ async function main() {
maxToolIters: 4,
});
let mutations = 0;
let rawRewrites = 0;
let folds = 0;
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
loop.log.compactInPlace = (...args) => {
mutations++;
rawRewrites++;
return origCompactInPlace(...args);
};
@@ -100,48 +132,57 @@ async function main() {
"api-v2",
];
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum");
console.log("-----+----------+---------+---------+------+----------+--------");
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum | churn");
console.log("-----+----------+---------+---------+------+----------+--------+----------");
let cumCost = 0;
let forceSummaryHit = false;
const measured: Array<{
turn: number;
ratio: number;
forcedSummary: boolean;
reasons: CacheChurnReason[];
}> = [];
const pricing = pricingFor(model);
for (let i = 0; i < sections.length; i++) {
const t0 = Date.now();
let usage: {
promptTokens: number;
completionTokens: number;
promptCacheHitTokens: number;
promptCacheMissTokens: number;
} | null = null;
let stats: TurnStats | null = null;
let warning = "";
let turnForcedSummary = false;
for await (const ev of loop.step(`Read the "${sections[i]}" section.`)) {
if (ev.role === "assistant_final" && ev.stats?.usage) {
usage = ev.stats.usage as typeof usage;
stats = ev.stats;
}
if (ev.role === "warning" && ev.content) {
warning = ev.content;
if (/folded \d+ messages/.test(ev.content)) folds++;
}
if (ev.forcedSummary) forceSummaryHit = true;
if (ev.forcedSummary) {
forceSummaryHit = true;
turnForcedSummary = true;
}
}
const ms = Date.now() - t0;
const usage = stats?.usage;
if (!usage) {
console.log(
`${String(i).padStart(3)} | (no usage) -- ${warning ? `warning: ${warning}` : ""}`,
);
continue;
}
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
const cost =
(usage.promptCacheHitTokens * PRICE_HIT_PER_M +
usage.promptCacheMissTokens * PRICE_MISS_PER_M +
usage.completionTokens * PRICE_COMPLETION_PER_M) /
1_000_000;
const ratio = cacheRatio(usage);
const cost = pricing
? (usage.promptCacheHitTokens * pricing.inputCacheHit +
usage.promptCacheMissTokens * pricing.inputCacheMiss +
usage.completionTokens * pricing.output) /
1_000_000
: 0;
const reasons = stats.cacheDiagnostics?.prefixChangeReasons ?? [];
measured.push({ turn: i, ratio, forcedSummary: turnForcedSummary, reasons: [...reasons] });
cumCost += cost;
console.log(
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} | ${formatReasons(reasons)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
);
if (forceSummaryHit) {
console.log(
@@ -151,14 +192,33 @@ async function main() {
}
}
console.log(`\ntotal log.compactInPlace() calls: ${mutations} (expected: ${folds} folds)`);
console.log(`\ntotal raw log.compactInPlace() calls: ${rawRewrites} (${folds} fold warning events)`);
console.log(`total cost across session: $${cumCost.toFixed(4)}`);
if (mutations !== folds) {
console.log(`FAIL: ${mutations} mutations but only ${folds} fold events — unexpected mutation`);
const sustained = measured.filter((entry) => entry.turn >= 4 && !entry.forcedSummary);
const sustainedAvg =
sustained.length > 0
? sustained.reduce((sum, entry) => sum + entry.ratio, 0) / sustained.length
: 0;
const unstablePrefixTurns = measured
.slice(1)
.filter((entry) => entry.reasons.some((reason) => reason !== "log_rewrite"));
console.log(`sustained cache hit average (turn >=4, pre-summary): ${sustainedAvg.toFixed(1)}%`);
if (unstablePrefixTurns.length > 0) {
const detail = unstablePrefixTurns
.map((entry) => `turn-${entry.turn}:${formatReasons(entry.reasons)}`)
.join(", ");
console.log(`FAIL: non-log prefix churn detected (${detail})`);
process.exit(1);
}
console.log(`\nVERDICT: ${folds} fold(s) fired, no other mutations.`);
if (sustainedAvg < 85) {
console.log(`FAIL: sustained cache hit average ${sustainedAvg.toFixed(1)}% below 85% threshold`);
process.exit(1);
}
console.log(
`\nVERDICT: cache stayed warm through oversized tool results; raw rewrites are diagnostic, and force-summary cold miss is expected when triggered.`,
);
}
main().catch((e) => {
+57 -14
View File
@@ -7,23 +7,54 @@
* that the API-level append-vs-mutate primitive behaves as expected.
*
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-loop-cache.mts
* Reads DEEPSEEK_API_KEY from .env.testbak.
* Reads DEEPSEEK_API_KEY from the environment, .env.testbak, or ~/.reasonix/config.json.
*/
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { CacheFirstLoop } from "../src/loop.js";
import { DeepSeekClient } from "../src/client.js";
import { ImmutablePrefix } from "../src/memory/runtime.js";
import type { CacheChurnReason, TurnStats } from "../src/telemetry/stats.js";
import { ToolRegistry } from "../src/tools.js";
function loadDotenv(path: string) {
function loadDotenv(path: string): void {
if (!existsSync(path)) return;
const txt = readFileSync(path, "utf8");
for (const line of txt.split(/\r?\n/)) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
}
function loadReasonixApiKey(): void {
if (process.env.DEEPSEEK_API_KEY) return;
const path = join(homedir(), ".reasonix", "config.json");
if (!existsSync(path)) return;
const cfg = JSON.parse(readFileSync(path, "utf8")) as {
apiKey?: string;
deepseekApiKey?: string;
endpoint?: { apiKey?: string };
};
const key = cfg.apiKey ?? cfg.deepseekApiKey ?? cfg.endpoint?.apiKey ?? "";
if (key) process.env.DEEPSEEK_API_KEY = key;
}
loadDotenv("./.env.testbak");
loadReasonixApiKey();
function cacheRatio(usage: {
promptCacheHitTokens: number;
promptCacheMissTokens: number;
}): number {
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
return total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
}
function formatReasons(reasons: readonly CacheChurnReason[]): string {
return reasons.length > 0 ? reasons.join(",") : "-";
}
const filler = (label: string, n: number): string =>
Array.from(
@@ -67,49 +98,61 @@ async function main() {
loop.log.append({ role: "assistant", content: "noted." });
const ratios: number[] = [];
let mutations = 0;
const observedChurn: Array<{ turn: number; reasons: CacheChurnReason[] }> = [];
let rawRewrites = 0;
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
loop.log.compactInPlace = (...args: Parameters<typeof origCompactInPlace>) => {
mutations++;
rawRewrites++;
return origCompactInPlace(...args);
};
for (let i = 0; i < 6; i++) {
let usage: { promptTokens: number; promptCacheHitTokens: number; promptCacheMissTokens: number } | null = null;
let stats: TurnStats | null = null;
const rewritesBefore = rawRewrites;
for await (const ev of loop.step(`Turn ${i}: just say "ok ${i}".`)) {
if (ev.role === "assistant_final" && ev.stats?.usage) {
usage = ev.stats.usage as typeof usage;
stats = ev.stats;
}
}
const usage = stats?.usage;
if (!usage) {
console.log(`turn-${i}: no usage captured`);
ratios.push(0);
continue;
}
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
const ratio = cacheRatio(usage);
const reasons = stats.cacheDiagnostics?.prefixChangeReasons ?? [];
observedChurn.push({ turn: i, reasons: [...reasons] });
console.log(
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)}`,
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)} rawRewrites=${rawRewrites - rewritesBefore} churn=${formatReasons(reasons)}`,
);
ratios.push(ratio);
}
console.log(`\ntotal log.compactInPlace() calls: ${mutations}`);
console.log(`\ntotal raw log.compactInPlace() calls: ${rawRewrites}`);
console.log(`cache hit % per turn: ${ratios.map((x) => x.toFixed(1)).join(", ")}`);
const warmRatios = ratios.slice(1);
const avgWarm = warmRatios.reduce((a, b) => a + b, 0) / warmRatios.length;
console.log(`warm-turn average (excluding cold start): ${avgWarm.toFixed(1)}%`);
if (mutations > 0) {
console.log(`\nFAIL: log was mutated ${mutations} time(s) — append-only invariant broken`);
const unstablePrefixTurns = observedChurn
.slice(1)
.filter((entry) => entry.reasons.some((reason) => reason !== "log_rewrite"));
if (unstablePrefixTurns.length > 0) {
const detail = unstablePrefixTurns
.map((entry) => `turn-${entry.turn}:${formatReasons(entry.reasons)}`)
.join(", ");
console.log(`\nFAIL: non-log prefix churn detected (${detail})`);
process.exit(1);
}
if (avgWarm < 80) {
console.log(`\nFAIL: warm-turn average ${avgWarm.toFixed(1)}% below 80% threshold`);
process.exit(1);
}
console.log(`\nPASS: append-only sustained, cache hit avg ${avgWarm.toFixed(1)}%`);
console.log(
`\nPASS: cache stayed warm (avg ${avgWarm.toFixed(1)}%); raw rewrites are reported for diagnosis, not treated as cache failure`,
);
}
main().catch((e) => {
+4 -2
View File
@@ -29,6 +29,7 @@ import {
loadApiKey,
loadEditMode,
loadEndpoint,
loadMaxIterPerTurn,
loadModel,
loadReasoningEffort,
normalizeMcpConfig,
@@ -112,9 +113,9 @@ export async function loadMcpServers(
process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`);
const t0 = Date.now();
const prefix = resolveMcpPrefix(spec.name, normalizedSpecs.length, globalPrefix);
if (spec.transport === "stdio") preflightStdioSpec(spec);
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
mcp = new McpClient({ transport, workspaceDir });
mcp = new McpClient({ transport, workspaceDir, requestTimeoutMs: spec.requestTimeoutMs });
await mcp.initialize();
const bridge = await bridgeMcpTools(mcp, {
registry: tools,
@@ -184,6 +185,7 @@ async function buildSession(opts: {
tools: toolset.tools,
model,
budgetUsd: opts.budgetUsd,
maxIterPerTurn: loadMaxIterPerTurn(),
session: `acp-${timestampSuffix()}`,
});
return {
+61 -1
View File
@@ -6,6 +6,7 @@ import {
loadApiKey,
loadHistoryScrollMode,
loadToolRateLimit,
normalizeMcpConfig,
readConfig,
searchEnabled,
} from "../../config.js";
@@ -19,10 +20,12 @@ import {
resolveSession,
} from "../../memory/session.js";
import { QQChannel } from "../../qq/channel.js";
import { TelegramChannel } from "../../telegram/channel.js";
import { ToolRegistry } from "../../tools.js";
import { registerChoiceTool } from "../../tools/choice.js";
import { registerMemoryTools } from "../../tools/memory.js";
import { registerWebTools } from "../../tools/web.js";
import { WeixinChannel } from "../../weixin/channel.js";
import { stopAndSaveCpuProfile } from "../cpu-prof.js";
import { markPhase } from "../startup-profile.js";
import { App } from "../ui/App.js";
@@ -129,10 +132,16 @@ interface RootProps extends ChatOptions {
historyScrollMode: ResolvedHistoryScrollMode;
/** Pre-created QQ channel (started before TUI mounts). */
qqChannel?: QQChannel;
telegramChannel?: TelegramChannel;
weixinChannel?: WeixinChannel;
/** App fills this ref on mount so QQ messages flow into the TUI input queue. */
qqSubmitRef: { current: ((text: string) => void) | null };
/** App fills this ref on mount so QQ errors appear in the TUI log. */
qqErrorRef: { current: ((msg: string) => void) | null };
telegramSubmitRef: { current: ((text: string) => void) | null };
telegramErrorRef: { current: ((msg: string) => void) | null };
weixinSubmitRef: { current: ((text: string) => void) | null };
weixinErrorRef: { current: ((msg: string) => void) | null };
}
function Root({
@@ -245,8 +254,14 @@ function Root({
dashboardHost={appProps.dashboardHost}
dashboardToken={appProps.dashboardToken}
qqChannel={appProps.qqChannel}
telegramChannel={appProps.telegramChannel}
weixinChannel={appProps.weixinChannel}
qqSubmitRef={appProps.qqSubmitRef}
qqErrorRef={appProps.qqErrorRef}
telegramSubmitRef={appProps.telegramSubmitRef}
telegramErrorRef={appProps.telegramErrorRef}
weixinSubmitRef={appProps.weixinSubmitRef}
weixinErrorRef={appProps.weixinErrorRef}
historyScrollMode={historyScrollMode}
onSwitchSession={setActiveSession}
/>
@@ -305,7 +320,8 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
platform: process.platform,
});
const startupInfoHints: string[] = [];
if (cfg.setupCompleted === true && (cfg.mcp?.length ?? 0) === 0 && mcpSpecs.length === 0) {
const hasAnyMcp = normalizeMcpConfig(cfg).length > 0 || mcpSpecs.length > 0;
if (cfg.setupCompleted === true && !hasAnyMcp) {
startupInfoHints.push(t("mcpHealth.emptyHint"));
}
@@ -351,8 +367,16 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
// deterministic.
const qqSubmitRef: { current: ((text: string) => void) | null } = { current: null };
const qqErrorRef: { current: ((msg: string) => void) | null } = { current: null };
const telegramSubmitRef: { current: ((text: string) => void) | null } = { current: null };
const telegramErrorRef: { current: ((msg: string) => void) | null } = { current: null };
const weixinSubmitRef: { current: ((text: string) => void) | null } = { current: null };
const weixinErrorRef: { current: ((msg: string) => void) | null } = { current: null };
const qqRequested = cfg.qq?.enabled === true;
const telegramRequested = cfg.telegram?.enabled === true;
const weixinRequested = cfg.weixin?.enabled === true;
let qqChannel: QQChannel | undefined;
let telegramChannel: TelegramChannel | undefined;
let weixinChannel: WeixinChannel | undefined;
if (qqRequested) {
const channel = new QQChannel({
onSubmitMessage: (text) => qqSubmitRef.current?.(text),
@@ -367,6 +391,34 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
process.stderr.write(`QQ bot failed: ${(err as Error).message}\n`);
}
}
if (telegramRequested) {
const channel = new TelegramChannel({
onSubmitMessage: (text) => telegramSubmitRef.current?.(text),
onError: (msg) => telegramErrorRef.current?.(msg),
});
process.stderr.write("Connecting Telegram bot...\n");
try {
await channel.start();
telegramChannel = channel;
process.stderr.write("Telegram bot connected\n");
} catch (err) {
process.stderr.write(`Telegram bot failed: ${(err as Error).message}\n`);
}
}
if (weixinRequested) {
const channel = new WeixinChannel({
onSubmitMessage: (text) => weixinSubmitRef.current?.(text),
onError: (msg) => weixinErrorRef.current?.(msg),
});
process.stderr.write("Connecting Weixin channel...\n");
try {
await channel.start();
weixinChannel = channel;
process.stderr.write("Weixin channel connected\n");
} catch (err) {
process.stderr.write(`Weixin channel failed: ${(err as Error).message}\n`);
}
}
// Before render() — shims Ink's per-card useBoxMetrics resize subscribe
// path so N cards don't accumulate N native stdout listeners.
@@ -404,8 +456,14 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
codeMode={codeMode}
session={resolvedSession}
qqChannel={qqChannel}
telegramChannel={telegramChannel}
weixinChannel={weixinChannel}
qqSubmitRef={qqSubmitRef}
qqErrorRef={qqErrorRef}
telegramSubmitRef={telegramSubmitRef}
telegramErrorRef={telegramErrorRef}
weixinSubmitRef={weixinSubmitRef}
weixinErrorRef={weixinErrorRef}
/>,
{ exitOnCtrlC: true, incrementalRendering: true },
);
@@ -415,6 +473,8 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
disableMouseMode();
await runtime.closeAll();
qqChannel?.stop();
telegramChannel?.stop();
weixinChannel?.stop();
await drainTtyResponses();
}
}
+7 -1
View File
@@ -70,7 +70,13 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
// Per-directory session so switching projects doesn't mix histories.
// `code-<sanitized-basename>` fits the session name rules without
// truncating most project names.
const session = opts.noSession ? undefined : `code-${sanitizeName(basename(rootDir))}`;
// Per-directory session unless --no-session or autoResumeSession:false in config (#2238).
// Explicit -r/--resume or --new flags override autoResumeSession so users can still
// manually resume or start fresh even when the default behaviour has been toggled.
const cfg = readConfig();
const explicitResume = opts.forceResume || opts.forceNew;
const autoResume = opts.noSession ? false : explicitResume || cfg.autoResumeSession !== false;
const session = autoResume ? `code-${sanitizeName(basename(rootDir))}` : undefined;
markPhase("semantic_bootstrap_start");
const { tools, jobs, registerRooted, reBootstrapSemantic, semantic } = await buildCodeToolset({
File diff suppressed because it is too large. Load diff
+172 -3
View File
@@ -8,6 +8,7 @@ import {
defaultConfigPath,
loadEndpoint,
loadProxyConfig,
normalizeMcpConfig,
readConfig,
resolveSemanticEmbeddingConfig,
} from "../../config.js";
@@ -18,6 +19,7 @@ import { indexExists } from "../../index/semantic/builder.js";
import { checkOllamaStatus } from "../../index/semantic/ollama-launcher.js";
import { listSessions } from "../../memory/session.js";
import { detectProxyUrl, matchesNoProxy, resolveNoProxy } from "../../net/proxy.js";
import { isCacheDiagnosticEntry } from "../../telemetry/cache-diagnostics.js";
import { resolveDataPath } from "../../tokenizer.js";
import { VERSION } from "../../version.js";
@@ -32,6 +34,7 @@ export interface DoctorCheck {
export interface DoctorOptions {
json?: boolean;
cache?: boolean;
}
type Level = DoctorLevel;
@@ -54,6 +57,17 @@ export async function runDoctorChecks(projectRoot: string): Promise<DoctorCheck[
return [r[0], r[1], ...checkProxy(), r[2], r[3], r[4], r[5], r[6], r[7]];
}
export async function runCacheDoctorChecks(projectRoot: string): Promise<DoctorCheck[]> {
const r = await Promise.all([
checkCacheDynamicPrompt(projectRoot),
checkCacheMcpOrder(),
checkCacheSkillsAndMemory(projectRoot),
checkCacheHooks(projectRoot),
checkCacheEvidence(),
]);
return r;
}
/** Probe hosts used to show users what's going through the proxy vs. direct. Cheap (no I/O), purely a routing simulation against the same NO_PROXY patterns the dispatcher uses. */
const PROXY_PROBE_HOSTS = ["api.deepseek.com", "github.com", "api.github.com"] as const;
@@ -192,7 +206,8 @@ async function checkConfig(): Promise<Check> {
if (cfg.model) parts.push(`model=${cfg.model}`);
if (cfg.reasoningEffort) parts.push(`effort=${cfg.reasoningEffort}`);
if (cfg.editMode) parts.push(`editMode=${cfg.editMode}`);
if (cfg.mcp && cfg.mcp.length > 0) parts.push(`mcp=${cfg.mcp.length}`);
const mcpCount = normalizeMcpConfig(cfg).length;
if (mcpCount > 0) parts.push(`mcp=${mcpCount}`);
return {
id: "config",
label: "config ",
@@ -377,6 +392,155 @@ async function checkHooks(projectRoot: string): Promise<Check> {
}
}
async function checkCacheDynamicPrompt(projectRoot: string): Promise<Check> {
const path = join(projectRoot, "REASONIX.md");
if (!existsSync(path)) {
return {
id: "cache-dynamic-prompt",
label: "cache prompt ",
level: "ok",
detail: "no project REASONIX.md found; no obvious project-memory timestamp source",
};
}
try {
const body = readFileSync(path, "utf8");
const dynamicPattern =
/\b(Date\.now|new Date|toISOString|timestamp|current time|today is|当前时间|今天是)\b/i;
if (dynamicPattern.test(body)) {
return {
id: "cache-dynamic-prompt",
label: "cache prompt ",
level: "warn",
detail:
"REASONIX.md contains timestamp-like text/code; dynamic time in the system prompt changes the byte-stable prefix",
};
}
return {
id: "cache-dynamic-prompt",
label: "cache prompt ",
level: "ok",
detail: "REASONIX.md has no obvious timestamp injection markers",
};
} catch (err) {
return {
id: "cache-dynamic-prompt",
label: "cache prompt ",
level: "warn",
detail: `could not read REASONIX.md: ${(err as Error).message}`,
};
}
}
async function checkCacheMcpOrder(): Promise<Check> {
try {
const cfg = readConfig();
const specs = normalizeMcpConfig(cfg).filter((spec) => !spec.disabled);
if (specs.length === 0) {
return {
id: "cache-mcp-order",
label: "cache mcp ",
level: "ok",
detail: "no enabled MCP servers configured",
};
}
const unnamed = specs.filter((spec) => !spec.name).length;
if (unnamed > 0) {
return {
id: "cache-mcp-order",
label: "cache mcp ",
level: "warn",
detail: `${unnamed}/${specs.length} MCP server specs have no stable name; name servers so tool prefixes stay deterministic`,
};
}
return {
id: "cache-mcp-order",
label: "cache mcp ",
level: "ok",
detail: `${specs.length} enabled MCP server${specs.length === 1 ? "" : "s"} have stable names; tool order/schema are normalized before prefix hashing`,
};
} catch (err) {
return {
id: "cache-mcp-order",
label: "cache mcp ",
level: "warn",
detail: `could not inspect MCP config: ${(err as Error).message}`,
};
}
}
async function checkCacheSkillsAndMemory(projectRoot: string): Promise<Check> {
const markers = ["REASONIX.md", ".reasonix"].filter((name) =>
existsSync(join(projectRoot, name)),
);
return {
id: "cache-memory-skills",
label: "cache memory ",
level: "ok",
detail:
markers.length > 0
? `${markers.join(", ")} present; project memory is loaded into the immutable prefix and should only change on /new or restart`
: "no project memory markers found; skill/memory prefix churn unlikely from this workspace",
};
}
async function checkCacheHooks(projectRoot: string): Promise<Check> {
try {
const all = loadHooks({ projectRoot });
if (all.length === 0) {
return {
id: "cache-hooks",
label: "cache hooks ",
level: "ok",
detail: "no hooks loaded",
};
}
return {
id: "cache-hooks",
label: "cache hooks ",
level: "warn",
detail: `${all.length} hook${all.length === 1 ? "" : "s"} loaded; ensure hook output does not inject timestamps or mutate prompt-visible files every turn`,
};
} catch (err) {
return {
id: "cache-hooks",
label: "cache hooks ",
level: "warn",
detail: `could not inspect hooks: ${(err as Error).message}`,
};
}
}
async function checkCacheEvidence(): Promise<Check> {
try {
const sessions = listSessions();
const withEvidence = sessions.filter((s) =>
s.meta.cacheDiagnostics?.some((entry) => isCacheDiagnosticEntry(entry)),
);
if (withEvidence.length === 0) {
return {
id: "cache-evidence",
label: "cache report ",
level: "warn",
detail:
"no session has cacheDiagnostics yet; run a model turn, then use /cache-miss-report",
};
}
return {
id: "cache-evidence",
label: "cache report ",
level: "ok",
detail: `${withEvidence.length}/${sessions.length} saved session${sessions.length === 1 ? "" : "s"} include cacheDiagnostics evidence`,
};
} catch (err) {
return {
id: "cache-evidence",
label: "cache report ",
level: "warn",
detail: `could not inspect session meta: ${(err as Error).message}`,
};
}
}
async function checkOllama(projectRoot: string): Promise<Check> {
let exists = false;
try {
@@ -508,9 +672,12 @@ export async function doctorCommand(opts: DoctorOptions = {}): Promise<void> {
const projectRoot = resolve(process.cwd());
const json = !!opts.json;
const cacheOnly = !!opts.cache;
if (!json) {
console.log(`${color(`reasonix ${VERSION} · doctor`, "1")} (cwd: ${projectRoot})`);
console.log(
`${color(`reasonix ${VERSION} · ${cacheOnly ? "doctor --cache" : "doctor"}`, "1")} (cwd: ${projectRoot})`,
);
console.log(` home: ${homedir()}`);
console.log("");
}
@@ -518,7 +685,9 @@ export async function doctorCommand(opts: DoctorOptions = {}): Promise<void> {
// Run independent checks in parallel — saves ~5s when api-reach has
// to time out. Each handler swallows its own throws into a `fail`
// result so a thrown promise can't kill the whole report.
const checks = await runDoctorChecks(projectRoot);
const checks = cacheOnly
? await runCacheDoctorChecks(projectRoot)
: await runDoctorChecks(projectRoot);
const ok = checks.filter((c) => c.level === "ok").length;
const warn = checks.filter((c) => c.level === "warn").length;
+21 -11
View File
@@ -2,7 +2,7 @@
import { Box, Text, render, useApp, useInput } from "ink";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { readConfig, writeConfig } from "../../config.js";
import { normalizeMcpConfig, readConfig, writeConfig } from "../../config.js";
import { loadDotenv } from "../../env.js";
import { loadOverlay } from "../../mcp/marketplace-overlay/loader.js";
import {
@@ -12,6 +12,7 @@ import {
specStringFor,
} from "../../mcp/registry-fetch.js";
import type { RegistryEntry } from "../../mcp/registry-types.js";
import { FG } from "../ui/theme/tokens.js";
const VISIBLE_ROWS = 12;
@@ -115,7 +116,10 @@ function McpBrowseApp() {
const spec = specStringFor(entry.name, entry.install);
const cfg = readConfig();
const existing = cfg.mcp ?? [];
if (existing.includes(spec)) {
const installedName = entry.name;
const normalized = normalizeMcpConfig(cfg);
const nameCollision = normalized.some((s) => s.name === installedName);
if (existing.includes(spec) || nameCollision) {
setStatus(`already installed: ${spec}`);
return;
}
@@ -172,16 +176,18 @@ function McpBrowseApp() {
<Text bold color="ansi:cyan">
◈ MCP marketplace
</Text>
<Text dim>{` · ${state.status}`}</Text>
<Text color={FG.faint}>{` · ${state.status}`}</Text>
</Box>
<Box marginTop={1}>
<Text>search: </Text>
<Text color="ansi:white">{state.query || "(type to filter)"}</Text>
<Text dim>{` ${filtered.length} match${filtered.length === 1 ? "" : "es"}`}</Text>
<Text
color={FG.faint}
>{` ${filtered.length} match${filtered.length === 1 ? "" : "es"}`}</Text>
</Box>
<Box marginTop={1} flexDirection="column">
{window.length === 0 ? (
<Text dim>{state.loading ? "loading…" : "no entries"}</Text>
<Text color={FG.faint}>{state.loading ? "loading…" : "no entries"}</Text>
) : (
window.map((e, i) => {
const idx = (start || 0) + i;
@@ -193,7 +199,7 @@ function McpBrowseApp() {
<Box key={e.name}>
<Text color={active ? "ansi:cyan" : undefined}>{active ? "▸ " : " "}</Text>
<Text bold={active}>{e.name.padEnd(40).slice(0, 40)}</Text>
<Text dim>{` ${tag}${pop}`}</Text>
<Text color={FG.faint}>{` ${tag}${pop}`}</Text>
</Box>
);
})
@@ -203,17 +209,19 @@ function McpBrowseApp() {
<Box marginTop={1} flexDirection="column">
<Text bold color="ansi:white">
{overlay?.[selected.name]?.title ?? selected.title}
{overlay?.[selected.name] ? <Text dim>{` \u00b7 ${selected.title}`}</Text> : null}
{overlay?.[selected.name] ? (
<Text color={FG.faint}>{` \u00b7 ${selected.title}`}</Text>
) : null}
</Text>
<Text dim>
<Text color={FG.faint}>
{overlay?.[selected.name]?.description ?? selected.description?.slice(0, 160) ?? null}
</Text>
{selected.install ? (
<Text dim>
<Text color={FG.faint}>
{`spec: ${selected.install.runtime} ${selected.install.packageId ?? selected.install.url ?? "—"} · ${selected.install.transport}`}
</Text>
) : (
<Text dim>(smithery listing — install info not exposed)</Text>
<Text color={FG.faint}>(smithery listing — install info not exposed)</Text>
)}
{selected.install?.requiredEnv?.length ? (
<Text color="ansi:yellow">{`needs: ${selected.install.requiredEnv.join(", ")}`}</Text>
@@ -221,7 +229,9 @@ function McpBrowseApp() {
</Box>
) : null}
<Box marginTop={1}>
<Text dim>type to filter · ↑↓ pick · enter install · tab load more · esc quit</Text>
<Text color={FG.faint}>
type to filter · ↑↓ pick · enter install · tab load more · esc quit
</Text>
</Box>
</Box>
);
+6 -2
View File
@@ -19,10 +19,14 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
const normalized = normalizeMcpConfig(cfg);
const matched = parsed.name ? normalized.find((s) => s.name === parsed.name) : undefined;
const spec = overlayMatchedSpec(parsed, matched);
if (spec.transport === "stdio") preflightStdioSpec(spec);
const workspaceDir = process.cwd();
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
const client = new McpClient({ transport, workspaceDir });
const client = new McpClient({
transport,
workspaceDir,
requestTimeoutMs: spec.requestTimeoutMs,
});
try {
await client.initialize();
const report = await inspectMcpServer(client);
+19 -6
View File
@@ -120,7 +120,10 @@ export interface McpRuntime {
signal?: AbortSignal,
): Promise<{ ok: true; summary: McpServerSummary } | { ok: false; reason: string }>;
removeSpec(raw: string, loop?: CacheFirstLoop): Promise<boolean>;
reloadFromConfig(loop?: CacheFirstLoop): Promise<{
reloadFromConfig(
loop?: CacheFirstLoop,
opts?: McpReloadOptions,
): Promise<{
added: string[];
removed: string[];
failed: Array<{ spec: string; reason: string }>;
@@ -131,6 +134,10 @@ export interface McpRuntime {
setLifecycleSink(sink: McpLifecycleSink): void;
}
interface McpReloadOptions {
force?: Iterable<string>;
}
export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
const records = new Map<string, SpecRecord>();
const insertionOrder: string[] = [];
@@ -183,10 +190,10 @@ export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
: ctx.getRequestedCount() === 1 && ctx.getMcpPrefix()
? (ctx.getMcpPrefix() as string)
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
const workspaceDir = ctx.getWorkspaceDir?.();
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
mcp = new McpClient({ transport, workspaceDir });
mcp = new McpClient({ transport, workspaceDir, requestTimeoutMs: spec.requestTimeoutMs });
await mcp.initialize({ signal });
const host: McpClientHost = { client: mcp };
const bridge = await bridgeMcpTools(mcp, {
@@ -331,7 +338,10 @@ export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
return true;
}
async function reloadFromConfig(loop?: CacheFirstLoop): Promise<{
async function reloadFromConfig(
loop?: CacheFirstLoop,
opts: McpReloadOptions = {},
): Promise<{
added: string[];
removed: string[];
failed: Array<{ spec: string; reason: string }>;
@@ -341,14 +351,17 @@ export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
const desired = normalized.map(specToRaw);
const desiredSet = new Set(desired);
const currentSet = new Set(records.keys());
const forceSet = new Set(opts.force ?? []);
const added: string[] = [];
const removed: string[] = [];
const failed: Array<{ spec: string; reason: string }> = [];
for (const spec of [...currentSet]) {
if (!desiredSet.has(spec)) {
const noLongerDesired = !desiredSet.has(spec);
if (noLongerDesired || forceSet.has(spec)) {
await removeSpec(spec, loop);
removed.push(spec);
currentSet.delete(spec);
if (noLongerDesired) removed.push(spec);
}
}
for (const spec of desired) {
+5 -3
View File
@@ -1,4 +1,4 @@
import { defaultConfigPath, readConfig, writeConfig } from "../../config.js";
import { defaultConfigPath, normalizeMcpConfig, readConfig, writeConfig } from "../../config.js";
import { t } from "../../i18n/index.js";
import { MCP_CATALOG, mcpCommandFor } from "../../mcp/catalog.js";
import {
@@ -301,7 +301,10 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
const cfg = readConfig();
const existing = cfg.mcp ?? [];
if (existing.includes(spec)) {
const installedName = parseInstalledName(spec);
const normalized = normalizeMcpConfig(cfg);
const nameCollision = installedName && normalized.some((s) => s.name === installedName);
if (existing.includes(spec) || nameCollision) {
console.log(t("mcpCli.alreadyInstalled", { spec }));
return;
}
@@ -310,7 +313,6 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
console.log(t("mcpCli.installed", { spec: entry.name }));
console.log(` spec: ${spec}`);
const installedName = parseInstalledName(spec);
if (entry.install.requiredEnv?.length) {
console.log(` needs: ${entry.install.requiredEnv.join(", ")}`);
console.log(" Either export these before launching, or add them to config:");
+4 -2
View File
@@ -7,6 +7,7 @@ import {
isPlausibleKey,
loadApiKey,
loadEndpoint,
loadMaxIterPerTurn,
loadToolRateLimit,
normalizeMcpConfig,
readConfig,
@@ -102,9 +103,9 @@ export async function runCommand(opts: RunOptions): Promise<void> {
: normalizedSpecs.length === 1 && opts.mcpPrefix
? opts.mcpPrefix
: "";
if (spec.transport === "stdio") preflightStdioSpec(spec);
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
mcp = new McpClient({ transport, workspaceDir });
mcp = new McpClient({ transport, workspaceDir, requestTimeoutMs: spec.requestTimeoutMs });
await mcp.initialize();
const bridge = await bridgeMcpTools(mcp, {
registry: tools,
@@ -151,6 +152,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
tools,
model: opts.model,
budgetUsd: opts.budgetUsd,
maxIterPerTurn: loadMaxIterPerTurn(),
});
const prefixHash = prefix.fingerprint;
+46 -2
View File
@@ -211,11 +211,45 @@ program
)
.option("--system-append <prompt>", t("ui.systemAppendHint"))
.option("--system-append-file <path>", t("ui.systemAppendFileHint"))
.option(
"--dry-run",
"for ssh:// targets: parse the URI, check local SSH, print planned steps (no remote commands execute). RFC for #2140.",
)
.option(
"--profile [path]",
"record a V8 CPU profile; saved on exit. Send the .cpuprofile back if you're reporting a perf bug.",
)
.action(async (dir: string | undefined, opts) => {
// ssh:// without --dry-run is not yet implemented (RFC #2140).
if (typeof dir === "string" && dir.startsWith("ssh://") && !opts.dryRun) {
process.stderr.write(
"ssh:// remote workspaces require --dry-run (RFC #2140).\n" +
"Full remote execution is not yet implemented.\n" +
"\n" +
"Short-term recommendation:\n" +
" Run Reasonix directly on the remote host, then use SSH tunnel for the dashboard:\n" +
" $ ssh -L 8420:127.0.0.1:8420 user@host\n" +
" $ reasonix code\n",
);
process.exit(1);
}
// SSH remote workspace RFC — dry-run bootstrap for #2140.
if (typeof dir === "string" && dir.startsWith("ssh://") && opts.dryRun) {
const { generateSshDryRunReport, parseSshUri, probeSsh } = await import("./ssh-remote.js");
const uri = parseSshUri(dir);
if (!uri) {
process.stderr.write(`invalid ssh:// URI: ${dir}\n`);
process.stderr.write("expected format: ssh://[user@]host[:port][/path]\n");
process.exit(1);
}
const ssh = probeSsh();
const report = generateSshDryRunReport(uri, ssh);
console.log(report);
if (!ssh) process.exit(1);
return;
}
persistEffortFlag(opts.effort);
const profiling = await maybeStartCpuProfile(opts.profile);
try {
@@ -314,7 +348,7 @@ program
mcp: defaults.mcp,
mcpPrefix: opts.mcpPrefix,
forceResume: continueOpts.forceResume,
forceNew: !!opts.new,
forceNew: !!opts.new || !!defaults.forceNew,
noDashboard: opts.dashboard === false || !loadDashboardEnabled(opts.config === false),
openDashboard: opts.openDashboard === true,
dashboardPort: resolveDashboardPort(
@@ -438,9 +472,19 @@ program
.command("doctor")
.description(t("cli.doctor"))
.option("--json", t("ui.jsonHint"))
.option("--cache", "run cache-stability checks only")
.action(async (opts) => {
const { doctorCommand } = await import("./commands/doctor.js");
await doctorCommand({ json: !!opts.json });
await doctorCommand({ json: !!opts.json, cache: !!opts.cache });
});
program
.command("doctor-cache")
.description("cache-stability health check")
.option("--json", t("ui.jsonHint"))
.action(async (opts) => {
const { doctorCommand } = await import("./commands/doctor.js");
await doctorCommand({ json: !!opts.json, cache: true });
});
program
+13 -3
View File
@@ -3,6 +3,7 @@ import {
type ReasoningEffort,
type ReasonixConfig,
isReasoningEffort,
loadModel,
loadReasoningEffort,
normalizeMcpConfig,
readConfig,
@@ -15,6 +16,8 @@ export interface ResolvedDefaults {
reasoningEffort: ReasoningEffort;
mcp: string[];
session: string | undefined;
/** True when autoResumeSession:false is in effect — tells callers to skip the session picker. */
forceNew?: boolean;
}
export interface RawCliFlags {
@@ -30,7 +33,8 @@ export interface RawCliFlags {
export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
const cfg: ReasonixConfig = flags.noConfig ? {} : readConfig();
const model = flags.model?.trim() || cfg.model?.trim() || DEFAULT_MODEL;
const model =
flags.model?.trim() || (flags.noConfig ? cfg.model?.trim() : loadModel()) || DEFAULT_MODEL;
const flagEffort = flags.effort?.toLowerCase();
const reasoningEffort: ReasoningEffort = isReasoningEffort(flagEffort)
@@ -47,9 +51,11 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
);
const mcp = normalizedMcp.map(specToRaw);
const session = resolveSession(flags.session, cfg.session);
const session = resolveSession(flags.session, cfg.session, cfg.autoResumeSession);
const forceNew =
cfg.autoResumeSession === false && flags.session === undefined ? true : undefined;
return { model, reasoningEffort, mcp, session };
return { model, reasoningEffort, mcp, session, forceNew };
}
function mergeDotMcpJson(cfg: ReasonixConfig, projectRoot: string): ReasonixConfig {
@@ -61,11 +67,15 @@ function mergeDotMcpJson(cfg: ReasonixConfig, projectRoot: string): ReasonixConf
function resolveSession(
flag: string | false | undefined,
configSession: string | null | undefined,
autoResume?: boolean,
): string | undefined {
if (flag === false) return undefined;
if (typeof flag === "string" && flag.length > 0) return flag;
if (configSession === null) return undefined;
if (typeof configSession === "string" && configSession.length > 0) return configSession;
// When autoResumeSession is explicitly false, don't default to "default" session
// so each launch starts fresh instead of resuming the previous conversation (#2238).
if (autoResume === false) return undefined;
return "default";
}
+157
View File
@@ -0,0 +1,157 @@
// SSH remote workspace RFC / dry-run — #2140. Full remote execution is a future deliverable.
import { execFileSync, execSync } from "node:child_process";
import { platform } from "node:os";
import { VERSION } from "../version.js";
export interface SshUri {
user: string;
host: string;
port: number;
path: string;
}
/** Parse ssh://[user@]host[:port]/path. Trailing `:port` is optional; defaults to 22. */
export function parseSshUri(raw: string): SshUri | null {
const m = /^ssh:\/\/(?:([^@:]+)@)?([^/:]+)(?::(\d+))?(\/.*)?$/.exec(raw);
if (!m) return null;
const user = m[1] ?? inferSshUser();
const host = m[2] ?? "";
if (!host) return null;
const port = m[3] ? Number.parseInt(m[3], 10) : 22;
const path = m[4] ?? "/";
return { user, host, port, path };
}
function inferSshUser(): string {
try {
const out = execFileSync("id", ["-un"], { encoding: "utf8", timeout: 1000 }).trim();
if (out) return out;
} catch {
/* fall through */
}
// Best-effort fallback on Windows.
if (platform() === "win32") {
try {
const out = execFileSync("whoami", [], { encoding: "utf8", timeout: 1000 }).trim();
if (out) return out;
} catch {
/* fall through */
}
}
return "root";
}
export interface SshProbe {
sshBin: string;
version: string;
}
export function probeSsh(): SshProbe | null {
try {
// ssh -V writes to stderr on all platforms; merge into stdout.
const version = execSync("ssh -V 2>&1", { encoding: "utf8", timeout: 3000 });
return { sshBin: "ssh", version: version.trim() };
} catch {
return null;
}
}
/** Minimal POSIX shell quoting — wraps the value in single quotes and escapes
* embedded single quotes. Sufficient for dry-run copy-paste safety. */
function shellQuote(value: string): string {
if (!/[^a-zA-Z0-9,._+:@%/-]/.test(value)) return value;
return `'${value.replace(/'/g, "'\\''")}'`;
}
export function generateSshDryRunReport(uri: SshUri, ssh: SshProbe | null): string {
const sq = shellQuote;
const sections = [
`reasonix ${VERSION} · SSH remote workspace RFC dry-run`,
"issue: https://github.com/esengine/DeepSeek-Reasonix/issues/2140",
"",
`target: ${sq(`ssh://${uri.user}@${uri.host}:${uri.port}${uri.path}`)}`,
"",
];
if (!ssh) {
sections.push(
"WARNING: `ssh` binary not found on PATH. Install an SSH client before running the real command.",
"",
);
} else {
sections.push(`ssh: ${ssh.version}`);
}
sections.push(
"--- parsed ---",
` user: ${uri.user}`,
` host: ${uri.host}`,
` port: ${uri.port}`,
` path: ${uri.path}`,
"",
"--- planned steps (dry run — no remote commands execute) ---",
"",
);
const remote = `${sq(uri.user)}@${sq(uri.host)}`;
const cdCmd = `cd ${sq(uri.path)} && reasonix code --no-dashboard`;
if (ssh) {
sections.push(
"1. verify connectivity",
` $ ssh -p ${uri.port} ${remote} -- 'echo ok'`,
"",
"2. probe remote environment",
` $ ssh -p ${uri.port} ${remote} -- 'node --version && npm --version && uname -s'`,
"",
"3. install or update Reasonix on remote",
` $ ssh -p ${uri.port} ${remote} -- 'npm i -g reasonix'`,
"",
"4. launch Reasonix in the target workspace on the remote host",
` $ ssh -p ${uri.port} ${remote} -- '${cdCmd}'`,
"",
"5. (local) open an SSH tunnel to the remote dashboard",
` $ ssh -N -L 8420:127.0.0.1:8420 -p ${uri.port} ${remote}`,
" Then open http://127.0.0.1:8420 in your local browser.",
"",
);
} else {
sections.push(
"1. install an SSH client",
" macOS: built-in (OpenSSH)",
" Windows: built-in (OpenSSH Client optional feature) or `winget install Microsoft.OpenSSH.Beta`",
" Linux: `apt install openssh-client` / `dnf install openssh-clients`",
"",
"2. verify connectivity",
` $ ssh -p ${uri.port} ${remote} -- 'echo ok'`,
"",
"3. install Reasonix on remote",
` $ ssh -p ${uri.port} ${remote} -- 'npm i -g reasonix'`,
"",
"4. launch Reasonix remotely",
` $ ssh -p ${uri.port} ${remote} -- '${cdCmd}'`,
"",
);
}
sections.push(
"--- short-term recommendation ---",
"",
"Until native remote execution lands, the simplest working setup is:",
" 1. Run Reasonix directly on the remote host (`ssh user@host`, then `reasonix code`).",
" 2. Forward the dashboard port to your local machine:",
` $ ssh -N -L 8420:127.0.0.1:8420 -p ${uri.port} ${remote}`,
" 3. Open http://127.0.0.1:8420 locally. The dashboard token gates access.",
"",
"--- RFC scope ---",
"",
"This dry-run is a reviewable design bootstrap for #2140.",
"It parses the URI, checks local tooling, and prints the steps Reasonix",
"would take. No remote commands execute and no network connections are made.",
"",
"#2141 (GPU passthrough) is tracked separately and is not part of this RFC.",
);
return sections.join("\n");
}
+264 -82
View File
@@ -9,6 +9,7 @@ import {
openEventSink,
} from "../../adapters/event-sink-jsonl.js";
import { type AtUrlExpansion, expandAtMentions, expandAtUrls } from "../../at-mentions.js";
import { prepareAutoGitRollbackForEditBlocks } from "../../code/auto-git-rollback.js";
import {
type CheckpointMeta,
createCheckpoint,
@@ -39,6 +40,8 @@ import {
loadEndpoint,
loadEngineeringLifecycleMode,
loadHistoryScrollMode,
loadMaxIterPerTurn,
loadMaxOutputTokens,
loadMouseWheelRows,
loadReasoningEffort,
loadTheme,
@@ -63,6 +66,7 @@ import {
deleteSession,
detectGitBranch,
freshSessionName,
hashSystemPrompt,
type listSessions,
listSessionsForWorkspace,
loadSessionMessages,
@@ -89,11 +93,9 @@ import {
shouldAutoNameSession,
} from "../../session-title.js";
import { loadSlashUsage, recordSlashUse } from "../../slash-usage.js";
import {
DEEPSEEK_CONTEXT_TOKENS,
DEFAULT_CONTEXT_TOKENS,
type SessionSummary,
} from "../../telemetry/stats.js";
import type { TelegramChannel } from "../../telegram/channel.js";
import { useTelegramChannel } from "../../telegram/use-telegram-channel.js";
import { type SessionSummary, resolveContextTokens } from "../../telemetry/stats.js";
import { defaultUsageLogPath } from "../../telemetry/usage.js";
import { warmupTokenizer } from "../../tokenizer.js";
import type { ToolRegistry } from "../../tools.js";
@@ -104,6 +106,8 @@ import { registerSkillTools } from "../../tools/skills.js";
import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
import { webFetch } from "../../tools/web.js";
import { openTranscriptFile } from "../../transcript/log.js";
import type { WeixinChannel } from "../../weixin/channel.js";
import { useWeixinChannel } from "../../weixin/use-weixin-channel.js";
import { listKnownWorkspaces, rememberWorkspace } from "../../workspaces.js";
import { openInExternalEditor } from "../edit/external-editor.js";
import { dumpStartupProfile, markPhase } from "../startup-profile.js";
@@ -120,6 +124,7 @@ import { ModelPicker } from "./ModelPicker.js";
import { PathConfirm } from "./PathConfirm.js";
import { PlanCheckpointConfirm } from "./PlanCheckpointConfirm.js";
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
import { PlanPanel } from "./PlanPanel.js";
import { PlanRefineInput } from "./PlanRefineInput.js";
import { PlanReviseConfirm, type ReviseChoice } from "./PlanReviseConfirm.js";
import { PlanReviseEditor } from "./PlanReviseEditor.js";
@@ -138,7 +143,7 @@ import type { PickerSnapshot, ViewerSnapshot } from "./dashboard/use-picker-broa
import { useViewerBroadcast } from "./dashboard/use-picker-broadcast.js";
import { formatEditResults, formatPendingPreview } from "./edit-history.js";
import {
buildEditToolBlocks,
buildEditToolBlocksForReview,
formatQueuedReviewToolResult,
isReviewGatedEditTool,
shouldApplyEditToolImmediately,
@@ -175,6 +180,8 @@ import { ModeStatusBar } from "./layout/LiveRows.js";
import { StaticCardStream } from "./layout/StaticCardStream.js";
import { StatusRow } from "./layout/StatusRow.js";
import type { StatusBarConfig } from "./layout/StatusRow.js";
import { shouldEnterPlanModeForExplicitIntent } from "./lifecycle-explicit-intent.js";
import { shouldSuggestPlanForEngineeringIntent } from "./lifecycle-plan-suggestion.js";
import { formatLoopStatus } from "./loop.js";
import { applyMcpAppend } from "./mcp-append.js";
import { handleMcpBrowseSlash } from "./mcp-browse.js";
@@ -199,8 +206,7 @@ import { InflightProvider } from "./state/inflight-context.js";
import { AgentStoreProvider, useAgentState, useAgentStore } from "./state/provider.js";
import { VerboseContext } from "./state/verbose-context.js";
import { ThemeProvider } from "./theme/context.js";
import { listThemeNames } from "./theme/tokens.js";
import { FG, type ThemeName } from "./theme/tokens.js";
import { FG, SURFACE, type ThemeName, listThemeNames } from "./theme/tokens.js";
import { TickerProvider, useSlowTick } from "./ticker.js";
import { handleTurnInterrupt } from "./turn-interrupt.js";
import { codeUndoContextMessage, codeUndoInfo } from "./undo-context.js";
@@ -307,10 +313,16 @@ export interface AppProps {
startupInfoHints?: string[];
/** Pre-created QQ channel (started before TUI mounts). */
qqChannel?: QQChannel;
telegramChannel?: TelegramChannel;
weixinChannel?: WeixinChannel;
/** Ref filled by App on mount so QQ messages flow into the TUI input queue. */
qqSubmitRef?: { current: ((text: string) => void) | null };
/** Ref filled by App on mount so QQ errors appear in the TUI log. */
qqErrorRef?: { current: ((msg: string) => void) | null };
telegramSubmitRef?: { current: ((text: string) => void) | null };
telegramErrorRef?: { current: ((msg: string) => void) | null };
weixinSubmitRef?: { current: ((text: string) => void) | null };
weixinErrorRef?: { current: ((msg: string) => void) | null };
/** Resolved chat-history scroll mode, computed by the launcher from config/env. */
historyScrollMode?: ResolvedHistoryScrollMode;
}
@@ -459,8 +471,14 @@ function AppInner({
onSwitchSession,
startupInfoHints,
qqChannel,
telegramChannel,
weixinChannel,
qqSubmitRef,
qqErrorRef,
telegramSubmitRef,
telegramErrorRef,
weixinSubmitRef,
weixinErrorRef,
historyScrollMode,
themeName,
setThemeName,
@@ -478,9 +496,7 @@ function AppInner({
const sessionModel = useAgentState((s) => s.session.model);
const sessionEffort = useAgentState((s) => s.status.reasoningEffort);
const ctxTokens = useAgentState((s) => s.status.promptTokens);
const ctxCap = useAgentState(
(s) => s.status.promptCap ?? DEEPSEEK_CONTEXT_TOKENS[s.session.model] ?? DEFAULT_CONTEXT_TOKENS,
);
const ctxCap = useAgentState((s) => s.status.promptCap ?? resolveContextTokens(s.session.model));
const sessionCostUsd = useAgentState((s) => s.status.sessionCost);
const lastTurnCostUsd = useAgentState((s) => s.status.cost);
const cacheHitRatio = useAgentState((s) => s.status.cacheHit);
@@ -563,10 +579,14 @@ function AppInner({
// reads from a ref populated AFTER useSessionInfo loads balance, so the
// subagent-end cost suffix renders in the live wallet's symbol.
const walletCurrencyRef = useRef<string | undefined>(undefined);
const loopRef = useRef<CacheFirstLoop | null>(null);
const { activities: subagentActivities, sinkRef: subagentSinkRef } = useSubagent({
session,
log,
getWalletCurrency: () => walletCurrencyRef.current,
// Fold subagent usage into the parent session's stats so the
// stats panel reflects total cost including child loops. (#2008)
onSubagentEnd: (model, usage) => loopRef.current?.stats.recordExternal(model, usage),
});
const { currentRootDir, setCurrentRootDir, currentRootDirRef } = useWorkspaceRoot(
codeMode?.rootDir,
@@ -615,6 +635,7 @@ function AppInner({
mode: engineeringLifecycleBaseModeRef.current,
});
}
const lifecyclePlanSuggestionSessionRef = useRef<string | null | undefined>(undefined);
// Refs that mirror state for stable read-callbacks handed to the
// embedded dashboard server. The server's `getXxx()` closures are
// captured once at startDashboard time; without ref-mirrors the
@@ -784,14 +805,11 @@ function AppInner({
const noTakeoverOverlay =
!pendingShell &&
!pendingPath &&
!pendingPlan &&
!pendingReviseEditor &&
!pendingSessionsPicker &&
!pendingEditPicker &&
!pendingWorkspacePicker &&
!pendingCheckpointPicker &&
!pendingMcpHub &&
!stagedInput &&
!pendingEditReview;
// Plan-mode indicator —displayed in the StatsPanel, mirrored onto
// the ToolRegistry so dispatch enforces read-only. Toggled via the
@@ -895,6 +913,14 @@ function AppInner({
// identify plans by intent rather than by filename.
const planBodyRef = useRef<string | null>(null);
const planSummaryRef = useRef<string | null>(null);
const planPanelOpen = !!(
pendingPlan ||
stagedInput ||
pendingCheckpoint ||
pendingRevision ||
pendingReviseEditor ||
(planStepsRef.current && planStepsRef.current.length > 0 && !planMode)
);
// Wall-clock when the latest tool_start fired. Cleared when the
// matching `tool` event arrives (or at turn end). Tools are
// dispatched serially in the loop, so a single ref is enough —no
@@ -933,6 +959,12 @@ function AppInner({
cacheHitRatio: 0,
lastPromptTokens: 0,
lastTurnCostUsd: 0,
totalCacheHitTokens: 0,
totalCacheMissTokens: 0,
lastCacheMissTokens: 0,
lastToolSchemaTokens: 0,
lastPrefixChanged: false,
lastPrefixChangeReasons: [],
});
const transcriptRef = useRef<WriteStream | null>(null);
@@ -964,7 +996,6 @@ function AppInner({
};
}, []);
const loopRef = useRef<CacheFirstLoop | null>(null);
// hookList + currentRootDir intentionally NOT in deps —they seed
// the loop on first construction (loopRef guards a single
// instantiation), and later edits flow in through the mutable
@@ -1025,6 +1056,8 @@ function AppInner({
hooks: hookList,
hookCwd: currentRootDir,
reasoningEffort: initialReasoningEffort ?? loadReasoningEffort(),
maxOutputTokens: loadMaxOutputTokens(),
maxIterPerTurn: loadMaxIterPerTurn(),
rebuildSystem,
});
loopRef.current = l;
@@ -1572,6 +1605,12 @@ function AppInner({
log.pushInfo(t("ui.ephemeralSession"));
} else if (loop.resumedMessageCount > 0) {
log.pushInfo(t("ui.resumedSession", { name: session, count: loop.resumedMessageCount }));
// Warn if system prompt changed since the session was last used — REASONIX.md
// or global memory edits cause a cache miss on the first resumed turn (#2212).
const savedFingerprint = loadSessionMeta(session).systemFingerprint;
if (savedFingerprint && savedFingerprint !== hashSystemPrompt(system)) {
log.pushWarning(t("ui.systemPromptChanged"), t("ui.systemPromptChangedDetail"));
}
} else {
log.pushInfo(t("ui.newSession", { name: session }));
}
@@ -1654,7 +1693,7 @@ function AppInner({
log.pushTip({ topic: tip.topic, sections: tip.sections, footer: tip.footer });
markMouseClipboardHintShown();
}
}, [session, loop, codeMode, syncPendingCount, log, pendingEdits, startupInfoHints]);
}, [session, loop, codeMode, syncPendingCount, log, pendingEdits, startupInfoHints, system]);
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
const quitProcess = useQuit(transcriptRef);
@@ -2033,7 +2072,7 @@ function AppInner({
// otherwise edit_file writes to the OLD root while read_file looks in
// the NEW one, producing ENOENT on the next read of a just-edited file.
const rootForEdit = currentRootDirRef.current;
const blocks = buildEditToolBlocks(name, args, rootForEdit);
const blocks = await buildEditToolBlocksForReview(name, args, rootForEdit, loop.readTracker);
if (!blocks || blocks.length === 0) return null;
// Helper: apply the current block(s) + record into history + arm
@@ -2046,6 +2085,8 @@ function AppInner({
// after —ToolCard renders that with the same text. Pushing here
// would produce "result shown twice".
const applyNow = (): string => {
const guard = prepareAutoGitRollbackForEditBlocks(rootForEdit, blocks, {});
if (guard) return guard;
const snaps = snapshotBeforeEdits(blocks, rootForEdit);
const results = applyEditBlocks(blocks, rootForEdit);
const good = results.some((r) => r.status === "applied" || r.status === "created");
@@ -2259,7 +2300,7 @@ function AppInner({
// StatsPanel reads). `balance` comes from useSessionInfo via a
// ref-mirror so this callback stays cheap.
const s = loop.stats.summary();
const ctxCap = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
const ctxCap = resolveContextTokens(loop.model);
return {
turns: s.turns,
totalCostUsd: s.totalCostUsd,
@@ -2708,11 +2749,74 @@ function AppInner({
onChoiceResolveRef: handleChoiceResolveRef,
});
const telegram = useTelegramChannel({
codeMode: !!codeMode,
initialChannel: telegramChannel,
log,
setQueuedSubmit,
telegramSubmitRef,
telegramErrorRef,
sessionName: session,
currentRootDir,
pendingGateIdRef,
completedStepIdsRef,
planStepsRef,
onCreateSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
onSelectSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
onModelPick: handleQQModelPick,
onThemePick: handleQQThemePick,
onShellConfirmRef: handleShellConfirmRef,
onPathConfirmRef: handlePathConfirmRef,
onPlanCancelRef: handlePlanCancelRef,
onPlanFeedbackRef: handlePlanFeedbackRef,
onCheckpointConfirmRef: handleCheckpointConfirmRef,
onCheckpointReviseRef: handleCheckpointReviseSubmitRef,
onPlanRevisionRef: handleReviseConfirmRef,
onChoiceResolveRef: handleChoiceResolveRef,
});
const weixin = useWeixinChannel({
codeMode: !!codeMode,
initialChannel: weixinChannel,
log,
setQueuedSubmit,
weixinSubmitRef,
weixinErrorRef,
sessionName: session,
currentRootDir,
pendingGateIdRef,
completedStepIdsRef,
planStepsRef,
onCreateSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
onSelectSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
onModelPick: handleQQModelPick,
onThemePick: handleQQThemePick,
onShellConfirmRef: handleShellConfirmRef,
onPathConfirmRef: handlePathConfirmRef,
onPlanCancelRef: handlePlanCancelRef,
onPlanFeedbackRef: handlePlanFeedbackRef,
onCheckpointConfirmRef: handleCheckpointConfirmRef,
onCheckpointReviseRef: handleCheckpointReviseSubmitRef,
onPlanRevisionRef: handleReviseConfirmRef,
onChoiceResolveRef: handleChoiceResolveRef,
});
const handleSubmit = useCallback(
async (raw: string) => {
const incoming = qq.parseSubmit(raw);
const qqIncoming = qq.parseSubmit(raw);
const telegramIncoming =
qqIncoming?.handled || qqIncoming?.fromQQ ? null : telegram.parseSubmit(raw);
const incoming =
qqIncoming?.handled || qqIncoming?.fromQQ
? qqIncoming
: telegramIncoming?.handled || telegramIncoming?.fromTelegram
? telegramIncoming
: weixin.parseSubmit(raw);
if (!incoming) return;
let { text, fromQQ } = incoming;
let { text } = incoming;
const fromQQ = "fromQQ" in incoming && incoming.fromQQ;
const fromTelegram = "fromTelegram" in incoming && incoming.fromTelegram;
const fromWeixin = "fromWeixin" in incoming && incoming.fromWeixin;
if (incoming.handled) {
return;
}
@@ -2954,12 +3058,28 @@ function AppInner({
disconnect: qq.disconnect,
status: qq.status,
},
telegram: {
connect: telegram.connect,
disconnect: telegram.disconnect,
status: telegram.status,
},
weixin: {
connect: weixin.connect,
disconnect: weixin.disconnect,
status: weixin.status,
},
sessionId: session,
getEngineeringLifecycleSnapshot: codeMode
? () => engineeringLifecycleRef.current?.snapshot() ?? null
: undefined,
jobs: codeMode?.jobs,
postInfo: fromQQ ? qq.sendInfo : log.pushInfo,
postInfo: fromQQ
? qq.sendInfo
: fromTelegram
? telegram.sendInfo
: fromWeixin
? weixin.sendInfo
: log.pushInfo,
postDoctor: (checks) => log.showDoctor(checks),
postUsage: (args) => log.showUsageVerbose(args),
postKeys: (args) =>
@@ -3019,8 +3139,14 @@ function AppInner({
generateSessionTitle: generateCurrentSessionTitle,
});
if (
fromQQ &&
qq.handleRemoteSlashResult({
(fromQQ
? qq
: fromTelegram
? telegram
: fromWeixin
? weixin
: null
)?.handleRemoteSlashResult({
result,
codeMode: !!codeMode,
sessions: listSessionsForWorkspace(currentRootDir),
@@ -3108,6 +3234,8 @@ function AppInner({
text,
});
if (fromQQ && result.info) qq.sendText(result.info);
if (fromTelegram && result.info) telegram.sendText(result.info);
if (fromWeixin && result.info) weixin.sendText(result.info);
if (outcome.kind === "resubmit") {
text = outcome.text;
} else {
@@ -3134,6 +3262,26 @@ function AppInner({
}
if (codeMode) {
const explicitPlanIntent = shouldEnterPlanModeForExplicitIntent({
text,
codeMode: true,
planMode,
});
if (explicitPlanIntent) {
togglePlanMode(true, "explicit-intent");
log.pushInfo(t("app.explicitPlanIntentArmed"));
} else if (
shouldSuggestPlanForEngineeringIntent({
text,
codeMode: true,
planMode,
lifecycleMode: engineeringLifecycleRef.current?.snapshot().mode ?? "off",
alreadySuggested: lifecyclePlanSuggestionSessionRef.current === (session ?? null),
})
) {
log.pushInfo(t("app.lifecyclePlanSuggestion"));
lifecyclePlanSuggestionSessionRef.current = session ?? null;
}
const before = engineeringLifecycleRef.current?.snapshot().state;
engineeringLifecycleRef.current?.observeUserPrompt(text);
const after = engineeringLifecycleRef.current?.snapshot().state;
@@ -3158,6 +3306,9 @@ function AppInner({
if (!existing.summary) patch.summary = text.replace(/\s+/g, " ").slice(0, 80);
if (!existing.branch) patch.branch = detectGitBranch(currentRootDir);
if (!existing.workspace) patch.workspace = currentRootDir;
// Always refresh the system-prompt fingerprint so the next resume can
// detect REASONIX.md / memory changes that happened since this turn.
patch.systemFingerprint = hashSystemPrompt(system);
if (Object.keys(patch).length > 0) patchSessionMeta(session, patch);
}
@@ -3182,6 +3333,8 @@ function AppInner({
busyRef.current = true;
setBusy(true);
qq.noteTurnFromQQ(fromQQ);
telegram.noteTurnFromTelegram(fromTelegram);
weixin.noteTurnFromWeixin(fromWeixin);
abortedThisTurn.current = false;
// Seal the in-progress history entry so this turn's edits open
// a new one —prior turns are preserved intact for /history and
@@ -3343,7 +3496,7 @@ function AppInner({
armUndoBanner,
pendingEdits,
syncPendingCount,
ctxMax: DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS,
ctxMax: resolveContextTokens(loop.model),
});
if (session) {
const m = loadSessionMeta(session);
@@ -3437,7 +3590,8 @@ function AppInner({
payload: {
event: "Stop",
cwd: currentRootDir,
lastAssistantText: streamRef.text,
lastAssistantText,
last_assistant_message: lastAssistantText,
turn: loop.stats.summary().turns,
},
});
@@ -3447,6 +3601,8 @@ function AppInner({
}
}
qq.maybeSendFinalReply(lastAssistantText);
telegram.maybeSendFinalReply(lastAssistantText);
weixin.maybeSendFinalReply(lastAssistantText);
} finally {
flush();
// Esc aborted the turn —close any in-flight cards (streaming /
@@ -3462,6 +3618,8 @@ function AppInner({
setBusy(false);
submittingRef.current = false;
qq.clearTurnReply();
telegram.clearTurnReply();
weixin.clearTurnReply();
// Refresh balance lazily —don't block the return.
refreshBalance();
}
@@ -3517,6 +3675,8 @@ function AppInner({
startLoop,
getLoopStatus,
qq,
telegram,
weixin,
isLoopActive,
isLoopFiring,
clearFiringFlag,
@@ -3536,6 +3696,7 @@ function AppInner({
liveMcpServers,
generateCurrentSessionTitle,
switchWorkspaceRoot,
system,
],
);
@@ -3617,8 +3778,10 @@ function AppInner({
setStagedCheckpointRevise(null);
pendingGateIdRef.current = null;
qq.resetInteractions();
telegram.resetInteractions();
weixin.resetInteractions();
pauseGate.cancelAll();
}, [qq]);
}, [qq, telegram, weixin]);
// Drain queued submits after the in-flight turn tears down.
// QQ pause-gate replies are the one exception: they need to re-enter
@@ -3626,13 +3789,16 @@ function AppInner({
// pauseGate.ask() can be resolved from the remote reply.
useEffect(() => {
if (queuedSubmit === null) return;
const canBypassBusy = qq.canBypassBusy(queuedSubmit);
const canBypassBusy =
qq.canBypassBusy(queuedSubmit) ||
telegram.canBypassBusy(queuedSubmit) ||
weixin.canBypassBusy(queuedSubmit);
if ((!busy && !submittingRef.current) || canBypassBusy) {
const text = queuedSubmit;
setQueuedSubmit(null);
void handleSubmit(text);
}
}, [busy, queuedSubmit, handleSubmit, qq]);
}, [busy, queuedSubmit, handleSubmit, qq, telegram, weixin]);
/**
* PlanConfirm callback. Three outcomes, all ending with a synthetic
@@ -3894,6 +4060,8 @@ function AppInner({
pendingGateIdRef.current = request.id;
qq.handlePauseRequest(request.kind, payload);
telegram.handlePauseRequest(request.kind, payload);
weixin.handlePauseRequest(request.kind, payload);
switch (request.kind) {
case "run_command":
@@ -4270,8 +4438,12 @@ function AppInner({
<>
<TickerProvider disabled={tickerSuspended}>
<InflightProvider inflight={loop.inflight}>
<Box flexDirection="row">
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="row" backgroundColor={SURFACE.bg}>
<Box
flexDirection="column"
flexGrow={planPanelOpen ? 0 : 1}
width={planPanelOpen ? "35%" : undefined}
>
<Box flexDirection="column" flexGrow={1}>
<LiveExpandContext.Provider value={liveExpand}>
<VerboseContext.Provider value={verboseMode}>
@@ -4300,6 +4472,7 @@ function AppInner({
) : null}
<LiveActivityArea
noTakeoverOverlay={noTakeoverOverlay}
suppressPlanLiveRow={planPanelOpen}
ongoingTool={ongoingTool}
toolProgress={toolProgress}
subagentActivities={subagentActivities}
@@ -4327,12 +4500,46 @@ function AppInner({
}
/>
</Box>
{stagedInput ? (
<PlanRefineInput
mode={stagedInput.mode}
questions={stagedInput.questions}
onSubmit={handleStagedInputSubmit}
onCancel={handleStagedInputCancel}
{planPanelOpen ? (
<ComposerArea
editMode={editMode}
pendingCount={pendingCount}
modeFlash={modeFlash}
planMode={planMode}
undoArmed={!!undoBanner || hasUndoable()}
jobs={codeMode ? codeMode.jobs : undefined}
activeLoop={activeLoop}
statusBar={statusBar}
showShortcuts={pendingShortcuts}
mode={
editMode === "yolo"
? t("statsPanel.modeYolo")
: editMode === "auto"
? t("statsPanel.modeAuto")
: editMode === "review"
? t("statsPanel.modeReview")
: editMode
}
model={`${sessionModel} \u00b7 ${sessionEffort ?? loop.reasoningEffort}`}
input={input}
setInput={setInput}
busy={busy}
steerBusy={busy}
onSubmit={handleSubmit}
onHistoryPrev={handleHistoryPrev}
onHistoryNext={handleHistoryNext}
onOpenExternalEditor={handleOpenExternalEditor}
onCursorChange={setComposerCursor}
isHistoryMode={isHistoryMode}
slashMatches={slashMatches}
slashSelected={slashSelected}
slashGroupMode={slashGroupMode}
slashAdvancedHidden={slashAdvancedHidden}
atState={atState}
atSelected={atSelected}
slashArgContext={slashArgContext}
slashArgMatches={slashArgMatches}
slashArgSelected={slashArgSelected}
/>
) : stagedChoiceCustom ? (
<PlanRefineInput
@@ -4353,26 +4560,6 @@ function AppInner({
allowCustom={pendingChoice.allowCustom}
onChoose={stableHandleChoiceConfirm}
/>
) : pendingRevision ? (
<PlanReviseConfirm
reason={pendingRevision.reason}
oldRemaining={(planStepsRef.current ?? []).filter(
(s) => !completedStepIdsRef.current.has(s.id),
)}
newRemaining={pendingRevision.remainingSteps}
summary={pendingRevision.summary}
onChoose={stableHandleReviseConfirm}
/>
) : pendingCheckpoint ? (
<PlanCheckpointConfirm
stepId={pendingCheckpoint.stepId}
title={pendingCheckpoint.title}
completed={pendingCheckpoint.completed}
total={pendingCheckpoint.total}
steps={planStepsRef.current ?? undefined}
completedStepIds={completedStepIdsRef.current}
onChoose={stableHandleCheckpointConfirm}
/>
) : pendingCheckpointPicker ? (
<CheckpointPicker
checkpoints={checkpointPickerList}
@@ -4569,32 +4756,6 @@ function AppInner({
: undefined
}
/>
) : pendingPlan ? (
<PlanConfirm
plan={pendingPlan}
steps={planStepsRef.current ?? undefined}
summary={planSummaryRef.current ?? undefined}
onChoose={stableHandlePlanConfirm}
projectRoot={currentRootDir}
/>
) : pendingReviseEditor ? (
<PlanReviseEditor
steps={planStepsRef.current ?? []}
completedStepIds={completedStepIdsRef.current}
onAccept={(revised, skippedIds) => {
planStepsRef.current = revised;
for (const id of skippedIds) completedStepIdsRef.current.add(id);
persistPlanState();
const planText = pendingReviseEditor;
setPendingReviseEditor(null);
setPendingPlan(planText);
}}
onCancel={() => {
const planText = pendingReviseEditor;
setPendingReviseEditor(null);
setPendingPlan(planText);
}}
/>
) : pendingShell ? (
<ShellConfirm
prompt={toApprovalPrompt({
@@ -4688,6 +4849,27 @@ function AppInner({
/>
)}
</Box>
{planPanelOpen ? (
<PlanPanel
planBody={planBodyRef.current}
planSummary={planSummaryRef.current}
planSteps={planStepsRef.current}
completedStepIds={completedStepIdsRef.current}
pendingPlan={pendingPlan}
stagedInput={stagedInput}
pendingCheckpoint={pendingCheckpoint}
pendingRevision={pendingRevision}
pendingReviseEditor={pendingReviseEditor}
isExecuting={
planStepsRef.current !== null && planStepsRef.current.length > 0 && !planMode
}
onPlanConfirm={stableHandlePlanConfirm}
onStagedInputSubmit={handleStagedInputSubmit}
onStagedInputCancel={handleStagedInputCancel}
onCheckpointChoose={stableHandleCheckpointConfirm}
onReviseConfirm={stableHandleReviseConfirm}
/>
) : null}
</Box>
</InflightProvider>
</TickerProvider>
+9 -9
View File
@@ -3,7 +3,7 @@ import { Box, Text } from "ink";
import React from "react";
import { t } from "../../i18n/index.js";
import { GLYPH, useColor } from "./theme.js";
import { SURFACE } from "./theme/tokens.js";
import { FG, SURFACE } from "./theme/tokens.js";
import type { AtPickerEntry, AtPickerState } from "./useCompletionPickers.js";
export interface AtMentionSuggestionsProps {
@@ -42,7 +42,7 @@ export function AtMentionSuggestions({
isSelected={windowStart + i === selectedIndex}
/>
))}
{hiddenBelow > 0 ? <Text dim>{` ↓ ${hiddenBelow} below`}</Text> : null}
{hiddenBelow > 0 ? <Text color={FG.faint}>{` ↓ ${hiddenBelow} below`}</Text> : null}
<FooterRow isBrowse={isBrowse} hasFolder={shown.some((e) => e.isDir)} />
</Box>
);
@@ -70,8 +70,8 @@ function HeaderRow({
return (
<Box>
{lead}
<Text dim>{`${where} ${counter}`}</Text>
{hiddenAbove > 0 ? <Text dim>{` ↑ ${hiddenAbove} above`}</Text> : null}
<Text color={FG.faint}>{`${where} ${counter}`}</Text>
{hiddenAbove > 0 ? <Text color={FG.faint}>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
);
}
@@ -81,8 +81,8 @@ function HeaderRow({
return (
<Box>
{lead}
<Text dim>{status}</Text>
{hiddenAbove > 0 ? <Text dim>{` ↑ ${hiddenAbove} above`}</Text> : null}
<Text color={FG.faint}>{status}</Text>
{hiddenAbove > 0 ? <Text color={FG.faint}>{` ↑ ${hiddenAbove} above`}</Text> : null}
</Box>
);
}
@@ -103,7 +103,7 @@ function EmptyRow({ state, color }: { state: AtPickerState; color: ReturnType<ty
if (state.searching) {
return (
<Box>
<Text dim>{t("atMentions.scanning")}</Text>
<Text color={FG.faint}>{t("atMentions.scanning")}</Text>
</Box>
);
}
@@ -131,7 +131,7 @@ function EntryRow({ entry, isSelected }: { entry: AtPickerEntry; isSelected: boo
<Text color={labelColor} bold={isSelected}>
{labelText.padEnd(20)}
</Text>
{entry.dirSuffix ? <Text dim>{` ${entry.dirSuffix}`}</Text> : null}
{entry.dirSuffix ? <Text color={FG.faint}>{` ${entry.dirSuffix}`}</Text> : null}
</Box>
);
}
@@ -140,7 +140,7 @@ function FooterRow({ isBrowse, hasFolder }: { isBrowse: boolean; hasFolder: bool
const hintKey = isBrowse && hasFolder ? "atMentions.footerBrowse" : "atMentions.footerInsert";
return (
<Box marginTop={0}>
<Text dim>{` ${t(hintKey)}`}</Text>
<Text color={FG.faint}>{` ${t(hintKey)}`}</Text>
</Box>
);
}
+7 -1
View File
@@ -20,6 +20,7 @@ import { SlashSuggestions } from "./SlashSuggestions.js";
import { StatusRow } from "./layout/StatusRow.js";
import { formatLoopStatus } from "./loop.js";
import { SURFACE } from "./theme/tokens.js";
import { useSlowTick } from "./ticker.js";
import type { StatusBarConfig } from "./layout/StatusRow.js";
@@ -110,7 +111,12 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
}) => {
useRenderTrace("ComposerArea");
const inputArea = (
<Box flexDirection="column" flexShrink={0} flexWrap="nowrap">
<Box
flexDirection="column"
flexShrink={0}
flexWrap="nowrap"
backgroundColor={SURFACE.bgInput}
>
<Box flexDirection="column" flexShrink={0} flexWrap="nowrap">
{slashMatches !== null ? (
<SlashSuggestions
+12 -11
View File
@@ -19,6 +19,7 @@ import {
findPrevDivergence,
} from "../../transcript/diff.js";
import { RecordView } from "./RecordView.js";
import { FG } from "./theme/tokens.js";
export interface DiffAppProps {
report: DiffReport;
@@ -86,7 +87,7 @@ export function DiffApp({ report }: DiffAppProps) {
) : null}
<Box marginTop={1} paddingX={1} borderStyle="single" borderColor="ansi:blackBright">
<Text dim>
<Text color={FG.faint}>
<Text bold>j</Text>/<Text bold>\u2193</Text> next \u00b7 <Text bold>k</Text>/
<Text bold>\u2191</Text> prev \u00b7 <Text bold>n</Text> next-diverge \u00b7{" "}
<Text bold>N</Text>/<Text bold>p</Text> prev-diverge \u00b7 <Text bold>g</Text>/
@@ -129,19 +130,19 @@ function DiffHeader({ report }: { report: DiffReport }) {
<Text color="ansi:cyan" bold>
{t("diffApp.title")}
</Text>
<Text dim> \u00b7 A=</Text>
<Text color={FG.faint}> \u00b7 A=</Text>
<Text color="ansi:blue">{a.label}</Text>
<Text dim> vs B=</Text>
<Text color={FG.faint}> vs B=</Text>
<Text color="ansi:magenta">{b.label}</Text>
</Text>
<Text dim>{t("diffApp.turnsAligned", { count: report.pairs.length })}</Text>
<Text color={FG.faint}>{t("diffApp.turnsAligned", { count: report.pairs.length })}</Text>
</Box>
<Box marginTop={1} gap={3}>
<Text>
<Text dim>cache </Text>
<Text color={FG.faint}>cache </Text>
<Text>{(a.stats.cacheHitRatio * 100).toFixed(1)}%</Text>
<Text dim> → </Text>
<Text color={FG.faint}> → </Text>
<Text>{(b.stats.cacheHitRatio * 100).toFixed(1)}%</Text>
<Text color={cacheDelta >= 0 ? "ansi:green" : "ansi:red"} bold>
{" "}
@@ -150,9 +151,9 @@ function DiffHeader({ report }: { report: DiffReport }) {
</Text>
</Text>
<Text>
<Text dim>cost </Text>
<Text color={FG.faint}>cost </Text>
<Text>${a.stats.totalCostUsd.toFixed(6)}</Text>
<Text dim> → </Text>
<Text color={FG.faint}> → </Text>
<Text>${b.stats.totalCostUsd.toFixed(6)}</Text>
<Text color={costDelta <= 0 ? "ansi:green" : "ansi:red"} bold>
{" "}
@@ -161,7 +162,7 @@ function DiffHeader({ report }: { report: DiffReport }) {
</Text>
</Text>
<Text>
<Text dim>model calls </Text>
<Text color={FG.faint}>model calls </Text>
<Text>
{a.stats.turns} → {b.stats.turns}
</Text>
@@ -170,7 +171,7 @@ function DiffHeader({ report }: { report: DiffReport }) {
{prefixLine ? (
<Box marginTop={1}>
<Text dim italic>
<Text color={FG.faint} italic>
{prefixLine}
</Text>
</Box>
@@ -201,7 +202,7 @@ function Pane({
</Text>
{records.length === 0 ? (
<Box marginTop={1}>
<Text dim italic>
<Text color={FG.faint} italic>
{t("diffApp.paneEmpty")}
</Text>
</Box>
+4 -3
View File
@@ -7,6 +7,7 @@ import { DenyContextInput } from "./DenyContextInput.js";
import { SplitDiff } from "./SplitDiff.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useKeystroke } from "./keystroke-context.js";
import { FG } from "./theme/tokens.js";
export type EditReviewChoice = "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto";
@@ -133,7 +134,7 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
footerHint={t("editConfirm.footer")}
>
{hiddenAbove > 0 ? (
<Text dim>
<Text color={FG.faint}>
{t(hiddenAbove === 1 ? "editConfirm.linesAbove" : "editConfirm.linesAbovePlural", {
count: hiddenAbove,
})}
@@ -148,10 +149,10 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
<Text color="#bef0c8" backgroundColor="#0c2718">
{t("editConfirm.newLabel")}
</Text>
<Text dim>{t("editConfirm.sideBySide")}</Text>
<Text color={FG.faint}>{t("editConfirm.sideBySide")}</Text>
</Box>
{hiddenBelow > 0 ? (
<Text dim>
<Text color={FG.faint}>
{t(hiddenBelow === 1 ? "editConfirm.linesBelow" : "editConfirm.linesBelowPlural", {
count: hiddenBelow,
})}
+3 -1
View File
@@ -23,6 +23,7 @@ type UndoBannerState = Parameters<typeof UndoBanner>[0]["banner"];
export interface LiveActivityAreaProps {
noTakeoverOverlay: boolean;
suppressPlanLiveRow?: boolean;
ongoingTool: { name: string; args?: string } | null;
toolProgress: { progress: number; total?: number; message?: string } | null;
subagentActivities: ReadonlyArray<SubagentActivity>;
@@ -39,6 +40,7 @@ export interface LiveActivityAreaProps {
export const LiveActivityArea: React.FC<LiveActivityAreaProps> = React.memo(
({
noTakeoverOverlay,
suppressPlanLiveRow,
ongoingTool,
toolProgress,
subagentActivities,
@@ -67,7 +69,7 @@ export const LiveActivityArea: React.FC<LiveActivityAreaProps> = React.memo(
{noTakeoverOverlay && busy && !isStreaming && !ongoingTool && !statusLine ? (
<ThinkingRow text={activityLabel} />
) : null}
{noTakeoverOverlay ? <PlanLiveRow /> : null}
{noTakeoverOverlay && !suppressPlanLiveRow ? <PlanLiveRow /> : null}
<ToastRail />
</Box>
);
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff. Show more