Compare commits

..
231 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
YHHandreasonix 1095f5ff52 fix(loop): force-summary uses active model, not hard-coded flash (#1986)
Context-guard and stuck-state recovery were calling
ctx.client.chat({ model: "deepseek-v4-flash" }) regardless of what the
user actually configured. Third-party DeepSeek-compatible endpoints
(mimo, self-hosted, Azure forks) that don't expose flash 400 on every
force-summary, the recovery path crashes, and the session looks like
it lost context — the messages are still there, but the user sees an
error and has no way back without /clear.

Pass this.model through summaryContext() and use it for the summary
call. `thinking: "disabled"` still keeps reasoning tokens off the bill
even when the active model is pro.

Add a regression test that asserts the force-summary path hits the
active model, not flash.

Co-authored-by: reasonix <[email protected]>
2026-05-26 21:52:09 -07:00
Weikeng Chenandreasonix c3e0ccf99c feat(desktop): 余额显示支持多币种(同时展示人民币和美元) (#1930)
* feat(desktop): show all currency balances from user/balance API

DeepSeek's /user/balance endpoint returns a balance_infos array with
separate entries per currency (CNY, USD). The desktop client was only
showing the single largest balance (pickPrimaryBalance), hiding the
other currency.

- Add BalanceInfoItem type and balanceInfos array to BalanceEvent protocol
- Pass all currency balances through emitBalance() in the Node child process
- Store multiple balance infos in the frontend Balance type
- Render all balances as a single segment: \$ X.XX / ¥ YY.YY
- Preserve existing single-balance fallback for backward compatibility

* fixup(desktop): restore click-to-toggle on balance segment

The multi-currency rendering dropped onClick={onToggleCurrency}, breaking
the mouse path to flip the cost segment between CNY/USD. Restore it with
the existing switchCurrency tooltip — the balance now shows both, but
the cost figure still tracks the user's chosen currency.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-26 21:47:26 -07:00
wade1999 16904ce664 feat(qq): default to markdown replies (#1941) 2026-05-26 21:47:06 -07:00
YHHandreasonix 54c3792085 fix(desktop): fire hooks (Pre/PostToolUse, UserPromptSubmit, Stop) per tab (#1984)
Desktop never wired the hook runner — `buildRuntimeFor` constructed
CacheFirstLoop without `hooks`/`hookCwd`, and `runTurn` had no
UserPromptSubmit/Stop dispatch. So hooks defined in
~/.reasonix/settings.json silently no-op'd in the desktop app even
though they fired fine under `reasonix code`.

Load hooks per tab from `tab.rootDir`'s project scope + global,
thread them into the loop (covers Pre/PostToolUse via the loop's own
trigger), fire UserPromptSubmit before `loop.step` and Stop in the
turn-complete tail, and re-load on workspace switch so project hooks
follow the new root. Non-pass outcomes surface via $error; a blocking
UserPromptSubmit aborts the turn before the prompt reaches the model.

Closes #1942

Co-authored-by: reasonix <[email protected]>
2026-05-26 21:45:03 -07:00
YHHandreasonix dac635ed45 fix(desktop): clear pending assistant on error so the spinner stops (#1660) (#1985)
When the SSE stream terminates mid-turn (proxy/LB idle drop, undici
`Error("terminated")`), the loop yields a role:"error" event and
returns. The reducer flips `busy:false` and appends the error card,
but the in-progress assistant message keeps `pending:true` — so the
UI shows a "thinking" spinner above the red error box and looks stuck.
Reported as "对话卡死" in #1660.

Loop has already returned by the time we receive the error, so any
streaming assistant message is no longer streaming. Settle it.

Co-authored-by: reasonix <[email protected]>
2026-05-26 21:16:36 -07:00
YHHandreasonix 691bd03201 fix(config): saveApiKey overrides stale env so UI save takes effect (#1982)
Reported: user installed via npx, ran setup, then switched to the
desktop app. Saving a fresh key from the desktop UI updated
config.json but every request still 401'd with the old key as the
masked tail in the error message.

Root cause: loadEndpoint's fallback (no custom baseUrl on either
side) prefers process.env.DEEPSEEK_API_KEY over cfg.apiKey on the
read path — a 12-factor convention. A stale env (User-level Windows
env var, ~/.env, shell rc) then shadowed the just-saved config
forever inside the running Node sidecar.

Fix: have saveApiKey mirror the new value into process.env so the
explicit UI save wins inside the current process. Read precedence is
unchanged — env still beats config for users who deliberately set
DEEPSEEK_API_KEY to override.

Co-authored-by: reasonix <[email protected]>
2026-05-26 21:04:16 -07:00
YHHandreasonix 4837165227 fix(loop): force-summary uses active model, not hard-coded flash (#1979)
Context-guard and stuck-state recovery were calling
ctx.client.chat({ model: "deepseek-v4-flash" }) regardless of what the
user actually configured. Third-party DeepSeek-compatible endpoints
(mimo, self-hosted, Azure forks) that don't expose flash 400 on every
force-summary, the recovery path crashes, and the session looks like
it lost context — the messages are still there, but the user sees an
error and has no way back without /clear.

Pass this.model through summaryContext() and use it for the summary
call. `thinking: "disabled"` still keeps reasoning tokens off the bill
even when the active model is pro.

Add a regression test that asserts the force-summary path hits the
active model, not flash.

Co-authored-by: reasonix <[email protected]>
2026-05-26 21:03:27 -07:00
YHHandreasonix 49e9d9e04a fix(desktop): wire /skill and /skills slash commands (#1969) (#1978)
Desktop palette had no /skill entry, so typing /skill (which works in
the CLI) showed "no matches". Add /skill + /skills as palette commands
that open Settings → Skills, and handle the same names on submit so
pressing Enter goes there too (mirrors the /search-engine special case).

Co-authored-by: reasonix <[email protected]>
2026-05-26 21:01:36 -07:00
YHHandreasonix ec9788d8ed fix(truncated-result-saver): fall back to ~/.reasonix when rootDir is fs root (#1974)
When launched with cwd=/ (e.g. some daemon/IDE contexts), saveTruncatedResult
joined the project root to .reasonix and tried to mkdir /.reasonix/truncated-results,
which fails with ENOENT and crashes run_skill on oversized results.

Treat fs-root rootDir the same as empty and write under the user's home dir
instead. Closes #1959.

Co-authored-by: reasonix <[email protected]>
2026-05-26 20:51:52 -07:00
YHHandreasonix 58f28d61aa fix(truncate): align slice boundaries to UTF-16 codepoint (#1970) (#1976)
`truncateForModel` and `truncateForModelByTokens` slice on UTF-16 code
units. When the cut lands inside a surrogate pair (emoji, supplementary
CJK) the resulting head ends with a lone high surrogate / tail starts
with a lone low surrogate. The lone-surrogate sanitizer in `client.ts`
catches one downstream layer, but the safer fix is to never produce
the malformed slice in the first place — keeps tool results clean even
on the disk transcript path that bypasses the client sanitizer.

Reported: HTML file full of emoji caused DeepSeek to reject the chat
request with `serde_json: unexpected end of hex escape at line 1
column 38621` once the file content was sliced to fit the result-token
budget.

Co-authored-by: reasonix <[email protected]>
2026-05-26 20:47:17 -07:00
AtmoOmenandreasonix 857951ac83 fix(web_fetch): fallback to DoH when system DNS returns fake internal IPs (#1944)
* fix(web_fetch): fall back to DoH when system DNS returns fake internal IPs

TUN mode proxies (Clash Meta, Sing-box, etc.) use Fake-IP DNS strategy,
returning reserved addresses (e.g. 198.18.0.0/15) for all domain lookups.
The SSRF check in assertPublicHttpUrl treats these as internal hosts and
rejects legitimate public domains like github.com.

Add dohResolve() that queries Cloudflare 1.1.1.1 DNS-over-HTTPS to get
real public IPs. When system DNS returns any internal/reserved address,
fall back to DoH and only reject if the DoH results are also internal.
This preserves SSRF protection while correctly handling DNS pollution
from TUN/mitm proxies.

* test(web_fetch): update SSRF test to account for DoH fallback

The old test asserted that globalThis.fetch was never called when DNS
returned internal addresses. After adding the DoH fallback in the
previous commit, the code now makes a single DoH query to verify.
Update the test to stub fetch with a rejected promise and assert it
calls the correct Cloudflare DoH endpoint.

* fixup(web_fetch): compress dohResolve JSDoc to one line (comment-policy)

---------

Co-authored-by: reasonix <[email protected]>
2026-05-26 20:44:17 -07:00
enderzcx 3f66304eb4 fix(cli): accept models endpoint in doctor (#1972) 2026-05-26 20:33:36 -07:00
enderzcx 7fc3c526f1 fix(desktop): refresh context token meter (#1964) 2026-05-26 20:28:03 -07:00
TaoMu b1ee3ff327 fix: sanitize lone surrogates in chat payloads (#1939) 2026-05-26 20:06:15 -07:00
TaoMu 8ede8830fa fix(desktop): preserve context on abort (#1938) 2026-05-26 20:04:26 -07:00
TaoMu 7e03aca713 Fix generated-script shell cwd guidance (#1937)
* fix: clarify shell cwd guidance for generated scripts

* fix: soften generated script cwd guidance

* test: stabilize regex worker restart check
2026-05-26 20:02:37 -07:00
lightfrontandXingbo Zhao 2f2ddf5d06 fix(desktop): support \[ \] and \( \) math delimiters in markdown (#1923)
* fix(desktop): support \[ \] and \( \) math delimiters in markdown

* fix(desktop): protect \[4pt] LaTeX line-break spacing from math delimiter normalization

---------

Co-authored-by: Xingbo Zhao <[email protected]>
2026-05-26 19:59:14 -07:00
TaoMu 3c08f600a9 fix: keep dashboard New Chat label on one line (#1924)
* fix dashboard new chat button wrapping

* test dashboard new chat localized label

* fix compact sidebar new chat layout
2026-05-26 19:52:15 -07:00
YHHandreasonix bfcf333222 chore(errors): tighten errorDetail propagation (#1965)
Follow-up on #1918 — strip the four loose joints that surfaced in review:

- Replace `(err as any).code` / `(err as any).phase` casts with a small
  `errorMeta(err)` narrowing helper in `loop/errors.ts`. Same payload,
  no escape hatch.
- Extract `is4xxError` alongside `is5xxError` so the loop's "client
  error → not retryable" branch reads like the existing 5xx code path
  instead of an inline regex.
- Decouple `recoverable` from `retryable` in the loop's API-failure
  yield. `recoverable` is the historical "agent can keep going" flag
  (consumed by event listing), `retryable` is the new external retry
  hint; conflating them changed event-listing output silently.
- Carry `name` through `ErrorEvent` + eventize so the ACP dispatcher
  surfaces the real error name instead of `ev.message.split(":")[0]`.

Co-authored-by: reasonix <[email protected]>
2026-05-26 19:52:14 -07:00
YHHandreasonix 82c016b193 fix(tui): break CardStream measure→render→measure loop (#1958)
* fix(tui): break CardStream measure→render→measure loop (#1955)

CardStream measured the inner scroll Box via `useBoxMetrics`, derived
maxScroll from `inner.height - outer.height`, then pinned-mode wrote
that back to scrollRows — which feeds into `marginTop` and items
composition, which feeds back into inner.height. Several edit_file
results streaming in one turn was enough to stack the cascade past
React's nested-update limit (50).

Inner height is already known per-card in the chat-scroll store —
the per-card `useBoxMetrics` in MeasuredCard reports it. Summing the
store gives the same total without ever measuring inner, so scrollRows
can no longer feed back into the value used to compute maxScroll.

- ink-update-depth-repro.test.tsx: pins down the underlying pattern
  (a component that decides its own render from its own useBoxMetrics
  reading trips Max update depth).
- cardstream-architecture.test.tsx: side-by-side pre/post-fix harness
  proving the post-fix path stays linear and converges on the right
  maxScroll.

Closes #1955

* test(tui): assert oscillation by render count, not Max-depth firing

CI hit a flaky failure: Linux + Windows runners didn't trip React's
nested-update guard within 80ms while local Windows did. The guard
firing is timing-dependent (depends on how the event loop happens to
interleave commits and setInterval) — what's deterministic is that the
broken pattern keeps re-measuring forever. Assert that instead: stable
layouts converge in a few renders, the bad pattern compounds without
bound.

* test(tui): loosen oscillation count threshold for slow CI runners

Windows CI cleared 22-26 renders in 80ms versus hundreds locally;
the 10× multiplier kept tripping. Switch to an absolute floor of 15
— still far above the stable-layout converge (≤ 3) and proves the
"doesn't converge" property without coupling to runner speed.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-26 19:45:34 -07:00
paradoxSCH 28990641b1 fix(acp): propagate structured error details through loop, transcript, ACP
When a proxy/LB idle timeout cuts an SSE stream, undici throws "terminated" as a bare string and ACP headless clients lose the context needed to decide whether to retry. Thread structured `errorDetail` ({name, message, code, phase, retryable, recoverable}) through client → loop → kernel events → transcript → ACP so the failure cause survives every hop.

All changes are additive: `LoopEvent.errorDetail`, `TranscriptRecord.errorDetail`, `SessionPromptResult.transcriptPath`, and `SessionUpdate.metadata.error` are all optional, so existing clients that ignore unknown fields keep working.

Closes #1884.
2026-05-26 19:45:11 -07:00
YHHandreasonix 0c837dda91 chore(dashboard): type the new server-snapshot helpers (#1961)
Replace any with named response shapes (ServerSessionsResponse /
ServerOverviewResponse) so mapSessionItem / emitSessionsSnapshot /
emitOverviewSnapshot read off real fields instead of structural any.
Callers still get any from apiFetch — narrowing happens at the boundary.

Co-authored-by: reasonix <[email protected]>
2026-05-26 19:36:45 -07:00
TaoMu 964ee5a0e6 fix dashboard session refresh (#1917) 2026-05-26 19:33:04 -07:00
Hermito Kattandhermitokatt 04694eae65 feat(web): add Brave Search API as web_search backend (#1916)
Add Brave Search (api.search.brave.com) as a new web search engine
alongside bing, searxng, metaso, tavily, perplexity, exa, and ollama.

Core:
- searchBrave(): GET api.search.brave.com/res/v1/web/search with
  X-Subscription-Token header, i18n error handling, 1–20 topK clamp
- loadBraveApiKey(): env chain BRAVE_SEARCH_API_KEY → BRAVE_API_KEY
  → config.braveApiKey; exported in public API
- /search-engine brave CLI handler + /se alias; argCompleter updated
- braveApiKey config field; webSearchEngine('brave') dispatcher

Integration:
- Desktop: dropdown option, API key credential UI, protocol types,
  SettingsPatch, webSearchApiKeys prefix, i18n labels (en/de/zh-CN)
- Dashboard: dropdown option, protocol type, i18n label (en)
- Server: VALID_WEB_SEARCH_ENGINES, error message, cast type
- Desktop RPC: incoming/outgoing types, collectWebSearchApiKeyPrefixes,
  persistence guard + save logic for braveApiKey

i18n:
- 5 new webErrors keys: braveMissingKey, braveUnauthorized,
  braveRateLimit, braveServerError, braveParseError
- Handler labels: usageBrave, switchedBraveNote (EN, de, zh-CN, ru)
- |brave added to ~40 engine suggestion strings (EN, de, zh-CN)
- de.ts additionally adds |ollama to suggestion strings (pre-existing
  omission in German locale)

Tests:
- web-tools.test.ts: 6 searchBrave test cases (happy path, 401/403,
  429, parse error, empty results, topK clamp)
- config.test.ts: 4 loadBraveApiKey tests (env chain fallback)
- config.test.ts: brave in webSearchEngine round-trip array
- public-api.test.ts: loadBraveApiKey in snapshot

Closes #1763

Co-authored-by: hermitokatt <[email protected]>
2026-05-26 19:25:08 -07:00
YHHandreasonix 65dbd122f6 docs(readme): add AtomGit star badge (#1954)
Co-authored-by: reasonix <[email protected]>
2026-05-26 19:07:04 -07:00
Bernardxu123andBernardxu123 89235a20bb fix(desktop): normalize path separators for Windows file opening (#1914)
* fix(desktop): normalize path separators for Windows file opening

Markdown file references and context panel paths use forward slashes from AI output, causing cmd.exe to fail on Windows. Normalize to backslash when the workspace path indicates Windows.

Closes #1869

* chore: retrigger CI

---------

Co-authored-by: Bernardxu123 <[email protected]>
2026-05-26 19:00:24 -07:00
enderzcx 2ee56515da feat(desktop): expose workspace switcher in sidebar (#1908) 2026-05-26 18:47:23 -07:00
CVEngineerandwufengfan e83a106c64 fix(desktop): allow clearing base URL and editor (#1905)
Co-authored-by: wufengfan <[email protected]>
2026-05-26 18:44:10 -07:00
YHHandreasonix 182d41eb87 refactor(ink): inline ink runtime as workspace package (#1947)
Was a stub `src/vendor/ink/index.d.ts` plus an empty `index.js` (the
real runtime arrived obfuscated, source unreachable). Moving the
source into `packages/ink` as a workspace dep gives consumers real
types and makes the runtime editable in-repo.

API migrations forced by the new stricter types:
- Text/Box `color` is a typed union now (`ansi:<name>` | `#hex` |
  `rgb()` | `ansi256()`); bare ansi names get the `ansi:` prefix
- `dimColor` → `dim`; Text's bold/dim discriminated union relaxed
  so the two can coexist (terminals collapse them anyway)
- `ThemeTokens` fields typed as `Color` so theme-derived values flow
  through helpers / props without manual casts

Markdown OSC 8 hyperlinks switch from `<Transform>` (a shim that
silently ignored the `transform` prop, breaking Ctrl+click hyperlinks)
to `<Link url>`, which uses ink's existing `ink-link` plumbing.

Dropped dead shims: `Transform`, `useAnimation` (zero callers after
the markdown rewrite).

Comment-policy pass on the new package: stripped 638 multi-line
essay block comments to 1-line summaries to match the repo rules.

Co-authored-by: reasonix <[email protected]>
2026-05-26 18:08:51 -07:00
reasonix 25988eb175 fix(desktop): bump app bundle version to 0.52.0 2026-05-26 04:09:20 -07:00
reasonix 171e10f69e chore(dsnix): bump to 0.52.0 + sync lockfile 2026-05-26 03:46:33 -07:00
reasonix cb3e6189d6 chore(release): 0.52.0 2026-05-26 03:39:52 -07:00
enderzcx 651779ae93 feat(web): add Ollama cloud search backend (#1900)
* feat(web): add Ollama cloud search backend

* fix(web): format Ollama web tool test
2026-05-26 03:24:26 -07:00
Tianjiu c634341883 fix(desktop): separate ctx scrollbar from resize handle (#1856)
* fix(desktop): separate ctx scrollbar from resize handle

* fix(desktop): add gap beside context scrollbar

* fix(desktop): push thread scrollbar away from right edge (#1695 #1793)

* fix(desktop): push thread scrollbar away from right edge

* thread scrollbar hover: thumb expands from 4px to 8px on hover

* fix(desktop): un-pin during scrollbar drag to stop rubber-banding
2026-05-26 03:20:02 -07:00
enderzcx 0510873412 fix(desktop): bump app bundle version to 0.51.0 (#1891) 2026-05-26 02:57:36 -07:00
YHHandreasonix f2e0b1f6d8 refactor(desktop): harden clipboard image paste path (#1897)
Three small improvements on top of #1889:

- JS: read the clipboard Blob with file.arrayBuffer() and pass the
  ArrayBuffer straight through invoke(). Drops the FileReader → data URL
  → atob → Uint8Array → number[] chain, which inflated the IPC payload
  by ~5x on every paste.
- Rust: tighten extension validation. sanitize_image_extension now
  requires the post-trim/lowercase value to be 1–8 ASCII alphanumeric
  chars, otherwise falls back to "png". Belts-and-braces vs a renderer
  passing "png/../../escape".
- Rust: spawn a startup thread that removes files older than 24h from
  the pasted-images temp dir so it doesn't grow unbounded between OS
  temp wipes.

Includes Rust unit tests for the extension sanitizer.

Co-authored-by: reasonix <[email protected]>
2026-05-26 02:57:10 -07:00
enderzcx 421fff2263 feat(desktop): import local AI app sessions (#1894) 2026-05-26 02:55:38 -07:00
enderzcx 660dcc662f feat(desktop): paste clipboard images into composer (#1889) 2026-05-26 02:39:00 -07:00
Odisseyandreasonix a44bbae18a i18n: add Russian (ru) language pack (#1879)
* i18n: add Russian (ru) language pack

* fix(i18n): apply biome format, add ru wizard label + slash.language description

- biome format wrapped long ru.ts lines (CI was red on format).
- LANGUAGE_LABELS in Wizard.tsx now includes ru ("Русский"), restoring the Record<LanguageCode, string> exhaustiveness.
- slash.language entry needs an explicit description override since the spread doesn't carry it through TypeScript's narrowing.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-26 02:35:35 -07:00
enderzcx de0d1fb19a fix(client): parse Ollama usage token fields (#1886) 2026-05-26 02:30:21 -07:00
YHHandreasonix 210781f88d refactor(desktop): pull JumpBar dot styling back into a helper (#1892)
Inlining the size/background ternary into JSX made the render branch
hard to read. Move it back into dotProps() — same matrix:
hover/near > active > base, hover transition-delay only when hovered.

Co-authored-by: reasonix <[email protected]>
2026-05-26 02:22:34 -07:00
Hermito Kattandhermitokatt c86383934a fix(i18n): German corrections — capitalization, terminology, and coverage (#1878)
- Capitalize 30 German sentence starts per orthographic rules
  ('noch keine ...' -> 'Noch keine ...', etc.)
- Fix terminology: Systemeingabeaufforderung -> System-Prompt,
  Denkaufwand -> Reasoning-Effort, Veranstaltungstyp -> Ereignistyp,
  Tree-Kill -> Prozessbaum
- Translate ratelimited -> Rate-Limit erreicht across all 6 search engines
- Add missing ui.yoloHint translation (was falling back to English)
- Plural format {e}/{en} -> (e)/(en) for i18n compatibility
- Fix typo: fortgesetzen -> fortgesetzt, awd -> CWD, du -> Du
- Tighten descriptions: zwangsweise/Stelle diesen String vor -> Praefix fuer
- Add German slash-command description coverage test
- Use z.B. consistently instead of z. B.
  (42 insertions, 38 deletions)

Co-authored-by: hermitokatt <[email protected]>
2026-05-26 02:17:20 -07:00
CVEngineerandwufengfan 33d03ef099 feat(desktop): highlight active message bar in JumpBar; follow latest message (#1813)
Co-authored-by: wufengfan <[email protected]>
2026-05-26 02:13:47 -07:00
YHHandreasonix c14d8a3a8a feat(config): accept DEEPSEEK_API_BASE_URL as a base-URL env alias (#1876) (#1887)
Users running an internal Nginx proxy in front of api.deepseek.com pick
the env name from the OPENAI_BASE_URL convention and try
DEEPSEEK_API_BASE_URL. We only read DEEPSEEK_BASE_URL, so the proxy
silently isn't used. Accept the alias (canonical name still wins on
conflict) so the obvious env name works.

Co-authored-by: reasonix <[email protected]>
2026-05-26 01:59:11 -07:00
YHHandreasonix dc1779e8da feat(proxy): accept proxy.url from config.json (#1881)
Desktop and GUI users on Windows often can't reliably set HTTPS_PROXY
for the spawned Reasonix process — env vars set in Clash, system
settings, or PowerShell profiles don't always reach a Tauri-launched
child. The proxy plumbing is in place; only the env-var source path
was wired.

Add cfg.proxy.url as a first-class source: it wins over HTTPS_PROXY /
HTTP_PROXY / ALL_PROXY when set, normalizes bare host:port like the env
path, and tags the install result with source: "config" | "env" so
/doctor can show which one is active.

Closes #1868

Co-authored-by: reasonix <[email protected]>
2026-05-26 01:46:39 -07:00
YHHandreasonix 585a243b0b fix(desktop): unit-suffix CSS vars during drag, use canonical thread-width helper (#1875)
The drag-path direct DOM writes for --thread-max-width and --composer-max-width
were unitless strings, so the consumer max-width var() fell back to its default
for one frame per mousemove until React's render caught up — a visible flicker
that cancels the perf win.

- Suffix both writes with `px`
- Replace the duplicated formula with the existing getThreadMaxWidth helper
- contain-intrinsic-size: 1px 100px → auto 100px, so the browser remembers
  the actual rendered size instead of pinning offscreen messages to 1px wide

Co-authored-by: reasonix <[email protected]>
2026-05-26 01:24:59 -07:00
TaoMu c96a524742 fix(tui): guard ASCII IME enter commits (#1874) 2026-05-26 01:17:56 -07:00
TaoMu 71065f34bd test(jobs): wait for tail output before asserting (#1872) 2026-05-26 01:17:17 -07:00
TaoMu 74e138c472 test(tui): cover prompt incremental repaint (#1873) 2026-05-26 01:13:49 -07:00
YHHandreasonix f226ad2508 fix(dashboard): sync TUI and Web modal state (#1831) (#1866)
The web client was POSTing to /api/modal with {id, response}, but
the server expects /api/modal/resolve with {kind, choice, text?},
so every web confirmation returned 405 and the TUI's pauseGate
stayed open. The reverse direction was broken too: the tauri-bridge
SSE handler ignored modal-down, leaving the web modal up after the
terminal user resolved it.

- bridge confirm/choice/plan/checkpoint/revision RPCs through the
  real /modal/resolve endpoint with the right body shape
- carry "shell" vs "path" on confirm_response so the server can
  route to the matching TUI handler
- emit \$modal_dismissed from modal-down so the web reducer clears
  pending lists when another surface resolves first

Co-authored-by: reasonix <[email protected]>
2026-05-26 01:10:09 -07:00
CVEngineerandwufengfan df0507fbb8 perf(desktop): direct DOM CSS vars during sidebar drag, disable CSS transition, content-visibility for messages (#1812)
* fix(desktop): drag perf — direct DOM + disable CSS transition during drag; fix test types

* fix: restore export on applyIncoming (test imports it)

* fix: revert resize handle to 5px (1px was ungrabbable per review)

---------

Co-authored-by: wufengfan <[email protected]>
2026-05-26 01:05:06 -07:00
YHHandreasonix 85668b0130 fix(desktop): float update banner above the thread (#1865)
The update notice rendered at the top of the thread scroll area, so a
restored session with messages would scroll the banner out of view and
the user had to open a new tab to see it. Lift it out of `thread-inner`
into a fixed-position overlay anchored to the viewport, rendered once at
the App root instead of per tab.

Co-authored-by: reasonix <[email protected]>
2026-05-26 01:02:31 -07:00
Tianjiu 6a2b78f5c0 fix(ui): highlight active plan edit mode (#1863) 2026-05-26 00:49:53 -07:00
YHHandreasonix 31acf1e232 fix(desktop): add search-engine API key UI + intercept slash command (#1867)
The desktop client had no UI for filling in API keys for web search
engines that require them (metaso, tavily, perplexity, exa) or for the
SearXNG endpoint. The `/search-engine` slash command also fell through
to the chat input instead of being handled locally.

- General settings page now renders a credentials row below the engine
  selector that adapts to the chosen engine: a URL input for searxng,
  a password input + signup link + clear button for the API-keyed
  engines. Existing keys show a masked prefix.
- Server-side `settings_save` accepts `webSearchEndpoint`,
  `metasoApiKey`, `tavilyApiKey`, `perplexityApiKey`, `exaApiKey`
  (with `null` to clear); `$settings` reports the masked prefix for
  each so the UI can render a "set" indicator without leaking the key.
- `/search-engine` and `/se` now open the general settings page
  instead of being forwarded as user text.

Closes #1844

Co-authored-by: reasonix <[email protected]>
2026-05-26 00:28:17 -07:00
YHHandreasonix da25e37b1d chore(desktop): split kind/length effects in @ popup, drop redundant guard (#1859)
- Reset activeIdx to 0 on popup.kind switch (slash <-> at) instead of
  clamping into the new popup's range, which left users on an arbitrary
  row after switching.
- Drop the lastActiveRef guard in Popup — useEffect([activeIdx]) already
  reruns only on change.
- Move the conditional class join out of a template literal so the
  non-at branch doesn't leave a trailing space.
- Remove unused EditMode / ReasoningEffort type imports from the test.

Co-authored-by: reasonix <[email protected]>
2026-05-26 00:14:07 -07:00
YHHandreasonix d0643eebb2 chore(i18n): drop zh-CN /qq argsHint, fall back to source (#1862)
Follow-up to #1857. The argsHint values are BNF over English command
keywords (`connect [appId appSecret [sandbox]] | status | disconnect`)
— there's nothing to translate. The merged zh-CN override dropped the
sub-argument structure and inlined an EN+CN gloss (`connect连接`)
that read awkwardly.

Removing the key lets zh-CN fall back to spec.argsHint, so users see
the same syntax as English and don't lose the appId/appSecret hint.

Co-authored-by: reasonix <[email protected]>
2026-05-26 00:13:53 -07:00
YHHandreasonix ab04288066 fix(mcp): drop LSP-only fields from initialize params (#1861)
`rootUri` and `workspaceFolders` are LSP concepts, not part of the MCP
2024-11-05 `initialize` schema. Strict servers (e.g. ida-pro-mcp on the
official Python SDK with pydantic) reject the request with
`Invalid params: unexpected parameters: ['workspaceFolders', 'rootUri']`.

Workspace roots are already exchanged the correct way: the client
advertises the `roots` capability, and the server requests them via
`roots/list` (already wired up).

Co-authored-by: reasonix <[email protected]>
2026-05-26 00:05:38 -07:00
Bernardxu123andBernardxu123 fd32b56535 fix(i18n): /qq command hints now use i18n, argsHint and description localized to Chinese (#1857)
Co-authored-by: Bernardxu123 <[email protected]>
2026-05-26 00:00:26 -07:00
Tianjiu 4163217919 fix(desktop): keep mention popup stable (#1850) 2026-05-25 23:53:01 -07:00
YHHandreasonix cb4b2be38e chore(ui): drop undo-context re-exports through useEditHistory (#1854)
The test was the only consumer routing through useEditHistory.ts —
import undo-context.ts directly. Six re-exports gone, no behavior
change.

Co-authored-by: reasonix <[email protected]>
2026-05-25 23:49:35 -07:00
TaoMu f10267038b fix(tui): preserve undo state in session context (#1810) 2026-05-25 23:22:45 -07:00
YHHandreasonix 6b015c0559 refactor(ink): extract vendored renderer to @esengine/ink + tui polish (#1847)
Stage 1 of #1829: ship the renderer as a separate private package
instead of vendoring at src/ink/. Picks up the tui-polish work that
landed alongside it (visuals, pulse animations, ghosting fix).

## Renderer extraction

- src/ink/ removed; renderer lives in esengine/reasonix-ink (private)
- package.json: ink dep points at @esengine/ink (file: tarball for
  local dev; swap to npm: alias post-publish); react-reconciler and
  yoga-layout added as direct deps so they hoist to a single top-level
  instance (single React dispatcher, single reconciler instance);
  17 transitive runtime deps and 3 @types removed
- tsconfig: ink path-map removed
- vitest.config: ink alias removed
- tests/helpers/ink-stdio.ts: text() uses strip-ansi + restores
  CSI N C cursor-forward back to literal spaces
- tests/helpers/ink-test.ts (new): replaces ink-testing-library;
  lastFrame() joins all chunks instead of returning only the most
  recent write() — required because the diff-based renderer emits
  multi-chunk frames per commit
- 15 test files migrated; 13 obsolete UI snapshot tests removed
  (5 files)

## Visual alignment

- UserCard: single full-row backgroundColor=MESSAGE_BG.user fill
- ApprovalCard: round border with borderLeft/borderRight=false
- PromptInput: round border (top + bottom only) + borderText with
  plan / history / mode / model
- ReasoningCard: italic + dim header; collapsed state appends
  (ctrl-o to expand) hint
- theme/tokens: new MESSAGE_BG section (user/bash/selected) for all
  5 themes
- CardHeader: glyph prop accepts string | ReactElement

## Pulse animation system

- Pulse primitive: generic frame cycler with DIAMOND/SQUARE/
  TRIANGLE/CIRCLE/HEX presets aligned to existing brand glyphs
- Wired into 7 cards + 4 LiveRows components
- ThinkingRow + OngoingToolRow: live elapsed time + output token
  count + t/s rate
- ticker.tsx: useTick / useSlowTick switched from
  useAnimationTimer (keepAlive=false) to useAnimationFrame
  (keepAlive=true) so the shared clock actually starts

## Bug fixes

- PromptInput: removed systemCursorSync — the out-of-band CSI CUP
  write desynced the renderer's screen model under sync-output mode,
  causing residual glyphs starting at the 3rd typed character
- Wizard: TextInput now sourced from ink (drop-in TextInput exposed
  by the package); ink-text-input dep removed

## Tests

279 files, 3722 passed, 11 skipped, 0 failed.

Refs #1829 (Stage 1).

Co-authored-by: reasonix <[email protected]>
2026-05-25 22:42:17 -07:00
reasonix 6c8650d2be fix(desktop): wire memoryDetail/onReadMemory through test fixtures
State requires `memoryDetail` since #1779 added it; test initial-state
builder and ContextPanel render fixture were missing the field. Root
`verify` doesn't run desktop tsc, so this surfaced only in the desktop
release matrix build for desktop-v0.51.0.
2026-05-25 09:42:15 -07:00
reasonix 98dec4a401 chore(dsnix): bump to 0.51.0 + sync lockfile 2026-05-25 09:31:23 -07:00
reasonix 8a56afd571 chore(release): 0.51.0 2026-05-25 09:22:58 -07:00
YHHandreasonix 10dccac9b0 chore(deps): bump ink 7.0.2 -> 7.0.4, drop patch-package machinery (#1791 follow-up) (#1809)
Ink 7.0.3 (Static stale-output fix, useBoxMetrics ref) and 7.0.4
(shared resize listener, useEffectEvent dependency fix) ship upstream
fixes worth picking up. None of them include the overflow-frame
flicker reduction from #1791, so dropping the patch reverts that
optimisation — accepting the regression in exchange for not carrying
a local fork of ink's render path.

- bump `ink` ^7.0.2 -> ^7.0.4 in package.json + lockfile
- delete `patches/ink+7.0.2.patch`
- drop `patch-package` from dependencies (and its transitive tree)
- remove the patch-package execSync from scripts/postinstall.mjs

Co-authored-by: reasonix <[email protected]>
2026-05-25 09:05:28 -07:00
YHHandreasonix 95f5df07e6 fix(tui): demote stuck running plan step on turn end (#1784) (#1806)
advanceActivePlanSteps marks the next non-settled step as `running`
whenever a step completes, but nothing demotes it when the agent stops
calling mark_step_complete mid-plan. The card then displays "step N in
progress" indefinitely even though the turn has ended.

Dispatch a new plan.idle event from the App.tsx turn-end finally; the
reducer maps running→queued on active plans. Multi-turn flows are
unaffected — the next mark_step_complete re-advances the marker via
advanceActivePlanSteps.

Closes #1784

Co-authored-by: reasonix <[email protected]>
2026-05-25 08:51:12 -07:00
YHHandreasonix 25eefbbdec perf(search): bypass regex worker for literal patterns (#1748) (#1804)
`search_content` was dispatching every pattern that parsed as a valid
RegExp — including plain literals like `new ToolRegistry` — to the
worker thread, paying full postMessage round-trip per file. On
multi-file scans this dominates wall time; one report hit the 120s
walk deadline on a query grep finished in 0.72s.

Detect patterns with no regex metacharacters and route them to the
existing main-thread `text.includes` branch. Also short-circuit
files whose buffer doesn't contain the substring so we skip the
line-split for misses.

Local bench (full repo, ~20K walkable files): root no-glob literal
2255 ms → 809 ms (2.8x); src/ *.ts literal 132 ms → 74 ms (1.8x).
Regex patterns still take the worker path; all 3731 tests pass.

Closes #1748

Co-authored-by: reasonix <[email protected]>
2026-05-25 08:50:20 -07:00
TaoMu c53535946a fix(loop): trim long-session retained payloads (#1799)
* fix(loop): prune stale plain reasoning

* fix(loop): trim retained tool-call payloads
2026-05-25 08:49:03 -07:00
YHHandreasonix 3218500736 fix(stdin): refuse ESC-less CSI recovery when followed by typed text (#1771) (#1800)
Typing or pasting tokens like `[DEBUG]` was being parsed as a stripped
left-arrow (`[D`) plus the leftover `EBUG]`. The ConPTY ESC-strip rescue
now only fires when the matched tail ends the chunk or chains into
another control byte; printable letters/digits after the tail mark the
input as user text.

Closes #1771

Co-authored-by: reasonix <[email protected]>
2026-05-25 08:12:18 -07:00
YHHandreasonix 65e833f6e8 fix(tui): hide max effort on non-DeepSeek endpoints (#1798)
#1657 dropped the preset abstraction and exposed reasoning effort
directly, keeping `max` as a DeepSeek-only extension that users opt
into knowing standard OpenAI / vLLM / Azure reject it with 400. The
TUI still advertised `max` everywhere — `/effort` argsHint, slash-arg
picker, ModelPicker effort rows, /effort handler — even when the
active endpoint was a third-party host that can't accept it. Users
on those endpoints saw `max` in every suggestion and reported it as
a preset-era leftover (#1794).

Endpoint-aware filter: when `loop.client.baseUrl` is not api.deepseek.com,
drop `max` from the choices the TUI surfaces:
  - `/effort` argsHint and argCompleter (autocomplete + arg picker)
  - ModelPicker effort rows
  - /effort handler's accept list + status / usage message
  - new `effortUsageNoMax` i18n key (EN / zh-CN / de) so the error
    on bad input doesn't itself name `max` as an option

`max` stays available on DeepSeek endpoints — that's the design from
#1657, just no longer visible where it would 400.

Fixes #1794.

Co-authored-by: reasonix <[email protected]>
2026-05-25 08:10:22 -07:00
Hermito Kattandhermitokatt 4a6be7dc50 fix(i18n): German (de) corrections and completions (#1797)
- /language de: add 'de' to argCompleter and argsHint in commands.ts
- Capitalization: fix ~90 lowercase German sentence starts throughout de.ts
- Backfill: add ~95 new translations for CLI hints, slash command
  descriptions, MCP marketplace text, and UI labels previously falling
  back to English via ...EN spreads
- Native-speaker review: multiple rounds of corrections to word choice,
  terminology consistency, and formatting
- Preserve T1anjiu's existing translations and the ...EN fallback pattern

Co-authored-by: hermitokatt <[email protected]>
2026-05-25 08:08:57 -07:00
YHHandreasonix b224b7bf42 fix(cli): replace BEL with ST on Windows stdout so cmd doesn't beep on every frame (#1795)
Windows cmd.exe rings the system bell when stdout receives a bare BEL
byte (0x07). Third-party libraries (ansi-escapes, terminal-link, ink's
own deps) embed BEL as an OSC sequence terminator — perfectly valid VT
syntax that Windows Terminal, ConEmu, and every *nix terminal handle
silently — but cmd treats the unrecognized OSC start as junk and beeps
on the BEL byte. Visible on #1786 as the system bell ringing on every
assistant response chunk.

Patching every emitter individually doesn't scale (the constants are
scattered across several deps). Instead wrap process.stdout/stderr on
Windows and rewrite `\x07` → `\x1b\` at the boundary. ST and BEL are
both valid OSC terminators per VT 510, so legitimate OSC sequences keep
working (Windows Terminal / ConEmu / conhost-with-VT accept either);
stray BEL outside an OSC turns into ESC backslash which cmd treats as
junk text instead of a bell.

Installed via side-effect import at the very top of src/cli/index.ts,
after node-version-guard and heap-limit-launch (which may re-exec) so
the wrap is in the process that actually runs the CLI.

Fixes #1786.

Co-authored-by: reasonix <[email protected]>
2026-05-25 07:26:59 -07:00
Bernardxu123andBernardxu123 a0a723109a fix(ui): patch Ink to reduce terminal flicker on overflow frames (#1791)
* fix(ui): patch Ink to reduce terminal flicker on overflow frames

* fix: move patch-package to dependencies for global install

---------

Co-authored-by: Bernardxu123 <[email protected]>
2026-05-25 06:51:54 -07:00
Tianjiu c4641ec7f0 fix(desktop): recalc thread width on resize (#1789) 2026-05-25 06:35:07 -07:00
YHHandreasonix ff705c1dc6 fix(dashboard): replay active modal on SSE connect so web confirm gate isn't stuck (#1770) (#1792)
When the assistant triggered a `run_command` shell confirmation in the
web dashboard, the tool card stayed on "running" indefinitely with no
Approve / Deny button. CLI and Tauri desktop worked fine.

Root cause: `modal-up` is broadcast at the moment the gate opens. If the
dashboard tab wasn't already connected to /api/events at that instant,
the event went out to zero subscribers and the dashboard never learned a
modal was up. The SSE handler already snapshotted `busy-change` for this
exact "client connects mid-state" reason; `getActiveModal` existed on
DashboardContext for the same purpose ("Snapshot of any modal currently
up (for SSE clients that connect mid-modal)") but was never wired into
the SSE stream.

Send the modal snapshot alongside `busy-change` on every new SSE
connection. Verified the new regression test fails on main (the read
times out at 5s — no modal-up ever arrives) and passes with the snapshot.

Fixes #1770.

Co-authored-by: reasonix <[email protected]>
2026-05-25 06:34:08 -07:00
ForeverYoungPp a65f1062fc fix: normalizeSemanticEmbeddingUserConfig drops timeoutMs field (#1788) 2026-05-25 06:33:33 -07:00
arata 48cc3f4cb2 fix(docs): polish docs responsive spacing (#1785)
Remove the duplicated Download item from the top nav while keeping the desktop download CTA, and add priority-based nav tiers so lower-value anchor links progressively disappear at narrower widths.

Tighten layout details across the landing page: prevent nav/version/language text wrapping, clean up hero metric alignment, and normalize spacing in feature cards, roadmap columns, and config bullet rows.
2026-05-25 06:32:09 -07:00
YHHandreasonix d85f9387a6 fix(dashboard): expose new memory browser to web mode (followup to #1766) (#1779)
PR #1766 reshaped MemoryEntryInfo to include `kind`/`path`/`type` and
added a `memory_read` RPC for the detail view, but only wired the
Tauri-desktop emit path. The web dashboard still mapped the old
response shape via #1775's loader, so:
  - frontend uses `m.path` as React key + onClick arg → undefined keys,
    duplicate-key warnings
  - clicking a memory row dispatches `memory_read` with undefined path →
    bridge default case warns, nothing happens

Plug the gap:
  - new GET /api/memory/entries → MemoryEntryInfo[] via the shared
    collectMemoryEntriesForWorkspace, identical to desktop's $memory shape
  - new GET /api/memory/read?path=... → MemoryEntryDetail via
    readMemoryEntryDetail
  - bridge: loadAndEmitMemory hits /memory/entries and emits entries
    verbatim; new memory_read RPC handler fetches /memory/read and emits
    $memory_detail

Co-authored-by: reasonix <[email protected]>
2026-05-25 04:33:17 -07:00
TaoMu 5165a54658 fix(tui): coalesce prompt cursor sync (#1768) 2026-05-25 04:28:16 -07:00
Tianjiu 582b440967 feat(i18n): add German (de) language support (#1773)
* fix(desktop): keep ctx resize handle off scrollbar

* feat(i18n): add German locale support

* feat(i18n): complete German translation for all three UI layers

Translate every untranslated key in src/i18n/de.ts (CLI/TUI),
dashboard/src/i18n/de.ts (web dashboard), and desktop/src/i18n/de.ts
(desktop app) — ~3,200 new lines of native German strings.

Covers all sections: common, cli/commands, ui, slash (all 42
commands), wizard, planFlow, errors, handlers, statusBar, editMode,
composer, path/shell/edit confirm, pickers, webErrors (all search
engines), marketplace, cards, labels, skills help, shortcuts, and
every other i18n namespace.

Terminology is consistent across all three layers. Translations
follow German typographic conventions (guillemets for quotation,
correct apostrophes, natural compound nouns).
2026-05-25 04:20:20 -07:00
TaoMu 3d9a0de38b fix: restore scroll and memory visibility (#1766) 2026-05-25 04:18:31 -07:00
YHHandreasonix b9ecc4efe0 fix(config): EXDEV fallback for atomic write on OneDrive / NTFS reparse points (#1778)
writeConfig and rewriteSession used renameSync for the final atomic swap.
On Windows, when ~/.reasonix sits inside a OneDrive-managed folder or
crosses a junction / reparse-point boundary, rename refuses with EXDEV
(-4037) even though the tmp file and destination are siblings. The CLI
then crashed on every launch trying to mint the dashboard token (#1738).

Extract a shared atomicWriteSync helper that catches EXDEV from rename
and falls back to copyFileSync + unlink. Atomicity is lost on that path
(no other safe option on Windows), but a working write beats the
launch-blocking error.

Fixes #1738.

Co-authored-by: reasonix <[email protected]>
2026-05-25 04:08:07 -07:00
YHHandreasonix 95913f1cf6 fix(dashboard): bootstrap mcp/skills/memory panels in web mode (#1775)
The CLI-hosted web dashboard's `serverInit()` only fetched settings,
sessions, and overview on mount — it never called `/api/mcp`,
`/api/skills`, or `/api/memory`. The reducer waits for `$mcp_specs`,
`$skills`, `$memory` events that the bridge had no path to emit, so
those three panels rendered empty and stayed empty until the user
triggered settings_save or restarted. Desktop (Tauri) shipped a
matching emit triple in desktop.ts on tab open — web mode regressed
on the equivalent.

Add three loaders that GET the REST endpoints and map their response
shape onto the dashboard event shape (`McpSpecInfo` / `SkillInfo` /
`MemoryEntryInfo`), fire them from `serverInit()`, and wire the
`mcp_specs_get` / `skills_get` / `memory_get` RPC commands so the
panels' refresh buttons also work. `mcp_specs_add` / `_remove` now
also re-emit the live list after the POST.

Fixes #1715.

Co-authored-by: reasonix <[email protected]>
2026-05-25 03:50:17 -07:00
YHHandreasonix 7df5d8b58e fix(loop): drain steer queue on /new + /cwd so prior intent doesn't leak (#1774)
clearLog() and switchWorkspace() left _steerQueue untouched. If the user
had typed a steer mid-turn in the prior session and the queue still held
text at the time of /new, the very next step() would pop it, wrap it in
the steer scaffolding, and ship it to DeepSeek as a user message — the
first prompt after /new would silently carry a fragment of the previous
conversation's intent.

Fixes #1707.

Co-authored-by: reasonix <[email protected]>
2026-05-25 03:44:36 -07:00
Bernardxu123andBernardxu123 1d6a30d11b fix(desktop): fix right panel scrollbar not draggable (#1765)
The scrollbar in the context panel was being overlapped by the resize
handle when the window was resized to trigger responsive breakpoints.

- Added padding-right: 20px to .ctx-body to create space for scrollbar
- Increased scrollbar width from 4px to 8px for better usability

Fixes #1695

Co-authored-by: Bernardxu123 <[email protected]>
2026-05-25 03:03:22 -07:00
Tianjiu 603600bf02 fix(desktop): verify bundled node TCC inheritance (#1749) 2026-05-25 03:00:59 -07:00
YHHandreasonix e6a175e138 perf(ui): index-backed mutateCard + O(n) plan.drop + elide cursor (#1769)
Three hot-path fixes in the cards reducer measured to cut per-event
cost dramatically on long sessions:

- mutateCard now does a Map<CardId, number> lookup instead of a full
  state.cards.findIndex scan. Each streaming/reasoning/tool chunk used
  to walk the whole transcript; on a 2000-card session that cost
  ~12.7ms per 1000 chunks, now ~1.7ms (86% faster).
- plan.drop replaces a .map() containing a .slice(i+1).some() with a
  single backward scan to find the last active plan, then one in-place
  patch. 1000-card session: ~65ms -> ~0.34ms per drop (99.5% faster).
- elideOldCardContent now starts from a persistent elideCursor that
  advances past stubbed and immutable-kind cards, so repeated appends
  no longer re-scan all prior elision-zone cards. 500 appends on a
  1000-card session: ~3.1ms -> ~1.6ms (48% faster).

The cardIndex map is mutated in place on append; structural changes
(session.fork, session.reset) rebuild it. tools/bench-reducer-hotpath.mjs
keeps the inlined old vs new comparison as a regression check.

All 3694 existing reducer / memory-budget / hydrate tests pass.

Co-authored-by: reasonix <[email protected]>
2026-05-25 02:55:13 -07:00
TaoMu bb9415a457 fix(tui): avoid Apple Terminal mouse reset crash (#1764) 2026-05-25 02:39:43 -07:00
TaoMu 5f661b932a Show desktop startup failures (#1759) 2026-05-25 02:30:31 -07:00
TaoMu bfef3c3e73 Guard CLI startup on unsupported Node (#1757) 2026-05-25 02:30:03 -07:00
Bernardxu123andBernardxu123 eb065799c7 fix(tui): sync system cursor for IME candidate window alignment (#1754)
* fix(tui): sync system cursor for IME candidate window alignment

* fix: satisfy biome exhaustive-deps lint rule

---------

Co-authored-by: Bernardxu123 <[email protected]>
2026-05-25 02:27:12 -07:00
Ethanandannon dba8a49e88 feat(ui): configurable cost display currency with click-to-toggle (#1710)
* feat(ui): configurable cost display currency with click-to-toggle

Add a costCurrency config option and a clickable status bar that
lets users toggle between USD and CNY display on the fly.

- costCurrency config field seeds the display preference on startup
- costDisplayCurrency state field drives reactive re-rendering
- mouse click on bottom status bar toggles USD/CNY instantly
- Wallet balance always stays in its own currency unaffected
- Module-level fallback ensures formatCost works without state
- All existing tests pass unchanged

* feat(ui): toast notification on cost-currency toggle

When clicking the status bar to swap display currency, a brief
toast confirms the change: 'Costs: $USD' or 'Costs: ¥CNY'.

* feat(ui): reactive UsageCard follows live cost currency toggle

* feat(ui): visual click hint on cost pill

Replace the generic '▸' arrow with '↻' (cycle arrow) on the
turn-cost pill to hint the display currency is clickable/togglable.

* style: trim comments to comply with comment policy

* refactor(ui): drop click-to-toggle, keep config-only cost currency

Per PR review feedback:
- Remove click-to-toggle interaction (hit-area too broad)
- Remove toast notification on toggle
- Remove module-level _defaultCostCurrency global (redundant with session state)
- Revert ↻ → ▸ glyph
- Keep costCurrency config field + costDisplayCurrency state plumbing
- Keep reactive UsageCard via useAgentState
- Keep precedence chain costDisplayCurrency ?? balanceCurrency in callers

---------

Co-authored-by: annon <[email protected]>
2026-05-25 02:23:04 -07:00
YHHandreasonix a683234090 perf(ui): progressive Static mount — session restore 4.5s → 430ms first paint (#1761)
Session restore on a 1000-turn workspace was committing every card to
ink's <Static> in one synchronous render — measured 4.5s of frozen
terminal. The cards array can't shrink (Static is append-only by
design), but it can grow incrementally.

useProgressiveBacklog snapshots the initial cards.length on first
non-empty render, releases the first INITIAL_BATCH (30) immediately,
then drains the rest in PROGRESSIVE_BATCH-sized chunks via setImmediate.
Each batch yields back to the event loop between renders so keystroke
input stays responsive during the catch-up.

Cards added AFTER the snapshot bypass the gate — new live turns
(streaming responses, mid-turn tool output) appear instantly even when
the historical backlog is still draining.

Cards added DURING the drain (rare — user typing during restore) are
deferred until the snapshot drains. This trades a small UX hiccup
(brief delay before a new card appears) for Static's append-only
contract — out-of-order commits would re-render history in the wrong
chronological order.

Measured (probe-render-large-session.mts, heavy 1KB card bodies):

  backlog | before    | after     | speedup
  --------+-----------+-----------+--------
    100   |   738 ms  |   399 ms  |  1.9x
    250   |  1440 ms  |   419 ms  |  3.4x
    500   |  2520 ms  |   411 ms  |  6.1x
   1000   |  4520 ms  |   429 ms  | 10.5x

First paint is now ~constant regardless of backlog size. Total mount
time is unchanged (the rest still has to render), but the user sees
the most-recent context immediately and can type while the rest
catches up.

Tests cover three invariants:
- small backlog (<= INITIAL_BATCH) releases everything immediately
- large backlog releases initial batch first, drains the rest in
  batches across setImmediate ticks
- chronological order is preserved across the drain (no card index
  reorder when more batches release)

Co-authored-by: reasonix <[email protected]>
2026-05-25 02:20:46 -07:00
TaoMu 0fe5041ccd fix(ui): reduce Windows terminal repaint cadence (#1750) 2026-05-25 02:00:16 -07:00
douglarek b5096d1278 feat(memory): support CLAUDE.md and ~/.claude/CLAUDE.md for Claude Code migration (#1694)
Add CLAUDE.md and .claude/CLAUDE.md to the project memory file search order (after REASONIX.md, before AGENTS.md) and read ~/.claude/CLAUDE.md as a global memory source alongside ~/.reasonix/REASONIX.md.

Changes:
- src/memory/project.ts: PROJECT_MEMORY_FILES now includes .claude/CLAUDE.md and CLAUDE.md
- src/memory/user.ts: add readGlobalClaudeMemory() and applyGlobalClaudeMemory() for ~/.claude/CLAUDE.md
- src/memory/user.ts: wire applyGlobalClaudeMemory into applyMemoryStack
- tests/project-memory.test.ts: add tests for CLAUDE.md priority and fallback
2026-05-25 01:57:13 -07:00
dacec354 618ed36b2a feat: preserve full tool results when truncated (#1615) (#1703)
* feat: preserve full tool results when truncated (#1615)

When a tool result exceeds the per-call token cap, auto-save the full
content to .reasonix/truncated-results/ and embed the file path in the
truncation marker so the model can read_file the complete result.

Changes:
- src/tools/truncated-result-saver.ts — new file: save + cleanup + skip
- src/mcp/registry.ts — truncateForModel/truncateForModelByTokens gain
  optional extraNote param appended to marker when truncated
- src/tools.ts — ToolDefinition.skipTruncationSave; dispatch save logic
- src/loop.ts — pass hookCwd as rootDir to dispatch
- src/tools/filesystem.ts — skipTruncationSave on read-only fs tools
- .gitignore — ignore .reasonix/truncated-results/
- tests/truncated-result-saver.test.ts — coverage for all paths

* fix: fix truncated-result-saver for CI + review — double .reasonix, Windows paths, comment policy, cleanup wiring, drop RFC doc

- Fix double .reasonix in fallback path (TRUNCATED_DIR had prefix, storageDir added it again)
- Normalize returned paths to forward slashes for cross-platform consistency
- Shrink multi-line block comments to one line (comment policy violation)
- Export storageDir for test consumption
- Align test file-creation patterns with project conventions (mkdtempSync, HOME redirect, top-level imports)
- Strengthen cleanupOldResults assertions to actually verify file removal
- Call cleanupOldResults(rootDir) at start of saveTruncatedResult so old files don't accumulate
- Drop .reasonix/rfc-1615.md — design docs don't belong in source tree
2026-05-25 01:54:02 -07:00
YHHandreasonix 3781904b11 test(edit-blocks): skip symlink-preservation test when symlink syscall fails (#1753)
Windows hosts without Developer Mode (or non-elevated shells) can't
create symlinks via fs.symlinkSync — the call throws EPERM. The new
symlink-preservation test added in b40a569 didn't guard for this and
failed for any contributor running `npm test` on a default Windows
setup.

Match the pattern already used in tests/at-mentions.test.ts: probe
symlinkSync once, set symlinksWorked = false on throw, return early
so the test passes vacuously on hosts that lack the capability.

Co-authored-by: reasonix <[email protected]>
2026-05-25 01:50:47 -07:00
YHHandreasonix aa539fdd55 chore(ui): render-trace readStats API + large-session probe with real numbers (#1751)
Extend src/cli/ui/render-trace.ts:
- readRenderTraceStats(): snapshot the counters directly instead of
  scraping stderr. Lets probes report numbers in their own format and
  decide when to read.
- resetRenderTraceStats(): clear between scenarios.

scripts/probe-render-large-session.mts runs two scenarios:
- (a) Mount of N cards via ink-testing-library — proxies session-restore
  paint cost. Real Ink reconciliation + yoga layout.
- (b) K parent-state ticks via react-test-renderer (which fires effects;
  ink-testing-library does not) — measures whether memo'd children
  actually skip work when an unrelated parent state changes.

Numbers from this probe answer the App.tsx-split debate:

  cards | (a) mount  | per-card  | (b) StaticCardStream re-renders / 200 ticks
  ------+------------+-----------+--------------------------------------------
   100  |   738 ms   | 7.38 ms   |     1
   250  |  1441 ms   | 5.76 ms   |     1
   500  |  2521 ms   | 5.04 ms   |     1
  1000  |  4517 ms   | 4.52 ms   |     1

Memo holds — parent re-rendering 200x cascades to ZERO StaticCardStream
re-renders. The "log append re-renders the whole App" hypothesis is
disproved. The real perceived cost is the ~5 ms/card initial-paint
hit on session restore (4.5 s freeze for a 1000-turn resume), which an
App.tsx structural split would not fix — that's a Static-priming /
progressive-render problem, not a memo problem.

react-test-renderer added to devDependencies for the probe (ink-testing-
library doesn't fire useEffect, so it can't be used to count renders).

Co-authored-by: reasonix <[email protected]>
2026-05-25 01:34:46 -07:00
YHHandreasonix e826984619 chore(ui): add per-component render trace gated by REASONIX_TRACE_RENDERS (#1747)
Before further App.tsx-shaped optimization (the "split into InputArea /
MessageList / Sidebar" advice), wire in cheap measurement so we cut
where the cost actually is, not where intuition points.

src/cli/ui/render-trace.ts
- useRenderTrace(name): noop when the flag is off (hooks still called
  unconditionally for rules-of-hooks compliance). When on, records
  count + avg/max ms per name and prints a one-line summary per second
  per traced component to stderr.

Wired into the four components most likely to dominate:
- AppInner — confirms whether App is actually re-rendering per keystroke
- ComposerArea — the input region; expected to re-render on every char
- StaticCardStream — the message list; should stay flat once history is
  in <Static>
- LiveActivityArea — the activity row; expected to tick on busy state

Usage:
  REASONIX_TRACE_RENDERS=1 reasonix code
  # then type — see lines like:
  # [render-trace] AppInner             42 renders · 7.0/s · avg 0.84ms · max 3.2ms
  # [render-trace] ComposerArea         42 renders · 7.0/s · avg 0.43ms · max 1.8ms

The existing memo boundaries (ComposerArea, StaticCardStream,
LiveActivityArea, CardRenderer all React.memo'd) make the obvious
optimization moves already in place. This lets a future PR target the
component whose number is actually too high, rather than splitting
App.tsx on principle and hoping.

Co-authored-by: reasonix <[email protected]>
2026-05-25 01:16:20 -07:00
YHHandreasonix 558d471113 refactor(core): extract LruCache + TtlLruCache + lazy(), replace ad-hoc duplicates (#1746)
Five hot-path call sites had grown the same patterns inline — Map +
delete-on-hit + size-cap eviction (tokenizer BPE + content caches),
and `await import()` scattered through tool handler bodies. Collapse
into three primitives.

src/core/lru.ts
- LruCache<K,V>: insertion-ordered Map with promote-on-hit + oldest-out.
- TtlLruCache<K,V>: LruCache wrapping {value, expiresAt}; stale read
  is a miss. One place owns the eviction rules.

src/core/lazy.ts
- lazy<T>(loader): memoize the loader's promise. First call starts the
  work, every later call returns the same pending/resolved promise.
  Failures stay sticky — the caller sees the same error on retry.

Call-site simplification

- src/tokenizer.ts — bpeCache + contentTokenCache: 14-line ad-hoc LRU
  → `cache.get/set`. Also exposes `warmupTokenizer()` so callers don't
  have to know how to trigger the gunzip + Map build.
- src/gitignore.ts — adds 5s TTL cache around loadGitignoreAt /
  loadGitignoreAtSync. Per-keystroke @-pickers walk every ancestor
  .gitignore; cached lookups inside the same tick are now free.
- src/at-mentions.ts — listDirectory result cached with 5s TTL, 64-entry
  cap. Same-tick keystroke bursts collapse to one readdir.
- src/tools/code-query.ts — `await import()` inside the fn bodies
  replaced with module-level `lazy(() => import(...))` factories.
  Lazy intent is declared at top of file; the handler body just awaits.
  Sessions that never call code_query no longer pay the multi-MB
  web-tree-sitter Emscripten runtime.
- src/code-query/grammar-map.ts (new) — extension→language detection
  as pure data, no web-tree-sitter dependency. parser.ts re-exports for
  back-compat.
- src/cli/ui/App.tsx — `setTimeout(warmupTokenizer, 0)` after first
  paint, amortizing the ~100ms / 35MB cold load while the user reads
  the banner.

Tests: tests/lru.test.ts (10 cases covering LRU promotion, eviction,
clear, TTL fresh/stale/refresh) and tests/lazy.test.ts (single-load,
identity, sticky rejection). All 3653 existing tests still pass.

Co-authored-by: reasonix <[email protected]>
2026-05-25 00:58:50 -07:00
Bernardxu123andBernardxu123 38d88abcc0 fix(tui): show plan mode [PLAN]/[计划] badge in input bar footer (#1742)
* feat(acp): support REASONIX_ACP_SYSTEM_APPEND env var for system prompt injection

* fix(tui): show plan mode badge in input bar footer

The ModeStatusBar component was imported but never rendered after the
ComposerArea extraction (#565 Phase 2), making plan mode invisible to
users running editMode=yolo + plan=on.

Fix: render the plan mode indicator as a localized [PLAN]/[计划] badge
directly inside the input bar's footer, to the right of the mode label.
No extra row, no layout shift — uses the existing i18n key
statsPanel.modePlan for automatic Chinese/English display.

Closes #1502

---------

Co-authored-by: Bernardxu123 <[email protected]>
2026-05-25 00:34:09 -07:00
Kagura 8a7c83435e fix(tui): implement forward-delete for Delete key (#1735)
The Delete key was treated identically to Backspace (both deleted the
character before the cursor). Split the handling so Delete removes the
character at the cursor position (forward-delete) while Backspace
continues to remove the character before the cursor.

Fixes #1728
2026-05-25 00:29:39 -07:00
YHHandreasonix 81249ec581 perf(markdown): memoize CodeBlock syntax highlight (#1743)
cli-highlight's tokenization ran on every CodeBlock render — multi-block
assistant replies were re-highlighting on every parent re-render
(slow-tick during streaming, theme change, terminal resize). Wrap the
highlight + entity decode in useMemo keyed on the token text and lang
so it only fires when the block content actually changes.

Settled cards already render via Static and skip re-render entirely;
this matters for the live streaming card where the parent re-renders
on every chunk while the code block content is stable.

Co-authored-by: reasonix <[email protected]>
2026-05-25 00:22:39 -07:00
YHHandreasonix d6aeafa984 perf(tokenizer): cache BPE + per-content bounded counts, fast-path truncate (#1741)
Three changes that together cut per-turn CPU ~57% and steady-state RSS
~22% in the 200-turn fakeFetch probe (rss=256MB→181MB at log.len=800).

- bpeEncode: in-place splice instead of slice/spread rebuild on every
  merge, plus 8K-entry LRU cache. Repetitive tool output (padded
  payloads, identifiers in code) re-encodes the same byte-level chunks
  thousands of times per session; the cache caps that at ~400KB.

- estimateConversationTokens: drop the full formatDeepSeekPrompt
  rebuild + single bounded tokenize. Sum per-message bounded counts
  with a fixed template overhead, gated by a content-string-keyed
  4K-entry LRU. Same entry tokenizes once over its lifetime instead of
  once per turn. The estimate drives fold thresholds (50%/75% of ctx)
  where ±5% slop is harmless.

- truncateForModelByTokens: sample-based fast path. For inputs in the
  [maxTokens, maxTokens*4] range the old code unconditionally tokenized
  the full string (37% total CPU on the 200-turn probe). Now we use a
  2KB-sample estimate with a 1.15x safety margin; only borderline cases
  fall through to a precise tokenize.

Regression origin: #1642/#1646 collapsed the conditional preflight into
an unconditional estimateTurnStart that runs every turn, surfacing the
underlying tokenizer cost. The tokenizer itself has always been a
pure-TS BPE port without caching — fine when called rarely, expensive
when called on every turn against growing logs.

Also adds three probes that reproduce + measure:
- scripts/probe-mem-leak.mts — drives CacheFirstLoop through N turns
  with fakeFetch, samples RSS/heap/log
- scripts/probe-jobs-leak.mts — confirms JobRegistry's MAX_COMPLETED_JOBS
  cap actually evicts
- scripts/analyze-cpuprofile.mjs — flat self/total roll-up for any
  .cpuprofile produced by --cpu-prof or `reasonix code --profile`

Co-authored-by: reasonix <[email protected]>
2026-05-25 00:11:38 -07:00
TaoMu 1768cf4d58 fix(desktop): contain long mcp specs (#1736) 2026-05-24 23:32:08 -07:00
CVEngineerandwufengfan 5290275857 feat(desktop): add JumpBar message navigation dock (#1729)
Co-authored-by: wufengfan <[email protected]>
2026-05-24 23:26:16 -07:00
TaoMu f00f238b66 fix(desktop): sync slash setting commands (#1724) 2026-05-24 23:23:13 -07:00
Bernardxu123andBernardxu123 395ffdb6d5 feat(acp): support REASONIX_ACP_SYSTEM_APPEND env var for system prompt injection (#1737)
Co-authored-by: Bernardxu123 <[email protected]>
2026-05-24 23:18:29 -07:00
YHHandreasonix 3a05934db0 refactor(loop): extract stream aggregator + chunked dispatch from step() (#1739)
step() was a ~560-line method doing model streaming, repair, context
decisions, and tool dispatch in one body. Pull two independent pieces
into their own files so future edits to either don't risk the other.

- src/loop/streaming.ts — async generator that owns the chunk loop,
  toolCallDelta buffering, and readiness counting. Caller uses yield*
  to splice events and capture {content, reasoning, toolCalls, usage}.
- src/loop/dispatch.ts — chunked parallel dispatch with per-call
  inflight tracking, tool_start/tool yields, and rate-limit warning.
  Env reads (REASONIX_TOOL_DISPATCH, REASONIX_PARALLEL_MAX) move with
  the dispatch logic.

loop.ts: 1174 -> 1022 lines. Public API of CacheFirstLoop unchanged.
Full test suite green (3625 / 12 skipped).

Co-authored-by: reasonix <[email protected]>
2026-05-24 23:00:08 -07:00
CVEngineer 6b5bf7ac2f feat(desktop): add JumpBar message navigation dock (#1725)
Floating right-edge dock that surfaces the last 10 user messages as
horizontal bars: hover to magnify + preview text, click to scroll the
thread to that turn. Bar widths step 32 / 20 / 14 / 12 px around the
hover point with staggered cubic-bezier transitions for the wave feel.

Adds data-turn on each user message in the thread so JumpBar can
querySelector the scroll target.

Thanks @CVEngineer66 for the original idea and the polished landing.
2026-05-24 20:58:49 -07:00
TaoMu 5467670146 fix(tui): drop ESC-stripped legacy X10 mouse reports (#1723)
#1637 handled the canonical ESC [ M ... legacy mouse form, but Windows
ConPTY can strip the leading ESC, leaving a bare [M... that fell back to
the printable-text path. A burst of these (e.g. holding the mouse over
the terminal) would keep growing composer state — residual of #1598.

Add tryEscapelessLegacyMouse() parallel to the existing escapeless SGR
mouse path: recognize [M + 3 payload bytes as a fixed 5-byte packet,
consume it, surface no input event. Wire it into both the main parse
loop and the printable-run terminator so a stray [M mid-typing breaks
the run instead of swallowing the report into text.

Tests cover: single packet dropped, 1000-packet flood produces no
events, printable text either side of a report stays intact.

Trade-off: literal user input of "[M" followed by exactly 3 chars is
also swallowed — but [M is not a realistic typing pattern and the
flood-protection win is worth it.

Thanks @GTC2080 for tracking down the residual case.
2026-05-24 20:25:03 -07:00
YoungDan-hero b40a5692ec fix(code): make edit-block writes crash-safe via atomic rename (#1721)
applyEditBlock previously truncated the target file in-place before
writing the new bytes. A crash between truncate and writeAll could leave
the file empty, partially rewritten, or NUL-padded — high blast radius
since model-generated edits all flow through this path.

Switch to the standard atomic-replace pattern:
1. Resolve realpath so edits through a symlink modify the referent.
2. Write the new bytes to a sibling .tmp file with wx (exclusive create).
3. chmod to preserve the original mode under umask.
4. fsync the temp fd, close, then rename onto the target.
5. Best-effort fsync the parent directory.
6. On any error, unlink the temp file so we don't leak .tmp clutter.

Also fixes a latent short-write bug in the previous loop: `if (n <= 0)
break;` would silently truncate on a partial write. The new writeAllSync
throws instead.

One semantic change worth noting: atomic-rename breaks hardlinks. The
edited path now points at the new file while any other hardlink to the
old inode is left pointing at the pre-edit content. Industry-standard
tradeoff (same as the editor in your shell does), but a behavior change
versus the old in-place truncate.

New test covers the symlink-preservation case (edit through link →
link still exists → referent file has new content).

Thanks @YoungDan-hero for the careful patch.
2026-05-24 20:20:20 -07:00
FriendsHL 7d49171a28 docs: sync ARCHITECTURE.md and benchmark pricing to match code (#1720)
Three stale-doc fixes:

- ARCHITECTURE.md §4.3 — replace removed /pro single-turn arming with the
  current /model flash|pro + settings.json model selection. Note the
  removal in 0.50.0 (#1657, #1630).
- ARCHITECTURE.md §4.4 — replace the never-existed FAILURE_ESCALATION_THRESHOLD
  counter with the actual <<<NEEDS_PRO>>> model self-report mechanism. No
  failure-counter; purely LLM-initiated, no-op on pro tier.
- benchmarks/real-world-cache/README.md — fix 10× pricing error in
  v4-flash cache-hit ($0.028 → $0.0028) and entirely wrong v4-pro pricing
  ($0.139/1.667/3.333 → $0.003625/0.435/0.87). Recalculated cost tables;
  headline 99.82% hit ratio unchanged, savings now correctly show ~97.7%
  (flash) / ~98.9% (pro).

Thanks @FriendsHL for catching this — the benchmark pricing in particular
is the public cache-first defense link, the old numbers would have been
embarrassing.
2026-05-24 20:00:13 -07:00
bluedevil947 e4259d3543 fix(index): skip .devenv and .direnv in glob, search, and directory_tree (#1706)
.devenv/ and .direnv/ in Nix/devenv-based projects hold cargo registries,
uv caches, and Nix store symlinks — easily 1.5 GB and tens of thousands of
files. A bare glob("**/package.json") would stat every entry under those
trees before matching. Add them to DEFAULT_INDEX_EXCLUDES.dirs alongside
node_modules / .git / target / etc; include_deps:true still lets users
opt in.

Thanks @bluedevil947 for the patch.
2026-05-24 19:19:51 -07:00
paradoxSCH 5af0f1661f fix(plan): archive completed plans instead of letting them persist (#1700)
On session resume, if a stored plan has every step completed, archive it
and skip restoration so users don't see a stale "resumed plan" card on
every open. Same archive-instead-of-persist behavior triggers mid-session
when the final step completes.

Thanks @paradoxSCH for the patch + tests.

Closes #1355
2026-05-24 19:00:06 -07:00
YHHandreasonix 0df09f32cb fix(loop): don't show DeepSeek-specific 5xx hints when host is not DeepSeek (#1716)
A user on a corporate intranet running a local model saw "DeepSeek 服务不可用 …
查看状态页 https://status.deepseek.com" after their local model returned a 500.
Three hardcoded assumptions stacked up: the client error prefix is always
"DeepSeek N:", is5xxError matched it regardless of provider, and the probe
hit /user/balance (a DS-only endpoint that 404s on ollama/vLLM/etc.), so the
unreachable branch fired and pointed users at DeepSeek's status page for an
outage that didn't exist.

Allow-list api.deepseek.com explicitly. For any other host, skip the
/user/balance probe and use provider-neutral wording that surfaces the
configured host instead of "DeepSeek".

Co-authored-by: reasonix <[email protected]>
2026-05-24 18:56:20 -07:00
YHHandreasonix 56f3ffe968 fix(desktop): self-heal stale model id + expose plan as 4th editMode (#1699)
* fix(api): self-heal stale model id in settings GET

#1663 added `loadModel()` which maps unsupported model ids (e.g. v3
`deepseek-chat` / `deepseek-reasoner` left over in a user's config)
to `DEFAULT_MODEL` when no custom endpoint is set. The CLI uses it,
but the dashboard/desktop `/settings` GET endpoint still returned the
raw `cfg.model`, so users upgrading from a pre-v4 config saw their
desktop landing on the stale id and the model picker rendering it as
"custom" — looks like the app forgot which model it's on.

Route the GET through `loadModel(ctx.configPath)` so the same
self-heal applies on the wire. Custom-baseUrl users still keep their
custom ids unchanged (`loadModel` trusts non-empty `baseUrl`).

* feat(plan): expose plan as a 4th editMode in API + desktop + dashboard UIs

#1681 wired `editMode === "plan"` into the ToolRegistry dispatch gate
so plan mode is now a persisted state, not a slash-only toggle. The
desktop and dashboard surfaces never grew a control to set it though —
the settings API + edit-mode API still rejected "plan" with a 400, the
composer's ModeSwitch hardcoded review/auto/yolo, and the settings
page tri-state only listed the same three. Net: plan mode was
reachable from the CLI's `/plan` slash and direct config edits, but
not from either GUI surface.

Admit "plan" in both validation sets (`src/server/api/settings.ts`,
`src/server/api/edit-mode.ts`) and the `/mode` slash completer
(`src/cli/ui/slash/commands.ts`). Add a "plan" entry to MODE_INFO in
both composers with `I.list` and a planHint string. Add "plan" to the
settings page button maps. Extend the local `EditMode` string unions
in `desktop/src/{protocol,App,ui/composer}.tsx` and the dashboard
equivalents so TS accepts it everywhere.

i18n: add `editMode.plan` + `editMode.planHint` (and `planDesc` on
desktop) in both en + zh-CN for each surface.

---------

Co-authored-by: reasonix <[email protected]>
2026-05-24 08:33:35 -07:00
TaoMu 38e6d2cf8c fix(desktop): restore aborted prompt drafts (#1693) 2026-05-24 08:30:00 -07:00
TaoMu d2f62d4561 fix(tui): drop stale copy startup hint (#1692) 2026-05-24 08:28:12 -07:00
CVEngineerandwufengfan 03fb6582ed feat(desktop): show collapsible file-change summary after each assistant turn (#1691)
Co-authored-by: wufengfan <[email protected]>
2026-05-24 08:19:38 -07:00
reasonix 5e757bd372 chore(dsnix): bump to 0.50.1 + sync lockfile 2026-05-24 07:39:33 -07:00
reasonix 2e7bdec3a8 fix(install): use execSync, not execFileSync, in postinstall
Node 18+ refuses to execFile `npm.cmd` directly on Windows
(CVE-2024-27980 mitigation), throwing `spawnSync npm.cmd EINVAL`.
Failed every Windows CI matrix entry on main after 0.50.1.

`execSync` routes through cmd.exe / sh, matching what the original
inline `&&`-chained postinstall did before #1584's regression.

End-user installs of [email protected].1 are unaffected: postinstall
short-circuits via the `dashboard/package.json` existence check before
reaching the broken execFile call. Only repo checkouts at tag v0.50.1
hit the bug, which is dev-side only.
2026-05-24 07:33:07 -07:00
reasonix 05b47947a1 fix(install): postinstall no-ops outside a git checkout (0.50.1)
#1584 wired `npm --prefix dashboard ci && npm --prefix desktop ci` into
root `postinstall` to fix CI's missing workspace deps. But the
published tarball ships only build artifacts under `dashboard/`
(index.html, app.css, dist/) and no `desktop/` at all, so end-user
`npx [email protected].0 code` failed during the dashboard `npm ci` and
the bin never ran — the only visible output was npm's cleanup
warnings on the half-extracted tree.

Move the workspace install into `scripts/postinstall.mjs`, which
exits 0 when `dashboard/package.json` is absent (i.e. when running
from the tarball, not the repo). Ship the script via `files:`.

Deprecate 0.50.0 on npm so existing installers see the warning.
2026-05-24 07:25:20 -07:00
reasonix 4188ddf007 chore(dsnix): bump to 0.50.0 + sync lockfile 2026-05-24 07:08:48 -07:00
568 changed files with 62041 additions and 4915 deletions

No files matched your search

+6
View File
@@ -1,4 +1,10 @@
DEEPSEEK_API_KEY=sk-your-key-here
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
+22 -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,19 @@ 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
working-directory: desktop
@@ -172,3 +175,13 @@ jobs:
releaseDraft: true
prerelease: false
args: --target ${{ matrix.target.tauri }}
- name: Verify app Node inherits TCC grants (macOS)
if: startsWith(matrix.target.os, 'macos-')
working-directory: desktop
shell: bash
run: |
node_bin=$(find src-tauri/target -path '*/release/bundle/macos/*.app/Contents/Resources/node' -type f | head -n 1)
test -n "$node_bin"
codesign -vvv --strict "$node_bin"
codesign -d --entitlements :- "$node_bin" 2>&1 | grep -q "com.apple.security.inherit"
+5
View File
@@ -25,12 +25,15 @@ 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.
.reasonix/semantic/
.reasonix/sessions/
.reasonix/semantic-skip
.reasonix/truncated-results/
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
scripts/.cm-entry.mjs
# Personal bun lockfile — project uses npm officially.
@@ -48,3 +51,5 @@ desktop/src-tauri/binaries/
# Design mock-up reference (local, not part of repo)
.design-ref/
*.cpuprofile
.probe-snapshots/
+147
View File
@@ -3,6 +3,153 @@
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.52.0] — 2026-05-26
**Ink renderer in-tree as `@esengine/ink`.** The vendored Ink fork now
lives as a workspace package (#1847), unblocking TUI-specific
renderer tweaks (focus, IME, Static append) without forking upstream.
TUI polish lands on top: guard ASCII IME enter commits so JP/CN IME
atomic commits don't double-submit (#1874), preserve undo state across
session context switches (#1810), incremental repaint coverage
(#1873).
**Desktop — clipboard image paste.** Paste screenshots / copied images
directly into the composer (#1889); paste path hardened against
oversized + non-image clipboard payloads (#1897). Sessions exported by
other local AI apps can be imported into Reasonix (#1894).
**Desktop — JumpBar polish + drag perf.** JumpBar now highlights the
active message bar and follows the latest one as the thread grows
(#1813); dot styling pulled into a shared helper (#1892). Sidebar drag
writes CSS vars directly to the DOM with `content-visibility` on
messages (#1812) and unit-suffixes the vars to dodge a Chromium
recompute storm (#1875).
**Desktop — smaller fixes.**
- Search-engine API key UI + slash command interception (#1867)
- Mention `@` popup stays stable while filtering (#1850); split
kind/length effects in the popup (#1859)
- Update banner floats above the thread instead of pushing layout
(#1865)
- `memoryDetail` / `onReadMemory` wired through test fixtures so the
desktop memory browser is exercisable in unit tests
**Config — base URL + proxy.** `DEEPSEEK_API_BASE_URL` accepted as an
env alias to the existing base-url override (#1876, #1887), and
`proxy.url` is now a first-class field in `config.json` (#1881). MCP
`initialize` no longer leaks LSP-only fields that some servers reject
(#1861).
**Providers.** Ollama usage parsing tolerates the field-name variants
recent Ollama versions emit (#1886).
**i18n.**
- Russian (`ru`) language pack added (#1879)
- German corrections — capitalization, terminology, coverage (#1878)
- `/qq` command hints + argsHint localized; English fallback for
zh-CN (#1857, #1862)
**Dashboard / UI.**
- Sync TUI and Web modal state so confirm gate doesn't desync between
surfaces (#1831, #1866)
- Highlight active plan edit mode (#1863)
- `useEditHistory` no longer re-exports undo-context through itself
(#1854)
## [0.51.0] — 2026-05-25
**First paint — session restore 4.5s → 430ms.** Progressive `<Static>`
mount streams scrollback in chunks instead of mounting the full history
synchronously (#1761). Combined with index-backed `mutateCard` + O(n)
`plan.drop` + cursor elide (#1769) and the bump to `[email protected]` (which
ships the flicker fix we'd been carrying as a patch — patch-package
machinery is gone, #1809), large session restores no longer block the
event loop on startup.
**Perf — tokenizer + markdown + search.** BPE tokenizer caches its
table + bounds per-content counts with a fast-path truncate (#1741),
so long sessions don't re-tokenize the world on every turn. CodeBlock
syntax highlight is memoized (#1743). Literal-pattern search bypasses
the regex worker entirely (#1748). Windows terminal repaint cadence is
throttled (#1750) and a BEL → ST swap on Windows stdout stops `cmd.exe`
from beeping on every render frame (#1795).
**Cross-platform robustness.**
- `EXDEV` fallback for atomic config writes on OneDrive / NTFS reparse
points (#1778) — config saves no longer fail on Windows + cloud-sync
- Apple Terminal mouse-reset crash guarded (#1764); legacy X10 mouse
reports without ESC are dropped (#1723); `ESC`-less CSI recovery is
refused when followed by typed text (#1771)
- CLI startup gates on unsupported Node versions with a clear error
instead of crashing in the middle of bootstrap (#1757)
- Desktop bundled Node inherits parent TCC grants on macOS (#1749) so
file / microphone permission prompts attach to the app, not the helper
- Crash-safe atomic rename for edit-block writes (#1721); completed
plans archive instead of persisting across sessions (#1700)
- `.devenv` and `.direnv` are skipped by glob / search / directory_tree (#1706)
**Memory — CLAUDE.md import for Claude Code migration.** Loading
`CLAUDE.md` and `~/.claude/CLAUDE.md` alongside the existing
`REASONIX.md` (#1694) lets Claude Code users carry their project /
user memory directly into Reasonix without rewriting it.
**i18n — German.** Full German (`de`) locale added (#1773) with a
follow-up correction pass (#1797).
**Desktop polish.**
- JumpBar message navigation dock (#1725, #1729)
- File-change summary card after each assistant turn (#1691)
- Self-heal stale model id; expose plan as 4th editMode (#1699)
- Restore aborted prompt drafts on relaunch (#1693)
- Recalc thread width on resize (#1789); right panel scrollbar
draggable (#1765); contain long MCP spec strings (#1736)
- Sync slash setting commands across surfaces (#1724)
- Surface desktop startup failures instead of silent exit (#1759)
**TUI polish.**
- Plan-mode `[PLAN]` / `[计划]` badge in input bar (#1742)
- Forward-delete for `Delete` key (#1735)
- IME candidate window cursor alignment (#1754)
- Coalesce prompt cursor sync to reduce flicker (#1768)
- Demote stuck `running` plan step on turn end (#1784)
- Hide `max` effort on non-DeepSeek endpoints (#1798)
- Drop stale "press Cmd-C to copy" startup hint (#1692)
**Dashboard / web mode.**
- Bootstrap mcp / skills / memory panels in web mode (#1775)
- Expose new memory browser to web (#1779)
- Replay active modal on SSE connect so confirm gate isn't stuck (#1770)
- Restore scroll and memory visibility (#1766)
- Docs responsive spacing polish (#1785)
**Smaller fixes.**
- Preserve full tool results when truncated (#1615)
- Trim long-session retained payloads to bound memory growth (#1799)
- Drain steer queue on `/new` + `/cwd` so prior intent doesn't leak (#1774)
- Suppress DeepSeek-specific 5xx hints when host isn't DeepSeek (#1716)
- `normalizeSemanticEmbeddingUserConfig` preserves `timeoutMs` (#1788)
- `REASONIX_ACP_SYSTEM_APPEND` env var for system-prompt injection (#1737)
- Configurable cost-display currency with click-to-toggle (#1710)
**Refactors / internals.**
- Extract `LruCache` + `TtlLruCache` + `lazy()` helpers from ad-hoc
duplicates (#1746)
- Split stream aggregator + chunked dispatch out of `step()` (#1739)
- Per-component render trace gated by `REASONIX_TRACE_RENDERS` (#1747);
`readStats` API on render trace + real-numbers large-session probe (#1751)
## [0.50.1] — 2026-05-24
**Install fix — `postinstall` no longer crashes on `npx`.** The
`postinstall` hook added in 0.50.0 (#1584) ran `npm ci` inside
`dashboard/` and `desktop/` to set up the dev workspace, but those
directories aren't shipped in the published tarball. Result: `npx
reasonix@0.50.0 code` failed during install with a `dashboard/package.json
not found` error and the CLI never started. `postinstall` now delegates
to `scripts/postinstall.mjs`, which exits 0 when `dashboard/package.json`
is missing (i.e. anywhere except a git checkout). 0.50.0 is deprecated
on npm.
## [0.50.0] — 2026-05-24
**Desktop dashboard — unified on the CLI-hosted React surface.** The
+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>
+10 -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>
@@ -25,6 +32,7 @@
<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>
@@ -179,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/>
@@ -216,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.
+9 -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>
@@ -25,6 +32,7 @@
<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>
@@ -135,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/>
+11 -10
View File
@@ -20,22 +20,22 @@ Used with permission, anonymized.
## Cost — using the prices Reasonix bills against (`src/telemetry/stats.ts`)
USD per 1M tokens — `inputCacheHit / inputCacheMiss / output`:
- `deepseek-v4-flash` — `0.028 / 0.139 / 0.278`
- `deepseek-v4-pro` — `0.139 / 1.667 / 3.333`
- `deepseek-v4-flash` — `0.0028 / 0.14 / 0.28`
- `deepseek-v4-pro` — `0.003625 / 0.435 / 0.87`
Assuming **v4-flash** (the project default):
| | This user (99.82% hit) | Same workload, **0% cache** |
|---|---:|---:|
| Cache-hit input | $12.18 | — |
| Cache-miss input | $0.11 | $60.58 |
| Cache-hit input | $1.22 | — |
| Cache-miss input | $0.11 | $61.01 |
| Output | $0.05 | $0.05 |
| **Total / day** | **$12.34** | **$60.63** |
| **Total / day** | **$1.38** | **$61.06** |
→ Cache saved this user **$48.29**, or **~80%** off the un-cached baseline, on a single day.
→ Cache saved this user **$59.68**, or **~97.7%** off the un-cached baseline, on a single day.
On **v4-pro** (5× the prefix-cache discount) the same workload would cost
**~$62.35** vs **~$727.08** without cache — a **~91% saving**.
On **v4-pro** (6.25% of uncached input cost) the same workload would cost
**~$2.07** vs **~$189.73** without cache — a **~98.9%** saving.
## "Isn't that just DeepSeek's prefix cache?"
@@ -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.
+8 -1
View File
@@ -24,6 +24,13 @@
}
},
"files": {
"ignore": ["dist", "node_modules", "coverage", "*.d.ts", "dashboard/codemirror.js"]
"ignore": [
"dist",
"node_modules",
"coverage",
"*.d.ts",
"dashboard/codemirror.js",
"packages/ink/**"
]
}
}
+155 -11
View File
@@ -6,7 +6,7 @@ import { isWebRuntime } from "./lib/tauri-bridge";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { CommandPalette, Toast, buildCommands, useCommandPalette } from "./CommandPalette";
import { WorkspaceProvider } from "./Markdown";
import { getLang, setLang, t, useLang } from "./i18n";
import { getLang, getLangLabel, getSupportedLangs, setLang, t, useLang } from "./i18n";
import { I } from "./icons";
import {
FONT_FAMILY,
@@ -32,6 +32,7 @@ import type {
IncomingEvent,
JobInfo,
McpSpecInfo,
MemoryDetail,
MemoryEntryInfo,
OutgoingCommand,
PlanVerdict,
@@ -68,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 =
@@ -179,11 +181,12 @@ export type SessionInfo = {
messageCount: number;
mtime: string;
summary?: string;
workspaceStatus?: "matched" | "legacy_missing_meta";
};
export type Settings = {
reasoningEffort: "low" | "medium" | "high" | "max";
editMode: "review" | "auto" | "yolo";
editMode: "review" | "auto" | "yolo" | "plan";
budgetUsd: number | null;
baseUrl?: string;
apiKeyPrefix?: string;
@@ -191,7 +194,20 @@ export type Settings = {
recentWorkspaces: string[];
model: string;
editor?: string;
webSearchEngine?: "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa";
webSearchEngine?:
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa"
| "brave"
| "ollama";
webSearchApiKeys?: {
baidu?: string;
};
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
version: string;
@@ -238,6 +254,7 @@ type State = {
/** Files the agent has read or modified this session — paths as the tool args provided them. */
sessionFiles: SessionFile[];
memory: MemoryEntryInfo[];
memoryDetail: MemoryDetail | null;
jobs: JobInfo[];
/** Live "skill running" indicator — set when a `skill_run` RPC dispatches, cleared on `$turn_complete`. */
activeSkill: SkillOrigin | null;
@@ -303,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 {
@@ -467,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"]);
@@ -545,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];
@@ -646,6 +676,23 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
},
],
};
case "$modal_dismissed":
switch (ev.kind) {
case "shell":
return { ...state, pendingConfirms: [] };
case "path":
return { ...state, pendingPathAccess: [] };
case "choice":
return { ...state, pendingChoices: [] };
case "plan":
return { ...state, pendingPlans: [] };
case "checkpoint":
return { ...state, pendingCheckpoints: [] };
case "revision":
return { ...state, pendingRevisions: [] };
default:
return state;
}
case "$step_completed": {
if (!state.activePlan) return state;
const stepIds = new Set(state.activePlan.completedStepIds);
@@ -666,8 +713,49 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
pendingCheckpoints: [],
pendingRevisions: [],
};
case "$sessions":
return { ...state, sessions: ev.items };
case "$sessions": {
const hasCurrent = "currentSession" in ev;
const nextCurrent =
ev.currentSession === null ? undefined : (ev.currentSession ?? state.currentSession);
const currentChanged = hasCurrent && nextCurrent !== state.currentSession;
return {
...state,
sessions: ev.items,
currentSession: nextCurrent,
messages: currentChanged ? [] : state.messages,
pendingConfirms: currentChanged ? [] : state.pendingConfirms,
pendingPathAccess: currentChanged ? [] : state.pendingPathAccess,
pendingChoices: currentChanged ? [] : state.pendingChoices,
pendingPlans: currentChanged ? [] : state.pendingPlans,
pendingCheckpoints: currentChanged ? [] : state.pendingCheckpoints,
pendingRevisions: currentChanged ? [] : state.pendingRevisions,
activePlan: currentChanged ? null : state.activePlan,
usage: currentChanged ? zeroUsage() : state.usage,
sessionFiles: currentChanged ? [] : state.sessionFiles,
queuedSends: currentChanged ? [] : state.queuedSends,
};
}
case "$session_usage": {
const empty =
ev.totalCostUsd === 0 &&
ev.totalPromptTokens === 0 &&
ev.totalCompletionTokens === 0 &&
ev.cacheHitTokens === 0 &&
ev.cacheMissTokens === 0;
return {
...state,
usage: {
...state.usage,
totalCostUsd: ev.totalCostUsd,
totalPromptTokens: ev.totalPromptTokens,
totalCompletionTokens: ev.totalCompletionTokens,
cacheHitTokens: ev.cacheHitTokens,
cacheMissTokens: ev.cacheMissTokens,
lastCallCacheHit: empty ? null : state.usage.lastCallCacheHit,
lastCallCacheMiss: empty ? null : state.usage.lastCallCacheMiss,
},
};
}
case "$mcp_specs":
return {
...state,
@@ -679,7 +767,16 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
case "$ctx_breakdown":
return { ...state, usage: { ...state.usage, reservedTokens: ev.reservedTokens } };
case "$memory":
return { ...state, memory: ev.entries };
return {
...state,
memory: ev.entries,
memoryDetail:
state.memoryDetail && ev.entries.some((entry) => entry.path === state.memoryDetail?.path)
? state.memoryDetail
: null,
};
case "$memory_detail":
return { ...state, memoryDetail: ev.detail };
case "$jobs":
return { ...state, jobs: ev.items };
case "$balance":
@@ -734,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,
@@ -1105,6 +1203,7 @@ function TabRuntime({
skills: [],
sessionFiles: [],
memory: [],
memoryDetail: null,
jobs: [],
activeSkill: null,
queuedSends: [],
@@ -1352,7 +1451,7 @@ function TabRuntime({
const resolveConfirm = useCallback(
(id: number, response: ConfirmationChoice) => {
sendRpc({ cmd: "confirm_response", id, response });
sendRpc({ cmd: "confirm_response", id, response, kind: "shell" });
dispatch({ t: "resolve_confirm", id });
},
[sendRpc],
@@ -1371,7 +1470,7 @@ function TabRuntime({
);
const resolvePathAccess = useCallback(
(id: number, response: ConfirmationChoice) => {
sendRpc({ cmd: "confirm_response", id, response });
sendRpc({ cmd: "confirm_response", id, response, kind: "path" });
dispatch({ t: "resolve_path_access", id });
},
[sendRpc],
@@ -1515,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: () => {
@@ -1601,9 +1739,10 @@ function TabRuntime({
cmd: "/lang",
desc: t("app.cmd.toggleLang"),
run: () => {
const next = getLang() === "zh-CN" ? "en" : "zh-CN";
const langs = getSupportedLangs();
const next = langs[(langs.indexOf(getLang()) + 1) % langs.length] ?? "en";
setLang(next);
const langName = next === "zh-CN" ? t("app.langZH") : t("app.langEN");
const langName = getLangLabel(next);
flashToast(t("app.toast.langSwitched", { lang: langName }));
},
},
@@ -2052,6 +2191,8 @@ function TabRuntime({
mcpBridged={state.mcpBridged}
sessionFiles={state.sessionFiles}
memory={state.memory}
memoryDetail={state.memoryDetail}
onReadMemory={(path) => sendRpc({ cmd: "memory_read", path })}
/>
<StatusBar
@@ -2121,6 +2262,8 @@ function TabRuntime({
mcpSpecs={state.mcpSpecs}
mcpBridged={state.mcpBridged}
skills={state.skills}
memory={state.memory}
memoryDetail={state.memoryDetail}
qq={state.qq}
onClose={() => setSettingsOpen(false)}
onSave={saveSettings}
@@ -2135,6 +2278,7 @@ function TabRuntime({
onPickWorkspace={pickWorkspace}
onAddMcpSpec={addMcpSpec}
onRemoveMcpSpec={removeMcpSpec}
onReadMemory={(path) => sendRpc({ cmd: "memory_read", path })}
/>
) : null}
File diff suppressed because it is too large. Load diff
+23
View File
@@ -232,12 +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",
@@ -303,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",
@@ -1233,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",
@@ -1310,6 +1331,8 @@ export const en = {
maxDesc: "DeepSeek extension; rejected by stock OpenAI / vLLM",
},
editMode: {
plan: "Plan",
planHint: "read-only · all write tools refuse to dispatch",
review: "Review",
reviewHint: "both edits and non-allowlisted shell ask first",
auto: "Auto",
+28 -4
View File
@@ -1,10 +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";
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(["en", "zh-CN"]);
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",
ja: "日本語",
ru: "Русский",
};
const STORAGE_KEY = "reasonix.lang";
type Listener = () => void;
@@ -18,7 +29,12 @@ function detectDefault(): Lang {
/* private mode */
}
const nav = typeof navigator !== "undefined" ? navigator.language : "";
return nav.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
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";
}
let currentLang: Lang = detectDefault();
@@ -38,6 +54,14 @@ export function getLang(): Lang {
return currentLang;
}
export function getSupportedLangs(): readonly Lang[] {
return SUPPORTED_LANGS;
}
export function getLangLabel(lang: Lang): string {
return LANG_LABELS[lang];
}
export function setLang(lang: Lang): void {
if (!SUPPORTED.has(lang) || lang === currentLang) return;
currentLang = lang;
@@ -63,7 +87,7 @@ export function useLang(): Lang {
return currentLang;
}
const dicts = { en, "zh-CN": zhCN } 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,
};
+23
View File
@@ -232,12 +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: "设置支出限额",
@@ -303,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: "通用",
@@ -1208,8 +1225,12 @@ export const zhCN = {
themeDark: "深色",
themeLight: "浅色",
themeStyleGraphite: "石墨",
themeStyleEmber: "余烬",
themeStyleAurora: "极光",
themeStyleSandstone: "砂岩",
themeStylePorcelain: "瓷白",
themeStyleLinen: "亚麻",
themeStyleGlacier: "冰川",
themeStyleMidnight: "午夜",
thisTurn: "本轮",
tokens: "Tokens",
@@ -1285,6 +1306,8 @@ export const zhCN = {
maxDesc: "DeepSeek 扩展;标准 OpenAI / vLLM 会拒绝",
},
editMode: {
plan: "计划",
planHint: "只读模式 · 所有写工具都不会派发",
review: "审阅",
reviewHint: "编辑和非允许列表的 shell 命令都会先询问",
auto: "自动",
+397 -140
View File
@@ -197,7 +197,10 @@ function sseToIncoming(ev: any): Record<string, any>[] {
break;
}
case "modal-down": {
// Modal closed — normally handled by user action response
// Sync close from another surface (TUI or sibling tab).
if (typeof ev.modalKind === "string") {
results.push({ type: "$modal_dismissed", tabId: "tab-1", kind: ev.modalKind });
}
break;
}
case "warning":
@@ -227,10 +230,12 @@ function sseToIncoming(ev: any): Record<string, any>[] {
function connectSSE(): void {
if (sse) sse.close();
const token = document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content") ?? "";
const sseUrl = token && token !== "__REASONIX_TOKEN__"
? `/api/events?token=${encodeURIComponent(token)}`
: "/api/events";
const token =
document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content") ?? "";
const sseUrl =
token && token !== "__REASONIX_TOKEN__"
? `/api/events?token=${encodeURIComponent(token)}`
: "/api/events";
sse = new EventSource(sseUrl);
sse.onmessage = (msg: MessageEvent) => {
try {
@@ -273,41 +278,19 @@ function connectSSE(): void {
setTimeout(connectSSE, Math.min(delay, 30000));
};
// SSE 连接成功后,开始定期轮询 overview 更新 balance/stats
// SSE 连接成功后,开始定期轮询 overview/sessions 更新右侧状态和会话列表
startStatsPolling();
}
// 定期轮询 stats/balance
// 定期轮询 stats/balance/sessions
let statsPollTimer: ReturnType<typeof setInterval> | null = null;
function startStatsPolling(): void {
if (statsPollTimer) clearInterval(statsPollTimer);
// 每 5 秒轮询一次 overview 获取最新 stats/balance
// 每 5 秒轮询一次,补偿 SSE 重连、App remount、以及其他终端写入 session 文件的情况。
statsPollTimer = setInterval(async () => {
try {
const overview = await apiFetch("overview");
if (overview?.stats) {
const stats = overview.stats;
// 更新 balance
if (stats.balance) {
const firstBalance = stats.balance[0];
if (firstBalance) {
emitEvent({
type: "$balance",
tabId: "tab-1",
currency: firstBalance.currency,
total: parseFloat(firstBalance.total_balance) || 0,
isAvailable: true,
});
}
}
// 更新 usage stats(缓存命中率、tokens、金额)
const totalTokens = Math.round(
stats.totalCostUsd > 0 ? (stats.cacheHitRatio * 10000) : 0
);
// 通过 $settings 事件附带更新 usage 相关信息
// 注意:usage 主要由 model.final 事件更新,这里只更新 balance
}
await refreshServerSnapshots();
} catch {
// 静默失败,等待下次轮询
}
@@ -318,7 +301,7 @@ function startStatsPolling(): void {
async function apiFetch(endpoint: string, options?: RequestInit): Promise<any> {
const token = document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content");
const headers: Record<string, string> = {
...(options?.headers as Record<string, string> ?? {}),
...((options?.headers as Record<string, string>) ?? {}),
};
if (token && token !== "__REASONIX_TOKEN__") {
headers["x-reasonix-token"] = token;
@@ -329,7 +312,11 @@ async function apiFetch(endpoint: string, options?: RequestInit): Promise<any> {
const res = await fetch(`/api/${endpoint}`, { ...options, headers });
if (res.status === 204) return null;
const text = await res.text();
try { return JSON.parse(text); } catch { return text; }
try {
return JSON.parse(text);
} catch {
return text;
}
}
function emitServerSettings(settings: any, overview?: any): void {
@@ -344,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 ?? "",
@@ -351,7 +339,184 @@ function emitServerSettings(settings: any, overview?: any): void {
});
}
function finiteNumber(value: unknown, fallback = 0): number {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : fallback;
}
interface ServerSessionItem {
name: string;
messageCount: number;
mtime: number;
summary?: string;
workspaceStatus?: "matched" | "legacy_missing_meta";
}
interface ServerSessionsResponse {
sessions?: ServerSessionItem[];
currentSession?: string | null;
}
interface ServerBalanceEntry {
currency: string;
total_balance: string;
}
interface ServerOverviewStats {
totalCostUsd?: number;
cacheHitTokens?: number;
cacheMissTokens?: number;
totalCompletionTokens?: number;
balance?: ServerBalanceEntry[];
}
interface ServerOverviewResponse {
stats?: ServerOverviewStats;
}
function mapSessionItem(s: ServerSessionItem) {
return {
name: s.name,
messageCount: s.messageCount,
mtime: new Date(s.mtime).toISOString(),
summary: s.summary,
workspaceStatus: s.workspaceStatus,
};
}
function emitSessionsSnapshot(sessionsData: ServerSessionsResponse | null | undefined): void {
if (!sessionsData?.sessions) return;
emitEvent({
type: "$sessions",
tabId: "tab-1",
currentSession:
typeof sessionsData.currentSession === "string" ? sessionsData.currentSession : null,
items: sessionsData.sessions.map(mapSessionItem),
});
}
function emitOverviewSnapshot(overview: ServerOverviewResponse | null | undefined): void {
const stats = overview?.stats;
if (!stats) return;
const cacheHitTokens = finiteNumber(stats.cacheHitTokens);
const cacheMissTokens = finiteNumber(stats.cacheMissTokens);
emitEvent({
type: "$session_usage",
tabId: "tab-1",
totalCostUsd: finiteNumber(stats.totalCostUsd),
totalPromptTokens: cacheHitTokens + cacheMissTokens,
totalCompletionTokens: finiteNumber(stats.totalCompletionTokens),
cacheHitTokens,
cacheMissTokens,
});
const firstBalance = stats.balance?.[0];
if (firstBalance) {
emitEvent({
type: "$balance",
tabId: "tab-1",
currency: firstBalance.currency,
total: Number.parseFloat(firstBalance.total_balance) || 0,
isAvailable: true,
});
}
}
async function refreshServerSnapshots(): Promise<void> {
const [sessionsData, overview] = await Promise.all([apiFetch("sessions"), apiFetch("overview")]);
emitSessionsSnapshot(sessionsData);
emitOverviewSnapshot(overview);
}
// 初始化 Server 状态
function emitMcpSpecsFromServer(specs: any[] | undefined, bridged: any[] | undefined): void {
const live = new Map<string, any>();
for (const s of bridged ?? []) {
if (typeof s?.spec === "string") live.set(s.spec, s);
}
const out = (specs ?? []).map((s: any) => {
const liveEntry = live.get(s.raw);
return {
raw: s.raw,
name: s.name ?? null,
transport: s.transport,
summary: s.summary,
parseError: s.parseError,
status: liveEntry ? "connected" : s.parseError ? "failed" : "configured",
statusReason: liveEntry ? undefined : s.parseError,
toolCount: liveEntry?.toolCount,
};
});
emitEvent({
type: "$mcp_specs",
tabId: "tab-1",
specs: out,
bridged: out.length > 0 && out.every((s) => s.status === "connected"),
});
}
function emitSkillsFromServer(data: any): void {
const items: any[] = [];
const tag = (rows: any[] | undefined, scope: string) => {
for (const r of rows ?? []) {
items.push({
name: r.name,
description: r.description ?? "",
scope,
path: r.path ?? "",
runAs: r.runAs ?? "inline",
model: r.model,
});
}
};
tag(data?.builtin, "builtin");
tag(data?.global, "global");
tag(data?.custom, "global");
tag(data?.project, "project");
emitEvent({ type: "$skills", tabId: "tab-1", items });
}
function emitMemoryEntriesFromServer(entries: any[] | undefined): void {
emitEvent({ type: "$memory", tabId: "tab-1", entries: entries ?? [] });
}
async function loadAndEmitMcp(): Promise<void> {
try {
const [specsResp, liveResp] = await Promise.all([apiFetch("mcp/specs"), apiFetch("mcp")]);
emitMcpSpecsFromServer(specsResp?.specs, liveResp?.servers);
} catch (err) {
console.warn("[tauri-bridge] mcp fetch failed:", err);
}
}
async function loadAndEmitSkills(): Promise<void> {
try {
const data = await apiFetch("skills");
if (data) emitSkillsFromServer(data);
} catch (err) {
console.warn("[tauri-bridge] skills fetch failed:", err);
}
}
async function loadAndEmitMemory(): Promise<void> {
try {
const data = await apiFetch("memory/entries");
emitMemoryEntriesFromServer(data?.entries);
} catch (err) {
console.warn("[tauri-bridge] memory fetch failed:", err);
}
}
async function loadAndEmitMemoryDetail(path: string): Promise<void> {
try {
const data = await apiFetch(`memory/read?path=${encodeURIComponent(path)}`);
if (data?.detail) emitEvent({ type: "$memory_detail", tabId: "tab-1", detail: data.detail });
} catch (err) {
console.warn("[tauri-bridge] memory read failed:", err);
}
}
async function serverInit(): Promise<void> {
document.documentElement.dataset.web = "true";
@@ -365,37 +530,16 @@ async function serverInit(): Promise<void> {
]);
wsDir = overview?.cwd ?? "";
if (settings) emitServerSettings(settings, overview);
if (sessionsData?.sessions) {
const items = sessionsData.sessions.map((s: any) => ({
name: s.name,
messageCount: s.messageCount,
mtime: new Date(s.mtime).toISOString(),
summary: s.summary,
}));
emitEvent({
type: "$sessions",
tabId: "tab-1",
items,
});
}
// 从 overview 中提取 balance 和 stats
const stats = overview?.stats;
if (stats?.balance) {
const firstBalance = stats.balance[0];
if (firstBalance) {
emitEvent({
type: "$balance",
tabId: "tab-1",
currency: firstBalance.currency,
total: parseFloat(firstBalance.total_balance) || 0,
isAvailable: true,
});
}
}
emitSessionsSnapshot(sessionsData);
emitOverviewSnapshot(overview);
} catch (err) {
console.warn("[tauri-bridge] server init failed:", err);
}
// Sidebar/right-rail panels — desktop pushes these on tab open; web has
// to pull them or every panel renders empty forever (#1715).
void Promise.all([loadAndEmitMcp(), loadAndEmitSkills(), loadAndEmitMemory()]);
emitEvent({ type: "$ready", tabId: "tab-1" });
emitEvent({
type: "$tab_opened",
@@ -438,21 +582,17 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
case "session_list": {
try {
const data = await apiFetch("sessions");
if (data?.sessions) {
const items = data.sessions.map((s: any) => ({
name: s.name,
messageCount: s.messageCount,
mtime: new Date(s.mtime).toISOString(),
summary: s.summary,
}));
emitEvent({ type: "$sessions", tabId: "tab-1", items });
}
} catch { /* ignore */ }
emitSessionsSnapshot(data);
} catch {
/* ignore */
}
break;
}
case "session_load": {
try {
const switchData = await apiFetch(`sessions/${encodeURIComponent(payload.name)}/switch`, { method: "POST" });
const switchData = await apiFetch(`sessions/${encodeURIComponent(payload.name)}/switch`, {
method: "POST",
});
if (!switchData?.ok) {
console.warn("[tauri-bridge] session switch failed:", payload.name, switchData);
break;
@@ -530,16 +670,10 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
await apiFetch(`sessions/${encodeURIComponent(payload.name)}`, { method: "DELETE" });
// 删除后刷新列表
const data = await apiFetch("sessions");
if (data?.sessions) {
const items = data.sessions.map((s: any) => ({
name: s.name,
messageCount: s.messageCount,
mtime: new Date(s.mtime).toISOString(),
summary: s.summary,
}));
emitEvent({ type: "$sessions", tabId: "tab-1", items });
}
} catch { /* ignore */ }
emitSessionsSnapshot(data);
} catch {
/* ignore */
}
break;
}
case "new_chat": {
@@ -558,24 +692,25 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
carryover: { totalCostUsd: 0, cacheHitTokens: 0, cacheMissTokens: 0 },
});
const listData = await apiFetch("sessions");
if (listData?.sessions) {
const items = listData.sessions.map((s: any) => ({
name: s.name,
messageCount: s.messageCount,
mtime: new Date(s.mtime).toISOString(),
summary: s.summary,
}));
emitEvent({ type: "$sessions", tabId: "tab-1", items });
}
emitSessionsSnapshot(listData);
const overview = await apiFetch("overview");
emitOverviewSnapshot(overview);
}
} catch { /* fallback */ }
} catch {
/* fallback */
}
break;
}
case "settings_get": {
try {
const [settings, overview] = await Promise.all([apiFetch("settings"), apiFetch("overview")]);
const [settings, overview] = await Promise.all([
apiFetch("settings"),
apiFetch("overview"),
]);
if (settings) emitServerSettings(settings, overview);
} catch { /* ignore */ }
} catch {
/* ignore */
}
break;
}
case "settings_save": {
@@ -585,9 +720,14 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
body: JSON.stringify(payload),
});
// 保存后主动拉取最新设置以同步 UI
const [settings, overview] = await Promise.all([apiFetch("settings"), apiFetch("overview")]);
const [settings, overview] = await Promise.all([
apiFetch("settings"),
apiFetch("overview"),
]);
if (settings) emitServerSettings(settings, overview);
} catch { /* ignore */ }
} catch {
/* ignore */
}
break;
}
case "setup_save_key": {
@@ -598,37 +738,51 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
break;
}
case "confirm_response": {
await apiFetch("modal", {
const kind = payload.kind === "path" ? "path" : "shell";
await apiFetch("modal/resolve", {
method: "POST",
body: JSON.stringify({ id: payload.id, response: payload.response }),
body: JSON.stringify({ kind, choice: payload.response.type }),
}).catch(() => {});
break;
}
case "choice_response": {
await apiFetch("modal", {
const r = payload.response;
let choice: Record<string, unknown>;
if (r.type === "pick") choice = { kind: "pick", optionId: r.optionId };
else if (r.type === "text") choice = { kind: "custom", text: r.text };
else choice = { kind: "cancel" };
await apiFetch("modal/resolve", {
method: "POST",
body: JSON.stringify({ id: payload.id, response: payload.response }),
body: JSON.stringify({ kind: "choice", choice }),
}).catch(() => {});
break;
}
case "plan_response": {
await apiFetch("modal", {
const r = payload.response;
await apiFetch("modal/resolve", {
method: "POST",
body: JSON.stringify({ id: payload.id, response: payload.response }),
body: JSON.stringify({ kind: "plan", choice: r.type, text: r.feedback }),
}).catch(() => {});
break;
}
case "checkpoint_response": {
await apiFetch("modal", {
const r = payload.response;
const text = r.type === "revise" ? r.feedback : undefined;
await apiFetch("modal/resolve", {
method: "POST",
body: JSON.stringify({ id: payload.id, response: payload.response }),
body: JSON.stringify({ kind: "checkpoint", choice: r.type, text }),
}).catch(() => {});
break;
}
case "revision_response": {
await apiFetch("modal", {
const r = payload.response;
// Server takes "accept" / "reject"; verdict uses past-participle.
const choice =
r.type === "accepted" ? "accept" : r.type === "rejected" ? "reject" : null;
if (choice === null) break;
await apiFetch("modal/resolve", {
method: "POST",
body: JSON.stringify({ id: payload.id, response: payload.response }),
body: JSON.stringify({ kind: "revision", choice }),
}).catch(() => {});
break;
}
@@ -638,12 +792,16 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
if (data?.jobs) {
emitEvent({ type: "$jobs", tabId: "tab-1", items: data.jobs });
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
break;
}
case "mention_query": {
try {
const data = await apiFetch(`files/search?q=${encodeURIComponent(payload.query)}&nonce=${payload.nonce}`);
const data = await apiFetch(
`files/search?q=${encodeURIComponent(payload.query)}&nonce=${payload.nonce}`,
);
if (data) {
emitEvent({
type: "$mention_results",
@@ -653,12 +811,16 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
results: data.results ?? [],
});
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
break;
}
case "mention_preview": {
try {
const data = await apiFetch(`file-read?path=${encodeURIComponent(payload.path)}&nonce=${payload.nonce}`);
const data = await apiFetch(
`file-read?path=${encodeURIComponent(payload.path)}&nonce=${payload.nonce}`,
);
if (data) {
emitEvent({
type: "$mention_preview",
@@ -669,7 +831,9 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
totalLines: data.totalLines ?? 0,
});
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
break;
}
// ── 桌面端特有操作(Web 版无操作或静默忽略) ──
@@ -686,25 +850,51 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
break;
}
case "jobs_stop": {
try { await apiFetch(`jobs/${payload.jobId}/stop`, { method: "POST" }); } catch {}
try {
await apiFetch(`jobs/${payload.jobId}/stop`, { method: "POST" });
} catch {}
break;
}
case "jobs_stop_all": {
try { await apiFetch("jobs/stop-all", { method: "POST" }); } catch {}
try {
await apiFetch("jobs/stop-all", { method: "POST" });
} catch {}
break;
}
case "compact_history": {
try { await apiFetch("messages/compact", { method: "POST" }); } catch {}
try {
await apiFetch("messages/compact", { method: "POST" });
} catch {}
break;
}
case "retry": {
try { await apiFetch("submit", { method: "POST", body: JSON.stringify({ retry: true }) }); } catch {}
try {
await apiFetch("submit", { method: "POST", body: JSON.stringify({ retry: true }) });
} catch {}
break;
}
case "skill_run": {
const body: Record<string, any> = { name: payload.name };
if (payload.args) body.args = payload.args;
try { await apiFetch("skills/run", { method: "POST", body: JSON.stringify(body) }); } catch {}
try {
await apiFetch("skills/run", { method: "POST", body: JSON.stringify(body) });
} catch {}
break;
}
case "mcp_specs_get": {
await loadAndEmitMcp();
break;
}
case "skills_get": {
await loadAndEmitSkills();
break;
}
case "memory_get": {
await loadAndEmitMemory();
break;
}
case "memory_read": {
if (typeof payload.path === "string") await loadAndEmitMemoryDetail(payload.path);
break;
}
case "mcp_specs_add":
@@ -713,8 +903,12 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
try {
await apiFetch("mcp", {
method: "POST",
body: JSON.stringify({ action: cmd === "mcp_specs_add" ? "add" : "remove", spec: payload.spec }),
body: JSON.stringify({
action: cmd === "mcp_specs_add" ? "add" : "remove",
spec: payload.spec,
}),
});
await loadAndEmitMcp();
} catch {}
break;
}
@@ -756,20 +950,31 @@ export async function invoke(cmd: string, args?: any): Promise<any> {
const payload = JSON.parse(args.line);
if (payload.cmd === "user_input") {
emitEvent({
type: "user.message", tabId: "tab-1",
id: Date.now(), ts: new Date().toISOString(), turn: 2, text: payload.text,
type: "user.message",
tabId: "tab-1",
id: Date.now(),
ts: new Date().toISOString(),
turn: 2,
text: payload.text,
});
mockAssistantTurn(payload.text);
} else if (payload.cmd === "session_list") {
emitEvent({ type: "$sessions", tabId: "tab-1", items: mockSessions });
} else if (payload.cmd === "session_load") {
emitEvent({
type: "$session_loaded", tabId: "tab-1", name: payload.name,
type: "$session_loaded",
tabId: "tab-1",
name: payload.name,
messages: mockMessages,
carryover: { totalCostUsd: 0.045, cacheHitTokens: 2500, cacheMissTokens: 1400 },
});
} else if (payload.cmd === "new_chat") {
emitEvent({ type: "$session_empty", tabId: "tab-1", name: "desktop-new-session", sizeBytes: 0 });
emitEvent({
type: "$session_empty",
tabId: "tab-1",
name: "desktop-new-session",
sizeBytes: 0,
});
} else if (payload.cmd === "settings_get") {
emitEvent({ type: "$settings", tabId: "tab-1", ...mockSettings });
}
@@ -787,7 +992,7 @@ export async function invoke(cmd: string, args?: any): Promise<any> {
// 2. @tauri-apps/api/event
export async function listen<T = any>(
eventName: string,
callback: EventCallback<T>
callback: EventCallback<T>,
): Promise<UnlistenFn> {
let bucket = listeners.get(eventName);
if (!bucket) {
@@ -815,10 +1020,18 @@ export function getCurrentWebview(): any {
export function getCurrentWindow(): any {
return {
isMaximized: async () => false,
minimize: async () => { console.log("[tauri-bridge] minimize (no-op)"); },
close: async () => { console.log("[tauri-bridge] close (no-op)"); },
toggleMaximize: async () => { console.log("[tauri-bridge] toggleMaximize (no-op)"); },
listen: async (_event: string, _callback: any): Promise<() => void> => () => {},
minimize: async () => {
console.log("[tauri-bridge] minimize (no-op)");
},
close: async () => {
console.log("[tauri-bridge] close (no-op)");
},
toggleMaximize: async () => {
console.log("[tauri-bridge] toggleMaximize (no-op)");
},
listen:
async (_event: string, _callback: any): Promise<() => void> =>
() => {},
label: "main",
};
}
@@ -854,17 +1067,36 @@ export async function relaunch(): Promise<void> {
}
// // ═══ Mock 数据(仅 Vite 开发模式使用)
//
//
const mockSessions = [
{ name: "desktop-20260520-1", messageCount: 8, mtime: new Date().toISOString(), summary: "项目工程模块重构及自适应 UI 设计" },
{ name: "desktop-20260519-2", messageCount: 14, mtime: new Date(Date.now() - 86400000).toISOString(), summary: "编写 WebSocket 桥接与 RPC 行协议对接逻辑" },
{ name: "desktop-20260518-3", messageCount: 4, mtime: new Date(Date.now() - 172800000).toISOString(), summary: "测试移动端 TextArea 与 Dynamic Viewport" },
{
name: "desktop-20260520-1",
messageCount: 8,
mtime: new Date().toISOString(),
summary: "项目工程模块重构及自适应 UI 设计",
},
{
name: "desktop-20260519-2",
messageCount: 14,
mtime: new Date(Date.now() - 86400000).toISOString(),
summary: "编写 WebSocket 桥接与 RPC 行协议对接逻辑",
},
{
name: "desktop-20260518-3",
messageCount: 4,
mtime: new Date(Date.now() - 172800000).toISOString(),
summary: "测试移动端 TextArea 与 Dynamic Viewport",
},
];
const mockSettings = {
reasoningEffort: "high", editMode: "review", budgetUsd: null,
workspaceDir: "", recentWorkspaces: [],
model: "deepseek-v4-flash", version: "0.47.2",
reasoningEffort: "high",
editMode: "review",
budgetUsd: null,
workspaceDir: "",
recentWorkspaces: [],
model: "deepseek-v4-flash",
version: "0.47.2",
};
const mockMessages: any[] = [
@@ -884,8 +1116,12 @@ function mockAssistantTurn(_promptText: string) {
emitEvent({ type: "status", text: "DeepSeek R1 思考中...", tabId: "tab-1" });
setTimeout(() => {
emitEvent({
type: "model.turn.started", tabId: "tab-1", id: Date.now(),
turn: 2, model: "deepseek-reasoner", reasoningEffort: "high",
type: "model.turn.started",
tabId: "tab-1",
id: Date.now(),
turn: 2,
model: "deepseek-reasoner",
reasoningEffort: "high",
});
}, 600);
// reasoning deltas
@@ -904,15 +1140,24 @@ function mockAssistantTurn(_promptText: string) {
delay += 500;
setTimeout(() => {
emitEvent({
type: "tool.intent", tabId: "tab-1", name: "list_dir",
args: JSON.stringify({ DirectoryPath: "d:/AI/workspace/dashboard" }), callId: "call_12345",
type: "tool.intent",
tabId: "tab-1",
name: "list_dir",
args: JSON.stringify({ DirectoryPath: "d:/AI/workspace/dashboard" }),
callId: "call_12345",
});
}, delay);
delay += 1000;
setTimeout(() => {
emitEvent({
type: "tool.result", tabId: "tab-1", name: "list_dir", ok: true,
output: JSON.stringify([{ name: "package.json", sizeBytes: 864 }, { name: "src", isDir: true }]),
type: "tool.result",
tabId: "tab-1",
name: "list_dir",
ok: true,
output: JSON.stringify([
{ name: "package.json", sizeBytes: 864 },
{ name: "src", isDir: true },
]),
callId: "call_12345",
});
}, delay);
@@ -931,7 +1176,14 @@ function mockAssistantTurn(_promptText: string) {
delay += 500;
});
setTimeout(() => {
emitEvent({ type: "model.final", tabId: "tab-1", turn: 2, content: chunks.join(""), reasoningContent: lines.join(""), costUsd: 0.002 });
emitEvent({
type: "model.final",
tabId: "tab-1",
turn: 2,
content: chunks.join(""),
reasoningContent: lines.join(""),
costUsd: 0.002,
});
emitEvent({ type: "$turn_complete", tabId: "tab-1" });
}, delay + 400);
}
@@ -940,7 +1192,12 @@ function mockSetupAndReady() {
document.documentElement.dataset.web = "true";
setTimeout(() => emitEvent({ type: "$ready", tabId: "tab-1" }), 100);
setTimeout(() => {
emitEvent({ type: "$tab_opened", tabId: "tab-1", workspaceDir: "d:\\AI\\workspace", active: true });
emitEvent({
type: "$tab_opened",
tabId: "tab-1",
workspaceDir: "d:\\AI\\workspace",
active: true,
});
}, 150);
setTimeout(() => emitEvent({ type: "$settings", tabId: "tab-1", ...mockSettings }), 200);
setTimeout(() => emitEvent({ type: "$sessions", tabId: "tab-1", items: mockSessions }), 350);
+57 -4
View File
@@ -88,6 +88,17 @@ export type RevisionRequiredEvent = {
export type RevisionVerdict = { type: "accepted" } | { type: "rejected" } | { type: "cancelled" };
export type ModalKind =
| "shell"
| "path"
| "choice"
| "plan"
| "checkpoint"
| "revision"
| "edit-review";
export type ModalDismissedEvent = { type: "$modal_dismissed"; kind: ModalKind };
export type StepCompletedEvent = {
type: "$step_completed";
stepId: string;
@@ -100,7 +111,23 @@ export type PlanClearedEvent = { type: "$plan_cleared" };
export type SessionsEvent = {
type: "$sessions";
items: { name: string; messageCount: number; mtime: string; summary?: string }[];
currentSession?: string | null;
items: {
name: string;
messageCount: number;
mtime: string;
summary?: string;
workspaceStatus?: "matched" | "legacy_missing_meta";
}[];
};
export type SessionUsageEvent = {
type: "$session_usage";
totalCostUsd: number;
totalPromptTokens: number;
totalCompletionTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
};
export type MentionResultsEvent = {
@@ -170,9 +197,12 @@ export type CtxBreakdownEvent = {
};
export type MemoryEntryInfo = {
kind: "project_file" | "global_file" | "structured";
name: string;
scope: "project" | "global";
path: string;
description: string;
type?: string;
};
export type MemoryEvent = {
@@ -180,6 +210,16 @@ export type MemoryEvent = {
entries: MemoryEntryInfo[];
};
export type MemoryDetail = MemoryEntryInfo & {
body: string;
createdAt?: string;
};
export type MemoryDetailEvent = {
type: "$memory_detail";
detail: MemoryDetail;
};
export type RetryResultEvent = { type: "$retry_result"; text: string };
export type BtwResultEvent = { type: "$btw_result"; question: string; answer: string };
@@ -245,17 +285,21 @@ export type NeedsSetupEvent = {
reason: "no_api_key";
};
export type EditMode = "review" | "auto" | "yolo";
export type EditMode = "review" | "auto" | "yolo" | "plan";
export type ReasoningEffort = "low" | "medium" | "high" | "max";
export type WebSearchEngineName =
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa";
| "exa"
| "brave"
| "ollama";
export type SettingsEvent = {
type: "$settings";
@@ -269,6 +313,9 @@ export type SettingsEvent = {
model: string;
editor?: string;
webSearchEngine?: WebSearchEngineName;
webSearchApiKeys?: {
baidu?: string;
};
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
version: string;
@@ -300,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;
};
@@ -423,6 +472,7 @@ export type IncomingEvent = { tabId?: string } & (
| ChoiceRequiredEvent
| PlanRequiredEvent
| SessionsEvent
| SessionUsageEvent
| SessionLoadedEvent
| SessionEmptyEvent
| NeedsSetupEvent
@@ -431,6 +481,7 @@ export type IncomingEvent = { tabId?: string } & (
| BalanceEvent
| CheckpointRequiredEvent
| RevisionRequiredEvent
| ModalDismissedEvent
| StepCompletedEvent
| PlanClearedEvent
| MentionResultsEvent
@@ -441,6 +492,7 @@ export type IncomingEvent = { tabId?: string } & (
| SkillsEvent
| CtxBreakdownEvent
| MemoryEvent
| MemoryDetailEvent
| JobsEvent
| UserMessageEvent
| ModelTurnStartedEvent
@@ -459,7 +511,7 @@ export type IncomingEvent = { tabId?: string } & (
export type OutgoingCommand = { tabId?: string } & (
| { cmd: "user_input"; text: string }
| { cmd: "abort" }
| { cmd: "confirm_response"; id: number; response: ConfirmationChoice }
| { cmd: "confirm_response"; id: number; response: ConfirmationChoice; kind: "shell" | "path" }
| { cmd: "choice_response"; id: number; response: ChoiceVerdict }
| { cmd: "plan_response"; id: number; response: PlanVerdict }
| { cmd: "checkpoint_response"; id: number; response: CheckpointVerdict }
@@ -467,6 +519,7 @@ export type OutgoingCommand = { tabId?: string } & (
| { cmd: "session_list" }
| { cmd: "session_delete"; name: string }
| { cmd: "session_load"; name: string }
| { cmd: "memory_read"; path: string }
| { cmd: "new_chat" }
| { cmd: "setup_save_key"; key: string }
| { cmd: "settings_get" }
+311
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);
@@ -797,6 +925,7 @@ html[data-platform="macos"] .titlebar .tb-left {
.sidebar {
grid-area: side;
container: sidebar / inline-size;
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
@@ -813,9 +942,11 @@ html[data-platform="macos"] .titlebar .tb-left {
}
.side-head .new-btn {
flex: 1;
min-width: 0;
height: 30px;
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
padding: 0 10px;
font-size: 14px;
@@ -825,6 +956,15 @@ html[data-platform="macos"] .titlebar .tb-left {
color: oklch(99% 0 0);
box-shadow: var(--shadow-sm);
}
.side-head .new-btn svg {
flex: 0 0 auto;
}
.side-head .new-btn > span:not(.shortcut) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.side-head .new-btn:hover {
background: var(--accent-strong);
}
@@ -837,6 +977,8 @@ html[data-platform="macos"] .titlebar .tb-left {
font-weight: 500;
}
.side-head .new-btn .shortcut {
display: none;
flex: 0 0 auto;
margin-left: auto;
}
.side-head .new-btn .shortcut kbd {
@@ -851,7 +993,13 @@ html[data-platform="macos"] .titlebar .tb-left {
padding: 1px 5px;
box-shadow: none;
}
@container sidebar (min-width: 191px) {
.side-head .new-btn .shortcut {
display: inline-flex;
}
}
.side-head .icon-btn {
flex: 0 0 auto;
width: 30px;
height: 30px;
border-radius: 6px;
@@ -2695,13 +2843,21 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
}
.mem-row {
padding: 6px 8px;
width: 100%;
border: 0;
border-radius: 6px;
display: flex;
gap: 8px;
align-items: flex-start;
background: var(--panel);
color: inherit;
text-align: left;
cursor: pointer;
margin-bottom: 4px;
}
.mem-row[data-active="true"] {
background: var(--accent-soft);
}
.mem-row:last-child {
margin-bottom: 0;
}
@@ -2720,6 +2876,20 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
color: var(--fg-2);
line-height: 1.5;
}
.mem-detail {
max-height: 220px;
overflow: auto;
margin: 8px 0 0;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
white-space: pre-wrap;
color: var(--fg-1);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
}
/* ---- Checkpoint marker ---- */
@@ -2983,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;
@@ -3004,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;
@@ -3033,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;
@@ -4415,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);
@@ -4439,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);
@@ -5768,6 +6001,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
.ms-seg[data-on="true"][data-k="review"] {
background: var(--tone-info-soft); color: var(--tone-info);
}
.ms-seg[data-on="true"][data-k="plan"] {
background: var(--tone-warn-soft); color: var(--tone-warn);
}
.ms-seg[data-on="true"][data-k="auto"] {
background: var(--accent-soft); color: var(--accent);
}
@@ -6990,3 +7226,78 @@ html[data-web="true"] .win-controls {
max-width: 100%;
}
}
.muted-card {
padding: 16px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
color: var(--muted);
}
.memory-browser {
display: grid;
grid-template-columns: minmax(220px, 0.9fr) minmax(280px, 1.1fr);
gap: 12px;
min-height: 320px;
}
.memory-list,
.memory-detail {
min-width: 0;
max-height: 52vh;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
}
.memory-list {
display: flex;
flex-direction: column;
}
.memory-item {
display: grid;
gap: 4px;
width: 100%;
padding: 10px 12px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.memory-item[data-active="true"] {
background: var(--accent-soft);
}
.memory-kind,
.memory-path {
font-family: var(--mono);
font-size: 10px;
color: var(--muted);
}
.memory-name {
font-size: 12px;
color: var(--text);
}
.memory-detail {
margin: 0;
padding: 12px;
white-space: pre-wrap;
font-family: var(--mono);
font-size: 11px;
line-height: 1.5;
color: var(--text);
}
@media (max-width: 860px) {
.memory-browser {
grid-template-columns: 1fr;
}
}
+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 ? (
+39 -14
View File
@@ -15,13 +15,14 @@ import { fmtElapsed } from "./live";
import { Shortcut } from "./shortcut";
export type ReasoningEffort = "low" | "medium" | "high" | "max";
export type EditMode = "review" | "auto" | "yolo";
export type EditMode = "review" | "auto" | "yolo" | "plan";
type ModeEntry = { k: EditMode; label: TKey; icon: React.ReactNode; hint: TKey };
const EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "max"];
const MODE_INFO: ModeEntry[] = [
{ k: "plan", label: "editMode.plan", icon: <I.list size={11} />, hint: "editMode.planHint" },
{ k: "review", label: "editMode.review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
{ k: "auto", label: "editMode.auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
{ k: "yolo", label: "editMode.yolo", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
@@ -63,6 +64,7 @@ export type SlashCmd = {
};
export type MentionItem = {
name: string;
path?: string;
kind: "file" | "dir" | "url" | "agent" | "clip";
desc?: string;
};
@@ -102,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} />;
@@ -239,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) {
@@ -316,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();
@@ -360,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;
@@ -602,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);
}
}}
@@ -639,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>
@@ -652,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={{
@@ -686,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}
+26 -5
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import type { SessionFile, Settings, UsageStats } from "../App";
import { t, useLang } from "../i18n";
import { I } from "../icons";
import type { McpSpecInfo, MemoryEntryInfo } from "../protocol";
import type { McpSpecInfo, MemoryDetail, MemoryEntryInfo } from "../protocol";
import { PanelErrorBoundary } from "./error-boundary";
type Tab = "files" | "tools" | "memory" | "rules";
@@ -16,6 +16,8 @@ export function ContextPanel({
mcpBridged,
sessionFiles,
memory,
memoryDetail,
onReadMemory,
}: {
settings: Settings | null;
usage: UsageStats;
@@ -23,6 +25,8 @@ export function ContextPanel({
mcpBridged: boolean;
sessionFiles: SessionFile[];
memory: MemoryEntryInfo[];
memoryDetail: MemoryDetail | null;
onReadMemory: (path: string) => void;
}) {
useLang();
const [tab, setTab] = useState<Tab>("files");
@@ -89,7 +93,9 @@ export function ContextPanel({
<PanelErrorBoundary key={tab} label={tab}>
{tab === "files" && <CtxFiles files={sessionFiles} />}
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
{tab === "memory" && <CtxMemory entries={memory} />}
{tab === "memory" && (
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
)}
{tab === "rules" && <CtxRules settings={settings} />}
</PanelErrorBoundary>
</div>
@@ -237,7 +243,15 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
);
}
function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
function CtxMemory({
entries,
detail,
onRead,
}: {
entries: MemoryEntryInfo[];
detail: MemoryDetail | null;
onRead: (path: string) => void;
}) {
return (
<div className="ctx-block">
<div className="h">
@@ -251,13 +265,20 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
) : (
<div className="mem">
{entries.map((m) => (
<div className="mem-row" key={`${m.scope}/${m.name}`}>
<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>
</div>
</button>
))}
{detail ? <pre className="mem-detail">{detail.body}</pre> : 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">
+145 -29
View File
@@ -1,8 +1,15 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { type ReactNode, useEffect, useState } from "react";
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
import { setLang, t, useLang } from "../i18n";
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
import { I } from "../icons";
import type { McpSpecInfo, SettingsPatch, SkillInfo } from "../protocol";
import type {
McpSpecInfo,
MemoryDetail,
MemoryEntryInfo,
SettingsPatch,
SkillInfo,
} from "../protocol";
import {
describeQQRowSummary,
getQQConnectIntent,
@@ -60,6 +67,8 @@ export function SettingsModal({
mcpSpecs,
mcpBridged,
skills,
memory,
memoryDetail,
qq,
onClose,
onSave,
@@ -72,6 +81,7 @@ export function SettingsModal({
onPickWorkspace,
onAddMcpSpec,
onRemoveMcpSpec,
onReadMemory,
}: {
settings: SettingsType;
balance: Balance | null;
@@ -89,6 +99,8 @@ export function SettingsModal({
mcpSpecs: McpSpecInfo[];
mcpBridged: boolean;
skills: SkillInfo[];
memory: MemoryEntryInfo[];
memoryDetail: MemoryDetail | null;
qq: QQDesktopSettingsState | null;
onClose: () => void;
onSave: (patch: SettingsPatch) => void;
@@ -101,6 +113,7 @@ export function SettingsModal({
onPickWorkspace: () => void;
onAddMcpSpec: (spec: string) => void;
onRemoveMcpSpec: (spec: string) => void;
onReadMemory: (path: string) => void;
}) {
const [page, setPage] = useState<PageId>(initialPage ?? "general");
const [qqConfigureOpen, setQQConfigureOpen] = useState(false);
@@ -173,7 +186,9 @@ export function SettingsModal({
onSave={onSave}
/>
)}
{page === "memory" && <PageMemory />}
{page === "memory" && (
<PageMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
)}
{page === "rules" && <PageRules settings={settings} onSave={onSave} />}
{page === "billing" && (
<PageBilling balance={balance} usage={usage} currency={currency} />
@@ -532,16 +547,11 @@ function PageGeneral({
<div className="h">{t("settings.languageHint")}</div>
</div>
<div className="seg-ctrl">
<button
type="button"
data-on={lang === "zh-CN"}
onClick={() => setLang("zh-CN")}
>
{t("settings.langZhCn")}
</button>
<button type="button" data-on={lang === "en"} onClick={() => setLang("en")}>
{t("settings.langEn")}
</button>
{getSupportedLangs().map((code) => (
<button type="button" key={code} data-on={lang === code} onClick={() => setLang(code)}>
{getLangLabel(code)}
</button>
))}
</div>
</div>
</section>
@@ -598,7 +608,7 @@ function PageGeneral({
<div className="h">{t("settings.editModeHint")}</div>
</div>
<div className="seg-ctrl">
{(["review", "auto", "yolo"] as const).map((m) => (
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
<button
type="button"
key={m}
@@ -660,27 +670,113 @@ function PageGeneral({
onSave({
webSearchEngine: e.target.value as
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa",
| "exa"
| "brave"
| "ollama",
})
}
>
<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>
<option value="brave">{t("settings.webSearchEngineBrave")}</option>
<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,
@@ -1015,22 +1111,42 @@ function PageSkills({
);
}
function PageMemory() {
function PageMemory({
entries,
detail,
onRead,
}: {
entries: MemoryEntryInfo[];
detail: MemoryDetail | null;
onRead: (path: string) => void;
}) {
return (
<section className="section">
<div className="stitle">{t("settings.memorySection")}</div>
<div
style={{
padding: 16,
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 10,
fontSize: 12,
color: "var(--muted)",
}}
>
{t("settings.memoryDesc")}
</div>
{entries.length === 0 ? (
<div className="muted-card">{t("settings.memoryDesc")}</div>
) : (
<div className="memory-browser">
<div className="memory-list">
{entries.map((m) => (
<button
type="button"
className="memory-item"
data-active={detail?.path === m.path}
key={m.path}
onClick={() => onRead(m.path)}
>
<span className="memory-kind">{m.kind.replace("_", " ")}</span>
<span className="memory-name">{m.description || m.name}</span>
<span className="memory-path">{m.path}</span>
</button>
))}
</div>
<pre className="memory-detail">
{detail ? detail.body : t("settings.memoryDesc")}
</pre>
</div>
)}
</section>
);
}
@@ -1052,7 +1168,7 @@ function PageRules({
<div className="h">{t("settings.editModeHint")}</div>
</div>
<div className="seg-ctrl">
{(["review", "auto", "yolo"] as const).map((m) => (
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
<button
type="button"
key={m}
+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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "reasonix-desktop",
"private": true,
"version": "0.50.0",
"version": "0.52.0",
"type": "module",
"scripts": {
"dev": "vite",
+30 -42
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
@@ -29,23 +30,21 @@ function adhocSignMac(binPath) {
// on every spawn. Ad-hoc is enough for TCC; CI later overlays Developer ID.
const ents = join(here, "..", "src-tauri", "entitlements", "node-helper.plist");
if (!existsSync(ents)) {
console.warn(`entitlements missing: ${ents} — skipping ad-hoc sign`);
return;
throw new Error(`entitlements missing: ${ents}`);
}
try {
execSync(
`codesign --force --sign - --options runtime --entitlements "${ents}" "${binPath}"`,
{ stdio: "inherit" },
);
} catch (e) {
console.warn(`ad-hoc codesign failed (non-fatal): ${e?.message ?? e}`);
execSync(
`codesign --force --sign - --options runtime --entitlements "${ents}" "${binPath}"`,
{ stdio: "inherit" },
);
if (!hasMacTccInheritance(binPath)) {
throw new Error(`codesign did not apply com.apple.security.inherit to ${binPath}`);
}
}
function isAdhocSigned(binPath) {
function hasMacTccInheritance(binPath) {
try {
const out = execSync(`codesign -dvv "${binPath}" 2>&1`, { encoding: "utf8" });
return out.includes("Signature=adhoc") || /Authority=/.test(out);
const out = execSync(`codesign -d --entitlements :- "${binPath}" 2>&1`, { encoding: "utf8" });
return out.includes("com.apple.security.inherit") && out.includes("<true/>");
} catch {
return false;
}
@@ -55,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 (!isAdhocSigned(targetExe)) {
console.log(`${targetExe} universal but unsigned — applying ad-hoc sign`);
if (archs === MAC_ARCH) {
if (!hasMacTccInheritance(targetExe)) {
console.log(`${targetExe} ${MAC_ARCH} build missing TCC inheritance — applying ad-hoc sign`);
adhocSignMac(targetExe);
} else {
console.log(`${targetExe} already universal + signed (${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`);
@@ -150,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)`);
}
+76 -1
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"
@@ -2849,10 +2902,12 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "reasonix-desktop"
version = "0.50.0"
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"
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "reasonix-desktop"
version = "0.50.0"
version = "0.52.0"
edition = "2021"
publish = false
@@ -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"));
}
}
+240 -12
View File
@@ -1,10 +1,102 @@
#![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;
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
@@ -83,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 {
@@ -129,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;
@@ -178,12 +275,14 @@ fn open_in_editor(command: String, path: String, line: Option<u32>) -> Result<()
#[cfg(windows)]
{
// Spawn through cmd.exe so `.cmd` shims (code.cmd, cursor.cmd) resolve via PATH.
// Normalize forward slashes to backslashes — cmd.exe doesn't handle them reliably.
let normalized = path.replace('/', "\\");
cmd = Command::new("cmd");
cmd.arg("/c").arg(trimmed);
if let Some(l) = line {
cmd.arg("-g").arg(format!("{}:{}", path, l));
cmd.arg("-g").arg(format!("{}:{}", normalized, l));
} else {
cmd.arg(&path);
cmd.arg(&normalized);
}
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
@@ -198,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(())
}
@@ -208,6 +309,56 @@ fn write_text_file(path: String, content: String) -> Result<(), String> {
std::fs::write(&path, &content).map_err(|e| format!("write failed: {e}"))
}
fn sanitize_image_extension(raw: Option<&str>) -> String {
let cleaned = raw
.map(|s| s.trim().trim_start_matches('.').to_ascii_lowercase())
.unwrap_or_default();
let ok = !cleaned.is_empty()
&& cleaned.len() <= 8
&& cleaned.chars().all(|c| c.is_ascii_alphanumeric());
if ok {
cleaned
} else {
"png".to_string()
}
}
#[tauri::command]
fn save_clipboard_image(bytes: Vec<u8>, extension: Option<String>) -> Result<String, String> {
let ext = sanitize_image_extension(extension.as_deref());
let dir = pasted_images_dir();
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir failed: {e}"))?;
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| format!("clock error: {e}"))?
.as_millis();
let path = dir.join(format!("reasonix-pasted-{ts}.{ext}"));
std::fs::write(&path, bytes).map_err(|e| format!("write failed: {e}"))?;
Ok(path.to_string_lossy().into_owned())
}
fn purge_old_pasted_images(max_age: Duration) {
let dir = pasted_images_dir();
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;
};
if cutoff.is_some_and(|t| modified < t) {
let _ = std::fs::remove_file(entry.path());
}
}
}
fn main() {
#[cfg(target_os = "linux")]
linux_webkit_compat();
@@ -219,18 +370,32 @@ 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,
write_text_file
write_text_file,
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
@@ -262,14 +427,77 @@ 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::{parse_desktop_close_behavior, sanitize_image_extension, DesktopCloseBehavior};
use serde_json::json;
#[test]
fn accepts_alphanumeric_extensions() {
assert_eq!(sanitize_image_extension(Some("png")), "png");
assert_eq!(sanitize_image_extension(Some("JPG")), "jpg");
assert_eq!(sanitize_image_extension(Some(".webp")), "webp");
assert_eq!(sanitize_image_extension(Some("svg")), "svg");
}
#[test]
fn falls_back_when_missing_or_invalid() {
assert_eq!(sanitize_image_extension(None), "png");
assert_eq!(sanitize_image_extension(Some("")), "png");
assert_eq!(sanitize_image_extension(Some(" ")), "png");
}
#[test]
fn rejects_path_separators_and_traversal() {
assert_eq!(sanitize_image_extension(Some("png/../../foo")), "png");
assert_eq!(sanitize_image_extension(Some("png\\foo")), "png");
assert_eq!(sanitize_image_extension(Some("../bin")), "png");
assert_eq!(sanitize_image_extension(Some("p.n.g")), "png");
}
#[test]
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);
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Reasonix",
"version": "0.50.0",
"version": "0.52.0",
"identifier": "dev.reasonix.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -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": {
+166 -1
View File
@@ -34,7 +34,8 @@ 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] {
return {
@@ -58,18 +59,22 @@ function initialState(): Parameters<typeof reduce>[0] {
lastCallCacheHit: null,
lastCallCacheMiss: null,
reservedTokens: 0,
liveLogTokens: 0,
},
sessions: [],
externalImportSources: [],
settings: null,
qq: null,
balance: null,
mentionResults: null,
mentionPreview: null,
promptHistoryResult: null,
mcpSpecs: [],
mcpBridged: false,
skills: [],
sessionFiles: [],
memory: [],
memoryDetail: null,
jobs: [],
activeSkill: null,
queuedSends: [],
@@ -130,6 +135,126 @@ function makePathPrompt(
};
}
describe("Desktop App reducer — usage", () => {
it("falls back prompt tokens to cache miss tokens when cache fields are absent", () => {
const next = reduce(initialState(), {
t: "incoming",
event: {
type: "model.final",
id: 1,
ts: "2026-05-27T00:00:00.000Z",
turn: 1,
content: "ok",
usage: { prompt_tokens: 1234, completion_tokens: 56, total_tokens: 1290 },
costUsd: 0.001,
},
});
expect(next.usage.totalPromptTokens).toBe(1234);
expect(next.usage.cacheHitTokens).toBe(0);
expect(next.usage.cacheMissTokens).toBe(1234);
expect(next.usage.lastCallCacheMiss).toBe(1234);
});
it("settles the pending assistant message when an error ends the turn (#1660)", () => {
const base = initialState();
const state = {
...base,
busy: true,
messages: [
...base.messages,
{
kind: "assistant" as const,
turn: 1,
segments: [{ kind: "reasoning" as const, text: "thinking…" }],
pending: true,
},
],
};
const next = reduce(state, {
t: "incoming",
event: {
type: "error",
id: 1,
ts: "2026-05-27T00:00:00.000Z",
turn: 1,
message: "SSE body read failed: terminated",
recoverable: false,
},
});
expect(next.busy).toBe(false);
const assistant = next.messages.find((m) => m.kind === "assistant");
expect(assistant?.pending).toBe(false);
const error = next.messages.find((m) => m.kind === "error");
expect(error?.message).toBe("SSE body read failed: terminated");
});
it("keeps cumulative usage when live context breakdown refreshes", () => {
const base = initialState();
const next = reduce(
{
...base,
usage: {
...base.usage,
cacheHitTokens: 80,
cacheMissTokens: 20,
totalPromptTokens: 100,
},
},
{
t: "incoming",
event: { type: "$ctx_breakdown", reservedTokens: 10, logTokens: 42 },
},
);
expect(next.usage.cacheHitTokens).toBe(80);
expect(next.usage.cacheMissTokens).toBe(20);
expect(next.usage.liveLogTokens).toBe(42);
});
});
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();
@@ -241,4 +366,44 @@ describe("Desktop App reducer — ApprovalPrompt integration", () => {
expect(next.pendingConfirms).toHaveLength(0);
expect(next.pendingPathAccess).toHaveLength(0);
});
it("patches settings optimistically for desktop setting commands", () => {
const state: Parameters<typeof reduce>[0] = {
...initialState(),
settings: {
reasoningEffort: "medium",
editMode: "review",
budgetUsd: null,
workspaceDir: "/workspace",
recentWorkspaces: [],
model: "deepseek-v4-flash",
version: "0.50.1",
},
};
const next = reduce(state, {
t: "settings_patch",
patch: { reasoningEffort: "low", editMode: "auto" },
});
expect(next.settings?.reasoningEffort).toBe("low");
expect(next.settings?.editMode).toBe("auto");
});
});
describe("desktop thread layout", () => {
it("recomputes the thread cap from the latest viewport width", () => {
const side = 244;
const ctx = 320;
expect(
getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx }),
).toBe(580);
expect(
getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx }),
).toBe(756);
expect(
getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx }),
).toBe(1120);
});
});
+1008 -378
View File
File diff suppressed because it is too large. Load diff
+151 -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,23 +24,114 @@ 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;
if (/^[a-zA-Z]:[\\/]/.test(rel) || rel.startsWith("/")) return rel;
const sep = ws.includes("\\") ? "\\" : "/";
/**
* 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(/[\\/]$/, "");
return `${trimmed}${sep}${rel.replace(/^\.[\\/]/, "")}`;
const relative = basePath.replace(/^\.[\\/]/, "").replace(/\//g, sep);
return `${trimmed}${sep}${relative}`;
}
const KNOWN_EXTS =
@@ -59,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}$`);
@@ -109,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);
@@ -177,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);
}
@@ -204,7 +296,49 @@ function withFilePills(children: ReactNode): ReactNode {
});
}
/**
* Convert bracket-style math delimiters to dollar-style so they survive
* the markdown parser (which would otherwise consume the backslash in `\[`
* as an escape). Handles both display math \[...\] → $$...$$ and inline
* math \(...\) → $...$.
*
* Protects `\\[` sequences (LaTeX line-break spacing like `\\[4pt]`)
* from being mangled — the regex must not match the `\[` inside `\\[`.
*/
function normalizeMathDelimiters(source: string): string {
// Protect LaTeX line-break spacing \\[ ... ] — use a sentinel that
// can't appear in normal text.
const LB = "\x00LB\x00";
let result = source.replace(/\\\\\[/g, LB);
result = result
// display math: \[ ... \] → $$ ... $$
.replace(/\\\[/g, "$$$$")
.replace(/\\\]/g, "$$$$")
// inline math: \( ... \) → $ ... $
.replace(/\\\(/g, "$$")
.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
@@ -219,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>,
@@ -233,7 +367,7 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) {
td: ({ children }) => <td>{withFilePills(children)}</td>,
}}
>
{source}
{normalizeMathDelimiters(source)}
</ReactMarkdown>
</div>
);
@@ -259,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 {
@@ -308,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 "";
}
+26
View File
@@ -0,0 +1,26 @@
export type AbortDraftSource = "user_input" | "skill_run" | "btw";
export type AbortDraftAction =
| { type: "record"; source: AbortDraftSource; text: string }
| { type: "clear" };
export function nextAbortDraftCandidate(
_current: string | null,
action: AbortDraftAction,
): string | null {
if (action.type === "clear") return null;
if (action.source === "btw") return null;
const text = action.text.trim();
return text ? text : null;
}
export function restoreAbortedDraft(
currentDraft: string,
interruptedText: string | null | undefined,
): string | null {
const text = interruptedText?.trim();
if (!text) return null;
if (currentDraft.trim()) return null;
return text;
}
+881
View File
@@ -0,0 +1,881 @@
import { en } from "./en";
export const de: typeof en = {
...en,
palette: {
...en.palette,
searchPlaceholder: "Befehle suchen...",
empty: "Keine Treffer",
footMove: "bewegen",
footRun: "ausführen",
footClose: "schließen",
newChat: "Neuer Chat",
newChatHint: "Aktuelle Unterhaltung verwerfen und neu beginnen",
newTab: "Neuer Tab",
newTabHint: "Einen weiteren Arbeitsbereichs-Tab öffnen",
closeTab: "Tab schließen",
closeTabHint: "Aktuellen Tab schließen",
abort: "Aktuellen Turn stoppen",
abortHint: "Modell mid-Generation unterbrechen",
copyLast: "Letzte Antwort kopieren",
copyLastHint: "Neueste Assistentenantwort kopieren",
copyConv: "Unterhaltung als Markdown kopieren",
copyConvHint: "Gesamte Unterhaltung in die Zwischenablage kopieren",
exportMd: "Unterhaltung als Markdown exportieren",
exportMdHint: "Als .md-Datei speichern",
clearChat: "Bildschirm leeren",
clearChatHint: "Nur UI leeren — Unterprozess bleibt aktiv",
focusComposer: "Composer fokussieren",
pickWorkspace: "Arbeitsbereich wechseln…",
pickWorkspaceHint: "Ein anderes Arbeitsverzeichnis wählen",
settings: "Einstellungen",
settingsHint: "Modell, Budget, Theme…",
about: "Über Reasonix",
groupNav: "Navigation",
groupAction: "Aktion",
groupWorkspace: "Arbeitsbereich",
groupSettings: "Einstellungen",
countSuffix: "Einträge",
},
about: {
...en.about,
sidebarLabel: "Über",
tagline: "DeepSeek-nativer Coding-Agent",
version: "Version",
repo: "Repository",
checkUpdates: "Nach Updates suchen",
checking: "Prüfe...",
upToDate: "Aktuell ({version})",
updateAvailable: "Update verfügbar: {version}",
openReleases: "Releases anzeigen",
checkFailed: "Prüfung fehlgeschlagen: {message}",
close: "Schließen",
},
workdir: {
...en.workdir,
title: "Arbeitsbereich wechseln",
searchPlaceholder: "Letzte Pfade suchen...",
empty: "Keine letzten Arbeitsbereiche",
browse: "Lokal durchsuchen...",
removeRecent: "Aus Liste entfernen",
},
sidebar: {
...en.sidebar,
newChat: "Neuer Chat",
searchCommands: "Befehle suchen...",
recent: "Zuletzt",
noSessions: "Noch keine Sitzungen",
wallet: "Wallet",
footHint: "Cache-First-Agent",
},
toast: {
...en.toast,
copied: "Kopiert",
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: {
...en.composer,
busy: "reasonix denkt nach...",
idle: "Frag etwas",
send: "senden",
newline: "neue Zeile",
commands: "Befehle",
placeholder: "Frag den Agenten / beschreibe eine Aufgabe...",
queueCount: "{n} in Warteschlange",
queue: "Warteschlange",
interrupt: "stopp",
mentionFiles: "Dateien erwähnen",
commandPalette: "Befehlsübersicht",
insertFile: "Datei einfügen (@-Erwähnung)",
insertImage: "Bild einfügen (@-Erwähnung)",
switchModel: "Modell wechseln",
switchEffort: "Reasoning-Effort wechseln",
slashHeader: "Befehle — Agent, Modell und Sitzung steuern",
atHeader: "Erwähnungen — Dateien im Arbeitsbereich",
noMatches: "Keine Treffer",
select: "auswählen",
confirm: "bestätigen",
close: "schließen",
imageFilterName: "Bilder",
commandsLabel: "Befehle",
mentionLabel: "Erwähnen",
workspaceRoot: "Arbeitsbereich-Root",
},
dragDrop: {
...en.dragDrop,
overlay: "Zum Anhängen als @-Erwähnung ablegen",
},
settings: {
...en.settings,
title: "Einstellungen",
close: "schließen",
language: "Sprache",
languageHint: "Nur UI — Agent-Prompts bleiben unverändert.",
langEn: "Englisch",
langZhCn: "Chinesisch (vereinfacht)",
langEnDesc: "Englische UI",
langZhCnDesc: "中文界面",
costCurrency: "Kostenwährung",
costCurrencyHint:
"Nur Anzeige. Interne Kosten werden in USD-Äquivalent pro Token berechnet, zum festen Kurs von 7,2 FX umgerechnet.",
cnyDesc: "entspricht DeepSeek-Dashboard",
usdDesc: "entspricht Referenztarifen pro Token",
theme: "Theme",
themeHint: "Lokal auf diesem Gerät, nicht mit der TUI synchronisiert.",
themeDark: "dunkel",
themeLight: "hell",
themeDarkDesc: "Standard · wenig Licht",
themeLightDesc: "hoher Kontrast · Tageslicht",
themeStyle: "Stil",
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",
fontScaleHint: "Skaliert die gesamte UI; wähle größer für hochauflösende Bildschirme.",
fontScaleSmall: "klein",
fontScaleMedium: "mittel",
fontScaleLarge: "groß",
fontFamily: "Schriftfamilie",
fontFamilyHint: "Textkörper; Code-Blöcke bleiben unabhängig davon monospace.",
fontFamilySans: "serifenlos",
fontFamilySystem: "System",
fontFamilySerif: "Serife",
fontFamilyCustom: "benutzerdefiniert",
customFontFamily: "Benutzerdefinierte Schriftfamilie",
customFontFamilyHint: "CSS-font-family-Wert für die gesamte Desktop-UI.",
reasoningEffort: "Reasoning-Effort",
reasoningEffortHint: "Wirkt live auf die laufende Sitzung.",
effortHigh: "hoch",
effortMax: "max",
effortHighDesc: "schneller, weniger Denken",
effortMaxDesc: "tieferes Reasoning, langsamer",
editMode: "Edit-Gate",
editModeHint:
"Tool-Genehmigungsverhalten — Änderungen werden auf Platte gespeichert, wirken beim nächsten Tool-Aufruf.",
editModeReview: "review",
editModeAuto: "auto",
editModeYolo: "yolo",
showSystemEvents: "Systemereignisse",
showSystemEventsHint:
"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",
budget: "Budgetgrenze",
budgetHint: "USD pro Sitzung. Leer = keine Grenze.",
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)",
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",
webSearchEndpoint: "SearXNG-Endpunkt",
webSearchEndpointHint: "Basis-URL der SearXNG-Instanz. Leer = http://localhost:8080.",
webSearchApiKeySignup: "(Schlüssel holen)",
webSearchApiKeyClear: "Löschen",
webSearchApiKey: {
metaso: "Metaso API-Key",
baidu: "Baidu API-Key",
tavily: "Tavily API-Key",
perplexity: "Perplexity API-Key",
exa: "Exa API-Key",
brave: "Brave API-Key",
ollama: "Ollama API-Key",
},
baseUrl: "DeepSeek-Basis-URL",
baseUrlHint:
"Nur bei Verwendung eines Proxys überschreiben. Leer = offizieller Endpunkt. Neustart erforderlich.",
workspace: "Arbeitsbereich",
workspaceHint:
"Root-Verzeichnis, in dem Agent-Tools arbeiten. Wechseln speichert in der Konfiguration und lädt Tools neu.",
workspaceChange: "Ändern…",
editor: "Editor",
editorHint:
"Wird beim Klicken auf einen Dateilink/-pfad verwendet. Systemstandard = OS-Dateizuordnung. Andere müssen im PATH sein.",
editorSystem: "Systemstandard",
editorCustom: "benutzerdefiniert",
editorPlaceholder: 'z.B. "code", "subl" oder vollständiger Pfad zur ausführbaren Datei',
editorDefault: "(Standard)",
apiKey: "API-Schlüssel",
apiKeyHint: "DeepSeek-Schlüssel — gespeichert in ~/.reasonix/config.json (0600).",
apiKeyNotSet: "(nicht gesetzt)",
apiKeyCancel: "Abbrechen",
apiKeySave: "Speichern",
apiKeyShow: "anzeigen",
apiKeyHide: "verbergen",
environment: "Umgebung",
model: "Modell",
pageGeneralLabel: "Allgemein",
pageGeneralDesc: "Darstellung, Sprache, Verhalten",
pageModelsLabel: "Modelle",
pageModelsDesc: "Standardmodell und Sampling-Parameter",
pageMcpLabel: "MCP-Server",
pageMcpDesc: "MCP-Protokoll-Tool-Server verwalten",
pageSkillsLabel: "Skills",
pageSkillsDesc: "Wiederverwendbare Prompt-Sets für /-Befehle",
pageMemoryLabel: "Memory",
pageMemoryDesc: "CLAUDE.md / AGENTS.md-Injektion",
pageRulesLabel: "Genehmigungsregeln",
pageRulesDesc: "Auto-genehmigen, ablehnen, Befehlsmuster bestätigen",
pageBillingLabel: "Konto & Abrechnung",
pageBillingDesc: "Guthaben, Nutzung und Rechnungen",
pageShortcutsLabel: "Kürzel",
pageShortcutsDesc: "Tastaturkürzel-Übersicht",
appearanceSection: "Darstellung",
workspaceSection: "Arbeitsbereich",
behaviorSection: "Verhalten",
apiSection: "DeepSeek API",
qqSection: "QQ-Kanal",
qqTitle: "QQ-Bot-Integration",
qqConnected: "Verbunden",
qqConnecting: "Verbinde",
qqEnabled: "Aktiviert",
qqFailed: "Fehlgeschlagen",
qqDisconnected: "Getrennt",
qqSummaryMissing: "Anmeldedaten noch nicht konfiguriert",
qqSummaryAppId: "App-ID {appId}",
qqSummaryDetail: "{appId} · {environment} · {access}",
qqConnect: "Verbinden",
qqConfigure: "Konfigurieren…",
qqDisconnect: "Trennen",
qqConfigureTitle: "QQ-Kanal konfigurieren",
qqConfigureHint: "Registriere einen QQ-Bot zum Empfangen und Beantworten von Nachrichten.",
qqEnvironment: "QQ-Umgebung",
qqEnvironmentHint:
"Wähle die QQ-Open-Platform-Umgebung für diesen Bot. Verwende Sandbox zum Testen und Production für den Live-Betrieb.",
qqBack: "Zurück",
qqAppId: "App-ID",
qqAppSecret: "App-Secret",
qqSandbox: "Sandbox",
qqProduction: "Produktion",
qqAccess: "Zugriff",
qqAccessOwner: "Besitzer {openId}",
qqAccessOwnerWithAllowlist: "Besitzer {openId} + Allowlist {count}",
qqAccessAllowlist: "Allowlist {count}",
qqAccessRuntime: "Erstabsender (nur zur Laufzeit, {openId})",
qqAccessOpen: "Offen (ungebunden)",
qqApplyLabel: "QQ-Bot beantragen",
qqApplyAction: "Antragsseite öffnen",
qqSave: "Speichern",
qqSaveAndConnect: "Speichern und verbinden",
mcpSection: "Konfiguriert",
currentWorkspace: "Aktueller Arbeitsbereich",
notSelected: "(nicht ausgewählt)",
apiKeySet: "Gesetzt · {prefix}…",
defaultModelCurrent: "Standardmodell · aktuell {model}",
modelCustom: "Benutzerdefinierte Modell-ID",
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: "· 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.",
subagentModelFlash: "flash",
subagentModelPro: "pro",
subagentModelHint:
"Modell, das verwendet wird, wenn dieser Skill seinen isolierten Subagenten startet",
memorySection: "Langzeit-Memory",
memoryDesc:
"Aktuelle Version verwendet die eingebaute CLAUDE.md / AGENTS.md-Injektion. Projekt-Level-Memory wird auf Kernel-Ebene verwaltet.",
appMode: "Anwendungsmodus",
ruleAutoApprovalSection: "Auto-genehmigte Befehle",
ruleAutoApprovalHint:
"Klicke in der ApprovalCard auf »Immer erlauben«, um einen Befehlspräfix zur Whitelist hinzuzufügen; nachfolgende passende Befehle werden nicht mehr nachfragen.",
balanceLabel: "Wallet-Guthaben",
balanceLow: "Unzureichend",
balanceAvailable: "Verfügbar",
sessionCost: "Diese Sitzung kostet",
cacheHitRate: "Cache-Trefferquote",
shortcutNewChat: "Neuer Chat",
shortcutNewTab: "Neuer Tab",
shortcutCloseTab: "Tab schließen",
shortcutCommandPalette: "Befehlsübersicht",
shortcutFocusComposer: "Composer fokussieren",
shortcutSwitchTab: "Tab wechseln",
shortcutAbort: "Streaming abbrechen",
shortcutSettings: "Einstellungen",
},
modal: {
...en.modal,
planFeedbackPlaceholder: "Welche Probleme hat der Plan? Was muss verbessert werden?",
choiceCustomPlaceholder: "Wo kann der Plan weiter verfeinert werden?",
},
plan: {
...en.plan,
activeTitle: "Aktiver Plan",
progress: "{done} / {total} Schritte",
progressNoTotal: "{done} Schritte",
expand: "erweitern",
collapse: "einklappen",
dismiss: "Plan verwerfen",
riskLow: "niedrig",
riskMed: "mittel",
riskHigh: "hoch",
},
editMode: {
...en.editMode,
label: "Edit-Gate",
plan: "Plan",
review: "Review",
auto: "Auto",
yolo: "YOLO",
planDesc: "schreibgeschützt · keine Edits, keine Shell",
reviewDesc: "jeden Schreibzugriff genehmigen",
autoDesc: "Auto-Edits, Befehle genehmigen",
yoloDesc: "alles automatisch",
planHint: "Schreibgeschützt · alle Schreibwerkzeuge verweigern die Ausführung",
reviewHint: "Jeder Tool-Aufruf benötigt Genehmigung",
autoHint: "Befehle, die der Allowlist entsprechen, werden auto-genehmigt",
yoloHint: "Alles auto-genehmigt · Mit Vorsicht verwenden",
},
markdown: {
...en.markdown,
filePillTitle: "Klicken zum Öffnen · Rechtsklick zum Kopieren",
externalLinkTitle: "{href} im Browser öffnen",
localLinkTitle: "{href} öffnen",
copy: "kopieren",
copied: "kopiert",
},
effort: {
...en.effort,
lowDesc: "am schnellsten — minimales Reasoning",
mediumDesc: "ausgewogen",
highDesc: "Standard — sicher für vLLM / Azure",
maxDesc: "DeepSeek-Erweiterung; von stock OpenAI / vLLM abgelehnt",
},
checkpoint: {
...en.checkpoint,
title: "Schritt abgeschlossen",
progress: "{done} / {total}",
notesLabel: "Notizen",
continue: "Fortsetzen",
revise: "Überarbeiten…",
stop: "Stopp",
revisePlaceholder:
"Was muss sich vor dem nächsten Schritt ändern? (leer lassen zum Fortfahren)",
sendRevise: "Überarbeitung senden",
},
revision: {
...en.revision,
title: "Plan-Überarbeitung",
subtitle: "Modell möchte die verbleibenden Schritte ersetzen",
remainingHeading: "Neue verbleibende Schritte",
accept: "Akzeptieren",
reject: "Ablehnen",
cancel: "Abbrechen",
},
app: {
...en.app,
toast: {
...en.app.toast,
newSession: "Neue Sitzung erstellt",
cleared: "Geleert",
copied: "Kopiert",
copiedMd: "Als Markdown kopiert",
exportedMd: "Als Markdown-Datei exportiert",
exportFailed: "Export fehlgeschlagen: {error}",
exportDialogTitle: "Unterhaltung exportieren",
emptySession: "Sitzung ist leer",
langSwitched: "Gewechselt zu {lang}",
modelSwitched: "Modell → {model}",
effortSwitched: "Effort → {effort}",
modeSwitched: "Modus: {mode}",
taskComplete: "Aufgabe abgeschlossen",
},
cmd: {
...en.app.cmd,
newSession: "Neue Sitzung",
clearChat: "Anzeige leeren",
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",
setEffort: "Reasoning-Effort auf {effort} setzen",
toggleTheme: "Theme wechseln",
toggleCurrency: "Währung wechseln (CNY / USD)",
toggleLang: "Sprache wechseln (CN / EN)",
exportMd: "Sitzung als Markdown kopieren",
help: "Alle Befehle anzeigen",
feedback: "Feedback auf GitHub senden (öffnet Browser)",
compact: "Ältere Turns in eine Zusammenfassung falten (Kontext sparen)",
retry: "Letzte Nachricht kürzen und erneut senden",
btw: "Eine Randfrage stellen (nie zum Kontext hinzugefügt)",
},
skill: {
...en.app.skill,
generic: "{scope} Skill · {runAs}",
scope: {
...en.app.skill.scope,
builtin: "Eingebaut",
global: "Global",
project: "Projekt",
},
runAs: {
inline: "inline",
subagent: "subagent",
},
},
yolo: {
...en.app.yolo,
banner1: "Alle Tool-Aufrufe, Shell-Befehle und Datei-Edits werden ",
bannerBold: "auto-genehmigt",
banner2: " ohne Nachfrage.",
switchBack: "Zu Review wechseln",
toast: "Alle Operationen werden ohne Nachfrage auto-genehmigt",
},
btwUsage: "▸ /btw <Frage> — eine Randfrage stellen, ohne den Gesprächskontext zu belasten.",
errorLabel: "Fehler",
warningLabel: "Warnung",
dismissError: "Schließen",
jumpToBottom: "Nach unten springen",
splashSubtitle: "DeepSeek-Agenten",
connecting: "Verbinde mit reasonix core...",
startupFailedTitle: "Reasonix konnte nicht starten",
startupFailedMessage:
"Das Desktop-Backend ist fehlgeschlagen, bevor ein Arbeitsbereich geöffnet wurde.",
startupFailedRetry: "Erneut versuchen",
startupFailedUnknown: "Unbekannter Startfehler",
langZH: "Chinesisch",
langEN: "Englisch",
titlebar: {
...en.app.titlebar,
sidebar: "Seitenleiste (⌘B)",
contextPanel: "Kontext-Panel",
more: "Mehr",
commandPalette: "Befehlsübersicht",
copyMd: "Unterhaltung als Markdown kopieren",
exportMd: "Markdown exportieren",
clearChat: "Bildschirm leeren",
settings: "Einstellungen",
windowControls: "Fenstersteuerung",
close: "Schließen",
minimize: "Minimieren",
maximize: "Maximieren",
restore: "Wiederherstellen",
},
tab: {
...en.app.tab,
newTabTitle: "Neuer Tab ⌘T",
newTab: "Neuer Tab",
},
header: {
...en.app.header,
noWorkspace: "Kein Arbeitsbereich ausgewählt",
running: "Läuft",
clickToSelect: "Klicken, um Arbeitsbereich auszuwählen",
copy: "Kopieren",
copyMd: "Unterhaltung als Markdown kopieren",
export: "Exportieren",
exportMd: "Als Markdown-Datei exportieren",
newChat: "Neuer Chat",
abort: "Stopp",
},
empty: {
...en.app.empty,
welcome: "Willkommen bei Reasonix",
currentWorkspace: "Aktueller Arbeitsbereich: ",
selectWorkspace: "Wähle zuerst einen Arbeitsbereich aus der oberen Leiste",
suggestion0: "Meine letzten Commit-Änderungen überprüfen",
suggestion1: "Alle TypeScript-Fehler in der aktuellen Datei beheben",
suggestion2: "README ins Chinesische/Englische übersetzen",
suggestion3: "CHANGELOG für dieses Repo generieren",
},
setup: {
...en.app.setup,
welcome: "Willkommen bei Reasonix",
description:
"Die Ersteinrichtung erfordert einen DeepSeek-API-Schlüssel und ein Arbeitsbereichsverzeichnis. Der Schlüssel wird nur lokal gespeichert.",
workspace: "Arbeitsbereich",
notSelected: "Nicht ausgewählt",
choose: "Wählen…",
saveAndStart: "Speichern & starten",
},
update: {
...en.app.update,
available: "Neue Version verfügbar · {current} → {latest}",
installing: "Installiere…",
downloading: "Lade herunter · {downloaded} / {total} ({pct}%)",
downloadingUnknown: "Lade herunter · {downloaded}",
failed: "Installation fehlgeschlagen",
clickToInstall: "Klicken zum Installieren & Neustarten",
install: "Installieren",
later: "Später",
},
exportUserLabel: "Du",
exportReasoningSummary: "Reasoning",
exportToolLabel: "Werkzeug",
session: {
...en.app.session,
new: "{workspace} · Neue Sitzung",
format: "Sitzung {month}-{day} {hour}:{minute}",
},
},
planBadge: {
...en.planBadge,
failed: "Fehlgeschlagen",
blocked: "Blockiert",
running: "Läuft",
done: "Erledigt",
pending: "Ausstehend",
},
cards: {
...en.cards,
reasoningName: "Denken",
reasoningComplete: "Abgeschlossen",
compactionName: "Komprimierter Verlauf",
compactionMeta: "{chars} Zeichen",
shellAwaiting: "Wartet auf Freigabe",
shellRunning: "Läuft",
shellExecuteHint: "diesen Befehl ausführen",
shellAlwaysAllow: "Immer erlauben",
shellReject: "Ablehnen",
shellRun: "Ausführen",
diffApplyChanges: "Änderungen anwenden",
diffDiscard: "Verwerfen",
diffApply: "Anwenden",
diffAwaiting: "Wartet auf Freigabe",
errorName: "Fehler",
searchName: "Websuche",
subagentDone: "Erledigt",
subagentFailed: "Fehlgeschlagen",
subagentRunning: "Läuft",
subagentDoneProgress: "erledigt",
memoryName: "Memory",
memoryCountSuffix: "Einträge",
checkpointRewind: "Hier zurückspulen",
planDefaultTitle: "Plan",
streaming: "streamt…",
failed: "fehlgeschlagen",
done: "fertig",
running: "Läuft",
error: "Fehler",
result: "Ergebnis",
hits: "Treffer",
applied: "angewendet",
cacheHit: "Cache-Treffer",
prompt: "Prompt",
reasoning: "Reasoning",
output: "Ausgabe",
cost: "Kosten",
elapsed: "verstrichen",
},
statusbar: {
...en.statusbar,
offline: "Offline",
busy: "Läuft",
online: "Online",
jobs: "Jobs",
jobsTip: "Hintergrundprozesse · ⌘J",
cacheHit: "Cache-Treffer",
cache: "Cache",
tokens: "Tokens",
thisTurn: "dieser Turn",
switchWorkspace: "Arbeitsbereich wechseln · {workspace}",
switchCurrency: "Währung wechseln (CNY / USD)",
balance: "Guthaben",
switchTheme: "Theme wechseln",
themeDark: "dunkel",
themeLight: "hell",
themeStyleGraphite: "Graphit",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstein",
themeStylePorcelain: "Porzellan",
themeStyleLinen: "Leinen",
themeStyleGlacier: "Gletscher",
themeStyleMidnight: "Mitternacht",
},
jobs: {
...en.jobs,
title: "Hintergrund-Jobs",
running: "läuft",
exited: "beendet",
empty: "Noch keine Hintergrund-Jobs in diesem Arbeitsbereich.",
stopAll: "Alle stoppen",
stopAllTip: "SIGTERM an jeden laufenden Job senden",
stop: "Stopp",
stopOne: "Diesen Job stoppen",
kbToggle: "umschalten",
kbClose: "schließen",
},
contextPanel: {
...en.contextPanel,
reservedKey: "reserviert",
usedKey: "verwendet",
filesTab: "Dateien",
toolsTab: "Tools",
memoryTab: "Memory",
rulesTab: "Regeln",
contextTokens: "Kontext · Tokens",
cacheKey: "Cache",
freeKey: "frei",
filesTitle: "Dateien im Kontext",
filesCount: "{count} Dateien",
fileModified: "geändert",
fileInContext: "im Kontext",
openFile: "Datei öffnen: {path}",
copyPath: "Pfad kopieren: {path}",
mcpTitle: "MCP-Server",
mcpReadyAll: "{count} bereit",
mcpReadySome: "{ready}/{count} bereit",
mcpEmpty: "Keine MCP-Server konfiguriert",
mcpTools: "{count} Tools",
mcpReady: "bereit",
mcpConnecting: "verbinde",
mcpDisabled: "deaktiviert",
mcpFailed: "fehlgeschlagen",
mcpConfigured: "konfiguriert",
memoryTitle: "Langzeit-Memory",
itemCount: "{count} Einträge",
scopeProject: "Projekt",
scopeGlobal: "Global",
autoApproveTitle: "Auto-Genehmigung",
ruleYolo: "YOLO-Modus · alle Tool-Aufrufe auto-genehmigt",
ruleReadOnly: "Schreibgeschützte Tools auto-genehmigt",
ruleShellAllowlist: "Shell-Befehle, die der Allowlist entsprechen, auto-genehmigt",
ruleWritesAsk: "Schreibzugriffe und unbekannte Shell-Befehle erfordern Bestätigung",
ruleReview: "Review-Modus · jeder Tool-Aufruf erfordert Bestätigung",
allow: "ERLAUBEN",
ask: "FRAGEN",
noFilesMsg: "Noch keine Dateien im Kontext.",
noMemoriesMsg: "Noch keine Erinnerungen gespeichert.",
},
sidebarPanel: {
...en.sidebarPanel,
sessionTitle: "Sitzung {month}-{day} {hour}:{minute}",
justNow: "gerade eben",
minutesAgo: "vor {n}m",
hoursAgo: "vor {n}h",
daysAgo: "vor {n}T",
weeksAgo: "vor {n}W",
newChat: "Neuer Chat",
commandPalette: "Befehlsübersicht",
searchSessions: "Sitzungen durchsuchen…",
recent: "Letzte",
noSessions: "Noch keine Sitzungen",
noMatches: "Keine Treffer",
messageCount: "{count} Nachrichten",
deleteSession: "Sitzung löschen",
importSessions: "Sitzung importieren",
importDetected: "Gefunden",
importCustom: "Eigener Pfad",
importFoundApps: "Gefundene Apps",
importScanning: "Lokale Apps werden gesucht…",
importSessionCount: "{count} Sitzungen · importiert alle",
importNotFound: "Keine lokalen Sitzungen gefunden",
importPrivacyHint: "Bestehende App-Einstellungen bleiben unverändert.",
importResult: "{imported} Sitzung(en) importiert, {skipped} übersprungen, {failed} fehlgeschlagen.",
continue: "Weiter",
refresh: "Aktualisieren",
importSource: "Quelle",
importFromClaude: "Claude",
importFromCodex: "Codex",
importPath: "Pfad zur Sitzungsdatei",
importName: "Sitzungsname",
importNamePlaceholder: "Optionaler eigener Name",
importConfirm: "Importieren",
browse: "Durchsuchen",
renameSession: "Sitzung umbenennen",
renamePlaceholder: "Sitzungstitel",
approvalRules: "Genehmigungsregeln",
settings: "Einstellungen",
cancel: "Abbrechen",
delete: "Löschen",
},
extraCards: {
...en.extraCards,
sessionUsage: "Sitzungsnutzung",
sessionCost: "Kosten {costLabel}",
approve: "Genehmigen",
reject: "Ablehnen",
doctorTitle: "/doctor · Systemprüfung",
balance: "Guthaben {balanceLabel}",
prompt: "Prompt",
reasoning: "Reasoning",
output: "Ausgabe",
cacheHit: "Cache-Treffer",
cache: "Cache",
contextWindow: "Kontextfenster",
topToolsUsage: "Top-Tools — Nutzung",
memoryUser: "BENUTZER · Einstellungen",
memoryFeedback: "FEEDBACK · Korrekturen",
memoryProject: "PROJEKT · Projektregeln",
memoryReference: "REFERENZ · Referenzen",
unknownKind: "# unbekannter Karten-Typ — Fallback-Rendering",
},
thread: {
...en.thread,
toolCalls: "{count} Tool-Aufrufe",
oneToolCall: "1 Tool-Aufruf",
keepSteps: "{n} verbleibende(n) Schritt(e) behalten",
keepOriginal: "Original behalten",
you: "Du",
planRunning: "Plan läuft · Schritt {step} / {total}",
collapse: "Einklappen",
activePlan: "Aktiver Plan",
risk: "Risiko",
planStepCount: "{count} Schritt(e)",
planConfirmationKind: "Planbestätigung",
startPlan: "Plan starten",
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}",
continue: "Fortsetzen",
stop: "Stopp",
revise: "Überarbeiten",
planRevisionKind: "Plan-Überarbeitung",
rewritePlan: "Plan umschreiben",
revisionMeta: "Grund · Laufzeiteinschränkung",
approveRewrite: "Umschreibung genehmigen",
shellConfirmationKind: "Shell-Bestätigung",
runCommand: "Befehl ausführen",
runBackgroundCommand: "Hintergrundbefehl ausführen",
riskMedium: "Risiko · mittel · {kind}",
execute: "Ausführen",
reject: "Ablehnen",
alwaysAllow: '"{prefix}" immer erlauben',
pathAccessKind: "Pfadzugriff",
read: "lesen",
write: "schreiben",
readPathOutsideSandbox: "Pfad außerhalb des Sandbox lesen",
writePathOutsideSandbox: "Pfad außerhalb des Sandbox schreiben",
allowRead: "Lesen erlauben",
allowWrite: "Schreiben erlauben",
alwaysAllowPrefix: "{prefix} immer erlauben",
userChoiceKind: "Benutzerauswahl",
optionCount: "{count} Optionen",
subagent: "Subagent",
copyMessage: "Diese Nachricht kopieren",
editMessage: "Diese Nachricht bearbeiten und erneut senden",
copyResponse: "Diese Antwort kopieren",
},
live: {
...en.live,
reasoning: "live · Reasoning",
running: "läuft",
you: "Du",
secondsAgo: "vor {seconds}s",
deliveredWaiting: "Zugestellt · warte auf Agenten",
},
panel: {
...en.panel,
renderError: "{panel} Panel-Rendering-Fehler",
},
notifications: {
...en.notifications,
approvalTitle: "Reasonix wartet auf Genehmigung",
inputTitle: "Reasonix wartet auf Eingabe",
planApprovalTitle: "Reasonix wartet auf Plan-Genehmigung",
checkpointApprovalTitle: "Reasonix wartet auf Checkpoint-Genehmigung",
revisionTitle: "Reasonix hat eine Plan-Überarbeitung vorgeschlagen",
commandBody: "Befehl: {command}",
readBody: "Lesen: {path}",
writeBody: "Schreiben: {path}",
turnCompleteTitle: "Reasonix-Aufgabe abgeschlossen",
turnCompleteBody: "Die aktuelle Aufgabe ist abgeschlossen und bereit zur Überprüfung.",
},
};
+150 -6
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",
@@ -152,9 +169,14 @@ export const en = {
editModeAuto: "auto",
editModeYolo: "yolo",
showSystemEvents: "System events",
showSystemEventsHint: "Show quiet inline notices for context compaction, abort, and rate-limit pauses.",
showSystemEventsHint:
"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",
@@ -163,12 +185,30 @@ 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)",
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",
webSearchEndpoint: "SearXNG endpoint",
webSearchEndpointHint: "Base URL of your SearXNG instance. Defaults to http://localhost:8080.",
webSearchApiKeySignup: "(get key)",
webSearchApiKeyClear: "Clear",
webSearchApiKey: {
metaso: "Metaso API key",
baidu: "Baidu API key",
tavily: "Tavily API key",
perplexity: "Perplexity API key",
exa: "Exa API key",
brave: "Brave API key",
ollama: "Ollama API key",
},
baseUrl: "DeepSeek base URL",
baseUrlHint: "Override only if using a proxy. Empty = official endpoint. Restart required.",
workspace: "Workspace",
@@ -255,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:
@@ -303,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",
@@ -319,12 +399,15 @@ export const en = {
},
editMode: {
label: "Edit gate",
plan: "plan",
review: "review",
auto: "auto",
yolo: "yolo",
planDesc: "read-only · no edits, no shell",
reviewDesc: "approve every write",
autoDesc: "auto edits, approve commands",
yoloDesc: "auto everything",
planHint: "Read-only mode · all write tools refuse to dispatch",
reviewHint: "Every tool call needs approval",
autoHint: "Commands matching the allowlist are auto-approved",
yoloHint: "All auto-approved · Use with caution",
@@ -382,6 +465,14 @@ 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}",
setEffort: "Set reasoning effort to {effort}",
toggleTheme: "Toggle theme",
toggleCurrency: "Toggle currency (CNY / USD)",
toggleLang: "Toggle language (CN / EN)",
@@ -418,6 +509,10 @@ export const en = {
jumpToBottom: "Jump to bottom",
splashSubtitle: "DeepSeek Agents",
connecting: "Connecting to reasonix core…",
startupFailedTitle: "Reasonix could not start",
startupFailedMessage: "The desktop backend failed before opening a workspace.",
startupFailedRetry: "Retry",
startupFailedUnknown: "Unknown startup error",
langZH: "Chinese",
langEN: "English",
titlebar: {
@@ -550,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",
@@ -592,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",
@@ -616,12 +731,35 @@ export const en = {
weeksAgo: "{n}w ago",
newChat: "New chat",
commandPalette: "Command palette",
workspace: "Workspace",
noWorkspace: "No workspace",
pickWorkspace: "Pick workspace",
switchWorkspace: "Switch workspace · {workspace}",
searchSessions: "Search sessions…",
recent: "Recent",
noSessions: "No sessions yet",
noMatches: "No matches",
messageCount: "{count} messages",
deleteSession: "Delete session",
importSessions: "Import session",
importDetected: "Detected",
importCustom: "Custom",
importFoundApps: "Found apps",
importScanning: "Scanning local apps…",
importSessionCount: "{count} sessions · imports all",
importNotFound: "No local sessions found",
importPrivacyHint: "Your existing app settings are not changed.",
importResult: "Imported {imported} session(s), skipped {skipped}, failed {failed}.",
continue: "Continue",
refresh: "Refresh",
importSource: "Source",
importFromClaude: "Claude",
importFromCodex: "Codex",
importPath: "Session file path",
importName: "Session name",
importNamePlaceholder: "Optional custom name",
importConfirm: "Import",
browse: "Browse",
renameSession: "Rename session",
renamePlaceholder: "Session title",
approvalRules: "Approval rules",
@@ -650,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",
@@ -663,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}",
@@ -679,7 +823,7 @@ export const en = {
riskMedium: "risk · medium · {kind}",
execute: "Execute",
reject: "Reject",
alwaysAllow: "Always allow \"{prefix}\"",
alwaysAllow: 'Always allow "{prefix}"',
pathAccessKind: "path access",
read: "read",
write: "write",
+28 -4
View File
@@ -1,10 +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";
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(["en", "zh-CN"]);
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",
ja: "日本語",
ru: "Русский",
};
const STORAGE_KEY = "reasonix.lang";
type Listener = () => void;
@@ -18,7 +29,12 @@ function detectDefault(): Lang {
/* private mode */
}
const nav = typeof navigator !== "undefined" ? navigator.language : "";
return nav.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
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";
}
let currentLang: Lang = detectDefault();
@@ -38,6 +54,14 @@ export function getLang(): Lang {
return currentLang;
}
export function getSupportedLangs(): readonly Lang[] {
return SUPPORTED_LANGS;
}
export function getLangLabel(lang: Lang): string {
return LANG_LABELS[lang];
}
export function setLang(lang: Lang): void {
if (!SUPPORTED.has(lang) || lang === currentLang) return;
currentLang = lang;
@@ -63,7 +87,7 @@ export function useLang(): Lang {
return currentLang;
}
const dicts = { en, "zh-CN": zhCN } 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,
};
+149 -5
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,12 +184,30 @@ 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)",
webSearchEngineBrave: "brave — 独立索引,每月 2000 次免费(设置 BRAVE_SEARCH_API_KEY)",
webSearchEngineOllama: "ollama — Ollama 云端网页搜索(设置 OLLAMA_API_KEY)",
webSearchEngineNote: "下一次 web_search 调用生效",
webSearchEndpoint: "SearXNG 端点",
webSearchEndpointHint: "自托管 SearXNG 实例的根 URL。留空 = http://localhost:8080。",
webSearchApiKeySignup: "(获取 key)",
webSearchApiKeyClear: "清除",
webSearchApiKey: {
metaso: "Metaso API key",
baidu: "百度 API key",
tavily: "Tavily API key",
perplexity: "Perplexity API key",
exa: "Exa API key",
brave: "Brave API key",
ollama: "Ollama API key",
},
baseUrl: "DeepSeek base URL",
baseUrlHint: "走代理时再改。留空 = 官方地址。需重启。",
workspace: "工作目录",
@@ -250,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。",
@@ -290,8 +369,8 @@ export const zhCN: typeof en = {
shortcutSettings: "设置",
},
modal: {
planFeedbackPlaceholder: "可选反馈 — 想要修改时写这里",
choiceCustomPlaceholder: "或者自己写一个答案…",
planFeedbackPlaceholder: "计划存在哪些问题?需要改进哪些地方?",
choiceCustomPlaceholder: "计划可以如何进一步细化?",
},
plan: {
activeTitle: "进行中的计划",
@@ -306,12 +385,15 @@ export const zhCN: typeof en = {
},
editMode: {
label: "审批模式",
plan: "plan",
review: "review",
auto: "auto",
yolo: "yolo",
planDesc: "只读 · 不改文件,不跑命令",
reviewDesc: "每次写入都要确认",
autoDesc: "改文件自动,命令仍要确认",
yoloDesc: "全部放行",
planHint: "只读模式 · 所有写工具都不会派发",
reviewHint: "每个工具调用都需要批准",
autoHint: "命中白名单的命令自动批准",
yoloHint: "全部自动批准 · 谨慎使用",
@@ -364,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: {
@@ -402,6 +488,14 @@ export const zhCN: typeof en = {
abort: "中断流式输出",
copyLast: "复制最后一条回复",
switchModel: "切换模型",
mcp: "打开 MCP 服务器设置",
mcpStatus: "查看 MCP 连接状态",
mcpRetry: "重试失败的 MCP 连接",
mcpImport: "从 cc-switch 导入 MCP",
searchEngine: "配置搜索引擎 + API key",
skill: "列出 / 管理用户技能(项目 + 自定义 + 全局 + 内置)",
setMode: "切换模式为 {mode}",
setEffort: "切换推理强度为 {effort}",
toggleTheme: "切换深浅主题",
toggleCurrency: "切换货币显示 (CNY / USD)",
toggleLang: "切换界面语言 (中 / 英)",
@@ -438,6 +532,10 @@ export const zhCN: typeof en = {
jumpToBottom: "回到底部",
splashSubtitle: "DeepSeek Agents",
connecting: "正在连接 reasonix 内核…",
startupFailedTitle: "Reasonix 无法启动",
startupFailedMessage: "桌面后端在打开工作区前启动失败。",
startupFailedRetry: "重试",
startupFailedUnknown: "未知启动错误",
langZH: "中文",
langEN: "English",
titlebar: {
@@ -552,6 +650,12 @@ export const zhCN: typeof en = {
cost: "花费",
elapsed: "耗时",
},
mcpCard: {
showTools: "工具",
hideTools: "收起工具",
availableTools: "{count} 个可用工具",
noTools: "已连接,但该服务器未暴露可调用工具",
},
contextPanel: {
reservedKey: "预留",
usedKey: "已用",
@@ -578,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: "项目",
@@ -602,12 +716,35 @@ export const zhCN: typeof en = {
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: "审批规则",
@@ -636,6 +773,8 @@ export const zhCN: typeof en = {
unknownKind: "# 未知卡片类型 — fallback 渲染",
},
thread: {
toolCalls: "{count} 个工具调用",
oneToolCall: "1 个工具调用",
keepSteps: "保留剩余 {n} 步",
keepOriginal: "保留原计划",
you: "你",
@@ -649,6 +788,10 @@ export const zhCN: typeof en = {
approve: "批准",
cancel: "取消",
refine: "细化",
sendFeedback: "发送",
skipFeedback: "跳过",
cancelFeedbackLabel: "取消 — 哪里有问题?",
refineFeedbackLabel: "细化 — 还需要什么?",
checkpointKind: "计划检查点",
checkpointTitle: "step {completed} / {total} 完成",
checkpointSub: "检查点 · {completed} / {total}",
@@ -665,7 +808,7 @@ export const zhCN: typeof en = {
riskMedium: "risk · 中 · {kind}",
execute: "执行",
reject: "拒绝",
alwaysAllow: "始终允许 \"{prefix}\"",
alwaysAllow: '始终允许 "{prefix}"',
pathAccessKind: "路径访问",
read: "读取",
write: "写入",
@@ -701,6 +844,7 @@ export const zhCN: typeof en = {
readBody: "\u8bfb\u53d6\uff1a{path}",
writeBody: "\u5199\u5165\uff1a{path}",
turnCompleteTitle: "\u4efb\u52a1\u5df2\u5b8c\u6210",
turnCompleteBody: "\u5f53\u524d\u4efb\u52a1\u5df2\u7ed3\u675f\uff0c\u53ef\u4ee5\u56de\u6765\u67e5\u770b\u7ed3\u679c\u4e86\u3002",
turnCompleteBody:
"\u5f53\u524d\u4efb\u52a1\u5df2\u7ed3\u675f\uff0c\u53ef\u4ee5\u56de\u6765\u67e5\u770b\u7ed3\u679c\u4e86\u3002",
},
};
+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;
+135 -3
View File
@@ -102,7 +102,36 @@ export type PlanClearedEvent = { type: "$plan_cleared" };
export type SessionsEvent = {
type: "$sessions";
items: { name: string; messageCount: number; mtime: string; summary?: string }[];
items: {
name: string;
messageCount: number;
mtime: string;
summary?: string;
workspaceStatus?: "matched" | "legacy_missing_meta";
}[];
};
export type ExternalSessionSource = "claude" | "codex";
export type ExternalSessionApp = {
source: ExternalSessionSource;
label: string;
root: string;
available: boolean;
sessionCount: number;
latestMtime?: string;
};
export type SessionImportSourcesEvent = {
type: "$session_import_sources";
apps: ExternalSessionApp[];
};
export type SessionImportResultEvent = {
type: "$session_import_result";
imported: number;
skipped: number;
failed: number;
};
export type MentionResultsEvent = {
@@ -120,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;
@@ -138,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 = {
@@ -150,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 = {
@@ -174,9 +239,12 @@ export type CtxBreakdownEvent = {
};
export type MemoryEntryInfo = {
kind: "project_file" | "global_file" | "structured";
name: string;
scope: "project" | "global";
path: string;
description: string;
type?: string;
};
export type MemoryEvent = {
@@ -184,6 +252,16 @@ export type MemoryEvent = {
entries: MemoryEntryInfo[];
};
export type MemoryDetail = MemoryEntryInfo & {
body: string;
createdAt?: string;
};
export type MemoryDetailEvent = {
type: "$memory_detail";
detail: MemoryDetail;
};
export type RetryResultEvent = { type: "$retry_result"; text: string };
export type BtwResultEvent = { type: "$btw_result"; question: string; answer: string };
@@ -250,17 +328,21 @@ export type NeedsSetupEvent = {
reason: "no_api_key";
};
export type EditMode = "review" | "auto" | "yolo";
export type EditMode = "review" | "auto" | "yolo" | "plan";
export type ReasoningEffort = "low" | "medium" | "high" | "max";
export type WebSearchEngineName =
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa";
| "exa"
| "brave"
| "ollama";
export type SettingsEvent = {
type: "$settings";
@@ -273,7 +355,18 @@ 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;
ollama?: string;
brave?: string;
};
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
version: string;
@@ -292,11 +385,19 @@ export type QQSettingsEvent = {
access: string;
};
export type BalanceInfoItem = {
currency: string;
total: number;
granted?: number;
toppedUp?: number;
};
export type BalanceEvent = {
type: "$balance";
currency: string;
total: number;
isAvailable: boolean;
balanceInfos: BalanceInfoItem[];
};
export type SettingsPatch = {
@@ -305,10 +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;
};
@@ -428,6 +541,8 @@ export type IncomingEvent = { tabId?: string } & (
| ChoiceRequiredEvent
| PlanRequiredEvent
| SessionsEvent
| SessionImportSourcesEvent
| SessionImportResultEvent
| SessionLoadedEvent
| SessionEmptyEvent
| NeedsSetupEvent
@@ -440,12 +555,14 @@ export type IncomingEvent = { tabId?: string } & (
| PlanClearedEvent
| MentionResultsEvent
| MentionPreviewEvent
| PromptHistoryResultEvent
| TabOpenedEvent
| TabClosedEvent
| McpSpecsEvent
| SkillsEvent
| CtxBreakdownEvent
| MemoryEvent
| MemoryDetailEvent
| JobsEvent
| UserMessageEvent
| ModelTurnStartedEvent
@@ -473,6 +590,10 @@ export type OutgoingCommand = { tabId?: string } & (
| { cmd: "session_delete"; name: string }
| { cmd: "session_load"; name: string }
| { cmd: "session_rename"; name: string; title: string }
| { cmd: "session_import"; source: ExternalSessionSource; path: string; name?: string }
| { cmd: "session_import_scan" }
| { cmd: "session_import_bulk"; sources: ExternalSessionSource[] }
| { cmd: "memory_read"; path: string }
| { cmd: "new_chat" }
| { cmd: "setup_save_key"; key: string }
| { cmd: "settings_get" }
@@ -484,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" }
+74
View File
@@ -0,0 +1,74 @@
import type { EditMode, ReasoningEffort } from "./protocol";
export const SLASH_EDIT_MODES = [
"plan",
"review",
"auto",
"yolo",
] as const satisfies readonly EditMode[];
export const SLASH_REASONING_EFFORTS = [
"low",
"medium",
"high",
"max",
] as const satisfies readonly ReasoningEffort[];
export type SlashSettingsCommand =
| { type: "editMode"; editMode: EditMode }
| { type: "reasoningEffort"; reasoningEffort: ReasoningEffort };
export type SlashSettingsDescriptor = {
cmd: string;
action: SlashSettingsCommand;
};
function isEditMode(value: string): value is EditMode {
return (SLASH_EDIT_MODES as readonly string[]).includes(value);
}
function isReasoningEffort(value: string): value is ReasoningEffort {
return (SLASH_REASONING_EFFORTS as readonly string[]).includes(value);
}
export function parseSlashSettingsCommand(input: string): SlashSettingsCommand | null {
const match = /^\/([a-zA-Z0-9_-]+)(?:\s+([^\s]+))?$/.exec(input.trim());
if (!match) return null;
const name = match[1]?.toLowerCase();
const arg = match[2]?.toLowerCase();
if (name === "effort") {
if (arg && isReasoningEffort(arg)) return { type: "reasoningEffort", reasoningEffort: arg };
return null;
}
if (name === "plan" && !arg) {
return { type: "editMode", editMode: "plan" };
}
if ((name === "model" || name === "mode" || name === "plan") && arg && isEditMode(arg)) {
return { type: "editMode", editMode: arg };
}
return null;
}
export function buildSlashSettingsDescriptors(): SlashSettingsDescriptor[] {
const modelModes = SLASH_EDIT_MODES.map((mode) => ({
cmd: `/model ${mode}`,
action: { type: "editMode", editMode: mode } as const,
}));
const planModes = [
{ cmd: "/plan", action: { type: "editMode", editMode: "plan" } as const },
...SLASH_EDIT_MODES.filter((mode) => mode !== "plan").map((mode) => ({
cmd: `/plan ${mode}`,
action: { type: "editMode", editMode: mode } as const,
})),
];
const effortCommands = SLASH_REASONING_EFFORTS.map((effort) => ({
cmd: `/effort ${effort}`,
action: { type: "reasoningEffort", reasoningEffort: effort } as const,
}));
return [...modelModes, ...planModes, ...effortCommands];
}
+1460 -70
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 ? (
+258
View File
@@ -0,0 +1,258 @@
// @vitest-environment jsdom
import { fireEvent, render, waitFor } from "@testing-library/react";
import { createRef } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setLang } from "../i18n";
import { Composer } from "./composer";
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
afterEach(() => {
vi.clearAllMocks();
});
function renderComposer(props?: Partial<React.ComponentProps<typeof Composer>>) {
const textareaRef = createRef<HTMLTextAreaElement>();
const onMentionQuery = vi.fn();
const utils = render(
<Composer
draft=""
setDraft={vi.fn()}
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={textareaRef}
slashCommands={[]}
onMentionQuery={onMentionQuery}
onMentionPreview={vi.fn()}
onMentionPicked={vi.fn()}
mentionResults={null}
workspaceDir="/repo"
{...props}
/>,
);
return { ...utils, textareaRef, onMentionQuery };
}
describe("desktop Composer @ popup", () => {
beforeEach(() => {
setLang("en");
});
it("keeps the active mention row when async results shrink", async () => {
const { container, rerender, onMentionQuery } = renderComposer();
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("missing textarea");
fireEvent.change(textarea, { target: { value: "@re" } });
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;
rerender(
<Composer
draft="@re"
setDraft={vi.fn()}
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={vi.fn()}
onMentionPicked={vi.fn()}
mentionResults={{ nonce, query: "re", results: ["alpha", "beta", "gamma"] }}
workspaceDir="/repo"
/>,
);
await waitFor(() => {
expect(container.querySelector('.popup-item[data-active="true"]')?.textContent).toContain(
"alpha",
);
});
const items = container.querySelectorAll(".popup-item");
fireEvent.mouseEnter(items[1]!);
expect(container.querySelector('.popup-item[data-active="true"]')?.textContent).toContain(
"beta",
);
rerender(
<Composer
draft="@re"
setDraft={vi.fn()}
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={vi.fn()}
onMentionPicked={vi.fn()}
mentionResults={{ nonce, query: "re", results: ["alpha", "beta"] }}
workspaceDir="/repo"
/>,
);
await waitFor(() => {
expect(container.querySelector('.popup-item[data-active="true"]')?.textContent).toContain(
"beta",
);
});
});
it("uses the wider at popup class for mention results", async () => {
const { container, rerender, onMentionQuery } = renderComposer();
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("missing textarea");
fireEvent.change(textarea, { target: { value: "@re" } });
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;
rerender(
<Composer
draft="@re"
setDraft={vi.fn()}
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={vi.fn()}
onMentionPicked={vi.fn()}
mentionResults={{ nonce, query: "re", results: ["alpha"] }}
workspaceDir="/repo"
/>,
);
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 ",
);
});
});
+300 -120
View File
@@ -9,6 +9,7 @@ import {
useState,
} from "react";
import type React from "react";
import { invoke } from "@tauri-apps/api/core";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { t, type TKey } from "../i18n";
import { I } from "../icons";
@@ -20,13 +21,14 @@ import { fmtElapsed } from "./live";
import { Shortcut } from "./shortcut";
export type ReasoningEffort = "low" | "medium" | "high" | "max";
export type EditMode = "review" | "auto" | "yolo";
export type EditMode = "review" | "auto" | "yolo" | "plan";
type ModeEntry = { k: EditMode; label: TKey; icon: React.ReactNode; hint: TKey };
const EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "max"];
const MODE_INFO: ModeEntry[] = [
{ k: "plan", label: "editMode.plan", icon: <I.list size={11} />, hint: "editMode.planHint" },
{ k: "review", label: "editMode.review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
{ k: "auto", label: "editMode.auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
{ k: "yolo", label: "editMode.yolo", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
@@ -68,6 +70,7 @@ export type SlashCmd = {
};
export type MentionItem = {
name: string;
path?: string;
kind: "file" | "dir" | "url" | "agent" | "clip";
desc?: string;
};
@@ -76,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} />,
@@ -107,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} />;
@@ -116,9 +184,23 @@ function atIcon(k: MentionItem["kind"]) {
return <I.at size={12} />;
}
function guessImageExtension(mime: string): string {
const normalized = mime.toLowerCase();
if (normalized === "image/jpeg") return "jpg";
if (normalized === "image/png") return "png";
if (normalized === "image/gif") return "gif";
if (normalized === "image/webp") return "webp";
if (normalized === "image/svg+xml") return "svg";
const slash = normalized.indexOf("/");
return slash >= 0 ? normalized.slice(slash + 1).replace(/[^a-z0-9]+/g, "") || "png" : "png";
}
export function Composer({
draft,
setDraft,
onDraftUserEdit,
promptHistoryBrowsing,
onPromptHistoryNavigate,
onSend,
onAbort,
disabled,
@@ -143,7 +225,10 @@ export function Composer({
onDequeueSend,
}: {
draft: string;
setDraft: (s: string) => void;
setDraft: React.Dispatch<React.SetStateAction<string>>;
onDraftUserEdit?: () => void;
promptHistoryBrowsing?: boolean;
onPromptHistoryNavigate?: (direction: "older" | "newer") => boolean | void;
onSend: () => void;
onAbort: () => void;
disabled?: boolean;
@@ -170,18 +255,58 @@ 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;
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();
};
useLayoutEffect(() => {
const textarea = textareaRef.current;
@@ -195,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]);
@@ -230,19 +356,31 @@ export function Composer({
: undefined,
});
if (typeof picked !== "string" || !picked) return;
const rel =
workspaceDir && picked.startsWith(workspaceDir)
? picked.slice(workspaceDir.length).replace(/^[\\/]+/, "")
: picked;
setDraft(draft ? `${draft.replace(/\s+$/, "")} @${rel} ` : `@${rel} `);
setChips((c) => [...c, { kind: "at", label: rel }]);
onMentionPicked?.(rel);
textareaRef.current?.focus();
insertMention(picked);
} catch (err) {
console.error("attach failed", err);
}
};
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(e.clipboardData?.items ?? []);
const imageItem = items.find((item) => item.type.startsWith("image/"));
if (!imageItem) return;
const file = imageItem.getAsFile();
if (!file) return;
e.preventDefault();
try {
const buffer = await file.arrayBuffer();
const savedPath = await invoke<string>("save_clipboard_image", {
bytes: buffer,
extension: guessImageExtension(file.type),
});
insertMention(savedPath);
} catch (err) {
console.error("clipboard image paste failed", err);
}
};
const slashItems = useMemo(() => {
if (!popup || popup.kind !== "slash") return [];
const q = popup.query.toLowerCase();
@@ -253,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) {
@@ -275,96 +417,118 @@ export function Composer({
useEffect(() => {
setActiveIdx(0);
}, [items.length, popup?.kind]);
}, [popup?.kind]);
useEffect(() => {
setActiveIdx((i) => (items.length ? Math.min(i, items.length - 1) : 0));
}, [items.length]);
useEffect(() => {
if (!popup || popup.kind !== "at" || !onMentionQuery) return;
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>) => {
@@ -387,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;
@@ -421,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());
}
}
};
@@ -511,40 +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}
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
@@ -637,9 +814,8 @@ export function Composer({
disabled={disabled || !draft.trim()}
onClick={() => {
if (!disabled && draft.trim()) {
recordSendAndReset();
onSend();
setChips([]);
setPickedChips(new Map());
}
}}
>
@@ -658,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);
}
}}
@@ -690,12 +867,15 @@ function Popup({
useEffect(() => {
requestAnimationFrame(() => {
const el = listRef.current?.querySelector<HTMLElement>(`[data-active="true"]`);
el?.scrollIntoView({ block: "nearest" });
el?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
});
}, [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>
@@ -708,7 +888,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={{
@@ -742,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}
+60
View File
@@ -20,6 +20,7 @@ const usage: UsageStats = {
lastCallCacheHit: null,
lastCallCacheMiss: null,
reservedTokens: 0,
liveLogTokens: 0,
};
const settings: Settings = {
@@ -41,6 +42,8 @@ function renderPanel() {
mcpBridged={false}
sessionFiles={[{ path: "src/new-file.ts", status: "m" }]}
memory={[]}
memoryDetail={null}
onReadMemory={() => {}}
/>,
);
}
@@ -69,4 +72,61 @@ describe("ContextPanel files", () => {
await waitFor(() => expect(openPath).toHaveBeenCalledWith("/repo/src/new-file.ts"));
});
it("renders live log tokens even before final usage arrives", () => {
render(
<ContextPanel
settings={settings}
usage={{ ...usage, reservedTokens: 50, liveLogTokens: 100 }}
mcpSpecs={[]}
mcpBridged={false}
sessionFiles={[]}
memory={[]}
memoryDetail={null}
onReadMemory={() => {}}
/>,
);
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");
});
});
+165 -63
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, MemoryEntryInfo } from "../protocol";
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;
@@ -18,6 +20,13 @@ export function ContextPanel({
mcpBridged,
sessionFiles,
memory,
memoryDetail,
activeTab,
activeTabNonce,
onReadMemory,
onOpenMcpSettings,
onEditMcpSpec,
onRetryMcpSpec,
}: {
settings: Settings | null;
usage: UsageStats;
@@ -25,14 +34,26 @@ export function ContextPanel({
mcpBridged: boolean;
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;
const cached = Math.max(0, lastHit - reserved);
const used = Math.max(0, lastMiss - Math.max(0, reserved - lastHit));
const observedLog = Math.max(0, lastHit + lastMiss - reserved);
const logTokens = Math.max(usage.liveLogTokens, observedLog);
const cached = Math.min(logTokens, Math.max(0, lastHit - reserved));
const used = Math.max(0, logTokens - cached);
const reservedPct = Math.min(100, (reserved / CONTEXT_MAX_TOKENS) * 100);
const usedPct = Math.min(100, (used / CONTEXT_MAX_TOKENS) * 100);
const cachedPct = Math.min(100, (cached / CONTEXT_MAX_TOKENS) * 100);
@@ -55,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">
@@ -87,12 +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} />}
{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>
);
@@ -104,11 +137,14 @@ type TreeNode =
async function openContextFile(path: string, settings: Settings | null): Promise<void> {
const workspaceDir = settings?.workspaceDir;
const sep = workspaceDir?.includes("\\") ? "\\" : "/";
const isWindows = workspaceDir?.includes("\\") ?? false;
const sep = isWindows ? "\\" : "/";
const abs =
workspaceDir && !/^[a-zA-Z]:[\\/]/.test(path) && !path.startsWith("/")
? `${workspaceDir.replace(/[\\/]$/, "")}${sep}${path.replace(/^[\\/]+/, "")}`
: path;
? `${workspaceDir.replace(/[\\/]$/, "")}${sep}${path.replace(/^[\\/]+/, "").replace(/\//g, sep)}`
: isWindows
? path.replace(/\//g, "\\")
: path;
const editor = settings?.editor?.trim();
if (editor) {
await invoke("open_in_editor", { command: editor, path: abs, line: null });
@@ -225,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">
@@ -242,50 +306,75 @@ 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>
);
}
function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
function CtxMemory({
entries,
detail,
onRead,
}: {
entries: MemoryEntryInfo[];
detail: MemoryDetail | null;
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">
@@ -295,15 +384,28 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
{entries.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
) : (
<div className="mem">
{entries.map((m) => (
<div className="mem-row" key={`${m.scope}/${m.name}`}>
<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>
<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">
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef, useState } from "react";
interface JumpBarProps {
messages: { kind: string; text?: string; turn?: number }[];
threadEl?: HTMLElement | null;
onScrollToTurn?: (turn: number) => void;
}
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);
const previewTop = useRef(0);
const [showPreview, setShowPreview] = useState(false);
const items = useMemo(
() =>
messages
.filter((m): m is { kind: "user"; text: string; turn: number } =>
m.kind === "user" && typeof m.text === "string" && typeof m.turn === "number",
)
.map((m) => ({ turn: m.turn, text: m.text.slice(0, 80) })),
[messages],
);
useEffect(() => {
if (items.length > 0) setActive(items[items.length - 1]!.turn);
}, [items]);
useEffect(() => {
if (active === null) return;
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [active]);
if (items.length < 2) return null;
const hoverIdx = hovered !== null ? items.findIndex((v) => v.turn === hovered) : -1;
const hoverText = hovered !== null ? items.find((v) => v.turn === hovered)?.text : null;
const onMove = (e: React.MouseEvent) => {
const el = barRef.current;
if (!el) return;
const q = el.querySelectorAll<HTMLElement>(".jump-item");
const barRect = el.getBoundingClientRect();
let closest = -1;
let closestDist = Infinity;
q.forEach((item, i) => {
const r = item.getBoundingClientRect();
const midY = r.top + r.height / 2;
const dist = Math.abs(e.clientY - midY);
if (dist < closestDist) {
closestDist = dist;
closest = i;
previewTop.current = midY - barRect.top;
}
});
if (closest >= 0 && closest < items.length) {
const turn = items[closest]?.turn;
if (turn !== undefined) {
setHovered(turn);
setShowPreview(true);
}
}
};
const scrollTo = (turn: number) => {
setActive(turn);
if (onScrollToTurn) {
onScrollToTurn(turn);
} else {
threadEl
?.querySelector(`[data-turn="${turn}"]`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const dotProps = (
idx: number,
turn: number,
): { style: React.CSSProperties; "data-d"?: string } => {
const isActive = active === turn;
if (hoverIdx < 0) {
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
}
const d = Math.abs(idx - hoverIdx);
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
return {
style: { width, transitionDelay: `${d * 20}ms`, background },
"data-d": d <= 2 ? String(d) : undefined,
};
};
return (
<div className="jump-bar" ref={barRef} onMouseMove={onMove} onMouseLeave={() => { setHovered(null); setShowPreview(false); }}>
<div className="jump-scroll">
{items.map((item, idx) => (
<div className="jump-item" key={item.turn} data-turn={item.turn} onClick={() => scrollTo(item.turn)}>
<div className="jump-dot" {...dotProps(idx, item.turn)} />
</div>
))}
</div>
{showPreview && hoverText && (
<div className="jump-preview" style={{ top: previewTop.current }}>
<span className="jump-text">{hoverText}</span>
</div>
)}
</div>
);
}
+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>
);
}
+626 -68
View File
@@ -1,8 +1,16 @@
import { type ReactNode, useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { type ReactNode, useEffect, useRef, useState } from "react";
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
import { setLang, t, useLang } from "../i18n";
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
import { I } from "../icons";
import type { McpSpecInfo, SettingsPatch, SkillInfo } from "../protocol";
import type {
ImportedMcpServer,
McpSpecInfo,
MemoryDetail,
MemoryEntryInfo,
SettingsPatch,
SkillInfo,
} from "../protocol";
import {
describeQQRowSummary,
getQQConnectIntent,
@@ -20,6 +28,7 @@ import {
type ThemeStyle,
themeForStyle,
} from "../theme";
import { McpServerCard } from "./mcp-server-card";
import { Shortcut, type ShortcutKey } from "./shortcut";
export type PageId =
@@ -59,9 +68,13 @@ export function SettingsModal({
customFontFamily,
onSetCustomFontFamily,
initialPage,
initialMcpEditRaw,
initialMcpEditNonce,
mcpSpecs,
mcpBridged,
skills,
memory,
memoryDetail,
qq,
onClose,
onSave,
@@ -72,8 +85,12 @@ export function SettingsModal({
onSaveQQConfig,
onOpenQQApplyLink,
onPickWorkspace,
onImportCcSwitchMcp,
onAddMcpSpec,
onRemoveMcpSpec,
onUpdateMcpSpec,
onRetryMcpSpec,
onReadMemory,
}: {
settings: SettingsType;
balance: Balance | null;
@@ -90,9 +107,13 @@ export function SettingsModal({
customFontFamily: string;
onSetCustomFontFamily: (family: string) => void;
initialPage?: PageId;
initialMcpEditRaw?: string | null;
initialMcpEditNonce?: number;
mcpSpecs: McpSpecInfo[];
mcpBridged: boolean;
skills: SkillInfo[];
memory: MemoryEntryInfo[];
memoryDetail: MemoryDetail | null;
qq: QQDesktopSettingsState | null;
onClose: () => void;
onSave: (patch: SettingsPatch) => void;
@@ -103,11 +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") {
@@ -178,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" && (
@@ -189,7 +222,9 @@ export function SettingsModal({
onSave={onSave}
/>
)}
{page === "memory" && <PageMemory />}
{page === "memory" && (
<PageMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
)}
{page === "rules" && <PageRules settings={settings} onSave={onSave} />}
{page === "billing" && (
<PageBilling balance={balance} usage={usage} currency={currency} />
@@ -591,16 +626,11 @@ function PageGeneral({
<div className="h">{t("settings.languageHint")}</div>
</div>
<div className="seg-ctrl">
<button
type="button"
data-on={lang === "zh-CN"}
onClick={() => setLang("zh-CN")}
>
{t("settings.langZhCn")}
</button>
<button type="button" data-on={lang === "en"} onClick={() => setLang("en")}>
{t("settings.langEn")}
</button>
{getSupportedLangs().map((code) => (
<button type="button" key={code} data-on={lang === code} onClick={() => setLang(code)}>
{getLangLabel(code)}
</button>
))}
</div>
</div>
</section>
@@ -626,7 +656,7 @@ function PageGeneral({
value={editorDraft}
placeholder="cursor --goto"
onChange={(e) => setEditorDraft(e.target.value)}
onBlur={() => onSave({ editor: editorDraft || undefined })}
onBlur={() => onSave({ editor: editorDraft.trim() })}
/>
</div>
</section>
@@ -657,7 +687,7 @@ function PageGeneral({
<div className="h">{t("settings.editModeHint")}</div>
</div>
<div className="seg-ctrl">
{(["review", "auto", "yolo"] as const).map((m) => (
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
<button
type="button"
key={m}
@@ -691,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>
@@ -719,27 +771,199 @@ function PageGeneral({
onSave({
webSearchEngine: e.target.value as
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa",
| "exa"
| "brave"
| "ollama",
})
}
>
<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>
<option value="brave">{t("settings.webSearchEngineBrave")}</option>
<option value="ollama">{t("settings.webSearchEngineOllama")}</option>
</select>
</div>
<WebSearchEngineCredentials settings={settings} onSave={onSave} />
</section>
</>
);
}
const SEARCH_ENGINE_API_KEY_FIELDS: ReadonlyArray<{
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",
patchKey: "perplexityApiKey",
signupUrl: "https://www.perplexity.ai/settings/api",
},
{ engine: "exa", patchKey: "exaApiKey", signupUrl: "https://dashboard.exa.ai/api-keys" },
{ engine: "brave", patchKey: "braveApiKey", signupUrl: "https://brave.com/search/api/" },
{ engine: "ollama", patchKey: "ollamaApiKey", signupUrl: "https://ollama.com/settings/keys" },
];
function WebSearchEngineCredentials({
settings,
onSave,
}: {
settings: SettingsType;
onSave: (patch: SettingsPatch) => void;
}) {
const engine = settings.webSearchEngine ?? "bing";
if (engine === "bing" || engine === "bing-intl") return null;
if (engine === "searxng") {
return <SearxngEndpointRow settings={settings} onSave={onSave} />;
}
const field = SEARCH_ENGINE_API_KEY_FIELDS.find((f) => f.engine === engine);
if (!field) return null;
const prefix = settings.webSearchApiKeys?.[engine];
return (
<WebSearchApiKeyRow
engine={engine}
patchKey={field.patchKey}
signupUrl={field.signupUrl}
prefix={prefix}
onSave={onSave}
/>
);
}
function SearxngEndpointRow({
settings,
onSave,
}: {
settings: SettingsType;
onSave: (patch: SettingsPatch) => void;
}) {
const [draft, setDraft] = useState(settings.webSearchEndpoint ?? "");
useEffect(() => {
setDraft(settings.webSearchEndpoint ?? "");
}, [settings.webSearchEndpoint]);
return (
<div className="setting-row">
<div className="l">
<div className="n">{t("settings.webSearchEndpoint")}</div>
<div className="h">{t("settings.webSearchEndpointHint")}</div>
</div>
<input
className="field mono"
value={draft}
placeholder="http://localhost:8080"
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
const next = draft.trim();
if (next === (settings.webSearchEndpoint ?? "")) return;
onSave({ webSearchEndpoint: next || null });
}}
/>
</div>
);
}
function WebSearchApiKeyRow({
engine,
patchKey,
signupUrl,
prefix,
onSave,
}: {
engine: "metaso" | "baidu" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
patchKey:
| "metasoApiKey"
| "baiduApiKey"
| "tavilyApiKey"
| "perplexityApiKey"
| "exaApiKey"
| "braveApiKey"
| "ollamaApiKey";
signupUrl: string;
prefix?: string;
onSave: (patch: SettingsPatch) => void;
}) {
const [draft, setDraft] = useState("");
const label = t(`settings.webSearchApiKey.${engine}` as const);
return (
<div className="setting-row">
<div className="l">
<div className="n">{label}</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({ [patchKey]: trimmed } as SettingsPatch);
setDraft("");
}}
>
{t("settings.apiKeySave")}
</button>
{prefix ? (
<button
type="button"
className="btn"
onClick={() => onSave({ [patchKey]: null } as SettingsPatch)}
>
{t("settings.webSearchApiKeyClear")}
</button>
) : null}
</div>
</div>
);
}
function ApiKeySection({
baseUrl,
apiKeyPrefix,
@@ -796,7 +1020,7 @@ function ApiKeySection({
className="field mono"
value={urlDraft}
onChange={(e) => setUrlDraft(e.target.value)}
onBlur={() => onSave({ baseUrl: urlDraft.trim() || undefined })}
onBlur={() => onSave({ baseUrl: urlDraft.trim() })}
/>
</div>
</section>
@@ -861,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>
@@ -889,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
@@ -935,31 +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>
<div className="nm">{s.name ?? "(anonymous)"}</div>
<div className="sub">{s.summary}</div>
</div>
<span className="grow" />
<button
type="button"
className="btn ghost"
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>
@@ -990,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,
@@ -1074,22 +1612,42 @@ function PageSkills({
);
}
function PageMemory() {
function PageMemory({
entries,
detail,
onRead,
}: {
entries: MemoryEntryInfo[];
detail: MemoryDetail | null;
onRead: (path: string) => void;
}) {
return (
<section className="section">
<div className="stitle">{t("settings.memorySection")}</div>
<div
style={{
padding: 16,
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 10,
fontSize: 12,
color: "var(--muted)",
}}
>
{t("settings.memoryDesc")}
</div>
{entries.length === 0 ? (
<div className="muted-card">{t("settings.memoryDesc")}</div>
) : (
<div className="memory-browser">
<div className="memory-list">
{entries.map((m) => (
<button
type="button"
className="memory-item"
data-active={detail?.path === m.path}
key={m.path}
onClick={() => onRead(m.path)}
>
<span className="memory-kind">{m.kind.replace("_", " ")}</span>
<span className="memory-name">{m.description || m.name}</span>
<span className="memory-path">{m.path}</span>
</button>
))}
</div>
<pre className="memory-detail">
{detail ? detail.body : t("settings.memoryDesc")}
</pre>
</div>
)}
</section>
);
}
@@ -1111,7 +1669,7 @@ function PageRules({
<div className="h">{t("settings.editModeHint")}</div>
</div>
<div className="seg-ctrl">
{(["review", "auto", "yolo"] as const).map((m) => (
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
<button
type="button"
key={m}
+306 -4
View File
@@ -1,7 +1,9 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import type { SessionInfo } from "../App";
import { t, useLang } from "../i18n";
import { I } from "../icons";
import type { ExternalSessionApp, ExternalSessionSource } from "../protocol";
import { Shortcut } from "./shortcut";
const RENAME_MAX_CHARS = 200;
@@ -13,6 +15,13 @@ type PendingDelete = {
y: number;
};
type PendingImport = {
x: number;
y: number;
};
type ImportSource = ExternalSessionSource;
function prettyName(s: SessionInfo): string {
if (s.summary && s.summary.trim()) return s.summary.trim();
const m = s.name.match(/^desktop-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(?:-(\d+))?$/);
@@ -41,22 +50,34 @@ function relative(ms: number): string {
export function Sidebar({
sessions,
importSources,
activeName,
workspaceDir,
onNewChat,
onLoadSession,
onDeleteSession,
onRenameSession,
onRefreshImportSources,
onImportDetectedSessions,
onImportSession,
onOpenWorkdir,
onOpenSettings,
onOpenRules,
onOpenCommands,
onOpenAbout,
}: {
sessions: SessionInfo[];
importSources: ExternalSessionApp[];
activeName?: string;
workspaceDir?: string;
onNewChat: () => void;
onLoadSession: (name: string) => void;
onDeleteSession: (name: string) => void;
onRenameSession: (name: string, title: string) => void;
onRefreshImportSources: () => void;
onImportDetectedSessions: (sources: ImportSource[]) => void;
onImportSession: (payload: { source: ImportSource; path: string; name?: string }) => void;
onOpenWorkdir: (anchor: { top?: number; bottom?: number; left: number }) => void;
onOpenSettings: () => void;
onOpenRules: () => void;
onOpenCommands: () => void;
@@ -65,8 +86,12 @@ export function Sidebar({
useLang();
const [query, setQuery] = useState("");
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null);
const [editingName, setEditingName] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const workspaceLabel = workspaceDir
? workspaceDir.split(/[\\/]/).pop() || workspaceDir
: t("sidebarPanel.noWorkspace");
const filtered = query
? sessions.filter((s) => {
const q = query.toLowerCase();
@@ -77,13 +102,15 @@ export function Sidebar({
: sessions;
useEffect(() => {
if (!pendingDelete) return;
if (!pendingDelete && !pendingImport) return;
const onMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target?.closest(".session-delete-popover")) setPendingDelete(null);
if (!target?.closest(".session-import-popover")) setPendingImport(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setPendingDelete(null);
if (e.key === "Escape") setPendingImport(null);
};
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("keydown", onKey);
@@ -91,7 +118,7 @@ export function Sidebar({
window.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("keydown", onKey);
};
}, [pendingDelete]);
}, [pendingDelete, pendingImport]);
return (
<aside className="sidebar">
@@ -101,6 +128,18 @@ export function Sidebar({
<span>{t("sidebarPanel.newChat")}</span>
<Shortcut keys={["mod", "N"]} />
</button>
<button
type="button"
className="icon-btn"
title={t("sidebarPanel.importSessions")}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
onRefreshImportSources();
setPendingImport({ x: rect.right, y: rect.bottom });
}}
>
<I.upload size={14} />
</button>
<button
type="button"
className="icon-btn"
@@ -111,6 +150,31 @@ export function Sidebar({
</button>
</div>
<div className="side-workspace">
<button
type="button"
className="workspace-btn"
title={
workspaceDir
? t("sidebarPanel.switchWorkspace", { workspace: workspaceDir })
: t("sidebarPanel.pickWorkspace")
}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
onOpenWorkdir({ top: rect.bottom + 6, left: rect.left });
}}
>
<span className="ico">
<I.folder size={13} />
</span>
<span className="body">
<span className="label">{t("sidebarPanel.workspace")}</span>
<span className="name">{workspaceLabel}</span>
</span>
<I.chev size={12} />
</button>
</div>
<div className="search-row">
<div className="input">
<I.search size={13} />
@@ -220,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 : (
@@ -306,6 +372,22 @@ export function Sidebar({
}}
/>
) : null}
{pendingImport ? (
<SessionImportPopover
target={pendingImport}
importSources={importSources}
onRefresh={onRefreshImportSources}
onCancel={() => setPendingImport(null)}
onImportDetected={(sources) => {
onImportDetectedSessions(sources);
setPendingImport(null);
}}
onImport={(payload) => {
onImportSession(payload);
setPendingImport(null);
}}
/>
) : null}
</aside>
);
}
@@ -365,3 +447,223 @@ function SessionDeletePopover({
</div>
);
}
function SessionImportPopover({
target,
importSources,
onRefresh,
onCancel,
onImportDetected,
onImport,
}: {
target: PendingImport;
importSources: ExternalSessionApp[];
onRefresh: () => void;
onCancel: () => void;
onImportDetected: (sources: ImportSource[]) => void;
onImport: (payload: { source: ImportSource; path: string; name?: string }) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({
left: target.x,
top: target.y,
});
const [mode, setMode] = useState<"detected" | "custom">("detected");
const [selected, setSelected] = useState<ImportSource[]>([]);
const [source, setSource] = useState<ImportSource>("claude");
const [path, setPath] = useState("");
const [name, setName] = useState("");
useEffect(() => {
const available = importSources.filter((app) => app.available).map((app) => app.source);
setSelected((prev) => {
const kept = prev.filter((source) => available.includes(source));
return kept.length > 0 ? kept : available;
});
}, [importSources]);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pad = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = target.x;
let top = target.y;
if (left + rect.width + pad > vw) left = Math.max(pad, vw - rect.width - pad);
if (top + rect.height + pad > vh) top = Math.max(pad, vh - rect.height - pad);
if (left !== pos.left || top !== pos.top) setPos({ left, top });
if (mode === "custom") firstInputRef.current?.focus();
}, [target.x, target.y, pos.left, pos.top, mode]);
const browse = async () => {
try {
const picked = await openFileDialog({
multiple: false,
directory: false,
filters: [{ name: "JSONL", extensions: ["jsonl", "json"] }],
});
if (typeof picked === "string" && picked) setPath(picked);
} catch (err) {
console.error("session import browse failed", err);
}
};
const submit = () => {
const trimmedPath = path.trim();
const trimmedName = name.trim();
if (!trimmedPath) return;
onImport({
source,
path: trimmedPath,
name: trimmedName || undefined,
});
};
const submitDetected = () => {
if (selected.length === 0) return;
onImportDetected(selected);
};
const toggleSource = (source: ImportSource) => {
setSelected((prev) =>
prev.includes(source) ? prev.filter((item) => item !== source) : [...prev, source],
);
};
return (
<div
ref={ref}
className="session-import-popover"
role="dialog"
aria-modal="true"
style={{ left: pos.left, top: pos.top }}
>
<div className="head">
<span>{t("sidebarPanel.importSessions")}</span>
<button type="button" className="close" onClick={onCancel} aria-label={t("sidebarPanel.cancel")}>
<I.x size={12} />
</button>
</div>
<div className="import-tabs">
<button type="button" data-on={mode === "detected"} onClick={() => setMode("detected")}>
{t("sidebarPanel.importDetected")}
</button>
<button type="button" data-on={mode === "custom"} onClick={() => setMode("custom")}>
{t("sidebarPanel.importCustom")}
</button>
</div>
{mode === "detected" ? (
<>
<div className="import-detected-head">
<span>{t("sidebarPanel.importFoundApps")}</span>
<button type="button" onClick={onRefresh} title={t("sidebarPanel.refresh")}>
<I.refresh size={12} />
</button>
</div>
<div className="import-app-list">
{importSources.length === 0 ? (
<div className="import-empty">{t("sidebarPanel.importScanning")}</div>
) : null}
{importSources.map((app) => {
const checked = selected.includes(app.source);
return (
<button
key={app.source}
type="button"
className="import-app-row"
data-disabled={!app.available || undefined}
onClick={() => {
if (app.available) toggleSource(app.source);
}}
>
<span className="app-icon">
{app.source === "claude" ? <I.terminal size={15} /> : <I.bot size={15} />}
</span>
<span className="app-body">
<span className="app-name">{app.label}</span>
<span className="app-meta">
{app.available
? t("sidebarPanel.importSessionCount", {
count: app.sessionCount,
})
: t("sidebarPanel.importNotFound")}
</span>
</span>
<span className="switch" data-on={checked && app.available ? true : undefined} />
</button>
);
})}
</div>
<div className="hint">{t("sidebarPanel.importPrivacyHint")}</div>
<div className="actions">
<button type="button" className="cancel" onClick={onCancel}>
{t("sidebarPanel.cancel")}
</button>
<button
type="button"
className="confirm"
disabled={selected.length === 0}
onClick={submitDetected}
>
{t("sidebarPanel.continue")}
</button>
</div>
</>
) : (
<>
<div className="field">
<span className="label">{t("sidebarPanel.importSource")}</span>
<div className="seg">
<button type="button" data-on={source === "claude"} onClick={() => setSource("claude")}>
{t("sidebarPanel.importFromClaude")}
</button>
<button type="button" data-on={source === "codex"} onClick={() => setSource("codex")}>
{t("sidebarPanel.importFromCodex")}
</button>
</div>
</div>
<div className="field">
<span className="label">{t("sidebarPanel.importPath")}</span>
<div className="path-row">
<input
ref={firstInputRef}
value={path}
placeholder={t("sidebarPanel.importPath")}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && path.trim()) submit();
}}
/>
<button type="button" onClick={() => void browse()}>
{t("sidebarPanel.browse")}
</button>
</div>
</div>
<div className="field">
<span className="label">{t("sidebarPanel.importName")}</span>
<input
value={name}
placeholder={t("sidebarPanel.importNamePlaceholder")}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && path.trim()) submit();
}}
/>
</div>
<div className="actions">
<button type="button" className="cancel" onClick={onCancel}>
{t("sidebarPanel.cancel")}
</button>
<button type="button" className="confirm" disabled={!path.trim()} onClick={submit}>
<I.upload size={11} />
{t("sidebarPanel.importConfirm")}
</button>
</div>
</>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { t } from "../i18n";
export type StartupFailureState = {
details: string[];
};
function toErrorText(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
export function coerceStartupFailure(
error: unknown,
stderrLines: readonly string[] = [],
): StartupFailureState {
const details = [toErrorText(error), ...stderrLines].map((line) => line.trim()).filter(Boolean);
return { details: details.length > 0 ? details : [t("app.startupFailedUnknown")] };
}
export function StartupFailure({
details,
onRetry,
}: {
details: readonly string[];
onRetry: () => void;
}) {
return (
<main className="startup-failure" role="alert" aria-live="assertive">
<section className="startup-failure-panel">
<p className="startup-failure-kicker">{t("app.errorLabel")}</p>
<h1>{t("app.startupFailedTitle")}</h1>
<p>{t("app.startupFailedMessage")}</p>
<pre>{details.join("\n")}</pre>
<button type="button" onClick={onRetry}>
{t("app.startupFailedRetry")}
</button>
</section>
</main>
);
}
+35 -6
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,
@@ -52,8 +62,17 @@ export function StatusBar({
onOpenSettings: () => void;
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 sessionPromptTokens =
usage.totalPromptTokens || usage.cacheHitTokens + usage.cacheMissTokens;
const liveContextTokens = usage.reservedTokens + usage.liveLogTokens;
const totalTokens = Math.max(sessionPromptTokens, liveContextTokens);
const cacheDenom = usage.cacheHitTokens + usage.cacheMissTokens;
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
@@ -85,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} />
@@ -137,10 +156,20 @@ export function StatusBar({
<span className="v vio">{settings?.model ?? "—"}</span>
<span className="v">{settings?.reasoningEffort ?? "high"}</span>
</span>
<span className="seg" title={t("statusbar.switchCurrency")} onClick={onToggleCurrency}>
<span
className="seg"
title={t("statusbar.switchCurrency")}
onClick={onToggleCurrency}
>
<I.coin size={11} />
<span>{t("statusbar.balance")}</span>
<span className="v ok">{balanceLabel}</span>
<span className="v ok">
{balance && balance.infos.length > 0
? balance.infos
.map((info) => `${info.currency === "USD" ? "$" : "¥"} ${info.total.toFixed(2)}`)
.join(" / ")
: balanceLabel}
</span>
</span>
<span
ref={themeButtonRef}
+11
View File
@@ -0,0 +1,11 @@
export function getThreadMaxWidth({
viewportWidth,
visibleSide,
visibleCtx,
}: {
viewportWidth: number;
visibleSide: number;
visibleCtx: number;
}): number {
return Math.max(580, Math.min(viewportWidth - visibleSide - visibleCtx - 80, 1120));
}
+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;
}
+30 -8
View File
@@ -51,9 +51,8 @@ export function useAutoScroll(
[containerRef],
);
// User-intent detection: only these gestures un-pin. Scroll events are
// intentionally NOT listened to — they can't tell user gestures from our
// own scrollTo.
// Un-pin only on real user gestures; scroll events can't tell our own
// scrollTo from the user, so they're only honored during an active drag.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
@@ -69,20 +68,43 @@ export function useAutoScroll(
});
};
// Scrollbar drag fires no wheel/touch; without scroll-watching here,
// the ResizeObserver re-pins mid-drag and the thumb rubber-bands.
let dragging = false;
const onScrollDuringDrag = () => {
isPinnedRef.current = isAtBottom();
refreshJumpButton();
};
const onPointerDown = () => {
onUserGesture();
if (dragging) return;
dragging = true;
el.addEventListener("scroll", onScrollDuringDrag, { passive: true });
};
const endDrag = () => {
if (!dragging) return;
dragging = false;
el.removeEventListener("scroll", onScrollDuringDrag);
onUserGesture();
};
el.addEventListener("wheel", onUserGesture, { passive: true });
el.addEventListener("touchmove", onUserGesture, { passive: true });
el.addEventListener("keydown", onUserGesture);
// pointerdown on the scrollbar gutter starts a drag-scroll. The
// drag itself fires no wheel/touch, but pointerdown's followup
// scroll arrives within a frame; one rAF measure catches it.
el.addEventListener("pointerdown", onUserGesture);
el.addEventListener("pointerdown", onPointerDown);
// Release may land outside the container if the pointer drifts.
window.addEventListener("pointerup", endDrag);
window.addEventListener("pointercancel", endDrag);
return () => {
if (pendingFrame) cancelAnimationFrame(pendingFrame);
el.removeEventListener("wheel", onUserGesture);
el.removeEventListener("touchmove", onUserGesture);
el.removeEventListener("keydown", onUserGesture);
el.removeEventListener("pointerdown", onUserGesture);
el.removeEventListener("pointerdown", onPointerDown);
el.removeEventListener("scroll", onScrollDuringDrag);
window.removeEventListener("pointerup", endDrag);
window.removeEventListener("pointercancel", endDrag);
};
}, [containerRef, isAtBottom, refreshJumpButton]);
+28 -3
View File
@@ -1,7 +1,9 @@
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";
const PERSIST_KEY_CTX = "reasonix.ctxWidth";
@@ -31,15 +33,19 @@ export function useResizable(
const draggingRef = useRef(false);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
// Track latest width via ref so mouseup handler always saves the current value.
const widthRef = useRef(width);
widthRef.current = width;
const cssVar = CSS_VAR[side];
const appRef = useRef<HTMLElement | null>(null);
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
draggingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = widthRef.current;
const appEl = document.querySelector(".app") as HTMLElement | null;
appRef.current = appEl;
if (appEl) appEl.dataset.dragging = "true";
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, []);
@@ -49,6 +55,9 @@ export function useResizable(
const onMove = (e: MouseEvent) => {
if (!draggingRef.current) return;
const appEl = appRef.current;
if (!appEl) return;
const delta = e.clientX - startXRef.current;
let next: number;
if (side === "side") {
@@ -58,12 +67,28 @@ export function useResizable(
}
const maxW = Math.floor(window.innerWidth * MAX_WIDTH_PCT);
next = Math.max(MIN_WIDTH, Math.min(next, maxW));
widthRef.current = next;
appEl.style.setProperty(cssVar, `${next}px`);
setWidth(next);
const otherVar = side === "side" ? "--ctx-width" : "--side-width";
const otherW = Number.parseFloat(appEl.style.getPropertyValue(otherVar)) || 0;
const tMax = getThreadMaxWidth({
viewportWidth: window.innerWidth,
visibleSide: side === "side" ? next : otherW,
visibleCtx: side === "ctx" ? next : otherW,
});
appEl.style.setProperty("--thread-max-width", `${tMax}px`);
appEl.style.setProperty("--composer-max-width", `${tMax}px`);
};
const onUp = () => {
if (!draggingRef.current) return;
draggingRef.current = false;
const appEl = appRef.current;
if (appEl) delete appEl.dataset.dragging;
appRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
try {
@@ -79,7 +104,7 @@ export function useResizable(
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [collapsed, side, persistKey]);
}, [collapsed, side, persistKey, cssVar]);
return { width, onMouseDown };
}
+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: {
+22 -14
View File
@@ -122,24 +122,32 @@ dragging 12KB through every future prompt.
A proactive 40% context-ratio threshold runs the same shrink pre-emptively
inside long multi-iter turns before the 80% emergency threshold fires.
#### 4.3 `/pro` single-turn arming
#### 4.3 Model selection (`/model`)
Users who predict a hard task type `/pro`; the **next** turn runs on
`v4-pro`, then auto-disarms. No preset churn, no forgotten revert. Armed
state is visible as a yellow `⇧ pro armed` pill in the header.
Users switch between flash and pro via `/model flash` or `/model pro`
(persistent — applies to every turn until changed). Model can also be
set in `.reasonix/settings.json` under the `model` key. No one-shot
arming; no forgotten revert risk when switching is explicit and sticky.
#### 4.4 Failure-signal auto-escalation
> **History.** Pre-0.50.0, `/pro` offered single-turn arming — type `/pro`,
> the next turn ran on v4-pro then auto-disarmed. Removed in 0.50.0
> (#1657, #1630) when presets were collapsed into direct model selection.
The loop counts visible "flash is struggling" events per turn:
- `edit_file` / `write_file` SEARCH-not-found errors
- ToolCallRepair fires (scavenge / truncation-fix / storm-break)
#### 4.4 Model self-report escalation (`<<<NEEDS_PRO>>>`)
Once the count hits `FAILURE_ESCALATION_THRESHOLD` (3), the **remainder of
the current turn** runs on `v4-pro`. Announced via a yellow warning row —
no silent cost surprises. Counter + escalation flag reset at every turn
start.
The model itself decides when a task exceeds its current tier. If a task
clearly needs stronger reasoning, the model emits a `<<<NEEDS_PRO>>>`
marker as the first line of its response. The system aborts the current
flash call and retries the turn on pro. Two forms:
Header shows a red `⇧ pro escalated` pill while the turn is on pro.
- `<<<NEEDS_PRO>>>` — bare marker, no rationale.
- `<<<NEEDS_PRO: <reason>>>>` — includes a one-sentence rationale the
user sees in a warning row.
On the pro tier, the marker is a no-op — pro is the top, so the contract
tells the model it can't escalate further. This is purely self-report:
there is no failure-counter threshold, no scavenge/storm counting, no
automatic escalation based on tool errors.
#### Cost transparency
@@ -170,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 |
+8 -9
View File
@@ -125,14 +125,13 @@
</span>
</a>
<div class="nav-links" role="navigation">
<a href="index.html#install">Install</a>
<a href="index.html#agents">Pillars</a>
<a href="index.html#features">Features</a>
<a href="index.html#config">Config</a>
<a href="configuration.html">Guide</a>
<a href="download.html">Download</a>
<a href="index.html#roadmap">Roadmap</a>
<a href="index.html#faq">FAQ</a>
<a class="nav-link-primary" href="index.html#install">Install</a>
<a class="nav-link-secondary" href="index.html#agents">Pillars</a>
<a class="nav-link-primary" href="index.html#features">Features</a>
<a class="nav-link-secondary" href="index.html#config">Config</a>
<a class="nav-link-persist" href="configuration.html">Guide</a>
<a class="nav-link-tertiary" href="index.html#roadmap">Roadmap</a>
<a class="nav-link-tertiary" href="index.html#faq">FAQ</a>
</div>
<div class="nav-cta">
<div class="lang-switch" role="group" aria-label="Language">
@@ -434,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>
+8 -9
View File
@@ -125,14 +125,13 @@
</span>
</a>
<div class="nav-links" role="navigation">
<a href="index.html#install">Install</a>
<a href="index.html#agents">Pillars</a>
<a href="index.html#features">Features</a>
<a href="index.html#config">Config</a>
<a href="configuration.html">Guide</a>
<a href="download.html">Download</a>
<a href="index.html#roadmap">Roadmap</a>
<a href="index.html#faq">FAQ</a>
<a class="nav-link-primary" href="index.html#install">Install</a>
<a class="nav-link-secondary" href="index.html#agents">Pillars</a>
<a class="nav-link-primary" href="index.html#features">Features</a>
<a class="nav-link-secondary" href="index.html#config">Config</a>
<a class="nav-link-persist" href="configuration.html">Guide</a>
<a class="nav-link-tertiary" href="index.html#roadmap">Roadmap</a>
<a class="nav-link-tertiary" href="index.html#faq">FAQ</a>
</div>
<div class="nav-cta">
<div class="lang-switch" role="group" aria-label="Language">
@@ -532,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>
+23 -14
View File
@@ -125,14 +125,13 @@
</span>
</a>
<div class="nav-links" role="navigation">
<a href="index.html#install">Install</a>
<a href="index.html#agents">Pillars</a>
<a href="index.html#features">Features</a>
<a href="index.html#config">Config</a>
<a href="configuration.html" style="color: var(--accent)">Guide</a>
<a href="download.html">Download</a>
<a href="index.html#roadmap">Roadmap</a>
<a href="index.html#faq">FAQ</a>
<a class="nav-link-primary" href="index.html#install">Install</a>
<a class="nav-link-secondary" href="index.html#agents">Pillars</a>
<a class="nav-link-primary" href="index.html#features">Features</a>
<a class="nav-link-secondary" href="index.html#config">Config</a>
<a class="nav-link-persist" href="configuration.html" style="color: var(--accent)">Guide</a>
<a class="nav-link-tertiary" href="index.html#roadmap">Roadmap</a>
<a class="nav-link-tertiary" href="index.html#faq">FAQ</a>
</div>
<div class="nav-cta">
<div class="lang-switch" role="group" aria-label="Language">
@@ -201,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>
@@ -622,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",
@@ -639,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 速览",
+4 -4
View File
@@ -172,10 +172,10 @@ function Hero() {
</a>
</div>
<div className="hero-stats">
<div className="hero-stat"><b>94<span style={{fontFamily:'var(--mono)',fontStyle:'normal',fontSize:18,marginLeft:2,color:'var(--cream-mute)'}}>%</span></b><span>Cache Hit</span></div>
<div className="hero-stat"><b>2.5<span style={{fontFamily:'var(--mono)',fontStyle:'normal',fontSize:18,marginLeft:2,color:'var(--cream-mute)'}}>×</span></b><span>Cost Down</span></div>
<div className="hero-stat"><b>2837</b><span>Tests</span></div>
<div className="hero-stat"><b>MIT</b><span>License</span></div>
<div className="hero-stat"><b>94<span className="metric-unit">%</span></b><span className="metric-label">Cache Hit</span></div>
<div className="hero-stat"><b>2.5<span className="metric-unit">×</span></b><span className="metric-label">Cost Down</span></div>
<div className="hero-stat"><b>2837</b><span className="metric-label">Tests</span></div>
<div className="hero-stat"><b>MIT</b><span className="metric-label">License</span></div>
</div>
</div>
<div style={{position:'relative'}}>
+11 -9
View File
@@ -12,14 +12,13 @@ function Nav({ active }) {
}, []);
const NAV_LINKS = [
{ href: "index.html#install", label: { zh: "安装", en: "Install" } },
{ href: "index.html#agents", label: { zh: "原理", en: "How it works" } },
{ href: "index.html#features", label: { zh: "特性", en: "Features" } },
{ href: "index.html#config", label: { zh: "配置", en: "Config" } },
{ href: "configuration.html", label: { zh: "Guide", en: "Guide" } },
{ href: "download.html", label: { zh: "下载", en: "Download" }, key: "download" },
{ href: "index.html#roadmap", label: { zh: "Roadmap", en: "Roadmap" } },
{ href: "index.html#faq", label: { zh: "FAQ", en: "FAQ" } },
{ href: "index.html#install", label: { zh: "安装", en: "Install" }, priority: "primary" },
{ href: "index.html#agents", label: { zh: "原理", en: "How it works" }, priority: "secondary" },
{ href: "index.html#features", label: { zh: "特性", en: "Features" }, priority: "primary" },
{ href: "index.html#config", label: { zh: "配置", en: "Config" }, priority: "secondary" },
{ href: "configuration.html", label: { zh: "Guide", en: "Guide" }, priority: "persist" },
{ href: "index.html#roadmap", label: { zh: "Roadmap", en: "Roadmap" }, priority: "tertiary" },
{ href: "index.html#faq", label: { zh: "FAQ", en: "FAQ" }, priority: "tertiary" },
];
return (
@@ -36,7 +35,10 @@ function Nav({ active }) {
<a
key={t(l.label, "en")}
href={l.href}
className={l.key && active === l.key ? "on" : ""}
className={[
`nav-link-${l.priority}`,
l.key && active === l.key ? "on" : "",
].filter(Boolean).join(" ")}
style={l.key && active === l.key ? { color: "var(--accent)" } : {}}
>
{t(l.label, lang)}
+1 -1
View File
@@ -74,7 +74,7 @@ function Roadmap() {
<h4>{t(c.title, lang)}</h4>
</header>
<ul>
{c.items.map((it, idx) => <li key={idx}>{t(it, lang)}</li>)}
{c.items.map((it, idx) => <li key={idx}><span>{t(it, lang)}</span></li>)}
</ul>
</div>
))}
+82 -27
View File
@@ -75,16 +75,17 @@ body {
border-bottom: 1px solid var(--rule);
}
.nav-inner {
max-width: var(--maxw);
max-width: 1680px;
margin: 0 auto;
padding: 16px var(--gutter);
display: flex;
align-items: center;
gap: 32px;
gap: clamp(20px, 2.5vw, 32px);
}
.brand {
display: flex; align-items: baseline; gap: 10px;
text-decoration: none; color: inherit;
flex-shrink: 0;
}
.brand-mark {
width: 22px; height: 22px;
@@ -98,7 +99,12 @@ body {
.brand-mark::after {
display: none;
}
.brand-name { display: flex; align-items: baseline; gap: 8px; }
.brand-name {
display: flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.brand-name b {
font-family: var(--serif);
font-style: italic;
@@ -115,8 +121,10 @@ body {
}
.nav-links {
display: flex; gap: 24px;
margin-left: 28px;
display: flex;
align-items: center;
gap: clamp(16px, 1.9vw, 24px);
margin-left: clamp(12px, 2vw, 28px);
font-family: var(--mono);
}
.nav-links a {
@@ -128,6 +136,7 @@ body {
transition: color 0.18s;
position: relative;
padding: 4px 0;
white-space: nowrap;
}
.nav-links a::after {
content: "";
@@ -142,6 +151,26 @@ body {
.nav-cta {
margin-left: auto;
display: flex; gap: 10px; align-items: center;
flex-shrink: 0;
}
@media (max-width: 1380px) {
.nav-link-tertiary { display: none; }
}
@media (max-width: 1160px) {
.nav-link-secondary { display: none; }
}
@media (max-width: 900px) {
.nav-link-primary { display: none; }
}
@media (max-width: 680px) {
.brand-name span,
.nav-cta .btn-ghost {
display: none;
}
}
/* ─── BUTTONS ──────────────────────────────────────────────── */
@@ -300,8 +329,8 @@ body {
.hero h1 {
font-family: var(--serif);
font-weight: 400;
font-size: clamp(54px, 8.6vw, 124px);
line-height: 0.95;
font-size: clamp(40px, 6.6vw, 76px);
line-height: 1;
letter-spacing: -0.02em;
margin: 0 0 28px;
color: var(--cream);
@@ -347,12 +376,16 @@ body {
max-width: 600px;
}
.hero-stat {
padding-right: 16px;
padding: 0 20px;
border-right: 1px solid var(--rule);
text-align: center;
}
.hero-stat:last-child { border-right: 0; }
.hero-stat b {
display: block;
display: flex;
align-items: baseline;
justify-content: center;
gap: 3px;
font-family: var(--serif);
font-style: italic;
font-weight: 400;
@@ -361,7 +394,17 @@ body {
line-height: 1;
color: var(--cream);
}
.hero-stat span {
.hero-stat .metric-unit {
display: inline;
margin-top: 0;
font-family: var(--mono);
font-style: normal;
font-size: 0.48em;
letter-spacing: 0;
text-transform: none;
color: var(--cream-mute);
}
.hero-stat .metric-label {
display: block;
margin-top: 8px;
font-family: var(--mono);
@@ -601,23 +644,18 @@ body {
@media (max-width: 880px) { .feat-grid { grid-template-columns: 1fr; } }
.feat {
position: relative;
padding: 36px 28px 36px 0;
padding: 36px 28px;
border-bottom: 1px solid var(--rule);
border-right: 1px solid var(--rule);
background: transparent;
border-radius: 0;
overflow: visible;
}
.feat:nth-child(3n) { border-right: 0; padding-right: 0; }
.feat:nth-child(3n) { border-right: 0; }
@media (max-width: 880px) {
.feat { border-right: 0; padding-right: 0; }
.feat { border-right: 0; }
}
.feat:nth-child(n+4) { padding-top: 36px; }
.feat > * { margin-left: 28px; }
.feat:nth-child(3n+1) > * { margin-left: 0; }
@media (max-width: 880px) {
.feat > * { margin-left: 0; }
}
.feat-num {
font-family: var(--mono);
@@ -1013,7 +1051,7 @@ body {
border-right: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
border-radius: 0;
padding: 28px 24px 28px 0;
padding: 28px 24px;
position: relative;
}
.rm-col:last-child { border-right: 0; }
@@ -1049,7 +1087,8 @@ body {
.rm-col ul { list-style: none; padding: 0; margin: 0; }
.rm-col li {
display: flex;
gap: 12px;
align-items: flex-start;
gap: 14px;
padding: 8px 0;
font-size: 13.5px;
color: var(--cream-dim);
@@ -1057,27 +1096,40 @@ body {
border-bottom: 1px solid var(--rule);
}
.rm-col li:last-child { border-bottom: 0; }
.rm-col li > span {
flex: 1;
min-width: 0;
}
.rm-col li::before {
content: "·";
content: "";
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--serif);
color: var(--cream-mute);
font-size: 18px;
line-height: 1;
width: auto; height: auto;
width: 16px;
height: 1.5em;
border: 0;
background: none !important;
background: radial-gradient(circle at 50% calc(50% - 3px), var(--cream-mute) 0 2.5px, transparent 2.75px);
flex-shrink: 0;
margin-top: -2px;
margin-top: 0;
text-align: center;
}
.rm-col.done li::before {
content: "✓";
background: none;
font-size: 13px;
color: var(--jade);
transform: translateY(1px);
}
.rm-col.now li::before {
content: "◐";
background: none;
font-size: 13px;
color: var(--accent);
transform: translateY(1px);
}
/* ─── FAQ ──────────────────────────────────────────────────── */
@@ -1272,7 +1324,7 @@ body {
.config-bullets li {
display: flex;
gap: 12px;
align-items: baseline;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid var(--rule);
font-size: 13px;
@@ -1283,12 +1335,14 @@ body {
}
.bullet-dot {
width: 5px; height: 5px;
margin-top: 0;
margin-top: 0.7em;
border-radius: 50%;
background: var(--accent);
box-shadow: none;
flex-shrink: 0;
align-self: center;
}
.config-bullets li > span:last-child {
flex: 1;
}
.config-files {
@@ -1631,6 +1685,7 @@ body {
.lang-switch {
display: inline-flex;
flex-shrink: 0;
border: 1px solid var(--rule-2);
border-radius: 6px;
overflow: hidden;
+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` 分支。
+576 -1191
View File
File diff suppressed because it is too large. Load diff
+34 -5
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.50.0",
"version": "0.52.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
@@ -22,6 +22,8 @@
"dashboard/index.html",
"dashboard/app.css",
"dashboard/dist",
"patches",
"scripts/postinstall.mjs",
"README.md",
"LICENSE"
],
@@ -29,7 +31,7 @@
"packages/*"
],
"scripts": {
"postinstall": "npm --prefix dashboard ci --ignore-scripts && npm --prefix desktop ci --ignore-scripts",
"postinstall": "node scripts/postinstall.mjs",
"build:dashboard": "npm --prefix dashboard run build",
"build": "npm run build:dashboard && tsup && node scripts/copy-dashboard-vendor-css.mjs && node scripts/copy-tree-sitter-grammars.mjs",
"dev": "tsx src/cli/index.ts",
@@ -44,7 +46,11 @@
"typecheck": "tsc --noEmit && tsc --noEmit -p dashboard",
"verify": "npm run build && npm run lint && npm run typecheck && npm run test --silent",
"prepare": "simple-git-hooks || true",
"prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm run test"
"prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm run test",
"pitch:pptx": "npx -y @marp-team/marp-cli pitch/pitch.md -o pitch/dist/pitch.pptx --allow-local-files",
"pitch:pdf": "npx -y @marp-team/marp-cli pitch/pitch.md -o pitch/dist/pitch.pdf --allow-local-files",
"pitch:html": "npx -y @marp-team/marp-cli pitch/pitch.md -o pitch/dist/pitch.html --allow-local-files",
"pitch:watch": "npx -y @marp-team/marp-cli pitch/pitch.md --preview --watch --allow-local-files"
},
"simple-git-hooks": {
"pre-commit": "npm run lint",
@@ -74,20 +80,41 @@
"node": ">=22"
},
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
"auto-bind": "^5.0.1",
"bidi-js": "^1.0.3",
"chalk": "^5.3.0",
"cli-boxes": "^4.0.1",
"cli-highlight": "^2.1.11",
"code-excerpt": "^4.0.0",
"commander": "^12.1.0",
"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",
"ink": "^7.0.2",
"ink-text-input": "^6.0.0",
"indent-string": "^5.0.0",
"ink": "workspace:*",
"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",
"signal-exit": "^4.1.0",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"supports-hyperlinks": "^4.0.0",
"undici": "^8.2.0",
"usehooks-ts": "^3.1.0",
"web-tree-sitter": "^0.26.9",
"wrap-ansi": "^9.0.0",
"ws": "^8.20.1",
"yoga-layout": "^3.2.1",
"zod": "^4.4.1"
},
"devDependencies": {
@@ -98,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",
@@ -108,6 +136,7 @@
"jsdom": "^29.1.1",
"marked": "^15.0.12",
"preact": "^10.22.0",
"react-test-renderer": "^19.2.6",
"simple-git-hooks": "^2.13.1",
"strip-ansi": "^7.1.0",
"tree-sitter-go": "^0.25.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsnix",
"version": "0.49.0",
"version": "0.52.0",
"description": "Short alias for reasonix — DeepSeek-native coding agent. Forwards to the reasonix CLI.",
"bin": {
"dsnix": "bin.cjs"
@@ -31,6 +31,6 @@
"node": ">=22"
},
"dependencies": {
"reasonix": "0.49.0"
"reasonix": "0.52.0"
}
}
+30
View File
@@ -0,0 +1,30 @@
MIT License
Copyright (c) 2026 esengine
Copyright (c) Vadim Demedes <[email protected]>
This package is derived from Ink (https://github.com/vadimdemedes/ink),
a React renderer for interactive command-line apps. Substantial portions
of the source code and design originate from that project and remain
copyright of its original authors. Modifications, extensions, and
additional modules added under this distribution are copyright of
esengine and are released under the same MIT terms.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
{
"name": "ink",
"version": "1.0.0",
"private": true,
"description": "TUI runtime — React reconciler + Yoga layout + diff-based terminal screen renderer. Derived from Ink (MIT).",
"license": "MIT",
"type": "module",
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"sideEffects": false,
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^19.2.0",
"react-reconciler": "^0.32.0",
"yoga-layout": "^3.2.1"
},
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
"auto-bind": "^5.0.1",
"bidi-js": "^1.0.3",
"chalk": "^5.3.0",
"cli-boxes": "^4.0.1",
"code-excerpt": "^4.0.0",
"emoji-regex": "^10.4.0",
"get-east-asian-width": "^1.3.0",
"indent-string": "^5.0.0",
"lodash-es": "^4.17.21",
"semver": "^7.6.3",
"signal-exit": "^4.1.0",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
"strip-ansi": "^7.1.0",
"supports-hyperlinks": "^4.0.0",
"type-fest": "^5.6.0",
"usehooks-ts": "^3.1.0",
"wrap-ansi": "^9.0.0"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.9.0",
"@types/react": "^19.2.14",
"@types/react-reconciler": "^0.31.0",
"@types/semver": "^7.5.8",
"@types/stack-utils": "^2.0.3",
"typescript": "^5.6.3"
}
}
+254
View File
@@ -0,0 +1,254 @@
import React from 'react';
import Link from './components/Link.js';
import Text from './components/Text.js';
import type { Color } from './styles.js';
import {
type NamedColor,
Parser,
type Color as TermioColor,
type TextStyle,
} from './termio.js';
type Props = {
children: string;
/** When true, force all text to be rendered with dim styling. */
dimColor?: boolean;
};
type SpanProps = {
color?: Color;
backgroundColor?: Color;
dim?: boolean;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
hyperlink?: string;
};
type Span = {
text: string;
props: SpanProps;
};
export const Ansi = React.memo(function Ansi({ children, dimColor }: Props) {
if (typeof children !== 'string') {
return dimColor ? <Text dim>{String(children)}</Text> : <Text>{String(children)}</Text>;
}
if (children === '') return null;
const spans = parseToSpans(children);
if (spans.length === 0) return null;
// Single-span fast path: skip the array map and avoid the wrapper
// <Text> when the only span has no styling of its own.
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
return dimColor ? <Text dim>{spans[0]!.text}</Text> : <Text>{spans[0]!.text}</Text>;
}
const content = spans.map((span, i) => {
const hyperlink = span.props.hyperlink;
if (dimColor) span.props.dim = true;
const hasTextProps = hasAnyTextProps(span.props);
if (hyperlink) {
return hasTextProps ? (
<Link key={i} url={hyperlink}>
<StyledText
color={span.props.color}
backgroundColor={span.props.backgroundColor}
dim={span.props.dim}
bold={span.props.bold}
italic={span.props.italic}
underline={span.props.underline}
strikethrough={span.props.strikethrough}
inverse={span.props.inverse}
>
{span.text}
</StyledText>
</Link>
) : (
<Link key={i} url={hyperlink}>
{span.text}
</Link>
);
}
return hasTextProps ? (
<StyledText
key={i}
color={span.props.color}
backgroundColor={span.props.backgroundColor}
dim={span.props.dim}
bold={span.props.bold}
italic={span.props.italic}
underline={span.props.underline}
strikethrough={span.props.strikethrough}
inverse={span.props.inverse}
>
{span.text}
</StyledText>
) : (
span.text
);
});
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>;
});
function parseToSpans(input: string): Span[] {
const parser = new Parser();
const actions = parser.feed(input);
const spans: Span[] = [];
let currentHyperlink: string | undefined;
for (const action of actions) {
if (action.type === 'link') {
currentHyperlink = action.action.type === 'start' ? action.action.url : undefined;
continue;
}
if (action.type === 'text') {
const text = action.graphemes.map((g) => g.value).join('');
if (!text) continue;
const props = textStyleToSpanProps(action.style);
if (currentHyperlink) props.hyperlink = currentHyperlink;
const lastSpan = spans[spans.length - 1];
if (lastSpan && propsEqual(lastSpan.props, props)) {
lastSpan.text += text;
} else {
spans.push({ text, props });
}
}
}
return spans;
}
function textStyleToSpanProps(style: TextStyle): SpanProps {
const props: SpanProps = {};
if (style.bold) props.bold = true;
if (style.dim) props.dim = true;
if (style.italic) props.italic = true;
if (style.underline !== 'none') props.underline = true;
if (style.strikethrough) props.strikethrough = true;
if (style.inverse) props.inverse = true;
const fg = colorToString(style.fg);
if (fg) props.color = fg;
const bg = colorToString(style.bg);
if (bg) props.backgroundColor = bg;
return props;
}
/** Map termio's named-colour identifiers to the `ansi:*` form Ink uses. */
const NAMED_COLOR_MAP: Record<NamedColor, string> = {
black: 'ansi:black',
red: 'ansi:red',
green: 'ansi:green',
yellow: 'ansi:yellow',
blue: 'ansi:blue',
magenta: 'ansi:magenta',
cyan: 'ansi:cyan',
white: 'ansi:white',
brightBlack: 'ansi:blackBright',
brightRed: 'ansi:redBright',
brightGreen: 'ansi:greenBright',
brightYellow: 'ansi:yellowBright',
brightBlue: 'ansi:blueBright',
brightMagenta: 'ansi:magentaBright',
brightCyan: 'ansi:cyanBright',
brightWhite: 'ansi:whiteBright',
};
function colorToString(color: TermioColor): Color | undefined {
switch (color.type) {
case 'named':
return NAMED_COLOR_MAP[color.name] as Color;
case 'indexed':
return `ansi256(${color.index})` as Color;
case 'rgb':
return `rgb(${color.r},${color.g},${color.b})` as Color;
case 'default':
return undefined;
}
}
function propsEqual(a: SpanProps, b: SpanProps): boolean {
return (
a.color === b.color &&
a.backgroundColor === b.backgroundColor &&
a.bold === b.bold &&
a.dim === b.dim &&
a.italic === b.italic &&
a.underline === b.underline &&
a.strikethrough === b.strikethrough &&
a.inverse === b.inverse &&
a.hyperlink === b.hyperlink
);
}
function hasAnyProps(props: SpanProps): boolean {
return (
props.color !== undefined ||
props.backgroundColor !== undefined ||
props.dim === true ||
props.bold === true ||
props.italic === true ||
props.underline === true ||
props.strikethrough === true ||
props.inverse === true ||
props.hyperlink !== undefined
);
}
function hasAnyTextProps(props: SpanProps): boolean {
return (
props.color !== undefined ||
props.backgroundColor !== undefined ||
props.dim === true ||
props.bold === true ||
props.italic === true ||
props.underline === true ||
props.strikethrough === true ||
props.inverse === true
);
}
type BaseTextStyleProps = {
color?: Color;
backgroundColor?: Color;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
};
type StyledTextProps = BaseTextStyleProps & {
bold?: boolean;
dim?: boolean;
children: React.ReactNode;
};
function StyledText({ bold, dim, children, ...rest }: StyledTextProps) {
if (dim) {
return (
<Text {...rest} dim>
{children}
</Text>
);
}
if (bold) {
return (
<Text {...rest} bold>
{children}
</Text>
);
}
return <Text {...rest}>{children}</Text>;
}
+27
View File
@@ -0,0 +1,27 @@
import { isEnvTruthy } from './envUtils.js';
type DebugOptions = {
reentrant?: boolean;
level?: string | number;
};
// Off by default. The env var lets us trace renderer activity in production
// builds without recompiling — useful when a downstream consumer hits a
// terminal-specific glitch we cannot reproduce locally.
let debugEnabled = isEnvTruthy(process.env['REASONIX_DEBUG_INK']);
export function setDebugLogging(enabled: boolean): void {
debugEnabled = enabled;
}
export function logForDebugging(message: string, _opts?: DebugOptions): void {
if (!debugEnabled) return;
// stderr can throw EPIPE when the receiving end has gone away (e.g. the
// process is being torn down). Diagnostics must never crash the host app,
// so we swallow — there is nowhere else to report this anyway.
try {
process.stderr.write(`[ink·debug] ${message}\n`);
} catch {
/* stderr unavailable during shutdown */
}
}
+7
View File
@@ -0,0 +1,7 @@
// Placeholder hook for a future "capture stdin before React mounts" feature.
// Some host CLIs need to buffer keystrokes typed during boot so they aren't
// dropped before the input reader is wired up. We keep the export so call
// sites compile, but right now there is nothing to stop.
export function stopCapturingEarlyInput(): void {
/* intentionally empty until early-input buffering is implemented */
}
+38
View File
@@ -0,0 +1,38 @@
// Terminal identification is needed to gate features (OSC 8 hyperlinks,
// Kitty keyboard protocol, sixel graphics, etc.). Detection runs once at
// module load: the host terminal does not change mid-process, and a static
// snapshot keeps every call site cheap.
type TerminalKind =
| 'kitty'
| 'iterm'
| 'wezterm'
| 'vscode'
| 'tmux'
| 'ghostty'
| 'windows-terminal'
| '';
function detectTerminal(): TerminalKind {
const termProgram = process.env['TERM_PROGRAM'] ?? '';
if (termProgram === 'iTerm.app') return 'iterm';
if (termProgram === 'vscode') return 'vscode';
if (termProgram === 'WezTerm') return 'wezterm';
if (termProgram === 'ghostty') return 'ghostty';
// Kitty doesn't set TERM_PROGRAM, but always sets either KITTY_WINDOW_ID
// or a TERM containing "kitty" (e.g. xterm-kitty).
if (process.env['KITTY_WINDOW_ID'] || process.env['TERM']?.includes('kitty')) {
return 'kitty';
}
// Windows Terminal exposes WT_SESSION. Check before tmux because tmux can
// legitimately run inside Windows Terminal and we want the outer host.
if (process.env['WT_SESSION']) return 'windows-terminal';
if (process.env['TMUX']) return 'tmux';
return '';
}
export const env = {
terminal: detectTerminal(),
} as const;
+13
View File
@@ -0,0 +1,13 @@
// Env vars are strings or undefined. Accept the common truthy spellings users
// reach for instinctively — "1", "true", "yes", "on" — so feature flags work
// regardless of which convention the caller picked up from another tool.
export function isEnvTruthy(value: string | undefined | null): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return (
normalized === '1' ||
normalized === 'true' ||
normalized === 'yes' ||
normalized === 'on'
);
}
@@ -0,0 +1,58 @@
import { execFile } from 'node:child_process';
type ExecResult = {
code: number;
stdout: string;
stderr: string;
};
type ExecOpts = {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeout?: number;
signal?: AbortSignal;
input?: string;
};
// Variant of `execFile` that never rejects. Callers (mainly OSC integrations
// probing optional terminal helpers) treat a non-zero exit code as data, not
// an exceptional condition — turning each invocation into a try/catch would
// be noise. The 10 MiB buffer matches Node's default ceiling shape; anything
// larger from a terminal helper would already indicate something is wrong.
export function execFileNoThrow(
file: string,
args: readonly string[],
opts: ExecOpts = {},
): Promise<ExecResult> {
return new Promise((resolve) => {
const child = execFile(
file,
args as string[],
{
cwd: opts.cwd,
env: opts.env ?? process.env,
timeout: opts.timeout,
signal: opts.signal,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
},
(err, stdout, stderr) => {
// Node attaches a numeric exit code on ExecException for normal
// non-zero exits, and a string code (e.g. 'ENOENT') for spawn
// failures. We only forward numeric codes; anything else collapses
// to a generic failure (1) so callers see a uniform shape.
const errnoCode = (err as NodeJS.ErrnoException | null)?.code;
const code =
typeof errnoCode === 'number' ? errnoCode : err ? 1 : 0;
resolve({
code,
stdout: stdout == null ? '' : String(stdout),
stderr: stderr == null ? '' : String(stderr),
});
},
);
if (opts.input !== undefined && child.stdin) {
child.stdin.end(opts.input);
}
});
}
+9
View File
@@ -0,0 +1,9 @@
import { isEnvTruthy } from './envUtils.js';
// Mouse tracking is a common pain point: it conflicts with terminal-level
// text selection (users can't copy output anymore) and breaks tmux's own
// mouse handling. The env var lets users opt out without code changes when
// they hit one of those scenarios.
export function isMouseClicksDisabled(): boolean {
return isEnvTruthy(process.env['REASONIX_DISABLE_MOUSE']);
}
+13
View File
@@ -0,0 +1,13 @@
// `Intl.Segmenter` construction is surprisingly expensive — measurable on
// the renderer hot path when called per-character. One module-level instance
// is enough: the segmenter is stateless across `.segment()` calls and we
// only ever need grapheme granularity for terminal width calculations.
let cachedSegmenter: Intl.Segmenter | null = null;
export function getGraphemeSegmenter(): Intl.Segmenter {
if (!cachedSegmenter) {
cachedSegmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
}
return cachedSegmenter;
}
+14
View File
@@ -0,0 +1,14 @@
/** Walk `text` line by line without allocating an intermediate array. */
export function forEachLine(
text: string,
visit: (line: string) => void | boolean,
): void {
let start = 0;
while (start <= text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.substring(start) : text.substring(start, end);
if (visit(line) === true) return;
if (end === -1) break;
start = end + 1;
}
}
+15
View File
@@ -0,0 +1,15 @@
// Centralised error sink so we have one place to redirect when embedding
// in a host that owns stderr (e.g. a TUI inside another TUI). Prefer the
// stack trace when available — for renderer bugs the call chain matters
// more than the message.
export function logError(err: unknown): void {
const message =
err instanceof Error ? err.stack ?? err.message : String(err);
// stderr may be closed during shutdown; refusing to log is preferable to
// crashing the host application.
try {
process.stderr.write(`[ink] ${message}\n`);
} catch {
/* stderr unavailable during shutdown */
}
}
+12
View File
@@ -0,0 +1,12 @@
import semver from "semver";
import { logError } from "./log.js";
/** Tolerant `>=` comparison for terminal-reported version strings. */
export function gte(a: string, b: string): boolean {
try {
return semver.gte(semver.coerce(a) ?? a, semver.coerce(b) ?? b);
} catch (error) {
logError(`semver gte(${a}, ${b}) failed: ${(error as Error).message}`);
return false;
}
}
Loaded 100 of 568 files, more files were not shown because too many files have changed in this diff. Show more