Compare commits

...
170 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
497 changed files with 51255 additions and 4468 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
+11 -9
View File
@@ -24,13 +24,11 @@ jobs:
fail-fast: false
matrix:
target:
# `rust` is the comma-separated list installed by dtolnay/rust-toolchain;
# `tauri` is what we pass to `cargo tauri build --target`. They differ
# only for the universal Mac shard, which produces one fat binary from
# two real Rust targets via lipo. Intel-only macos-13 runner is dropped
# — GH retired most of that pool, queue waits ran to hours.
# macOS now ships two single-arch shards so Apple Silicon and Intel
# users get separate DMGs. `rust` and `tauri` stay aligned per shard.
- { os: ubuntu-22.04, label: "linux-x64", rust: "x86_64-unknown-linux-gnu", tauri: "x86_64-unknown-linux-gnu" }
- { os: macos-14, label: "macos-universal", rust: "aarch64-apple-darwin,x86_64-apple-darwin", tauri: "universal-apple-darwin" }
- { os: macos-15-intel, label: "macos-x64", rust: "x86_64-apple-darwin", tauri: "x86_64-apple-darwin" }
- { os: macos-14, label: "macos-arm64", rust: "aarch64-apple-darwin", tauri: "aarch64-apple-darwin" }
- { os: windows-latest, label: "windows-x64", rust: "x86_64-pc-windows-msvc", tauri: "x86_64-pc-windows-msvc" }
runs-on: ${{ matrix.target.os }}
# secrets.* isn't accessible from step-level if:, so expose presence as a
@@ -96,14 +94,18 @@ jobs:
working-directory: desktop
run: npm run bundle:node
- name: Verify bundled Node is universal (macOS)
- name: Verify bundled Node matches host arch (macOS)
if: startsWith(matrix.target.os, 'macos-')
working-directory: desktop
run: |
expected_arch=$(uname -m)
archs=$(lipo -archs src-tauri/binaries/node)
echo "node archs: $archs"
echo "$archs" | grep -q "arm64"
echo "$archs" | grep -q "x86_64"
echo "expected arch: $expected_arch"
if [ "$archs" != "$expected_arch" ]; then
echo "Expected $expected_arch Node, got: $archs" >&2
exit 1
fi
codesign -d --entitlements :- src-tauri/binaries/node 2>&1 | grep -q "com.apple.security.inherit"
- name: Build & verify desktop frontend
+2
View File
@@ -25,6 +25,8 @@ sessions/
.turbo/
# Local-only Claude Code settings — personal overrides, never committed.
.claude/settings.local.json
# Local agent notes — personal workflow reminders, never committed.
agent.md
# Per-user Reasonix state under .reasonix/ — committable team-level
# files (settings.json, skills/) stay tracked, but the user-private
# bits (semantic index, sessions, opt-out markers) never should.
+53
View File
@@ -3,6 +3,59 @@
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>`
+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/>
+3 -2
View File
@@ -59,8 +59,9 @@ hit rates:
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
the cached prefix so it never poisons the next hit.
4. **Auto-compact** — when context approaches the cap, older turns fold into
a summary message *appended* to the prefix; the prefix itself isn't
rewritten, so the cache survives the fold.
a summary message. The summary request is shaped to reuse the main agent's
already-cached system/tool/history prefix; the following main-agent request
still pays a cold miss for the newly synthesized summary segment.
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
the bytes cacheable.
+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/**"
]
}
}
+131 -6
View File
@@ -69,6 +69,7 @@ import { WorkdirPop } from "./ui/workdir-pop";
import { WorkdirInputModal } from "./ui/workdir-input-modal";
import { useAutoScroll } from "./ui/useAutoScroll";
import { useDisableTextAssist } from "./ui/useDisableTextAssist";
import { elideTranscriptMessages } from "./ui/transcript-elision";
import { openUrl } from "@tauri-apps/plugin-opener";
export type AssistantSegment =
@@ -193,7 +194,20 @@ export type Settings = {
recentWorkspaces: string[];
model: string;
editor?: string;
webSearchEngine?: "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa";
webSearchEngine?:
| "bing"
| "bing-intl"
| "searxng"
| "metaso"
| "baidu"
| "tavily"
| "perplexity"
| "exa"
| "brave"
| "ollama";
webSearchApiKeys?: {
baidu?: string;
};
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
version: string;
@@ -306,6 +320,10 @@ function nextMessageTurn(messages: ChatMessage[]): number {
}
function reduce(state: State, action: Action): State {
return withElidedTranscript(reduceRaw(state, action));
}
function reduceRaw(state: State, action: Action): State {
switch (action.t) {
case "send_user": {
return {
@@ -470,6 +488,11 @@ function reduce(state: State, action: Action): State {
}
}
function withElidedTranscript(state: State): State {
const messages = elideTranscriptMessages(state.messages);
return messages === state.messages ? state : { ...state, messages };
}
const READING_TOOLS = new Set(["read_file"]);
const MODIFYING_TOOLS = new Set(["edit_file", "write_file"]);
@@ -548,6 +571,10 @@ function appendTextSegment(
}
export function applyIncoming(state: State, ev: IncomingEvent): State {
return withElidedTranscript(applyIncomingRaw(state, ev));
}
function applyIncomingRaw(state: State, ev: IncomingEvent): State {
switch (ev.type) {
case "user.message": {
const last = state.messages[state.messages.length - 1];
@@ -649,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);
@@ -669,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,
@@ -746,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,
@@ -1365,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],
@@ -1384,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],
@@ -1528,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: () => {
+21
View File
@@ -250,12 +250,21 @@ export const de: typeof en = {
webSearchNote: "web_fetch + web_search Werkzeuge",
webSearchEngine: "Suchmaschine",
webSearchEngineBing: "bing — Standard, funktioniert von CN ohne Proxy",
webSearchEngineBingIntl: "bing-intl — internationaler Index über www.bing.com",
webSearchEngineSearxng: "searxng — selbst gehostet (Endpunkt via /se searxng <url>)",
webSearchEngineMetaso: "metaso — 100/Tag kostenlos (CN-freundlich, kostenloser Schlüssel auf metaso.cn)",
webSearchEngineBaidu: "baidu — Baidu AI Search (BAIDU_API_KEY oder QIANFAN_API_KEY setzen)",
webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)",
webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)",
webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)",
webSearchEngineBrave: "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)",
webSearchEngineOllama: "ollama — Ollama Cloud-Websuche (OLLAMA_API_KEY setzen)",
webSearchEngineNote: "gilt für den nächsten web_search-Aufruf",
webSearchApiKeySignup: "(Schlüssel holen)",
webSearchApiKeyClear: "Löschen",
webSearchApiKey: {
baidu: "Baidu API-Schlüssel",
},
sectionBudget: "Budget",
budget: "Budget",
budgetHint: "Ausgabengrenzen festlegen",
@@ -321,10 +330,18 @@ export const de: typeof en = {
themeStyleHint: "Theme-Stil wählen",
themeStyleGraphite: "Graphit",
themeStyleGraphiteDesc: "Kühle dunkle Neutrale — am besten für lange Sitzungen.",
themeStyleEmber: "Ember",
themeStyleEmberDesc: "Warmes Dunkel mit stärkerem Reasonix-Orange.",
themeStyleAurora: "Aurora",
themeStyleAuroraDesc: "Türkisgrünes Dunkel für weicheres Arbeiten bei wenig Licht.",
themeStyleSandstone: "Sandstein",
themeStyleSandstoneDesc: "Warme helle Töne — angenehm in hellen Räumen.",
themeStylePorcelain: "Porzellan",
themeStylePorcelainDesc: "Sauberes Hell mit extra Kontrast.",
themeStyleLinen: "Leinen",
themeStyleLinenDesc: "Editoriales warmes Hell mit papierartigen Flächen.",
themeStyleGlacier: "Gletscher",
themeStyleGlacierDesc: "Kühles Hell mit klaren blauen Akzenten.",
themeStyleMidnight: "Mitternacht",
themeStyleMidnightDesc: "Tiefes Dunkel mit kühleren Highlights.",
pageGeneralLabel: "Allgemein",
@@ -1281,8 +1298,12 @@ export const de: typeof en = {
themeDark: "Dunkel",
themeLight: "Hell",
themeStyleGraphite: "Graphit",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstein",
themeStylePorcelain: "Porzellan",
themeStyleLinen: "Leinen",
themeStyleGlacier: "Gletscher",
themeStyleMidnight: "Mitternacht",
thisTurn: "Dieser Turn",
tokens: "Tokens",
+21
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",
+14 -4
View File
@@ -1,13 +1,21 @@
import { useEffect, useState } from "react";
import { de } from "./de";
import { en } from "./en";
import { ja } from "./ja";
import { ru } from "./ru";
import { zhCN } from "./zh-CN";
export type Lang = "en" | "zh-CN" | "de";
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de"];
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
const LANG_LABELS: Record<Lang, string> = { en: "English", "zh-CN": "简体中文", de: "Deutsch" };
const LANG_LABELS: Record<Lang, string> = {
en: "English",
"zh-CN": "简体中文",
de: "Deutsch",
ja: "日本語",
ru: "Русский",
};
const STORAGE_KEY = "reasonix.lang";
type Listener = () => void;
@@ -24,6 +32,8 @@ function detectDefault(): Lang {
const lower = nav.toLowerCase();
if (lower.startsWith("zh")) return "zh-CN";
if (lower.startsWith("de")) return "de";
if (lower.startsWith("ja")) return "ja";
if (lower.startsWith("ru")) return "ru";
return "en";
}
@@ -77,7 +87,7 @@ export function useLang(): Lang {
return currentLang;
}
const dicts = { en, "zh-CN": zhCN, de } as const;
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
type Dict = typeof en;
type Path<T, K extends keyof T = keyof T> = K extends string
+507
View File
@@ -0,0 +1,507 @@
import { en } from "./en";
export const ja: typeof en = {
...en,
app: {
...en.app,
sectionWorkspace: "ワークスペース",
sectionObserve: "監視",
sectionConfigure: "設定",
tabChat: "チャット",
tabPlans: "プラン",
tabSessions: "セッション",
tabOverview: "概要",
tabUsage: "使用量",
tabSystem: "システム",
tabSemantic: "セマンティック",
tabTools: "ツール",
tabPermissions: "権限",
tabMcp: "MCP",
tabSkills: "スキル",
tabMemory: "メモリ",
tabHooks: "フック",
tabSettings: "設定",
sectionChanges: "変更",
tabChanges: "変更",
footer: "127.0.0.1 のみ · トークン認証",
themeToggle: "テーマ切替",
themeLight: "ライト",
themeDark: "ダーク",
skill: {
...en.app.skill,
scope: {
...en.app.skill.scope,
builtin: "ビルトイン",
global: "グローバル",
project: "プロジェクト",
},
runAs: {
...en.app.skill.runAs,
subagent: "サブエージェント",
inline: "インライン",
},
generic: "スキル",
},
exportReasoningSummary: "推論サマリー",
exportToolLabel: "ツール呼び出し",
exportUserLabel: "ユーザーメッセージ",
empty: {
...en.app.empty,
currentWorkspace: "現在のワークスペース",
selectWorkspace: "ワークスペースを選択して開始",
suggestion0: "新しい会話を始める",
suggestion1: "設定を開く",
suggestion2: "概要を表示",
suggestion3: "セッションを閲覧",
welcome: "Reasonix へようこそ",
},
errorLabel: "エラー",
connecting: "接続中…",
session: {
...en.app.session,
format: "セッション",
new: "新しいセッション",
},
setup: {
...en.app.setup,
choose: "ワークスペースを選択",
description: "作業を開始するワークスペースを選択",
notSelected: "ワークスペース未選択",
saveAndStart: "保存して開始",
welcome: "ようこそ",
workspace: "ワークスペース",
},
tab: {
...en.app.tab,
newTab: "+ 新しいタブ",
newTabTitle: "新しいタブを開く",
},
titlebar: {
...en.app.titlebar,
clearChat: "チャットクリア",
close: "閉じる",
commandPalette: "コマンドパレット",
contextPanel: "コンテキストパネル",
copyMd: "Markdownでコピー",
exportMd: "Markdownでエクスポート",
maximize: "最大化",
minimize: "最小化",
more: "その他",
restore: "元に戻す",
settings: "設定",
sidebar: "サイドバー",
windowControls: "ウィンドウ操作",
},
toast: {
...en.app.toast,
cleared: "チャットをクリアしました",
copied: "クリップボードにコピーしました",
copiedMd: "Markdownでコピーしました",
emptySession: "エクスポートするメッセージがありません",
exportDialogTitle: "チャットをエクスポート",
exportedMd: "Markdownでエクスポートしました",
exportFailed: "エクスポートに失敗しました",
langSwitched: "言語を切り替えました",
modelSwitched: "モデル → {model}",
effortSwitched: "努力 → {effort}",
modeSwitched: "モードを切り替えました",
newSession: "新しいセッションを作成しました",
},
yolo: {
...en.app.yolo,
toast: "YOLOモードが有効です",
},
cmd: {
...en.app.cmd,
abort: "中断",
btw: "BTW",
clearChat: "チャットクリア",
compact: "圧縮",
copyLast: "最後をコピー",
exportMd: "Markdownでエクスポート",
feedback: "フィードバック",
help: "ヘルプ",
newSession: "新しいセッション",
retry: "再試行",
switchModel: "モデル切替",
toggleCurrency: "通貨切替",
toggleLang: "言語切替",
toggleTheme: "テーマ切替",
},
header: {
...en.app.header,
abort: "中断",
clickToSelect: "クリックで選択",
copy: "コピー",
copyMd: "Markdownでコピー",
export: "エクスポート",
exportMd: "Markdownでエクスポート",
newChat: "新しいチャット",
noWorkspace: "ワークスペースなし",
running: "実行中…",
},
jumpToBottom: "最下部にジャンプ",
langEN: "English",
langZH: "中文",
splashSubtitle: "AI搭載コーディングアシスタント",
},
changes: {
...en.changes,
chatPlaceholder: "コードについて質問…",
chatWelcome: "変更 — プロジェクトファイルについて質問してください。",
chatSend: "送信",
viewerPlaceholder: "表示するファイルを選択",
treeEmpty: "(空)",
tabClose: "タブを閉じる",
newConversation: "新規",
clearConversation: "クリア",
newTitle: "/new — 会話コンテキストを消去",
clearTitle: "/clear — 表示スクロールバックを消去",
newConfirmBusy: "ターンが実行中です。中断して新しい会話を始めますか?",
newConfirm: "現在の会話をクリアして新しく始めますか?",
newToast: "新しい会話",
clearToast: "スクロールバックをクリアしました",
newFailed: "/new 失敗: {error}",
clearFailed: "/clear 失敗: {error}",
chatSendBtn: "送信",
fileTreeTitle: "ファイル",
codeViewerTitle: "コードビューア",
chatPanelTitle: "チャット",
loadingFiles: "プロジェクトファイルを読み込み中…",
review: "レビュー",
allFiles: "すべてのファイル",
changes: "変更",
commentLabel: "行にコメント",
commentPlaceholder: "コメントを追加…",
commentCancel: "キャンセル",
commentSubmit: "コメント",
commentLine: "行",
commentEdit: "編集",
commentDelete: "削除",
diffSourceGit: "Gitの変更",
diffSourceSession: "前回のセッション",
diffSourceCheckpoint: "チェックポイント",
checkpointEmpty: "このワークスペースにチェックポイントはまだありません。",
restoreBtn: "復元",
restoreConfirm: '"{name}" を復元しますか?現在のファイルが上書きされます。',
deleteBtn: "削除",
deleteConfirm:
'チェックポイント "{name}" を削除しますか?スナップショットは削除され、ファイルはそのままです。',
createBtn: "スナップショット",
createPlaceholder: "スナップショット名…",
backToList: "リストに戻る",
reviewEmpty: "保留中のレビューはありません。",
diffStyleUnified: "統合",
diffStyleSplit: "分割",
expandAll: "すべて展開",
collapseAll: "すべて折りたたみ",
createFailed: "作成に失敗しました",
restoreFailed: "復元に失敗しました",
deleteFailed: "削除に失敗しました",
},
common: {
...en.common,
loading: "読み込み中…",
loadingFailed: "{name} 失敗: {error}",
back: "← 戻る",
save: "保存",
remove: "削除",
cancel: "キャンセル",
delete: "削除",
add: "追加",
confirm: "確認",
noData: "まだ {name} がありません。",
all: "すべて",
yes: "はい",
no: "いいえ",
on: "オン",
off: "オフ",
enabled: "有効",
disabled: "無効",
},
settings: {
...en.settings,
title: "設定",
loading: "設定を読み込み中…",
saved: "保存: {fields}",
sectionApi: "DeepSeek API",
apiKey: "APIキー",
apiKeyNotSet: "未設定",
apiKeySave: "キーを保存",
apiKeySet: "設定済み",
apiSection: "API設定",
notSet: "(未設定)",
replace: "置換",
pasteKey: "新しい sk-… トークンを貼り付け",
saveKey: "キーを保存",
baseUrl: "ベースURL",
baseUrlPlaceholder: "https://api.deepseek.com (デフォルト)",
baseUrlHint: "APIリクエストのベースURL",
sectionDefaults: "デフォルト",
effortSection: "推論努力",
modelCustom: "カスタムモデルID",
modelCustomHint: "エンドポイントが提供するOpenAI互換ID (vLLM, Ollama, Together, …)。",
modelCustomActive: "現在カスタムIDで実行中: {model}",
appliesNextTurn: "次のターンから適用",
effort: "努力",
effortMax: "max (DeepSeek拡張 — 標準OpenAI / vLLMでは拒否)",
effortHigh: "high (安全なデフォルト — vLLM / Azure互換)",
effortMedium: "medium",
effortLow: "low (最速)",
webSearch: "Web検索",
webSearchNote: "web_fetch + web_search ツール",
webSearchEngine: "検索エンジン",
webSearchEngineBing: "bing — デフォルト、プロキシなしで中国から利用可",
webSearchEngineBingIntl: "bing-intl — www.bing.com 経由の国際版インデックス",
webSearchEngineSearxng: "searxng — セルフホスト (/se searxng <url> でエンドポイント設定)",
webSearchEngineMetaso: "metaso — 100回/日無料 (中国対応、metaso.cnで無料キー取得)",
webSearchEngineBaidu: "baidu — Baidu AI Search (BAIDU_API_KEY または QIANFAN_API_KEY を設定)",
webSearchEngineTavily: "tavily — 1000回/月無料 (TAVILY_API_KEY を設定)",
webSearchEnginePerplexity: "perplexity — AIネイティブ (PERPLEXITY_API_KEY を設定)",
webSearchEngineExa: "exa — AIネイティブ 1000回/月無料 (EXA_API_KEY を設定)",
webSearchEngineBrave: "brave — 独立インデックス、2000回/月無料 (BRAVE_SEARCH_API_KEY を設定)",
webSearchEngineOllama: "ollama — Ollama クラウド Web 検索 (OLLAMA_API_KEY を設定)",
webSearchEngineNote: "次の web_search 呼び出しに適用",
webSearchApiKeySignup: "(キーを取得)",
webSearchApiKeyClear: "クリア",
webSearchApiKey: {
...en.settings.webSearchApiKey,
baidu: "Baidu APIキー",
},
sectionBudget: "予算",
budget: "予算",
budgetHint: "支出制限を設定",
budgetPlaceholder: "予算額を入力",
budgetOf: "/",
budgetSetCap: "上限を設定",
budgetCustom: "カスタム",
budgetBumpHint: "上限を引き上げて続行",
budgetClear: "上限をクリア",
budgetIdleLine: "80%で警告 · 100%超過で拒否",
budgetWarnLine: "上限接近 — ループは100%超過で拒否",
budgetRefusing: "上限到達 — 引き上げまたはクリアまで次のターンは拒否",
sectionLoop: "ループ",
loopIdleHint: "固定間隔でプロンプトを自動再送信。",
loopCostHint: "各イテレーションのコスト ~{cost} (前回ターン)。",
loopInterval: "間隔",
loopCustom: "カスタム",
loopRangeError: "間隔は5秒〜6時間の範囲である必要があります",
loopPrompt: "プロンプト",
loopPromptPlaceholder: "例: デプロイ状態を確認してエラーを報告",
loopStart: "ループ開始",
loopStop: "停止",
loopRunning: "実行中",
loopIter: "反復 {iter}",
loopFiresIn: "{remaining} 後に実行",
sectionSkills: "スキル",
skillPaths: "スキルパス",
skillPathsPlaceholder: "/path/to/skills-a, /path/to/skills-b",
skillPathsNote: "カンマ区切り · 次の /new または新規セッション",
skillsEmpty: "スキルが読み込まれていません",
skillsLoaded: "スキル読み込み済み",
subagentModelFlash: "flash",
subagentModelPro: "pro",
subagentModelHint: "このスキルが分離サブエージェントを生成するときに使用するモデル",
sectionRuntime: "ランタイム",
activeModel: "アクティブモデル",
model: "モデル",
modelFlashDesc: "Flash — 常にflash",
modelProDesc: "Pro — 常にpro",
modelPricingLine: "${hit} ヒット · ${miss} ミス · ${out} 出力 / 1M tok",
editMode: "編集モード",
editModeNote: "チャットタブヘッダーから切り替え",
editModeHint: "編集モードを設定",
showSystemEvents: "システムイベント",
showSystemEventsHint: "コンテキスト圧縮、中断、レート制限の通知を表示。",
shown: "表示",
hidden: "非表示",
sectionLanguage: "言語",
language: "言語",
languageHint: "言語を切り替え",
langEn: "English",
langZhCn: "简体中文",
appMode: "アプリモード",
appearanceSection: "外観",
behaviorSection: "動作",
ruleAutoApprovalHint: "自動承認ルールを設定",
ruleAutoApprovalSection: "自動承認ルール",
theme: "テーマ",
themeDark: "ダーク",
themeHint: "ライト/ダークテーマを切り替え",
themeLight: "ライト",
themeStyle: "テーマスタイル",
themeStyleHint: "テーマスタイルを選択",
themeStyleGraphite: "グラファイト",
themeStyleGraphiteDesc: "クールなダークニュートラル — 長時間のセッションに最適。",
themeStyleEmber: "エンバー",
themeStyleEmberDesc: "Reasonixのオレンジを強めた暖かいダーク。",
themeStyleAurora: "オーロラ",
themeStyleAuroraDesc: "低照度でも柔らかいティールグリーンのダーク。",
themeStyleSandstone: "サンドストーン",
themeStyleSandstoneDesc: "温かみのあるライトトーン — 明るい部屋に最適。",
themeStylePorcelain: "ポーセリン",
themeStylePorcelainDesc: "クリーンなライト、高コントラスト。",
themeStyleLinen: "リネン",
themeStyleLinenDesc: "紙のような表面を持つ編集調の暖かいライト。",
themeStyleGlacier: "グレイシャー",
themeStyleGlacierDesc: "くっきりしたブルーアクセントのクールなライト。",
themeStyleMidnight: "ミッドナイト",
themeStyleMidnightDesc: "深いダーク、クールなハイライト。",
pageGeneralLabel: "一般",
pageGeneralDesc: "テーマ、フォント、言語、ワークスペース。",
pageModelsLabel: "モデル",
pageModelsDesc: "デフォルトモデルと推論努力。",
pageMcpLabel: "MCPサーバー",
pageMcpDesc: "外部ツールサーバーに接続。",
pageSkillsLabel: "スキル",
pageSkillsDesc: "/skill で実行できるユーザー定義スキル。",
pageMemoryLabel: "メモリ",
pageMemoryDesc: "セッションをまたいで保持される長期メモ。",
pageRulesLabel: "ルール",
pageRulesDesc: "シェル+パスアクセスの自動承認ルール。",
pageBillingLabel: "請求",
pageBillingDesc: "APIキー、残高、セッションごとの予算。",
pageShortcutsLabel: "ショートカット",
pageShortcutsDesc: "キーボードショートカットリファレンス。",
fontFamily: "フォントファミリー",
fontFamilyHint: "フォントファミリーを選択",
fontFamilySans: "サンセリフ",
fontFamilySerif: "セリフ",
fontFamilySystem: "システム",
fontScale: "フォントスケール",
fontScaleHint: "フォントサイズを調整",
fontScaleLarge: "大",
fontScaleMedium: "中",
fontScaleSmall: "小",
currentWorkspace: "現在のワークスペース",
defaultModelCurrent: "現在のモデル",
editor: "エディタ",
editorHint: "エディタ設定を構成",
maxOutput: "最大出力",
reasoningEffort: "推論努力",
reasoningEffortHint: "推論の深さを調整",
sessionCost: "セッションコスト",
balanceAvailable: "利用可能",
balanceLabel: "残高",
balanceLow: "残高不足",
cacheHitRate: "キャッシュヒット率",
ctxWindow: "コンテキストウィンドウ",
notSelected: "未選択",
parseError: "パースエラー",
workspaceChange: "変更",
workspaceSection: "ワークスペース",
mcpAdd: "追加",
mcpAddSection: "MCPサーバーを追加",
mcpBridged: "ブリッジ済み",
mcpConfigured: "設定済み",
mcpEmpty: "MCPサーバーなし",
mcpNotBridged: "ブリッジ未接続",
mcpRemove: "削除",
mcpSpecFormat: "JSON形式",
mcpSpecLabel: "サーバー仕様",
memoryDesc: "メモリ設定を構成",
memorySection: "メモリ",
shortcutAbort: "中断",
shortcutCloseTab: "タブを閉じる",
shortcutCommandPalette: "コマンドパレット",
shortcutFocusComposer: "入力欄にフォーカス",
shortcutNewChat: "新しいチャット",
shortcutNewTab: "新しいタブ",
shortcutSettings: "設定",
shortcutSwitchTab: "タブ切替",
qqAccessAllowlist: "許可リスト",
qqAccessOpen: "オープン",
qqAccessOwner: "所有者",
qqAccessOwnerWithAllowlist: "所有者 + 許可リスト",
qqAccessRuntime: "ランタイム",
qqAppId: "App ID",
qqApplyAction: "申請",
qqApplyLabel: "申請",
qqAppSecret: "App Secret",
qqBack: "戻る",
qqConfigure: "設定",
qqConfigureHint: "QQ統合を設定",
qqConfigureTitle: "QQ設定",
qqConnected: "接続済み",
qqConnecting: "接続中…",
qqDisconnected: "切断済み",
qqEnvironment: "環境",
qqFailed: "失敗",
qqProduction: "本番",
qqSandbox: "サンドボックス",
qqSave: "保存",
qqSaveAndConnect: "保存して接続",
qqSection: "QQ統合",
qqSummaryAppId: "App ID",
qqSummaryDetail: "詳細",
qqSummaryMissing: "未設定",
qqTitle: "QQボット",
},
chat: {
...en.chat,
modeMirror: "TUIミラー",
modeView: "セッションビュー",
placeholder: "プロンプトを入力 — Enterで送信、Shift+Enterで改行 · / @ でピッカー",
placeholderBusy: "現在のターンの完了を待っています…",
placeholderSteerBusy: "入力で操作 — ビジー中はコマンドとピッカーが無効です",
steerHint: "メッセージが現在のターンを操作します(指示として追加)",
send: "送信",
new: "新規",
clear: "クリア",
newTitle: "/new — 会話コンテキストを消去(ループログ + スクロールバック)",
clearTitle: "/clear — 表示スクロールバックのみ消去(コンテキストは保持)",
noConversation: "まだ会話がありません。下のプロンプトを送信して開始してください。",
newConfirmBusy: "ターンが実行中です。中断して新しい会話を始めますか?",
newConfirm: "現在の会話をクリアして新しく始めますか?",
newToast: "新しい会話",
clearToast: "スクロールバックをクリアしました",
newFailed: "/new 失敗: {error}",
clearFailed: "/clear 失敗: {error}",
eventStreamError: "イベントストリームが中断されました — 再接続中…",
semanticBanner: "このプロジェクトではセマンティック検索が有効になっていません。",
semanticBannerDesc:
"インデックスを一度構築すると、モデルは正確な文字列のgrepではなく意味でコードを見つけられるようになります(「認証失敗をどのように処理しているか?」など)。",
semanticBannerBtn: "構築する →",
semanticBannerDismiss: "閉じる(再表示しない)",
slashCommands: "スラッシュコマンド",
projectFiles: "プロジェクトファイル",
effortTitle: "reasoning_effort — 次のターンから適用",
effortMaxTitle: "max (DeepSeek拡張)",
effortHighTitle: "high (安全なデフォルト — vLLM / Azure互換)",
effortMediumTitle: "medium",
effortLowTitle: "low (最速)",
editGateTitle: "編集ゲート — TUIではShift+Tabで切替",
editReviewTitle: "review — 編集と未許可シェルの両方が最初に確認",
editAutoTitle: "auto — 編集は自動適用、シェルは確認あり",
editYoloTitle: "yolo — 編集とシェルの両方が自動実行、許可リストバイパス",
railSession: "セッション",
railTurns: "ターン",
railPromptTok: "プロンプトtok",
railCost: "コスト",
railCacheHit: "キャッシュヒット",
railToolBudget: "ツール予算",
railSpend: "消費",
railActivePlan: "アクティブプラン",
railProgress: "進捗",
statusModel: "モデル",
statusCtx: "ctx",
statusCache: "キャッシュ",
statusTurn: "ターン",
statusSession: "セッション",
statusBalance: "残高",
statusTurns: "{count} ターン",
waitingStats: "· · · ライブ統計待ち",
inflightPhase: "{phase}",
inflightRunning: "実行中",
inflightThinking: "思考中",
inflightStreaming: "ストリーミング中",
inflightWaiting: "待機中",
inflightReasoning: "推論 {count} ch",
inflightOut: "出力 {count} ch",
abortBtn: "中断 (Esc)",
confirmBtn: "適用 (y)",
rejectBtn: "拒否 (n)",
applyRestBtn: "残りを適用 (a)",
flipAutoBtn: "AUTOに切替 (A)",
},
};
+5
View File
@@ -0,0 +1,5 @@
import { en } from "./en";
export const ru: typeof en = {
...en,
};
+21
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",
+130 -89
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":
@@ -275,39 +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: Number.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 {
// 静默失败,等待下次轮询
}
@@ -348,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 ?? "",
@@ -355,6 +339,96 @@ 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>();
@@ -456,33 +530,8 @@ 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: Number.parseFloat(firstBalance.total_balance) || 0,
isAvailable: true,
});
}
}
emitSessionsSnapshot(sessionsData);
emitOverviewSnapshot(overview);
} catch (err) {
console.warn("[tauri-bridge] server init failed:", err);
}
@@ -533,15 +582,7 @@ 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 });
}
emitSessionsSnapshot(data);
} catch {
/* ignore */
}
@@ -629,15 +670,7 @@ 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 });
}
emitSessionsSnapshot(data);
} catch {
/* ignore */
}
@@ -659,15 +692,9 @@ 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 */
@@ -711,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;
}
+34 -2
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,6 +111,7 @@ export type PlanClearedEvent = { type: "$plan_cleared" };
export type SessionsEvent = {
type: "$sessions";
currentSession?: string | null;
items: {
name: string;
messageCount: number;
@@ -109,6 +121,15 @@ export type SessionsEvent = {
}[];
};
export type SessionUsageEvent = {
type: "$session_usage";
totalCostUsd: number;
totalPromptTokens: number;
totalCompletionTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
};
export type MentionResultsEvent = {
type: "$mention_results";
nonce: number;
@@ -270,11 +291,15 @@ 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";
@@ -288,6 +313,9 @@ export type SettingsEvent = {
model: string;
editor?: string;
webSearchEngine?: WebSearchEngineName;
webSearchApiKeys?: {
baidu?: string;
};
subagentModels?: Record<string, "flash" | "pro">;
showSystemEvents?: boolean;
version: string;
@@ -319,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;
};
@@ -442,6 +472,7 @@ export type IncomingEvent = { tabId?: string } & (
| ChoiceRequiredEvent
| PlanRequiredEvent
| SessionsEvent
| SessionUsageEvent
| SessionLoadedEvent
| SessionEmptyEvent
| NeedsSetupEvent
@@ -450,6 +481,7 @@ export type IncomingEvent = { tabId?: string } & (
| BalanceEvent
| CheckpointRequiredEvent
| RevisionRequiredEvent
| ModalDismissedEvent
| StepCompletedEvent
| PlanClearedEvent
| MentionResultsEvent
@@ -479,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 }
+214
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;
@@ -3005,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;
@@ -3026,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;
@@ -3055,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;
@@ -4437,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);
@@ -4461,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);
@@ -5790,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);
}
+16
View File
@@ -11,8 +11,12 @@ export function isTheme(value: unknown): value is Theme {
export const THEME_STYLE = {
GRAPHITE: "graphite",
EMBER: "ember",
AURORA: "aurora",
SANDSTONE: "sandstone",
PORCELAIN: "porcelain",
LINEN: "linen",
GLACIER: "glacier",
MIDNIGHT: "midnight",
} as const;
@@ -25,23 +29,35 @@ export const DEFAULT_THEME_STYLE: Record<Theme, ThemeStyle> = {
export const THEME_STYLE_THEME: Record<ThemeStyle, Theme> = {
graphite: THEME.DARK,
ember: THEME.DARK,
aurora: THEME.DARK,
sandstone: THEME.LIGHT,
porcelain: THEME.LIGHT,
linen: THEME.LIGHT,
glacier: THEME.LIGHT,
midnight: THEME.DARK,
};
export const THEME_STYLES = [
THEME_STYLE.GRAPHITE,
THEME_STYLE.EMBER,
THEME_STYLE.AURORA,
THEME_STYLE.SANDSTONE,
THEME_STYLE.PORCELAIN,
THEME_STYLE.LINEN,
THEME_STYLE.GLACIER,
THEME_STYLE.MIDNIGHT,
] as const;
export function isThemeStyle(value: unknown): value is ThemeStyle {
return (
value === THEME_STYLE.GRAPHITE ||
value === THEME_STYLE.EMBER ||
value === THEME_STYLE.AURORA ||
value === THEME_STYLE.SANDSTONE ||
value === THEME_STYLE.PORCELAIN ||
value === THEME_STYLE.LINEN ||
value === THEME_STYLE.GLACIER ||
value === THEME_STYLE.MIDNIGHT
);
}
+3 -2
View File
@@ -668,7 +668,8 @@ export function MetricStrip({
costLabel,
elapsed,
}: {
cacheHit?: number;
/** Pre-formatted cache percentage string (e.g. "72.12%"). */
cacheHit?: string;
promptTokens?: number;
outputTokens?: number;
costLabel?: string;
@@ -680,7 +681,7 @@ export function MetricStrip({
<span className="item">
<I.zap size={11} style={{ color: "var(--accent)" }} />
<span>{t("cards.cacheHit")}</span>
<span className="v acc">{cacheHit}%</span>
<span className="v acc">{cacheHit}</span>
</span>
) : null}
{promptTokens !== undefined ? (
+37 -13
View File
@@ -64,6 +64,7 @@ export type SlashCmd = {
};
export type MentionItem = {
name: string;
path?: string;
kind: "file" | "dir" | "url" | "agent" | "clip";
desc?: string;
};
@@ -103,6 +104,20 @@ function parentOfAtQuery(query: string): string | null {
return parentIdx >= 0 ? `${dirContext.slice(0, parentIdx)}/` : "";
}
function displayMentionPath(path: string): { name: string; desc?: string } {
const normalized = path.replace(/\\/g, "/");
const isDir = /[\\/]$/.test(path);
const trimmed = normalized.replace(/\/+$/, "");
if (!trimmed) return { name: path };
const slash = trimmed.lastIndexOf("/");
const base = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
const parent = slash >= 0 ? `${trimmed.slice(0, slash)}/` : "";
return {
name: isDir ? `${base}/` : base,
desc: parent || undefined,
};
}
function atIcon(k: MentionItem["kind"]) {
if (k === "file") return <I.file size={12} />;
if (k === "dir") return <I.folder size={12} />;
@@ -240,11 +255,15 @@ export function Composer({
const atItems = useMemo<MentionItem[]>(() => {
if (!popup || popup.kind !== "at") return [];
if (!mentionResults || mentionResults.nonce !== popup.nonce) return [];
const base: MentionItem[] = mentionResults.results.map((path) => ({
name: path,
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
desc: path,
}));
const base: MentionItem[] = mentionResults.results.map((path) => {
const display = displayMentionPath(path);
return {
name: display.name,
path,
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
desc: display.desc,
};
});
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
const parent = parentOfAtQuery(popup.query);
if (parent !== null) {
@@ -317,9 +336,10 @@ export function Composer({
return;
}
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
setDraft(next ? `${next} @${mention.name} ` : `@${mention.name} `);
setChips((c) => [...c, { kind: "at", label: mention.name }]);
onMentionPicked?.(mention.name);
const mentionPath = mention.path ?? mention.name;
setDraft(next ? `${next} @${mentionPath} ` : `@${mentionPath} `);
setChips((c) => [...c, { kind: "at", label: mentionPath }]);
onMentionPicked?.(mentionPath);
}
setPopup(null);
textareaRef.current?.focus();
@@ -361,7 +381,7 @@ export function Composer({
setPopup({ kind: "at", query: parent, nonce });
return;
}
const dirPath = mention.name.replace(/\/+$/, "");
const dirPath = (mention.path ?? mention.name).replace(/[\\/]+$/, "");
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
setDraft(next);
const nonce = ++nonceRef.current;
@@ -603,7 +623,8 @@ export function Composer({
onHover={(i, item) => {
setActiveIdx(i);
if (popup.kind === "at" && onMentionPreview) {
const path = (item as MentionItem).name;
const mention = item as MentionItem;
const path = mention.path ?? mention.name;
onMentionPreview(path, popup.nonce);
}
}}
@@ -640,7 +661,10 @@ function Popup({
}, [activeIdx]);
return (
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
<div
className={kind === "at" ? "popup at-popup" : "popup"}
onMouseDown={(e) => e.preventDefault()}
>
<div className="ph">
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
<span>
@@ -653,7 +677,7 @@ function Popup({
<I.x size={11} />
</span>
</div>
<div className="popup-list" ref={listRef}>
<div className={kind === "at" ? "popup-list at-popup-list" : "popup-list"} ref={listRef}>
{items.length === 0 ? (
<div
style={{
@@ -687,7 +711,7 @@ function Popup({
</>
) : (
<>
<span>{(it as MentionItem).name}</span>
<span className="mention-name">{(it as MentionItem).name}</span>
{(it as MentionItem).desc ? (
<div className="desc">{(it as MentionItem).desc}</div>
) : null}
+7 -1
View File
@@ -258,7 +258,13 @@ export function UsageFull({
useLang();
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
const total = promptTokens + reasoningTokens + outputTokens + cacheHitTokens || 1;
const pct = (n: number) => Math.round((n / total) * 100);
const pct = (n: number): number => {
const raw = (n / total) * 100;
if (raw === 100) return 100;
const rounded = Math.round(raw * 100) / 100;
if (rounded >= 100) return 99.99;
return rounded;
};
return (
<div className="usage-full">
<div className="uh">
+88 -1
View File
@@ -1,3 +1,4 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { type ReactNode, useEffect, useState } from "react";
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
@@ -669,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,
+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",
+19 -29
View File
@@ -1,5 +1,5 @@
// Pulls Node 22 from nodejs.org into desktop/src-tauri/binaries/.
// Mac always produces a universal Mach-O so Intel + Apple Silicon ship from one .dmg (issue #1234).
// macOS downloads the host-arch binary so arm64 and x64 releases stay split.
import { execSync } from "node:child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
import https from "node:https";
@@ -22,6 +22,7 @@ if (!HOST_ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
const isWin = PLAT === "win32";
const targetExe = join(binDir, isWin ? "node.exe" : "node");
const MAC_ARCH = HOST_ARCH === "x64" ? "x86_64" : HOST_ARCH;
function adhocSignMac(binPath) {
// #1611: child node needs com.apple.security.inherit so it inherits the
@@ -53,16 +54,16 @@ if (existsSync(targetExe) && statSync(targetExe).size > 1024 * 1024) {
if (PLAT === "darwin") {
try {
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
if (archs.includes("arm64") && archs.includes("x86_64")) {
if (archs === MAC_ARCH) {
if (!hasMacTccInheritance(targetExe)) {
console.log(`${targetExe} universal but missing TCC inheritance — applying ad-hoc sign`);
console.log(`${targetExe} ${MAC_ARCH} build missing TCC inheritance — applying ad-hoc sign`);
adhocSignMac(targetExe);
} else {
console.log(`${targetExe} already universal + TCC-inheriting (${archs}) — delete to refetch`);
console.log(`${targetExe} already ${MAC_ARCH} + TCC-inheriting — delete to refetch`);
}
process.exit(0);
}
console.log(`${targetExe} present but not universal (${archs}) — rebuilding`);
console.log(`${targetExe} present but not ${MAC_ARCH} (${archs}) — rebuilding`);
rmSync(targetExe);
} catch {
console.log(`${targetExe} present but not verifiable as Mach-O — rebuilding`);
@@ -148,36 +149,25 @@ async function fetchAndExtract(arch) {
return { inner, extractDir };
}
const { inner, extractDir } = await fetchAndExtract(HOST_ARCH);
if (existsSync(targetExe)) rmSync(targetExe);
renameSync(inner, targetExe);
if (!isWin) {
try {
chmodSync(targetExe, 0o755);
} catch {
/* ignore */
}
}
rmSync(extractDir, { recursive: true, force: true });
if (PLAT === "darwin") {
const [arm, x64] = await Promise.all([fetchAndExtract("arm64"), fetchAndExtract("x64")]);
console.log("Creating universal binary with lipo ...");
if (existsSync(targetExe)) rmSync(targetExe);
execSync(`lipo -create "${arm.inner}" "${x64.inner}" -output "${targetExe}"`, { stdio: "inherit" });
chmodSync(targetExe, 0o755);
rmSync(arm.extractDir, { recursive: true, force: true });
rmSync(x64.extractDir, { recursive: true, force: true });
adhocSignMac(targetExe);
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
console.log(`Done: ${targetExe} (${mb} MB, archs: ${archs})`);
} else {
const { inner, extractDir } = await fetchAndExtract(HOST_ARCH);
if (existsSync(targetExe)) rmSync(targetExe);
renameSync(inner, targetExe);
if (!isWin) {
try {
chmodSync(targetExe, 0o755);
} catch {
/* ignore */
}
}
rmSync(extractDir, { recursive: true, force: true });
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
console.log(`Done: ${targetExe} (${mb} MB)`);
}
+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": {
+125 -1
View File
@@ -34,7 +34,7 @@ vi.mock("./theme", () => ({
themeForStyle: vi.fn(() => "dark"),
}));
import { reduce } from "./App";
import { readWindowExpanded, reduce, toggleWindowExpanded } from "./App";
import { getThreadMaxWidth } from "./ui/thread-layout";
function initialState(): Parameters<typeof reduce>[0] {
@@ -59,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: [],
@@ -131,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();
+729 -352
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 "";
}
+132 -13
View File
@@ -57,6 +57,7 @@ export const de: typeof en = {
searchPlaceholder: "Letzte Pfade suchen...",
empty: "Keine letzten Arbeitsbereiche",
browse: "Lokal durchsuchen...",
removeRecent: "Aus Liste entfernen",
},
sidebar: {
...en.sidebar,
@@ -73,6 +74,9 @@ export const de: typeof en = {
copiedMd: "Unterhaltung als Markdown kopiert",
newSession: "Neue Sitzung gestartet",
cleared: "UI geleert",
mcpStatusOpened: "MCP-Statusleiste geöffnet",
mcpRetryNone: "Keine fehlgeschlagenen MCP-Server zum Wiederholen",
mcpRetryQueued: "{count} fehlgeschlagene MCP-Server werden erneut versucht",
aboutLine: "Reasonix v{version} · Cache-First-DeepSeek-Agent",
},
composer: {
@@ -132,10 +136,18 @@ export const de: typeof en = {
themeStyleHint: "Wähle die visuelle Palette für den gewählten hellen oder dunklen Modus.",
themeStyleGraphite: "Graphit",
themeStyleGraphiteDesc: "Originale dunkle Palette mit neutralen Graphit-Panels.",
themeStyleEmber: "Ember",
themeStyleEmberDesc: "Warme dunkle Palette mit stärkerem Reasonix-Orange.",
themeStyleAurora: "Aurora",
themeStyleAuroraDesc: "Türkisgrüne dunkle Palette für weichere Arbeit bei wenig Licht.",
themeStyleSandstone: "Sandstein",
themeStyleSandstoneDesc: "Originale warme helle Palette.",
themeStylePorcelain: "Porzellan",
themeStylePorcelainDesc: "Saubere Codex-ähnliche helle Palette mit ruhigem Kontrast.",
themeStyleLinen: "Leinen",
themeStyleLinenDesc: "Editoriale warme helle Palette mit papierartigen Flächen.",
themeStyleGlacier: "Gletscher",
themeStyleGlacierDesc: "Kühle helle Palette mit klaren blauen Akzenten.",
themeStyleMidnight: "Mitternacht",
themeStyleMidnightDesc: "Marineblaue dunkle Palette mit kühlen blauen Oberflächen.",
fontScale: "Schriftgröße",
@@ -158,14 +170,21 @@ export const de: typeof en = {
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.",
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.",
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",
@@ -174,14 +193,34 @@ export const de: typeof en = {
budgetPlaceholder: "keine Grenze",
webSearchEngine: "Suchmaschine",
webSearchEngineBing: "bing — Standard, funktioniert von CN ohne Proxy",
webSearchEngineBingIntl: "bing-intl — internationaler Index über www.bing.com",
webSearchEngineSearxng: "searxng — selbst gehostet (Endpunkt via /se searxng <url>)",
webSearchEngineMetaso: "metaso — 100/Tag kostenlos (CN-freundlich, kostenloser Schlüssel auf metaso.cn)",
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.",
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.",
@@ -261,35 +300,78 @@ export const de: typeof en = {
apiKeySet: "Gesetzt · {prefix}…",
defaultModelCurrent: "Standardmodell · aktuell {model}",
modelCustom: "Benutzerdefinierte Modell-ID",
modelCustomHint: "Jede OpenAI-kompatible ID, die dein Endpunkt bereitstellt (vLLM, Ollama, Together, …).",
modelCustomHint:
"Jede OpenAI-kompatible ID, die dein Endpunkt bereitstellt (vLLM, Ollama, Together, …).",
modelCustomActive: "Läuft aktuell auf benutzerdefinierter ID: {model}",
contextTokensLabel: "Kontextfenstergröße",
contextTokensHint: "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.",
contextTokensPlaceholder: "Automatisch",
effortSection: "Reasoning-Effort",
ctxWindow: "Kontext",
maxOutput: "Ausgabe",
mcpConfigured: "Konfiguriert · {count}",
mcpBridged: "· Verbunden",
mcpNotBridged:
"· Aktuelle Desktop-Sitzung nicht verbunden — starte reasonix code (TUI) neu, um wirksam zu werden",
mcpNotBridged: "· Wird auf die aktuelle Desktop-Sitzung angewendet",
mcpStatusConnected: "· Verbunden {connected}/{total}",
mcpStatusConnecting:
"· Verbinde {connected}/{total}; Konfigurationsänderungen werden automatisch angewendet",
mcpStatusFailed: "· Verbunden {connected}/{total}; {failed} fehlgeschlagen",
mcpStatusDisabled: "· {disabled}/{total} deaktiviert",
mcpStatusPending: "· Wartet auf Anwendung",
mcpStatus: {
configured: "Wartet",
handshake: "Verbindet",
connected: "Verbunden",
failed: "Fehlgeschlagen",
disabled: "Deaktiviert",
},
mcpFailure: {
auth: "Authentifizierung fehlgeschlagen · Authorization oder x-api-key in headers konfigurieren",
missingToken: "Bearer-Token fehlt · Authorization in headers konfigurieren",
command: "Befehl nicht verfügbar · command/args oder lokale Abhängigkeiten prüfen",
network: "Netzwerkfehler · Endpoint, Proxy oder Verbindung prüfen",
unknown: "Verbindung fehlgeschlagen · Details für den Originalfehler öffnen",
},
mcpEmpty: "Noch keine MCP-Server konfiguriert. Gib unten eine Spec ein, um einen hinzuzufügen.",
mcpRemove: "Entfernen",
mcpRetry: "Erneut versuchen",
mcpDetail: "Details",
mcpHideDetail: "Ausblenden",
mcpDetailTitle: "Originalfehler",
mcpEditConfig: "Bearbeiten",
parseError: "Parse fehlgeschlagen: {error}",
mcpAddSection: "Server hinzufügen",
mcpSpecLabel: "Spec-String",
mcpSpecFormat: "Format: <code>name=befehl args</code> oder <code>name=https://host/sse</code>",
mcpAdd: "Hinzufügen",
mcpImport: "Aus cc-switch importieren",
mcpImporting: "Importiere…",
mcpEdit: "Bearbeiten",
mcpEditTitle: "MCP bearbeiten",
mcpEditBack: "Zurück",
mcpEditName: "MCP-Titel (eindeutig)",
mcpEditTransport: "Transport",
mcpEditJson: "Vollständige JSON-Konfiguration",
mcpEditFormat: "Formatieren",
mcpEditSave: "Speichern",
mcpEditNameRequired: "MCP-Titel ist erforderlich",
mcpEditJsonObjectRequired: "JSON-Konfiguration muss ein Objekt sein",
mcpEditCommandRequired: "stdio-Konfiguration benötigt command",
mcpEditUrlRequired: "Remote-MCP-Konfiguration benötigt url",
skillsLoaded: "Geladen · {count} · aufrufbar über /-Befehle",
skillsEmpty:
"Keine Skills verfügbar. Erstelle SKILL.md unter ~/.reasonix/skills/ oder .reasonix/skills/ in deinem Projekt-Root.",
subagentModelFlash: "flash",
subagentModelPro: "pro",
subagentModelHint: "Modell, das verwendet wird, wenn dieser Skill seinen isolierten Subagenten startet",
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.',
"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",
@@ -306,8 +388,8 @@ export const de: typeof en = {
},
modal: {
...en.modal,
planFeedbackPlaceholder: "Optional: Feedback — schreibe hier, wenn du Änderungen möchtest",
choiceCustomPlaceholder: "Oder gib deine eigene Antwort ein…",
planFeedbackPlaceholder: "Welche Probleme hat der Plan? Was muss verbessert werden?",
choiceCustomPlaceholder: "Wo kann der Plan weiter verfeinert werden?",
},
plan: {
...en.plan,
@@ -360,7 +442,8 @@ export const de: typeof en = {
continue: "Fortsetzen",
revise: "Überarbeiten…",
stop: "Stopp",
revisePlaceholder: "Was muss sich vor dem nächsten Schritt ändern? (leer lassen zum Fortfahren)",
revisePlaceholder:
"Was muss sich vor dem nächsten Schritt ändern? (leer lassen zum Fortfahren)",
sendRevise: "Überarbeitung senden",
},
revision: {
@@ -397,6 +480,12 @@ export const de: typeof en = {
abort: "Streaming stoppen",
copyLast: "Letzte Antwort kopieren",
switchModel: "Modell wechseln",
mcp: "MCP-Server-Einstellungen öffnen",
mcpStatus: "MCP-Verbindungsstatus anzeigen",
mcpRetry: "Fehlgeschlagene MCP-Verbindungen erneut versuchen",
mcpImport: "MCP-Server aus cc-switch importieren",
searchEngine: "Suchmaschine + API-Keys konfigurieren",
skill: "Skills auflisten / verwalten (Projekt + Custom + Global + Eingebaut)",
setMode: "Modus auf {mode} setzen",
setEffort: "Reasoning-Effort auf {effort} setzen",
toggleTheme: "Theme wechseln",
@@ -439,7 +528,8 @@ export const de: typeof en = {
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.",
startupFailedMessage:
"Das Desktop-Backend ist fehlgeschlagen, bevor ein Arbeitsbereich geöffnet wurde.",
startupFailedRetry: "Erneut versuchen",
startupFailedUnknown: "Unbekannter Startfehler",
langZH: "Chinesisch",
@@ -584,8 +674,12 @@ export const de: typeof en = {
themeDark: "dunkel",
themeLight: "hell",
themeStyleGraphite: "Graphit",
themeStyleEmber: "Ember",
themeStyleAurora: "Aurora",
themeStyleSandstone: "Sandstein",
themeStylePorcelain: "Porzellan",
themeStyleLinen: "Leinen",
themeStyleGlacier: "Gletscher",
themeStyleMidnight: "Mitternacht",
},
jobs: {
@@ -659,6 +753,25 @@ export const de: typeof en = {
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",
@@ -689,6 +802,8 @@ export const de: typeof en = {
},
thread: {
...en.thread,
toolCalls: "{count} Tool-Aufrufe",
oneToolCall: "1 Tool-Aufruf",
keepSteps: "{n} verbleibende(n) Schritt(e) behalten",
keepOriginal: "Original behalten",
you: "Du",
@@ -702,6 +817,10 @@ export const de: typeof en = {
approve: "Genehmigen",
cancel: "Abbrechen",
refine: "Verfeinern",
sendFeedback: "Senden",
skipFeedback: "Überspringen",
cancelFeedbackLabel: "Abbrechen — was ist falsch?",
refineFeedbackLabel: "Verfeinern — was soll geändert werden?",
checkpointKind: "Plan-Checkpoint",
checkpointTitle: "Schritt {completed} / {total} abgeschlossen",
checkpointSub: "Checkpoint · {completed} / {total}",
+141 -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",
@@ -385,6 +465,12 @@ 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",
@@ -559,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",
@@ -601,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",
@@ -625,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",
@@ -659,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",
@@ -672,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}",
@@ -688,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",
+14 -4
View File
@@ -1,13 +1,21 @@
import { useEffect, useState } from "react";
import { de } from "./de";
import { en } from "./en";
import { ja } from "./ja";
import { ru } from "./ru";
import { zhCN } from "./zh-CN";
export type Lang = "en" | "zh-CN" | "de";
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de"];
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
const LANG_LABELS: Record<Lang, string> = { en: "English", "zh-CN": "简体中文", de: "Deutsch" };
const LANG_LABELS: Record<Lang, string> = {
en: "English",
"zh-CN": "简体中文",
de: "Deutsch",
ja: "日本語",
ru: "Русский",
};
const STORAGE_KEY = "reasonix.lang";
type Listener = () => void;
@@ -24,6 +32,8 @@ function detectDefault(): Lang {
const lower = nav.toLowerCase();
if (lower.startsWith("zh")) return "zh-CN";
if (lower.startsWith("de")) return "de";
if (lower.startsWith("ja")) return "ja";
if (lower.startsWith("ru")) return "ru";
return "en";
}
@@ -77,7 +87,7 @@ export function useLang(): Lang {
return currentLang;
}
const dicts = { en, "zh-CN": zhCN, de } as const;
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
type Dict = typeof en;
type Path<T, K extends keyof T = keyof T> = K extends string
+889
View File
@@ -0,0 +1,889 @@
import { en } from "./en";
export const ja: typeof en = {
...en,
palette: {
...en.palette,
searchPlaceholder: "コマンドを検索…",
empty: "一致なし",
footMove: "移動",
footRun: "実行",
footClose: "閉じる",
newChat: "新しいチャット",
newChatHint: "現在の会話を破棄して新規開始",
newTab: "新しいタブ",
newTabHint: "別のワークスペースタブを開く",
closeTab: "タブを閉じる",
closeTabHint: "現在のタブを閉じる",
abort: "現在のターンを停止",
abortHint: "モデルの生成を中断",
copyLast: "最後の返信をコピー",
copyLastHint: "最新のアシスタント返信をコピー",
copyConv: "会話をMarkdownでコピー",
copyConvHint: "会話全体をクリップボードにコピー",
exportMd: "会話をMarkdownでエクスポート",
exportMdHint: ".mdファイルとして保存",
clearChat: "画面クリア",
clearChatHint: "UIのみクリア — サブプロセスは存続",
focusComposer: "入力欄にフォーカス",
pickWorkspace: "ワークスペース切替…",
pickWorkspaceHint: "別の作業ディレクトリを選択",
settings: "設定",
settingsHint: "モデル、予算、テーマ…",
about: "Reasonixについて",
groupNav: "ナビゲーション",
groupAction: "アクション",
groupWorkspace: "ワークスペース",
groupSettings: "設定",
countSuffix: "項目",
},
about: {
...en.about,
sidebarLabel: "概要",
tagline: "DeepSeekネイティブコーディングエージェント",
version: "バージョン",
repo: "リポジトリ",
checkUpdates: "更新を確認",
checking: "確認中…",
upToDate: "最新です ({version})",
updateAvailable: "更新あり: {version}",
openReleases: "リリースを見る",
checkFailed: "確認に失敗: {message}",
close: "閉じる",
},
workdir: {
...en.workdir,
title: "ワークスペース切替",
searchPlaceholder: "最近のパスを検索…",
empty: "最近のワークスペースはありません",
browse: "ローカルを参照…",
removeRecent: "履歴から削除",
},
sidebar: {
...en.sidebar,
newChat: "新しいチャット",
searchCommands: "コマンドを検索…",
recent: "最近",
noSessions: "セッションなし",
wallet: "ウォレット",
footHint: "キャッシュファーストエージェント",
},
toast: {
...en.toast,
copied: "コピーしました",
copiedMd: "会話をMarkdownでコピーしました",
newSession: "新しいセッションを開始しました",
cleared: "UIをクリアしました",
mcpStatusOpened: "MCPステータスパネルを開きました",
mcpRetryNone: "再試行する失敗MCPサーバーはありません",
mcpRetryQueued: "失敗したMCPサーバー {count} 件を再試行しています",
aboutLine: "Reasonix v{version} · キャッシュファーストDeepSeekエージェント",
},
composer: {
...en.composer,
busy: "reasonixが考え中…",
idle: "質問する",
send: "送信",
newline: "改行",
commands: "コマンド",
placeholder: "エージェントに質問 / タスクを説明…",
queueCount: "{n}件キュー",
queue: "キュー",
interrupt: "停止",
mentionFiles: "ファイルをメンション",
commandPalette: "コマンドパレット",
insertFile: "ファイルを挿入 (@メンション)",
insertImage: "画像を挿入 (@メンション)",
switchModel: "モデル切替",
switchEffort: "推論努力切替",
slashHeader: "コマンド — エージェント、モデル、セッションを制御",
atHeader: "メンション — ワークスペース内のファイル",
noMatches: "一致なし",
select: "選択",
confirm: "確定",
close: "閉じる",
imageFilterName: "画像",
commandsLabel: "コマンド",
mentionLabel: "メンション",
workspaceRoot: "ワークスペースルート",
},
dragDrop: {
...en.dragDrop,
overlay: "ドロップして@メンションとして添付",
},
settings: {
...en.settings,
title: "設定",
close: "閉じる",
language: "言語",
languageHint: "UIのみ — エージェントのプロンプトには影響しません。",
langEn: "English",
langZhCn: "简体中文",
langEnDesc: "英語UI",
langZhCnDesc: "中国語UI",
costCurrency: "コスト通貨",
costCurrencyHint:
"表示のみ。内部コストはトークンあたりUSD換算で計算され、固定レート7.2で変換されます。",
cnyDesc: "DeepSeekダッシュボードに一致",
usdDesc: "トークンあたりのリファレンスレートに一致",
theme: "テーマ",
themeHint: "このデバイスのみ、TUIとは同期されません。",
themeDark: "ダーク",
themeLight: "ライト",
themeDarkDesc: "デフォルト · 低照度",
themeLightDesc: "高コントラスト · 昼間",
themeStyle: "スタイル",
themeStyleHint: "選択したライト/ダークモードのビジュアルパレットを選択します。",
themeStyleGraphite: "グラファイト",
themeStyleGraphiteDesc: "ニュートラルなグラファイトパネルのオリジナルダークパレット。",
themeStyleEmber: "エンバー",
themeStyleEmberDesc: "Reasonixのオレンジを強めた暖かいダークパレット。",
themeStyleAurora: "オーロラ",
themeStyleAuroraDesc: "低照度でも柔らかいティールグリーンのダークパレット。",
themeStyleSandstone: "サンドストーン",
themeStyleSandstoneDesc: "オリジナルの温かみのあるライトパレット。",
themeStylePorcelain: "ポーセリン",
themeStylePorcelainDesc: "静かなコントラストのクリーンなCodexライクなライトパレット。",
themeStyleLinen: "リネン",
themeStyleLinenDesc: "紙のような表面を持つ編集調の暖かいライトパレット。",
themeStyleGlacier: "グレイシャー",
themeStyleGlacierDesc: "くっきりしたブルーアクセントのクールなライトパレット。",
themeStyleMidnight: "ミッドナイト",
themeStyleMidnightDesc: "クールなブルーの表面を持つネイビーダークパレット。",
fontScale: "フォントスケール",
fontScaleHint: "UI全体をスケーリング; 高DPI画面では大きめを選択。",
fontScaleSmall: "小",
fontScaleMedium: "中",
fontScaleLarge: "大",
fontFamily: "フォントファミリー",
fontFamilyHint: "本文用; コードブロックは常に等幅です。",
fontFamilySans: "サンセリフ",
fontFamilySystem: "システム",
fontFamilySerif: "セリフ",
fontFamilyCustom: "カスタム",
customFontFamily: "カスタムフォント",
customFontFamilyHint: "デスクトップUI全体のCSS font-family値。",
reasoningEffort: "推論努力",
reasoningEffortHint: "実行中のセッションに即時適用されます。",
effortHigh: "high",
effortMax: "max",
effortHighDesc: "高速、思考少なめ",
effortMaxDesc: "深い推論、低速",
editMode: "編集ゲート",
editModeHint:
"ツール承認動作 — 変更はディスクに保存され、次のツール呼び出しから有効になります。",
editModeReview: "review",
editModeAuto: "auto",
editModeYolo: "yolo",
showSystemEvents: "システムイベント",
showSystemEventsHint:
"コンテキスト圧縮、中断、レート制限の一時停止に関する静かなインライン通知を表示します。",
shown: "表示",
hidden: "非表示",
desktopCloseBehavior: "ウィンドウを閉じる",
desktopCloseBehaviorHint:
"既定では閉じると終了します。トレイに隠すと実行中のセッションを維持します。",
closeToQuit: "終了",
closeToTray: "トレイに隠す",
editModeReviewDesc: "すべての書き込みを承認",
editModeAutoDesc: "コマンドを承認、編集は自動",
editModeYoloDesc: "すべて自動承認 — 完全自動",
budget: "予算上限",
budgetHint: "セッションあたりUSD。空欄 = 上限なし。",
budgetPlaceholder: "上限なし",
webSearchEngine: "検索エンジン",
webSearchEngineBing: "bing — デフォルト、プロキシなしで中国から利用可",
webSearchEngineBingIntl: "bing-intl — www.bing.com 経由の国際版インデックス",
webSearchEngineSearxng: "searxng — セルフホスト (/se searxng <url> でエンドポイント設定)",
webSearchEngineMetaso: "metaso — 100回/日無料 (中国対応、metaso.cnで無料キー取得)",
webSearchEngineBaidu:
"baidu — Baidu AI Search、公式ドキュメントでは1500回/月無料 (BAIDU_API_KEY または QIANFAN_API_KEY を設定)",
webSearchEngineTavily: "tavily — 1000回/月無料 (TAVILY_API_KEY を設定)",
webSearchEnginePerplexity: "perplexity — AIネイティブ (PERPLEXITY_API_KEY を設定)",
webSearchEngineExa: "exa — AIネイティブ 1000回/月無料 (EXA_API_KEY を設定)",
webSearchEngineBrave: "brave — 独立インデックス、2000回/月無料 (BRAVE_SEARCH_API_KEY を設定)",
webSearchEngineOllama: "ollama — Ollama クラウド web 検索 (OLLAMA_API_KEY を設定)",
webSearchEngineNote: "次の web_search 呼び出しに適用されます",
webSearchEndpoint: "SearXNG エンドポイント",
webSearchEndpointHint: "SearXNGインスタンスのベースURL。デフォルトは http://localhost:8080。",
webSearchApiKeySignup: "(キーを取得)",
webSearchApiKeyClear: "クリア",
webSearchApiKey: {
metaso: "Metaso APIキー",
baidu: "Baidu APIキー",
tavily: "Tavily APIキー",
perplexity: "Perplexity APIキー",
exa: "Exa APIキー",
brave: "Brave APIキー",
ollama: "Ollama APIキー",
},
baseUrl: "DeepSeek ベースURL",
baseUrlHint: "プロキシ使用時のみ上書き。空欄 = 公式エンドポイント。再起動が必要です。",
workspace: "ワークスペース",
workspaceHint:
"エージェントツールが操作するルートディレクトリ。切り替えは設定に保存され、ツールが再読み込みされます。",
workspaceChange: "変更…",
editor: "エディタ",
editorHint:
"ファイルリンク/パスをクリックしたときに使用。システムデフォルト = OSのファイル関連付け。その他はPATHに必要。",
editorSystem: "システムデフォルト",
editorCustom: "カスタム",
editorPlaceholder: '例: "code", "subl", または実行ファイルのフルパス',
editorDefault: "(デフォルト)",
apiKey: "APIキー",
apiKeyHint: "DeepSeekキー — ~/.reasonix/config.json (0600) に保存。",
apiKeyNotSet: "(未設定)",
apiKeyCancel: "キャンセル",
apiKeySave: "保存",
apiKeyShow: "表示",
apiKeyHide: "非表示",
environment: "環境",
model: "モデル",
pageGeneralLabel: "一般",
pageGeneralDesc: "外観、言語、動作",
pageModelsLabel: "モデル",
pageModelsDesc: "デフォルトモデルとサンプリングパラメータ",
pageMcpLabel: "MCPサーバー",
pageMcpDesc: "MCPプロトコルツールサーバーの管理",
pageSkillsLabel: "スキル",
pageSkillsDesc: "/ コマンド用の再利用可能なプロンプトセット",
pageMemoryLabel: "メモリ",
pageMemoryDesc: "CLAUDE.md / AGENTS.md インジェクション",
pageRulesLabel: "承認ルール",
pageRulesDesc: "コマンドパターンの自動承認、拒否、確認",
pageBillingLabel: "アカウントと請求",
pageBillingDesc: "残高、使用量、請求書",
pageShortcutsLabel: "ショートカット",
pageShortcutsDesc: "キーボードショートカット概要",
appearanceSection: "外観",
workspaceSection: "ワークスペース",
behaviorSection: "動作",
apiSection: "DeepSeek API",
qqSection: "QQチャンネル",
qqTitle: "QQボット統合",
qqConnected: "接続済み",
qqConnecting: "接続中",
qqEnabled: "有効",
qqFailed: "失敗",
qqDisconnected: "切断済み",
qqSummaryMissing: "認証情報がまだ設定されていません",
qqSummaryAppId: "App ID {appId}",
qqSummaryDetail: "{appId} · {environment} · {access}",
qqConnect: "接続",
qqConfigure: "設定…",
qqDisconnect: "切断",
qqConfigureTitle: "QQチャンネルの設定",
qqConfigureHint: "QQボットを登録してメッセージの受信と返信を行います。",
qqEnvironment: "QQ環境",
qqEnvironmentHint:
"このボットのQQオープンプラットフォーム環境を選択します。テストにはSandbox、本番にはProductionを使用してください。",
qqBack: "戻る",
qqAppId: "App ID",
qqAppSecret: "App Secret",
qqSandbox: "Sandbox",
qqProduction: "Production",
qqAccess: "アクセス",
qqAccessOwner: "所有者 {openId}",
qqAccessOwnerWithAllowlist: "所有者 {openId} + 許可リスト {count}",
qqAccessAllowlist: "許可リスト {count}",
qqAccessRuntime: "最初の送信者(ランタイムのみ, {openId})",
qqAccessOpen: "オープン(制限なし)",
qqApplyLabel: "QQボットを申請",
qqApplyAction: "申請ページを開く",
qqSave: "保存",
qqSaveAndConnect: "保存して接続",
mcpSection: "設定済み",
currentWorkspace: "現在のワークスペース",
notSelected: "(未選択)",
apiKeySet: "設定済み · {prefix}…",
defaultModelCurrent: "デフォルトモデル · 現在 {model}",
modelCustom: "カスタムモデルID",
modelCustomHint: "エンドポイントが提供するOpenAI互換ID (vLLM, Ollama, Together, …)。",
modelCustomActive: "現在カスタムIDで実行中: {model}",
contextTokensLabel: "コンテキストウィンドウサイズ",
contextTokensHint:
"現在のモデルのプロンプト側トークン上限を上書きします(例: 1000000 で 1M)。空欄なら既定値を使用。",
contextTokensPlaceholder: "自動",
effortSection: "推論努力",
ctxWindow: "コンテキスト",
maxOutput: "出力",
mcpConfigured: "設定済み · {count}",
mcpBridged: "· ブリッジ済み",
mcpNotBridged: "· 現在のデスクトップセッションに適用中",
mcpStatusConnected: "· 接続済み {connected}/{total}",
mcpStatusConnecting: "· 接続中 {connected}/{total}、設定変更は自動適用されます",
mcpStatusFailed: "· 接続済み {connected}/{total}、{failed} 件失敗",
mcpStatusDisabled: "· {disabled}/{total} 件無効",
mcpStatusPending: "· 適用待ち",
mcpStatus: {
configured: "待機中",
handshake: "接続中",
connected: "接続済み",
failed: "接続失敗",
disabled: "無効",
},
mcpFailure: {
auth: "認証失敗 · headers に Authorization または x-api-key を設定してください",
missingToken: "Bearer Token がありません · headers に Authorization を設定してください",
command: "起動コマンドが利用できません · command/args またはローカル依存を確認してください",
network: "ネットワーク失敗 · エンドポイント、プロキシ、接続を確認してください",
unknown: "接続失敗 · 詳細で元のエラーを確認してください",
},
mcpEmpty: "MCPサーバーがまだ設定されていません。以下に仕様を入力して追加してください。",
mcpRemove: "削除",
mcpRetry: "再試行",
mcpDetail: "詳細",
mcpHideDetail: "閉じる",
mcpDetailTitle: "元のエラー",
mcpEditConfig: "編集",
parseError: "パース失敗: {error}",
mcpAddSection: "サーバーを追加",
mcpSpecLabel: "仕様文字列",
mcpSpecFormat: "形式: <code>name=command args</code> または <code>name=https://host/sse</code>",
mcpAdd: "追加",
mcpImport: "cc-switch からインポート",
mcpImporting: "インポート中…",
mcpEdit: "編集",
mcpEditTitle: "MCP を編集",
mcpEditBack: "戻る",
mcpEditName: "MCP タイトル(一意)",
mcpEditTransport: "トランスポート",
mcpEditJson: "完全な JSON 設定",
mcpEditFormat: "整形",
mcpEditSave: "保存",
mcpEditNameRequired: "MCP タイトルは必須です",
mcpEditJsonObjectRequired: "JSON 設定はオブジェクトである必要があります",
mcpEditCommandRequired: "stdio 設定には command が必要です",
mcpEditUrlRequired: "リモート MCP 設定には url が必要です",
skillsLoaded: "読み込み済み · {count} · / コマンドで呼び出し",
skillsEmpty:
"利用可能なスキルがありません。~/.reasonix/skills/ またはプロジェクトルートの .reasonix/skills/ に SKILL.md を作成してください。",
subagentModelFlash: "flash",
subagentModelPro: "pro",
subagentModelHint: "このスキルが分離サブエージェントを生成するときに使用するモデル",
memorySection: "長期メモリ",
memoryDesc:
"現在のバージョンはビルトインの CLAUDE.md / AGENTS.md インジェクションに依存しています。プロジェクトレベルのメモリはカーネルレベルで維持されます。",
appMode: "アプリケーションモード",
ruleAutoApprovalSection: "コマンドの自動承認",
ruleAutoApprovalHint:
'ApprovalCardで "Always allow" をクリックすると、コマンドプレフィックスが許可リストに追加され、以降の一致するコマンドはプロンプト表示されなくなります。',
balanceLabel: "ウォレット残高",
balanceLow: "不足",
balanceAvailable: "利用可能",
sessionCost: "このセッションのコスト",
cacheHitRate: "キャッシュヒット率",
shortcutNewChat: "新しいチャット",
shortcutNewTab: "新しいタブ",
shortcutCloseTab: "タブを閉じる",
shortcutCommandPalette: "コマンドパレット",
shortcutFocusComposer: "入力欄にフォーカス",
shortcutSwitchTab: "タブ切替",
shortcutAbort: "ストリーミング中断",
shortcutSettings: "設定",
},
modal: {
...en.modal,
planFeedbackPlaceholder: "任意のフィードバック — 変更が必要な場合はここに記入",
choiceCustomPlaceholder: "または自分の回答を入力…",
},
plan: {
...en.plan,
activeTitle: "アクティブプラン",
progress: "{done} / {total} ステップ",
progressNoTotal: "{done} ステップ",
expand: "展開",
collapse: "折りたたみ",
dismiss: "プランを閉じる",
riskLow: "低",
riskMed: "中",
riskHigh: "高",
},
editMode: {
...en.editMode,
label: "編集ゲート",
plan: "plan",
review: "review",
auto: "auto",
yolo: "yolo",
planDesc: "読み取り専用 · 編集・シェル不可",
reviewDesc: "すべての書き込みを承認",
autoDesc: "編集は自動、コマンドは承認",
yoloDesc: "すべて自動",
planHint: "読み取り専用モード · すべての書き込みツールが拒否されます",
reviewHint: "すべてのツール呼び出しに承認が必要です",
autoHint: "許可リストに一致するコマンドは自動承認されます",
yoloHint: "すべて自動承認 · 注意して使用してください",
},
markdown: {
...en.markdown,
filePillTitle: "クリックで開く · 右クリックでコピー",
externalLinkTitle: "ブラウザで {href} を開く",
localLinkTitle: "{href} を開く",
copy: "コピー",
copied: "コピーしました",
},
effort: {
...en.effort,
lowDesc: "最速 — 最小限の推論",
mediumDesc: "バランス",
highDesc: "デフォルト — vLLM / Azure で安全",
maxDesc: "DeepSeek拡張; 標準OpenAI / vLLMでは拒否されます",
},
checkpoint: {
...en.checkpoint,
title: "ステップ完了",
progress: "{done} / {total}",
notesLabel: "メモ",
continue: "続行",
revise: "修正…",
stop: "停止",
revisePlaceholder: "次のステップの前に変更が必要ですか?(空白のまま続行)",
sendRevise: "修正を送信",
},
revision: {
...en.revision,
title: "プラン修正",
subtitle: "モデルが残りのステップを置き換えようとしています",
remainingHeading: "新しい残りステップ",
accept: "承認",
reject: "拒否",
cancel: "キャンセル",
},
app: {
...en.app,
toast: {
...en.app.toast,
newSession: "新しいセッションを作成しました",
cleared: "クリアしました",
copied: "コピーしました",
copiedMd: "Markdownとしてコピーしました",
exportedMd: "Markdownファイルとしてエクスポートしました",
exportFailed: "エクスポート失敗: {error}",
exportDialogTitle: "会話をエクスポート",
emptySession: "セッションが空です",
langSwitched: "{lang} に切り替えました",
modelSwitched: "モデル → {model}",
effortSwitched: "努力 → {effort}",
modeSwitched: "モード: {mode}",
taskComplete: "タスク完了",
},
cmd: {
...en.app.cmd,
newSession: "新しいセッション",
clearChat: "画面クリア",
abort: "ストリーミング出力を停止",
copyLast: "最後の返信をコピー",
switchModel: "モデル切替",
mcp: "MCPサーバー設定を開く",
mcpStatus: "MCP接続ステータスを表示",
mcpRetry: "失敗したMCP接続を再試行",
mcpImport: "cc-switchからMCPサーバーをインポート",
searchEngine: "Web検索エンジンとAPIキーを設定",
skill: "ユーザースキルの一覧と管理(プロジェクト + カスタム + グローバル + 組み込み)",
setMode: "モードを {mode} に設定",
setEffort: "推論努力を {effort} に設定",
toggleTheme: "テーマ切替",
toggleCurrency: "通貨切替 (CNY / USD)",
toggleLang: "言語切替 (CN / EN)",
exportMd: "セッションをMarkdownでコピー",
help: "全コマンドを表示",
feedback: "GitHubでフィードバックを送信(ブラウザが開きます)",
compact: "古いターンをサマリーに折りたたみ(コンテキスト節約)",
retry: "最後のメッセージを切り詰めて再送",
btw: "脇道の質問をする(コンテキストに追加されません)",
},
skill: {
...en.app.skill,
generic: "{scope} スキル · {runAs}",
scope: {
...en.app.skill.scope,
builtin: "ビルトイン",
global: "グローバル",
project: "プロジェクト",
},
runAs: {
inline: "インライン",
subagent: "サブエージェント",
},
},
yolo: {
...en.app.yolo,
banner1: "すべてのツール呼び出し、シェルコマンド、ファイル編集が",
bannerBold: "自動承認",
banner2: "されます(確認なし)。",
switchBack: "レビューに切り替え",
toast: "すべての操作が確認なしで自動承認されます",
},
btwUsage: "▸ /btw <question> — 会話コンテキストを汚さずに脇道の質問をします。",
errorLabel: "エラー",
warningLabel: "警告",
dismissError: "閉じる",
jumpToBottom: "最下部にジャンプ",
splashSubtitle: "DeepSeek Agents",
connecting: "reasonixコアに接続中…",
startupFailedTitle: "Reasonixを起動できませんでした",
startupFailedMessage: "デスクトップバックエンドがワークスペースを開く前に失敗しました。",
startupFailedRetry: "再試行",
startupFailedUnknown: "不明な起動エラー",
langZH: "中国語",
langEN: "英語",
titlebar: {
...en.app.titlebar,
sidebar: "サイドバー (⌘B)",
contextPanel: "コンテキストパネル",
more: "その他",
commandPalette: "コマンドパレット",
copyMd: "会話をMarkdownでコピー",
exportMd: "Markdownをエクスポート",
clearChat: "画面クリア",
settings: "設定",
windowControls: "ウィンドウ操作",
close: "閉じる",
minimize: "最小化",
maximize: "最大化",
restore: "元に戻す",
},
tab: {
...en.app.tab,
newTabTitle: "新しいタブ ⌘T",
newTab: "新しいタブ",
},
header: {
...en.app.header,
noWorkspace: "ワークスペースが選択されていません",
running: "実行中",
clickToSelect: "クリックしてワークスペースを選択",
copy: "コピー",
copyMd: "会話をMarkdownでコピー",
export: "エクスポート",
exportMd: "Markdownファイルとしてエクスポート",
newChat: "新しいチャット",
abort: "停止",
},
empty: {
...en.app.empty,
welcome: "Reasonixへようこそ",
currentWorkspace: "現在のワークスペース: ",
selectWorkspace: "まずトップバーからワークスペースを選択してください",
suggestion0: "最新のコミット変更をレビュー",
suggestion1: "現在のファイルのTypeScriptエラーをすべて修正",
suggestion2: "READMEを中国語/英語に翻訳",
suggestion3: "このリポジトリのCHANGELOGを生成",
},
setup: {
...en.app.setup,
welcome: "Reasonixへようこそ",
description:
"初回セットアップにはDeepSeek APIキーとワークスペースディレクトリが必要です。キーはローカルのみに保存されます。",
workspace: "ワークスペース",
notSelected: "未選択",
choose: "選択…",
saveAndStart: "保存して開始",
},
update: {
...en.app.update,
available: "新しいバージョンが利用可能 · {current} → {latest}",
installing: "インストール中…",
downloading: "ダウンロード中 · {downloaded} / {total} ({pct}%)",
downloadingUnknown: "ダウンロード中 · {downloaded}",
failed: "インストールに失敗しました",
clickToInstall: "クリックしてインストール & 再起動",
install: "インストール",
later: "後で",
},
exportUserLabel: "あなた",
exportReasoningSummary: "推論",
exportToolLabel: "ツール",
session: {
...en.app.session,
new: "{workspace} · 新しいセッション",
format: "セッション {month}-{day} {hour}:{minute}",
},
},
planBadge: {
...en.planBadge,
failed: "失敗",
blocked: "ブロック",
running: "実行中",
done: "完了",
pending: "保留中",
},
cards: {
...en.cards,
reasoningName: "思考中",
reasoningComplete: "完了",
compactionName: "履歴圧縮",
compactionMeta: "{chars} 文字",
shellAwaiting: "承認待ち",
shellRunning: "実行中",
shellExecuteHint: "このコマンドを実行",
shellAlwaysAllow: "常に許可",
shellReject: "拒否",
shellRun: "実行",
diffApplyChanges: "変更を適用",
diffDiscard: "破棄",
diffApply: "適用",
diffAwaiting: "承認待ち",
errorName: "エラー",
searchName: "Web検索",
subagentDone: "完了",
subagentFailed: "失敗",
subagentRunning: "実行中",
subagentDoneProgress: "完了",
memoryName: "メモリ",
memoryCountSuffix: "項目",
checkpointRewind: "ここに戻す",
planDefaultTitle: "プラン",
streaming: "ストリーミング中…",
failed: "失敗",
done: "完了",
running: "実行中",
error: "エラー",
result: "結果",
hits: "ヒット",
applied: "適用済み",
cacheHit: "キャッシュヒット",
prompt: "プロンプト",
reasoning: "推論",
output: "出力",
cost: "コスト",
elapsed: "経過",
},
statusbar: {
...en.statusbar,
offline: "オフライン",
busy: "実行中",
online: "オンライン",
jobs: "ジョブ",
jobsTip: "バックグラウンドプロセス · ⌘J",
cacheHit: "キャッシュヒット",
cache: "キャッシュ",
tokens: "トークン",
thisTurn: "このターン",
switchWorkspace: "ワークスペース切替 · {workspace}",
switchCurrency: "通貨切替 (CNY / USD)",
balance: "残高",
switchTheme: "テーマ切替",
themeDark: "ダーク",
themeLight: "ライト",
themeStyleGraphite: "グラファイト",
themeStyleEmber: "エンバー",
themeStyleAurora: "オーロラ",
themeStyleSandstone: "サンドストーン",
themeStylePorcelain: "ポーセリン",
themeStyleLinen: "リネン",
themeStyleGlacier: "グレイシャー",
themeStyleMidnight: "ミッドナイト",
},
jobs: {
...en.jobs,
title: "バックグラウンドジョブ",
running: "実行中",
exited: "終了",
empty: "このワークスペースにバックグラウンドジョブはまだありません。",
stopAll: "すべて停止",
stopAllTip: "実行中の全ジョブにSIGTERMを送信",
stop: "停止",
stopOne: "このジョブを停止",
kbToggle: "切替",
kbClose: "閉じる",
},
contextPanel: {
...en.contextPanel,
reservedKey: "予約済み",
usedKey: "使用中",
filesTab: "ファイル",
toolsTab: "ツール",
memoryTab: "メモリ",
rulesTab: "ルール",
contextTokens: "コンテキスト · トークン",
cacheKey: "キャッシュ",
freeKey: "空き",
filesTitle: "コンテキスト内のファイル",
filesCount: "{count} ファイル",
fileModified: "変更あり",
fileInContext: "コンテキスト内",
openFile: "ファイルを開く: {path}",
copyPath: "パスをコピー: {path}",
mcpTitle: "MCPサーバー",
mcpReadyAll: "{count} 準備完了",
mcpReadySome: "{ready}/{count} 準備完了",
mcpEmpty: "MCPサーバーが設定されていません",
mcpTools: "{count} ツール",
mcpReady: "準備完了",
mcpConnecting: "接続中",
mcpDisabled: "無効",
mcpFailed: "失敗",
mcpConfigured: "設定済み",
mcpHealthTotal: "MCP {count}",
mcpHealthReady: "接続済み {count}",
mcpHealthFailed: "失敗 {count}",
mcpHealthTools: "ツール {count}",
mcpFilterAll: "すべて",
mcpFilterReady: "利用可能",
mcpFilterFailed: "失敗",
mcpFilterEmpty: "このフィルターにサーバーはありません",
mcpRetryAll: "失敗項目を再試行",
mcpSettings: "設定",
memoryTitle: "長期メモリ",
itemCount: "{count} 項目",
scopeProject: "プロジェクト",
scopeGlobal: "グローバル",
autoApproveTitle: "自動承認",
ruleYolo: "YOLOモード · すべてのツール呼び出しが自動承認",
ruleReadOnly: "読み取り専用ツールは自動承認",
ruleShellAllowlist: "許可リストに一致するシェルコマンドは自動承認",
ruleWritesAsk: "書き込みと未知のシェルコマンドは確認が必要",
ruleReview: "レビューモード · すべてのツール呼び出しに確認が必要",
allow: "許可",
ask: "確認",
noFilesMsg: "まだコンテキスト内にファイルはありません。",
noMemoriesMsg: "まだメモリは保存されていません。",
},
sidebarPanel: {
...en.sidebarPanel,
sessionTitle: "セッション {month}-{day} {hour}:{minute}",
justNow: "たった今",
minutesAgo: "{n}分前",
hoursAgo: "{n}時間前",
daysAgo: "{n}日前",
weeksAgo: "{n}週間前",
newChat: "新しいチャット",
commandPalette: "コマンドパレット",
workspace: "ワークスペース",
noWorkspace: "ワークスペースなし",
pickWorkspace: "ワークスペースを選択",
switchWorkspace: "ワークスペースを切り替え · {workspace}",
searchSessions: "セッションを検索…",
recent: "最近",
noSessions: "セッションなし",
noMatches: "一致なし",
messageCount: "{count} メッセージ",
deleteSession: "セッションを削除",
importSessions: "セッションをインポート",
importDetected: "検出済み",
importCustom: "カスタム",
importFoundApps: "検出されたアプリ",
importScanning: "ローカルアプリをスキャン中…",
importSessionCount: "{count} セッション · すべてインポート",
importNotFound: "ローカルセッションが見つかりません",
importPrivacyHint: "既存のアプリ設定は変更されません。",
importResult: "{imported} 件のセッションをインポート、{skipped} 件スキップ、{failed} 件失敗。",
continue: "続行",
refresh: "更新",
importSource: "ソース",
importFromClaude: "Claude",
importFromCodex: "Codex",
importPath: "セッションファイルのパス",
importName: "セッション名",
importNamePlaceholder: "任意のカスタム名",
importConfirm: "インポート",
browse: "参照",
renameSession: "セッション名を変更",
renamePlaceholder: "セッションタイトル",
approvalRules: "承認ルール",
settings: "設定",
cancel: "キャンセル",
delete: "削除",
},
extraCards: {
...en.extraCards,
sessionUsage: "セッション使用量",
sessionCost: "コスト {costLabel}",
approve: "承認",
reject: "拒否",
doctorTitle: "/doctor · システムチェック",
balance: "残高 {balanceLabel}",
prompt: "プロンプト",
reasoning: "推論",
output: "出力",
cacheHit: "キャッシュヒット",
cache: "キャッシュ",
contextWindow: "コンテキストウィンドウ",
topToolsUsage: "上位ツール — 使用量",
memoryUser: "USER · 設定",
memoryFeedback: "FEEDBACK · 修正",
memoryProject: "PROJECT · プロジェクトルール",
memoryReference: "REFERENCE · リファレンス",
unknownKind: "# 不明なカードタイプ — フォールバック表示",
},
thread: {
...en.thread,
toolCalls: "{count} 件のツール呼び出し",
oneToolCall: "1 件のツール呼び出し",
keepSteps: "残り {n} ステップを保持",
keepOriginal: "オリジナルを保持",
you: "あなた",
planRunning: "プラン実行中 · ステップ {step} / {total}",
collapse: "折りたたみ",
activePlan: "アクティブプラン",
risk: "リスク",
planStepCount: "{count} ステップ",
planConfirmationKind: "プラン確認",
startPlan: "プラン開始",
approve: "承認",
cancel: "キャンセル",
refine: "修正依頼",
sendFeedback: "送信",
skipFeedback: "スキップ",
cancelFeedbackLabel: "キャンセル — 問題は何ですか?",
refineFeedbackLabel: "修正 — 何を変更すべきですか?",
checkpointKind: "プランチェックポイント",
checkpointTitle: "ステップ {completed} / {total} 完了",
checkpointSub: "チェックポイント · {completed} / {total}",
continue: "続行",
stop: "停止",
revise: "修正",
planRevisionKind: "プラン修正",
rewritePlan: "プラン書き直し",
revisionMeta: "理由 · ランタイム制約",
approveRewrite: "書き直しを承認",
shellConfirmationKind: "シェル確認",
runCommand: "コマンド実行",
runBackgroundCommand: "バックグラウンドコマンド実行",
riskMedium: "リスク · 中 · {kind}",
execute: "実行",
reject: "拒否",
alwaysAllow: '"{prefix}" を常に許可',
pathAccessKind: "パスアクセス",
read: "読み取り",
write: "書き込み",
readPathOutsideSandbox: "サンドボックス外のパスを読み取り",
writePathOutsideSandbox: "サンドボックス外のパスに書き込み",
allowRead: "読み取り許可",
allowWrite: "書き込み許可",
alwaysAllowPrefix: "{prefix} を常に許可",
userChoiceKind: "ユーザー選択",
optionCount: "{count} 選択肢",
subagent: "サブエージェント",
copyMessage: "このメッセージをコピー",
editMessage: "このメッセージを編集して再送信",
copyResponse: "この返信をコピー",
},
live: {
...en.live,
reasoning: "ライブ · 推論",
running: "実行中",
you: "あなた",
secondsAgo: "{seconds}秒前",
deliveredWaiting: "配信済み · エージェント待ち",
},
panel: {
...en.panel,
renderError: "{panel} パネルレンダリングエラー",
},
notifications: {
...en.notifications,
approvalTitle: "Reasonixが承認待ちです",
inputTitle: "Reasonixが入力を待っています",
planApprovalTitle: "Reasonixがプラン承認待ちです",
checkpointApprovalTitle: "Reasonixがチェックポイント承認待ちです",
revisionTitle: "Reasonixがプラン修正を提案しました",
commandBody: "コマンド: {command}",
readBody: "読み取り: {path}",
writeBody: "書き込み: {path}",
turnCompleteTitle: "Reasonixタスク完了",
turnCompleteBody: "現在のタスクが完了し、レビュー可能です。",
},
};
+5
View File
@@ -0,0 +1,5 @@
import { en } from "./en";
export const ru: typeof en = {
...en,
};
+140 -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: "进行中的计划",
@@ -367,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: {
@@ -405,6 +488,12 @@ 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: "切换深浅主题",
@@ -561,6 +650,12 @@ export const zhCN: typeof en = {
cost: "花费",
elapsed: "耗时",
},
mcpCard: {
showTools: "工具",
hideTools: "收起工具",
availableTools: "{count} 个可用工具",
noTools: "已连接,但该服务器未暴露可调用工具",
},
contextPanel: {
reservedKey: "预留",
usedKey: "已用",
@@ -587,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: "项目",
@@ -611,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: "审批规则",
@@ -645,6 +773,8 @@ export const zhCN: typeof en = {
unknownKind: "# 未知卡片类型 — fallback 渲染",
},
thread: {
toolCalls: "{count} 个工具调用",
oneToolCall: "1 个工具调用",
keepSteps: "保留剩余 {n} 步",
keepOriginal: "保留原计划",
you: "你",
@@ -658,6 +788,10 @@ export const zhCN: typeof en = {
approve: "批准",
cancel: "取消",
refine: "细化",
sendFeedback: "发送",
skipFeedback: "跳过",
cancelFeedbackLabel: "取消 — 哪里有问题?",
refineFeedbackLabel: "细化 — 还需要什么?",
checkpointKind: "计划检查点",
checkpointTitle: "step {completed} / {total} 完成",
checkpointSub: "检查点 · {completed} / {total}",
@@ -674,7 +808,7 @@ export const zhCN: typeof en = {
riskMedium: "risk · 中 · {kind}",
execute: "执行",
reject: "拒绝",
alwaysAllow: "始终允许 \"{prefix}\"",
alwaysAllow: '始终允许 "{prefix}"',
pathAccessKind: "路径访问",
read: "读取",
write: "写入",
@@ -710,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;
+112 -1
View File
@@ -111,6 +111,29 @@ export type SessionsEvent = {
}[];
};
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 = {
type: "$mention_results";
nonce: number;
@@ -126,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;
@@ -144,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 = {
@@ -156,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 = {
@@ -275,11 +334,15 @@ 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";
@@ -292,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;
@@ -311,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 = {
@@ -324,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;
};
@@ -447,6 +541,8 @@ export type IncomingEvent = { tabId?: string } & (
| ChoiceRequiredEvent
| PlanRequiredEvent
| SessionsEvent
| SessionImportSourcesEvent
| SessionImportResultEvent
| SessionLoadedEvent
| SessionEmptyEvent
| NeedsSetupEvent
@@ -459,6 +555,7 @@ export type IncomingEvent = { tabId?: string } & (
| PlanClearedEvent
| MentionResultsEvent
| MentionPreviewEvent
| PromptHistoryResultEvent
| TabOpenedEvent
| TabClosedEvent
| McpSpecsEvent
@@ -493,6 +590,9 @@ 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 }
@@ -505,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" }
+1161 -75
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 ",
);
});
});
+298 -119
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";
@@ -69,6 +70,7 @@ export type SlashCmd = {
};
export type MentionItem = {
name: string;
path?: string;
kind: "file" | "dir" | "url" | "agent" | "clip";
desc?: string;
};
@@ -77,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} />,
@@ -108,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} />;
@@ -117,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,
@@ -144,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;
@@ -171,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;
@@ -196,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]);
@@ -231,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();
@@ -254,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) {
@@ -276,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>) => {
@@ -388,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;
@@ -422,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());
}
}
};
@@ -512,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
@@ -638,9 +814,8 @@ export function Composer({
disabled={disabled || !draft.trim()}
onClick={() => {
if (!disabled && draft.trim()) {
recordSendAndReset();
onSend();
setChips([]);
setPickedChips(new Map());
}
}}
>
@@ -659,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);
}
}}
@@ -691,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>
@@ -709,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={{
@@ -743,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");
});
});
+152 -71
View File
@@ -1,13 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
import { openPath } from "@tauri-apps/plugin-opener";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { SessionFile, Settings, UsageStats } from "../App";
import { Markdown } from "../Markdown";
import { t, useLang } from "../i18n";
import { I } from "../icons";
import type { McpSpecInfo, MemoryDetail, MemoryEntryInfo } from "../protocol";
import { PanelErrorBoundary } from "./error-boundary";
import { McpServerCard } from "./mcp-server-card";
type Tab = "files" | "tools" | "memory" | "rules";
export type ContextPanelTab = "files" | "tools" | "memory" | "rules";
const CONTEXT_MAX_TOKENS = 1_000_000;
@@ -19,7 +21,12 @@ export function ContextPanel({
sessionFiles,
memory,
memoryDetail,
activeTab,
activeTabNonce,
onReadMemory,
onOpenMcpSettings,
onEditMcpSpec,
onRetryMcpSpec,
}: {
settings: Settings | null;
usage: UsageStats;
@@ -28,15 +35,25 @@ export function ContextPanel({
sessionFiles: SessionFile[];
memory: MemoryEntryInfo[];
memoryDetail: MemoryDetail | null;
activeTab?: ContextPanelTab;
activeTabNonce?: number;
onReadMemory: (path: string) => void;
onOpenMcpSettings?: () => void;
onEditMcpSpec?: (spec: McpSpecInfo) => void;
onRetryMcpSpec?: (raw: string) => void;
}) {
useLang();
const [tab, setTab] = useState<Tab>("files");
const [tab, setTab] = useState<ContextPanelTab>("files");
useEffect(() => {
if (activeTab) setTab(activeTab);
}, [activeTab, activeTabNonce]);
const reserved = usage.reservedTokens;
const lastHit = usage.lastCallCacheHit ?? 0;
const lastMiss = usage.lastCallCacheMiss ?? 0;
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);
@@ -59,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">
@@ -91,14 +108,24 @@ export function ContextPanel({
</div>
</div>
<PanelErrorBoundary key={tab} label={tab}>
{tab === "files" && <CtxFiles files={sessionFiles} settings={settings} />}
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
{tab === "memory" && (
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
)}
{tab === "rules" && <CtxRules settings={settings} />}
</PanelErrorBoundary>
<div className="ctx-body-tab">
<PanelErrorBoundary key={tab} label={tab}>
{tab === "files" && <CtxFiles files={sessionFiles} settings={settings} />}
{tab === "tools" && (
<CtxTools
specs={mcpSpecs}
bridged={mcpBridged}
onOpenSettings={onOpenMcpSettings}
onEdit={onEditMcpSpec}
onRetry={onRetryMcpSpec}
/>
)}
{tab === "memory" && (
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
)}
{tab === "rules" && <CtxRules settings={settings} />}
</PanelErrorBoundary>
</div>
</div>
</aside>
);
@@ -110,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 });
@@ -231,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">
@@ -248,42 +306,59 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
{specs.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.mcpEmpty")}</div>
) : (
specs.map((s) => {
const dot =
s.status === "connected"
? "ok"
: s.status === "failed" || s.parseError
? "off"
: "pending";
const suffix = s.statusReason
? ` · ${s.statusReason}`
: s.status === "connected"
? typeof s.toolCount === "number"
? ` · ${t("contextPanel.mcpTools", { count: s.toolCount })}`
: ` · ${t("contextPanel.mcpReady")}`
: s.status === "handshake"
? ` · ${t("contextPanel.mcpConnecting")}`
: s.status === "disabled"
? ` · ${t("contextPanel.mcpDisabled")}`
: s.status === "failed"
? ` · ${t("contextPanel.mcpFailed")}`
: ` · ${t("contextPanel.mcpConfigured")}`;
return (
<div className="mcp-row" key={s.raw}>
<span className="ico">
<I.wrench size={12} />
</span>
<div className="body">
<div className="n">{s.name ?? s.summary}</div>
<div className="m">
{s.transport}
{suffix}
</div>
</div>
<span className="status" data-s={dot} />
</div>
);
})
<>
<div className="mcp-health-strip">
<span>{t("contextPanel.mcpHealthTotal", { count: specs.length })}</span>
<span data-kind="ok">{t("contextPanel.mcpHealthReady", { count: readyCount })}</span>
<span data-kind={failedCount > 0 ? "failed" : "muted"}>
{t("contextPanel.mcpHealthFailed", { count: failedCount })}
</span>
<span>{t("contextPanel.mcpHealthTools", { count: toolCount })}</span>
</div>
<div className="mcp-filter-row">
{(["all", "ready", "failed"] as const).map((kind) => (
<button
key={kind}
type="button"
className="mcp-filter"
data-active={filter === kind}
onClick={() => setFilter(kind)}
>
{kind === "all"
? t("contextPanel.mcpFilterAll")
: kind === "ready"
? t("contextPanel.mcpFilterReady")
: t("contextPanel.mcpFilterFailed")}
</button>
))}
<span className="spacer" />
{failedCount > 0 && onRetry ? (
<button type="button" className="mcp-mini-action" onClick={retryAll}>
<I.refresh size={12} />
{t("contextPanel.mcpRetryAll")}
</button>
) : null}
{onOpenSettings ? (
<button type="button" className="mcp-mini-action" onClick={onOpenSettings}>
<I.cog size={12} />
{t("contextPanel.mcpSettings")}
</button>
) : null}
</div>
{filtered.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.mcpFilterEmpty")}</div>
) : (
filtered.map((s) => (
<McpServerCard
key={s.raw}
spec={s}
mode="context"
onRetry={onRetry}
onEdit={onEdit ?? (onOpenSettings ? () => onOpenSettings() : undefined)}
/>
))
)}
</>
)}
</div>
);
@@ -299,7 +374,7 @@ function CtxMemory({
onRead: (path: string) => void;
}) {
return (
<div className="ctx-block">
<div className="ctx-block" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
<div className="h">
<span>{t("contextPanel.memoryTitle")}</span>
<span className="right">
@@ -309,22 +384,28 @@ function CtxMemory({
{entries.length === 0 ? (
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
) : (
<div className="mem">
{entries.map((m) => (
<button
type="button"
className="mem-row"
data-active={detail?.path === m.path}
key={m.path}
onClick={() => onRead(m.path)}
>
<span className="scope" data-s={m.scope}>
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
</span>
<span className="txt">{m.description || m.name}</span>
</button>
))}
{detail ? <pre className="mem-detail">{detail.body}</pre> : null}
<div className="mem" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
<div style={{ flexShrink: 0 }}>
{entries.map((m) => (
<button
type="button"
className="mem-row"
data-active={detail?.path === m.path}
key={m.path}
onClick={() => onRead(m.path)}
>
<span className="scope" data-s={m.scope}>
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
</span>
<span className="txt">{m.description || m.name}</span>
</button>
))}
</div>
{detail ? (
<div className="mem-detail">
<Markdown source={detail.body} />
</div>
) : null}
</div>
)}
</div>
+7 -1
View File
@@ -258,7 +258,13 @@ export function UsageFull({
useLang();
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
const total = promptTokens + reasoningTokens + outputTokens + cacheHitTokens || 1;
const pct = (n: number) => Math.round((n / total) * 100);
const pct = (n: number): number => {
const raw = (n / total) * 100;
if (raw === 100) return 100;
const rounded = Math.round(raw * 100) / 100;
if (rounded >= 100) return 99.99;
return rounded;
};
return (
<div className="usage-full">
<div className="uh">
+48 -21
View File
@@ -1,12 +1,14 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
interface JumpBarProps {
messages: { kind: string; text?: string; turn?: number }[];
threadEl: HTMLElement | null;
threadEl?: HTMLElement | null;
onScrollToTurn?: (turn: number) => void;
}
export function JumpBar({ messages, threadEl }: JumpBarProps) {
export function JumpBar({ messages, threadEl, onScrollToTurn }: JumpBarProps) {
const [hovered, setHovered] = useState<number | null>(null);
const [active, setActive] = useState<number | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const previewTop = useRef(0);
const [showPreview, setShowPreview] = useState(false);
@@ -21,20 +23,29 @@ export function JumpBar({ messages, threadEl }: JumpBarProps) {
[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 visible = items;
const hoverIdx = hovered !== null ? visible.findIndex((v) => v.turn === hovered) : -1;
const hoverText = hovered !== null ? visible.find((v) => v.turn === hovered)?.text : 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 items = el.querySelectorAll<HTMLElement>(".jump-item");
const q = el.querySelectorAll<HTMLElement>(".jump-item");
const barRect = el.getBoundingClientRect();
let closest = -1;
let closestDist = Infinity;
items.forEach((item, i) => {
q.forEach((item, i) => {
const r = item.getBoundingClientRect();
const midY = r.top + r.height / 2;
const dist = Math.abs(e.clientY - midY);
@@ -44,8 +55,8 @@ export function JumpBar({ messages, threadEl }: JumpBarProps) {
previewTop.current = midY - barRect.top;
}
});
if (closest >= 0 && closest < visible.length) {
const turn = visible[closest]?.turn;
if (closest >= 0 && closest < items.length) {
const turn = items[closest]?.turn;
if (turn !== undefined) {
setHovered(turn);
setShowPreview(true);
@@ -54,25 +65,41 @@ export function JumpBar({ messages, threadEl }: JumpBarProps) {
};
const scrollTo = (turn: number) => {
const el = threadEl?.querySelector(`[data-turn="${turn}"]`);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
setActive(turn);
if (onScrollToTurn) {
onScrollToTurn(turn);
} else {
threadEl
?.querySelector(`[data-turn="${turn}"]`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const barProps = (idx: number): { style: React.CSSProperties; "data-d"?: string } => {
if (hoverIdx < 0) return { style: { width: 12 } };
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 : 12;
return { style: { width, transitionDelay: `${d * 20}ms` }, "data-d": d <= 2 ? String(d) : undefined };
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">
{visible.map((item, idx) => (
<div className="jump-item" key={item.turn} onClick={() => scrollTo(item.turn)}>
<div className="jump-dot" {...barProps(idx)} />
</div>
))}
{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 }}>
+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>
);
}
+569 -41
View File
@@ -1,8 +1,10 @@
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 { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
import { I } from "../icons";
import type {
ImportedMcpServer,
McpSpecInfo,
MemoryDetail,
MemoryEntryInfo,
@@ -26,6 +28,7 @@ import {
type ThemeStyle,
themeForStyle,
} from "../theme";
import { McpServerCard } from "./mcp-server-card";
import { Shortcut, type ShortcutKey } from "./shortcut";
export type PageId =
@@ -65,6 +68,8 @@ export function SettingsModal({
customFontFamily,
onSetCustomFontFamily,
initialPage,
initialMcpEditRaw,
initialMcpEditNonce,
mcpSpecs,
mcpBridged,
skills,
@@ -80,8 +85,11 @@ export function SettingsModal({
onSaveQQConfig,
onOpenQQApplyLink,
onPickWorkspace,
onImportCcSwitchMcp,
onAddMcpSpec,
onRemoveMcpSpec,
onUpdateMcpSpec,
onRetryMcpSpec,
onReadMemory,
}: {
settings: SettingsType;
@@ -99,6 +107,8 @@ export function SettingsModal({
customFontFamily: string;
onSetCustomFontFamily: (family: string) => void;
initialPage?: PageId;
initialMcpEditRaw?: string | null;
initialMcpEditNonce?: number;
mcpSpecs: McpSpecInfo[];
mcpBridged: boolean;
skills: SkillInfo[];
@@ -114,12 +124,18 @@ export function SettingsModal({
onSaveQQConfig: (patch: { appId?: string; appSecret?: string; sandbox: boolean }) => void;
onOpenQQApplyLink: () => void;
onPickWorkspace: () => void;
onImportCcSwitchMcp: () => Promise<void>;
onAddMcpSpec: (spec: string) => void;
onRemoveMcpSpec: (spec: string) => void;
onUpdateMcpSpec: (raw: string, server: ImportedMcpServer) => void;
onRetryMcpSpec: (raw: string) => void;
onReadMemory: (path: string) => void;
}) {
const [page, setPage] = useState<PageId>(initialPage ?? "general");
const [qqConfigureOpen, setQQConfigureOpen] = useState(false);
useEffect(() => {
if (initialPage) setPage(initialPage);
}, [initialPage]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
@@ -190,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" && (
@@ -635,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>
@@ -700,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>
@@ -728,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,
@@ -805,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>
@@ -870,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>
@@ -898,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
@@ -944,32 +1261,14 @@ function PageMCP({
</div>
) : (
specs.map((s) => (
<div className="scard" key={s.raw}>
<div className="top">
<span className="ico">
<I.wrench size={14} />
</span>
<div className="mcp-spec-body">
<div className="nm">{s.name ?? "(anonymous)"}</div>
<div className="sub mcp-spec-summary" title={s.summary}>
{s.summary}
</div>
</div>
<button
type="button"
className="btn ghost mcp-remove"
style={{ color: "var(--danger)" }}
onClick={() => onRemove(s.raw)}
>
{t("settings.mcpRemove")}
</button>
</div>
{s.parseError ? (
<div className="desc" style={{ color: "var(--danger)" }}>
{t("settings.parseError", { error: s.parseError })}
</div>
) : null}
</div>
<McpServerCard
key={s.raw}
spec={s}
mode="settings"
onEdit={setEditing}
onRetry={onRetry}
onRemove={onRemove}
/>
))
)}
</section>
@@ -1000,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,
+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>
);
}
+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}
+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: {
+1 -1
View File
@@ -178,7 +178,7 @@ src/
│ ├── skills.ts # list + invoke SKILL.md playbooks
│ ├── subagent.ts # spawn_subagent — flash+high by default
│ ├── plan.ts # submit_plan (review gate)
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)
│ └── web.ts # web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)
├── mcp/ # MCP client + bridge (stdio + SSE)
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
├── project-memory.ts # REASONIX.md loader
+1 -1
View File
@@ -128,7 +128,7 @@ Type `/` mid-chat to open the picker. Aliases shown in parentheses. Code-mode-on
|---|---|
| `/pro [off]` | Arm v4-pro for the NEXT turn only |
| `/budget [usd\|off]` | Session USD cap |
| `/search-engine <mojeek\|searxng\|metaso>` (`/se`) | Switch web search backend |
| `/search-engine <bing\|bing-intl\|searxng\|metaso\|baidu\|tavily\|perplexity\|exa\|brave\|ollama>` (`/se`) | Switch web search backend |
| `/hooks [reload]` | List / reload hooks |
| `/permissions [list\|add\|remove\|clear]` | Edit shell allowlist |
| `/dashboard [stop]` | Launch / stop the embedded web dashboard |
+1 -1
View File
@@ -433,7 +433,7 @@
│ ├── skills.ts <span class="hash"># list + invoke SKILL.md playbooks</span>
│ ├── subagent.ts <span class="hash"># spawn_subagent — flash+high by default</span>
│ ├── plan.ts <span class="hash"># submit_plan (review gate)</span>
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)</span>
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)</span>
├── mcp/ <span class="hash"># MCP client + bridge (stdio + SSE)</span>
├── memory.ts <span class="hash"># ImmutablePrefix / AppendOnlyLog / VolatileScratch</span>
├── project-memory.ts <span class="hash"># REASONIX.md loader</span>
+1 -1
View File
@@ -531,7 +531,7 @@
<td>Session USD cap</td>
</tr>
<tr>
<td><code>/search-engine &lt;mojeek|searxng|metaso&gt;</code> (<code>/se</code>)</td>
<td><code>/search-engine &lt;bing|bing-intl|searxng|metaso|baidu|tavily|perplexity|exa|brave|ollama&gt;</code> (<code>/se</code>)</td>
<td>Switch web search backend</td>
</tr>
<tr>
+16 -6
View File
@@ -200,7 +200,7 @@
"reasoningEffort": "high", <span class="hash"># <span data-i18n="cfg.k.effort">high | max</span></span>
"theme": "auto", <span class="hash"># <span data-i18n="cfg.k.theme">light | dark | auto</span></span>
"search": false, <span class="hash"># <span data-i18n="cfg.k.search">enable web_search/web_fetch tools</span></span>
"webSearchEngine": "mojeek", <span class="hash"># <span data-i18n="cfg.k.engine">mojeek | searxng | metaso</span></span>
"webSearchEngine": "bing", <span class="hash"># <span data-i18n="cfg.k.engine">bing | baidu | searxng | metaso | ...</span></span>
"webSearchEndpoint": "http://localhost:8080",
"mcp": [], <span class="hash"># <span data-i18n="cfg.k.mcp">MCP server specs (legacy string format)</span></span>
"mcpServers": {}, <span class="hash"># <span data-i18n="cfg.k.mcpsrv">MCP server objects (canonical format)</span></span>
@@ -621,14 +621,16 @@ created: 2026-05-09
<h2 data-i18n="ws.title">Web search</h2>
<p data-i18n="ws.body1">
<code>web_search</code> + <code>web_fetch</code> ship in the box. Default
backend is <strong>Mojeek</strong> (no setup); switch to a self-hosted
<strong>SearXNG</strong> when you want full control over upstream engines, or
use <strong>Metaso</strong> (100 free searches/day, configure your own API key for more).
backend is <strong>Bing</strong> (works from CN without proxy); switch to
<strong>Baidu AI Search</strong> when you want Baidu Cloud Qianfan's official
search API. You can also use self-hosted <strong>SearXNG</strong>,
<strong>Metaso</strong>, or other API engines.
</p>
<pre class="code"><code>/search-engine mojeek
<pre class="code"><code>/search-engine bing
/search-engine searxng <span class="hash"># http://localhost:8080</span>
/search-engine searxng http://192.168.1.5:8888
/search-engine metaso</code></pre>
/search-engine metaso
/search-engine baidu</code></pre>
<p data-i18n="ws.body.json">Equivalent <code>config.json</code>:</p>
<pre class="code"><code>{
"webSearchEngine": "searxng",
@@ -638,6 +640,14 @@ created: 2026-05-09
<pre class="code"><code>{
"webSearchEngine": "metaso",
"metasoApiKey": "mk-..." <span class="hash"># Optional: 100 free searches/day, configure your own API key for more.</span>
}</code></pre>
<p data-i18n="ws.body.baidu">
Or for Baidu AI Search (Baidu's official docs currently list
<strong>1,500 free AI Search calls per month</strong>):
</p>
<pre class="code"><code>{
"webSearchEngine": "baidu",
"baiduApiKey": "bce-v3/..." <span class="hash"># Or set BAIDU_API_KEY / QIANFAN_API_KEY</span>
}</code></pre>
<p data-i18n="ws.body.start">Start a local SearXNG:</p>
<pre class="code"><code>podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng</code></pre>
+8 -4
View File
@@ -40,7 +40,7 @@
"cfg.k.effort": "high | max",
"cfg.k.theme": "light | dark | auto",
"cfg.k.search": "enable web_search/web_fetch tools",
"cfg.k.engine": "mojeek | searxng | metaso",
"cfg.k.engine": "bing | baidu | searxng | metaso | ...",
"cfg.k.mcp": "MCP server specs",
"cfg.k.mcpoff": "names skipped at startup",
"cfg.k.projects": "per-workspace overrides",
@@ -154,10 +154,12 @@
"ws.title": "Web search",
"ws.body1":
"<code>web_search</code> + <code>web_fetch</code> ship in the box. Default backend is <strong>Mojeek</strong> (no setup); switch to a self-hosted <strong>SearXNG</strong> when you want full control over upstream engines, or use <strong>Metaso</strong> (100 free searches/day, configure your own API key for more).",
"<code>web_search</code> + <code>web_fetch</code> ship in the box. Default backend is <strong>Bing</strong> (works from CN without proxy); switch to <strong>Baidu AI Search</strong> when you want Baidu Cloud Qianfan's official search API. You can also use self-hosted <strong>SearXNG</strong>, <strong>Metaso</strong>, or other API engines.",
"ws.body.json": "Equivalent <code>config.json</code>:",
"ws.body.start": "Start a local SearXNG:",
"ws.body.metaso": "Or for Metaso:",
"ws.body.baidu":
"Or for Baidu AI Search (Baidu's official docs currently list <strong>1,500 free AI Search calls per month</strong>):",
"ix.title": "Semantic index",
"ix.body1":
@@ -207,7 +209,7 @@
"cfg.k.effort": "high | max",
"cfg.k.theme": "light | dark | auto",
"cfg.k.search": "启用 web_search / web_fetch 工具",
"cfg.k.engine": "mojeek | searxng | metaso",
"cfg.k.engine": "bing | baidu | searxng | metaso | ...",
"cfg.k.mcp": "MCP 服务器列表",
"cfg.k.mcpoff": "启动时跳过的服务器名",
"cfg.k.projects": "按工作区的覆盖配置",
@@ -327,10 +329,12 @@
"ws.title": "Web 搜索",
"ws.body1":
"<code>web_search</code> + <code>web_fetch</code> 开箱即用。默认走 <strong>Mojeek</strong>(无需配置);想完全控制上游引擎就切到自托管 <strong>SearXNG</strong>,或使用 <strong>Metaso</strong>(每天 100 次免费搜索,配置你自己的 API 密钥可提升限额)。",
"<code>web_search</code> + <code>web_fetch</code> 开箱即用。默认走 <strong>Bing</strong>(国内裸 IP 直连);需要百度智能云千帆官方搜索 API 时可切到 <strong>百度 AI Search</strong>。也可以使用自托管 <strong>SearXNG</strong>、<strong>Metaso</strong> 或其他 API 引擎。",
"ws.body.json": "等价的 <code>config.json</code>:",
"ws.body.start": "本机起一个 SearXNG:",
"ws.body.metaso": "或使用 Metaso:",
"ws.body.baidu":
"或使用百度 AI Search(百度官方文档当前写有 <strong>AI Search 每月 1500 次免费额度</strong>):",
"ix.title": "语义索引",
"ix.body1":
+2 -2
View File
@@ -138,7 +138,7 @@
"Per-workspace shell allowlist. Exact-prefix match. Interactive \"always allow\" persists.",
"conf.ws.title": "Web search",
"conf.ws.body":
"Mojeek by default, no setup. Switch to self-hosted SearXNG or Metaso with <code>/search-engine</code>.",
"Bing by default. Switch to Baidu AI Search, self-hosted SearXNG, Metaso, or other API engines with <code>/search-engine</code>.",
"conf.cta": "Open the configuration guide →",
"cli.title": "CLI at a glance",
@@ -319,7 +319,7 @@
"按工作区的 shell 白名单。精确前缀匹配。交互式“永久允许”会持久化。",
"conf.ws.title": "Web 搜索",
"conf.ws.body":
"默认 Mojeek,零配置。用 <code>/search-engine</code> 切自托管 SearXNG 或 Metaso。",
"默认 Bing。用 <code>/search-engine</code> 切百度 AI Search、自托管 SearXNG、Metaso 或其他 API 引擎。",
"conf.cta": "打开配置指南 →",
"cli.title": "CLI 速览",
+183
View File
@@ -0,0 +1,183 @@
# Telegram channel setup
Reasonix can attach Telegram to an existing `chat` or `code` session as a remote channel. Telegram is not a third runtime mode.
Once connected, Telegram can:
- send normal user messages into the active session
- receive follow-up assistant replies with Telegram Markdown rendering
- continue confirmation, choice, checkpoint, and plan-style follow-up interactions with inline buttons
- expose Reasonix slash commands through the Telegram bot command menu
## Before you start
Prepare these first:
- a recent Reasonix release that already includes Telegram support
- a Telegram account
- a Telegram bot token from BotFather
- the numeric Telegram user id that should be allowed to drive Reasonix
Important:
- Telegram is fail-closed: the channel refuses to start until `telegram.ownerUserId` or `telegram.allowlist` is configured
- keep the bot token private
- only allow Telegram users you trust to run a code-driving bot
## Get your Telegram bot token
The BotFather UI may change, but the flow is usually:
1. Open Telegram and start a chat with `@BotFather`.
2. Send `/newbot`.
3. Follow the prompts for bot name and username.
4. Copy the bot token BotFather returns.
## Get your Telegram user id
Reasonix access control uses Telegram's numeric user id, not the `@username`.
Common ways to get it:
- send a message to a helper bot such as `@userinfobot`
- use Telegram's `getUpdates` API after messaging your bot once
- read it from your own trusted bot tooling
Save this value as `telegram.ownerUserId` or include it in `telegram.allowlist`.
## Connect from the CLI
Start a session first:
~~~bash
reasonix code
# or
reasonix chat
~~~
Configure access control before connecting. For example, edit `~/.reasonix/config.json`:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
Then run:
~~~text
/telegram connect
~~~
First-time behavior:
1. Reasonix asks for the Telegram bot token in the current TUI.
2. Enter `/cancel` to abort.
3. Reasonix saves the token and enables Telegram auto-start after a successful connection.
If a token is already saved, `/telegram connect` reuses it directly.
You can also pass the token inline:
~~~text
/telegram connect <botToken>
~~~
Other Telegram commands:
- `/telegram status`
- `/telegram disconnect`
After the first successful connection, later `chat` and `code` sessions auto-start the Telegram channel while it stays enabled.
## Environment variables
You can also configure Telegram through environment variables:
~~~bash
TELEGRAM_BOT_TOKEN=123456:botfather-token
TELEGRAM_OWNER_USER_ID=123456789
TELEGRAM_ALLOWLIST=123456789,987654321
~~~
Environment values override the saved config for token and access checks. `TELEGRAM_ALLOWLIST` accepts comma- or whitespace-separated ids.
## Typical usage
1. Start `reasonix code` or `reasonix chat`.
2. Configure `telegram.ownerUserId` or `telegram.allowlist`.
3. Complete `/telegram connect` once.
4. Send a message to your Telegram bot.
5. Let the local Reasonix session keep running.
6. Continue replies, approvals, and follow-up interactions from Telegram when needed.
Telegram extends the current session. It does not replace `chat` or `code`.
## Security notes
- Access is fail-closed. With no owner or allowlist, the channel does not start.
- Unauthorized senders and button presses are ignored.
- Confirmation buttons are bound to the current Telegram confirmation message, so stale buttons from older confirmations cannot approve newer requests.
- Authorized users are rate-limited before their messages reach the agent prompt path.
## Troubleshooting
### `/telegram connect` fails with an access control error
Configure at least one allowed Telegram user:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
or:
~~~json
{
"telegram": {
"allowlist": ["123456789", "987654321"]
}
}
~~~
Then reconnect:
~~~text
/telegram connect
~~~
### `/telegram connect` fails with a token error
Check these first:
- the token was copied exactly from BotFather
- there are no extra spaces or shell quoting mistakes
- the bot has not been deleted or regenerated in BotFather
If needed, reconnect with an explicit token:
~~~text
/telegram connect <botToken>
~~~
### Telegram receives the message, but no reply comes back
Check that the local Reasonix session is still running and the channel is still connected:
~~~text
/telegram status
~~~
### Telegram says another poller is active
Only one long-polling process can run for a Telegram bot at a time. Stop the other Reasonix process or any other bot process using the same token, then reconnect.
### `/telegram` commands do not exist in your installed package
Your installed npm version is too old. Upgrade to a release that already includes Telegram support, or use the current repository `main` branch.
+183
View File
@@ -0,0 +1,183 @@
# Telegram 连接指南
Reasonix 可以把现有的 `chat` 或 `code` 会话延伸到 Telegram 上,作为远程通道使用。Telegram 扩展的是当前会话,不是独立的新运行模式。
连接成功后,Telegram 可以:
- 把普通消息送进当前会话
- 接收支持 Telegram Markdown 渲染的助手回复
- 用内联按钮继续确认、选择、checkpoint、plan 这类二次交互
- 把 Reasonix slash 命令注册成 Telegram bot command
## 开始前先准备
请先确认:
- 使用的是已经包含 Telegram 支持的较新 Reasonix 版本
- 已经有 Telegram 账号
- 已经从 BotFather 拿到 Telegram bot token
- 已经知道允许驱动 Reasonix 的 Telegram 数字用户 id
注意:
- Telegram 默认 fail-closed:没有配置 `telegram.ownerUserId` 或 `telegram.allowlist` 时,通道会拒绝启动
- bot token 要妥善保管,不要公开
- 只允许你信任的 Telegram 用户驱动这个代码执行 bot
## 获取 Telegram bot token
BotFather 的界面可能会变化,但通常流程是:
1. 在 Telegram 中打开 `@BotFather`
2. 发送 `/newbot`
3. 按提示填写 bot 名称和 username
4. 复制 BotFather 返回的 bot token
## 获取 Telegram 用户 id
Reasonix 的访问控制使用 Telegram 数字用户 id,不使用 `@username`。
常见获取方式:
- 给 `@userinfobot` 这类辅助 bot 发消息查看
- 给自己的 bot 发一条消息后,通过 Telegram `getUpdates` API 查看
- 从你信任的自有 bot 工具里读取
请把这个 id 保存为 `telegram.ownerUserId`,或加入 `telegram.allowlist`。
## 在 CLI 里连接
先启动一个会话:
~~~bash
reasonix code
# 或
reasonix chat
~~~
连接前先配置访问控制。例如编辑 `~/.reasonix/config.json`:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
然后运行:
~~~text
/telegram connect
~~~
首次连接时会这样引导:
1. 在当前 TUI 里提示你输入 Telegram bot token
2. 输入 `/cancel` 可以取消
3. 连接成功后,Reasonix 会保存 token 并启用后续自动启动
如果本地已经保存过 token,`/telegram connect` 会直接复用。
也可以直接一次性传参:
~~~text
/telegram connect <botToken>
~~~
其他相关命令:
- `/telegram status`
- `/telegram disconnect`
第一次连接成功后,只要 Telegram 保持启用,后续 `chat` 和 `code` 会话都会自动启动 Telegram 通道。
## 环境变量
也可以用环境变量配置 Telegram:
~~~bash
TELEGRAM_BOT_TOKEN=123456:botfather-token
TELEGRAM_OWNER_USER_ID=123456789
TELEGRAM_ALLOWLIST=123456789,987654321
~~~
环境变量会覆盖本地保存的 token 和访问控制配置。`TELEGRAM_ALLOWLIST` 支持用逗号或空白分隔多个 id。
## 典型使用方式
1. 启动 `reasonix code` 或 `reasonix chat`
2. 配置 `telegram.ownerUserId` 或 `telegram.allowlist`
3. 完成一次 `/telegram connect`
4. 从 Telegram 给 bot 发一条消息
5. 本地 Reasonix 会话继续运行
6. 需要时直接在 Telegram 里继续回复、确认或选择
Telegram 只是扩展当前会话,不替代 `chat` 或 `code`。
## 安全说明
- 访问控制默认 fail-closed;没有 owner 或 allowlist 时通道不会启动。
- 未授权用户的消息和按钮点击都会被忽略。
- 确认按钮会绑定到当前 Telegram 确认消息,旧确认里的按钮不能批准新的请求。
- 授权用户的消息在进入 agent prompt 路径前会被限流。
## 排障
### `/telegram connect` 报访问控制错误
至少配置一个允许访问的 Telegram 用户:
~~~json
{
"telegram": {
"ownerUserId": "123456789"
}
}
~~~
或者:
~~~json
{
"telegram": {
"allowlist": ["123456789", "987654321"]
}
}
~~~
然后重新连接:
~~~text
/telegram connect
~~~
### `/telegram connect` 报 token 错误
优先检查:
- token 是否完整复制自 BotFather
- 是否多了空格或 shell 引号问题
- BotFather 里这个 bot 是否已删除或重新生成 token
必要时可以直接显式传参重试:
~~~text
/telegram connect <botToken>
~~~
### Telegram 能收到消息,但没有后续回复
先确认本地 Reasonix 会话还在运行,而且 Telegram 通道仍然在线:
~~~text
/telegram status
~~~
### Telegram 提示已有另一个 poller
同一个 Telegram bot 同一时间只能有一个 long-polling 进程。请停止其他使用同一个 token 的 Reasonix 进程或 bot 进程,然后重新连接。
### 已安装的 npm 版本里没有 `/telegram` 命令
说明本地包版本太旧。请升级到已经包含 Telegram 支持的发行版,或者直接使用仓库最新 `main` 分支。
+557 -1185
View File
File diff suppressed because it is too large. Load diff
+30 -4
View File
@@ -1,6 +1,6 @@
{
"name": "reasonix",
"version": "0.51.0",
"version": "0.52.0",
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
"type": "module",
"bin": {
@@ -46,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",
@@ -76,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.4",
"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": {
@@ -100,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",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dsnix",
"version": "0.50.1",
"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.50.1"
"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;
}
}
+6
View File
@@ -0,0 +1,6 @@
// Thin indirection over the upstream `slice-ansi` package. Keeping the
// dependency behind a local module means we can swap implementations
// (e.g. for a custom width-aware slicer) without touching every call site.
import sliceAnsi from 'slice-ansi';
export default sliceAnsi;
+22
View File
@@ -0,0 +1,22 @@
/** Per-process "last user activity" timestamp. */
let lastInteractionTime = 0;
/** Mark "real user interaction just happened". */
export function updateLastInteractionTime(): void {
lastInteractionTime = Date.now();
}
/** Reset the timestamp. */
export function flushInteractionTime(): void {
lastInteractionTime = 0;
}
/** Mark scroll input as user activity. */
export function markScrollActivity(): void {
lastInteractionTime = Date.now();
}
export function getLastInteractionTime(): number {
return lastInteractionTime;
}
+23
View File
@@ -0,0 +1,23 @@
// Single import boundary for `yoga-layout`. Sibling files re-export through
// here so a future swap (alternative layout backend, version pin, mock for
// tests) only has to touch this file.
import Yoga from 'yoga-layout';
export * from 'yoga-layout';
export default Yoga;
type YogaCounters = {
visited: number;
measured: number;
cacheHits: number;
live: number;
};
// Diagnostic counters surfaced to renderer telemetry. The TypeScript port of
// Yoga we use does not expose internal stats, so these are currently zeroed
// placeholders — kept so call sites (`ink.tsx`, `reconciler.ts`) continue to
// compile and so we have a hook to wire real numbers in later without an API
// change.
export function getYogaCounters(): YogaCounters {
return { visited: 0, measured: 0, cacheHits: 0, live: 0 };
}
+47
View File
@@ -0,0 +1,47 @@
/** `bidi-js` ships without types. */
declare module 'bidi-js' {
type BidiFactory = (
text: string,
explicitDirection?: string,
) => {
paragraphs: Array<{ start: number; end: number; level: number }>;
levels: Uint8Array;
getReorderSegments(start: number, end: number, levels: Uint8Array): number[][];
};
const bidiFactory: () => BidiFactory;
export default bidiFactory;
}
declare module 'react-reconciler' {
interface Reconciler<C, _I, _T, _R, _P> {
updateContainerSync: (
element: unknown,
container: C,
parent: unknown,
callback: unknown,
) => unknown;
flushSyncWork: () => void;
flushSyncFromReconciler: <R>(fn: () => R) => R;
}
}
/** Bun exposes process-level globals that don't exist under Node. */
// biome-ignore lint/suspicious/noExplicitAny: Bun globals tolerate any payload
declare const Bun:
| undefined
| {
version?: string;
argv?: readonly string[];
stringWidth?: (s: string, opts?: unknown) => number;
wrapAnsi?: (s: string, cols: number, opts?: unknown) => string;
};
declare namespace JSX {
interface IntrinsicElements {
'ink-box': Record<string, unknown>;
'ink-text': Record<string, unknown>;
'ink-link': Record<string, unknown>;
'ink-raw-ansi': Record<string, unknown>;
}
}
+110
View File
@@ -0,0 +1,110 @@
// @ts-nocheck
/** Software-side bidirectional reordering for clustered terminal cells. */
import bidiFactory from 'bidi-js';
type ClusteredChar = {
value: string;
width: number;
styleId: number;
hyperlink: string | undefined;
};
let bidiInstance: ReturnType<typeof bidiFactory> | undefined;
let cachedNeedsBidi: boolean | undefined;
// Windows lacks terminal-side bidi entirely. Windows Terminal hosting WSL
// still routes output through conhost, so WSL processes inherit the same
// gap — we detect that via WT_SESSION rather than process.platform. VS Code's
// xterm.js doesn't implement bidi either and is reachable on any OS.
function needsSoftwareBidi(): boolean {
if (cachedNeedsBidi === undefined) {
cachedNeedsBidi =
process.platform === 'win32' ||
typeof process.env['WT_SESSION'] === 'string' ||
process.env['TERM_PROGRAM'] === 'vscode';
}
return cachedNeedsBidi;
}
function getBidi() {
if (!bidiInstance) {
bidiInstance = bidiFactory();
}
return bidiInstance;
}
export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] {
if (!needsSoftwareBidi() || characters.length === 0) {
return characters;
}
const plainText = characters.map(c => c.value).join('');
// Probing for RTL code points first lets us skip the bidi-js call entirely
// on the dominant LTR-only path — paragraph-level scan with one regex test.
if (!hasRtlCharacters(plainText)) {
return characters;
}
const bidi = getBidi();
const { levels } = bidi.getEmbeddingLevels(plainText, 'auto');
// bidi-js returns one level per UTF-16 code unit. Walk the cluster array
// in parallel, taking the level of the first code unit of each cluster as
// the cluster's level — within a cluster all units share a level.
const charLevels: number[] = [];
let offset = 0;
for (let i = 0; i < characters.length; i++) {
charLevels.push(levels[offset]!);
offset += characters[i]!.value.length;
}
// Classic UBA reorder: from the deepest embedding level down to 1, reverse
// each maximal run of clusters whose level is >= the current level. The
// result is identical to bidi-js's reorderSegments output but expressed
// over cluster indices so we don't have to map back from string offsets.
const reordered = [...characters];
const maxLevel = Math.max(...charLevels);
for (let level = maxLevel; level >= 1; level--) {
let i = 0;
while (i < reordered.length) {
if (charLevels[i]! >= level) {
let j = i + 1;
while (j < reordered.length && charLevels[j]! >= level) {
j++;
}
reverseRange(reordered, i, j - 1);
reverseRange(charLevels, i, j - 1);
i = j;
} else {
i++;
}
}
}
return reordered;
}
function reverseRange<T>(arr: T[], start: number, end: number): void {
while (start < end) {
const tmp = arr[start]!;
arr[start] = arr[end]!;
arr[end] = tmp;
start++;
end--;
}
}
// Conservative pre-check for any code point that could carry an RTL bidi
// class. We err on the side of false positives (running the full algorithm
// on a string that turns out to be neutral is harmless) and avoid false
// negatives that would leave RTL text visibly reversed.
//
// Covers: Hebrew (incl. presentation forms), Arabic (incl. supplement,
// extended-A, presentation forms A/B), Thaana, and Syriac.
function hasRtlCharacters(text: string): boolean {
return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test(
text,
);
}
+53
View File
@@ -0,0 +1,53 @@
import {
CURSOR_HOME,
csi,
ERASE_SCREEN,
ERASE_SCROLLBACK,
} from './termio/csi.js';
const CURSOR_HOME_WINDOWS = csi(0, 'f');
function isWindowsTerminal(): boolean {
return process.platform === 'win32' && !!process.env.WT_SESSION;
}
function isMintty(): boolean {
if (process.env.TERM_PROGRAM === 'mintty') return true;
// GitBash, MSYS2, MINGW all use mintty under the hood and set $MSYSTEM.
if (process.platform === 'win32' && process.env.MSYSTEM) return true;
return false;
}
function isModernWindowsTerminal(): boolean {
if (isWindowsTerminal()) return true;
// VS Code's integrated terminal on Windows runs over ConPTY, which
// forwards ED3 correctly. The TERM_PROGRAM_VERSION check rules out
// ancient builds where ConPTY didn't ship.
if (
process.platform === 'win32' &&
process.env.TERM_PROGRAM === 'vscode' &&
process.env.TERM_PROGRAM_VERSION
) {
return true;
}
if (isMintty()) return true;
return false;
}
export function getClearTerminalSequence(): string {
if (process.platform === 'win32') {
if (isModernWindowsTerminal()) {
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME;
}
// Legacy Win32 console: no ED3, no DEC private modes; fall back to
// an HVP cursor-home so output doesn't leak escape literals.
return ERASE_SCREEN + CURSOR_HOME_WINDOWS;
}
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME;
}
export const clearTerminal = getClearTerminalSequence();
+129
View File
@@ -0,0 +1,129 @@
import chalk, { type ChalkInstance } from 'chalk';
import type { Color, TextStyles } from './styles.js';
/** Adjust chalk's color level for environments it can't auto-detect. */
const VSCODE_PROGRAM = 'vscode';
const TMUX_TRUECOLOR_OPT_OUT = 'REASONIX_TMUX_TRUECOLOR';
function liftLevelForXtermJs(): boolean {
if (process.env.TERM_PROGRAM === VSCODE_PROGRAM && chalk.level === 2) {
chalk.level = 3;
return true;
}
return false;
}
function clampLevelForTmux(): boolean {
if (process.env[TMUX_TRUECOLOR_OPT_OUT]) return false;
if (process.env.TMUX && chalk.level > 2) {
chalk.level = 2;
return true;
}
return false;
}
export const CHALK_BOOSTED_FOR_XTERMJS = liftLevelForXtermJs();
export const CHALK_CLAMPED_FOR_TMUX = clampLevelForTmux();
export type ColorType = 'foreground' | 'background';
const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/;
const ANSI256_REGEX = /^ansi256\(\s?(\d+)\s?\)$/;
const ANSI_PREFIX = 'ansi:';
/** Map of ANSI palette names → `[foreground, background]` chalk applicators. */
const ANSI_PALETTE: Record<string, readonly [ChalkInstance, ChalkInstance]> = {
black: [chalk.black, chalk.bgBlack],
red: [chalk.red, chalk.bgRed],
green: [chalk.green, chalk.bgGreen],
yellow: [chalk.yellow, chalk.bgYellow],
blue: [chalk.blue, chalk.bgBlue],
magenta: [chalk.magenta, chalk.bgMagenta],
cyan: [chalk.cyan, chalk.bgCyan],
white: [chalk.white, chalk.bgWhite],
blackBright: [chalk.blackBright, chalk.bgBlackBright],
redBright: [chalk.redBright, chalk.bgRedBright],
greenBright: [chalk.greenBright, chalk.bgGreenBright],
yellowBright: [chalk.yellowBright, chalk.bgYellowBright],
blueBright: [chalk.blueBright, chalk.bgBlueBright],
magentaBright: [chalk.magentaBright, chalk.bgMagentaBright],
cyanBright: [chalk.cyanBright, chalk.bgCyanBright],
whiteBright: [chalk.whiteBright, chalk.bgWhiteBright],
};
function applyPaletteColor(text: string, name: string, type: ColorType): string {
const entry = ANSI_PALETTE[name];
if (!entry) return text;
return entry[type === 'foreground' ? 0 : 1](text);
}
function applyAnsi256(text: string, raw: string, type: ColorType): string {
const match = ANSI256_REGEX.exec(raw);
if (!match) return text;
const index = Number(match[1]);
return type === 'foreground' ? chalk.ansi256(index)(text) : chalk.bgAnsi256(index)(text);
}
function applyRgb(text: string, raw: string, type: ColorType): string {
const match = RGB_REGEX.exec(raw);
if (!match) return text;
const r = Number(match[1]);
const g = Number(match[2]);
const b = Number(match[3]);
return type === 'foreground' ? chalk.rgb(r, g, b)(text) : chalk.bgRgb(r, g, b)(text);
}
/** Wrap `text` in the SGR escapes for `color`. */
export const colorize = (
text: string,
color: string | undefined,
type: ColorType,
): string => {
if (!color) return text;
if (color.startsWith(ANSI_PREFIX)) {
return applyPaletteColor(text, color.slice(ANSI_PREFIX.length), type);
}
if (color.startsWith('#')) {
return type === 'foreground' ? chalk.hex(color)(text) : chalk.bgHex(color)(text);
}
if (color.startsWith('ansi256')) {
return applyAnsi256(text, color, type);
}
if (color.startsWith('rgb')) {
return applyRgb(text, color, type);
}
return text;
};
/** Apply a full `TextStyles` block to a string. */
export function applyTextStyles(text: string, styles: TextStyles): string {
let out = text;
if (styles.inverse) out = chalk.inverse(out);
if (styles.strikethrough) out = chalk.strikethrough(out);
if (styles.underline) out = chalk.underline(out);
if (styles.italic) out = chalk.italic(out);
if (styles.bold) out = chalk.bold(out);
if (styles.dim) out = chalk.dim(out);
if (styles.color) {
out = colorize(out, styles.color, 'foreground');
}
if (styles.backgroundColor) {
out = colorize(out, styles.backgroundColor, 'background');
}
return out;
}
/** Convenience wrapper that applies a foreground colour only. */
export function applyColor(text: string, color: Color | undefined): string {
if (!color) return text;
return colorize(text, color, 'foreground');
}
@@ -0,0 +1,49 @@
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react';
import instances from '../instances.js';
import {
DISABLE_MOUSE_TRACKING,
ENABLE_MOUSE_TRACKING,
ENTER_ALT_SCREEN,
EXIT_ALT_SCREEN,
} from '../termio/dec.js';
import { TerminalWriteContext } from '../useTerminalNotification.js';
import Box from './Box.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
/** CSI sequence that clears the alt-screen and parks the cursor at home. */
const CLEAR_AND_HOME = '\x1B[2J\x1B[H';
const FALLBACK_ROWS = 24;
type Props = PropsWithChildren<{
mouseTracking?: boolean;
}>;
export function AlternateScreen({ children, mouseTracking = true }: Props) {
const size = useContext(TerminalSizeContext);
const writeRaw = useContext(TerminalWriteContext);
useInsertionEffect(() => {
const ink = instances.get(process.stdout);
if (!writeRaw) return;
writeRaw(
ENTER_ALT_SCREEN + CLEAR_AND_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
);
ink?.setAltScreenActive(true, mouseTracking);
return () => {
ink?.setAltScreenActive(false);
ink?.clearTextSelection();
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN);
};
}, [writeRaw, mouseTracking]);
const rows = size?.rows ?? FALLBACK_ROWS;
return (
<Box flexDirection="column" height={rows} width="100%" flexShrink={0}>
{children}
</Box>
);
}
+713
View File
@@ -0,0 +1,713 @@
import React, { PureComponent, type ReactNode } from 'react';
import { logForDebugging } from '../_internal/debug.js';
import { stopCapturingEarlyInput } from '../_internal/earlyInput.js';
import { isEnvTruthy } from '../_internal/envUtils.js';
import { isMouseClicksDisabled } from '../_internal/fullscreen.js';
import { logError } from '../_internal/log.js';
import { updateLastInteractionTime } from '../_internal/state.js';
import { EventEmitter } from '../events/emitter.js';
import { InputEvent } from '../events/input-event.js';
import { TerminalFocusEvent } from '../events/terminal-focus-event.js';
import { INITIAL_STATE, type ParsedInput, type ParsedKey, type ParsedMouse, parseMultipleKeypresses } from '../parse-keypress.js';
import reconciler from '../reconciler.js';
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js';
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js';
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js';
import { TerminalQuerier, xtversion } from '../terminal-querier.js';
import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT } from '../termio/csi.js';
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js';
import AppContext from './AppContext.js';
import { ClockProvider } from './ClockContext.js';
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
import ErrorOverview from './ErrorOverview.js';
import StdinContext from './StdinContext.js';
import { TerminalFocusProvider } from './TerminalFocusContext.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
// Windows doesn't have SIGSTOP/SIGCONT semantics, so the Ctrl+Z suspend
// path is a no-op there. Cached at module load — the platform isn't
// going to change at runtime.
const SUPPORTS_SUSPEND = process.platform !== 'win32';
// If stdin goes silent for this long and then receives a chunk, treat
// the next chunk as a resume signal and re-assert terminal modes (mouse
// tracking, etc.). Real reattach scenarios — tmux detach/attach, SSH
// reconnect, laptop wake — reset the terminal's DEC private modes
// without sending us any signal we could hook into directly. Five
// seconds is comfortably longer than any normal inter-keystroke gap but
// short enough that the first scroll after reattach still works.
const STDIN_RESUME_GAP_MS = 5000;
// Short flush timeout for a stranded ESC-prefixed sequence (cursor
// keys, etc.). 50ms is enough to receive the rest of an SS3/CSI tail
// across most terminals; long enough that a deliberate single ESC keypress
// resolves to "Escape" instead of being treated as the start of a sequence.
const NORMAL_FLUSH_TIMEOUT_MS = 50;
// Pastes can interleave with our processing if the terminal sends them in
// chunks larger than the readable buffer. Hold incomplete paste content
// longer so we don't split it across two parse runs.
const PASTE_FLUSH_TIMEOUT_MS = 500;
// macOS double/triple-click default: a follow-up click within this many
// ms and this many cells of the previous click counts as a continuation
// (word-select on 2, line-select on 3). The position tolerance allows
// for trackpad jitter between consecutive taps.
const MULTI_CLICK_TIMEOUT_MS = 500;
const MULTI_CLICK_DISTANCE = 1;
// Bits in the SGR mouse button code we need to inspect by name. Names
// match what the terminal protocol calls them.
const MOUSE_BUTTON_BASE_MASK = 0x03;
const MOUSE_BUTTON_MOTION_BIT = 0x20;
const MOUSE_BUTTON_ALT_BIT = 0x08;
const MOUSE_BUTTON_BASE_RELEASE_LEGACY = 3; // "no button" from X10 encoding
type Props = {
readonly children: ReactNode;
readonly stdin: NodeJS.ReadStream;
readonly stdout: NodeJS.WriteStream;
readonly stderr: NodeJS.WriteStream;
readonly exitOnCtrlC: boolean;
readonly onExit: (error?: Error) => void;
readonly terminalColumns: number;
readonly terminalRows: number;
/** Shared selection state. */
readonly selection: SelectionState;
readonly onSelectionChange: () => void;
/** Dispatch a click at `(col, row)`. */
readonly onClickAt: (col: number, row: number) => boolean;
readonly onHoverAt: (col: number, row: number) => void;
readonly getHyperlinkAt: (col: number, row: number) => string | undefined;
/** Open a hyperlink URL in the browser. Called when the defer fires. */
readonly onOpenHyperlink: (url: string) => void;
/** Fired on the press half of a double/triple click at `(col, row)`. */
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void;
/** Fired on drag-motion. */
readonly onSelectionDrag: (col: number, row: number) => void;
readonly onStdinResume?: () => void;
readonly onCursorDeclaration?: CursorDeclarationSetter;
/** Dispatch a parsed keyboard event through the DOM tree. */
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void;
};
type State = {
readonly error?: Error;
};
/** Root component for every rendered tree. */
export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp';
static getDerivedStateFromError(error: Error) {
return {
error,
};
}
override state = {
error: undefined,
};
// Reference count for raw-mode enablement. Components that need raw
// keyboard input call `handleSetRawMode(true)` independently; we only
// disable raw mode once every consumer has released.
rawModeEnabledCount = 0;
internal_eventEmitter = new EventEmitter();
keyParseState = INITIAL_STATE;
// Timer for flushing incomplete escape sequences. Always either null
// or a live timer reference — `flushIncomplete` clears it itself.
incompleteEscapeTimer: NodeJS.Timeout | null = null;
// Terminal query/response dispatcher. Responses (DECRPM, DA1, OSC
// replies, ...) arrive on stdin and `parse-keypress` separates them
// from real keys; this querier routes them to pending resolvers.
querier = new TerminalQuerier(this.props.stdout);
// Multi-click tracking for double/triple-click text selection. A
// click within `MULTI_CLICK_TIMEOUT_MS` and `MULTI_CLICK_DISTANCE`
// of the previous one increments `clickCount`; otherwise it resets
// to 1 (the new chain starts).
lastClickTime = 0;
lastClickCol = -1;
lastClickRow = -1;
clickCount = 0;
// Pending hyperlink open. Cancelled if a second click arrives within
// `MULTI_CLICK_TIMEOUT_MS` so double-clicking an OSC 8 link selects
// the word without also opening the browser. DOM `onClick` dispatch
// does NOT go through this timer — `onClickAt` returning `true`
// short-circuits before the link path runs.
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null;
// Last mode-1003 motion position. Terminals dedupe at cell granularity
// already, but tracking it ourselves lets us skip `dispatchHover`
// entirely on duplicate events (drag-then-release at the same cell,
// for example).
lastHoverCol = -1;
lastHoverRow = -1;
// Timestamp of the last stdin chunk. Used by the resume-gap check to
// detect tmux attach / SSH reconnect / laptop wake. Initialised to
// `now` so the first chunk after startup doesn't false-trigger.
lastStdinTime = Date.now();
// True iff the configured stdin is a TTY (and therefore supports
// entering raw mode).
isRawModeSupported(): boolean {
return this.props.stdin.isTTY;
}
// Memoize terminal size context value to prevent unnecessary re-renders
// in consumers (useTerminalViewport). Without this, a new object is
// created on every render, defeating dependency-array checks.
private _lastTerminalColumns = 0;
private _lastTerminalRows = 0;
private _terminalSizeValue: { columns: number; rows: number } = { columns: 0, rows: 0 };
override render() {
if (this.props.terminalColumns !== this._lastTerminalColumns ||
this.props.terminalRows !== this._lastTerminalRows) {
this._lastTerminalColumns = this.props.terminalColumns;
this._lastTerminalRows = this.props.terminalRows;
this._terminalSizeValue = {
columns: this.props.terminalColumns,
rows: this.props.terminalRows,
};
}
return <TerminalSizeContext.Provider value={this._terminalSizeValue}>
<AppContext.Provider value={{
exit: this.handleExit
}}>
<StdinContext.Provider value={{
stdin: this.props.stdin,
setRawMode: this.handleSetRawMode,
isRawModeSupported: this.isRawModeSupported(),
internal_exitOnCtrlC: this.props.exitOnCtrlC,
internal_eventEmitter: this.internal_eventEmitter,
internal_querier: this.querier
}}>
<TerminalFocusProvider>
<ClockProvider>
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
</CursorDeclarationContext.Provider>
</ClockProvider>
</TerminalFocusProvider>
</StdinContext.Provider>
</AppContext.Provider>
</TerminalSizeContext.Provider>;
}
override componentDidMount() {
// Hide the native cursor while the app is running. In accessibility
// mode the cursor stays visible because screen magnifiers and similar
// assistive tools track its position to follow focus.
if (this.props.stdout.isTTY && !isEnvTruthy(process.env.REASONIX_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
}
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR);
}
// Drop any pending timers so they don't fire into a torn-down tree.
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
this.incompleteEscapeTimer = null;
}
if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer);
this.pendingHyperlinkTimer = null;
}
// Skip `setRawMode` on non-TTY stdin; calling it would throw the
// same error the prop-time guard reports, and unmount paths are not
// a useful place to surface that.
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
}
}
override componentDidCatch(error: Error) {
this.handleExit(error);
}
handleSetRawMode = (isEnabled: boolean): void => {
const {
stdin
} = this.props;
if (!this.isRawModeSupported()) {
if (stdin === process.stdin) {
throw new Error('Raw mode is not supported on the default process.stdin used as the input stream.\nThis usually means the process was launched without a TTY (e.g. piped input, CI runner). Pass an explicit `stdin` option whose `isTTY` is true, or render with `{ stdin: process.stdin }` only inside an interactive terminal.');
} else {
throw new Error('Raw mode is not supported on the provided `stdin` stream.\nThe stream must be a TTY (its `isTTY` property must be true). Streams from pipes, files, or non-interactive shells cannot enter raw mode; supply a TTY-backed stream or skip the components that depend on raw keyboard input.');
}
}
stdin.setEncoding('utf8');
if (isEnabled) {
// Only do the heavy setup once, on the first enable.
if (this.rawModeEnabledCount === 0) {
// Stop early-input capture before installing our own readable
// listener. Both use the same `'readable' + read()` pattern, so
// they cannot coexist on the same stream — whichever drains
// stdin first wins, and the loser sees no events. Any buffered
// text the early capture collected is preserved separately and
// can be consumed via `consumeEarlyInput()`.
stopCapturingEarlyInput();
stdin.ref();
stdin.setRawMode(true);
stdin.addListener('readable', this.handleReadable);
// Bracketed paste mode: pastes arrive wrapped in
// `ESC [ 200 ~` / `ESC [ 201 ~` so the parser can tell them
// apart from typed input.
this.props.stdout.write(EBP);
// DECSET 1004 — terminal focus reporting.
this.props.stdout.write(EFE);
// Extended key reporting, so ctrl+shift+<letter> is
// distinguishable from plain ctrl+<letter>. We push the kitty
// keyboard stack (CSI >1u) AND set xterm modifyOtherKeys level
// 2 (CSI >4;2m); terminals respect whichever they implement
// (tmux honours only the latter), and the unused one is a
// harmless no-op on the rest.
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
}
// Probe terminal identity via XTVERSION. Unlike `TERM_PROGRAM`
// this query/reply travels through the pty, which means it
// survives SSH and tmux. The reply (when we get one) is used
// for wheel-scroll base detection on hosts where env vars
// aren't reliable. Fire-and-forget: the querier's DA1 sentinel
// bounds the round-trip, so a silent terminal just leaves
// `name` undefined and we move on.
//
// Deferred to the next macrotask so it doesn't interleave with
// alt-screen / mouse-tracking writes that may happen later in
// the same render cycle.
setImmediate(() => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
if (r) {
setXtversionName(r.name);
logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
} else {
logForDebugging('XTVERSION: no reply (terminal ignored query)');
}
});
});
}
this.rawModeEnabledCount++;
return;
}
// Disable for real only when every consumer has released.
if (--this.rawModeEnabledCount === 0) {
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
// DECRST 1004 — leave terminal focus reporting off.
this.props.stdout.write(DFE);
// Disable bracketed paste mode.
this.props.stdout.write(DBP);
stdin.setRawMode(false);
stdin.removeListener('readable', this.handleReadable);
stdin.unref();
}
};
/** Flush whatever incomplete escape sequence the parser is holding. */
flushIncomplete = (): void => {
this.incompleteEscapeTimer = null;
if (!this.keyParseState.incomplete) return;
if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, NORMAL_FLUSH_TIMEOUT_MS);
return;
}
// Run the parser with `input=null` to signal "no more bytes are
// coming, finalise whatever you have". Reuses all the regular parse
// dispatch code below.
this.processInput(null);
};
processInput = (input: string | Buffer | null): void => {
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input);
this.keyParseState = newState;
if (keys.length > 0) {
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined);
}
// Re-arm the flush timer if the parser is still holding a partial
// sequence. Cancel an existing timer first so the timeout reflects
// the most recent input (especially important when we transition
// into paste mode and need the longer timeout).
if (this.keyParseState.incomplete) {
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer);
}
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.keyParseState.mode === 'IN_PASTE' ? PASTE_FLUSH_TIMEOUT_MS : NORMAL_FLUSH_TIMEOUT_MS,
);
}
};
handleReadable = (): void => {
// Detect long stdin gaps (tmux attach, SSH reconnect, laptop wake).
// The terminal probably reset its DEC private modes during the gap,
// so we ask the host to re-assert mouse tracking and friends. One
// `Date.now()` per readable event covers every chunk we drain in
// this turn.
const now = Date.now();
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.();
}
this.lastStdinTime = now;
try {
let chunk;
while ((chunk = this.props.stdin.read() as string | null) !== null) {
this.processInput(chunk);
}
} catch (error) {
// Bun's behaviour on an uncaught throw inside a stream 'readable'
// handler is to wedge the stream permanently — data stays
// buffered and 'readable' never re-fires. Logging + recovering
// here keeps the input pipeline healthy so subsequent keystrokes
// are still delivered.
logError(error);
// Re-attach the listener if Bun detached it as part of the
// exception path. Without this the session is alive but the
// stdin reader is silently dead — every key vanishes into the
// void until the user kills the process.
const { stdin } = this.props;
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) {
logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', {
level: 'warn',
});
stdin.addListener('readable', this.handleReadable);
}
}
};
handleInput = (input: string | undefined): void => {
// Ctrl+C → exit. The Ctrl+Z (suspend) path used to live here too,
// but it now runs in `processKeysInBatch` against the parsed key so
// it works under both legacy (`\x1a`) and Kitty CSI-u (`\x1b[122;5u`)
// encodings — Ghostty, iTerm2, kitty, and WezTerm all emit the latter.
if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit();
}
};
handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) {
this.handleSetRawMode(false);
}
this.props.onExit(error);
};
handleTerminalFocus = (isFocused: boolean): void => {
// The focus store fans out to `TerminalFocusProvider` (context) and
// to the clock (interval speed), so we don't need to setState here
// — App.tsx is intentionally insulated from focus churn.
setTerminalFocused(isFocused);
};
handleSuspend = (): void => {
if (!this.isRawModeSupported()) {
return;
}
// Snapshot the raw-mode reference count so we can restore the
// exact same depth on resume — components that were holding raw
// mode at suspend time still expect it to be on when we come back.
const rawModeCountBeforeSuspend = this.rawModeEnabledCount;
// Fully unwind raw mode so the parent shell sees a normal cooked
// terminal while we're stopped.
while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false);
}
// Restore cursor visibility, turn off focus reporting, and disable
// mouse tracking before stopping. `DISABLE_MOUSE_TRACKING` is a
// no-op if tracking was never enabled, but emitting it
// unconditionally is necessary: otherwise leftover SGR mouse
// sequences would arrive at the shell prompt and render as
// garbled text until the user runs `reset`.
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING);
}
// Notify subscribers so app-level housekeeping (saving state,
// suspending background work) can run before we actually stop.
this.internal_eventEmitter.emit('suspend');
const resumeHandler = () => {
// Restore raw mode to the depth it was at when we suspended.
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
if (this.isRawModeSupported()) {
this.handleSetRawMode(true);
}
}
// Hide the cursor again (unless in accessibility mode) and
// re-enable focus reporting so the terminal is back in the state
// the app expected before suspension.
if (this.props.stdout.isTTY) {
if (!isEnvTruthy(process.env.REASONIX_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
}
this.props.stdout.write(EFE);
}
this.internal_eventEmitter.emit('resume');
process.removeListener('SIGCONT', resumeHandler);
};
process.on('SIGCONT', resumeHandler);
process.kill(process.pid, 'SIGSTOP');
};
}
function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void {
// Refresh the "last interaction" timestamp used by notification
// dampening and other idle-gated background work. Only real
// engagement counts: keys + mouse clicks/drags, but the passive
// no-button motion that mode-1003 emits when the user just brushes
// the cursor through the window does not — counting it would
// suppress idle notifications and defer housekeeping forever.
// Terminal responses (XTVERSION etc.) are automated and excluded.
if (items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !isPassiveHover(i.button)))) {
updateLastInteractionTime();
}
for (const item of items) {
// Terminal responses (DECRPM, DA1, OSC replies, ...) are not user
// input — resolve any pending promise on the querier and stop.
if (item.kind === 'response') {
app.querier.onResponse(item.response);
continue;
}
// Mouse click/drag/motion. The terminal protocol sends 1-indexed
// columns and rows; the screen buffer uses 0-indexed, so the
// handler does the conversion.
if (item.kind === 'mouse') {
handleMouseEvent(app, item);
continue;
}
const sequence = item.sequence;
// DECSET 1004 terminal focus events.
if (sequence === FOCUS_IN) {
app.handleTerminalFocus(true);
const event = new TerminalFocusEvent('terminalfocus');
app.internal_eventEmitter.emit('terminalfocus', event);
continue;
}
if (sequence === FOCUS_OUT) {
app.handleTerminalFocus(false);
// Defensive selection close: if we never saw the mouse release
// (e.g. iTerm2 doesn't capture the pointer past the window
// bounds, so the release SGR never arrives), the focus-out event
// is our next observable signal that the drag is over. Without
// this, `isDragging` stays true forever and drag-to-scroll's
// timer keeps firing.
if (app.props.selection.isDragging) {
finishSelection(app.props.selection);
app.props.onSelectionChange();
}
const event = new TerminalFocusEvent('terminalblur');
app.internal_eventEmitter.emit('terminalblur', event);
continue;
}
// Receiving real input implies the terminal window is focused.
// Some emulators (notably tmux without `focus-events on`) never
// emit focus-in, so this is the only signal we'll get that they
// came back. Without it the clock stays at the blurred-tick rate
// and animations look choppy.
if (!getTerminalFocused()) {
setTerminalFocused(true);
}
// Ctrl+Z (suspend). Reading the parsed key here, rather than
// matching `\x1a` on the raw byte, handles both legacy and Kitty
// CSI-u (`\x1b[122;5u`) encodings.
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
app.handleSuspend();
continue;
}
app.handleInput(sequence);
const event = new InputEvent(item);
app.internal_eventEmitter.emit('input', event);
// Mirror the input through the DOM tree so `onKeyDown` handlers on
// focused Boxes can react.
app.props.dispatchKeyboardEvent(item);
}
}
function isPassiveHover(button: number): boolean {
return (button & MOUSE_BUTTON_MOTION_BIT) !== 0
&& (button & MOUSE_BUTTON_BASE_MASK) === MOUSE_BUTTON_BASE_RELEASE_LEGACY;
}
export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling without disabling wheel scroll.
// Wheel events arrive through the keybinding system as
// 'wheelup'/'wheeldown', not here, so this gate doesn't affect them.
if (isMouseClicksDisabled()) return;
const sel = app.props.selection;
// Terminal protocol is 1-indexed; the screen buffer is 0-indexed.
const col = m.col - 1;
const row = m.row - 1;
const baseButton = m.button & MOUSE_BUTTON_BASE_MASK;
if (m.action === 'press') {
if ((m.button & MOUSE_BUTTON_MOTION_BIT) !== 0 && baseButton === MOUSE_BUTTON_BASE_RELEASE_LEGACY) {
// Mode-1003 motion with no button held: dispatch hover and stop.
// No selection logic, no click-count side effects.
//
// Lost-release recovery: if the user is "dragging" but we see
// no-button motion, the release happened outside the terminal
// window (iTerm2 doesn't capture the pointer past window
// bounds, so its SGR 'm' release never arrives). Finish the
// selection here so copy-on-select still fires. The FOCUS_OUT
// path handles "switched apps", but not "released past the edge,
// came back" — and tmux without `focus-events on` doesn't even
// emit focus changes, so this is the more reliable signal.
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
}
if (col === app.lastHoverCol && row === app.lastHoverRow) return;
app.lastHoverCol = col;
app.lastHoverRow = row;
app.props.onHoverAt(col, row);
return;
}
if (baseButton !== 0) {
// Non-left press breaks the multi-click chain.
app.clickCount = 0;
return;
}
if ((m.button & MOUSE_BUTTON_MOTION_BIT) !== 0) {
// Drag motion: mode-aware extension (char / word / line).
// `onSelectionDrag` calls `notifySelectionChange` internally so
// we don't double-up by calling `onSelectionChange` here.
app.props.onSelectionDrag(col, row);
return;
}
// Lost-release fallback for mode-1002-only terminals (no motion
// reports when no button is held). Seeing a fresh press while
// `isDragging=true` means the previous release was dropped (cursor
// left the window). Finish that selection so copy-on-select fires
// before `startSelection` / `onMultiClick` clobbers the anchor.
// Mode-1003 terminals hit the no-button-motion recovery above
// instead, so this branch only fires on the older protocol.
if (sel.isDragging) {
finishSelection(sel);
app.props.onSelectionChange();
}
// Fresh left press. Detect multi-click here, on press, not on
// release. Two reasons:
//
// 1. The word/line highlight needs to appear immediately so the
// feedback feels native.
// 2. Detecting on release used to let double-click+drag fall
// through to char-mode selection, because by the time the
// second release came in we were already mid-drag.
const now = Date.now();
const nearLast = now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS
&& Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE
&& Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE;
app.clickCount = nearLast ? app.clickCount + 1 : 1;
app.lastClickTime = now;
app.lastClickCol = col;
app.lastClickRow = row;
if (app.clickCount >= 2) {
// Cancel any pending hyperlink-open from the prior click — this
// is a double-click, not a single-click on a link.
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
app.pendingHyperlinkTimer = null;
}
// Cap at 3 (line select) for quadruple-and-up clicks.
const count = app.clickCount === 2 ? 2 : 3;
app.props.onMultiClick(col, row, count);
return;
}
startSelection(sel, col, row);
// SGR bit 0x08 carries the alt-key state. (xterm.js wires its
// `altKey` field here, NOT `metaKey` — see the hyperlink guard
// below for the consequences.) On macOS xterm.js, receiving alt
// also implies `macOptionClickForcesSelection=OFF`; otherwise
// xterm.js would have consumed the event for native selection
// before forwarding it.
sel.lastPressHadAlt = (m.button & MOUSE_BUTTON_ALT_BIT) !== 0;
app.props.onSelectionChange();
return;
}
// Release path. End any drag even when the base button is non-zero:
// some terminals encode the release with the motion bit set, or with
// `button=3` "no button" (left over from the pre-SGR X10 encoding).
// Filtering those out would leave `isDragging=true` permanently and
// let drag-to-scroll's timer keep firing until the scroll boundary.
// For non-left releases we still only act when we ARE dragging, so an
// unrelated middle/right release doesn't touch the selection.
if (baseButton !== 0) {
if (!sel.isDragging) return;
finishSelection(sel);
app.props.onSelectionChange();
return;
}
finishSelection(sel);
// Note: we deliberately do NOT reset `clickCount` on a release that
// followed a drag. This matches NSEvent.clickCount semantics — an
// intervening drag doesn't break the click chain — and the practical
// payoff is that trackpad jitter during an intended double-click
// (press → wobble → release → press) resolves to word-select instead
// of degrading to a fresh single click. The `nearLast` window
// (`MULTI_CLICK_TIMEOUT_MS` / `MULTI_CLICK_DISTANCE`) keeps the
// effect bounded; a deliberate long drag past that window just
// starts a fresh chain.
//
// Click semantics by mode:
// - char mode: press sets anchor, focus stays null until drag, so
// a press+release with no drag is `hasSelection=false` → click.
// - word/line mode: the press already set both anchor and focus
// (`hasSelection=true`), so release just keeps the highlight.
//
// The `sel.anchor` guard handles orphaned releases — e.g. the button
// was already held when mouse tracking was enabled, so we get a
// release with no prior press recorded.
if (!hasSelection(sel) && sel.anchor) {
// Single click: dispatch DOM `onClick` immediately. Cursor
// repositioning and similar handlers are latency-sensitive and
// shouldn't be deferred. If no DOM handler consumed the click, we
// fall through to hyperlink handling, which IS deferred so a
// second click can cancel it.
if (!app.props.onClickAt(col, row)) {
// Resolve the URL synchronously while the screen buffer still
// shows what the user clicked. Only the browser-open itself is
// deferred — that's what double-click can cancel.
const url = app.props.getHyperlinkAt(col, row);
// xterm.js (VS Code, Cursor, Windsurf, ...) ships its own OSC 8
// handler that fires on Cmd+click *without consuming the mouse
// event*: Linkifier._handleMouseUp calls link.activate() but
// never preventDefault/stopPropagation, so the click is still
// forwarded to the pty as SGR. If we opened the URL too, both
// VS Code's terminalLinkManager AND we would open it — twice.
// We can't filter by Cmd from this end: xterm.js drops metaKey
// before SGR encoding (its ICoreMouseEvent has no meta field;
// the SGR bit we call "meta" is wired to alt). The least-bad
// option is to defer entirely to xterm.js when we know we're
// inside it; Cmd+click is the native UX there anyway.
//
// TERM_PROGRAM='vscode' is the synchronous fast-path; the
// `isXtermJs()` fallback uses the XTVERSION probe result, which
// catches SSH sessions and non-VS Code embedders like Hyper.
if (url && process.env.TERM_PROGRAM !== 'vscode' && !isXtermJs()) {
// Clicking a second link supersedes the first — only the
// most recent click opens.
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer);
}
app.pendingHyperlinkTimer = setTimeout((app, url) => {
app.pendingHyperlinkTimer = null;
app.props.onOpenHyperlink(url);
}, MULTI_CLICK_TIMEOUT_MS, app, url);
}
}
}
app.props.onSelectionChange();
}
Loaded 100 of 497 files, more files were not shown because too many files have changed in this diff. Show more