mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b1bf4a50 | ||
|
|
8907573f09 | ||
|
|
512e3dd66e | ||
|
|
fccea10236 | ||
|
|
72e89ff7c3 | ||
|
|
d83a0bd630 | ||
|
|
b1f7d1ab15 | ||
|
|
bd98ef3fe3 | ||
|
|
621aad4d87 | ||
|
|
4e4faf2eae | ||
|
|
c0f2320224 | ||
|
|
b58c505c84 | ||
|
|
e57ca88473 | ||
|
|
e3b31be66c | ||
|
|
f3082b91d0 | ||
|
|
08eaac5ed4 | ||
|
|
744d1f93eb | ||
|
|
893c1c7f9f | ||
|
|
e95c93f2dc | ||
|
|
bdbafa7b7c | ||
|
|
868b61b4ac | ||
|
|
991ad37959 | ||
|
|
4811857f6b | ||
|
|
1739231050 | ||
|
|
0a7f9ed4a1 | ||
|
|
b5f884a61d | ||
|
|
5f03643efd | ||
|
|
249e18f425 | ||
|
|
9b1e01bea9 | ||
|
|
e472735144 | ||
|
|
583054feaf | ||
|
|
a6613c6708 | ||
|
|
88114f2f78 | ||
|
|
0a5bc5402b | ||
|
|
df5776c5a4 | ||
|
|
2e7ff04711 | ||
|
|
0711fada71 | ||
|
|
7704b77344 | ||
|
|
02d0ece98a | ||
|
|
17bc1aa4ab | ||
|
|
c3670c973c | ||
|
|
96199134e7 | ||
|
|
55fbb3338d | ||
|
|
4980ecdf7f | ||
|
|
7336a112b1 | ||
|
|
6b5ae1ea40 | ||
|
|
7cf019559d | ||
|
|
84fb462b6d | ||
|
|
96ae010fac | ||
|
|
28ea853730 | ||
|
|
119d880575 | ||
|
|
745a7e54c8 | ||
|
|
8131e24102 | ||
|
|
4cf52b3653 | ||
|
|
7854d42410 | ||
|
|
823ae7ffca | ||
|
|
bf69ccbd8c | ||
|
|
a6b32c0638 | ||
|
|
58b01976ae | ||
|
|
1bcec032a6 | ||
|
|
47f6ba2fa5 | ||
|
|
03a3ae2a58 | ||
|
|
00ae400175 | ||
|
|
0678972729 | ||
|
|
638f230f40 | ||
|
|
8e9b7d4689 | ||
|
|
b063966b92 | ||
|
|
55a8f62c4d | ||
|
|
0b64723128 | ||
|
|
17725071e5 | ||
|
|
2eec3a9f53 | ||
|
|
5c3ba5313a | ||
|
|
984e830df4 | ||
|
|
abb1088d3d | ||
|
|
08883ce211 | ||
|
|
3e6cee2a6a | ||
|
|
0a83ed0937 | ||
|
|
9569024c9b | ||
|
|
dc4a5dcfcf | ||
|
|
fa66bc4d21 | ||
|
|
7cbedd0b6a | ||
|
|
b13bcbcd77 | ||
|
|
6a5d5b1a13 | ||
|
|
9ec2ecae02 | ||
|
|
7f67bc29ce | ||
|
|
280ef69cd1 | ||
|
|
dbaefa766c | ||
|
|
e19d4df68e | ||
|
|
909b5cced5 | ||
|
|
6960630283 | ||
|
|
7042722fb0 | ||
|
|
ad6ecf0dda | ||
|
|
db651d51da | ||
|
|
e54a99df7b | ||
|
|
0d3c6e20e2 | ||
|
|
a0bd169cd0 | ||
|
|
b51cb03a23 | ||
|
|
d8e896a8b5 | ||
|
|
69f5147629 | ||
|
|
f0a831b931 | ||
|
|
547228ae8c | ||
|
|
ef40665371 | ||
|
|
9df95d75e7 | ||
|
|
c4a0dc5121 | ||
|
|
a0816a02f5 | ||
|
|
3b4fd7dca0 | ||
|
|
1095f5ff52 | ||
|
|
c3e0ccf99c | ||
|
|
16904ce664 | ||
|
|
54c3792085 | ||
|
|
dac635ed45 | ||
|
|
691bd03201 | ||
|
|
4837165227 | ||
|
|
49e9d9e04a | ||
|
|
ec9788d8ed | ||
|
|
58f28d61aa | ||
|
|
857951ac83 | ||
|
|
3f66304eb4 | ||
|
|
7fc3c526f1 | ||
|
|
b1ee3ff327 | ||
|
|
8ede8830fa | ||
|
|
7e03aca713 | ||
|
|
2f2ddf5d06 | ||
|
|
3c08f600a9 | ||
|
|
bfcf333222 | ||
|
|
82c016b193 | ||
|
|
28990641b1 | ||
|
|
0c837dda91 | ||
|
|
964ee5a0e6 | ||
|
|
04694eae65 | ||
|
|
65dbd122f6 | ||
|
|
89235a20bb | ||
|
|
2ee56515da | ||
|
|
e83a106c64 | ||
|
|
182d41eb87 | ||
|
|
25988eb175 | ||
|
|
171e10f69e | ||
|
|
cb3e6189d6 | ||
|
|
651779ae93 | ||
|
|
c634341883 | ||
|
|
0510873412 | ||
|
|
f2e0b1f6d8 | ||
|
|
421fff2263 | ||
|
|
660dcc662f | ||
|
|
a44bbae18a | ||
|
|
de0d1fb19a | ||
|
|
210781f88d | ||
|
|
c86383934a | ||
|
|
33d03ef099 | ||
|
|
c14d8a3a8a | ||
|
|
dc1779e8da | ||
|
|
585a243b0b | ||
|
|
c96a524742 | ||
|
|
71065f34bd | ||
|
|
74e138c472 | ||
|
|
f226ad2508 | ||
|
|
df0507fbb8 | ||
|
|
85668b0130 | ||
|
|
6a2b78f5c0 | ||
|
|
31acf1e232 | ||
|
|
da25e37b1d | ||
|
|
d0643eebb2 | ||
|
|
ab04288066 | ||
|
|
fd32b56535 | ||
|
|
4163217919 | ||
|
|
cb4b2be38e | ||
|
|
f10267038b | ||
|
|
6b015c0559 | ||
|
|
6c8650d2be | ||
|
|
98dec4a401 | ||
|
|
8a56afd571 | ||
|
|
10dccac9b0 | ||
|
|
95f5df07e6 | ||
|
|
25eefbbdec | ||
|
|
c53535946a | ||
|
|
3218500736 | ||
|
|
65e833f6e8 | ||
|
|
4a6be7dc50 | ||
|
|
b224b7bf42 | ||
|
|
a0a723109a | ||
|
|
c4641ec7f0 | ||
|
|
ff705c1dc6 | ||
|
|
a65f1062fc | ||
|
|
48cc3f4cb2 | ||
|
|
d85f9387a6 | ||
|
|
5165a54658 | ||
|
|
582b440967 | ||
|
|
3d9a0de38b | ||
|
|
b9ecc4efe0 | ||
|
|
95913f1cf6 | ||
|
|
7df5d8b58e | ||
|
|
1d6a30d11b | ||
|
|
603600bf02 | ||
|
|
e6a175e138 | ||
|
|
bb9415a457 | ||
|
|
5f661b932a | ||
|
|
bfef3c3e73 | ||
|
|
eb065799c7 | ||
|
|
dba8a49e88 | ||
|
|
a683234090 | ||
|
|
0fe5041ccd | ||
|
|
b5096d1278 | ||
|
|
618ed36b2a | ||
|
|
3781904b11 | ||
|
|
aa539fdd55 | ||
|
|
e826984619 | ||
|
|
558d471113 | ||
|
|
38d88abcc0 | ||
|
|
8a7c83435e | ||
|
|
81249ec581 | ||
|
|
d6aeafa984 | ||
|
|
1768cf4d58 | ||
|
|
5290275857 | ||
|
|
f00f238b66 | ||
|
|
395ffdb6d5 | ||
|
|
3a05934db0 | ||
|
|
6b5bf7ac2f | ||
|
|
5467670146 | ||
|
|
b40a5692ec | ||
|
|
7d49171a28 | ||
|
|
e4259d3543 | ||
|
|
5af0f1661f | ||
|
|
0df09f32cb | ||
|
|
56f3ffe968 | ||
|
|
38e6d2cf8c | ||
|
|
d2f62d4561 | ||
|
|
03fb6582ed | ||
|
|
5e757bd372 | ||
|
|
2e7bdec3a8 | ||
|
|
05b47947a1 | ||
|
|
4188ddf007 |
No files matched your search
@@ -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
|
||||
@@ -24,13 +24,11 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
# `rust` is the comma-separated list installed by dtolnay/rust-toolchain;
|
||||
# `tauri` is what we pass to `cargo tauri build --target`. They differ
|
||||
# only for the universal Mac shard, which produces one fat binary from
|
||||
# two real Rust targets via lipo. Intel-only macos-13 runner is dropped
|
||||
# — GH retired most of that pool, queue waits ran to hours.
|
||||
# macOS now ships two single-arch shards so Apple Silicon and Intel
|
||||
# users get separate DMGs. `rust` and `tauri` stay aligned per shard.
|
||||
- { os: ubuntu-22.04, label: "linux-x64", rust: "x86_64-unknown-linux-gnu", tauri: "x86_64-unknown-linux-gnu" }
|
||||
- { os: macos-14, label: "macos-universal", rust: "aarch64-apple-darwin,x86_64-apple-darwin", tauri: "universal-apple-darwin" }
|
||||
- { os: macos-15-intel, label: "macos-x64", rust: "x86_64-apple-darwin", tauri: "x86_64-apple-darwin" }
|
||||
- { os: macos-14, label: "macos-arm64", rust: "aarch64-apple-darwin", tauri: "aarch64-apple-darwin" }
|
||||
- { os: windows-latest, label: "windows-x64", rust: "x86_64-pc-windows-msvc", tauri: "x86_64-pc-windows-msvc" }
|
||||
runs-on: ${{ matrix.target.os }}
|
||||
# secrets.* isn't accessible from step-level if:, so expose presence as a
|
||||
@@ -96,14 +94,19 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: npm run bundle:node
|
||||
|
||||
- name: Verify bundled Node is universal (macOS)
|
||||
- name: Verify bundled Node matches host arch (macOS)
|
||||
if: startsWith(matrix.target.os, 'macos-')
|
||||
working-directory: desktop
|
||||
run: |
|
||||
expected_arch=$(uname -m)
|
||||
archs=$(lipo -archs src-tauri/binaries/node)
|
||||
echo "node archs: $archs"
|
||||
echo "$archs" | grep -q "arm64"
|
||||
echo "$archs" | grep -q "x86_64"
|
||||
echo "expected arch: $expected_arch"
|
||||
if [ "$archs" != "$expected_arch" ]; then
|
||||
echo "Expected $expected_arch Node, got: $archs" >&2
|
||||
exit 1
|
||||
fi
|
||||
codesign -d --entitlements :- src-tauri/binaries/node 2>&1 | grep -q "com.apple.security.inherit"
|
||||
|
||||
- name: Build & verify desktop frontend
|
||||
working-directory: desktop
|
||||
@@ -172,3 +175,13 @@ jobs:
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target.tauri }}
|
||||
|
||||
- name: Verify app Node inherits TCC grants (macOS)
|
||||
if: startsWith(matrix.target.os, 'macos-')
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
run: |
|
||||
node_bin=$(find src-tauri/target -path '*/release/bundle/macos/*.app/Contents/Resources/node' -type f | head -n 1)
|
||||
test -n "$node_bin"
|
||||
codesign -vvv --strict "$node_bin"
|
||||
codesign -d --entitlements :- "$node_bin" 2>&1 | grep -q "com.apple.security.inherit"
|
||||
@@ -25,12 +25,15 @@ sessions/
|
||||
.turbo/
|
||||
# Local-only Claude Code settings — personal overrides, never committed.
|
||||
.claude/settings.local.json
|
||||
# Local agent notes — personal workflow reminders, never committed.
|
||||
agent.md
|
||||
# Per-user Reasonix state under .reasonix/ — committable team-level
|
||||
# files (settings.json, skills/) stay tracked, but the user-private
|
||||
# bits (semantic index, sessions, opt-out markers) never should.
|
||||
.reasonix/semantic/
|
||||
.reasonix/sessions/
|
||||
.reasonix/semantic-skip
|
||||
.reasonix/truncated-results/
|
||||
# Scratch entry regenerated each time scripts/bundle-codemirror.mjs runs.
|
||||
scripts/.cm-entry.mjs
|
||||
# Personal bun lockfile — project uses npm officially.
|
||||
@@ -48,3 +51,5 @@ desktop/src-tauri/binaries/
|
||||
# Design mock-up reference (local, not part of repo)
|
||||
.design-ref/
|
||||
*.cpuprofile
|
||||
|
||||
.probe-snapshots/
|
||||
+147
@@ -3,6 +3,153 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.52.0] — 2026-05-26
|
||||
|
||||
**Ink renderer in-tree as `@esengine/ink`.** The vendored Ink fork now
|
||||
lives as a workspace package (#1847), unblocking TUI-specific
|
||||
renderer tweaks (focus, IME, Static append) without forking upstream.
|
||||
TUI polish lands on top: guard ASCII IME enter commits so JP/CN IME
|
||||
atomic commits don't double-submit (#1874), preserve undo state across
|
||||
session context switches (#1810), incremental repaint coverage
|
||||
(#1873).
|
||||
|
||||
**Desktop — clipboard image paste.** Paste screenshots / copied images
|
||||
directly into the composer (#1889); paste path hardened against
|
||||
oversized + non-image clipboard payloads (#1897). Sessions exported by
|
||||
other local AI apps can be imported into Reasonix (#1894).
|
||||
|
||||
**Desktop — JumpBar polish + drag perf.** JumpBar now highlights the
|
||||
active message bar and follows the latest one as the thread grows
|
||||
(#1813); dot styling pulled into a shared helper (#1892). Sidebar drag
|
||||
writes CSS vars directly to the DOM with `content-visibility` on
|
||||
messages (#1812) and unit-suffixes the vars to dodge a Chromium
|
||||
recompute storm (#1875).
|
||||
|
||||
**Desktop — smaller fixes.**
|
||||
- Search-engine API key UI + slash command interception (#1867)
|
||||
- Mention `@` popup stays stable while filtering (#1850); split
|
||||
kind/length effects in the popup (#1859)
|
||||
- Update banner floats above the thread instead of pushing layout
|
||||
(#1865)
|
||||
- `memoryDetail` / `onReadMemory` wired through test fixtures so the
|
||||
desktop memory browser is exercisable in unit tests
|
||||
|
||||
**Config — base URL + proxy.** `DEEPSEEK_API_BASE_URL` accepted as an
|
||||
env alias to the existing base-url override (#1876, #1887), and
|
||||
`proxy.url` is now a first-class field in `config.json` (#1881). MCP
|
||||
`initialize` no longer leaks LSP-only fields that some servers reject
|
||||
(#1861).
|
||||
|
||||
**Providers.** Ollama usage parsing tolerates the field-name variants
|
||||
recent Ollama versions emit (#1886).
|
||||
|
||||
**i18n.**
|
||||
- Russian (`ru`) language pack added (#1879)
|
||||
- German corrections — capitalization, terminology, coverage (#1878)
|
||||
- `/qq` command hints + argsHint localized; English fallback for
|
||||
zh-CN (#1857, #1862)
|
||||
|
||||
**Dashboard / UI.**
|
||||
- Sync TUI and Web modal state so confirm gate doesn't desync between
|
||||
surfaces (#1831, #1866)
|
||||
- Highlight active plan edit mode (#1863)
|
||||
- `useEditHistory` no longer re-exports undo-context through itself
|
||||
(#1854)
|
||||
|
||||
## [0.51.0] — 2026-05-25
|
||||
|
||||
**First paint — session restore 4.5s → 430ms.** Progressive `<Static>`
|
||||
mount streams scrollback in chunks instead of mounting the full history
|
||||
synchronously (#1761). Combined with index-backed `mutateCard` + O(n)
|
||||
`plan.drop` + cursor elide (#1769) and the bump to `[email protected]` (which
|
||||
ships the flicker fix we'd been carrying as a patch — patch-package
|
||||
machinery is gone, #1809), large session restores no longer block the
|
||||
event loop on startup.
|
||||
|
||||
**Perf — tokenizer + markdown + search.** BPE tokenizer caches its
|
||||
table + bounds per-content counts with a fast-path truncate (#1741),
|
||||
so long sessions don't re-tokenize the world on every turn. CodeBlock
|
||||
syntax highlight is memoized (#1743). Literal-pattern search bypasses
|
||||
the regex worker entirely (#1748). Windows terminal repaint cadence is
|
||||
throttled (#1750) and a BEL → ST swap on Windows stdout stops `cmd.exe`
|
||||
from beeping on every render frame (#1795).
|
||||
|
||||
**Cross-platform robustness.**
|
||||
- `EXDEV` fallback for atomic config writes on OneDrive / NTFS reparse
|
||||
points (#1778) — config saves no longer fail on Windows + cloud-sync
|
||||
- Apple Terminal mouse-reset crash guarded (#1764); legacy X10 mouse
|
||||
reports without ESC are dropped (#1723); `ESC`-less CSI recovery is
|
||||
refused when followed by typed text (#1771)
|
||||
- CLI startup gates on unsupported Node versions with a clear error
|
||||
instead of crashing in the middle of bootstrap (#1757)
|
||||
- Desktop bundled Node inherits parent TCC grants on macOS (#1749) so
|
||||
file / microphone permission prompts attach to the app, not the helper
|
||||
- Crash-safe atomic rename for edit-block writes (#1721); completed
|
||||
plans archive instead of persisting across sessions (#1700)
|
||||
- `.devenv` and `.direnv` are skipped by glob / search / directory_tree (#1706)
|
||||
|
||||
**Memory — CLAUDE.md import for Claude Code migration.** Loading
|
||||
`CLAUDE.md` and `~/.claude/CLAUDE.md` alongside the existing
|
||||
`REASONIX.md` (#1694) lets Claude Code users carry their project /
|
||||
user memory directly into Reasonix without rewriting it.
|
||||
|
||||
**i18n — German.** Full German (`de`) locale added (#1773) with a
|
||||
follow-up correction pass (#1797).
|
||||
|
||||
**Desktop polish.**
|
||||
- JumpBar message navigation dock (#1725, #1729)
|
||||
- File-change summary card after each assistant turn (#1691)
|
||||
- Self-heal stale model id; expose plan as 4th editMode (#1699)
|
||||
- Restore aborted prompt drafts on relaunch (#1693)
|
||||
- Recalc thread width on resize (#1789); right panel scrollbar
|
||||
draggable (#1765); contain long MCP spec strings (#1736)
|
||||
- Sync slash setting commands across surfaces (#1724)
|
||||
- Surface desktop startup failures instead of silent exit (#1759)
|
||||
|
||||
**TUI polish.**
|
||||
- Plan-mode `[PLAN]` / `[计划]` badge in input bar (#1742)
|
||||
- Forward-delete for `Delete` key (#1735)
|
||||
- IME candidate window cursor alignment (#1754)
|
||||
- Coalesce prompt cursor sync to reduce flicker (#1768)
|
||||
- Demote stuck `running` plan step on turn end (#1784)
|
||||
- Hide `max` effort on non-DeepSeek endpoints (#1798)
|
||||
- Drop stale "press Cmd-C to copy" startup hint (#1692)
|
||||
|
||||
**Dashboard / web mode.**
|
||||
- Bootstrap mcp / skills / memory panels in web mode (#1775)
|
||||
- Expose new memory browser to web (#1779)
|
||||
- Replay active modal on SSE connect so confirm gate isn't stuck (#1770)
|
||||
- Restore scroll and memory visibility (#1766)
|
||||
- Docs responsive spacing polish (#1785)
|
||||
|
||||
**Smaller fixes.**
|
||||
- Preserve full tool results when truncated (#1615)
|
||||
- Trim long-session retained payloads to bound memory growth (#1799)
|
||||
- Drain steer queue on `/new` + `/cwd` so prior intent doesn't leak (#1774)
|
||||
- Suppress DeepSeek-specific 5xx hints when host isn't DeepSeek (#1716)
|
||||
- `normalizeSemanticEmbeddingUserConfig` preserves `timeoutMs` (#1788)
|
||||
- `REASONIX_ACP_SYSTEM_APPEND` env var for system-prompt injection (#1737)
|
||||
- Configurable cost-display currency with click-to-toggle (#1710)
|
||||
|
||||
**Refactors / internals.**
|
||||
- Extract `LruCache` + `TtlLruCache` + `lazy()` helpers from ad-hoc
|
||||
duplicates (#1746)
|
||||
- Split stream aggregator + chunked dispatch out of `step()` (#1739)
|
||||
- Per-component render trace gated by `REASONIX_TRACE_RENDERS` (#1747);
|
||||
`readStats` API on render trace + real-numbers large-session probe (#1751)
|
||||
|
||||
## [0.50.1] — 2026-05-24
|
||||
|
||||
**Install fix — `postinstall` no longer crashes on `npx`.** The
|
||||
`postinstall` hook added in 0.50.0 (#1584) ran `npm ci` inside
|
||||
`dashboard/` and `desktop/` to set up the dev workspace, but those
|
||||
directories aren't shipped in the published tarball. Result: `npx
|
||||
reasonix@0.50.0 code` failed during install with a `dashboard/package.json
|
||||
not found` error and the CLI never started. `postinstall` now delegates
|
||||
to `scripts/postinstall.mjs`, which exits 0 when `dashboard/package.json`
|
||||
is missing (i.e. anywhere except a git checkout). 0.50.0 is deprecated
|
||||
on npm.
|
||||
|
||||
## [0.50.0] — 2026-05-24
|
||||
|
||||
**Desktop dashboard — unified on the CLI-hosted React surface.** The
|
||||
|
||||
+339
@@ -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>
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<strong>日本語</strong>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">公式サイト</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/configuration.html">ガイド</a>
|
||||
·
|
||||
<a href="./docs/ARCHITECTURE.md">アーキテクチャ</a>
|
||||
·
|
||||
<a href="./benchmarks/">ベンチマーク</a>
|
||||
·
|
||||
<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>
|
||||
@@ -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>
|
||||
·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="./README.ja-JP.md">日本語</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<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
@@ -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>
|
||||
·
|
||||
<strong>简体中文</strong>
|
||||
·
|
||||
<a href="./README.ja-JP.md">日本語</a>
|
||||
·
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<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/>
|
||||
|
||||
@@ -20,22 +20,22 @@ Used with permission, anonymized.
|
||||
## Cost — using the prices Reasonix bills against (`src/telemetry/stats.ts`)
|
||||
|
||||
USD per 1M tokens — `inputCacheHit / inputCacheMiss / output`:
|
||||
- `deepseek-v4-flash` — `0.028 / 0.139 / 0.278`
|
||||
- `deepseek-v4-pro` — `0.139 / 1.667 / 3.333`
|
||||
- `deepseek-v4-flash` — `0.0028 / 0.14 / 0.28`
|
||||
- `deepseek-v4-pro` — `0.003625 / 0.435 / 0.87`
|
||||
|
||||
Assuming **v4-flash** (the project default):
|
||||
|
||||
| | This user (99.82% hit) | Same workload, **0% cache** |
|
||||
|---|---:|---:|
|
||||
| Cache-hit input | $12.18 | — |
|
||||
| Cache-miss input | $0.11 | $60.58 |
|
||||
| Cache-hit input | $1.22 | — |
|
||||
| Cache-miss input | $0.11 | $61.01 |
|
||||
| Output | $0.05 | $0.05 |
|
||||
| **Total / day** | **$12.34** | **$60.63** |
|
||||
| **Total / day** | **$1.38** | **$61.06** |
|
||||
|
||||
→ Cache saved this user **$48.29**, or **~80%** off the un-cached baseline, on a single day.
|
||||
→ Cache saved this user **$59.68**, or **~97.7%** off the un-cached baseline, on a single day.
|
||||
|
||||
On **v4-pro** (5× the prefix-cache discount) the same workload would cost
|
||||
**~$62.35** vs **~$727.08** without cache — a **~91% saving**.
|
||||
On **v4-pro** (6.25% of uncached input cost) the same workload would cost
|
||||
**~$2.07** vs **~$189.73** without cache — a **~98.9%** saving.
|
||||
|
||||
## "Isn't that just DeepSeek's prefix cache?"
|
||||
|
||||
@@ -59,8 +59,9 @@ hit rates:
|
||||
3. **`VolatileScratch`** — chain-of-thought / per-turn scratch lives outside
|
||||
the cached prefix so it never poisons the next hit.
|
||||
4. **Auto-compact** — when context approaches the cap, older turns fold into
|
||||
a summary message *appended* to the prefix; the prefix itself isn't
|
||||
rewritten, so the cache survives the fold.
|
||||
a summary message. The summary request is shaped to reuse the main agent's
|
||||
already-cached system/tool/history prefix; the following main-agent request
|
||||
still pays a cold miss for the newly synthesized summary segment.
|
||||
|
||||
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
|
||||
the bytes cacheable.
|
||||
|
||||
+8
-1
@@ -24,6 +24,13 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["dist", "node_modules", "coverage", "*.d.ts", "dashboard/codemirror.js"]
|
||||
"ignore": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"coverage",
|
||||
"*.d.ts",
|
||||
"dashboard/codemirror.js",
|
||||
"packages/ink/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
+155
-11
@@ -6,7 +6,7 @@ import { isWebRuntime } from "./lib/tauri-bridge";
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { CommandPalette, Toast, buildCommands, useCommandPalette } from "./CommandPalette";
|
||||
import { WorkspaceProvider } from "./Markdown";
|
||||
import { getLang, setLang, t, useLang } from "./i18n";
|
||||
import { getLang, getLangLabel, getSupportedLangs, setLang, t, useLang } from "./i18n";
|
||||
import { I } from "./icons";
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
IncomingEvent,
|
||||
JobInfo,
|
||||
McpSpecInfo,
|
||||
MemoryDetail,
|
||||
MemoryEntryInfo,
|
||||
OutgoingCommand,
|
||||
PlanVerdict,
|
||||
@@ -68,6 +69,7 @@ import { WorkdirPop } from "./ui/workdir-pop";
|
||||
import { WorkdirInputModal } from "./ui/workdir-input-modal";
|
||||
import { useAutoScroll } from "./ui/useAutoScroll";
|
||||
import { useDisableTextAssist } from "./ui/useDisableTextAssist";
|
||||
import { elideTranscriptMessages } from "./ui/transcript-elision";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
|
||||
export type AssistantSegment =
|
||||
@@ -179,11 +181,12 @@ export type SessionInfo = {
|
||||
messageCount: number;
|
||||
mtime: string;
|
||||
summary?: string;
|
||||
workspaceStatus?: "matched" | "legacy_missing_meta";
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
reasoningEffort: "low" | "medium" | "high" | "max";
|
||||
editMode: "review" | "auto" | "yolo";
|
||||
editMode: "review" | "auto" | "yolo" | "plan";
|
||||
budgetUsd: number | null;
|
||||
baseUrl?: string;
|
||||
apiKeyPrefix?: string;
|
||||
@@ -191,7 +194,20 @@ export type Settings = {
|
||||
recentWorkspaces: string[];
|
||||
model: string;
|
||||
editor?: string;
|
||||
webSearchEngine?: "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa";
|
||||
webSearchEngine?:
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "ollama";
|
||||
webSearchApiKeys?: {
|
||||
baidu?: string;
|
||||
};
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
version: string;
|
||||
@@ -238,6 +254,7 @@ type State = {
|
||||
/** Files the agent has read or modified this session — paths as the tool args provided them. */
|
||||
sessionFiles: SessionFile[];
|
||||
memory: MemoryEntryInfo[];
|
||||
memoryDetail: MemoryDetail | null;
|
||||
jobs: JobInfo[];
|
||||
/** Live "skill running" indicator — set when a `skill_run` RPC dispatches, cleared on `$turn_complete`. */
|
||||
activeSkill: SkillOrigin | null;
|
||||
@@ -303,6 +320,10 @@ function nextMessageTurn(messages: ChatMessage[]): number {
|
||||
}
|
||||
|
||||
function reduce(state: State, action: Action): State {
|
||||
return withElidedTranscript(reduceRaw(state, action));
|
||||
}
|
||||
|
||||
function reduceRaw(state: State, action: Action): State {
|
||||
switch (action.t) {
|
||||
case "send_user": {
|
||||
return {
|
||||
@@ -467,6 +488,11 @@ function reduce(state: State, action: Action): State {
|
||||
}
|
||||
}
|
||||
|
||||
function withElidedTranscript(state: State): State {
|
||||
const messages = elideTranscriptMessages(state.messages);
|
||||
return messages === state.messages ? state : { ...state, messages };
|
||||
}
|
||||
|
||||
const READING_TOOLS = new Set(["read_file"]);
|
||||
const MODIFYING_TOOLS = new Set(["edit_file", "write_file"]);
|
||||
|
||||
@@ -545,6 +571,10 @@ function appendTextSegment(
|
||||
}
|
||||
|
||||
export function applyIncoming(state: State, ev: IncomingEvent): State {
|
||||
return withElidedTranscript(applyIncomingRaw(state, ev));
|
||||
}
|
||||
|
||||
function applyIncomingRaw(state: State, ev: IncomingEvent): State {
|
||||
switch (ev.type) {
|
||||
case "user.message": {
|
||||
const last = state.messages[state.messages.length - 1];
|
||||
@@ -646,6 +676,23 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
|
||||
},
|
||||
],
|
||||
};
|
||||
case "$modal_dismissed":
|
||||
switch (ev.kind) {
|
||||
case "shell":
|
||||
return { ...state, pendingConfirms: [] };
|
||||
case "path":
|
||||
return { ...state, pendingPathAccess: [] };
|
||||
case "choice":
|
||||
return { ...state, pendingChoices: [] };
|
||||
case "plan":
|
||||
return { ...state, pendingPlans: [] };
|
||||
case "checkpoint":
|
||||
return { ...state, pendingCheckpoints: [] };
|
||||
case "revision":
|
||||
return { ...state, pendingRevisions: [] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
case "$step_completed": {
|
||||
if (!state.activePlan) return state;
|
||||
const stepIds = new Set(state.activePlan.completedStepIds);
|
||||
@@ -666,8 +713,49 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
|
||||
pendingCheckpoints: [],
|
||||
pendingRevisions: [],
|
||||
};
|
||||
case "$sessions":
|
||||
return { ...state, sessions: ev.items };
|
||||
case "$sessions": {
|
||||
const hasCurrent = "currentSession" in ev;
|
||||
const nextCurrent =
|
||||
ev.currentSession === null ? undefined : (ev.currentSession ?? state.currentSession);
|
||||
const currentChanged = hasCurrent && nextCurrent !== state.currentSession;
|
||||
return {
|
||||
...state,
|
||||
sessions: ev.items,
|
||||
currentSession: nextCurrent,
|
||||
messages: currentChanged ? [] : state.messages,
|
||||
pendingConfirms: currentChanged ? [] : state.pendingConfirms,
|
||||
pendingPathAccess: currentChanged ? [] : state.pendingPathAccess,
|
||||
pendingChoices: currentChanged ? [] : state.pendingChoices,
|
||||
pendingPlans: currentChanged ? [] : state.pendingPlans,
|
||||
pendingCheckpoints: currentChanged ? [] : state.pendingCheckpoints,
|
||||
pendingRevisions: currentChanged ? [] : state.pendingRevisions,
|
||||
activePlan: currentChanged ? null : state.activePlan,
|
||||
usage: currentChanged ? zeroUsage() : state.usage,
|
||||
sessionFiles: currentChanged ? [] : state.sessionFiles,
|
||||
queuedSends: currentChanged ? [] : state.queuedSends,
|
||||
};
|
||||
}
|
||||
case "$session_usage": {
|
||||
const empty =
|
||||
ev.totalCostUsd === 0 &&
|
||||
ev.totalPromptTokens === 0 &&
|
||||
ev.totalCompletionTokens === 0 &&
|
||||
ev.cacheHitTokens === 0 &&
|
||||
ev.cacheMissTokens === 0;
|
||||
return {
|
||||
...state,
|
||||
usage: {
|
||||
...state.usage,
|
||||
totalCostUsd: ev.totalCostUsd,
|
||||
totalPromptTokens: ev.totalPromptTokens,
|
||||
totalCompletionTokens: ev.totalCompletionTokens,
|
||||
cacheHitTokens: ev.cacheHitTokens,
|
||||
cacheMissTokens: ev.cacheMissTokens,
|
||||
lastCallCacheHit: empty ? null : state.usage.lastCallCacheHit,
|
||||
lastCallCacheMiss: empty ? null : state.usage.lastCallCacheMiss,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "$mcp_specs":
|
||||
return {
|
||||
...state,
|
||||
@@ -679,7 +767,16 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
|
||||
case "$ctx_breakdown":
|
||||
return { ...state, usage: { ...state.usage, reservedTokens: ev.reservedTokens } };
|
||||
case "$memory":
|
||||
return { ...state, memory: ev.entries };
|
||||
return {
|
||||
...state,
|
||||
memory: ev.entries,
|
||||
memoryDetail:
|
||||
state.memoryDetail && ev.entries.some((entry) => entry.path === state.memoryDetail?.path)
|
||||
? state.memoryDetail
|
||||
: null,
|
||||
};
|
||||
case "$memory_detail":
|
||||
return { ...state, memoryDetail: ev.detail };
|
||||
case "$jobs":
|
||||
return { ...state, jobs: ev.items };
|
||||
case "$balance":
|
||||
@@ -734,6 +831,7 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
|
||||
model: ev.model,
|
||||
editor: ev.editor,
|
||||
webSearchEngine: ev.webSearchEngine,
|
||||
webSearchApiKeys: ev.webSearchApiKeys,
|
||||
subagentModels: ev.subagentModels,
|
||||
showSystemEvents: ev.showSystemEvents,
|
||||
version: ev.version,
|
||||
@@ -1105,6 +1203,7 @@ function TabRuntime({
|
||||
skills: [],
|
||||
sessionFiles: [],
|
||||
memory: [],
|
||||
memoryDetail: null,
|
||||
jobs: [],
|
||||
activeSkill: null,
|
||||
queuedSends: [],
|
||||
@@ -1352,7 +1451,7 @@ function TabRuntime({
|
||||
|
||||
const resolveConfirm = useCallback(
|
||||
(id: number, response: ConfirmationChoice) => {
|
||||
sendRpc({ cmd: "confirm_response", id, response });
|
||||
sendRpc({ cmd: "confirm_response", id, response, kind: "shell" });
|
||||
dispatch({ t: "resolve_confirm", id });
|
||||
},
|
||||
[sendRpc],
|
||||
@@ -1371,7 +1470,7 @@ function TabRuntime({
|
||||
);
|
||||
const resolvePathAccess = useCallback(
|
||||
(id: number, response: ConfirmationChoice) => {
|
||||
sendRpc({ cmd: "confirm_response", id, response });
|
||||
sendRpc({ cmd: "confirm_response", id, response, kind: "path" });
|
||||
dispatch({ t: "resolve_path_access", id });
|
||||
},
|
||||
[sendRpc],
|
||||
@@ -1515,11 +1614,50 @@ function TabRuntime({
|
||||
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
|
||||
e.preventDefault();
|
||||
abort();
|
||||
} else if (e.key === "Enter" && !mod && !e.shiftKey && !e.altKey) {
|
||||
// Defer to any control that already handles Enter — native inputs/buttons,
|
||||
// ARIA button/link widgets (sidebar rows, file pills), or anything that called
|
||||
// preventDefault — so we only grant when focus is on inert layout (#2015).
|
||||
if (e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target?.isContentEditable ||
|
||||
target?.closest('input, textarea, button, select, a, [role="button"], [role="link"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (settingsOpen || jobsOpen || wdOpen) return;
|
||||
// Enter grants the pending authorization prompt (run once), matching the
|
||||
// TUI where Enter confirms the highlighted choice (#1962).
|
||||
const confirm = state.pendingConfirms.at(-1);
|
||||
if (confirm) {
|
||||
e.preventDefault();
|
||||
resolveConfirm(confirm.id, { type: "run_once" });
|
||||
return;
|
||||
}
|
||||
const pathAccess = state.pendingPathAccess.at(-1);
|
||||
if (pathAccess) {
|
||||
e.preventDefault();
|
||||
resolvePathAccess(pathAccess.id, { type: "run_once" });
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [active, state.busy, abort, newChat, settingsOpen, openSettingsAt]);
|
||||
}, [
|
||||
active,
|
||||
state.busy,
|
||||
state.pendingConfirms,
|
||||
state.pendingPathAccess,
|
||||
resolveConfirm,
|
||||
resolvePathAccess,
|
||||
settingsOpen,
|
||||
jobsOpen,
|
||||
wdOpen,
|
||||
abort,
|
||||
newChat,
|
||||
openSettingsAt,
|
||||
]);
|
||||
|
||||
const commands = buildCommands({
|
||||
newChat: () => {
|
||||
@@ -1601,9 +1739,10 @@ function TabRuntime({
|
||||
cmd: "/lang",
|
||||
desc: t("app.cmd.toggleLang"),
|
||||
run: () => {
|
||||
const next = getLang() === "zh-CN" ? "en" : "zh-CN";
|
||||
const langs = getSupportedLangs();
|
||||
const next = langs[(langs.indexOf(getLang()) + 1) % langs.length] ?? "en";
|
||||
setLang(next);
|
||||
const langName = next === "zh-CN" ? t("app.langZH") : t("app.langEN");
|
||||
const langName = getLangLabel(next);
|
||||
flashToast(t("app.toast.langSwitched", { lang: langName }));
|
||||
},
|
||||
},
|
||||
@@ -2052,6 +2191,8 @@ function TabRuntime({
|
||||
mcpBridged={state.mcpBridged}
|
||||
sessionFiles={state.sessionFiles}
|
||||
memory={state.memory}
|
||||
memoryDetail={state.memoryDetail}
|
||||
onReadMemory={(path) => sendRpc({ cmd: "memory_read", path })}
|
||||
/>
|
||||
|
||||
<StatusBar
|
||||
@@ -2121,6 +2262,8 @@ function TabRuntime({
|
||||
mcpSpecs={state.mcpSpecs}
|
||||
mcpBridged={state.mcpBridged}
|
||||
skills={state.skills}
|
||||
memory={state.memory}
|
||||
memoryDetail={state.memoryDetail}
|
||||
qq={state.qq}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSave={saveSettings}
|
||||
@@ -2135,6 +2278,7 @@ function TabRuntime({
|
||||
onPickWorkspace={pickWorkspace}
|
||||
onAddMcpSpec={addMcpSpec}
|
||||
onRemoveMcpSpec={removeMcpSpec}
|
||||
onReadMemory={(path) => sendRpc({ cmd: "memory_read", path })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -232,12 +232,21 @@ export const en = {
|
||||
webSearchNote: "web_fetch + web_search tools",
|
||||
webSearchEngine: "search engine",
|
||||
webSearchEngineBing: "bing — default, works from CN without proxy",
|
||||
webSearchEngineBingIntl: "bing-intl — international index via www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — self-hosted (set endpoint via /se searxng <url>)",
|
||||
webSearchEngineMetaso: "metaso — 100/day free (CN-friendly, free key at metaso.cn)",
|
||||
webSearchEngineBaidu: "baidu — Baidu AI Search (set BAIDU_API_KEY or QIANFAN_API_KEY)",
|
||||
webSearchEngineTavily: "tavily — 1000/mo free (set TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI-native (set PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI-native 1000/mo free (set EXA_API_KEY)",
|
||||
webSearchEngineBrave: "brave — independent index, 2000/mo free (set BRAVE_SEARCH_API_KEY)",
|
||||
webSearchEngineOllama: "ollama — Ollama cloud web search (set OLLAMA_API_KEY)",
|
||||
webSearchEngineNote: "applies to the next web_search call",
|
||||
webSearchApiKeySignup: "(get key)",
|
||||
webSearchApiKeyClear: "Clear",
|
||||
webSearchApiKey: {
|
||||
baidu: "Baidu API key",
|
||||
},
|
||||
sectionBudget: "Budget",
|
||||
budget: "Budget",
|
||||
budgetHint: "Set spending limits",
|
||||
@@ -303,10 +312,18 @@ export const en = {
|
||||
themeStyleHint: "Choose theme style",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleGraphiteDesc: "Cool dark neutrals — best for long sessions.",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleEmberDesc: "Warm dark with a stronger Reasonix orange accent.",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleAuroraDesc: "Teal-green dark for a softer low-light workspace.",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStyleSandstoneDesc: "Warm light tones — easy on bright rooms.",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStylePorcelainDesc: "Clean light with extra contrast.",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleLinenDesc: "Editorial warm light with paper-like surfaces.",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleGlacierDesc: "Cool light with crisp blue accents.",
|
||||
themeStyleMidnight: "Midnight",
|
||||
themeStyleMidnightDesc: "Deep dark with cooler highlights.",
|
||||
pageGeneralLabel: "General",
|
||||
@@ -1233,8 +1250,12 @@ export const en = {
|
||||
themeDark: "Dark",
|
||||
themeLight: "Light",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleMidnight: "Midnight",
|
||||
thisTurn: "This turn",
|
||||
tokens: "Tokens",
|
||||
@@ -1310,6 +1331,8 @@ export const en = {
|
||||
maxDesc: "DeepSeek extension; rejected by stock OpenAI / vLLM",
|
||||
},
|
||||
editMode: {
|
||||
plan: "Plan",
|
||||
planHint: "read-only · all write tools refuse to dispatch",
|
||||
review: "Review",
|
||||
reviewHint: "both edits and non-allowlisted shell ask first",
|
||||
auto: "Auto",
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { ja } from "./ja";
|
||||
import { ru } from "./ru";
|
||||
import { zhCN } from "./zh-CN";
|
||||
|
||||
export type Lang = "en" | "zh-CN";
|
||||
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
|
||||
|
||||
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(["en", "zh-CN"]);
|
||||
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
|
||||
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
|
||||
const LANG_LABELS: Record<Lang, string> = {
|
||||
en: "English",
|
||||
"zh-CN": "简体中文",
|
||||
de: "Deutsch",
|
||||
ja: "日本語",
|
||||
ru: "Русский",
|
||||
};
|
||||
const STORAGE_KEY = "reasonix.lang";
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -18,7 +29,12 @@ function detectDefault(): Lang {
|
||||
/* private mode */
|
||||
}
|
||||
const nav = typeof navigator !== "undefined" ? navigator.language : "";
|
||||
return nav.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
|
||||
const lower = nav.toLowerCase();
|
||||
if (lower.startsWith("zh")) return "zh-CN";
|
||||
if (lower.startsWith("de")) return "de";
|
||||
if (lower.startsWith("ja")) return "ja";
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
return "en";
|
||||
}
|
||||
|
||||
let currentLang: Lang = detectDefault();
|
||||
@@ -38,6 +54,14 @@ export function getLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
export function getSupportedLangs(): readonly Lang[] {
|
||||
return SUPPORTED_LANGS;
|
||||
}
|
||||
|
||||
export function getLangLabel(lang: Lang): string {
|
||||
return LANG_LABELS[lang];
|
||||
}
|
||||
|
||||
export function setLang(lang: Lang): void {
|
||||
if (!SUPPORTED.has(lang) || lang === currentLang) return;
|
||||
currentLang = lang;
|
||||
@@ -63,7 +87,7 @@ export function useLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
const dicts = { en, "zh-CN": zhCN } as const;
|
||||
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
|
||||
|
||||
type Dict = typeof en;
|
||||
type Path<T, K extends keyof T = keyof T> = K extends string
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const ru: typeof en = {
|
||||
...en,
|
||||
};
|
||||
@@ -232,12 +232,21 @@ export const zhCN = {
|
||||
webSearchNote: "web_fetch + web_search 工具",
|
||||
webSearchEngine: "搜索引擎",
|
||||
webSearchEngineBing: "bing — 默认,国内裸 IP 直连,无需代理",
|
||||
webSearchEngineBingIntl: "bing-intl — 国际版索引,走 www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — 自托管(端点用 /se searxng <url> 配置)",
|
||||
webSearchEngineMetaso: "metaso — 每日 100 次免费(国内友好,metaso.cn 可领免费 key)",
|
||||
webSearchEngineBaidu: "baidu — 百度 AI Search(设置 BAIDU_API_KEY 或 QIANFAN_API_KEY)",
|
||||
webSearchEngineTavily: "tavily — 每月 1000 次免费(设置 TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI 直接回答(设置 PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI 直接回答,每月 1000 次免费(设置 EXA_API_KEY)",
|
||||
webSearchEngineBrave: "brave — 独立索引,每月 2000 次免费(设置 BRAVE_SEARCH_API_KEY)",
|
||||
webSearchEngineOllama: "ollama — Ollama 云端网页搜索(设置 OLLAMA_API_KEY)",
|
||||
webSearchEngineNote: "下一次 web_search 调用生效",
|
||||
webSearchApiKeySignup: "(获取 key)",
|
||||
webSearchApiKeyClear: "清除",
|
||||
webSearchApiKey: {
|
||||
baidu: "百度 API key",
|
||||
},
|
||||
sectionBudget: "预算",
|
||||
budget: "预算",
|
||||
budgetHint: "设置支出限额",
|
||||
@@ -303,10 +312,18 @@ export const zhCN = {
|
||||
themeStyleHint: "选择主题风格",
|
||||
themeStyleGraphite: "石墨",
|
||||
themeStyleGraphiteDesc: "冷色深色调,适合长时间使用。",
|
||||
themeStyleEmber: "余烬",
|
||||
themeStyleEmberDesc: "暖黑深色调,强化 Reasonix 橙色品牌感。",
|
||||
themeStyleAurora: "极光",
|
||||
themeStyleAuroraDesc: "青绿色深色调,低光环境更柔和。",
|
||||
themeStyleSandstone: "砂岩",
|
||||
themeStyleSandstoneDesc: "暖色浅色调,明亮环境更舒适。",
|
||||
themeStylePorcelain: "瓷白",
|
||||
themeStylePorcelainDesc: "高对比度浅色,简洁清爽。",
|
||||
themeStyleLinen: "亚麻",
|
||||
themeStyleLinenDesc: "偏纸张质感的温暖浅色调。",
|
||||
themeStyleGlacier: "冰川",
|
||||
themeStyleGlacierDesc: "冷白浅色调,蓝色强调更清爽。",
|
||||
themeStyleMidnight: "午夜",
|
||||
themeStyleMidnightDesc: "深色配冷色高光,更深邃。",
|
||||
pageGeneralLabel: "通用",
|
||||
@@ -1208,8 +1225,12 @@ export const zhCN = {
|
||||
themeDark: "深色",
|
||||
themeLight: "浅色",
|
||||
themeStyleGraphite: "石墨",
|
||||
themeStyleEmber: "余烬",
|
||||
themeStyleAurora: "极光",
|
||||
themeStyleSandstone: "砂岩",
|
||||
themeStylePorcelain: "瓷白",
|
||||
themeStyleLinen: "亚麻",
|
||||
themeStyleGlacier: "冰川",
|
||||
themeStyleMidnight: "午夜",
|
||||
thisTurn: "本轮",
|
||||
tokens: "Tokens",
|
||||
@@ -1285,6 +1306,8 @@ export const zhCN = {
|
||||
maxDesc: "DeepSeek 扩展;标准 OpenAI / vLLM 会拒绝",
|
||||
},
|
||||
editMode: {
|
||||
plan: "计划",
|
||||
planHint: "只读模式 · 所有写工具都不会派发",
|
||||
review: "审阅",
|
||||
reviewHint: "编辑和非允许列表的 shell 命令都会先询问",
|
||||
auto: "自动",
|
||||
|
||||
+397
-140
@@ -197,7 +197,10 @@ function sseToIncoming(ev: any): Record<string, any>[] {
|
||||
break;
|
||||
}
|
||||
case "modal-down": {
|
||||
// Modal closed — normally handled by user action response
|
||||
// Sync close from another surface (TUI or sibling tab).
|
||||
if (typeof ev.modalKind === "string") {
|
||||
results.push({ type: "$modal_dismissed", tabId: "tab-1", kind: ev.modalKind });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "warning":
|
||||
@@ -227,10 +230,12 @@ function sseToIncoming(ev: any): Record<string, any>[] {
|
||||
|
||||
function connectSSE(): void {
|
||||
if (sse) sse.close();
|
||||
const token = document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content") ?? "";
|
||||
const sseUrl = token && token !== "__REASONIX_TOKEN__"
|
||||
? `/api/events?token=${encodeURIComponent(token)}`
|
||||
: "/api/events";
|
||||
const token =
|
||||
document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content") ?? "";
|
||||
const sseUrl =
|
||||
token && token !== "__REASONIX_TOKEN__"
|
||||
? `/api/events?token=${encodeURIComponent(token)}`
|
||||
: "/api/events";
|
||||
sse = new EventSource(sseUrl);
|
||||
sse.onmessage = (msg: MessageEvent) => {
|
||||
try {
|
||||
@@ -273,41 +278,19 @@ function connectSSE(): void {
|
||||
setTimeout(connectSSE, Math.min(delay, 30000));
|
||||
};
|
||||
|
||||
// SSE 连接成功后,开始定期轮询 overview 更新 balance/stats
|
||||
// SSE 连接成功后,开始定期轮询 overview/sessions 更新右侧状态和会话列表
|
||||
startStatsPolling();
|
||||
}
|
||||
|
||||
// 定期轮询 stats/balance
|
||||
// 定期轮询 stats/balance/sessions
|
||||
let statsPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startStatsPolling(): void {
|
||||
if (statsPollTimer) clearInterval(statsPollTimer);
|
||||
// 每 5 秒轮询一次 overview 获取最新 stats/balance
|
||||
// 每 5 秒轮询一次,补偿 SSE 重连、App remount、以及其他终端写入 session 文件的情况。
|
||||
statsPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const overview = await apiFetch("overview");
|
||||
if (overview?.stats) {
|
||||
const stats = overview.stats;
|
||||
// 更新 balance
|
||||
if (stats.balance) {
|
||||
const firstBalance = stats.balance[0];
|
||||
if (firstBalance) {
|
||||
emitEvent({
|
||||
type: "$balance",
|
||||
tabId: "tab-1",
|
||||
currency: firstBalance.currency,
|
||||
total: parseFloat(firstBalance.total_balance) || 0,
|
||||
isAvailable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 更新 usage stats(缓存命中率、tokens、金额)
|
||||
const totalTokens = Math.round(
|
||||
stats.totalCostUsd > 0 ? (stats.cacheHitRatio * 10000) : 0
|
||||
);
|
||||
// 通过 $settings 事件附带更新 usage 相关信息
|
||||
// 注意:usage 主要由 model.final 事件更新,这里只更新 balance
|
||||
}
|
||||
await refreshServerSnapshots();
|
||||
} catch {
|
||||
// 静默失败,等待下次轮询
|
||||
}
|
||||
@@ -318,7 +301,7 @@ function startStatsPolling(): void {
|
||||
async function apiFetch(endpoint: string, options?: RequestInit): Promise<any> {
|
||||
const token = document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content");
|
||||
const headers: Record<string, string> = {
|
||||
...(options?.headers as Record<string, string> ?? {}),
|
||||
...((options?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
if (token && token !== "__REASONIX_TOKEN__") {
|
||||
headers["x-reasonix-token"] = token;
|
||||
@@ -329,7 +312,11 @@ async function apiFetch(endpoint: string, options?: RequestInit): Promise<any> {
|
||||
const res = await fetch(`/api/${endpoint}`, { ...options, headers });
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function emitServerSettings(settings: any, overview?: any): void {
|
||||
@@ -344,6 +331,7 @@ function emitServerSettings(settings: any, overview?: any): void {
|
||||
model: overview?.model ?? settings?.model ?? "deepseek-v4-flash",
|
||||
editor: "code",
|
||||
webSearchEngine: settings?.webSearchEngine ?? "bing",
|
||||
webSearchApiKeys: settings?.webSearchApiKeys ?? {},
|
||||
subagentModels: settings?.subagentModels ?? {},
|
||||
version: overview?.version ?? "",
|
||||
baseUrl: settings?.baseUrl ?? "",
|
||||
@@ -351,7 +339,184 @@ function emitServerSettings(settings: any, overview?: any): void {
|
||||
});
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, fallback = 0): number {
|
||||
const number = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
interface ServerSessionItem {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
mtime: number;
|
||||
summary?: string;
|
||||
workspaceStatus?: "matched" | "legacy_missing_meta";
|
||||
}
|
||||
|
||||
interface ServerSessionsResponse {
|
||||
sessions?: ServerSessionItem[];
|
||||
currentSession?: string | null;
|
||||
}
|
||||
|
||||
interface ServerBalanceEntry {
|
||||
currency: string;
|
||||
total_balance: string;
|
||||
}
|
||||
|
||||
interface ServerOverviewStats {
|
||||
totalCostUsd?: number;
|
||||
cacheHitTokens?: number;
|
||||
cacheMissTokens?: number;
|
||||
totalCompletionTokens?: number;
|
||||
balance?: ServerBalanceEntry[];
|
||||
}
|
||||
|
||||
interface ServerOverviewResponse {
|
||||
stats?: ServerOverviewStats;
|
||||
}
|
||||
|
||||
function mapSessionItem(s: ServerSessionItem) {
|
||||
return {
|
||||
name: s.name,
|
||||
messageCount: s.messageCount,
|
||||
mtime: new Date(s.mtime).toISOString(),
|
||||
summary: s.summary,
|
||||
workspaceStatus: s.workspaceStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function emitSessionsSnapshot(sessionsData: ServerSessionsResponse | null | undefined): void {
|
||||
if (!sessionsData?.sessions) return;
|
||||
emitEvent({
|
||||
type: "$sessions",
|
||||
tabId: "tab-1",
|
||||
currentSession:
|
||||
typeof sessionsData.currentSession === "string" ? sessionsData.currentSession : null,
|
||||
items: sessionsData.sessions.map(mapSessionItem),
|
||||
});
|
||||
}
|
||||
|
||||
function emitOverviewSnapshot(overview: ServerOverviewResponse | null | undefined): void {
|
||||
const stats = overview?.stats;
|
||||
if (!stats) return;
|
||||
|
||||
const cacheHitTokens = finiteNumber(stats.cacheHitTokens);
|
||||
const cacheMissTokens = finiteNumber(stats.cacheMissTokens);
|
||||
emitEvent({
|
||||
type: "$session_usage",
|
||||
tabId: "tab-1",
|
||||
totalCostUsd: finiteNumber(stats.totalCostUsd),
|
||||
totalPromptTokens: cacheHitTokens + cacheMissTokens,
|
||||
totalCompletionTokens: finiteNumber(stats.totalCompletionTokens),
|
||||
cacheHitTokens,
|
||||
cacheMissTokens,
|
||||
});
|
||||
|
||||
const firstBalance = stats.balance?.[0];
|
||||
if (firstBalance) {
|
||||
emitEvent({
|
||||
type: "$balance",
|
||||
tabId: "tab-1",
|
||||
currency: firstBalance.currency,
|
||||
total: Number.parseFloat(firstBalance.total_balance) || 0,
|
||||
isAvailable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshServerSnapshots(): Promise<void> {
|
||||
const [sessionsData, overview] = await Promise.all([apiFetch("sessions"), apiFetch("overview")]);
|
||||
emitSessionsSnapshot(sessionsData);
|
||||
emitOverviewSnapshot(overview);
|
||||
}
|
||||
|
||||
// 初始化 Server 状态
|
||||
function emitMcpSpecsFromServer(specs: any[] | undefined, bridged: any[] | undefined): void {
|
||||
const live = new Map<string, any>();
|
||||
for (const s of bridged ?? []) {
|
||||
if (typeof s?.spec === "string") live.set(s.spec, s);
|
||||
}
|
||||
const out = (specs ?? []).map((s: any) => {
|
||||
const liveEntry = live.get(s.raw);
|
||||
return {
|
||||
raw: s.raw,
|
||||
name: s.name ?? null,
|
||||
transport: s.transport,
|
||||
summary: s.summary,
|
||||
parseError: s.parseError,
|
||||
status: liveEntry ? "connected" : s.parseError ? "failed" : "configured",
|
||||
statusReason: liveEntry ? undefined : s.parseError,
|
||||
toolCount: liveEntry?.toolCount,
|
||||
};
|
||||
});
|
||||
emitEvent({
|
||||
type: "$mcp_specs",
|
||||
tabId: "tab-1",
|
||||
specs: out,
|
||||
bridged: out.length > 0 && out.every((s) => s.status === "connected"),
|
||||
});
|
||||
}
|
||||
|
||||
function emitSkillsFromServer(data: any): void {
|
||||
const items: any[] = [];
|
||||
const tag = (rows: any[] | undefined, scope: string) => {
|
||||
for (const r of rows ?? []) {
|
||||
items.push({
|
||||
name: r.name,
|
||||
description: r.description ?? "",
|
||||
scope,
|
||||
path: r.path ?? "",
|
||||
runAs: r.runAs ?? "inline",
|
||||
model: r.model,
|
||||
});
|
||||
}
|
||||
};
|
||||
tag(data?.builtin, "builtin");
|
||||
tag(data?.global, "global");
|
||||
tag(data?.custom, "global");
|
||||
tag(data?.project, "project");
|
||||
emitEvent({ type: "$skills", tabId: "tab-1", items });
|
||||
}
|
||||
|
||||
function emitMemoryEntriesFromServer(entries: any[] | undefined): void {
|
||||
emitEvent({ type: "$memory", tabId: "tab-1", entries: entries ?? [] });
|
||||
}
|
||||
|
||||
async function loadAndEmitMcp(): Promise<void> {
|
||||
try {
|
||||
const [specsResp, liveResp] = await Promise.all([apiFetch("mcp/specs"), apiFetch("mcp")]);
|
||||
emitMcpSpecsFromServer(specsResp?.specs, liveResp?.servers);
|
||||
} catch (err) {
|
||||
console.warn("[tauri-bridge] mcp fetch failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndEmitSkills(): Promise<void> {
|
||||
try {
|
||||
const data = await apiFetch("skills");
|
||||
if (data) emitSkillsFromServer(data);
|
||||
} catch (err) {
|
||||
console.warn("[tauri-bridge] skills fetch failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndEmitMemory(): Promise<void> {
|
||||
try {
|
||||
const data = await apiFetch("memory/entries");
|
||||
emitMemoryEntriesFromServer(data?.entries);
|
||||
} catch (err) {
|
||||
console.warn("[tauri-bridge] memory fetch failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndEmitMemoryDetail(path: string): Promise<void> {
|
||||
try {
|
||||
const data = await apiFetch(`memory/read?path=${encodeURIComponent(path)}`);
|
||||
if (data?.detail) emitEvent({ type: "$memory_detail", tabId: "tab-1", detail: data.detail });
|
||||
} catch (err) {
|
||||
console.warn("[tauri-bridge] memory read failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function serverInit(): Promise<void> {
|
||||
document.documentElement.dataset.web = "true";
|
||||
|
||||
@@ -365,37 +530,16 @@ async function serverInit(): Promise<void> {
|
||||
]);
|
||||
wsDir = overview?.cwd ?? "";
|
||||
if (settings) emitServerSettings(settings, overview);
|
||||
if (sessionsData?.sessions) {
|
||||
const items = sessionsData.sessions.map((s: any) => ({
|
||||
name: s.name,
|
||||
messageCount: s.messageCount,
|
||||
mtime: new Date(s.mtime).toISOString(),
|
||||
summary: s.summary,
|
||||
}));
|
||||
emitEvent({
|
||||
type: "$sessions",
|
||||
tabId: "tab-1",
|
||||
items,
|
||||
});
|
||||
}
|
||||
// 从 overview 中提取 balance 和 stats
|
||||
const stats = overview?.stats;
|
||||
if (stats?.balance) {
|
||||
const firstBalance = stats.balance[0];
|
||||
if (firstBalance) {
|
||||
emitEvent({
|
||||
type: "$balance",
|
||||
tabId: "tab-1",
|
||||
currency: firstBalance.currency,
|
||||
total: parseFloat(firstBalance.total_balance) || 0,
|
||||
isAvailable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
emitSessionsSnapshot(sessionsData);
|
||||
emitOverviewSnapshot(overview);
|
||||
} catch (err) {
|
||||
console.warn("[tauri-bridge] server init failed:", err);
|
||||
}
|
||||
|
||||
// Sidebar/right-rail panels — desktop pushes these on tab open; web has
|
||||
// to pull them or every panel renders empty forever (#1715).
|
||||
void Promise.all([loadAndEmitMcp(), loadAndEmitSkills(), loadAndEmitMemory()]);
|
||||
|
||||
emitEvent({ type: "$ready", tabId: "tab-1" });
|
||||
emitEvent({
|
||||
type: "$tab_opened",
|
||||
@@ -438,21 +582,17 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
case "session_list": {
|
||||
try {
|
||||
const data = await apiFetch("sessions");
|
||||
if (data?.sessions) {
|
||||
const items = data.sessions.map((s: any) => ({
|
||||
name: s.name,
|
||||
messageCount: s.messageCount,
|
||||
mtime: new Date(s.mtime).toISOString(),
|
||||
summary: s.summary,
|
||||
}));
|
||||
emitEvent({ type: "$sessions", tabId: "tab-1", items });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
emitSessionsSnapshot(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "session_load": {
|
||||
try {
|
||||
const switchData = await apiFetch(`sessions/${encodeURIComponent(payload.name)}/switch`, { method: "POST" });
|
||||
const switchData = await apiFetch(`sessions/${encodeURIComponent(payload.name)}/switch`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!switchData?.ok) {
|
||||
console.warn("[tauri-bridge] session switch failed:", payload.name, switchData);
|
||||
break;
|
||||
@@ -530,16 +670,10 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
await apiFetch(`sessions/${encodeURIComponent(payload.name)}`, { method: "DELETE" });
|
||||
// 删除后刷新列表
|
||||
const data = await apiFetch("sessions");
|
||||
if (data?.sessions) {
|
||||
const items = data.sessions.map((s: any) => ({
|
||||
name: s.name,
|
||||
messageCount: s.messageCount,
|
||||
mtime: new Date(s.mtime).toISOString(),
|
||||
summary: s.summary,
|
||||
}));
|
||||
emitEvent({ type: "$sessions", tabId: "tab-1", items });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
emitSessionsSnapshot(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "new_chat": {
|
||||
@@ -558,24 +692,25 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
carryover: { totalCostUsd: 0, cacheHitTokens: 0, cacheMissTokens: 0 },
|
||||
});
|
||||
const listData = await apiFetch("sessions");
|
||||
if (listData?.sessions) {
|
||||
const items = listData.sessions.map((s: any) => ({
|
||||
name: s.name,
|
||||
messageCount: s.messageCount,
|
||||
mtime: new Date(s.mtime).toISOString(),
|
||||
summary: s.summary,
|
||||
}));
|
||||
emitEvent({ type: "$sessions", tabId: "tab-1", items });
|
||||
}
|
||||
emitSessionsSnapshot(listData);
|
||||
const overview = await apiFetch("overview");
|
||||
emitOverviewSnapshot(overview);
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
} catch {
|
||||
/* fallback */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "settings_get": {
|
||||
try {
|
||||
const [settings, overview] = await Promise.all([apiFetch("settings"), apiFetch("overview")]);
|
||||
const [settings, overview] = await Promise.all([
|
||||
apiFetch("settings"),
|
||||
apiFetch("overview"),
|
||||
]);
|
||||
if (settings) emitServerSettings(settings, overview);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "settings_save": {
|
||||
@@ -585,9 +720,14 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
// 保存后主动拉取最新设置以同步 UI
|
||||
const [settings, overview] = await Promise.all([apiFetch("settings"), apiFetch("overview")]);
|
||||
const [settings, overview] = await Promise.all([
|
||||
apiFetch("settings"),
|
||||
apiFetch("overview"),
|
||||
]);
|
||||
if (settings) emitServerSettings(settings, overview);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "setup_save_key": {
|
||||
@@ -598,37 +738,51 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case "confirm_response": {
|
||||
await apiFetch("modal", {
|
||||
const kind = payload.kind === "path" ? "path" : "shell";
|
||||
await apiFetch("modal/resolve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: payload.id, response: payload.response }),
|
||||
body: JSON.stringify({ kind, choice: payload.response.type }),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "choice_response": {
|
||||
await apiFetch("modal", {
|
||||
const r = payload.response;
|
||||
let choice: Record<string, unknown>;
|
||||
if (r.type === "pick") choice = { kind: "pick", optionId: r.optionId };
|
||||
else if (r.type === "text") choice = { kind: "custom", text: r.text };
|
||||
else choice = { kind: "cancel" };
|
||||
await apiFetch("modal/resolve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: payload.id, response: payload.response }),
|
||||
body: JSON.stringify({ kind: "choice", choice }),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "plan_response": {
|
||||
await apiFetch("modal", {
|
||||
const r = payload.response;
|
||||
await apiFetch("modal/resolve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: payload.id, response: payload.response }),
|
||||
body: JSON.stringify({ kind: "plan", choice: r.type, text: r.feedback }),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "checkpoint_response": {
|
||||
await apiFetch("modal", {
|
||||
const r = payload.response;
|
||||
const text = r.type === "revise" ? r.feedback : undefined;
|
||||
await apiFetch("modal/resolve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: payload.id, response: payload.response }),
|
||||
body: JSON.stringify({ kind: "checkpoint", choice: r.type, text }),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
case "revision_response": {
|
||||
await apiFetch("modal", {
|
||||
const r = payload.response;
|
||||
// Server takes "accept" / "reject"; verdict uses past-participle.
|
||||
const choice =
|
||||
r.type === "accepted" ? "accept" : r.type === "rejected" ? "reject" : null;
|
||||
if (choice === null) break;
|
||||
await apiFetch("modal/resolve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: payload.id, response: payload.response }),
|
||||
body: JSON.stringify({ kind: "revision", choice }),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
@@ -638,12 +792,16 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
if (data?.jobs) {
|
||||
emitEvent({ type: "$jobs", tabId: "tab-1", items: data.jobs });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "mention_query": {
|
||||
try {
|
||||
const data = await apiFetch(`files/search?q=${encodeURIComponent(payload.query)}&nonce=${payload.nonce}`);
|
||||
const data = await apiFetch(
|
||||
`files/search?q=${encodeURIComponent(payload.query)}&nonce=${payload.nonce}`,
|
||||
);
|
||||
if (data) {
|
||||
emitEvent({
|
||||
type: "$mention_results",
|
||||
@@ -653,12 +811,16 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
results: data.results ?? [],
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "mention_preview": {
|
||||
try {
|
||||
const data = await apiFetch(`file-read?path=${encodeURIComponent(payload.path)}&nonce=${payload.nonce}`);
|
||||
const data = await apiFetch(
|
||||
`file-read?path=${encodeURIComponent(payload.path)}&nonce=${payload.nonce}`,
|
||||
);
|
||||
if (data) {
|
||||
emitEvent({
|
||||
type: "$mention_preview",
|
||||
@@ -669,7 +831,9 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
totalLines: data.totalLines ?? 0,
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
// ── 桌面端特有操作(Web 版无操作或静默忽略) ──
|
||||
@@ -686,25 +850,51 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case "jobs_stop": {
|
||||
try { await apiFetch(`jobs/${payload.jobId}/stop`, { method: "POST" }); } catch {}
|
||||
try {
|
||||
await apiFetch(`jobs/${payload.jobId}/stop`, { method: "POST" });
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
case "jobs_stop_all": {
|
||||
try { await apiFetch("jobs/stop-all", { method: "POST" }); } catch {}
|
||||
try {
|
||||
await apiFetch("jobs/stop-all", { method: "POST" });
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
case "compact_history": {
|
||||
try { await apiFetch("messages/compact", { method: "POST" }); } catch {}
|
||||
try {
|
||||
await apiFetch("messages/compact", { method: "POST" });
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
case "retry": {
|
||||
try { await apiFetch("submit", { method: "POST", body: JSON.stringify({ retry: true }) }); } catch {}
|
||||
try {
|
||||
await apiFetch("submit", { method: "POST", body: JSON.stringify({ retry: true }) });
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
case "skill_run": {
|
||||
const body: Record<string, any> = { name: payload.name };
|
||||
if (payload.args) body.args = payload.args;
|
||||
try { await apiFetch("skills/run", { method: "POST", body: JSON.stringify(body) }); } catch {}
|
||||
try {
|
||||
await apiFetch("skills/run", { method: "POST", body: JSON.stringify(body) });
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
case "mcp_specs_get": {
|
||||
await loadAndEmitMcp();
|
||||
break;
|
||||
}
|
||||
case "skills_get": {
|
||||
await loadAndEmitSkills();
|
||||
break;
|
||||
}
|
||||
case "memory_get": {
|
||||
await loadAndEmitMemory();
|
||||
break;
|
||||
}
|
||||
case "memory_read": {
|
||||
if (typeof payload.path === "string") await loadAndEmitMemoryDetail(payload.path);
|
||||
break;
|
||||
}
|
||||
case "mcp_specs_add":
|
||||
@@ -713,8 +903,12 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
|
||||
try {
|
||||
await apiFetch("mcp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: cmd === "mcp_specs_add" ? "add" : "remove", spec: payload.spec }),
|
||||
body: JSON.stringify({
|
||||
action: cmd === "mcp_specs_add" ? "add" : "remove",
|
||||
spec: payload.spec,
|
||||
}),
|
||||
});
|
||||
await loadAndEmitMcp();
|
||||
} catch {}
|
||||
break;
|
||||
}
|
||||
@@ -756,20 +950,31 @@ export async function invoke(cmd: string, args?: any): Promise<any> {
|
||||
const payload = JSON.parse(args.line);
|
||||
if (payload.cmd === "user_input") {
|
||||
emitEvent({
|
||||
type: "user.message", tabId: "tab-1",
|
||||
id: Date.now(), ts: new Date().toISOString(), turn: 2, text: payload.text,
|
||||
type: "user.message",
|
||||
tabId: "tab-1",
|
||||
id: Date.now(),
|
||||
ts: new Date().toISOString(),
|
||||
turn: 2,
|
||||
text: payload.text,
|
||||
});
|
||||
mockAssistantTurn(payload.text);
|
||||
} else if (payload.cmd === "session_list") {
|
||||
emitEvent({ type: "$sessions", tabId: "tab-1", items: mockSessions });
|
||||
} else if (payload.cmd === "session_load") {
|
||||
emitEvent({
|
||||
type: "$session_loaded", tabId: "tab-1", name: payload.name,
|
||||
type: "$session_loaded",
|
||||
tabId: "tab-1",
|
||||
name: payload.name,
|
||||
messages: mockMessages,
|
||||
carryover: { totalCostUsd: 0.045, cacheHitTokens: 2500, cacheMissTokens: 1400 },
|
||||
});
|
||||
} else if (payload.cmd === "new_chat") {
|
||||
emitEvent({ type: "$session_empty", tabId: "tab-1", name: "desktop-new-session", sizeBytes: 0 });
|
||||
emitEvent({
|
||||
type: "$session_empty",
|
||||
tabId: "tab-1",
|
||||
name: "desktop-new-session",
|
||||
sizeBytes: 0,
|
||||
});
|
||||
} else if (payload.cmd === "settings_get") {
|
||||
emitEvent({ type: "$settings", tabId: "tab-1", ...mockSettings });
|
||||
}
|
||||
@@ -787,7 +992,7 @@ export async function invoke(cmd: string, args?: any): Promise<any> {
|
||||
// 2. @tauri-apps/api/event
|
||||
export async function listen<T = any>(
|
||||
eventName: string,
|
||||
callback: EventCallback<T>
|
||||
callback: EventCallback<T>,
|
||||
): Promise<UnlistenFn> {
|
||||
let bucket = listeners.get(eventName);
|
||||
if (!bucket) {
|
||||
@@ -815,10 +1020,18 @@ export function getCurrentWebview(): any {
|
||||
export function getCurrentWindow(): any {
|
||||
return {
|
||||
isMaximized: async () => false,
|
||||
minimize: async () => { console.log("[tauri-bridge] minimize (no-op)"); },
|
||||
close: async () => { console.log("[tauri-bridge] close (no-op)"); },
|
||||
toggleMaximize: async () => { console.log("[tauri-bridge] toggleMaximize (no-op)"); },
|
||||
listen: async (_event: string, _callback: any): Promise<() => void> => () => {},
|
||||
minimize: async () => {
|
||||
console.log("[tauri-bridge] minimize (no-op)");
|
||||
},
|
||||
close: async () => {
|
||||
console.log("[tauri-bridge] close (no-op)");
|
||||
},
|
||||
toggleMaximize: async () => {
|
||||
console.log("[tauri-bridge] toggleMaximize (no-op)");
|
||||
},
|
||||
listen:
|
||||
async (_event: string, _callback: any): Promise<() => void> =>
|
||||
() => {},
|
||||
label: "main",
|
||||
};
|
||||
}
|
||||
@@ -854,17 +1067,36 @@ export async function relaunch(): Promise<void> {
|
||||
}
|
||||
|
||||
// // ═══ Mock 数据(仅 Vite 开发模式使用)
|
||||
//
|
||||
//
|
||||
const mockSessions = [
|
||||
{ name: "desktop-20260520-1", messageCount: 8, mtime: new Date().toISOString(), summary: "项目工程模块重构及自适应 UI 设计" },
|
||||
{ name: "desktop-20260519-2", messageCount: 14, mtime: new Date(Date.now() - 86400000).toISOString(), summary: "编写 WebSocket 桥接与 RPC 行协议对接逻辑" },
|
||||
{ name: "desktop-20260518-3", messageCount: 4, mtime: new Date(Date.now() - 172800000).toISOString(), summary: "测试移动端 TextArea 与 Dynamic Viewport" },
|
||||
{
|
||||
name: "desktop-20260520-1",
|
||||
messageCount: 8,
|
||||
mtime: new Date().toISOString(),
|
||||
summary: "项目工程模块重构及自适应 UI 设计",
|
||||
},
|
||||
{
|
||||
name: "desktop-20260519-2",
|
||||
messageCount: 14,
|
||||
mtime: new Date(Date.now() - 86400000).toISOString(),
|
||||
summary: "编写 WebSocket 桥接与 RPC 行协议对接逻辑",
|
||||
},
|
||||
{
|
||||
name: "desktop-20260518-3",
|
||||
messageCount: 4,
|
||||
mtime: new Date(Date.now() - 172800000).toISOString(),
|
||||
summary: "测试移动端 TextArea 与 Dynamic Viewport",
|
||||
},
|
||||
];
|
||||
|
||||
const mockSettings = {
|
||||
reasoningEffort: "high", editMode: "review", budgetUsd: null,
|
||||
workspaceDir: "", recentWorkspaces: [],
|
||||
model: "deepseek-v4-flash", version: "0.47.2",
|
||||
reasoningEffort: "high",
|
||||
editMode: "review",
|
||||
budgetUsd: null,
|
||||
workspaceDir: "",
|
||||
recentWorkspaces: [],
|
||||
model: "deepseek-v4-flash",
|
||||
version: "0.47.2",
|
||||
};
|
||||
|
||||
const mockMessages: any[] = [
|
||||
@@ -884,8 +1116,12 @@ function mockAssistantTurn(_promptText: string) {
|
||||
emitEvent({ type: "status", text: "DeepSeek R1 思考中...", tabId: "tab-1" });
|
||||
setTimeout(() => {
|
||||
emitEvent({
|
||||
type: "model.turn.started", tabId: "tab-1", id: Date.now(),
|
||||
turn: 2, model: "deepseek-reasoner", reasoningEffort: "high",
|
||||
type: "model.turn.started",
|
||||
tabId: "tab-1",
|
||||
id: Date.now(),
|
||||
turn: 2,
|
||||
model: "deepseek-reasoner",
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
}, 600);
|
||||
// reasoning deltas
|
||||
@@ -904,15 +1140,24 @@ function mockAssistantTurn(_promptText: string) {
|
||||
delay += 500;
|
||||
setTimeout(() => {
|
||||
emitEvent({
|
||||
type: "tool.intent", tabId: "tab-1", name: "list_dir",
|
||||
args: JSON.stringify({ DirectoryPath: "d:/AI/workspace/dashboard" }), callId: "call_12345",
|
||||
type: "tool.intent",
|
||||
tabId: "tab-1",
|
||||
name: "list_dir",
|
||||
args: JSON.stringify({ DirectoryPath: "d:/AI/workspace/dashboard" }),
|
||||
callId: "call_12345",
|
||||
});
|
||||
}, delay);
|
||||
delay += 1000;
|
||||
setTimeout(() => {
|
||||
emitEvent({
|
||||
type: "tool.result", tabId: "tab-1", name: "list_dir", ok: true,
|
||||
output: JSON.stringify([{ name: "package.json", sizeBytes: 864 }, { name: "src", isDir: true }]),
|
||||
type: "tool.result",
|
||||
tabId: "tab-1",
|
||||
name: "list_dir",
|
||||
ok: true,
|
||||
output: JSON.stringify([
|
||||
{ name: "package.json", sizeBytes: 864 },
|
||||
{ name: "src", isDir: true },
|
||||
]),
|
||||
callId: "call_12345",
|
||||
});
|
||||
}, delay);
|
||||
@@ -931,7 +1176,14 @@ function mockAssistantTurn(_promptText: string) {
|
||||
delay += 500;
|
||||
});
|
||||
setTimeout(() => {
|
||||
emitEvent({ type: "model.final", tabId: "tab-1", turn: 2, content: chunks.join(""), reasoningContent: lines.join(""), costUsd: 0.002 });
|
||||
emitEvent({
|
||||
type: "model.final",
|
||||
tabId: "tab-1",
|
||||
turn: 2,
|
||||
content: chunks.join(""),
|
||||
reasoningContent: lines.join(""),
|
||||
costUsd: 0.002,
|
||||
});
|
||||
emitEvent({ type: "$turn_complete", tabId: "tab-1" });
|
||||
}, delay + 400);
|
||||
}
|
||||
@@ -940,7 +1192,12 @@ function mockSetupAndReady() {
|
||||
document.documentElement.dataset.web = "true";
|
||||
setTimeout(() => emitEvent({ type: "$ready", tabId: "tab-1" }), 100);
|
||||
setTimeout(() => {
|
||||
emitEvent({ type: "$tab_opened", tabId: "tab-1", workspaceDir: "d:\\AI\\workspace", active: true });
|
||||
emitEvent({
|
||||
type: "$tab_opened",
|
||||
tabId: "tab-1",
|
||||
workspaceDir: "d:\\AI\\workspace",
|
||||
active: true,
|
||||
});
|
||||
}, 150);
|
||||
setTimeout(() => emitEvent({ type: "$settings", tabId: "tab-1", ...mockSettings }), 200);
|
||||
setTimeout(() => emitEvent({ type: "$sessions", tabId: "tab-1", items: mockSessions }), 350);
|
||||
|
||||
@@ -88,6 +88,17 @@ export type RevisionRequiredEvent = {
|
||||
|
||||
export type RevisionVerdict = { type: "accepted" } | { type: "rejected" } | { type: "cancelled" };
|
||||
|
||||
export type ModalKind =
|
||||
| "shell"
|
||||
| "path"
|
||||
| "choice"
|
||||
| "plan"
|
||||
| "checkpoint"
|
||||
| "revision"
|
||||
| "edit-review";
|
||||
|
||||
export type ModalDismissedEvent = { type: "$modal_dismissed"; kind: ModalKind };
|
||||
|
||||
export type StepCompletedEvent = {
|
||||
type: "$step_completed";
|
||||
stepId: string;
|
||||
@@ -100,7 +111,23 @@ export type PlanClearedEvent = { type: "$plan_cleared" };
|
||||
|
||||
export type SessionsEvent = {
|
||||
type: "$sessions";
|
||||
items: { name: string; messageCount: number; mtime: string; summary?: string }[];
|
||||
currentSession?: string | null;
|
||||
items: {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
mtime: string;
|
||||
summary?: string;
|
||||
workspaceStatus?: "matched" | "legacy_missing_meta";
|
||||
}[];
|
||||
};
|
||||
|
||||
export type SessionUsageEvent = {
|
||||
type: "$session_usage";
|
||||
totalCostUsd: number;
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
};
|
||||
|
||||
export type MentionResultsEvent = {
|
||||
@@ -170,9 +197,12 @@ export type CtxBreakdownEvent = {
|
||||
};
|
||||
|
||||
export type MemoryEntryInfo = {
|
||||
kind: "project_file" | "global_file" | "structured";
|
||||
name: string;
|
||||
scope: "project" | "global";
|
||||
path: string;
|
||||
description: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type MemoryEvent = {
|
||||
@@ -180,6 +210,16 @@ export type MemoryEvent = {
|
||||
entries: MemoryEntryInfo[];
|
||||
};
|
||||
|
||||
export type MemoryDetail = MemoryEntryInfo & {
|
||||
body: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type MemoryDetailEvent = {
|
||||
type: "$memory_detail";
|
||||
detail: MemoryDetail;
|
||||
};
|
||||
|
||||
export type RetryResultEvent = { type: "$retry_result"; text: string };
|
||||
|
||||
export type BtwResultEvent = { type: "$btw_result"; question: string; answer: string };
|
||||
@@ -245,17 +285,21 @@ export type NeedsSetupEvent = {
|
||||
reason: "no_api_key";
|
||||
};
|
||||
|
||||
export type EditMode = "review" | "auto" | "yolo";
|
||||
export type EditMode = "review" | "auto" | "yolo" | "plan";
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high" | "max";
|
||||
|
||||
export type WebSearchEngineName =
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa";
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "ollama";
|
||||
|
||||
export type SettingsEvent = {
|
||||
type: "$settings";
|
||||
@@ -269,6 +313,9 @@ export type SettingsEvent = {
|
||||
model: string;
|
||||
editor?: string;
|
||||
webSearchEngine?: WebSearchEngineName;
|
||||
webSearchApiKeys?: {
|
||||
baidu?: string;
|
||||
};
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
version: string;
|
||||
@@ -300,9 +347,11 @@ export type SettingsPatch = {
|
||||
budgetUsd?: number | null;
|
||||
baseUrl?: string;
|
||||
workspaceDir?: string;
|
||||
recentWorkspaces?: string[];
|
||||
model?: string;
|
||||
editor?: string;
|
||||
webSearchEngine?: WebSearchEngineName;
|
||||
baiduApiKey?: string | null;
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
};
|
||||
@@ -423,6 +472,7 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| ChoiceRequiredEvent
|
||||
| PlanRequiredEvent
|
||||
| SessionsEvent
|
||||
| SessionUsageEvent
|
||||
| SessionLoadedEvent
|
||||
| SessionEmptyEvent
|
||||
| NeedsSetupEvent
|
||||
@@ -431,6 +481,7 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| BalanceEvent
|
||||
| CheckpointRequiredEvent
|
||||
| RevisionRequiredEvent
|
||||
| ModalDismissedEvent
|
||||
| StepCompletedEvent
|
||||
| PlanClearedEvent
|
||||
| MentionResultsEvent
|
||||
@@ -441,6 +492,7 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| SkillsEvent
|
||||
| CtxBreakdownEvent
|
||||
| MemoryEvent
|
||||
| MemoryDetailEvent
|
||||
| JobsEvent
|
||||
| UserMessageEvent
|
||||
| ModelTurnStartedEvent
|
||||
@@ -459,7 +511,7 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "user_input"; text: string }
|
||||
| { cmd: "abort" }
|
||||
| { cmd: "confirm_response"; id: number; response: ConfirmationChoice }
|
||||
| { cmd: "confirm_response"; id: number; response: ConfirmationChoice; kind: "shell" | "path" }
|
||||
| { cmd: "choice_response"; id: number; response: ChoiceVerdict }
|
||||
| { cmd: "plan_response"; id: number; response: PlanVerdict }
|
||||
| { cmd: "checkpoint_response"; id: number; response: CheckpointVerdict }
|
||||
@@ -467,6 +519,7 @@ export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "session_list" }
|
||||
| { cmd: "session_delete"; name: string }
|
||||
| { cmd: "session_load"; name: string }
|
||||
| { cmd: "memory_read"; path: string }
|
||||
| { cmd: "new_chat" }
|
||||
| { cmd: "setup_save_key"; key: string }
|
||||
| { cmd: "settings_get" }
|
||||
|
||||
@@ -86,6 +86,70 @@ body[data-drag-over="1"]::after {
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
[data-theme-style="ember"] {
|
||||
--bg: oklch(15% 0.018 38);
|
||||
--bg-2: oklch(18% 0.018 38);
|
||||
--panel: oklch(20.5% 0.018 38);
|
||||
--panel-2: oklch(23.5% 0.02 38);
|
||||
--card: oklch(22.5% 0.018 38);
|
||||
--card-hover: oklch(26% 0.022 38);
|
||||
--border: oklch(31% 0.026 38);
|
||||
--border-strong: oklch(39% 0.032 38);
|
||||
--fg: oklch(96% 0.012 72);
|
||||
--fg-2: oklch(82% 0.014 72);
|
||||
--muted: oklch(64% 0.014 70);
|
||||
--muted-2: oklch(50% 0.014 68);
|
||||
|
||||
--accent: oklch(70% 0.18 38);
|
||||
--accent-soft: oklch(70% 0.18 38 / 0.15);
|
||||
--accent-strong: oklch(75% 0.19 36);
|
||||
|
||||
--success: oklch(69% 0.13 145);
|
||||
--success-soft: oklch(69% 0.13 145 / 0.14);
|
||||
--warning: oklch(78% 0.14 82);
|
||||
--warning-soft: oklch(78% 0.14 82 / 0.16);
|
||||
--danger: oklch(66% 0.18 25);
|
||||
--danger-soft: oklch(66% 0.18 25 / 0.16);
|
||||
--violet: oklch(72% 0.12 315);
|
||||
--violet-soft: oklch(72% 0.12 315 / 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 0 oklch(0% 0 0 / 0.38);
|
||||
--shadow-md: 0 8px 24px -8px oklch(0% 0 0 / 0.54);
|
||||
--shadow-lg: 0 24px 60px -16px oklch(0% 0 0 / 0.7);
|
||||
}
|
||||
|
||||
[data-theme-style="aurora"] {
|
||||
--bg: oklch(16% 0.018 190);
|
||||
--bg-2: oklch(18.5% 0.02 190);
|
||||
--panel: oklch(21% 0.022 190);
|
||||
--panel-2: oklch(24.5% 0.024 190);
|
||||
--card: oklch(23% 0.023 190);
|
||||
--card-hover: oklch(27% 0.026 190);
|
||||
--border: oklch(32% 0.028 190);
|
||||
--border-strong: oklch(41% 0.033 190);
|
||||
--fg: oklch(94% 0.012 185);
|
||||
--fg-2: oklch(80% 0.018 185);
|
||||
--muted: oklch(63% 0.02 185);
|
||||
--muted-2: oklch(50% 0.02 185);
|
||||
|
||||
--accent: oklch(72% 0.13 175);
|
||||
--accent-soft: oklch(72% 0.13 175 / 0.16);
|
||||
--accent-strong: oklch(78% 0.14 170);
|
||||
|
||||
--success: oklch(73% 0.14 145);
|
||||
--success-soft: oklch(73% 0.14 145 / 0.14);
|
||||
--warning: oklch(79% 0.13 85);
|
||||
--warning-soft: oklch(79% 0.13 85 / 0.16);
|
||||
--danger: oklch(67% 0.17 25);
|
||||
--danger-soft: oklch(67% 0.17 25 / 0.16);
|
||||
--violet: oklch(72% 0.12 250);
|
||||
--violet-soft: oklch(72% 0.12 250 / 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 0 oklch(0% 0 0 / 0.38);
|
||||
--shadow-md: 0 8px 24px -8px oklch(0% 0 0 / 0.52);
|
||||
--shadow-lg: 0 24px 60px -16px oklch(0% 0 0 / 0.68);
|
||||
}
|
||||
|
||||
[data-theme="light"],
|
||||
[data-theme-style="sandstone"] {
|
||||
--bg: oklch(97.5% 0.008 80);
|
||||
@@ -152,6 +216,70 @@ body[data-drag-over="1"]::after {
|
||||
--shadow-lg: 0 24px 60px -22px oklch(20% 0.01 260 / 0.18);
|
||||
}
|
||||
|
||||
[data-theme-style="linen"] {
|
||||
--bg: oklch(98% 0.018 78);
|
||||
--bg-2: oklch(95.5% 0.022 76);
|
||||
--panel: oklch(99% 0.012 78);
|
||||
--panel-2: oklch(93.5% 0.026 75);
|
||||
--card: oklch(99.2% 0.01 78);
|
||||
--card-hover: oklch(96% 0.018 76);
|
||||
--border: oklch(87% 0.028 72);
|
||||
--border-strong: oklch(76% 0.036 68);
|
||||
--fg: oklch(22% 0.018 45);
|
||||
--fg-2: oklch(36% 0.018 45);
|
||||
--muted: oklch(53% 0.016 50);
|
||||
--muted-2: oklch(67% 0.014 58);
|
||||
|
||||
--accent: oklch(57% 0.18 34);
|
||||
--accent-soft: oklch(57% 0.18 34 / 0.1);
|
||||
--accent-strong: oklch(51% 0.2 32);
|
||||
|
||||
--success: oklch(47% 0.13 150);
|
||||
--success-soft: oklch(47% 0.13 150 / 0.1);
|
||||
--warning: oklch(57% 0.14 72);
|
||||
--warning-soft: oklch(57% 0.14 72 / 0.12);
|
||||
--danger: oklch(53% 0.2 22);
|
||||
--danger-soft: oklch(53% 0.2 22 / 0.1);
|
||||
--violet: oklch(55% 0.13 315);
|
||||
--violet-soft: oklch(55% 0.13 315 / 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 0 oklch(30% 0.05 50 / 0.05);
|
||||
--shadow-md: 0 8px 24px -10px oklch(30% 0.05 50 / 0.13);
|
||||
--shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.2);
|
||||
}
|
||||
|
||||
[data-theme-style="glacier"] {
|
||||
--bg: oklch(98.2% 0.01 235);
|
||||
--bg-2: oklch(95.8% 0.012 235);
|
||||
--panel: oklch(99.6% 0.004 235);
|
||||
--panel-2: oklch(94.5% 0.014 235);
|
||||
--card: oklch(100% 0 0);
|
||||
--card-hover: oklch(96.6% 0.012 235);
|
||||
--border: oklch(88% 0.018 235);
|
||||
--border-strong: oklch(78% 0.026 235);
|
||||
--fg: oklch(23% 0.018 255);
|
||||
--fg-2: oklch(37% 0.018 255);
|
||||
--muted: oklch(54% 0.016 250);
|
||||
--muted-2: oklch(68% 0.014 245);
|
||||
|
||||
--accent: oklch(56% 0.13 215);
|
||||
--accent-soft: oklch(56% 0.13 215 / 0.1);
|
||||
--accent-strong: oklch(50% 0.15 215);
|
||||
|
||||
--success: oklch(48% 0.13 155);
|
||||
--success-soft: oklch(48% 0.13 155 / 0.1);
|
||||
--warning: oklch(58% 0.14 78);
|
||||
--warning-soft: oklch(58% 0.14 78 / 0.12);
|
||||
--danger: oklch(54% 0.2 22);
|
||||
--danger-soft: oklch(54% 0.2 22 / 0.1);
|
||||
--violet: oklch(56% 0.12 285);
|
||||
--violet-soft: oklch(56% 0.12 285 / 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 0 oklch(20% 0.02 240 / 0.04);
|
||||
--shadow-md: 0 8px 24px -12px oklch(20% 0.02 240 / 0.1);
|
||||
--shadow-lg: 0 24px 60px -22px oklch(20% 0.02 240 / 0.18);
|
||||
}
|
||||
|
||||
[data-theme-style="midnight"] {
|
||||
--bg: oklch(19% 0.024 265);
|
||||
--bg-2: oklch(17% 0.026 260);
|
||||
@@ -797,6 +925,7 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
|
||||
.sidebar {
|
||||
grid-area: side;
|
||||
container: sidebar / inline-size;
|
||||
background: var(--bg-2);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
@@ -813,9 +942,11 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
}
|
||||
.side-head .new-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
@@ -825,6 +956,15 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
color: oklch(99% 0 0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.side-head .new-btn svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.side-head .new-btn > span:not(.shortcut) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.side-head .new-btn:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
@@ -837,6 +977,8 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
font-weight: 500;
|
||||
}
|
||||
.side-head .new-btn .shortcut {
|
||||
display: none;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
.side-head .new-btn .shortcut kbd {
|
||||
@@ -851,7 +993,13 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
padding: 1px 5px;
|
||||
box-shadow: none;
|
||||
}
|
||||
@container sidebar (min-width: 191px) {
|
||||
.side-head .new-btn .shortcut {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
.side-head .icon-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
@@ -2695,13 +2843,21 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
|
||||
}
|
||||
.mem-row {
|
||||
padding: 6px 8px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
background: var(--panel);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mem-row[data-active="true"] {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.mem-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -2720,6 +2876,20 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
|
||||
color: var(--fg-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mem-detail {
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
margin: 8px 0 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
white-space: pre-wrap;
|
||||
color: var(--fg-1);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- Checkpoint marker ---- */
|
||||
|
||||
@@ -2983,6 +3153,9 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
|
||||
flex-direction: column;
|
||||
z-index: 50;
|
||||
}
|
||||
.popup.at-popup {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
.popup .ph {
|
||||
padding: 8px 12px;
|
||||
font-family: inherit;
|
||||
@@ -3004,8 +3177,12 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
|
||||
}
|
||||
.popup-list {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
.at-popup-list .popup-item {
|
||||
min-width: 0;
|
||||
}
|
||||
.popup-item {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr auto;
|
||||
@@ -3033,12 +3210,20 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] {
|
||||
.popup-item .nm {
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.popup-item .nm .cmd {
|
||||
font-family: inherit;
|
||||
color: var(--accent);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.popup-item .mention-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.popup-item .desc {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
@@ -4415,6 +4600,30 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
|
||||
.theme-pop-item[data-style="graphite"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(68% 0.16 38);
|
||||
}
|
||||
.style-card[data-style="ember"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="ember"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(15% 0.018 38);
|
||||
}
|
||||
.style-card[data-style="ember"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="ember"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(23.5% 0.02 38);
|
||||
}
|
||||
.style-card[data-style="ember"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="ember"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(70% 0.18 38);
|
||||
}
|
||||
.style-card[data-style="aurora"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="aurora"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(16% 0.018 190);
|
||||
}
|
||||
.style-card[data-style="aurora"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="aurora"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(24.5% 0.024 190);
|
||||
}
|
||||
.style-card[data-style="aurora"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="aurora"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(72% 0.13 175);
|
||||
}
|
||||
.style-card[data-style="sandstone"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="sandstone"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(97.5% 0.008 80);
|
||||
@@ -4439,6 +4648,30 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
|
||||
.theme-pop-item[data-style="porcelain"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(56% 0.12 285);
|
||||
}
|
||||
.style-card[data-style="linen"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="linen"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(98% 0.018 78);
|
||||
}
|
||||
.style-card[data-style="linen"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="linen"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(93.5% 0.026 75);
|
||||
}
|
||||
.style-card[data-style="linen"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="linen"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(57% 0.18 34);
|
||||
}
|
||||
.style-card[data-style="glacier"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="glacier"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(98.2% 0.01 235);
|
||||
}
|
||||
.style-card[data-style="glacier"] .style-swatches span:nth-child(2),
|
||||
.theme-pop-item[data-style="glacier"] .style-swatches span:nth-child(2) {
|
||||
background: oklch(94.5% 0.014 235);
|
||||
}
|
||||
.style-card[data-style="glacier"] .style-swatches span:nth-child(3),
|
||||
.theme-pop-item[data-style="glacier"] .style-swatches span:nth-child(3) {
|
||||
background: oklch(56% 0.13 215);
|
||||
}
|
||||
.style-card[data-style="midnight"] .style-swatches span:nth-child(1),
|
||||
.theme-pop-item[data-style="midnight"] .style-swatches span:nth-child(1) {
|
||||
background: oklch(17% 0.026 260);
|
||||
@@ -5768,6 +6001,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] {
|
||||
.ms-seg[data-on="true"][data-k="review"] {
|
||||
background: var(--tone-info-soft); color: var(--tone-info);
|
||||
}
|
||||
.ms-seg[data-on="true"][data-k="plan"] {
|
||||
background: var(--tone-warn-soft); color: var(--tone-warn);
|
||||
}
|
||||
.ms-seg[data-on="true"][data-k="auto"] {
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
@@ -6990,3 +7226,78 @@ html[data-web="true"] .win-controls {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.muted-card {
|
||||
padding: 16px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.memory-browser {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.9fr) minmax(280px, 1.1fr);
|
||||
gap: 12px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.memory-list,
|
||||
.memory-detail {
|
||||
min-width: 0;
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.memory-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memory-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-item[data-active="true"] {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.memory-kind,
|
||||
.memory-path {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.memory-name {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.memory-detail {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.memory-browser {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -15,13 +15,14 @@ import { fmtElapsed } from "./live";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high" | "max";
|
||||
export type EditMode = "review" | "auto" | "yolo";
|
||||
export type EditMode = "review" | "auto" | "yolo" | "plan";
|
||||
|
||||
type ModeEntry = { k: EditMode; label: TKey; icon: React.ReactNode; hint: TKey };
|
||||
|
||||
const EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "max"];
|
||||
|
||||
const MODE_INFO: ModeEntry[] = [
|
||||
{ k: "plan", label: "editMode.plan", icon: <I.list size={11} />, hint: "editMode.planHint" },
|
||||
{ k: "review", label: "editMode.review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
|
||||
{ k: "auto", label: "editMode.auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
|
||||
{ k: "yolo", label: "editMode.yolo", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
|
||||
@@ -63,6 +64,7 @@ export type SlashCmd = {
|
||||
};
|
||||
export type MentionItem = {
|
||||
name: string;
|
||||
path?: string;
|
||||
kind: "file" | "dir" | "url" | "agent" | "clip";
|
||||
desc?: string;
|
||||
};
|
||||
@@ -102,6 +104,20 @@ function parentOfAtQuery(query: string): string | null {
|
||||
return parentIdx >= 0 ? `${dirContext.slice(0, parentIdx)}/` : "";
|
||||
}
|
||||
|
||||
function displayMentionPath(path: string): { name: string; desc?: string } {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const isDir = /[\\/]$/.test(path);
|
||||
const trimmed = normalized.replace(/\/+$/, "");
|
||||
if (!trimmed) return { name: path };
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
const base = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
||||
const parent = slash >= 0 ? `${trimmed.slice(0, slash)}/` : "";
|
||||
return {
|
||||
name: isDir ? `${base}/` : base,
|
||||
desc: parent || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function atIcon(k: MentionItem["kind"]) {
|
||||
if (k === "file") return <I.file size={12} />;
|
||||
if (k === "dir") return <I.folder size={12} />;
|
||||
@@ -239,11 +255,15 @@ export function Composer({
|
||||
const atItems = useMemo<MentionItem[]>(() => {
|
||||
if (!popup || popup.kind !== "at") return [];
|
||||
if (!mentionResults || mentionResults.nonce !== popup.nonce) return [];
|
||||
const base: MentionItem[] = mentionResults.results.map((path) => ({
|
||||
name: path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: path,
|
||||
}));
|
||||
const base: MentionItem[] = mentionResults.results.map((path) => {
|
||||
const display = displayMentionPath(path);
|
||||
return {
|
||||
name: display.name,
|
||||
path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: display.desc,
|
||||
};
|
||||
});
|
||||
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
|
||||
const parent = parentOfAtQuery(popup.query);
|
||||
if (parent !== null) {
|
||||
@@ -316,9 +336,10 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next ? `${next} @${mention.name} ` : `@${mention.name} `);
|
||||
setChips((c) => [...c, { kind: "at", label: mention.name }]);
|
||||
onMentionPicked?.(mention.name);
|
||||
const mentionPath = mention.path ?? mention.name;
|
||||
setDraft(next ? `${next} @${mentionPath} ` : `@${mentionPath} `);
|
||||
setChips((c) => [...c, { kind: "at", label: mentionPath }]);
|
||||
onMentionPicked?.(mentionPath);
|
||||
}
|
||||
setPopup(null);
|
||||
textareaRef.current?.focus();
|
||||
@@ -360,7 +381,7 @@ export function Composer({
|
||||
setPopup({ kind: "at", query: parent, nonce });
|
||||
return;
|
||||
}
|
||||
const dirPath = mention.name.replace(/\/+$/, "");
|
||||
const dirPath = (mention.path ?? mention.name).replace(/[\\/]+$/, "");
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
|
||||
setDraft(next);
|
||||
const nonce = ++nonceRef.current;
|
||||
@@ -602,7 +623,8 @@ export function Composer({
|
||||
onHover={(i, item) => {
|
||||
setActiveIdx(i);
|
||||
if (popup.kind === "at" && onMentionPreview) {
|
||||
const path = (item as MentionItem).name;
|
||||
const mention = item as MentionItem;
|
||||
const path = mention.path ?? mention.name;
|
||||
onMentionPreview(path, popup.nonce);
|
||||
}
|
||||
}}
|
||||
@@ -639,7 +661,10 @@ function Popup({
|
||||
}, [activeIdx]);
|
||||
|
||||
return (
|
||||
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div
|
||||
className={kind === "at" ? "popup at-popup" : "popup"}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="ph">
|
||||
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
|
||||
<span>
|
||||
@@ -652,7 +677,7 @@ function Popup({
|
||||
<I.x size={11} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-list" ref={listRef}>
|
||||
<div className={kind === "at" ? "popup-list at-popup-list" : "popup-list"} ref={listRef}>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -686,7 +711,7 @@ function Popup({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{(it as MentionItem).name}</span>
|
||||
<span className="mention-name">{(it as MentionItem).name}</span>
|
||||
{(it as MentionItem).desc ? (
|
||||
<div className="desc">{(it as MentionItem).desc}</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import type { SessionFile, Settings, UsageStats } from "../App";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo, MemoryEntryInfo } from "../protocol";
|
||||
import type { McpSpecInfo, MemoryDetail, MemoryEntryInfo } from "../protocol";
|
||||
import { PanelErrorBoundary } from "./error-boundary";
|
||||
|
||||
type Tab = "files" | "tools" | "memory" | "rules";
|
||||
@@ -16,6 +16,8 @@ export function ContextPanel({
|
||||
mcpBridged,
|
||||
sessionFiles,
|
||||
memory,
|
||||
memoryDetail,
|
||||
onReadMemory,
|
||||
}: {
|
||||
settings: Settings | null;
|
||||
usage: UsageStats;
|
||||
@@ -23,6 +25,8 @@ export function ContextPanel({
|
||||
mcpBridged: boolean;
|
||||
sessionFiles: SessionFile[];
|
||||
memory: MemoryEntryInfo[];
|
||||
memoryDetail: MemoryDetail | null;
|
||||
onReadMemory: (path: string) => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [tab, setTab] = useState<Tab>("files");
|
||||
@@ -89,7 +93,9 @@ export function ContextPanel({
|
||||
<PanelErrorBoundary key={tab} label={tab}>
|
||||
{tab === "files" && <CtxFiles files={sessionFiles} />}
|
||||
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
|
||||
{tab === "memory" && <CtxMemory entries={memory} />}
|
||||
{tab === "memory" && (
|
||||
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
|
||||
)}
|
||||
{tab === "rules" && <CtxRules settings={settings} />}
|
||||
</PanelErrorBoundary>
|
||||
</div>
|
||||
@@ -237,7 +243,15 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
|
||||
);
|
||||
}
|
||||
|
||||
function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
|
||||
function CtxMemory({
|
||||
entries,
|
||||
detail,
|
||||
onRead,
|
||||
}: {
|
||||
entries: MemoryEntryInfo[];
|
||||
detail: MemoryDetail | null;
|
||||
onRead: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
@@ -251,13 +265,20 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
|
||||
) : (
|
||||
<div className="mem">
|
||||
{entries.map((m) => (
|
||||
<div className="mem-row" key={`${m.scope}/${m.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="mem-row"
|
||||
data-active={detail?.path === m.path}
|
||||
key={m.path}
|
||||
onClick={() => onRead(m.path)}
|
||||
>
|
||||
<span className="scope" data-s={m.scope}>
|
||||
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
|
||||
</span>
|
||||
<span className="txt">{m.description || m.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{detail ? <pre className="mem-detail">{detail.body}</pre> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -258,7 +258,13 @@ export function UsageFull({
|
||||
useLang();
|
||||
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
||||
const total = promptTokens + reasoningTokens + outputTokens + cacheHitTokens || 1;
|
||||
const pct = (n: number) => Math.round((n / total) * 100);
|
||||
const pct = (n: number): number => {
|
||||
const raw = (n / total) * 100;
|
||||
if (raw === 100) return 100;
|
||||
const rounded = Math.round(raw * 100) / 100;
|
||||
if (rounded >= 100) return 99.99;
|
||||
return rounded;
|
||||
};
|
||||
return (
|
||||
<div className="usage-full">
|
||||
<div className="uh">
|
||||
|
||||
+145
-29
@@ -1,8 +1,15 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
|
||||
import { setLang, t, useLang } from "../i18n";
|
||||
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo, SettingsPatch, SkillInfo } from "../protocol";
|
||||
import type {
|
||||
McpSpecInfo,
|
||||
MemoryDetail,
|
||||
MemoryEntryInfo,
|
||||
SettingsPatch,
|
||||
SkillInfo,
|
||||
} from "../protocol";
|
||||
import {
|
||||
describeQQRowSummary,
|
||||
getQQConnectIntent,
|
||||
@@ -60,6 +67,8 @@ export function SettingsModal({
|
||||
mcpSpecs,
|
||||
mcpBridged,
|
||||
skills,
|
||||
memory,
|
||||
memoryDetail,
|
||||
qq,
|
||||
onClose,
|
||||
onSave,
|
||||
@@ -72,6 +81,7 @@ export function SettingsModal({
|
||||
onPickWorkspace,
|
||||
onAddMcpSpec,
|
||||
onRemoveMcpSpec,
|
||||
onReadMemory,
|
||||
}: {
|
||||
settings: SettingsType;
|
||||
balance: Balance | null;
|
||||
@@ -89,6 +99,8 @@ export function SettingsModal({
|
||||
mcpSpecs: McpSpecInfo[];
|
||||
mcpBridged: boolean;
|
||||
skills: SkillInfo[];
|
||||
memory: MemoryEntryInfo[];
|
||||
memoryDetail: MemoryDetail | null;
|
||||
qq: QQDesktopSettingsState | null;
|
||||
onClose: () => void;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
@@ -101,6 +113,7 @@ export function SettingsModal({
|
||||
onPickWorkspace: () => void;
|
||||
onAddMcpSpec: (spec: string) => void;
|
||||
onRemoveMcpSpec: (spec: string) => void;
|
||||
onReadMemory: (path: string) => void;
|
||||
}) {
|
||||
const [page, setPage] = useState<PageId>(initialPage ?? "general");
|
||||
const [qqConfigureOpen, setQQConfigureOpen] = useState(false);
|
||||
@@ -173,7 +186,9 @@ export function SettingsModal({
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
{page === "memory" && <PageMemory />}
|
||||
{page === "memory" && (
|
||||
<PageMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
|
||||
)}
|
||||
{page === "rules" && <PageRules settings={settings} onSave={onSave} />}
|
||||
{page === "billing" && (
|
||||
<PageBilling balance={balance} usage={usage} currency={currency} />
|
||||
@@ -532,16 +547,11 @@ function PageGeneral({
|
||||
<div className="h">{t("settings.languageHint")}</div>
|
||||
</div>
|
||||
<div className="seg-ctrl">
|
||||
<button
|
||||
type="button"
|
||||
data-on={lang === "zh-CN"}
|
||||
onClick={() => setLang("zh-CN")}
|
||||
>
|
||||
{t("settings.langZhCn")}
|
||||
</button>
|
||||
<button type="button" data-on={lang === "en"} onClick={() => setLang("en")}>
|
||||
{t("settings.langEn")}
|
||||
</button>
|
||||
{getSupportedLangs().map((code) => (
|
||||
<button type="button" key={code} data-on={lang === code} onClick={() => setLang(code)}>
|
||||
{getLangLabel(code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -598,7 +608,7 @@ function PageGeneral({
|
||||
<div className="h">{t("settings.editModeHint")}</div>
|
||||
</div>
|
||||
<div className="seg-ctrl">
|
||||
{(["review", "auto", "yolo"] as const).map((m) => (
|
||||
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m}
|
||||
@@ -660,27 +670,113 @@ function PageGeneral({
|
||||
onSave({
|
||||
webSearchEngine: e.target.value as
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa",
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "ollama",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="bing">{t("settings.webSearchEngineBing")}</option>
|
||||
<option value="bing-intl">{t("settings.webSearchEngineBingIntl")}</option>
|
||||
<option value="searxng">{t("settings.webSearchEngineSearxng")}</option>
|
||||
<option value="metaso">{t("settings.webSearchEngineMetaso")}</option>
|
||||
<option value="baidu">{t("settings.webSearchEngineBaidu")}</option>
|
||||
<option value="tavily">{t("settings.webSearchEngineTavily")}</option>
|
||||
<option value="perplexity">{t("settings.webSearchEnginePerplexity")}</option>
|
||||
<option value="exa">{t("settings.webSearchEngineExa")}</option>
|
||||
<option value="brave">{t("settings.webSearchEngineBrave")}</option>
|
||||
<option value="ollama">{t("settings.webSearchEngineOllama")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<WebSearchEngineCredentials settings={settings} onSave={onSave} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchEngineCredentials({
|
||||
settings,
|
||||
onSave,
|
||||
}: {
|
||||
settings: SettingsType;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
if (settings.webSearchEngine !== "baidu") return null;
|
||||
return (
|
||||
<WebSearchApiKeyRow
|
||||
prefix={settings.webSearchApiKeys?.baidu}
|
||||
signupUrl="https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy"
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchApiKeyRow({
|
||||
prefix,
|
||||
signupUrl,
|
||||
onSave,
|
||||
}: {
|
||||
prefix?: string;
|
||||
signupUrl: string;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
return (
|
||||
<div className="setting-row">
|
||||
<div className="l">
|
||||
<div className="n">{t("settings.webSearchApiKey.baidu")}</div>
|
||||
<div className="h">
|
||||
{prefix ? t("settings.apiKeySet", { prefix }) : t("settings.apiKeyNotSet")}{" "}
|
||||
<a
|
||||
href={signupUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUrl(signupUrl).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
{t("settings.webSearchApiKeySignup")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
className="field mono"
|
||||
type="password"
|
||||
value={draft}
|
||||
placeholder={prefix ?? ""}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) return;
|
||||
onSave({ baiduApiKey: trimmed });
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
{t("settings.apiKeySave")}
|
||||
</button>
|
||||
{prefix ? (
|
||||
<button type="button" className="btn" onClick={() => onSave({ baiduApiKey: null })}>
|
||||
{t("settings.webSearchApiKeyClear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeySection({
|
||||
baseUrl,
|
||||
apiKeyPrefix,
|
||||
@@ -1015,22 +1111,42 @@ function PageSkills({
|
||||
);
|
||||
}
|
||||
|
||||
function PageMemory() {
|
||||
function PageMemory({
|
||||
entries,
|
||||
detail,
|
||||
onRead,
|
||||
}: {
|
||||
entries: MemoryEntryInfo[];
|
||||
detail: MemoryDetail | null;
|
||||
onRead: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="stitle">{t("settings.memorySection")}</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
fontSize: 12,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{t("settings.memoryDesc")}
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="muted-card">{t("settings.memoryDesc")}</div>
|
||||
) : (
|
||||
<div className="memory-browser">
|
||||
<div className="memory-list">
|
||||
{entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
className="memory-item"
|
||||
data-active={detail?.path === m.path}
|
||||
key={m.path}
|
||||
onClick={() => onRead(m.path)}
|
||||
>
|
||||
<span className="memory-kind">{m.kind.replace("_", " ")}</span>
|
||||
<span className="memory-name">{m.description || m.name}</span>
|
||||
<span className="memory-path">{m.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="memory-detail">
|
||||
{detail ? detail.body : t("settings.memoryDesc")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1052,7 +1168,7 @@ function PageRules({
|
||||
<div className="h">{t("settings.editModeHint")}</div>
|
||||
</div>
|
||||
<div className="seg-ctrl">
|
||||
{(["review", "auto", "yolo"] as const).map((m) => (
|
||||
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "reasonix-desktop",
|
||||
"private": true,
|
||||
"version": "0.50.0",
|
||||
"version": "0.52.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pulls Node 22 from nodejs.org into desktop/src-tauri/binaries/.
|
||||
// Mac always produces a universal Mach-O so Intel + Apple Silicon ship from one .dmg (issue #1234).
|
||||
// macOS downloads the host-arch binary so arm64 and x64 releases stay split.
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
||||
import https from "node:https";
|
||||
@@ -22,6 +22,7 @@ if (!HOST_ARCH || !["win32", "darwin", "linux"].includes(PLAT)) {
|
||||
|
||||
const isWin = PLAT === "win32";
|
||||
const targetExe = join(binDir, isWin ? "node.exe" : "node");
|
||||
const MAC_ARCH = HOST_ARCH === "x64" ? "x86_64" : HOST_ARCH;
|
||||
|
||||
function adhocSignMac(binPath) {
|
||||
// #1611: child node needs com.apple.security.inherit so it inherits the
|
||||
@@ -29,23 +30,21 @@ function adhocSignMac(binPath) {
|
||||
// on every spawn. Ad-hoc is enough for TCC; CI later overlays Developer ID.
|
||||
const ents = join(here, "..", "src-tauri", "entitlements", "node-helper.plist");
|
||||
if (!existsSync(ents)) {
|
||||
console.warn(`entitlements missing: ${ents} — skipping ad-hoc sign`);
|
||||
return;
|
||||
throw new Error(`entitlements missing: ${ents}`);
|
||||
}
|
||||
try {
|
||||
execSync(
|
||||
`codesign --force --sign - --options runtime --entitlements "${ents}" "${binPath}"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`ad-hoc codesign failed (non-fatal): ${e?.message ?? e}`);
|
||||
execSync(
|
||||
`codesign --force --sign - --options runtime --entitlements "${ents}" "${binPath}"`,
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (!hasMacTccInheritance(binPath)) {
|
||||
throw new Error(`codesign did not apply com.apple.security.inherit to ${binPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isAdhocSigned(binPath) {
|
||||
function hasMacTccInheritance(binPath) {
|
||||
try {
|
||||
const out = execSync(`codesign -dvv "${binPath}" 2>&1`, { encoding: "utf8" });
|
||||
return out.includes("Signature=adhoc") || /Authority=/.test(out);
|
||||
const out = execSync(`codesign -d --entitlements :- "${binPath}" 2>&1`, { encoding: "utf8" });
|
||||
return out.includes("com.apple.security.inherit") && out.includes("<true/>");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -55,16 +54,16 @@ if (existsSync(targetExe) && statSync(targetExe).size > 1024 * 1024) {
|
||||
if (PLAT === "darwin") {
|
||||
try {
|
||||
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
|
||||
if (archs.includes("arm64") && archs.includes("x86_64")) {
|
||||
if (!isAdhocSigned(targetExe)) {
|
||||
console.log(`${targetExe} universal but unsigned — applying ad-hoc sign`);
|
||||
if (archs === MAC_ARCH) {
|
||||
if (!hasMacTccInheritance(targetExe)) {
|
||||
console.log(`${targetExe} ${MAC_ARCH} build missing TCC inheritance — applying ad-hoc sign`);
|
||||
adhocSignMac(targetExe);
|
||||
} else {
|
||||
console.log(`${targetExe} already universal + signed (${archs}) — delete to refetch`);
|
||||
console.log(`${targetExe} already ${MAC_ARCH} + TCC-inheriting — delete to refetch`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`${targetExe} present but not universal (${archs}) — rebuilding`);
|
||||
console.log(`${targetExe} present but not ${MAC_ARCH} (${archs}) — rebuilding`);
|
||||
rmSync(targetExe);
|
||||
} catch {
|
||||
console.log(`${targetExe} present but not verifiable as Mach-O — rebuilding`);
|
||||
@@ -150,36 +149,25 @@ async function fetchAndExtract(arch) {
|
||||
return { inner, extractDir };
|
||||
}
|
||||
|
||||
const { inner, extractDir } = await fetchAndExtract(HOST_ARCH);
|
||||
|
||||
if (existsSync(targetExe)) rmSync(targetExe);
|
||||
renameSync(inner, targetExe);
|
||||
if (!isWin) {
|
||||
try {
|
||||
chmodSync(targetExe, 0o755);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
if (PLAT === "darwin") {
|
||||
const [arm, x64] = await Promise.all([fetchAndExtract("arm64"), fetchAndExtract("x64")]);
|
||||
|
||||
console.log("Creating universal binary with lipo ...");
|
||||
if (existsSync(targetExe)) rmSync(targetExe);
|
||||
execSync(`lipo -create "${arm.inner}" "${x64.inner}" -output "${targetExe}"`, { stdio: "inherit" });
|
||||
chmodSync(targetExe, 0o755);
|
||||
|
||||
rmSync(arm.extractDir, { recursive: true, force: true });
|
||||
rmSync(x64.extractDir, { recursive: true, force: true });
|
||||
|
||||
adhocSignMac(targetExe);
|
||||
|
||||
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
|
||||
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`Done: ${targetExe} (${mb} MB, archs: ${archs})`);
|
||||
} else {
|
||||
const { inner, extractDir } = await fetchAndExtract(HOST_ARCH);
|
||||
|
||||
if (existsSync(targetExe)) rmSync(targetExe);
|
||||
renameSync(inner, targetExe);
|
||||
if (!isWin) {
|
||||
try {
|
||||
chmodSync(targetExe, 0o755);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
const mb = (statSync(targetExe).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`Done: ${targetExe} (${mb} MB)`);
|
||||
}
|
||||
Generated
+76
-1
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
#[cfg(not(debug_assertions))]
|
||||
use tauri::Manager;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use which::which_all;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -69,7 +69,10 @@ fn resolve_cli(app: &AppHandle) -> Result<(String, Vec<String>)> {
|
||||
if is_real_node && cli_path.exists() {
|
||||
return Ok((
|
||||
node_path.to_string_lossy().into_owned(),
|
||||
vec![cli_path.to_string_lossy().into_owned(), "desktop".to_string()],
|
||||
vec![
|
||||
cli_path.to_string_lossy().into_owned(),
|
||||
"desktop".to_string(),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -85,7 +88,6 @@ fn resolve_cli(app: &AppHandle) -> Result<(String, Vec<String>)> {
|
||||
let entry = candidates
|
||||
.into_iter()
|
||||
.find(|p| p.exists())
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| anyhow!("dist/cli/index.js not found — run `npm run build` at repo root"))?;
|
||||
|
||||
let node_path = find_real_node().context("node not found")?;
|
||||
@@ -123,7 +125,11 @@ fn find_real_node() -> Result<PathBuf> {
|
||||
"{} ({} bytes{})",
|
||||
p.display(),
|
||||
size,
|
||||
if is_ms_store_shim { ", MS Store shim" } else { "" },
|
||||
if is_ms_store_shim {
|
||||
", MS Store shim"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -263,7 +269,9 @@ fn kill_process_tree(pid: u32) {
|
||||
#[tauri::command]
|
||||
pub fn rpc_kill(state: State<'_, RpcState>) -> Result<(), String> {
|
||||
let handle_opt = state.inner.lock().take();
|
||||
let Some(handle) = handle_opt else { return Ok(()) };
|
||||
let Some(handle) = handle_opt else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
drop(handle.stdin);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Reasonix",
|
||||
"version": "0.50.0",
|
||||
"version": "0.52.0",
|
||||
"identifier": "dev.reasonix.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data: asset: https://asset.localhost; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost"
|
||||
"csp": "default-src 'self'; img-src 'self' data: asset: https://asset.localhost; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://api.deepseek.com https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev https://github.com"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+166
-1
@@ -34,7 +34,8 @@ vi.mock("./theme", () => ({
|
||||
themeForStyle: vi.fn(() => "dark"),
|
||||
}));
|
||||
|
||||
import { reduce } from "./App";
|
||||
import { readWindowExpanded, reduce, toggleWindowExpanded } from "./App";
|
||||
import { getThreadMaxWidth } from "./ui/thread-layout";
|
||||
|
||||
function initialState(): Parameters<typeof reduce>[0] {
|
||||
return {
|
||||
@@ -58,18 +59,22 @@ function initialState(): Parameters<typeof reduce>[0] {
|
||||
lastCallCacheHit: null,
|
||||
lastCallCacheMiss: null,
|
||||
reservedTokens: 0,
|
||||
liveLogTokens: 0,
|
||||
},
|
||||
sessions: [],
|
||||
externalImportSources: [],
|
||||
settings: null,
|
||||
qq: null,
|
||||
balance: null,
|
||||
mentionResults: null,
|
||||
mentionPreview: null,
|
||||
promptHistoryResult: null,
|
||||
mcpSpecs: [],
|
||||
mcpBridged: false,
|
||||
skills: [],
|
||||
sessionFiles: [],
|
||||
memory: [],
|
||||
memoryDetail: null,
|
||||
jobs: [],
|
||||
activeSkill: null,
|
||||
queuedSends: [],
|
||||
@@ -130,6 +135,126 @@ function makePathPrompt(
|
||||
};
|
||||
}
|
||||
|
||||
describe("Desktop App reducer — usage", () => {
|
||||
it("falls back prompt tokens to cache miss tokens when cache fields are absent", () => {
|
||||
const next = reduce(initialState(), {
|
||||
t: "incoming",
|
||||
event: {
|
||||
type: "model.final",
|
||||
id: 1,
|
||||
ts: "2026-05-27T00:00:00.000Z",
|
||||
turn: 1,
|
||||
content: "ok",
|
||||
usage: { prompt_tokens: 1234, completion_tokens: 56, total_tokens: 1290 },
|
||||
costUsd: 0.001,
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.usage.totalPromptTokens).toBe(1234);
|
||||
expect(next.usage.cacheHitTokens).toBe(0);
|
||||
expect(next.usage.cacheMissTokens).toBe(1234);
|
||||
expect(next.usage.lastCallCacheMiss).toBe(1234);
|
||||
});
|
||||
|
||||
it("settles the pending assistant message when an error ends the turn (#1660)", () => {
|
||||
const base = initialState();
|
||||
const state = {
|
||||
...base,
|
||||
busy: true,
|
||||
messages: [
|
||||
...base.messages,
|
||||
{
|
||||
kind: "assistant" as const,
|
||||
turn: 1,
|
||||
segments: [{ kind: "reasoning" as const, text: "thinking…" }],
|
||||
pending: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = reduce(state, {
|
||||
t: "incoming",
|
||||
event: {
|
||||
type: "error",
|
||||
id: 1,
|
||||
ts: "2026-05-27T00:00:00.000Z",
|
||||
turn: 1,
|
||||
message: "SSE body read failed: terminated",
|
||||
recoverable: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.busy).toBe(false);
|
||||
const assistant = next.messages.find((m) => m.kind === "assistant");
|
||||
expect(assistant?.pending).toBe(false);
|
||||
const error = next.messages.find((m) => m.kind === "error");
|
||||
expect(error?.message).toBe("SSE body read failed: terminated");
|
||||
});
|
||||
|
||||
it("keeps cumulative usage when live context breakdown refreshes", () => {
|
||||
const base = initialState();
|
||||
const next = reduce(
|
||||
{
|
||||
...base,
|
||||
usage: {
|
||||
...base.usage,
|
||||
cacheHitTokens: 80,
|
||||
cacheMissTokens: 20,
|
||||
totalPromptTokens: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
t: "incoming",
|
||||
event: { type: "$ctx_breakdown", reservedTokens: 10, logTokens: 42 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(next.usage.cacheHitTokens).toBe(80);
|
||||
expect(next.usage.cacheMissTokens).toBe(20);
|
||||
expect(next.usage.liveLogTokens).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Desktop App window controls", () => {
|
||||
it("treats macOS zoom state as fullscreen", async () => {
|
||||
const win = {
|
||||
isFullscreen: vi.fn(async () => true),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
setFullscreen: vi.fn(async () => {}),
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await expect(readWindowExpanded(win, true)).resolves.toBe(true);
|
||||
expect(win.isFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(win.isMaximized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses native fullscreen for macOS zoom button", async () => {
|
||||
const win = {
|
||||
isFullscreen: vi.fn(async () => false),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
setFullscreen: vi.fn(async () => {}),
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await toggleWindowExpanded(win, true, false);
|
||||
expect(win.setFullscreen).toHaveBeenCalledWith(true);
|
||||
expect(win.toggleMaximize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps maximize behavior off macOS", async () => {
|
||||
const win = {
|
||||
isFullscreen: vi.fn(async () => false),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
setFullscreen: vi.fn(async () => {}),
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await toggleWindowExpanded(win, false, false);
|
||||
expect(win.toggleMaximize).toHaveBeenCalledTimes(1);
|
||||
expect(win.setFullscreen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Desktop App reducer — ApprovalPrompt integration", () => {
|
||||
it("stores shell confirm with prompt on $confirm_required", () => {
|
||||
const state = initialState();
|
||||
@@ -241,4 +366,44 @@ describe("Desktop App reducer — ApprovalPrompt integration", () => {
|
||||
expect(next.pendingConfirms).toHaveLength(0);
|
||||
expect(next.pendingPathAccess).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("patches settings optimistically for desktop setting commands", () => {
|
||||
const state: Parameters<typeof reduce>[0] = {
|
||||
...initialState(),
|
||||
settings: {
|
||||
reasoningEffort: "medium",
|
||||
editMode: "review",
|
||||
budgetUsd: null,
|
||||
workspaceDir: "/workspace",
|
||||
recentWorkspaces: [],
|
||||
model: "deepseek-v4-flash",
|
||||
version: "0.50.1",
|
||||
},
|
||||
};
|
||||
|
||||
const next = reduce(state, {
|
||||
t: "settings_patch",
|
||||
patch: { reasoningEffort: "low", editMode: "auto" },
|
||||
});
|
||||
|
||||
expect(next.settings?.reasoningEffort).toBe("low");
|
||||
expect(next.settings?.editMode).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop thread layout", () => {
|
||||
it("recomputes the thread cap from the latest viewport width", () => {
|
||||
const side = 244;
|
||||
const ctx = 320;
|
||||
|
||||
expect(
|
||||
getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx }),
|
||||
).toBe(580);
|
||||
expect(
|
||||
getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx }),
|
||||
).toBe(756);
|
||||
expect(
|
||||
getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx }),
|
||||
).toBe(1120);
|
||||
});
|
||||
});
|
||||
+1008
-378
File diff suppressed because it is too large.
Load diff
+151
-16
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export type AbortDraftSource = "user_input" | "skill_run" | "btw";
|
||||
|
||||
export type AbortDraftAction =
|
||||
| { type: "record"; source: AbortDraftSource; text: string }
|
||||
| { type: "clear" };
|
||||
|
||||
export function nextAbortDraftCandidate(
|
||||
_current: string | null,
|
||||
action: AbortDraftAction,
|
||||
): string | null {
|
||||
if (action.type === "clear") return null;
|
||||
if (action.source === "btw") return null;
|
||||
|
||||
const text = action.text.trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
export function restoreAbortedDraft(
|
||||
currentDraft: string,
|
||||
interruptedText: string | null | undefined,
|
||||
): string | null {
|
||||
const text = interruptedText?.trim();
|
||||
if (!text) return null;
|
||||
if (currentDraft.trim()) return null;
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const de: typeof en = {
|
||||
...en,
|
||||
palette: {
|
||||
...en.palette,
|
||||
searchPlaceholder: "Befehle suchen...",
|
||||
empty: "Keine Treffer",
|
||||
footMove: "bewegen",
|
||||
footRun: "ausführen",
|
||||
footClose: "schließen",
|
||||
newChat: "Neuer Chat",
|
||||
newChatHint: "Aktuelle Unterhaltung verwerfen und neu beginnen",
|
||||
newTab: "Neuer Tab",
|
||||
newTabHint: "Einen weiteren Arbeitsbereichs-Tab öffnen",
|
||||
closeTab: "Tab schließen",
|
||||
closeTabHint: "Aktuellen Tab schließen",
|
||||
abort: "Aktuellen Turn stoppen",
|
||||
abortHint: "Modell mid-Generation unterbrechen",
|
||||
copyLast: "Letzte Antwort kopieren",
|
||||
copyLastHint: "Neueste Assistentenantwort kopieren",
|
||||
copyConv: "Unterhaltung als Markdown kopieren",
|
||||
copyConvHint: "Gesamte Unterhaltung in die Zwischenablage kopieren",
|
||||
exportMd: "Unterhaltung als Markdown exportieren",
|
||||
exportMdHint: "Als .md-Datei speichern",
|
||||
clearChat: "Bildschirm leeren",
|
||||
clearChatHint: "Nur UI leeren — Unterprozess bleibt aktiv",
|
||||
focusComposer: "Composer fokussieren",
|
||||
pickWorkspace: "Arbeitsbereich wechseln…",
|
||||
pickWorkspaceHint: "Ein anderes Arbeitsverzeichnis wählen",
|
||||
settings: "Einstellungen",
|
||||
settingsHint: "Modell, Budget, Theme…",
|
||||
about: "Über Reasonix",
|
||||
groupNav: "Navigation",
|
||||
groupAction: "Aktion",
|
||||
groupWorkspace: "Arbeitsbereich",
|
||||
groupSettings: "Einstellungen",
|
||||
countSuffix: "Einträge",
|
||||
},
|
||||
about: {
|
||||
...en.about,
|
||||
sidebarLabel: "Über",
|
||||
tagline: "DeepSeek-nativer Coding-Agent",
|
||||
version: "Version",
|
||||
repo: "Repository",
|
||||
checkUpdates: "Nach Updates suchen",
|
||||
checking: "Prüfe...",
|
||||
upToDate: "Aktuell ({version})",
|
||||
updateAvailable: "Update verfügbar: {version}",
|
||||
openReleases: "Releases anzeigen",
|
||||
checkFailed: "Prüfung fehlgeschlagen: {message}",
|
||||
close: "Schließen",
|
||||
},
|
||||
workdir: {
|
||||
...en.workdir,
|
||||
title: "Arbeitsbereich wechseln",
|
||||
searchPlaceholder: "Letzte Pfade suchen...",
|
||||
empty: "Keine letzten Arbeitsbereiche",
|
||||
browse: "Lokal durchsuchen...",
|
||||
removeRecent: "Aus Liste entfernen",
|
||||
},
|
||||
sidebar: {
|
||||
...en.sidebar,
|
||||
newChat: "Neuer Chat",
|
||||
searchCommands: "Befehle suchen...",
|
||||
recent: "Zuletzt",
|
||||
noSessions: "Noch keine Sitzungen",
|
||||
wallet: "Wallet",
|
||||
footHint: "Cache-First-Agent",
|
||||
},
|
||||
toast: {
|
||||
...en.toast,
|
||||
copied: "Kopiert",
|
||||
copiedMd: "Unterhaltung als Markdown kopiert",
|
||||
newSession: "Neue Sitzung gestartet",
|
||||
cleared: "UI geleert",
|
||||
mcpStatusOpened: "MCP-Statusleiste geöffnet",
|
||||
mcpRetryNone: "Keine fehlgeschlagenen MCP-Server zum Wiederholen",
|
||||
mcpRetryQueued: "{count} fehlgeschlagene MCP-Server werden erneut versucht",
|
||||
aboutLine: "Reasonix v{version} · Cache-First-DeepSeek-Agent",
|
||||
},
|
||||
composer: {
|
||||
...en.composer,
|
||||
busy: "reasonix denkt nach...",
|
||||
idle: "Frag etwas",
|
||||
send: "senden",
|
||||
newline: "neue Zeile",
|
||||
commands: "Befehle",
|
||||
placeholder: "Frag den Agenten / beschreibe eine Aufgabe...",
|
||||
queueCount: "{n} in Warteschlange",
|
||||
queue: "Warteschlange",
|
||||
interrupt: "stopp",
|
||||
mentionFiles: "Dateien erwähnen",
|
||||
commandPalette: "Befehlsübersicht",
|
||||
insertFile: "Datei einfügen (@-Erwähnung)",
|
||||
insertImage: "Bild einfügen (@-Erwähnung)",
|
||||
switchModel: "Modell wechseln",
|
||||
switchEffort: "Reasoning-Effort wechseln",
|
||||
slashHeader: "Befehle — Agent, Modell und Sitzung steuern",
|
||||
atHeader: "Erwähnungen — Dateien im Arbeitsbereich",
|
||||
noMatches: "Keine Treffer",
|
||||
select: "auswählen",
|
||||
confirm: "bestätigen",
|
||||
close: "schließen",
|
||||
imageFilterName: "Bilder",
|
||||
commandsLabel: "Befehle",
|
||||
mentionLabel: "Erwähnen",
|
||||
workspaceRoot: "Arbeitsbereich-Root",
|
||||
},
|
||||
dragDrop: {
|
||||
...en.dragDrop,
|
||||
overlay: "Zum Anhängen als @-Erwähnung ablegen",
|
||||
},
|
||||
settings: {
|
||||
...en.settings,
|
||||
title: "Einstellungen",
|
||||
close: "schließen",
|
||||
language: "Sprache",
|
||||
languageHint: "Nur UI — Agent-Prompts bleiben unverändert.",
|
||||
langEn: "Englisch",
|
||||
langZhCn: "Chinesisch (vereinfacht)",
|
||||
langEnDesc: "Englische UI",
|
||||
langZhCnDesc: "中文界面",
|
||||
costCurrency: "Kostenwährung",
|
||||
costCurrencyHint:
|
||||
"Nur Anzeige. Interne Kosten werden in USD-Äquivalent pro Token berechnet, zum festen Kurs von 7,2 FX umgerechnet.",
|
||||
cnyDesc: "entspricht DeepSeek-Dashboard",
|
||||
usdDesc: "entspricht Referenztarifen pro Token",
|
||||
theme: "Theme",
|
||||
themeHint: "Lokal auf diesem Gerät, nicht mit der TUI synchronisiert.",
|
||||
themeDark: "dunkel",
|
||||
themeLight: "hell",
|
||||
themeDarkDesc: "Standard · wenig Licht",
|
||||
themeLightDesc: "hoher Kontrast · Tageslicht",
|
||||
themeStyle: "Stil",
|
||||
themeStyleHint: "Wähle die visuelle Palette für den gewählten hellen oder dunklen Modus.",
|
||||
themeStyleGraphite: "Graphit",
|
||||
themeStyleGraphiteDesc: "Originale dunkle Palette mit neutralen Graphit-Panels.",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleEmberDesc: "Warme dunkle Palette mit stärkerem Reasonix-Orange.",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleAuroraDesc: "Türkisgrüne dunkle Palette für weichere Arbeit bei wenig Licht.",
|
||||
themeStyleSandstone: "Sandstein",
|
||||
themeStyleSandstoneDesc: "Originale warme helle Palette.",
|
||||
themeStylePorcelain: "Porzellan",
|
||||
themeStylePorcelainDesc: "Saubere Codex-ähnliche helle Palette mit ruhigem Kontrast.",
|
||||
themeStyleLinen: "Leinen",
|
||||
themeStyleLinenDesc: "Editoriale warme helle Palette mit papierartigen Flächen.",
|
||||
themeStyleGlacier: "Gletscher",
|
||||
themeStyleGlacierDesc: "Kühle helle Palette mit klaren blauen Akzenten.",
|
||||
themeStyleMidnight: "Mitternacht",
|
||||
themeStyleMidnightDesc: "Marineblaue dunkle Palette mit kühlen blauen Oberflächen.",
|
||||
fontScale: "Schriftgröße",
|
||||
fontScaleHint: "Skaliert die gesamte UI; wähle größer für hochauflösende Bildschirme.",
|
||||
fontScaleSmall: "klein",
|
||||
fontScaleMedium: "mittel",
|
||||
fontScaleLarge: "groß",
|
||||
fontFamily: "Schriftfamilie",
|
||||
fontFamilyHint: "Textkörper; Code-Blöcke bleiben unabhängig davon monospace.",
|
||||
fontFamilySans: "serifenlos",
|
||||
fontFamilySystem: "System",
|
||||
fontFamilySerif: "Serife",
|
||||
fontFamilyCustom: "benutzerdefiniert",
|
||||
customFontFamily: "Benutzerdefinierte Schriftfamilie",
|
||||
customFontFamilyHint: "CSS-font-family-Wert für die gesamte Desktop-UI.",
|
||||
reasoningEffort: "Reasoning-Effort",
|
||||
reasoningEffortHint: "Wirkt live auf die laufende Sitzung.",
|
||||
effortHigh: "hoch",
|
||||
effortMax: "max",
|
||||
effortHighDesc: "schneller, weniger Denken",
|
||||
effortMaxDesc: "tieferes Reasoning, langsamer",
|
||||
editMode: "Edit-Gate",
|
||||
editModeHint:
|
||||
"Tool-Genehmigungsverhalten — Änderungen werden auf Platte gespeichert, wirken beim nächsten Tool-Aufruf.",
|
||||
editModeReview: "review",
|
||||
editModeAuto: "auto",
|
||||
editModeYolo: "yolo",
|
||||
showSystemEvents: "Systemereignisse",
|
||||
showSystemEventsHint:
|
||||
"Zeige leise Inline-Hinweise zur Kontextkomprimierung, Abbruch und Ratelimit-Pausen.",
|
||||
shown: "angezeigt",
|
||||
hidden: "ausgeblendet",
|
||||
desktopCloseBehavior: "Fenster schließen",
|
||||
desktopCloseBehaviorHint:
|
||||
"Standardmäßig beendet Schließen die App. In die Ablage ausblenden hält laufende Sitzungen aktiv.",
|
||||
closeToQuit: "beenden",
|
||||
closeToTray: "in Ablage",
|
||||
editModeReviewDesc: "jeden Schreibzugriff genehmigen",
|
||||
editModeAutoDesc: "Befehle genehmigen, Edits auto-anwenden",
|
||||
editModeYoloDesc: "nichts genehmigen — Vollautomatik",
|
||||
budget: "Budgetgrenze",
|
||||
budgetHint: "USD pro Sitzung. Leer = keine Grenze.",
|
||||
budgetPlaceholder: "keine Grenze",
|
||||
webSearchEngine: "Suchmaschine",
|
||||
webSearchEngineBing: "bing — Standard, funktioniert von CN ohne Proxy",
|
||||
webSearchEngineBingIntl: "bing-intl — internationaler Index über www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — selbst gehostet (Endpunkt via /se searxng <url>)",
|
||||
webSearchEngineMetaso:
|
||||
"metaso — 100/Tag kostenlos (CN-freundlich, kostenloser Schlüssel auf metaso.cn)",
|
||||
webSearchEngineBaidu:
|
||||
"baidu — Baidu AI Search, laut Baidu-Doku 1500/Monat kostenlos (BAIDU_API_KEY oder QIANFAN_API_KEY setzen)",
|
||||
webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)",
|
||||
webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)",
|
||||
webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)",
|
||||
webSearchEngineBrave: "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)",
|
||||
webSearchEngineOllama: "ollama — Ollama Cloud-Websuche (OLLAMA_API_KEY setzen)",
|
||||
webSearchEngineNote: "gilt für den nächsten web_search-Aufruf",
|
||||
webSearchEndpoint: "SearXNG-Endpunkt",
|
||||
webSearchEndpointHint: "Basis-URL der SearXNG-Instanz. Leer = http://localhost:8080.",
|
||||
webSearchApiKeySignup: "(Schlüssel holen)",
|
||||
webSearchApiKeyClear: "Löschen",
|
||||
webSearchApiKey: {
|
||||
metaso: "Metaso API-Key",
|
||||
baidu: "Baidu API-Key",
|
||||
tavily: "Tavily API-Key",
|
||||
perplexity: "Perplexity API-Key",
|
||||
exa: "Exa API-Key",
|
||||
brave: "Brave API-Key",
|
||||
ollama: "Ollama API-Key",
|
||||
},
|
||||
baseUrl: "DeepSeek-Basis-URL",
|
||||
baseUrlHint:
|
||||
"Nur bei Verwendung eines Proxys überschreiben. Leer = offizieller Endpunkt. Neustart erforderlich.",
|
||||
workspace: "Arbeitsbereich",
|
||||
workspaceHint:
|
||||
"Root-Verzeichnis, in dem Agent-Tools arbeiten. Wechseln speichert in der Konfiguration und lädt Tools neu.",
|
||||
workspaceChange: "Ändern…",
|
||||
editor: "Editor",
|
||||
editorHint:
|
||||
"Wird beim Klicken auf einen Dateilink/-pfad verwendet. Systemstandard = OS-Dateizuordnung. Andere müssen im PATH sein.",
|
||||
editorSystem: "Systemstandard",
|
||||
editorCustom: "benutzerdefiniert",
|
||||
editorPlaceholder: 'z.B. "code", "subl" oder vollständiger Pfad zur ausführbaren Datei',
|
||||
editorDefault: "(Standard)",
|
||||
apiKey: "API-Schlüssel",
|
||||
apiKeyHint: "DeepSeek-Schlüssel — gespeichert in ~/.reasonix/config.json (0600).",
|
||||
apiKeyNotSet: "(nicht gesetzt)",
|
||||
apiKeyCancel: "Abbrechen",
|
||||
apiKeySave: "Speichern",
|
||||
apiKeyShow: "anzeigen",
|
||||
apiKeyHide: "verbergen",
|
||||
environment: "Umgebung",
|
||||
model: "Modell",
|
||||
pageGeneralLabel: "Allgemein",
|
||||
pageGeneralDesc: "Darstellung, Sprache, Verhalten",
|
||||
pageModelsLabel: "Modelle",
|
||||
pageModelsDesc: "Standardmodell und Sampling-Parameter",
|
||||
pageMcpLabel: "MCP-Server",
|
||||
pageMcpDesc: "MCP-Protokoll-Tool-Server verwalten",
|
||||
pageSkillsLabel: "Skills",
|
||||
pageSkillsDesc: "Wiederverwendbare Prompt-Sets für /-Befehle",
|
||||
pageMemoryLabel: "Memory",
|
||||
pageMemoryDesc: "CLAUDE.md / AGENTS.md-Injektion",
|
||||
pageRulesLabel: "Genehmigungsregeln",
|
||||
pageRulesDesc: "Auto-genehmigen, ablehnen, Befehlsmuster bestätigen",
|
||||
pageBillingLabel: "Konto & Abrechnung",
|
||||
pageBillingDesc: "Guthaben, Nutzung und Rechnungen",
|
||||
pageShortcutsLabel: "Kürzel",
|
||||
pageShortcutsDesc: "Tastaturkürzel-Übersicht",
|
||||
appearanceSection: "Darstellung",
|
||||
workspaceSection: "Arbeitsbereich",
|
||||
behaviorSection: "Verhalten",
|
||||
apiSection: "DeepSeek API",
|
||||
qqSection: "QQ-Kanal",
|
||||
qqTitle: "QQ-Bot-Integration",
|
||||
qqConnected: "Verbunden",
|
||||
qqConnecting: "Verbinde",
|
||||
qqEnabled: "Aktiviert",
|
||||
qqFailed: "Fehlgeschlagen",
|
||||
qqDisconnected: "Getrennt",
|
||||
qqSummaryMissing: "Anmeldedaten noch nicht konfiguriert",
|
||||
qqSummaryAppId: "App-ID {appId}",
|
||||
qqSummaryDetail: "{appId} · {environment} · {access}",
|
||||
qqConnect: "Verbinden",
|
||||
qqConfigure: "Konfigurieren…",
|
||||
qqDisconnect: "Trennen",
|
||||
qqConfigureTitle: "QQ-Kanal konfigurieren",
|
||||
qqConfigureHint: "Registriere einen QQ-Bot zum Empfangen und Beantworten von Nachrichten.",
|
||||
qqEnvironment: "QQ-Umgebung",
|
||||
qqEnvironmentHint:
|
||||
"Wähle die QQ-Open-Platform-Umgebung für diesen Bot. Verwende Sandbox zum Testen und Production für den Live-Betrieb.",
|
||||
qqBack: "Zurück",
|
||||
qqAppId: "App-ID",
|
||||
qqAppSecret: "App-Secret",
|
||||
qqSandbox: "Sandbox",
|
||||
qqProduction: "Produktion",
|
||||
qqAccess: "Zugriff",
|
||||
qqAccessOwner: "Besitzer {openId}",
|
||||
qqAccessOwnerWithAllowlist: "Besitzer {openId} + Allowlist {count}",
|
||||
qqAccessAllowlist: "Allowlist {count}",
|
||||
qqAccessRuntime: "Erstabsender (nur zur Laufzeit, {openId})",
|
||||
qqAccessOpen: "Offen (ungebunden)",
|
||||
qqApplyLabel: "QQ-Bot beantragen",
|
||||
qqApplyAction: "Antragsseite öffnen",
|
||||
qqSave: "Speichern",
|
||||
qqSaveAndConnect: "Speichern und verbinden",
|
||||
mcpSection: "Konfiguriert",
|
||||
currentWorkspace: "Aktueller Arbeitsbereich",
|
||||
notSelected: "(nicht ausgewählt)",
|
||||
apiKeySet: "Gesetzt · {prefix}…",
|
||||
defaultModelCurrent: "Standardmodell · aktuell {model}",
|
||||
modelCustom: "Benutzerdefinierte Modell-ID",
|
||||
modelCustomHint:
|
||||
"Jede OpenAI-kompatible ID, die dein Endpunkt bereitstellt (vLLM, Ollama, Together, …).",
|
||||
modelCustomActive: "Läuft aktuell auf benutzerdefinierter ID: {model}",
|
||||
contextTokensLabel: "Kontextfenstergröße",
|
||||
contextTokensHint: "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.",
|
||||
contextTokensPlaceholder: "Automatisch",
|
||||
effortSection: "Reasoning-Effort",
|
||||
ctxWindow: "Kontext",
|
||||
maxOutput: "Ausgabe",
|
||||
mcpConfigured: "Konfiguriert · {count}",
|
||||
mcpBridged: "· Verbunden",
|
||||
mcpNotBridged: "· Wird auf die aktuelle Desktop-Sitzung angewendet",
|
||||
mcpStatusConnected: "· Verbunden {connected}/{total}",
|
||||
mcpStatusConnecting:
|
||||
"· Verbinde {connected}/{total}; Konfigurationsänderungen werden automatisch angewendet",
|
||||
mcpStatusFailed: "· Verbunden {connected}/{total}; {failed} fehlgeschlagen",
|
||||
mcpStatusDisabled: "· {disabled}/{total} deaktiviert",
|
||||
mcpStatusPending: "· Wartet auf Anwendung",
|
||||
mcpStatus: {
|
||||
configured: "Wartet",
|
||||
handshake: "Verbindet",
|
||||
connected: "Verbunden",
|
||||
failed: "Fehlgeschlagen",
|
||||
disabled: "Deaktiviert",
|
||||
},
|
||||
mcpFailure: {
|
||||
auth: "Authentifizierung fehlgeschlagen · Authorization oder x-api-key in headers konfigurieren",
|
||||
missingToken: "Bearer-Token fehlt · Authorization in headers konfigurieren",
|
||||
command: "Befehl nicht verfügbar · command/args oder lokale Abhängigkeiten prüfen",
|
||||
network: "Netzwerkfehler · Endpoint, Proxy oder Verbindung prüfen",
|
||||
unknown: "Verbindung fehlgeschlagen · Details für den Originalfehler öffnen",
|
||||
},
|
||||
mcpEmpty: "Noch keine MCP-Server konfiguriert. Gib unten eine Spec ein, um einen hinzuzufügen.",
|
||||
mcpRemove: "Entfernen",
|
||||
mcpRetry: "Erneut versuchen",
|
||||
mcpDetail: "Details",
|
||||
mcpHideDetail: "Ausblenden",
|
||||
mcpDetailTitle: "Originalfehler",
|
||||
mcpEditConfig: "Bearbeiten",
|
||||
parseError: "Parse fehlgeschlagen: {error}",
|
||||
mcpAddSection: "Server hinzufügen",
|
||||
mcpSpecLabel: "Spec-String",
|
||||
mcpSpecFormat: "Format: <code>name=befehl args</code> oder <code>name=https://host/sse</code>",
|
||||
mcpAdd: "Hinzufügen",
|
||||
mcpImport: "Aus cc-switch importieren",
|
||||
mcpImporting: "Importiere…",
|
||||
mcpEdit: "Bearbeiten",
|
||||
mcpEditTitle: "MCP bearbeiten",
|
||||
mcpEditBack: "Zurück",
|
||||
mcpEditName: "MCP-Titel (eindeutig)",
|
||||
mcpEditTransport: "Transport",
|
||||
mcpEditJson: "Vollständige JSON-Konfiguration",
|
||||
mcpEditFormat: "Formatieren",
|
||||
mcpEditSave: "Speichern",
|
||||
mcpEditNameRequired: "MCP-Titel ist erforderlich",
|
||||
mcpEditJsonObjectRequired: "JSON-Konfiguration muss ein Objekt sein",
|
||||
mcpEditCommandRequired: "stdio-Konfiguration benötigt command",
|
||||
mcpEditUrlRequired: "Remote-MCP-Konfiguration benötigt url",
|
||||
skillsLoaded: "Geladen · {count} · aufrufbar über /-Befehle",
|
||||
skillsEmpty:
|
||||
"Keine Skills verfügbar. Erstelle SKILL.md unter ~/.reasonix/skills/ oder .reasonix/skills/ in deinem Projekt-Root.",
|
||||
subagentModelFlash: "flash",
|
||||
subagentModelPro: "pro",
|
||||
subagentModelHint:
|
||||
"Modell, das verwendet wird, wenn dieser Skill seinen isolierten Subagenten startet",
|
||||
memorySection: "Langzeit-Memory",
|
||||
memoryDesc:
|
||||
"Aktuelle Version verwendet die eingebaute CLAUDE.md / AGENTS.md-Injektion. Projekt-Level-Memory wird auf Kernel-Ebene verwaltet.",
|
||||
appMode: "Anwendungsmodus",
|
||||
ruleAutoApprovalSection: "Auto-genehmigte Befehle",
|
||||
ruleAutoApprovalHint:
|
||||
"Klicke in der ApprovalCard auf »Immer erlauben«, um einen Befehlspräfix zur Whitelist hinzuzufügen; nachfolgende passende Befehle werden nicht mehr nachfragen.",
|
||||
balanceLabel: "Wallet-Guthaben",
|
||||
balanceLow: "Unzureichend",
|
||||
balanceAvailable: "Verfügbar",
|
||||
sessionCost: "Diese Sitzung kostet",
|
||||
cacheHitRate: "Cache-Trefferquote",
|
||||
shortcutNewChat: "Neuer Chat",
|
||||
shortcutNewTab: "Neuer Tab",
|
||||
shortcutCloseTab: "Tab schließen",
|
||||
shortcutCommandPalette: "Befehlsübersicht",
|
||||
shortcutFocusComposer: "Composer fokussieren",
|
||||
shortcutSwitchTab: "Tab wechseln",
|
||||
shortcutAbort: "Streaming abbrechen",
|
||||
shortcutSettings: "Einstellungen",
|
||||
},
|
||||
modal: {
|
||||
...en.modal,
|
||||
planFeedbackPlaceholder: "Welche Probleme hat der Plan? Was muss verbessert werden?",
|
||||
choiceCustomPlaceholder: "Wo kann der Plan weiter verfeinert werden?",
|
||||
},
|
||||
plan: {
|
||||
...en.plan,
|
||||
activeTitle: "Aktiver Plan",
|
||||
progress: "{done} / {total} Schritte",
|
||||
progressNoTotal: "{done} Schritte",
|
||||
expand: "erweitern",
|
||||
collapse: "einklappen",
|
||||
dismiss: "Plan verwerfen",
|
||||
riskLow: "niedrig",
|
||||
riskMed: "mittel",
|
||||
riskHigh: "hoch",
|
||||
},
|
||||
editMode: {
|
||||
...en.editMode,
|
||||
label: "Edit-Gate",
|
||||
plan: "Plan",
|
||||
review: "Review",
|
||||
auto: "Auto",
|
||||
yolo: "YOLO",
|
||||
planDesc: "schreibgeschützt · keine Edits, keine Shell",
|
||||
reviewDesc: "jeden Schreibzugriff genehmigen",
|
||||
autoDesc: "Auto-Edits, Befehle genehmigen",
|
||||
yoloDesc: "alles automatisch",
|
||||
planHint: "Schreibgeschützt · alle Schreibwerkzeuge verweigern die Ausführung",
|
||||
reviewHint: "Jeder Tool-Aufruf benötigt Genehmigung",
|
||||
autoHint: "Befehle, die der Allowlist entsprechen, werden auto-genehmigt",
|
||||
yoloHint: "Alles auto-genehmigt · Mit Vorsicht verwenden",
|
||||
},
|
||||
markdown: {
|
||||
...en.markdown,
|
||||
filePillTitle: "Klicken zum Öffnen · Rechtsklick zum Kopieren",
|
||||
externalLinkTitle: "{href} im Browser öffnen",
|
||||
localLinkTitle: "{href} öffnen",
|
||||
copy: "kopieren",
|
||||
copied: "kopiert",
|
||||
},
|
||||
effort: {
|
||||
...en.effort,
|
||||
lowDesc: "am schnellsten — minimales Reasoning",
|
||||
mediumDesc: "ausgewogen",
|
||||
highDesc: "Standard — sicher für vLLM / Azure",
|
||||
maxDesc: "DeepSeek-Erweiterung; von stock OpenAI / vLLM abgelehnt",
|
||||
},
|
||||
checkpoint: {
|
||||
...en.checkpoint,
|
||||
title: "Schritt abgeschlossen",
|
||||
progress: "{done} / {total}",
|
||||
notesLabel: "Notizen",
|
||||
continue: "Fortsetzen",
|
||||
revise: "Überarbeiten…",
|
||||
stop: "Stopp",
|
||||
revisePlaceholder:
|
||||
"Was muss sich vor dem nächsten Schritt ändern? (leer lassen zum Fortfahren)",
|
||||
sendRevise: "Überarbeitung senden",
|
||||
},
|
||||
revision: {
|
||||
...en.revision,
|
||||
title: "Plan-Überarbeitung",
|
||||
subtitle: "Modell möchte die verbleibenden Schritte ersetzen",
|
||||
remainingHeading: "Neue verbleibende Schritte",
|
||||
accept: "Akzeptieren",
|
||||
reject: "Ablehnen",
|
||||
cancel: "Abbrechen",
|
||||
},
|
||||
app: {
|
||||
...en.app,
|
||||
toast: {
|
||||
...en.app.toast,
|
||||
newSession: "Neue Sitzung erstellt",
|
||||
cleared: "Geleert",
|
||||
copied: "Kopiert",
|
||||
copiedMd: "Als Markdown kopiert",
|
||||
exportedMd: "Als Markdown-Datei exportiert",
|
||||
exportFailed: "Export fehlgeschlagen: {error}",
|
||||
exportDialogTitle: "Unterhaltung exportieren",
|
||||
emptySession: "Sitzung ist leer",
|
||||
langSwitched: "Gewechselt zu {lang}",
|
||||
modelSwitched: "Modell → {model}",
|
||||
effortSwitched: "Effort → {effort}",
|
||||
modeSwitched: "Modus: {mode}",
|
||||
taskComplete: "Aufgabe abgeschlossen",
|
||||
},
|
||||
cmd: {
|
||||
...en.app.cmd,
|
||||
newSession: "Neue Sitzung",
|
||||
clearChat: "Anzeige leeren",
|
||||
abort: "Streaming stoppen",
|
||||
copyLast: "Letzte Antwort kopieren",
|
||||
switchModel: "Modell wechseln",
|
||||
mcp: "MCP-Server-Einstellungen öffnen",
|
||||
mcpStatus: "MCP-Verbindungsstatus anzeigen",
|
||||
mcpRetry: "Fehlgeschlagene MCP-Verbindungen erneut versuchen",
|
||||
mcpImport: "MCP-Server aus cc-switch importieren",
|
||||
searchEngine: "Suchmaschine + API-Keys konfigurieren",
|
||||
skill: "Skills auflisten / verwalten (Projekt + Custom + Global + Eingebaut)",
|
||||
setMode: "Modus auf {mode} setzen",
|
||||
setEffort: "Reasoning-Effort auf {effort} setzen",
|
||||
toggleTheme: "Theme wechseln",
|
||||
toggleCurrency: "Währung wechseln (CNY / USD)",
|
||||
toggleLang: "Sprache wechseln (CN / EN)",
|
||||
exportMd: "Sitzung als Markdown kopieren",
|
||||
help: "Alle Befehle anzeigen",
|
||||
feedback: "Feedback auf GitHub senden (öffnet Browser)",
|
||||
compact: "Ältere Turns in eine Zusammenfassung falten (Kontext sparen)",
|
||||
retry: "Letzte Nachricht kürzen und erneut senden",
|
||||
btw: "Eine Randfrage stellen (nie zum Kontext hinzugefügt)",
|
||||
},
|
||||
skill: {
|
||||
...en.app.skill,
|
||||
generic: "{scope} Skill · {runAs}",
|
||||
scope: {
|
||||
...en.app.skill.scope,
|
||||
builtin: "Eingebaut",
|
||||
global: "Global",
|
||||
project: "Projekt",
|
||||
},
|
||||
runAs: {
|
||||
inline: "inline",
|
||||
subagent: "subagent",
|
||||
},
|
||||
},
|
||||
yolo: {
|
||||
...en.app.yolo,
|
||||
banner1: "Alle Tool-Aufrufe, Shell-Befehle und Datei-Edits werden ",
|
||||
bannerBold: "auto-genehmigt",
|
||||
banner2: " ohne Nachfrage.",
|
||||
switchBack: "Zu Review wechseln",
|
||||
toast: "Alle Operationen werden ohne Nachfrage auto-genehmigt",
|
||||
},
|
||||
btwUsage: "▸ /btw <Frage> — eine Randfrage stellen, ohne den Gesprächskontext zu belasten.",
|
||||
errorLabel: "Fehler",
|
||||
warningLabel: "Warnung",
|
||||
dismissError: "Schließen",
|
||||
jumpToBottom: "Nach unten springen",
|
||||
splashSubtitle: "DeepSeek-Agenten",
|
||||
connecting: "Verbinde mit reasonix core...",
|
||||
startupFailedTitle: "Reasonix konnte nicht starten",
|
||||
startupFailedMessage:
|
||||
"Das Desktop-Backend ist fehlgeschlagen, bevor ein Arbeitsbereich geöffnet wurde.",
|
||||
startupFailedRetry: "Erneut versuchen",
|
||||
startupFailedUnknown: "Unbekannter Startfehler",
|
||||
langZH: "Chinesisch",
|
||||
langEN: "Englisch",
|
||||
titlebar: {
|
||||
...en.app.titlebar,
|
||||
sidebar: "Seitenleiste (⌘B)",
|
||||
contextPanel: "Kontext-Panel",
|
||||
more: "Mehr",
|
||||
commandPalette: "Befehlsübersicht",
|
||||
copyMd: "Unterhaltung als Markdown kopieren",
|
||||
exportMd: "Markdown exportieren",
|
||||
clearChat: "Bildschirm leeren",
|
||||
settings: "Einstellungen",
|
||||
windowControls: "Fenstersteuerung",
|
||||
close: "Schließen",
|
||||
minimize: "Minimieren",
|
||||
maximize: "Maximieren",
|
||||
restore: "Wiederherstellen",
|
||||
},
|
||||
tab: {
|
||||
...en.app.tab,
|
||||
newTabTitle: "Neuer Tab ⌘T",
|
||||
newTab: "Neuer Tab",
|
||||
},
|
||||
header: {
|
||||
...en.app.header,
|
||||
noWorkspace: "Kein Arbeitsbereich ausgewählt",
|
||||
running: "Läuft",
|
||||
clickToSelect: "Klicken, um Arbeitsbereich auszuwählen",
|
||||
copy: "Kopieren",
|
||||
copyMd: "Unterhaltung als Markdown kopieren",
|
||||
export: "Exportieren",
|
||||
exportMd: "Als Markdown-Datei exportieren",
|
||||
newChat: "Neuer Chat",
|
||||
abort: "Stopp",
|
||||
},
|
||||
empty: {
|
||||
...en.app.empty,
|
||||
welcome: "Willkommen bei Reasonix",
|
||||
currentWorkspace: "Aktueller Arbeitsbereich: ",
|
||||
selectWorkspace: "Wähle zuerst einen Arbeitsbereich aus der oberen Leiste",
|
||||
suggestion0: "Meine letzten Commit-Änderungen überprüfen",
|
||||
suggestion1: "Alle TypeScript-Fehler in der aktuellen Datei beheben",
|
||||
suggestion2: "README ins Chinesische/Englische übersetzen",
|
||||
suggestion3: "CHANGELOG für dieses Repo generieren",
|
||||
},
|
||||
setup: {
|
||||
...en.app.setup,
|
||||
welcome: "Willkommen bei Reasonix",
|
||||
description:
|
||||
"Die Ersteinrichtung erfordert einen DeepSeek-API-Schlüssel und ein Arbeitsbereichsverzeichnis. Der Schlüssel wird nur lokal gespeichert.",
|
||||
workspace: "Arbeitsbereich",
|
||||
notSelected: "Nicht ausgewählt",
|
||||
choose: "Wählen…",
|
||||
saveAndStart: "Speichern & starten",
|
||||
},
|
||||
update: {
|
||||
...en.app.update,
|
||||
available: "Neue Version verfügbar · {current} → {latest}",
|
||||
installing: "Installiere…",
|
||||
downloading: "Lade herunter · {downloaded} / {total} ({pct}%)",
|
||||
downloadingUnknown: "Lade herunter · {downloaded}",
|
||||
failed: "Installation fehlgeschlagen",
|
||||
clickToInstall: "Klicken zum Installieren & Neustarten",
|
||||
install: "Installieren",
|
||||
later: "Später",
|
||||
},
|
||||
exportUserLabel: "Du",
|
||||
exportReasoningSummary: "Reasoning",
|
||||
exportToolLabel: "Werkzeug",
|
||||
session: {
|
||||
...en.app.session,
|
||||
new: "{workspace} · Neue Sitzung",
|
||||
format: "Sitzung {month}-{day} {hour}:{minute}",
|
||||
},
|
||||
},
|
||||
planBadge: {
|
||||
...en.planBadge,
|
||||
failed: "Fehlgeschlagen",
|
||||
blocked: "Blockiert",
|
||||
running: "Läuft",
|
||||
done: "Erledigt",
|
||||
pending: "Ausstehend",
|
||||
},
|
||||
cards: {
|
||||
...en.cards,
|
||||
reasoningName: "Denken",
|
||||
reasoningComplete: "Abgeschlossen",
|
||||
compactionName: "Komprimierter Verlauf",
|
||||
compactionMeta: "{chars} Zeichen",
|
||||
shellAwaiting: "Wartet auf Freigabe",
|
||||
shellRunning: "Läuft",
|
||||
shellExecuteHint: "diesen Befehl ausführen",
|
||||
shellAlwaysAllow: "Immer erlauben",
|
||||
shellReject: "Ablehnen",
|
||||
shellRun: "Ausführen",
|
||||
diffApplyChanges: "Änderungen anwenden",
|
||||
diffDiscard: "Verwerfen",
|
||||
diffApply: "Anwenden",
|
||||
diffAwaiting: "Wartet auf Freigabe",
|
||||
errorName: "Fehler",
|
||||
searchName: "Websuche",
|
||||
subagentDone: "Erledigt",
|
||||
subagentFailed: "Fehlgeschlagen",
|
||||
subagentRunning: "Läuft",
|
||||
subagentDoneProgress: "erledigt",
|
||||
memoryName: "Memory",
|
||||
memoryCountSuffix: "Einträge",
|
||||
checkpointRewind: "Hier zurückspulen",
|
||||
planDefaultTitle: "Plan",
|
||||
streaming: "streamt…",
|
||||
failed: "fehlgeschlagen",
|
||||
done: "fertig",
|
||||
running: "Läuft",
|
||||
error: "Fehler",
|
||||
result: "Ergebnis",
|
||||
hits: "Treffer",
|
||||
applied: "angewendet",
|
||||
cacheHit: "Cache-Treffer",
|
||||
prompt: "Prompt",
|
||||
reasoning: "Reasoning",
|
||||
output: "Ausgabe",
|
||||
cost: "Kosten",
|
||||
elapsed: "verstrichen",
|
||||
},
|
||||
statusbar: {
|
||||
...en.statusbar,
|
||||
offline: "Offline",
|
||||
busy: "Läuft",
|
||||
online: "Online",
|
||||
jobs: "Jobs",
|
||||
jobsTip: "Hintergrundprozesse · ⌘J",
|
||||
cacheHit: "Cache-Treffer",
|
||||
cache: "Cache",
|
||||
tokens: "Tokens",
|
||||
thisTurn: "dieser Turn",
|
||||
switchWorkspace: "Arbeitsbereich wechseln · {workspace}",
|
||||
switchCurrency: "Währung wechseln (CNY / USD)",
|
||||
balance: "Guthaben",
|
||||
switchTheme: "Theme wechseln",
|
||||
themeDark: "dunkel",
|
||||
themeLight: "hell",
|
||||
themeStyleGraphite: "Graphit",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstein",
|
||||
themeStylePorcelain: "Porzellan",
|
||||
themeStyleLinen: "Leinen",
|
||||
themeStyleGlacier: "Gletscher",
|
||||
themeStyleMidnight: "Mitternacht",
|
||||
},
|
||||
jobs: {
|
||||
...en.jobs,
|
||||
title: "Hintergrund-Jobs",
|
||||
running: "läuft",
|
||||
exited: "beendet",
|
||||
empty: "Noch keine Hintergrund-Jobs in diesem Arbeitsbereich.",
|
||||
stopAll: "Alle stoppen",
|
||||
stopAllTip: "SIGTERM an jeden laufenden Job senden",
|
||||
stop: "Stopp",
|
||||
stopOne: "Diesen Job stoppen",
|
||||
kbToggle: "umschalten",
|
||||
kbClose: "schließen",
|
||||
},
|
||||
contextPanel: {
|
||||
...en.contextPanel,
|
||||
reservedKey: "reserviert",
|
||||
usedKey: "verwendet",
|
||||
filesTab: "Dateien",
|
||||
toolsTab: "Tools",
|
||||
memoryTab: "Memory",
|
||||
rulesTab: "Regeln",
|
||||
contextTokens: "Kontext · Tokens",
|
||||
cacheKey: "Cache",
|
||||
freeKey: "frei",
|
||||
filesTitle: "Dateien im Kontext",
|
||||
filesCount: "{count} Dateien",
|
||||
fileModified: "geändert",
|
||||
fileInContext: "im Kontext",
|
||||
openFile: "Datei öffnen: {path}",
|
||||
copyPath: "Pfad kopieren: {path}",
|
||||
mcpTitle: "MCP-Server",
|
||||
mcpReadyAll: "{count} bereit",
|
||||
mcpReadySome: "{ready}/{count} bereit",
|
||||
mcpEmpty: "Keine MCP-Server konfiguriert",
|
||||
mcpTools: "{count} Tools",
|
||||
mcpReady: "bereit",
|
||||
mcpConnecting: "verbinde",
|
||||
mcpDisabled: "deaktiviert",
|
||||
mcpFailed: "fehlgeschlagen",
|
||||
mcpConfigured: "konfiguriert",
|
||||
memoryTitle: "Langzeit-Memory",
|
||||
itemCount: "{count} Einträge",
|
||||
scopeProject: "Projekt",
|
||||
scopeGlobal: "Global",
|
||||
autoApproveTitle: "Auto-Genehmigung",
|
||||
ruleYolo: "YOLO-Modus · alle Tool-Aufrufe auto-genehmigt",
|
||||
ruleReadOnly: "Schreibgeschützte Tools auto-genehmigt",
|
||||
ruleShellAllowlist: "Shell-Befehle, die der Allowlist entsprechen, auto-genehmigt",
|
||||
ruleWritesAsk: "Schreibzugriffe und unbekannte Shell-Befehle erfordern Bestätigung",
|
||||
ruleReview: "Review-Modus · jeder Tool-Aufruf erfordert Bestätigung",
|
||||
allow: "ERLAUBEN",
|
||||
ask: "FRAGEN",
|
||||
noFilesMsg: "Noch keine Dateien im Kontext.",
|
||||
noMemoriesMsg: "Noch keine Erinnerungen gespeichert.",
|
||||
},
|
||||
sidebarPanel: {
|
||||
...en.sidebarPanel,
|
||||
sessionTitle: "Sitzung {month}-{day} {hour}:{minute}",
|
||||
justNow: "gerade eben",
|
||||
minutesAgo: "vor {n}m",
|
||||
hoursAgo: "vor {n}h",
|
||||
daysAgo: "vor {n}T",
|
||||
weeksAgo: "vor {n}W",
|
||||
newChat: "Neuer Chat",
|
||||
commandPalette: "Befehlsübersicht",
|
||||
searchSessions: "Sitzungen durchsuchen…",
|
||||
recent: "Letzte",
|
||||
noSessions: "Noch keine Sitzungen",
|
||||
noMatches: "Keine Treffer",
|
||||
messageCount: "{count} Nachrichten",
|
||||
deleteSession: "Sitzung löschen",
|
||||
importSessions: "Sitzung importieren",
|
||||
importDetected: "Gefunden",
|
||||
importCustom: "Eigener Pfad",
|
||||
importFoundApps: "Gefundene Apps",
|
||||
importScanning: "Lokale Apps werden gesucht…",
|
||||
importSessionCount: "{count} Sitzungen · importiert alle",
|
||||
importNotFound: "Keine lokalen Sitzungen gefunden",
|
||||
importPrivacyHint: "Bestehende App-Einstellungen bleiben unverändert.",
|
||||
importResult: "{imported} Sitzung(en) importiert, {skipped} übersprungen, {failed} fehlgeschlagen.",
|
||||
continue: "Weiter",
|
||||
refresh: "Aktualisieren",
|
||||
importSource: "Quelle",
|
||||
importFromClaude: "Claude",
|
||||
importFromCodex: "Codex",
|
||||
importPath: "Pfad zur Sitzungsdatei",
|
||||
importName: "Sitzungsname",
|
||||
importNamePlaceholder: "Optionaler eigener Name",
|
||||
importConfirm: "Importieren",
|
||||
browse: "Durchsuchen",
|
||||
renameSession: "Sitzung umbenennen",
|
||||
renamePlaceholder: "Sitzungstitel",
|
||||
approvalRules: "Genehmigungsregeln",
|
||||
settings: "Einstellungen",
|
||||
cancel: "Abbrechen",
|
||||
delete: "Löschen",
|
||||
},
|
||||
extraCards: {
|
||||
...en.extraCards,
|
||||
sessionUsage: "Sitzungsnutzung",
|
||||
sessionCost: "Kosten {costLabel}",
|
||||
approve: "Genehmigen",
|
||||
reject: "Ablehnen",
|
||||
doctorTitle: "/doctor · Systemprüfung",
|
||||
balance: "Guthaben {balanceLabel}",
|
||||
prompt: "Prompt",
|
||||
reasoning: "Reasoning",
|
||||
output: "Ausgabe",
|
||||
cacheHit: "Cache-Treffer",
|
||||
cache: "Cache",
|
||||
contextWindow: "Kontextfenster",
|
||||
topToolsUsage: "Top-Tools — Nutzung",
|
||||
memoryUser: "BENUTZER · Einstellungen",
|
||||
memoryFeedback: "FEEDBACK · Korrekturen",
|
||||
memoryProject: "PROJEKT · Projektregeln",
|
||||
memoryReference: "REFERENZ · Referenzen",
|
||||
unknownKind: "# unbekannter Karten-Typ — Fallback-Rendering",
|
||||
},
|
||||
thread: {
|
||||
...en.thread,
|
||||
toolCalls: "{count} Tool-Aufrufe",
|
||||
oneToolCall: "1 Tool-Aufruf",
|
||||
keepSteps: "{n} verbleibende(n) Schritt(e) behalten",
|
||||
keepOriginal: "Original behalten",
|
||||
you: "Du",
|
||||
planRunning: "Plan läuft · Schritt {step} / {total}",
|
||||
collapse: "Einklappen",
|
||||
activePlan: "Aktiver Plan",
|
||||
risk: "Risiko",
|
||||
planStepCount: "{count} Schritt(e)",
|
||||
planConfirmationKind: "Planbestätigung",
|
||||
startPlan: "Plan starten",
|
||||
approve: "Genehmigen",
|
||||
cancel: "Abbrechen",
|
||||
refine: "Verfeinern",
|
||||
sendFeedback: "Senden",
|
||||
skipFeedback: "Überspringen",
|
||||
cancelFeedbackLabel: "Abbrechen — was ist falsch?",
|
||||
refineFeedbackLabel: "Verfeinern — was soll geändert werden?",
|
||||
checkpointKind: "Plan-Checkpoint",
|
||||
checkpointTitle: "Schritt {completed} / {total} abgeschlossen",
|
||||
checkpointSub: "Checkpoint · {completed} / {total}",
|
||||
continue: "Fortsetzen",
|
||||
stop: "Stopp",
|
||||
revise: "Überarbeiten",
|
||||
planRevisionKind: "Plan-Überarbeitung",
|
||||
rewritePlan: "Plan umschreiben",
|
||||
revisionMeta: "Grund · Laufzeiteinschränkung",
|
||||
approveRewrite: "Umschreibung genehmigen",
|
||||
shellConfirmationKind: "Shell-Bestätigung",
|
||||
runCommand: "Befehl ausführen",
|
||||
runBackgroundCommand: "Hintergrundbefehl ausführen",
|
||||
riskMedium: "Risiko · mittel · {kind}",
|
||||
execute: "Ausführen",
|
||||
reject: "Ablehnen",
|
||||
alwaysAllow: '"{prefix}" immer erlauben',
|
||||
pathAccessKind: "Pfadzugriff",
|
||||
read: "lesen",
|
||||
write: "schreiben",
|
||||
readPathOutsideSandbox: "Pfad außerhalb des Sandbox lesen",
|
||||
writePathOutsideSandbox: "Pfad außerhalb des Sandbox schreiben",
|
||||
allowRead: "Lesen erlauben",
|
||||
allowWrite: "Schreiben erlauben",
|
||||
alwaysAllowPrefix: "{prefix} immer erlauben",
|
||||
userChoiceKind: "Benutzerauswahl",
|
||||
optionCount: "{count} Optionen",
|
||||
subagent: "Subagent",
|
||||
copyMessage: "Diese Nachricht kopieren",
|
||||
editMessage: "Diese Nachricht bearbeiten und erneut senden",
|
||||
copyResponse: "Diese Antwort kopieren",
|
||||
},
|
||||
live: {
|
||||
...en.live,
|
||||
reasoning: "live · Reasoning",
|
||||
running: "läuft",
|
||||
you: "Du",
|
||||
secondsAgo: "vor {seconds}s",
|
||||
deliveredWaiting: "Zugestellt · warte auf Agenten",
|
||||
},
|
||||
panel: {
|
||||
...en.panel,
|
||||
renderError: "{panel} Panel-Rendering-Fehler",
|
||||
},
|
||||
notifications: {
|
||||
...en.notifications,
|
||||
approvalTitle: "Reasonix wartet auf Genehmigung",
|
||||
inputTitle: "Reasonix wartet auf Eingabe",
|
||||
planApprovalTitle: "Reasonix wartet auf Plan-Genehmigung",
|
||||
checkpointApprovalTitle: "Reasonix wartet auf Checkpoint-Genehmigung",
|
||||
revisionTitle: "Reasonix hat eine Plan-Überarbeitung vorgeschlagen",
|
||||
commandBody: "Befehl: {command}",
|
||||
readBody: "Lesen: {path}",
|
||||
writeBody: "Schreiben: {path}",
|
||||
turnCompleteTitle: "Reasonix-Aufgabe abgeschlossen",
|
||||
turnCompleteBody: "Die aktuelle Aufgabe ist abgeschlossen und bereit zur Überprüfung.",
|
||||
},
|
||||
};
|
||||
+150
-6
@@ -51,6 +51,7 @@ export const en = {
|
||||
searchPlaceholder: "Search recent paths…",
|
||||
empty: "No recent workspaces",
|
||||
browse: "Browse local…",
|
||||
removeRecent: "Remove from recent",
|
||||
},
|
||||
sidebar: {
|
||||
newChat: "New chat",
|
||||
@@ -65,6 +66,14 @@ export const en = {
|
||||
copiedMd: "Conversation copied as Markdown",
|
||||
newSession: "Started a new session",
|
||||
cleared: "UI cleared",
|
||||
mcpImported: "Imported {imported} MCP server(s) from cc-switch",
|
||||
mcpImportedWithSkipped:
|
||||
"Imported {imported} MCP server(s), skipped {skipped} duplicate name(s)",
|
||||
mcpImportNone: "No new MCP servers available to import",
|
||||
mcpImportFailed: "Failed to import cc-switch MCP servers: {error}",
|
||||
mcpStatusOpened: "Opened MCP status panel",
|
||||
mcpRetryNone: "No failed MCP servers to retry",
|
||||
mcpRetryQueued: "Retrying {count} failed MCP server(s)",
|
||||
aboutLine: "Reasonix v{version} · cache-first DeepSeek agent",
|
||||
},
|
||||
composer: {
|
||||
@@ -121,10 +130,18 @@ export const en = {
|
||||
themeStyleHint: "Choose the visual palette for the selected light or dark mode.",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleGraphiteDesc: "Original dark palette with neutral graphite panels.",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleEmberDesc: "Warm dark palette with a stronger Reasonix orange accent.",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleAuroraDesc: "Teal-green dark palette for a softer low-light workspace.",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStyleSandstoneDesc: "Original warm light palette.",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStylePorcelainDesc: "Clean Codex-like light palette with quiet contrast.",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleLinenDesc: "Editorial warm light palette with paper-like surfaces.",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleGlacierDesc: "Cool light palette with crisp blue accents.",
|
||||
themeStyleMidnight: "Midnight",
|
||||
themeStyleMidnightDesc: "Navy dark palette with cool blue surfaces.",
|
||||
fontScale: "Font scale",
|
||||
@@ -152,9 +169,14 @@ export const en = {
|
||||
editModeAuto: "auto",
|
||||
editModeYolo: "yolo",
|
||||
showSystemEvents: "System events",
|
||||
showSystemEventsHint: "Show quiet inline notices for context compaction, abort, and rate-limit pauses.",
|
||||
showSystemEventsHint:
|
||||
"Show quiet inline notices for context compaction, abort, and rate-limit pauses.",
|
||||
shown: "shown",
|
||||
hidden: "hidden",
|
||||
desktopCloseBehavior: "Window close",
|
||||
desktopCloseBehaviorHint: "Close quits by default. Hide to tray keeps running sessions alive.",
|
||||
closeToQuit: "quit",
|
||||
closeToTray: "hide to tray",
|
||||
editModeReviewDesc: "approve every write",
|
||||
editModeAutoDesc: "approve commands, auto edits",
|
||||
editModeYoloDesc: "approve nothing — full auto",
|
||||
@@ -163,12 +185,30 @@ export const en = {
|
||||
budgetPlaceholder: "no cap",
|
||||
webSearchEngine: "search engine",
|
||||
webSearchEngineBing: "bing — default, works from CN without proxy",
|
||||
webSearchEngineBingIntl: "bing-intl — international index via www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — self-hosted (set endpoint via /se searxng <url>)",
|
||||
webSearchEngineMetaso: "metaso — 100/day free (CN-friendly, free key at metaso.cn)",
|
||||
webSearchEngineBaidu:
|
||||
"baidu — Baidu AI Search, 1500/mo free in Baidu docs (set BAIDU_API_KEY or QIANFAN_API_KEY)",
|
||||
webSearchEngineTavily: "tavily — 1000/mo free (set TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI-native (set PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI-native 1000/mo free (set EXA_API_KEY)",
|
||||
webSearchEngineBrave: "brave — independent index, 2000/mo free (set BRAVE_SEARCH_API_KEY)",
|
||||
webSearchEngineOllama: "ollama — Ollama cloud web search (set OLLAMA_API_KEY)",
|
||||
webSearchEngineNote: "applies to the next web_search call",
|
||||
webSearchEndpoint: "SearXNG endpoint",
|
||||
webSearchEndpointHint: "Base URL of your SearXNG instance. Defaults to http://localhost:8080.",
|
||||
webSearchApiKeySignup: "(get key)",
|
||||
webSearchApiKeyClear: "Clear",
|
||||
webSearchApiKey: {
|
||||
metaso: "Metaso API key",
|
||||
baidu: "Baidu API key",
|
||||
tavily: "Tavily API key",
|
||||
perplexity: "Perplexity API key",
|
||||
exa: "Exa API key",
|
||||
brave: "Brave API key",
|
||||
ollama: "Ollama API key",
|
||||
},
|
||||
baseUrl: "DeepSeek base URL",
|
||||
baseUrlHint: "Override only if using a proxy. Empty = official endpoint. Restart required.",
|
||||
workspace: "Workspace",
|
||||
@@ -255,21 +295,61 @@ export const en = {
|
||||
modelCustom: "Custom model id",
|
||||
modelCustomHint: "Any OpenAI-compatible id your endpoint serves (vLLM, Ollama, Together, …).",
|
||||
modelCustomActive: "Currently running on a custom id: {model}",
|
||||
contextTokensLabel: "Context window size",
|
||||
contextTokensHint: "Override the prompt-side token cap for the current model (e.g. 1000000 for 1M). Leave empty to use the built-in default.",
|
||||
contextTokensPlaceholder: "auto",
|
||||
effortSection: "Reasoning effort",
|
||||
ctxWindow: "Context",
|
||||
maxOutput: "Output",
|
||||
// MCP
|
||||
mcpConfigured: "Configured · {count}",
|
||||
mcpBridged: "· Bridged",
|
||||
mcpNotBridged:
|
||||
"· Current desktop session not bridged — restart reasonix code (TUI) to take effect",
|
||||
mcpNotBridged: "· Applying to the current desktop session",
|
||||
mcpStatusConnected: "· Connected {connected}/{total}",
|
||||
mcpStatusConnecting: "· Connecting {connected}/{total}; config changes apply automatically",
|
||||
mcpStatusFailed: "· Connected {connected}/{total}; {failed} failed",
|
||||
mcpStatusDisabled: "· {disabled}/{total} disabled",
|
||||
mcpStatusPending: "· Waiting to apply",
|
||||
mcpStatus: {
|
||||
configured: "Waiting",
|
||||
handshake: "Connecting",
|
||||
connected: "Connected",
|
||||
failed: "Failed",
|
||||
disabled: "Disabled",
|
||||
},
|
||||
mcpFailure: {
|
||||
auth: "Authentication failed · configure Authorization or x-api-key in headers",
|
||||
missingToken: "Missing Bearer token · configure Authorization in headers",
|
||||
command: "Command unavailable · check command/args or local dependencies",
|
||||
network: "Network failed · check endpoint, proxy, or connectivity",
|
||||
unknown: "Connection failed · open details for the raw error",
|
||||
},
|
||||
mcpEmpty: "No MCP servers configured yet. Enter a spec below to add one.",
|
||||
mcpRemove: "Remove",
|
||||
mcpRetry: "Retry",
|
||||
mcpDetail: "Details",
|
||||
mcpHideDetail: "Hide",
|
||||
mcpDetailTitle: "Raw error",
|
||||
mcpEditConfig: "Edit",
|
||||
parseError: "Parse failed: {error}",
|
||||
mcpAddSection: "Add server",
|
||||
mcpSpecLabel: "Spec string",
|
||||
mcpSpecFormat: "Format: <code>name=command args</code> or <code>name=https://host/sse</code>",
|
||||
mcpAdd: "Add",
|
||||
mcpImport: "Import from cc-switch",
|
||||
mcpImporting: "Importing…",
|
||||
mcpEdit: "Edit",
|
||||
mcpEditTitle: "Edit MCP",
|
||||
mcpEditBack: "Back",
|
||||
mcpEditName: "MCP title (unique)",
|
||||
mcpEditTransport: "Transport",
|
||||
mcpEditJson: "Full JSON config",
|
||||
mcpEditFormat: "Format",
|
||||
mcpEditSave: "Save",
|
||||
mcpEditNameRequired: "MCP title is required",
|
||||
mcpEditJsonObjectRequired: "JSON config must be an object",
|
||||
mcpEditCommandRequired: "stdio config requires command",
|
||||
mcpEditUrlRequired: "Remote MCP config requires url",
|
||||
// Skills
|
||||
skillsLoaded: "Loaded · {count} · invoke via / commands",
|
||||
skillsEmpty:
|
||||
@@ -303,8 +383,8 @@ export const en = {
|
||||
shortcutSettings: "Settings",
|
||||
},
|
||||
modal: {
|
||||
planFeedbackPlaceholder: "Optional feedback — write here if you want changes",
|
||||
choiceCustomPlaceholder: "Or type your own answer…",
|
||||
planFeedbackPlaceholder: "What problems does the plan have? What needs to be improved?",
|
||||
choiceCustomPlaceholder: "Where can the plan be further refined?",
|
||||
},
|
||||
plan: {
|
||||
activeTitle: "Active plan",
|
||||
@@ -319,12 +399,15 @@ export const en = {
|
||||
},
|
||||
editMode: {
|
||||
label: "Edit gate",
|
||||
plan: "plan",
|
||||
review: "review",
|
||||
auto: "auto",
|
||||
yolo: "yolo",
|
||||
planDesc: "read-only · no edits, no shell",
|
||||
reviewDesc: "approve every write",
|
||||
autoDesc: "auto edits, approve commands",
|
||||
yoloDesc: "auto everything",
|
||||
planHint: "Read-only mode · all write tools refuse to dispatch",
|
||||
reviewHint: "Every tool call needs approval",
|
||||
autoHint: "Commands matching the allowlist are auto-approved",
|
||||
yoloHint: "All auto-approved · Use with caution",
|
||||
@@ -382,6 +465,14 @@ export const en = {
|
||||
abort: "Stop streaming output",
|
||||
copyLast: "Copy last reply",
|
||||
switchModel: "Switch model",
|
||||
mcp: "Open MCP server settings",
|
||||
mcpStatus: "Show MCP connection status",
|
||||
mcpRetry: "Retry failed MCP connections",
|
||||
mcpImport: "Import MCP servers from cc-switch",
|
||||
searchEngine: "Configure web search engine + API keys",
|
||||
skill: "List and manage user skills (project + custom + global + builtin)",
|
||||
setMode: "Set mode to {mode}",
|
||||
setEffort: "Set reasoning effort to {effort}",
|
||||
toggleTheme: "Toggle theme",
|
||||
toggleCurrency: "Toggle currency (CNY / USD)",
|
||||
toggleLang: "Toggle language (CN / EN)",
|
||||
@@ -418,6 +509,10 @@ export const en = {
|
||||
jumpToBottom: "Jump to bottom",
|
||||
splashSubtitle: "DeepSeek Agents",
|
||||
connecting: "Connecting to reasonix core…",
|
||||
startupFailedTitle: "Reasonix could not start",
|
||||
startupFailedMessage: "The desktop backend failed before opening a workspace.",
|
||||
startupFailedRetry: "Retry",
|
||||
startupFailedUnknown: "Unknown startup error",
|
||||
langZH: "Chinese",
|
||||
langEN: "English",
|
||||
titlebar: {
|
||||
@@ -550,10 +645,20 @@ export const en = {
|
||||
themeDark: "dark",
|
||||
themeLight: "light",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleMidnight: "Midnight",
|
||||
},
|
||||
mcpCard: {
|
||||
showTools: "Tools",
|
||||
hideTools: "Hide tools",
|
||||
availableTools: "{count} available tools",
|
||||
noTools: "Connected, but this server did not expose callable tools",
|
||||
},
|
||||
jobs: {
|
||||
title: "Background jobs",
|
||||
running: "running",
|
||||
@@ -592,6 +697,16 @@ export const en = {
|
||||
mcpDisabled: "disabled",
|
||||
mcpFailed: "failed",
|
||||
mcpConfigured: "configured",
|
||||
mcpHealthTotal: "MCP {count}",
|
||||
mcpHealthReady: "ready {count}",
|
||||
mcpHealthFailed: "failed {count}",
|
||||
mcpHealthTools: "tools {count}",
|
||||
mcpFilterAll: "All",
|
||||
mcpFilterReady: "Ready",
|
||||
mcpFilterFailed: "Failed",
|
||||
mcpFilterEmpty: "No servers in this filter",
|
||||
mcpRetryAll: "Retry failed",
|
||||
mcpSettings: "Settings",
|
||||
memoryTitle: "Long-term memory",
|
||||
itemCount: "{count} items",
|
||||
scopeProject: "project",
|
||||
@@ -616,12 +731,35 @@ export const en = {
|
||||
weeksAgo: "{n}w ago",
|
||||
newChat: "New chat",
|
||||
commandPalette: "Command palette",
|
||||
workspace: "Workspace",
|
||||
noWorkspace: "No workspace",
|
||||
pickWorkspace: "Pick workspace",
|
||||
switchWorkspace: "Switch workspace · {workspace}",
|
||||
searchSessions: "Search sessions…",
|
||||
recent: "Recent",
|
||||
noSessions: "No sessions yet",
|
||||
noMatches: "No matches",
|
||||
messageCount: "{count} messages",
|
||||
deleteSession: "Delete session",
|
||||
importSessions: "Import session",
|
||||
importDetected: "Detected",
|
||||
importCustom: "Custom",
|
||||
importFoundApps: "Found apps",
|
||||
importScanning: "Scanning local apps…",
|
||||
importSessionCount: "{count} sessions · imports all",
|
||||
importNotFound: "No local sessions found",
|
||||
importPrivacyHint: "Your existing app settings are not changed.",
|
||||
importResult: "Imported {imported} session(s), skipped {skipped}, failed {failed}.",
|
||||
continue: "Continue",
|
||||
refresh: "Refresh",
|
||||
importSource: "Source",
|
||||
importFromClaude: "Claude",
|
||||
importFromCodex: "Codex",
|
||||
importPath: "Session file path",
|
||||
importName: "Session name",
|
||||
importNamePlaceholder: "Optional custom name",
|
||||
importConfirm: "Import",
|
||||
browse: "Browse",
|
||||
renameSession: "Rename session",
|
||||
renamePlaceholder: "Session title",
|
||||
approvalRules: "Approval rules",
|
||||
@@ -650,6 +788,8 @@ export const en = {
|
||||
unknownKind: "# unknown card kind — fallback render",
|
||||
},
|
||||
thread: {
|
||||
toolCalls: "{count} tool calls",
|
||||
oneToolCall: "1 tool call",
|
||||
keepSteps: "Keep {n} remaining step(s)",
|
||||
keepOriginal: "Keep original",
|
||||
you: "You",
|
||||
@@ -663,6 +803,10 @@ export const en = {
|
||||
approve: "Approve",
|
||||
cancel: "Cancel",
|
||||
refine: "Refine",
|
||||
sendFeedback: "Send",
|
||||
skipFeedback: "Skip",
|
||||
cancelFeedbackLabel: "Cancel — what went wrong?",
|
||||
refineFeedbackLabel: "Refine — what should change?",
|
||||
checkpointKind: "plan checkpoint",
|
||||
checkpointTitle: "step {completed} / {total} complete",
|
||||
checkpointSub: "checkpoint · {completed} / {total}",
|
||||
@@ -679,7 +823,7 @@ export const en = {
|
||||
riskMedium: "risk · medium · {kind}",
|
||||
execute: "Execute",
|
||||
reject: "Reject",
|
||||
alwaysAllow: "Always allow \"{prefix}\"",
|
||||
alwaysAllow: 'Always allow "{prefix}"',
|
||||
pathAccessKind: "path access",
|
||||
read: "read",
|
||||
write: "write",
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { ja } from "./ja";
|
||||
import { ru } from "./ru";
|
||||
import { zhCN } from "./zh-CN";
|
||||
|
||||
export type Lang = "en" | "zh-CN";
|
||||
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
|
||||
|
||||
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(["en", "zh-CN"]);
|
||||
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
|
||||
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
|
||||
const LANG_LABELS: Record<Lang, string> = {
|
||||
en: "English",
|
||||
"zh-CN": "简体中文",
|
||||
de: "Deutsch",
|
||||
ja: "日本語",
|
||||
ru: "Русский",
|
||||
};
|
||||
const STORAGE_KEY = "reasonix.lang";
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -18,7 +29,12 @@ function detectDefault(): Lang {
|
||||
/* private mode */
|
||||
}
|
||||
const nav = typeof navigator !== "undefined" ? navigator.language : "";
|
||||
return nav.toLowerCase().startsWith("zh") ? "zh-CN" : "en";
|
||||
const lower = nav.toLowerCase();
|
||||
if (lower.startsWith("zh")) return "zh-CN";
|
||||
if (lower.startsWith("de")) return "de";
|
||||
if (lower.startsWith("ja")) return "ja";
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
return "en";
|
||||
}
|
||||
|
||||
let currentLang: Lang = detectDefault();
|
||||
@@ -38,6 +54,14 @@ export function getLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
export function getSupportedLangs(): readonly Lang[] {
|
||||
return SUPPORTED_LANGS;
|
||||
}
|
||||
|
||||
export function getLangLabel(lang: Lang): string {
|
||||
return LANG_LABELS[lang];
|
||||
}
|
||||
|
||||
export function setLang(lang: Lang): void {
|
||||
if (!SUPPORTED.has(lang) || lang === currentLang) return;
|
||||
currentLang = lang;
|
||||
@@ -63,7 +87,7 @@ export function useLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
const dicts = { en, "zh-CN": zhCN } as const;
|
||||
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
|
||||
|
||||
type Dict = typeof en;
|
||||
type Path<T, K extends keyof T = keyof T> = K extends string
|
||||
|
||||
@@ -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: "現在のタスクが完了し、レビュー可能です。",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const ru: typeof en = {
|
||||
...en,
|
||||
};
|
||||
+149
-5
@@ -53,6 +53,7 @@ export const zhCN: typeof en = {
|
||||
searchPlaceholder: "搜索最近路径…",
|
||||
empty: "暂无最近工作目录",
|
||||
browse: "浏览本地…",
|
||||
removeRecent: "移除此记录",
|
||||
},
|
||||
sidebar: {
|
||||
newChat: "新建会话",
|
||||
@@ -67,6 +68,13 @@ export const zhCN: typeof en = {
|
||||
copiedMd: "整段对话已复制为 Markdown",
|
||||
newSession: "已开新会话",
|
||||
cleared: "已清空 UI",
|
||||
mcpImported: "已从 cc-switch 导入 {imported} 个 MCP 服务器",
|
||||
mcpImportedWithSkipped: "已导入 {imported} 个 MCP 服务器,跳过 {skipped} 个同名项",
|
||||
mcpImportNone: "没有可导入的新 MCP 服务器",
|
||||
mcpImportFailed: "导入 cc-switch MCP 失败:{error}",
|
||||
mcpStatusOpened: "已打开 MCP 状态面板",
|
||||
mcpRetryNone: "没有失败的 MCP 需要重试",
|
||||
mcpRetryQueued: "已重试 {count} 个失败的 MCP",
|
||||
aboutLine: "Reasonix v{version} · cache-first DeepSeek agent",
|
||||
},
|
||||
composer: {
|
||||
@@ -122,10 +130,18 @@ export const zhCN: typeof en = {
|
||||
themeStyleHint: "为当前浅色或深色模式选择具体视觉配色。",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleGraphiteDesc: "原有深色风格,中性石墨面板。",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleEmberDesc: "暖黑深色风格,强化 Reasonix 橙色品牌感。",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleAuroraDesc: "青绿色深色风格,低光环境更柔和。",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStyleSandstoneDesc: "原有浅色风格,温暖明亮。",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStylePorcelainDesc: "类似 Codex App 的干净浅色风格。",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleLinenDesc: "偏纸张质感的温暖浅色风格。",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleGlacierDesc: "冷白浅色风格,蓝色强调更清爽。",
|
||||
themeStyleMidnight: "Midnight",
|
||||
themeStyleMidnightDesc: "偏藏青色的深色风格,冷静克制。",
|
||||
fontScale: "字体大小",
|
||||
@@ -156,6 +172,10 @@ export const zhCN: typeof en = {
|
||||
showSystemEventsHint: "在对话流中以细线显示压缩 / 中断 / 限速等系统事件",
|
||||
shown: "显示",
|
||||
hidden: "隐藏",
|
||||
desktopCloseBehavior: "关闭窗口",
|
||||
desktopCloseBehaviorHint: "默认关闭即退出。隐藏到托盘会让运行中的会话继续保活。",
|
||||
closeToQuit: "退出",
|
||||
closeToTray: "隐藏到托盘",
|
||||
editModeReviewDesc: "每次写入都要确认",
|
||||
editModeAutoDesc: "改文件自动,命令仍要确认",
|
||||
editModeYoloDesc: "全部放行 — 完全自动",
|
||||
@@ -164,12 +184,30 @@ export const zhCN: typeof en = {
|
||||
budgetPlaceholder: "不限",
|
||||
webSearchEngine: "搜索引擎",
|
||||
webSearchEngineBing: "bing — 默认,国内裸 IP 直连,无需代理",
|
||||
webSearchEngineBingIntl: "bing-intl — 国际版索引,走 www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — 自托管(端点用 /se searxng <url> 配置)",
|
||||
webSearchEngineMetaso: "metaso — 每日 100 次免费(国内友好,metaso.cn 可领免费 key)",
|
||||
webSearchEngineBaidu:
|
||||
"baidu — 百度 AI Search,官方文档写有每月 1500 次免费额度(设置 BAIDU_API_KEY 或 QIANFAN_API_KEY)",
|
||||
webSearchEngineTavily: "tavily — 每月 1000 次免费(设置 TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI 直接回答(设置 PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI 直接回答,每月 1000 次免费(设置 EXA_API_KEY)",
|
||||
webSearchEngineBrave: "brave — 独立索引,每月 2000 次免费(设置 BRAVE_SEARCH_API_KEY)",
|
||||
webSearchEngineOllama: "ollama — Ollama 云端网页搜索(设置 OLLAMA_API_KEY)",
|
||||
webSearchEngineNote: "下一次 web_search 调用生效",
|
||||
webSearchEndpoint: "SearXNG 端点",
|
||||
webSearchEndpointHint: "自托管 SearXNG 实例的根 URL。留空 = http://localhost:8080。",
|
||||
webSearchApiKeySignup: "(获取 key)",
|
||||
webSearchApiKeyClear: "清除",
|
||||
webSearchApiKey: {
|
||||
metaso: "Metaso API key",
|
||||
baidu: "百度 API key",
|
||||
tavily: "Tavily API key",
|
||||
perplexity: "Perplexity API key",
|
||||
exa: "Exa API key",
|
||||
brave: "Brave API key",
|
||||
ollama: "Ollama API key",
|
||||
},
|
||||
baseUrl: "DeepSeek base URL",
|
||||
baseUrlHint: "走代理时再改。留空 = 官方地址。需重启。",
|
||||
workspace: "工作目录",
|
||||
@@ -250,19 +288,60 @@ export const zhCN: typeof en = {
|
||||
modelCustom: "自定义模型 ID",
|
||||
modelCustomHint: "任何 OpenAI 兼容的 ID(vLLM、Ollama、Together …)。",
|
||||
modelCustomActive: "当前运行自定义 ID:{model}",
|
||||
contextTokensLabel: "上下文窗口大小",
|
||||
contextTokensHint: "为当前模型覆盖提示侧 token 上限(如 1000000 表示 1M)。留空使用内置默认值。",
|
||||
contextTokensPlaceholder: "自动",
|
||||
effortSection: "推理强度",
|
||||
ctxWindow: "上下文",
|
||||
maxOutput: "输出",
|
||||
mcpConfigured: "已配置 · {count}",
|
||||
mcpBridged: "· 已桥接",
|
||||
mcpNotBridged: "· 当前桌面会话未桥接,重启 reasonix code (TUI) 后生效",
|
||||
mcpNotBridged: "· 正在应用到当前桌面会话",
|
||||
mcpStatusConnected: "· 已连接 {connected}/{total}",
|
||||
mcpStatusConnecting: "· 正在连接 {connected}/{total},配置变更会自动应用",
|
||||
mcpStatusFailed: "· 已连接 {connected}/{total},{failed} 个失败",
|
||||
mcpStatusDisabled: "· {disabled}/{total} 个已禁用",
|
||||
mcpStatusPending: "· 等待应用",
|
||||
mcpStatus: {
|
||||
configured: "等待连接",
|
||||
handshake: "连接中",
|
||||
connected: "已连接",
|
||||
failed: "连接失败",
|
||||
disabled: "已禁用",
|
||||
},
|
||||
mcpFailure: {
|
||||
auth: "鉴权失败 · 请在 headers 中配置 Authorization 或 x-api-key",
|
||||
missingToken: "缺少 Bearer Token · 请在 headers 中配置 Authorization",
|
||||
command: "启动命令不可用 · 请检查 command/args 或本机依赖",
|
||||
network: "网络连接失败 · 请检查地址、代理或网络",
|
||||
unknown: "连接失败 · 查看详情获取原始错误",
|
||||
},
|
||||
mcpEmpty: "还没有配置 MCP 服务器。下面输入 spec 添加。",
|
||||
mcpRemove: "移除",
|
||||
mcpRetry: "重试",
|
||||
mcpDetail: "详情",
|
||||
mcpHideDetail: "收起",
|
||||
mcpDetailTitle: "原始错误",
|
||||
mcpEditConfig: "编辑",
|
||||
parseError: "解析失败:{error}",
|
||||
mcpAddSection: "添加服务器",
|
||||
mcpSpecLabel: "spec 字符串",
|
||||
mcpSpecFormat: "格式:<code>name=command args</code> 或 <code>name=https://host/sse</code>",
|
||||
mcpAdd: "添加",
|
||||
mcpImport: "从 cc-switch 导入",
|
||||
mcpImporting: "导入中…",
|
||||
mcpEdit: "编辑",
|
||||
mcpEditTitle: "编辑 MCP",
|
||||
mcpEditBack: "返回",
|
||||
mcpEditName: "MCP 标题(唯一)",
|
||||
mcpEditTransport: "传输类型",
|
||||
mcpEditJson: "完整的 JSON 配置",
|
||||
mcpEditFormat: "格式化",
|
||||
mcpEditSave: "保存",
|
||||
mcpEditNameRequired: "MCP 标题不能为空",
|
||||
mcpEditJsonObjectRequired: "JSON 配置必须是对象",
|
||||
mcpEditCommandRequired: "stdio 配置需要 command",
|
||||
mcpEditUrlRequired: "远程 MCP 配置需要 url",
|
||||
skillsLoaded: "已加载 · {count} · 通过 / 命令调用",
|
||||
skillsEmpty:
|
||||
"没有可用技能。可在 ~/.reasonix/skills/ 或 项目根/.reasonix/skills/ 下创建 SKILL.md。",
|
||||
@@ -290,8 +369,8 @@ export const zhCN: typeof en = {
|
||||
shortcutSettings: "设置",
|
||||
},
|
||||
modal: {
|
||||
planFeedbackPlaceholder: "可选反馈 — 想要修改时写这里",
|
||||
choiceCustomPlaceholder: "或者自己写一个答案…",
|
||||
planFeedbackPlaceholder: "计划存在哪些问题?需要改进哪些地方?",
|
||||
choiceCustomPlaceholder: "计划可以如何进一步细化?",
|
||||
},
|
||||
plan: {
|
||||
activeTitle: "进行中的计划",
|
||||
@@ -306,12 +385,15 @@ export const zhCN: typeof en = {
|
||||
},
|
||||
editMode: {
|
||||
label: "审批模式",
|
||||
plan: "plan",
|
||||
review: "review",
|
||||
auto: "auto",
|
||||
yolo: "yolo",
|
||||
planDesc: "只读 · 不改文件,不跑命令",
|
||||
reviewDesc: "每次写入都要确认",
|
||||
autoDesc: "改文件自动,命令仍要确认",
|
||||
yoloDesc: "全部放行",
|
||||
planHint: "只读模式 · 所有写工具都不会派发",
|
||||
reviewHint: "每个工具调用都需要批准",
|
||||
autoHint: "命中白名单的命令自动批准",
|
||||
yoloHint: "全部自动批准 · 谨慎使用",
|
||||
@@ -364,8 +446,12 @@ export const zhCN: typeof en = {
|
||||
themeDark: "深色",
|
||||
themeLight: "浅色",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleMidnight: "Midnight",
|
||||
},
|
||||
jobs: {
|
||||
@@ -402,6 +488,14 @@ export const zhCN: typeof en = {
|
||||
abort: "中断流式输出",
|
||||
copyLast: "复制最后一条回复",
|
||||
switchModel: "切换模型",
|
||||
mcp: "打开 MCP 服务器设置",
|
||||
mcpStatus: "查看 MCP 连接状态",
|
||||
mcpRetry: "重试失败的 MCP 连接",
|
||||
mcpImport: "从 cc-switch 导入 MCP",
|
||||
searchEngine: "配置搜索引擎 + API key",
|
||||
skill: "列出 / 管理用户技能(项目 + 自定义 + 全局 + 内置)",
|
||||
setMode: "切换模式为 {mode}",
|
||||
setEffort: "切换推理强度为 {effort}",
|
||||
toggleTheme: "切换深浅主题",
|
||||
toggleCurrency: "切换货币显示 (CNY / USD)",
|
||||
toggleLang: "切换界面语言 (中 / 英)",
|
||||
@@ -438,6 +532,10 @@ export const zhCN: typeof en = {
|
||||
jumpToBottom: "回到底部",
|
||||
splashSubtitle: "DeepSeek Agents",
|
||||
connecting: "正在连接 reasonix 内核…",
|
||||
startupFailedTitle: "Reasonix 无法启动",
|
||||
startupFailedMessage: "桌面后端在打开工作区前启动失败。",
|
||||
startupFailedRetry: "重试",
|
||||
startupFailedUnknown: "未知启动错误",
|
||||
langZH: "中文",
|
||||
langEN: "English",
|
||||
titlebar: {
|
||||
@@ -552,6 +650,12 @@ export const zhCN: typeof en = {
|
||||
cost: "花费",
|
||||
elapsed: "耗时",
|
||||
},
|
||||
mcpCard: {
|
||||
showTools: "工具",
|
||||
hideTools: "收起工具",
|
||||
availableTools: "{count} 个可用工具",
|
||||
noTools: "已连接,但该服务器未暴露可调用工具",
|
||||
},
|
||||
contextPanel: {
|
||||
reservedKey: "预留",
|
||||
usedKey: "已用",
|
||||
@@ -578,6 +682,16 @@ export const zhCN: typeof en = {
|
||||
mcpDisabled: "已禁用",
|
||||
mcpFailed: "失败",
|
||||
mcpConfigured: "已配置",
|
||||
mcpHealthTotal: "MCP {count}",
|
||||
mcpHealthReady: "已连接 {count}",
|
||||
mcpHealthFailed: "失败 {count}",
|
||||
mcpHealthTools: "工具 {count}",
|
||||
mcpFilterAll: "全部",
|
||||
mcpFilterReady: "可用",
|
||||
mcpFilterFailed: "失败",
|
||||
mcpFilterEmpty: "当前筛选下没有服务器",
|
||||
mcpRetryAll: "重试失败项",
|
||||
mcpSettings: "设置",
|
||||
memoryTitle: "长期记忆",
|
||||
itemCount: "{count} 项",
|
||||
scopeProject: "项目",
|
||||
@@ -602,12 +716,35 @@ export const zhCN: typeof en = {
|
||||
weeksAgo: "{n} 周前",
|
||||
newChat: "新会话",
|
||||
commandPalette: "命令面板",
|
||||
workspace: "工作目录",
|
||||
noWorkspace: "未选择",
|
||||
pickWorkspace: "选择工作目录",
|
||||
switchWorkspace: "切换工作目录 · {workspace}",
|
||||
searchSessions: "搜索会话…",
|
||||
recent: "近期",
|
||||
noSessions: "暂无会话",
|
||||
noMatches: "无匹配结果",
|
||||
messageCount: "{count} 条",
|
||||
deleteSession: "删除会话",
|
||||
importSessions: "导入会话",
|
||||
importDetected: "自动发现",
|
||||
importCustom: "自定义",
|
||||
importFoundApps: "找到的应用",
|
||||
importScanning: "正在扫描本地应用…",
|
||||
importSessionCount: "{count} 个会话 · 导入全部",
|
||||
importNotFound: "未找到本地会话",
|
||||
importPrivacyHint: "你现有的应用设置不会受影响。",
|
||||
importResult: "已导入 {imported} 个会话,跳过 {skipped} 个,失败 {failed} 个。",
|
||||
continue: "继续",
|
||||
refresh: "刷新",
|
||||
importSource: "来源",
|
||||
importFromClaude: "Claude",
|
||||
importFromCodex: "Codex",
|
||||
importPath: "会话文件路径",
|
||||
importName: "会话名称",
|
||||
importNamePlaceholder: "可选自定义名称",
|
||||
importConfirm: "导入",
|
||||
browse: "浏览",
|
||||
renameSession: "重命名会话",
|
||||
renamePlaceholder: "会话标题",
|
||||
approvalRules: "审批规则",
|
||||
@@ -636,6 +773,8 @@ export const zhCN: typeof en = {
|
||||
unknownKind: "# 未知卡片类型 — fallback 渲染",
|
||||
},
|
||||
thread: {
|
||||
toolCalls: "{count} 个工具调用",
|
||||
oneToolCall: "1 个工具调用",
|
||||
keepSteps: "保留剩余 {n} 步",
|
||||
keepOriginal: "保留原计划",
|
||||
you: "你",
|
||||
@@ -649,6 +788,10 @@ export const zhCN: typeof en = {
|
||||
approve: "批准",
|
||||
cancel: "取消",
|
||||
refine: "细化",
|
||||
sendFeedback: "发送",
|
||||
skipFeedback: "跳过",
|
||||
cancelFeedbackLabel: "取消 — 哪里有问题?",
|
||||
refineFeedbackLabel: "细化 — 还需要什么?",
|
||||
checkpointKind: "计划检查点",
|
||||
checkpointTitle: "step {completed} / {total} 完成",
|
||||
checkpointSub: "检查点 · {completed} / {total}",
|
||||
@@ -665,7 +808,7 @@ export const zhCN: typeof en = {
|
||||
riskMedium: "risk · 中 · {kind}",
|
||||
execute: "执行",
|
||||
reject: "拒绝",
|
||||
alwaysAllow: "始终允许 \"{prefix}\"",
|
||||
alwaysAllow: '始终允许 "{prefix}"',
|
||||
pathAccessKind: "路径访问",
|
||||
read: "读取",
|
||||
write: "写入",
|
||||
@@ -701,6 +844,7 @@ export const zhCN: typeof en = {
|
||||
readBody: "\u8bfb\u53d6\uff1a{path}",
|
||||
writeBody: "\u5199\u5165\uff1a{path}",
|
||||
turnCompleteTitle: "\u4efb\u52a1\u5df2\u5b8c\u6210",
|
||||
turnCompleteBody: "\u5f53\u524d\u4efb\u52a1\u5df2\u7ed3\u675f\uff0c\u53ef\u4ee5\u56de\u6765\u67e5\u770b\u7ed3\u679c\u4e86\u3002",
|
||||
turnCompleteBody:
|
||||
"\u5f53\u524d\u4efb\u52a1\u5df2\u7ed3\u675f\uff0c\u53ef\u4ee5\u56de\u6765\u67e5\u770b\u7ed3\u679c\u4e86\u3002",
|
||||
},
|
||||
};
|
||||
@@ -77,6 +77,13 @@ export const I = {
|
||||
help: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></Ic>),
|
||||
refresh: (p: IconProps) => (<Ic {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 4v4h-4M21 12a9 9 0 0 1-15 6.7L3 16M3 20v-4h4" /></Ic>),
|
||||
copy: (p: IconProps) => (<Ic {...p}><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" /></Ic>),
|
||||
trash: (p: IconProps) => (
|
||||
<Ic {...p}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</Ic>
|
||||
),
|
||||
};
|
||||
|
||||
export type IconKey = keyof typeof I;
|
||||
+135
-3
@@ -102,7 +102,36 @@ export type PlanClearedEvent = { type: "$plan_cleared" };
|
||||
|
||||
export type SessionsEvent = {
|
||||
type: "$sessions";
|
||||
items: { name: string; messageCount: number; mtime: string; summary?: string }[];
|
||||
items: {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
mtime: string;
|
||||
summary?: string;
|
||||
workspaceStatus?: "matched" | "legacy_missing_meta";
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ExternalSessionSource = "claude" | "codex";
|
||||
|
||||
export type ExternalSessionApp = {
|
||||
source: ExternalSessionSource;
|
||||
label: string;
|
||||
root: string;
|
||||
available: boolean;
|
||||
sessionCount: number;
|
||||
latestMtime?: string;
|
||||
};
|
||||
|
||||
export type SessionImportSourcesEvent = {
|
||||
type: "$session_import_sources";
|
||||
apps: ExternalSessionApp[];
|
||||
};
|
||||
|
||||
export type SessionImportResultEvent = {
|
||||
type: "$session_import_result";
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
|
||||
export type MentionResultsEvent = {
|
||||
@@ -120,6 +149,20 @@ export type MentionPreviewEvent = {
|
||||
totalLines: number;
|
||||
};
|
||||
|
||||
export type PromptHistoryCursor = {
|
||||
sessionName: string;
|
||||
messageIndex: number;
|
||||
};
|
||||
|
||||
export type PromptHistoryResultEvent = {
|
||||
type: "$prompt_history_result";
|
||||
nonce: number;
|
||||
entry: {
|
||||
value: string;
|
||||
cursor: PromptHistoryCursor;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type TabOpenedEvent = {
|
||||
type: "$tab_opened";
|
||||
workspaceDir: string;
|
||||
@@ -138,10 +181,19 @@ export type McpSpecInfo = {
|
||||
name: string | null;
|
||||
transport: "stdio" | "sse" | "streamable-http";
|
||||
summary: string;
|
||||
config?: ImportedMcpServer;
|
||||
parseError?: string;
|
||||
status: McpSpecStatus;
|
||||
statusHint?: "auth" | "missing-token" | "command" | "network" | "unknown";
|
||||
statusReason?: string;
|
||||
toolCount?: number;
|
||||
tools?: McpToolInfo[];
|
||||
};
|
||||
|
||||
export type McpToolInfo = {
|
||||
name: string;
|
||||
registeredName: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type McpSpecsEvent = {
|
||||
@@ -150,6 +202,19 @@ export type McpSpecsEvent = {
|
||||
bridged: boolean;
|
||||
};
|
||||
|
||||
export type ImportedMcpServer = {
|
||||
name: string;
|
||||
transport: "stdio" | "sse" | "streamable-http";
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export type SkillScope = "project" | "global" | "builtin";
|
||||
|
||||
export type SkillInfo = {
|
||||
@@ -174,9 +239,12 @@ export type CtxBreakdownEvent = {
|
||||
};
|
||||
|
||||
export type MemoryEntryInfo = {
|
||||
kind: "project_file" | "global_file" | "structured";
|
||||
name: string;
|
||||
scope: "project" | "global";
|
||||
path: string;
|
||||
description: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type MemoryEvent = {
|
||||
@@ -184,6 +252,16 @@ export type MemoryEvent = {
|
||||
entries: MemoryEntryInfo[];
|
||||
};
|
||||
|
||||
export type MemoryDetail = MemoryEntryInfo & {
|
||||
body: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type MemoryDetailEvent = {
|
||||
type: "$memory_detail";
|
||||
detail: MemoryDetail;
|
||||
};
|
||||
|
||||
export type RetryResultEvent = { type: "$retry_result"; text: string };
|
||||
|
||||
export type BtwResultEvent = { type: "$btw_result"; question: string; answer: string };
|
||||
@@ -250,17 +328,21 @@ export type NeedsSetupEvent = {
|
||||
reason: "no_api_key";
|
||||
};
|
||||
|
||||
export type EditMode = "review" | "auto" | "yolo";
|
||||
export type EditMode = "review" | "auto" | "yolo" | "plan";
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high" | "max";
|
||||
|
||||
export type WebSearchEngineName =
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa";
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "ollama";
|
||||
|
||||
export type SettingsEvent = {
|
||||
type: "$settings";
|
||||
@@ -273,7 +355,18 @@ export type SettingsEvent = {
|
||||
recentWorkspaces: string[];
|
||||
model: string;
|
||||
editor?: string;
|
||||
desktopCloseBehavior?: "closeToTray" | "closeToQuit";
|
||||
webSearchEngine?: WebSearchEngineName;
|
||||
webSearchEndpoint?: string;
|
||||
webSearchApiKeys?: {
|
||||
metaso?: string;
|
||||
baidu?: string;
|
||||
tavily?: string;
|
||||
perplexity?: string;
|
||||
exa?: string;
|
||||
ollama?: string;
|
||||
brave?: string;
|
||||
};
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
version: string;
|
||||
@@ -292,11 +385,19 @@ export type QQSettingsEvent = {
|
||||
access: string;
|
||||
};
|
||||
|
||||
export type BalanceInfoItem = {
|
||||
currency: string;
|
||||
total: number;
|
||||
granted?: number;
|
||||
toppedUp?: number;
|
||||
};
|
||||
|
||||
export type BalanceEvent = {
|
||||
type: "$balance";
|
||||
currency: string;
|
||||
total: number;
|
||||
isAvailable: boolean;
|
||||
balanceInfos: BalanceInfoItem[];
|
||||
};
|
||||
|
||||
export type SettingsPatch = {
|
||||
@@ -305,10 +406,22 @@ export type SettingsPatch = {
|
||||
budgetUsd?: number | null;
|
||||
baseUrl?: string;
|
||||
workspaceDir?: string;
|
||||
recentWorkspaces?: string[];
|
||||
model?: string;
|
||||
editor?: string;
|
||||
desktopCloseBehavior?: "closeToTray" | "closeToQuit";
|
||||
webSearchEngine?: WebSearchEngineName;
|
||||
webSearchEndpoint?: string | null;
|
||||
metasoApiKey?: string | null;
|
||||
baiduApiKey?: string | null;
|
||||
tavilyApiKey?: string | null;
|
||||
perplexityApiKey?: string | null;
|
||||
exaApiKey?: string | null;
|
||||
ollamaApiKey?: string | null;
|
||||
braveApiKey?: string | null;
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
/** Per-model context-window override (tokens). Keys are model ids; values are the prompt-side token cap. */
|
||||
contextTokens?: Record<string, number>;
|
||||
showSystemEvents?: boolean;
|
||||
};
|
||||
|
||||
@@ -428,6 +541,8 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| ChoiceRequiredEvent
|
||||
| PlanRequiredEvent
|
||||
| SessionsEvent
|
||||
| SessionImportSourcesEvent
|
||||
| SessionImportResultEvent
|
||||
| SessionLoadedEvent
|
||||
| SessionEmptyEvent
|
||||
| NeedsSetupEvent
|
||||
@@ -440,12 +555,14 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| PlanClearedEvent
|
||||
| MentionResultsEvent
|
||||
| MentionPreviewEvent
|
||||
| PromptHistoryResultEvent
|
||||
| TabOpenedEvent
|
||||
| TabClosedEvent
|
||||
| McpSpecsEvent
|
||||
| SkillsEvent
|
||||
| CtxBreakdownEvent
|
||||
| MemoryEvent
|
||||
| MemoryDetailEvent
|
||||
| JobsEvent
|
||||
| UserMessageEvent
|
||||
| ModelTurnStartedEvent
|
||||
@@ -473,6 +590,10 @@ export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "session_delete"; name: string }
|
||||
| { cmd: "session_load"; name: string }
|
||||
| { cmd: "session_rename"; name: string; title: string }
|
||||
| { cmd: "session_import"; source: ExternalSessionSource; path: string; name?: string }
|
||||
| { cmd: "session_import_scan" }
|
||||
| { cmd: "session_import_bulk"; sources: ExternalSessionSource[] }
|
||||
| { cmd: "memory_read"; path: string }
|
||||
| { cmd: "new_chat" }
|
||||
| { cmd: "setup_save_key"; key: string }
|
||||
| { cmd: "settings_get" }
|
||||
@@ -484,12 +605,23 @@ export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "mention_query"; query: string; nonce: number }
|
||||
| { cmd: "mention_preview"; path: string; nonce: number }
|
||||
| { cmd: "mention_picked"; path: string }
|
||||
| {
|
||||
cmd: "prompt_history_step";
|
||||
nonce: number;
|
||||
direction: "older" | "newer";
|
||||
cursor?: PromptHistoryCursor | null;
|
||||
startSessionName?: string;
|
||||
stopSessionName?: string;
|
||||
}
|
||||
| { cmd: "tab_open"; workspaceDir?: string }
|
||||
| { cmd: "tab_close" }
|
||||
| { cmd: "tab_activate"; tabId: string }
|
||||
| { cmd: "mcp_specs_get" }
|
||||
| { cmd: "mcp_specs_add"; spec: string }
|
||||
| { cmd: "mcp_specs_remove"; spec: string }
|
||||
| { cmd: "mcp_import_servers"; servers: ImportedMcpServer[] }
|
||||
| { cmd: "mcp_specs_update"; raw: string; server: ImportedMcpServer }
|
||||
| { cmd: "mcp_specs_retry"; raw: string }
|
||||
| { cmd: "skills_get" }
|
||||
| { cmd: "skill_run"; name: string; args?: string }
|
||||
| { cmd: "jobs_list" }
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { EditMode, ReasoningEffort } from "./protocol";
|
||||
|
||||
export const SLASH_EDIT_MODES = [
|
||||
"plan",
|
||||
"review",
|
||||
"auto",
|
||||
"yolo",
|
||||
] as const satisfies readonly EditMode[];
|
||||
export const SLASH_REASONING_EFFORTS = [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
] as const satisfies readonly ReasoningEffort[];
|
||||
|
||||
export type SlashSettingsCommand =
|
||||
| { type: "editMode"; editMode: EditMode }
|
||||
| { type: "reasoningEffort"; reasoningEffort: ReasoningEffort };
|
||||
|
||||
export type SlashSettingsDescriptor = {
|
||||
cmd: string;
|
||||
action: SlashSettingsCommand;
|
||||
};
|
||||
|
||||
function isEditMode(value: string): value is EditMode {
|
||||
return (SLASH_EDIT_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isReasoningEffort(value: string): value is ReasoningEffort {
|
||||
return (SLASH_REASONING_EFFORTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function parseSlashSettingsCommand(input: string): SlashSettingsCommand | null {
|
||||
const match = /^\/([a-zA-Z0-9_-]+)(?:\s+([^\s]+))?$/.exec(input.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const name = match[1]?.toLowerCase();
|
||||
const arg = match[2]?.toLowerCase();
|
||||
|
||||
if (name === "effort") {
|
||||
if (arg && isReasoningEffort(arg)) return { type: "reasoningEffort", reasoningEffort: arg };
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === "plan" && !arg) {
|
||||
return { type: "editMode", editMode: "plan" };
|
||||
}
|
||||
|
||||
if ((name === "model" || name === "mode" || name === "plan") && arg && isEditMode(arg)) {
|
||||
return { type: "editMode", editMode: arg };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildSlashSettingsDescriptors(): SlashSettingsDescriptor[] {
|
||||
const modelModes = SLASH_EDIT_MODES.map((mode) => ({
|
||||
cmd: `/model ${mode}`,
|
||||
action: { type: "editMode", editMode: mode } as const,
|
||||
}));
|
||||
const planModes = [
|
||||
{ cmd: "/plan", action: { type: "editMode", editMode: "plan" } as const },
|
||||
...SLASH_EDIT_MODES.filter((mode) => mode !== "plan").map((mode) => ({
|
||||
cmd: `/plan ${mode}`,
|
||||
action: { type: "editMode", editMode: mode } as const,
|
||||
})),
|
||||
];
|
||||
const effortCommands = SLASH_REASONING_EFFORTS.map((effort) => ({
|
||||
cmd: `/effort ${effort}`,
|
||||
action: { type: "reasoningEffort", reasoningEffort: effort } as const,
|
||||
}));
|
||||
|
||||
return [...modelModes, ...planModes, ...effortCommands];
|
||||
}
|
||||
+1460
-70
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setLang } from "../i18n";
|
||||
import { Composer } from "./composer";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderComposer(props?: Partial<React.ComponentProps<typeof Composer>>) {
|
||||
const textareaRef = createRef<HTMLTextAreaElement>();
|
||||
const onMentionQuery = vi.fn();
|
||||
const utils = render(
|
||||
<Composer
|
||||
draft=""
|
||||
setDraft={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
disabled={false}
|
||||
busy={false}
|
||||
modelLabel="deepseek-v4-flash"
|
||||
reasoningEffort="high"
|
||||
onModelChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
editMode="review"
|
||||
onEditModeChange={vi.fn()}
|
||||
textareaRef={textareaRef}
|
||||
slashCommands={[]}
|
||||
onMentionQuery={onMentionQuery}
|
||||
onMentionPreview={vi.fn()}
|
||||
onMentionPicked={vi.fn()}
|
||||
mentionResults={null}
|
||||
workspaceDir="/repo"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { ...utils, textareaRef, onMentionQuery };
|
||||
}
|
||||
|
||||
describe("desktop Composer @ popup", () => {
|
||||
beforeEach(() => {
|
||||
setLang("en");
|
||||
});
|
||||
|
||||
it("keeps the active mention row when async results shrink", async () => {
|
||||
const { container, rerender, onMentionQuery } = renderComposer();
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "@re" } });
|
||||
|
||||
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
|
||||
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;
|
||||
|
||||
rerender(
|
||||
<Composer
|
||||
draft="@re"
|
||||
setDraft={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
disabled={false}
|
||||
busy={false}
|
||||
modelLabel="deepseek-v4-flash"
|
||||
reasoningEffort="high"
|
||||
onModelChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
editMode="review"
|
||||
onEditModeChange={vi.fn()}
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
slashCommands={[]}
|
||||
onMentionQuery={onMentionQuery}
|
||||
onMentionPreview={vi.fn()}
|
||||
onMentionPicked={vi.fn()}
|
||||
mentionResults={{ nonce, query: "re", results: ["alpha", "beta", "gamma"] }}
|
||||
workspaceDir="/repo"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.popup-item[data-active="true"]')?.textContent).toContain(
|
||||
"alpha",
|
||||
);
|
||||
});
|
||||
|
||||
const items = container.querySelectorAll(".popup-item");
|
||||
fireEvent.mouseEnter(items[1]!);
|
||||
|
||||
expect(container.querySelector('.popup-item[data-active="true"]')?.textContent).toContain(
|
||||
"beta",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<Composer
|
||||
draft="@re"
|
||||
setDraft={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
disabled={false}
|
||||
busy={false}
|
||||
modelLabel="deepseek-v4-flash"
|
||||
reasoningEffort="high"
|
||||
onModelChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
editMode="review"
|
||||
onEditModeChange={vi.fn()}
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
slashCommands={[]}
|
||||
onMentionQuery={onMentionQuery}
|
||||
onMentionPreview={vi.fn()}
|
||||
onMentionPicked={vi.fn()}
|
||||
mentionResults={{ nonce, query: "re", results: ["alpha", "beta"] }}
|
||||
workspaceDir="/repo"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.popup-item[data-active="true"]')?.textContent).toContain(
|
||||
"beta",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the wider at popup class for mention results", async () => {
|
||||
const { container, rerender, onMentionQuery } = renderComposer();
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "@re" } });
|
||||
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
|
||||
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;
|
||||
|
||||
rerender(
|
||||
<Composer
|
||||
draft="@re"
|
||||
setDraft={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
disabled={false}
|
||||
busy={false}
|
||||
modelLabel="deepseek-v4-flash"
|
||||
reasoningEffort="high"
|
||||
onModelChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
editMode="review"
|
||||
onEditModeChange={vi.fn()}
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
slashCommands={[]}
|
||||
onMentionQuery={onMentionQuery}
|
||||
onMentionPreview={vi.fn()}
|
||||
onMentionPicked={vi.fn()}
|
||||
mentionResults={{ nonce, query: "re", results: ["alpha"] }}
|
||||
workspaceDir="/repo"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".popup-list.at-popup-list")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("delegates ArrowUp prompt-history navigation only at the input start", () => {
|
||||
const onPromptHistoryNavigate = vi.fn(() => true);
|
||||
const { container } = renderComposer({
|
||||
draft: "current prompt",
|
||||
onPromptHistoryNavigate,
|
||||
});
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
textarea.setSelectionRange(3, 3);
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
expect(onPromptHistoryNavigate).not.toHaveBeenCalled();
|
||||
|
||||
textarea.setSelectionRange(0, 0);
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
expect(onPromptHistoryNavigate).toHaveBeenCalledWith("older");
|
||||
});
|
||||
|
||||
it("allows ArrowDown prompt-history navigation while browsing at a boundary", () => {
|
||||
const onPromptHistoryNavigate = vi.fn(() => true);
|
||||
const { container } = renderComposer({
|
||||
draft: "recalled prompt",
|
||||
promptHistoryBrowsing: true,
|
||||
onPromptHistoryNavigate,
|
||||
});
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
textarea.setSelectionRange("recalled prompt".length, "recalled prompt".length);
|
||||
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||
|
||||
expect(onPromptHistoryNavigate).toHaveBeenCalledWith("newer");
|
||||
});
|
||||
|
||||
it("shows mention filenames first while preserving the full path for preview and pick", async () => {
|
||||
const setDraft = vi.fn();
|
||||
const onMentionPicked = vi.fn();
|
||||
const onMentionPreview = vi.fn();
|
||||
const { container, rerender, onMentionQuery } = renderComposer({
|
||||
setDraft,
|
||||
onMentionPicked,
|
||||
onMentionPreview,
|
||||
});
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "@foo" } });
|
||||
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
|
||||
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;
|
||||
|
||||
rerender(
|
||||
<Composer
|
||||
draft="@foo"
|
||||
setDraft={setDraft}
|
||||
onSend={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
disabled={false}
|
||||
busy={false}
|
||||
modelLabel="deepseek-v4-flash"
|
||||
reasoningEffort="high"
|
||||
onModelChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
editMode="review"
|
||||
onEditModeChange={vi.fn()}
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
slashCommands={[]}
|
||||
onMentionQuery={onMentionQuery}
|
||||
onMentionPreview={onMentionPreview}
|
||||
onMentionPicked={onMentionPicked}
|
||||
mentionResults={{ nonce, query: "foo", results: ["src/very/deep/foo.ts"] }}
|
||||
workspaceDir="/repo"
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = await waitFor(() => {
|
||||
const item = container.querySelector(".popup-item");
|
||||
expect(item).not.toBeNull();
|
||||
return item as HTMLElement;
|
||||
});
|
||||
|
||||
expect(row.querySelector(".nm > span")?.textContent).toBe("foo.ts");
|
||||
expect(row.querySelector(".desc")?.textContent).toBe("src/very/deep/");
|
||||
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(onMentionPreview).toHaveBeenCalledWith("src/very/deep/foo.ts", nonce);
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onMentionPicked).toHaveBeenCalledWith("src/very/deep/foo.ts");
|
||||
const draftUpdate = setDraft.mock.calls.at(-1)?.[0];
|
||||
expect(typeof draftUpdate).toBe("function");
|
||||
expect((draftUpdate as (current: string) => string)("@foo")).toBe(
|
||||
"@src/very/deep/foo.ts ",
|
||||
);
|
||||
});
|
||||
});
|
||||
+300
-120
@@ -9,6 +9,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { t, type TKey } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
@@ -20,13 +21,14 @@ import { fmtElapsed } from "./live";
|
||||
import { Shortcut } from "./shortcut";
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high" | "max";
|
||||
export type EditMode = "review" | "auto" | "yolo";
|
||||
export type EditMode = "review" | "auto" | "yolo" | "plan";
|
||||
|
||||
type ModeEntry = { k: EditMode; label: TKey; icon: React.ReactNode; hint: TKey };
|
||||
|
||||
const EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "max"];
|
||||
|
||||
const MODE_INFO: ModeEntry[] = [
|
||||
{ k: "plan", label: "editMode.plan", icon: <I.list size={11} />, hint: "editMode.planHint" },
|
||||
{ k: "review", label: "editMode.review", icon: <I.shield size={11} />, hint: "editMode.reviewHint" },
|
||||
{ k: "auto", label: "editMode.auto", icon: <I.zap size={11} />, hint: "editMode.autoHint" },
|
||||
{ k: "yolo", label: "editMode.yolo", icon: <I.warn size={11} />, hint: "editMode.yoloHint" },
|
||||
@@ -68,6 +70,7 @@ export type SlashCmd = {
|
||||
};
|
||||
export type MentionItem = {
|
||||
name: string;
|
||||
path?: string;
|
||||
kind: "file" | "dir" | "url" | "agent" | "clip";
|
||||
desc?: string;
|
||||
};
|
||||
@@ -76,11 +79,62 @@ export type Chip =
|
||||
| { kind: "at"; label: string }
|
||||
| { kind: "slash"; label: string };
|
||||
|
||||
/** For long paths show only the filename; truncate filename if it's still too long. */
|
||||
function chipLabel(label: string, maxLen = 32): string {
|
||||
if (label.length <= maxLen) return label;
|
||||
const sep = label.includes("/") ? "/" : "\\";
|
||||
const segments = label.split(sep);
|
||||
const filename = segments[segments.length - 1]!;
|
||||
if (filename.length <= maxLen) return filename;
|
||||
return filename.slice(0, maxLen - 1) + "…";
|
||||
}
|
||||
|
||||
type Popup =
|
||||
| { kind: "slash"; query: string }
|
||||
| { kind: "at"; query: string; nonce: number }
|
||||
| null;
|
||||
|
||||
type ActiveRange = { start: number; end: number; sigil: string; query: string };
|
||||
|
||||
/** Return the @word or /word range at cursorPos, or null. */
|
||||
function findActiveRange(text: string, cursorPos: number): ActiveRange | null {
|
||||
if (cursorPos < 0 || cursorPos > text.length) return null;
|
||||
let wordStart = cursorPos;
|
||||
while (wordStart > 0 && !/\s/.test(text[wordStart - 1]!)) wordStart--;
|
||||
let wordEnd = cursorPos;
|
||||
while (wordEnd < text.length && !/\s/.test(text[wordEnd]!)) wordEnd++;
|
||||
if (wordStart === wordEnd) return null;
|
||||
const word = text.slice(wordStart, wordEnd);
|
||||
if (word.length > 0 && (word[0] === "@" || word[0] === "/")) {
|
||||
if (wordStart === 0 || /\s/.test(text[wordStart - 1]!)) {
|
||||
return { start: wordStart, end: wordEnd, sigil: word[0], query: word.slice(1) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Render draft text with @word and /word tokens wrapped in highlight spans. */
|
||||
function highlightTokens(text: string): React.ReactNode {
|
||||
if (!text) return text;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
for (const m of text.matchAll(/(\s|^)([@\/]\S+)/g)) {
|
||||
const pre = text.slice(lastIdx, m.index);
|
||||
if (pre) parts.push(pre);
|
||||
parts.push(
|
||||
<span key={m.index}>
|
||||
{m[1]}
|
||||
<span className="hl-token">{m[2]}</span>
|
||||
</span>,
|
||||
);
|
||||
lastIdx = m.index + m[0].length;
|
||||
}
|
||||
if (lastIdx < text.length) {
|
||||
parts.push(text.slice(lastIdx));
|
||||
}
|
||||
return parts.length > 0 ? parts : text;
|
||||
}
|
||||
|
||||
function slashIcon(cmd: string) {
|
||||
const m: Record<string, React.ReactNode> = {
|
||||
"/clear": <I.x size={12} />,
|
||||
@@ -107,6 +161,20 @@ function parentOfAtQuery(query: string): string | null {
|
||||
return parentIdx >= 0 ? `${dirContext.slice(0, parentIdx)}/` : "";
|
||||
}
|
||||
|
||||
function displayMentionPath(path: string): { name: string; desc?: string } {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const isDir = /[\\/]$/.test(path);
|
||||
const trimmed = normalized.replace(/\/+$/, "");
|
||||
if (!trimmed) return { name: path };
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
const base = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
||||
const parent = slash >= 0 ? `${trimmed.slice(0, slash)}/` : "";
|
||||
return {
|
||||
name: isDir ? `${base}/` : base,
|
||||
desc: parent || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function atIcon(k: MentionItem["kind"]) {
|
||||
if (k === "file") return <I.file size={12} />;
|
||||
if (k === "dir") return <I.folder size={12} />;
|
||||
@@ -116,9 +184,23 @@ function atIcon(k: MentionItem["kind"]) {
|
||||
return <I.at size={12} />;
|
||||
}
|
||||
|
||||
function guessImageExtension(mime: string): string {
|
||||
const normalized = mime.toLowerCase();
|
||||
if (normalized === "image/jpeg") return "jpg";
|
||||
if (normalized === "image/png") return "png";
|
||||
if (normalized === "image/gif") return "gif";
|
||||
if (normalized === "image/webp") return "webp";
|
||||
if (normalized === "image/svg+xml") return "svg";
|
||||
const slash = normalized.indexOf("/");
|
||||
return slash >= 0 ? normalized.slice(slash + 1).replace(/[^a-z0-9]+/g, "") || "png" : "png";
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
draft,
|
||||
setDraft,
|
||||
onDraftUserEdit,
|
||||
promptHistoryBrowsing,
|
||||
onPromptHistoryNavigate,
|
||||
onSend,
|
||||
onAbort,
|
||||
disabled,
|
||||
@@ -143,7 +225,10 @@ export function Composer({
|
||||
onDequeueSend,
|
||||
}: {
|
||||
draft: string;
|
||||
setDraft: (s: string) => void;
|
||||
setDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
onDraftUserEdit?: () => void;
|
||||
promptHistoryBrowsing?: boolean;
|
||||
onPromptHistoryNavigate?: (direction: "older" | "newer") => boolean | void;
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
disabled?: boolean;
|
||||
@@ -170,18 +255,58 @@ export function Composer({
|
||||
onQueueWhileBusy?: (text: string) => void;
|
||||
onDequeueSend?: (index: number) => void;
|
||||
}) {
|
||||
const [chips, setChips] = useState<Chip[]>([]);
|
||||
const [popup, setPopup] = useState<Popup>(null);
|
||||
const [pickedChips, setPickedChips] = useState<Map<string, Chip["kind"]>>(new Map());
|
||||
const chips = useMemo(() => {
|
||||
const result: Chip[] = [];
|
||||
for (const [label, kind] of pickedChips) {
|
||||
const sigil = kind === "at" ? "@" : "/";
|
||||
const escaped = sigil + label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`(?:^|\\s)${escaped}(?:\\s|$)`);
|
||||
if (re.test(draft)) {
|
||||
result.push({ kind, label });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [draft, pickedChips]);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const backdropContent = useMemo(() => highlightTokens(draft), [draft]);
|
||||
const nonceRef = useRef(0);
|
||||
const modelWrapRef = useRef<HTMLDivElement>(null);
|
||||
// macOS Chinese IME fires compositionend BEFORE the confirm keydown.
|
||||
const composingRef = useRef(false);
|
||||
const compositionEndedAtRef = useRef(0);
|
||||
const historyRef = useRef<string[]>([]);
|
||||
const [browseIdx, setBrowseIdx] = useState(-1);
|
||||
const savedDraftRef = useRef("");
|
||||
const activeRangeRef = useRef<ActiveRange | null>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const insertMention = (picked: string) => {
|
||||
const rel =
|
||||
workspaceDir && picked.startsWith(workspaceDir)
|
||||
? picked.slice(workspaceDir.length).replace(/^[\\/]+/, "")
|
||||
: picked;
|
||||
const range = activeRangeRef.current;
|
||||
if (range && range.sigil === "@") {
|
||||
const savedRange = { ...range };
|
||||
setDraft((current) => {
|
||||
const before = current.slice(0, savedRange.start);
|
||||
const after = current.slice(savedRange.end);
|
||||
const insertion = `@${rel}`;
|
||||
const spacerBefore = before && !before.endsWith(" ") ? " " : "";
|
||||
const spacerAfter = after ? (after.startsWith(" ") ? "" : " ") : " ";
|
||||
return `${before}${spacerBefore}${insertion}${spacerAfter}${after}`;
|
||||
});
|
||||
} else {
|
||||
setDraft((current) =>
|
||||
current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `,
|
||||
);
|
||||
}
|
||||
activeRangeRef.current = null;
|
||||
setPickedChips((prev) => new Map(prev).set(rel, "at"));
|
||||
onMentionPicked?.(rel);
|
||||
setPopup(null);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
@@ -195,6 +320,7 @@ export function Composer({
|
||||
const prev = prevDraftRef.current;
|
||||
prevDraftRef.current = draft;
|
||||
if (draft === "/" && prev !== "/") {
|
||||
activeRangeRef.current = { start: 0, end: 1, sigil: "/", query: "" };
|
||||
setPopup({ kind: "slash", query: "" });
|
||||
}
|
||||
}, [draft]);
|
||||
@@ -230,19 +356,31 @@ export function Composer({
|
||||
: undefined,
|
||||
});
|
||||
if (typeof picked !== "string" || !picked) return;
|
||||
const rel =
|
||||
workspaceDir && picked.startsWith(workspaceDir)
|
||||
? picked.slice(workspaceDir.length).replace(/^[\\/]+/, "")
|
||||
: picked;
|
||||
setDraft(draft ? `${draft.replace(/\s+$/, "")} @${rel} ` : `@${rel} `);
|
||||
setChips((c) => [...c, { kind: "at", label: rel }]);
|
||||
onMentionPicked?.(rel);
|
||||
textareaRef.current?.focus();
|
||||
insertMention(picked);
|
||||
} catch (err) {
|
||||
console.error("attach failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = Array.from(e.clipboardData?.items ?? []);
|
||||
const imageItem = items.find((item) => item.type.startsWith("image/"));
|
||||
if (!imageItem) return;
|
||||
const file = imageItem.getAsFile();
|
||||
if (!file) return;
|
||||
e.preventDefault();
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const savedPath = await invoke<string>("save_clipboard_image", {
|
||||
bytes: buffer,
|
||||
extension: guessImageExtension(file.type),
|
||||
});
|
||||
insertMention(savedPath);
|
||||
} catch (err) {
|
||||
console.error("clipboard image paste failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (!popup || popup.kind !== "slash") return [];
|
||||
const q = popup.query.toLowerCase();
|
||||
@@ -253,11 +391,15 @@ export function Composer({
|
||||
const atItems = useMemo<MentionItem[]>(() => {
|
||||
if (!popup || popup.kind !== "at") return [];
|
||||
if (!mentionResults || mentionResults.nonce !== popup.nonce) return [];
|
||||
const base: MentionItem[] = mentionResults.results.map((path) => ({
|
||||
name: path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: path,
|
||||
}));
|
||||
const base: MentionItem[] = mentionResults.results.map((path) => {
|
||||
const display = displayMentionPath(path);
|
||||
return {
|
||||
name: display.name,
|
||||
path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: display.desc,
|
||||
};
|
||||
});
|
||||
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
|
||||
const parent = parentOfAtQuery(popup.query);
|
||||
if (parent !== null) {
|
||||
@@ -275,96 +417,118 @@ export function Composer({
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIdx(0);
|
||||
}, [items.length, popup?.kind]);
|
||||
}, [popup?.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIdx((i) => (items.length ? Math.min(i, items.length - 1) : 0));
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!popup || popup.kind !== "at" || !onMentionQuery) return;
|
||||
onMentionQuery(popup.query, popup.nonce);
|
||||
}, [popup, onMentionQuery]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const v = e.target.value;
|
||||
setDraft(v);
|
||||
const trail = v.match(/(^|\s)([/@])([^\s]*)$/);
|
||||
if (trail) {
|
||||
const sigil = trail[2];
|
||||
const query = trail[3] ?? "";
|
||||
if (sigil === "/") {
|
||||
setPopup({ kind: "slash", query });
|
||||
const syncPopupFromCursor = (value: string, cursorPos: number) => {
|
||||
const range = findActiveRange(value, cursorPos);
|
||||
if (range && (range.sigil === "/" || range.sigil === "@")) {
|
||||
activeRangeRef.current = range;
|
||||
if (range.sigil === "/") {
|
||||
if (popup?.kind !== "slash" || popup.query !== range.query) {
|
||||
setPopup({ kind: "slash", query: range.query });
|
||||
}
|
||||
} else {
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query, nonce });
|
||||
if (popup?.kind !== "at" || popup.query !== range.query) {
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: range.query, nonce });
|
||||
}
|
||||
}
|
||||
} else if (popup) {
|
||||
activeRangeRef.current = null;
|
||||
setPopup(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const v = e.target.value;
|
||||
onDraftUserEdit?.();
|
||||
setDraft(v);
|
||||
const cursorPos = e.target.selectionStart ?? v.length;
|
||||
syncPopupFromCursor(v, cursorPos);
|
||||
};
|
||||
|
||||
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const ta = e.currentTarget;
|
||||
const cursorPos = ta.selectionStart;
|
||||
if (cursorPos === null) return;
|
||||
syncPopupFromCursor(ta.value, cursorPos);
|
||||
};
|
||||
|
||||
const handleTextareaScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (backdropRef.current) {
|
||||
backdropRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = () => setPopup(null);
|
||||
|
||||
const pickItem = (idx: number) => {
|
||||
const it = items[idx];
|
||||
if (!it || !popup) return;
|
||||
const range = activeRangeRef.current;
|
||||
const before = range ? draft.slice(0, range.start) : draft;
|
||||
const after = range ? draft.slice(range.end) : "";
|
||||
if (popup.kind === "slash") {
|
||||
const cmd = (it as SlashCmd).cmd;
|
||||
const insertOnly = (it as SlashCmd).insertOnly === true;
|
||||
if (insertOnly) {
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next ? `${next} ${cmd} ` : `${cmd} `);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
const spacer = before && !before.endsWith(" ") ? " " : "";
|
||||
setDraft(`${before}${spacer}${cmd} ${after}`);
|
||||
setPickedChips((prev) => new Map(prev).set(cmd.replace(/^\//, ""), "slash"));
|
||||
} else {
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
setDraft(`${before}${after}`);
|
||||
(it as SlashCmd).run();
|
||||
}
|
||||
} else {
|
||||
const mention = it as MentionItem;
|
||||
if (mention.name === "..") {
|
||||
const parent = parentOfAtQuery(popup.query) ?? "";
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
|
||||
const newText = `@${parent}`;
|
||||
const next = `${before}${newText}${after}`;
|
||||
setDraft(next);
|
||||
activeRangeRef.current = {
|
||||
start: range?.start ?? 0,
|
||||
end: (range?.start ?? 0) + newText.length,
|
||||
sigil: "@",
|
||||
query: parent,
|
||||
};
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: parent, nonce });
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next ? `${next} @${mention.name} ` : `@${mention.name} `);
|
||||
setChips((c) => [...c, { kind: "at", label: mention.name }]);
|
||||
onMentionPicked?.(mention.name);
|
||||
const mentionPath = mention.path ?? mention.name;
|
||||
insertMention(mentionPath);
|
||||
return;
|
||||
}
|
||||
activeRangeRef.current = null;
|
||||
setPopup(null);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const recordSendAndReset = () => {
|
||||
const trimmed = draft.trim();
|
||||
historyRef.current.push(trimmed);
|
||||
if (historyRef.current.length > 100) historyRef.current.shift();
|
||||
setBrowseIdx(-1);
|
||||
};
|
||||
|
||||
const navigateHistory = (dir: -1 | 1) => {
|
||||
const hist = historyRef.current;
|
||||
if (hist.length === 0) return;
|
||||
if (dir === -1) {
|
||||
const nextIdx = browseIdx + 1;
|
||||
if (nextIdx < hist.length) {
|
||||
if (browseIdx === -1) savedDraftRef.current = draft;
|
||||
setBrowseIdx(nextIdx);
|
||||
setDraft(hist[hist.length - 1 - nextIdx]);
|
||||
}
|
||||
} else {
|
||||
if (browseIdx > 0) {
|
||||
const nextIdx = browseIdx - 1;
|
||||
setBrowseIdx(nextIdx);
|
||||
setDraft(hist[hist.length - 1 - nextIdx]);
|
||||
} else if (browseIdx === 0) {
|
||||
setBrowseIdx(-1);
|
||||
setDraft(savedDraftRef.current);
|
||||
}
|
||||
}
|
||||
const shouldNavigatePromptHistory = (
|
||||
e: KeyboardEvent<HTMLTextAreaElement>,
|
||||
direction: "older" | "newer",
|
||||
) => {
|
||||
if (!onPromptHistoryNavigate) return false;
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false;
|
||||
const native = e.nativeEvent as globalThis.KeyboardEvent;
|
||||
if (native.isComposing) return false;
|
||||
const ta = textareaRef.current;
|
||||
if (!ta || ta.selectionStart !== ta.selectionEnd) return false;
|
||||
const atStart = ta.selectionStart === 0;
|
||||
const atEnd = ta.selectionStart === draft.length;
|
||||
if (promptHistoryBrowsing) return atStart || atEnd;
|
||||
return direction === "older" && atStart;
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -387,25 +551,40 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && popup.kind === "at" && items.length > 0) {
|
||||
// Tab on a directory enters it — replaces `@src` with `@src/`
|
||||
// and re-queries so the popup shows that directory's children.
|
||||
e.preventDefault();
|
||||
// `..` is the synthetic parent-dir entry (#1019); same shape
|
||||
// but rewrites to the parent path.
|
||||
const it = items[activeIdx];
|
||||
if (it && (it as MentionItem).kind === "dir") {
|
||||
e.preventDefault();
|
||||
const mention = it as MentionItem;
|
||||
const range = activeRangeRef.current;
|
||||
const before = range ? draft.slice(0, range.start) : draft;
|
||||
const after = range ? draft.slice(range.end) : "";
|
||||
if (mention.name === "..") {
|
||||
const parent = parentOfAtQuery(popup.query) ?? "";
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
|
||||
const newText = `@${parent}`;
|
||||
const next = `${before}${newText}${after}`;
|
||||
setDraft(next);
|
||||
activeRangeRef.current = {
|
||||
start: range?.start ?? 0,
|
||||
end: (range?.start ?? 0) + newText.length,
|
||||
sigil: "@",
|
||||
query: parent,
|
||||
};
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: parent, nonce });
|
||||
return;
|
||||
}
|
||||
const dirPath = mention.name.replace(/\/+$/, "");
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
|
||||
const dirPath = (mention.path ?? mention.name).replace(/[\\/]+$/, "");
|
||||
const newText = `@${dirPath}/`;
|
||||
const next = `${before}${newText}${after}`;
|
||||
setDraft(next);
|
||||
activeRangeRef.current = {
|
||||
start: range?.start ?? 0,
|
||||
end: (range?.start ?? 0) + newText.length,
|
||||
sigil: "@",
|
||||
query: `${dirPath}/`,
|
||||
};
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query: `${dirPath}/`, nonce });
|
||||
return;
|
||||
@@ -421,31 +600,29 @@ export function Composer({
|
||||
}
|
||||
}
|
||||
if (!popup) {
|
||||
const ta = textareaRef.current;
|
||||
if (e.key === "ArrowUp" && ta && ta.selectionStart === 0) {
|
||||
e.preventDefault();
|
||||
navigateHistory(-1);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown" && ta && ta.selectionStart === draft.length) {
|
||||
e.preventDefault();
|
||||
navigateHistory(1);
|
||||
return;
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const direction = e.key === "ArrowUp" ? "older" : "newer";
|
||||
if (shouldNavigatePromptHistory(e, direction)) {
|
||||
const handled = onPromptHistoryNavigate?.(direction);
|
||||
if (handled !== false) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (composingRef.current || Date.now() - compositionEndedAtRef.current < 50) return;
|
||||
if (composingRef.current || e.nativeEvent.isComposing || Date.now() - compositionEndedAtRef.current < 50) return;
|
||||
if (e.key === "Enter" && !e.shiftKey && !popup) {
|
||||
e.preventDefault();
|
||||
if (busy) {
|
||||
const text = draft.trim();
|
||||
if (text && onQueueWhileBusy) {
|
||||
onQueueWhileBusy(text);
|
||||
setChips([]);
|
||||
setPickedChips(new Map());
|
||||
}
|
||||
} else if (!disabled && draft.trim()) {
|
||||
recordSendAndReset();
|
||||
onSend();
|
||||
setChips([]);
|
||||
setPickedChips(new Map());
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -511,40 +688,40 @@ export function Composer({
|
||||
{chips.length > 0 ? (
|
||||
<div className="composer-tags">
|
||||
{chips.map((c, i) => (
|
||||
<span key={i} className={`chip ${c.kind}`}>
|
||||
<span key={`${c.kind}-${c.label}-${i}`} className={`chip ${c.kind}`} title={c.label}>
|
||||
{c.kind === "slash" ? (
|
||||
<I.slash size={11} />
|
||||
) : (
|
||||
<I.at size={11} />
|
||||
)}
|
||||
<span>{c.label}</span>
|
||||
<span
|
||||
className="x"
|
||||
onClick={() =>
|
||||
setChips((cs) => cs.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
<I.x size={10} />
|
||||
</span>
|
||||
<span>{chipLabel(c.label)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
placeholder={t("composer.placeholder")}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true; }}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
compositionEndedAtRef.current = Date.now();
|
||||
}}
|
||||
rows={DEFAULT_COMPOSER_ROWS}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="composer-textarea-wrap">
|
||||
<div className="composer-backdrop" ref={backdropRef} aria-hidden="true">
|
||||
{backdropContent}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
placeholder={t("composer.placeholder")}
|
||||
onChange={handleChange}
|
||||
onSelect={handleSelect}
|
||||
onScroll={handleTextareaScroll}
|
||||
onPaste={(e) => void handlePaste(e)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true; }}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
compositionEndedAtRef.current = Date.now();
|
||||
}}
|
||||
rows={DEFAULT_COMPOSER_ROWS}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="composer-foot">
|
||||
<button
|
||||
@@ -637,9 +814,8 @@ export function Composer({
|
||||
disabled={disabled || !draft.trim()}
|
||||
onClick={() => {
|
||||
if (!disabled && draft.trim()) {
|
||||
recordSendAndReset();
|
||||
onSend();
|
||||
setChips([]);
|
||||
setPickedChips(new Map());
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -658,7 +834,8 @@ export function Composer({
|
||||
onHover={(i, item) => {
|
||||
setActiveIdx(i);
|
||||
if (popup.kind === "at" && onMentionPreview) {
|
||||
const path = (item as MentionItem).name;
|
||||
const mention = item as MentionItem;
|
||||
const path = mention.path ?? mention.name;
|
||||
onMentionPreview(path, popup.nonce);
|
||||
}
|
||||
}}
|
||||
@@ -690,12 +867,15 @@ function Popup({
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = listRef.current?.querySelector<HTMLElement>(`[data-active="true"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
el?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
});
|
||||
}, [activeIdx]);
|
||||
|
||||
return (
|
||||
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div
|
||||
className={kind === "at" ? "popup at-popup" : "popup"}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="ph">
|
||||
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
|
||||
<span>
|
||||
@@ -708,7 +888,7 @@ function Popup({
|
||||
<I.x size={11} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-list" ref={listRef}>
|
||||
<div className={kind === "at" ? "popup-list at-popup-list" : "popup-list"} ref={listRef}>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -742,7 +922,7 @@ function Popup({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{(it as MentionItem).name}</span>
|
||||
<span className="mention-name">{(it as MentionItem).name}</span>
|
||||
{(it as MentionItem).desc ? (
|
||||
<div className="desc">{(it as MentionItem).desc}</div>
|
||||
) : null}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { SessionFile, Settings, UsageStats } from "../App";
|
||||
import { Markdown } from "../Markdown";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo, MemoryEntryInfo } from "../protocol";
|
||||
import type { McpSpecInfo, MemoryDetail, MemoryEntryInfo } from "../protocol";
|
||||
import { PanelErrorBoundary } from "./error-boundary";
|
||||
import { McpServerCard } from "./mcp-server-card";
|
||||
|
||||
type Tab = "files" | "tools" | "memory" | "rules";
|
||||
export type ContextPanelTab = "files" | "tools" | "memory" | "rules";
|
||||
|
||||
const CONTEXT_MAX_TOKENS = 1_000_000;
|
||||
|
||||
@@ -18,6 +20,13 @@ export function ContextPanel({
|
||||
mcpBridged,
|
||||
sessionFiles,
|
||||
memory,
|
||||
memoryDetail,
|
||||
activeTab,
|
||||
activeTabNonce,
|
||||
onReadMemory,
|
||||
onOpenMcpSettings,
|
||||
onEditMcpSpec,
|
||||
onRetryMcpSpec,
|
||||
}: {
|
||||
settings: Settings | null;
|
||||
usage: UsageStats;
|
||||
@@ -25,14 +34,26 @@ export function ContextPanel({
|
||||
mcpBridged: boolean;
|
||||
sessionFiles: SessionFile[];
|
||||
memory: MemoryEntryInfo[];
|
||||
memoryDetail: MemoryDetail | null;
|
||||
activeTab?: ContextPanelTab;
|
||||
activeTabNonce?: number;
|
||||
onReadMemory: (path: string) => void;
|
||||
onOpenMcpSettings?: () => void;
|
||||
onEditMcpSpec?: (spec: McpSpecInfo) => void;
|
||||
onRetryMcpSpec?: (raw: string) => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [tab, setTab] = useState<Tab>("files");
|
||||
const [tab, setTab] = useState<ContextPanelTab>("files");
|
||||
useEffect(() => {
|
||||
if (activeTab) setTab(activeTab);
|
||||
}, [activeTab, activeTabNonce]);
|
||||
const reserved = usage.reservedTokens;
|
||||
const lastHit = usage.lastCallCacheHit ?? 0;
|
||||
const lastMiss = usage.lastCallCacheMiss ?? 0;
|
||||
const cached = Math.max(0, lastHit - reserved);
|
||||
const used = Math.max(0, lastMiss - Math.max(0, reserved - lastHit));
|
||||
const observedLog = Math.max(0, lastHit + lastMiss - reserved);
|
||||
const logTokens = Math.max(usage.liveLogTokens, observedLog);
|
||||
const cached = Math.min(logTokens, Math.max(0, lastHit - reserved));
|
||||
const used = Math.max(0, logTokens - cached);
|
||||
const reservedPct = Math.min(100, (reserved / CONTEXT_MAX_TOKENS) * 100);
|
||||
const usedPct = Math.min(100, (used / CONTEXT_MAX_TOKENS) * 100);
|
||||
const cachedPct = Math.min(100, (cached / CONTEXT_MAX_TOKENS) * 100);
|
||||
@@ -55,7 +76,7 @@ export function ContextPanel({
|
||||
</div>
|
||||
|
||||
<div className="ctx-body">
|
||||
<div className="ctx-block">
|
||||
<div className="ctx-block ctx-body-tokens">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.contextTokens")}</span>
|
||||
<span className="right">
|
||||
@@ -87,12 +108,24 @@ export function ContextPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PanelErrorBoundary key={tab} label={tab}>
|
||||
{tab === "files" && <CtxFiles files={sessionFiles} settings={settings} />}
|
||||
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
|
||||
{tab === "memory" && <CtxMemory entries={memory} />}
|
||||
{tab === "rules" && <CtxRules settings={settings} />}
|
||||
</PanelErrorBoundary>
|
||||
<div className="ctx-body-tab">
|
||||
<PanelErrorBoundary key={tab} label={tab}>
|
||||
{tab === "files" && <CtxFiles files={sessionFiles} settings={settings} />}
|
||||
{tab === "tools" && (
|
||||
<CtxTools
|
||||
specs={mcpSpecs}
|
||||
bridged={mcpBridged}
|
||||
onOpenSettings={onOpenMcpSettings}
|
||||
onEdit={onEditMcpSpec}
|
||||
onRetry={onRetryMcpSpec}
|
||||
/>
|
||||
)}
|
||||
{tab === "memory" && (
|
||||
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
|
||||
)}
|
||||
{tab === "rules" && <CtxRules settings={settings} />}
|
||||
</PanelErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -104,11 +137,14 @@ type TreeNode =
|
||||
|
||||
async function openContextFile(path: string, settings: Settings | null): Promise<void> {
|
||||
const workspaceDir = settings?.workspaceDir;
|
||||
const sep = workspaceDir?.includes("\\") ? "\\" : "/";
|
||||
const isWindows = workspaceDir?.includes("\\") ?? false;
|
||||
const sep = isWindows ? "\\" : "/";
|
||||
const abs =
|
||||
workspaceDir && !/^[a-zA-Z]:[\\/]/.test(path) && !path.startsWith("/")
|
||||
? `${workspaceDir.replace(/[\\/]$/, "")}${sep}${path.replace(/^[\\/]+/, "")}`
|
||||
: path;
|
||||
? `${workspaceDir.replace(/[\\/]$/, "")}${sep}${path.replace(/^[\\/]+/, "").replace(/\//g, sep)}`
|
||||
: isWindows
|
||||
? path.replace(/\//g, "\\")
|
||||
: path;
|
||||
const editor = settings?.editor?.trim();
|
||||
if (editor) {
|
||||
await invoke("open_in_editor", { command: editor, path: abs, line: null });
|
||||
@@ -225,8 +261,36 @@ function CtxFiles({ files, settings }: { files: SessionFile[]; settings: Setting
|
||||
);
|
||||
}
|
||||
|
||||
function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }) {
|
||||
type McpFilter = "all" | "ready" | "failed";
|
||||
|
||||
function CtxTools({
|
||||
specs,
|
||||
bridged,
|
||||
onOpenSettings,
|
||||
onEdit,
|
||||
onRetry,
|
||||
}: {
|
||||
specs: McpSpecInfo[];
|
||||
bridged: boolean;
|
||||
onOpenSettings?: () => void;
|
||||
onEdit?: (spec: McpSpecInfo) => void;
|
||||
onRetry?: (raw: string) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<McpFilter>("all");
|
||||
const readyCount = specs.filter((s) => s.status === "connected").length;
|
||||
const failed = specs.filter((s) => s.status === "failed");
|
||||
const failedCount = failed.length;
|
||||
const toolCount = specs.reduce((sum, s) => sum + (s.toolCount ?? s.tools?.length ?? 0), 0);
|
||||
const filtered =
|
||||
filter === "ready"
|
||||
? specs.filter((s) => s.status === "connected")
|
||||
: filter === "failed"
|
||||
? failed
|
||||
: [...failed, ...specs.filter((s) => s.status !== "failed")];
|
||||
const retryAll = () => {
|
||||
if (!onRetry) return;
|
||||
for (const spec of failed) onRetry(spec.raw);
|
||||
};
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
@@ -242,50 +306,75 @@ function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }
|
||||
{specs.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.mcpEmpty")}</div>
|
||||
) : (
|
||||
specs.map((s) => {
|
||||
const dot =
|
||||
s.status === "connected"
|
||||
? "ok"
|
||||
: s.status === "failed" || s.parseError
|
||||
? "off"
|
||||
: "pending";
|
||||
const suffix = s.statusReason
|
||||
? ` · ${s.statusReason}`
|
||||
: s.status === "connected"
|
||||
? typeof s.toolCount === "number"
|
||||
? ` · ${t("contextPanel.mcpTools", { count: s.toolCount })}`
|
||||
: ` · ${t("contextPanel.mcpReady")}`
|
||||
: s.status === "handshake"
|
||||
? ` · ${t("contextPanel.mcpConnecting")}`
|
||||
: s.status === "disabled"
|
||||
? ` · ${t("contextPanel.mcpDisabled")}`
|
||||
: s.status === "failed"
|
||||
? ` · ${t("contextPanel.mcpFailed")}`
|
||||
: ` · ${t("contextPanel.mcpConfigured")}`;
|
||||
return (
|
||||
<div className="mcp-row" key={s.raw}>
|
||||
<span className="ico">
|
||||
<I.wrench size={12} />
|
||||
</span>
|
||||
<div className="body">
|
||||
<div className="n">{s.name ?? s.summary}</div>
|
||||
<div className="m">
|
||||
{s.transport}
|
||||
{suffix}
|
||||
</div>
|
||||
</div>
|
||||
<span className="status" data-s={dot} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<>
|
||||
<div className="mcp-health-strip">
|
||||
<span>{t("contextPanel.mcpHealthTotal", { count: specs.length })}</span>
|
||||
<span data-kind="ok">{t("contextPanel.mcpHealthReady", { count: readyCount })}</span>
|
||||
<span data-kind={failedCount > 0 ? "failed" : "muted"}>
|
||||
{t("contextPanel.mcpHealthFailed", { count: failedCount })}
|
||||
</span>
|
||||
<span>{t("contextPanel.mcpHealthTools", { count: toolCount })}</span>
|
||||
</div>
|
||||
<div className="mcp-filter-row">
|
||||
{(["all", "ready", "failed"] as const).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
className="mcp-filter"
|
||||
data-active={filter === kind}
|
||||
onClick={() => setFilter(kind)}
|
||||
>
|
||||
{kind === "all"
|
||||
? t("contextPanel.mcpFilterAll")
|
||||
: kind === "ready"
|
||||
? t("contextPanel.mcpFilterReady")
|
||||
: t("contextPanel.mcpFilterFailed")}
|
||||
</button>
|
||||
))}
|
||||
<span className="spacer" />
|
||||
{failedCount > 0 && onRetry ? (
|
||||
<button type="button" className="mcp-mini-action" onClick={retryAll}>
|
||||
<I.refresh size={12} />
|
||||
{t("contextPanel.mcpRetryAll")}
|
||||
</button>
|
||||
) : null}
|
||||
{onOpenSettings ? (
|
||||
<button type="button" className="mcp-mini-action" onClick={onOpenSettings}>
|
||||
<I.cog size={12} />
|
||||
{t("contextPanel.mcpSettings")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.mcpFilterEmpty")}</div>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<McpServerCard
|
||||
key={s.raw}
|
||||
spec={s}
|
||||
mode="context"
|
||||
onRetry={onRetry}
|
||||
onEdit={onEdit ?? (onOpenSettings ? () => onOpenSettings() : undefined)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
|
||||
function CtxMemory({
|
||||
entries,
|
||||
detail,
|
||||
onRead,
|
||||
}: {
|
||||
entries: MemoryEntryInfo[];
|
||||
detail: MemoryDetail | null;
|
||||
onRead: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctx-block">
|
||||
<div className="ctx-block" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.memoryTitle")}</span>
|
||||
<span className="right">
|
||||
@@ -295,15 +384,28 @@ function CtxMemory({ entries }: { entries: MemoryEntryInfo[] }) {
|
||||
{entries.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
|
||||
) : (
|
||||
<div className="mem">
|
||||
{entries.map((m) => (
|
||||
<div className="mem-row" key={`${m.scope}/${m.name}`}>
|
||||
<span className="scope" data-s={m.scope}>
|
||||
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
|
||||
</span>
|
||||
<span className="txt">{m.description || m.name}</span>
|
||||
<div className="mem" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
className="mem-row"
|
||||
data-active={detail?.path === m.path}
|
||||
key={m.path}
|
||||
onClick={() => onRead(m.path)}
|
||||
>
|
||||
<span className="scope" data-s={m.scope}>
|
||||
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
|
||||
</span>
|
||||
<span className="txt">{m.description || m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{detail ? (
|
||||
<div className="mem-detail">
|
||||
<Markdown source={detail.body} />
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface JumpBarProps {
|
||||
messages: { kind: string; text?: string; turn?: number }[];
|
||||
threadEl?: HTMLElement | null;
|
||||
onScrollToTurn?: (turn: number) => void;
|
||||
}
|
||||
|
||||
export function JumpBar({ messages, threadEl, onScrollToTurn }: JumpBarProps) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const previewTop = useRef(0);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
messages
|
||||
.filter((m): m is { kind: "user"; text: string; turn: number } =>
|
||||
m.kind === "user" && typeof m.text === "string" && typeof m.turn === "number",
|
||||
)
|
||||
.map((m) => ({ turn: m.turn, text: m.text.slice(0, 80) })),
|
||||
[messages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length > 0) setActive(items[items.length - 1]!.turn);
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return;
|
||||
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
if (items.length < 2) return null;
|
||||
|
||||
const hoverIdx = hovered !== null ? items.findIndex((v) => v.turn === hovered) : -1;
|
||||
const hoverText = hovered !== null ? items.find((v) => v.turn === hovered)?.text : null;
|
||||
|
||||
const onMove = (e: React.MouseEvent) => {
|
||||
const el = barRef.current;
|
||||
if (!el) return;
|
||||
const q = el.querySelectorAll<HTMLElement>(".jump-item");
|
||||
const barRect = el.getBoundingClientRect();
|
||||
let closest = -1;
|
||||
let closestDist = Infinity;
|
||||
q.forEach((item, i) => {
|
||||
const r = item.getBoundingClientRect();
|
||||
const midY = r.top + r.height / 2;
|
||||
const dist = Math.abs(e.clientY - midY);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = i;
|
||||
previewTop.current = midY - barRect.top;
|
||||
}
|
||||
});
|
||||
if (closest >= 0 && closest < items.length) {
|
||||
const turn = items[closest]?.turn;
|
||||
if (turn !== undefined) {
|
||||
setHovered(turn);
|
||||
setShowPreview(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scrollTo = (turn: number) => {
|
||||
setActive(turn);
|
||||
if (onScrollToTurn) {
|
||||
onScrollToTurn(turn);
|
||||
} else {
|
||||
threadEl
|
||||
?.querySelector(`[data-turn="${turn}"]`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
const dotProps = (
|
||||
idx: number,
|
||||
turn: number,
|
||||
): { style: React.CSSProperties; "data-d"?: string } => {
|
||||
const isActive = active === turn;
|
||||
if (hoverIdx < 0) {
|
||||
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
|
||||
}
|
||||
const d = Math.abs(idx - hoverIdx);
|
||||
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
|
||||
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
|
||||
return {
|
||||
style: { width, transitionDelay: `${d * 20}ms`, background },
|
||||
"data-d": d <= 2 ? String(d) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jump-bar" ref={barRef} onMouseMove={onMove} onMouseLeave={() => { setHovered(null); setShowPreview(false); }}>
|
||||
<div className="jump-scroll">
|
||||
{items.map((item, idx) => (
|
||||
<div className="jump-item" key={item.turn} data-turn={item.turn} onClick={() => scrollTo(item.turn)}>
|
||||
<div className="jump-dot" {...dotProps(idx, item.turn)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showPreview && hoverText && (
|
||||
<div className="jump-preview" style={{ top: previewTop.current }}>
|
||||
<span className="jump-text">{hoverText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo } from "../protocol";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export type McpServerCardMode = "settings" | "context";
|
||||
|
||||
export function mcpStatusLabel(spec: McpSpecInfo): string {
|
||||
if (spec.status === "failed") return mcpFailureLabel(spec);
|
||||
switch (spec.status) {
|
||||
case "connected":
|
||||
return t("settings.mcpStatus.connected");
|
||||
case "handshake":
|
||||
return t("settings.mcpStatus.handshake");
|
||||
case "disabled":
|
||||
return t("settings.mcpStatus.disabled");
|
||||
case "configured":
|
||||
return t("settings.mcpStatus.configured");
|
||||
}
|
||||
}
|
||||
|
||||
export function mcpFailureLabel(spec: McpSpecInfo): string {
|
||||
switch (spec.statusHint) {
|
||||
case "missing-token":
|
||||
return t("settings.mcpFailure.missingToken");
|
||||
case "auth":
|
||||
return t("settings.mcpFailure.auth");
|
||||
case "command":
|
||||
return t("settings.mcpFailure.command");
|
||||
case "network":
|
||||
return t("settings.mcpFailure.network");
|
||||
default:
|
||||
return t("settings.mcpFailure.unknown");
|
||||
}
|
||||
}
|
||||
|
||||
export function mcpEditLabel(spec: McpSpecInfo): string {
|
||||
void spec;
|
||||
return t("settings.mcpEdit");
|
||||
}
|
||||
|
||||
export function McpServerCard({
|
||||
spec,
|
||||
mode = "settings",
|
||||
onEdit,
|
||||
onRetry,
|
||||
onRemove,
|
||||
}: {
|
||||
spec: McpSpecInfo;
|
||||
mode?: McpServerCardMode;
|
||||
onEdit?: (spec: McpSpecInfo) => void;
|
||||
onRetry?: (raw: string) => void;
|
||||
onRemove?: (raw: string) => void;
|
||||
}) {
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const tools = spec.tools ?? [];
|
||||
const hasTools = tools.length > 0;
|
||||
const canExpandTools = hasTools || spec.status === "connected";
|
||||
const displayName = spec.name ?? "(anonymous)";
|
||||
const status = mcpStatusLabel(spec);
|
||||
const toolCount = typeof spec.toolCount === "number" ? spec.toolCount : tools.length;
|
||||
const statusText = `${status}${toolCount ? ` · ${t("contextPanel.mcpTools", { count: toolCount })}` : ""}`;
|
||||
const statusTooltip =
|
||||
spec.status === "failed" && spec.statusReason
|
||||
? `${statusText}\n${spec.statusReason}`
|
||||
: statusText;
|
||||
|
||||
return (
|
||||
<div className="scard mcp-server-card" data-mode={mode} data-status={spec.status}>
|
||||
<div className="top">
|
||||
<span className="ico">
|
||||
<I.wrench size={14} />
|
||||
</span>
|
||||
<div className="mcp-spec-body">
|
||||
<div className="nm">{displayName}</div>
|
||||
<div className="sub mcp-spec-summary" title={spec.summary}>
|
||||
{spec.summary}
|
||||
</div>
|
||||
<Tooltip content={statusTooltip} className="mcp-status-tooltip">
|
||||
<span className="mcp-spec-status" data-status={spec.status}>
|
||||
{statusText}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mcp-card-actions">
|
||||
{canExpandTools ? (
|
||||
<button type="button" className="btn ghost" onClick={() => setToolsOpen((v) => !v)}>
|
||||
<I.chev size={13} className={toolsOpen ? "rot" : ""} />
|
||||
{toolsOpen ? t("mcpCard.hideTools") : t("mcpCard.showTools")}
|
||||
</button>
|
||||
) : null}
|
||||
{onEdit && !spec.parseError ? (
|
||||
<button type="button" className="btn ghost" onClick={() => onEdit(spec)}>
|
||||
<I.pencil size={13} />
|
||||
{mcpEditLabel(spec)}
|
||||
</button>
|
||||
) : null}
|
||||
{onRetry && spec.status === "failed" ? (
|
||||
<button type="button" className="btn ghost" onClick={() => onRetry(spec.raw)}>
|
||||
<I.refresh size={13} />
|
||||
{t("settings.mcpRetry")}
|
||||
</button>
|
||||
) : null}
|
||||
{spec.status === "failed" && spec.statusReason ? (
|
||||
<button type="button" className="btn ghost" onClick={() => setDetailOpen((v) => !v)}>
|
||||
{detailOpen ? t("settings.mcpHideDetail") : t("settings.mcpDetail")}
|
||||
</button>
|
||||
) : null}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost mcp-remove"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => onRemove(spec.raw)}
|
||||
>
|
||||
{t("settings.mcpRemove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{spec.parseError ? (
|
||||
<div className="desc" style={{ color: "var(--danger)" }}>
|
||||
{t("settings.parseError", { error: spec.parseError })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{toolsOpen ? (
|
||||
<div className="mcp-tools-detail">
|
||||
<div className="mcp-tools-detail-title">
|
||||
{hasTools
|
||||
? t("mcpCard.availableTools", { count: tools.length })
|
||||
: t("mcpCard.noTools")}
|
||||
</div>
|
||||
{hasTools ? (
|
||||
<div className="mcp-tools-grid">
|
||||
{tools.map((tool) => (
|
||||
<div className="mcp-tool-chip" key={tool.registeredName} title={tool.description ?? tool.registeredName}>
|
||||
<span className="mcp-tool-name">{tool.registeredName}</span>
|
||||
{tool.description ? <span className="mcp-tool-desc">{tool.description}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailOpen && spec.statusReason ? (
|
||||
<div className="mcp-error-detail">
|
||||
<div className="mcp-error-detail-title">{t("settings.mcpDetailTitle")}</div>
|
||||
<pre>{spec.statusReason}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+626
-68
@@ -1,8 +1,16 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
|
||||
import { setLang, t, useLang } from "../i18n";
|
||||
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo, SettingsPatch, SkillInfo } from "../protocol";
|
||||
import type {
|
||||
ImportedMcpServer,
|
||||
McpSpecInfo,
|
||||
MemoryDetail,
|
||||
MemoryEntryInfo,
|
||||
SettingsPatch,
|
||||
SkillInfo,
|
||||
} from "../protocol";
|
||||
import {
|
||||
describeQQRowSummary,
|
||||
getQQConnectIntent,
|
||||
@@ -20,6 +28,7 @@ import {
|
||||
type ThemeStyle,
|
||||
themeForStyle,
|
||||
} from "../theme";
|
||||
import { McpServerCard } from "./mcp-server-card";
|
||||
import { Shortcut, type ShortcutKey } from "./shortcut";
|
||||
|
||||
export type PageId =
|
||||
@@ -59,9 +68,13 @@ export function SettingsModal({
|
||||
customFontFamily,
|
||||
onSetCustomFontFamily,
|
||||
initialPage,
|
||||
initialMcpEditRaw,
|
||||
initialMcpEditNonce,
|
||||
mcpSpecs,
|
||||
mcpBridged,
|
||||
skills,
|
||||
memory,
|
||||
memoryDetail,
|
||||
qq,
|
||||
onClose,
|
||||
onSave,
|
||||
@@ -72,8 +85,12 @@ export function SettingsModal({
|
||||
onSaveQQConfig,
|
||||
onOpenQQApplyLink,
|
||||
onPickWorkspace,
|
||||
onImportCcSwitchMcp,
|
||||
onAddMcpSpec,
|
||||
onRemoveMcpSpec,
|
||||
onUpdateMcpSpec,
|
||||
onRetryMcpSpec,
|
||||
onReadMemory,
|
||||
}: {
|
||||
settings: SettingsType;
|
||||
balance: Balance | null;
|
||||
@@ -90,9 +107,13 @@ export function SettingsModal({
|
||||
customFontFamily: string;
|
||||
onSetCustomFontFamily: (family: string) => void;
|
||||
initialPage?: PageId;
|
||||
initialMcpEditRaw?: string | null;
|
||||
initialMcpEditNonce?: number;
|
||||
mcpSpecs: McpSpecInfo[];
|
||||
mcpBridged: boolean;
|
||||
skills: SkillInfo[];
|
||||
memory: MemoryEntryInfo[];
|
||||
memoryDetail: MemoryDetail | null;
|
||||
qq: QQDesktopSettingsState | null;
|
||||
onClose: () => void;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
@@ -103,11 +124,18 @@ export function SettingsModal({
|
||||
onSaveQQConfig: (patch: { appId?: string; appSecret?: string; sandbox: boolean }) => void;
|
||||
onOpenQQApplyLink: () => void;
|
||||
onPickWorkspace: () => void;
|
||||
onImportCcSwitchMcp: () => Promise<void>;
|
||||
onAddMcpSpec: (spec: string) => void;
|
||||
onRemoveMcpSpec: (spec: string) => void;
|
||||
onUpdateMcpSpec: (raw: string, server: ImportedMcpServer) => void;
|
||||
onRetryMcpSpec: (raw: string) => void;
|
||||
onReadMemory: (path: string) => void;
|
||||
}) {
|
||||
const [page, setPage] = useState<PageId>(initialPage ?? "general");
|
||||
const [qqConfigureOpen, setQQConfigureOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (initialPage) setPage(initialPage);
|
||||
}, [initialPage]);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
@@ -178,8 +206,13 @@ export function SettingsModal({
|
||||
<PageMCP
|
||||
specs={mcpSpecs}
|
||||
bridged={mcpBridged}
|
||||
initialEditRaw={initialMcpEditRaw}
|
||||
initialEditNonce={initialMcpEditNonce}
|
||||
onImportCcSwitch={onImportCcSwitchMcp}
|
||||
onAdd={onAddMcpSpec}
|
||||
onRemove={onRemoveMcpSpec}
|
||||
onUpdate={onUpdateMcpSpec}
|
||||
onRetry={onRetryMcpSpec}
|
||||
/>
|
||||
)}
|
||||
{page === "skills" && (
|
||||
@@ -189,7 +222,9 @@ export function SettingsModal({
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
{page === "memory" && <PageMemory />}
|
||||
{page === "memory" && (
|
||||
<PageMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
|
||||
)}
|
||||
{page === "rules" && <PageRules settings={settings} onSave={onSave} />}
|
||||
{page === "billing" && (
|
||||
<PageBilling balance={balance} usage={usage} currency={currency} />
|
||||
@@ -591,16 +626,11 @@ function PageGeneral({
|
||||
<div className="h">{t("settings.languageHint")}</div>
|
||||
</div>
|
||||
<div className="seg-ctrl">
|
||||
<button
|
||||
type="button"
|
||||
data-on={lang === "zh-CN"}
|
||||
onClick={() => setLang("zh-CN")}
|
||||
>
|
||||
{t("settings.langZhCn")}
|
||||
</button>
|
||||
<button type="button" data-on={lang === "en"} onClick={() => setLang("en")}>
|
||||
{t("settings.langEn")}
|
||||
</button>
|
||||
{getSupportedLangs().map((code) => (
|
||||
<button type="button" key={code} data-on={lang === code} onClick={() => setLang(code)}>
|
||||
{getLangLabel(code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -626,7 +656,7 @@ function PageGeneral({
|
||||
value={editorDraft}
|
||||
placeholder="cursor --goto"
|
||||
onChange={(e) => setEditorDraft(e.target.value)}
|
||||
onBlur={() => onSave({ editor: editorDraft || undefined })}
|
||||
onBlur={() => onSave({ editor: editorDraft.trim() })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -657,7 +687,7 @@ function PageGeneral({
|
||||
<div className="h">{t("settings.editModeHint")}</div>
|
||||
</div>
|
||||
<div className="seg-ctrl">
|
||||
{(["review", "auto", "yolo"] as const).map((m) => (
|
||||
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m}
|
||||
@@ -691,6 +721,28 @@ function PageGeneral({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="l">
|
||||
<div className="n">{t("settings.desktopCloseBehavior")}</div>
|
||||
<div className="h">{t("settings.desktopCloseBehaviorHint")}</div>
|
||||
</div>
|
||||
<div className="seg-ctrl">
|
||||
<button
|
||||
type="button"
|
||||
data-on={(settings.desktopCloseBehavior ?? "closeToQuit") === "closeToQuit"}
|
||||
onClick={() => onSave({ desktopCloseBehavior: "closeToQuit" })}
|
||||
>
|
||||
{t("settings.closeToQuit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-on={settings.desktopCloseBehavior === "closeToTray"}
|
||||
onClick={() => onSave({ desktopCloseBehavior: "closeToTray" })}
|
||||
>
|
||||
{t("settings.closeToTray")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="l">
|
||||
<div className="n">{t("settings.budget")}</div>
|
||||
@@ -719,27 +771,199 @@ function PageGeneral({
|
||||
onSave({
|
||||
webSearchEngine: e.target.value as
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa",
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "ollama",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="bing">{t("settings.webSearchEngineBing")}</option>
|
||||
<option value="bing-intl">{t("settings.webSearchEngineBingIntl")}</option>
|
||||
<option value="searxng">{t("settings.webSearchEngineSearxng")}</option>
|
||||
<option value="metaso">{t("settings.webSearchEngineMetaso")}</option>
|
||||
<option value="baidu">{t("settings.webSearchEngineBaidu")}</option>
|
||||
<option value="tavily">{t("settings.webSearchEngineTavily")}</option>
|
||||
<option value="perplexity">{t("settings.webSearchEnginePerplexity")}</option>
|
||||
<option value="exa">{t("settings.webSearchEngineExa")}</option>
|
||||
<option value="brave">{t("settings.webSearchEngineBrave")}</option>
|
||||
<option value="ollama">{t("settings.webSearchEngineOllama")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<WebSearchEngineCredentials settings={settings} onSave={onSave} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SEARCH_ENGINE_API_KEY_FIELDS: ReadonlyArray<{
|
||||
engine: "metaso" | "baidu" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
|
||||
patchKey:
|
||||
| "metasoApiKey"
|
||||
| "baiduApiKey"
|
||||
| "tavilyApiKey"
|
||||
| "perplexityApiKey"
|
||||
| "exaApiKey"
|
||||
| "braveApiKey"
|
||||
| "ollamaApiKey";
|
||||
signupUrl: string;
|
||||
}> = [
|
||||
{ engine: "metaso", patchKey: "metasoApiKey", signupUrl: "https://metaso.cn/settings/api" },
|
||||
{
|
||||
engine: "baidu",
|
||||
patchKey: "baiduApiKey",
|
||||
signupUrl: "https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy",
|
||||
},
|
||||
{ engine: "tavily", patchKey: "tavilyApiKey", signupUrl: "https://app.tavily.com" },
|
||||
{
|
||||
engine: "perplexity",
|
||||
patchKey: "perplexityApiKey",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
},
|
||||
{ engine: "exa", patchKey: "exaApiKey", signupUrl: "https://dashboard.exa.ai/api-keys" },
|
||||
{ engine: "brave", patchKey: "braveApiKey", signupUrl: "https://brave.com/search/api/" },
|
||||
{ engine: "ollama", patchKey: "ollamaApiKey", signupUrl: "https://ollama.com/settings/keys" },
|
||||
];
|
||||
|
||||
function WebSearchEngineCredentials({
|
||||
settings,
|
||||
onSave,
|
||||
}: {
|
||||
settings: SettingsType;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
const engine = settings.webSearchEngine ?? "bing";
|
||||
if (engine === "bing" || engine === "bing-intl") return null;
|
||||
if (engine === "searxng") {
|
||||
return <SearxngEndpointRow settings={settings} onSave={onSave} />;
|
||||
}
|
||||
const field = SEARCH_ENGINE_API_KEY_FIELDS.find((f) => f.engine === engine);
|
||||
if (!field) return null;
|
||||
const prefix = settings.webSearchApiKeys?.[engine];
|
||||
return (
|
||||
<WebSearchApiKeyRow
|
||||
engine={engine}
|
||||
patchKey={field.patchKey}
|
||||
signupUrl={field.signupUrl}
|
||||
prefix={prefix}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SearxngEndpointRow({
|
||||
settings,
|
||||
onSave,
|
||||
}: {
|
||||
settings: SettingsType;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(settings.webSearchEndpoint ?? "");
|
||||
useEffect(() => {
|
||||
setDraft(settings.webSearchEndpoint ?? "");
|
||||
}, [settings.webSearchEndpoint]);
|
||||
return (
|
||||
<div className="setting-row">
|
||||
<div className="l">
|
||||
<div className="n">{t("settings.webSearchEndpoint")}</div>
|
||||
<div className="h">{t("settings.webSearchEndpointHint")}</div>
|
||||
</div>
|
||||
<input
|
||||
className="field mono"
|
||||
value={draft}
|
||||
placeholder="http://localhost:8080"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = draft.trim();
|
||||
if (next === (settings.webSearchEndpoint ?? "")) return;
|
||||
onSave({ webSearchEndpoint: next || null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchApiKeyRow({
|
||||
engine,
|
||||
patchKey,
|
||||
signupUrl,
|
||||
prefix,
|
||||
onSave,
|
||||
}: {
|
||||
engine: "metaso" | "baidu" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
|
||||
patchKey:
|
||||
| "metasoApiKey"
|
||||
| "baiduApiKey"
|
||||
| "tavilyApiKey"
|
||||
| "perplexityApiKey"
|
||||
| "exaApiKey"
|
||||
| "braveApiKey"
|
||||
| "ollamaApiKey";
|
||||
signupUrl: string;
|
||||
prefix?: string;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const label = t(`settings.webSearchApiKey.${engine}` as const);
|
||||
return (
|
||||
<div className="setting-row">
|
||||
<div className="l">
|
||||
<div className="n">{label}</div>
|
||||
<div className="h">
|
||||
{prefix ? t("settings.apiKeySet", { prefix }) : t("settings.apiKeyNotSet")}{" "}
|
||||
<a
|
||||
href={signupUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUrl(signupUrl).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
{t("settings.webSearchApiKeySignup")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
className="field mono"
|
||||
type="password"
|
||||
value={draft}
|
||||
placeholder={prefix ?? ""}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) return;
|
||||
onSave({ [patchKey]: trimmed } as SettingsPatch);
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
{t("settings.apiKeySave")}
|
||||
</button>
|
||||
{prefix ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => onSave({ [patchKey]: null } as SettingsPatch)}
|
||||
>
|
||||
{t("settings.webSearchApiKeyClear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeySection({
|
||||
baseUrl,
|
||||
apiKeyPrefix,
|
||||
@@ -796,7 +1020,7 @@ function ApiKeySection({
|
||||
className="field mono"
|
||||
value={urlDraft}
|
||||
onChange={(e) => setUrlDraft(e.target.value)}
|
||||
onBlur={() => onSave({ baseUrl: urlDraft.trim() || undefined })}
|
||||
onBlur={() => onSave({ baseUrl: urlDraft.trim() })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -861,6 +1085,32 @@ function PageModels({
|
||||
{t("settings.modelCustomActive", { model: settings.model })}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="setting-row" style={{ marginTop: 12 }}>
|
||||
<div className="l">
|
||||
<div className="n">{t("settings.contextTokensLabel")}</div>
|
||||
<div className="h">{t("settings.contextTokensHint")}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
className="field mono"
|
||||
type="number"
|
||||
min={1}
|
||||
value={settings.contextTokens?.[settings.model] ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
const num = raw ? parseInt(raw, 10) : 0;
|
||||
const next = { ...(settings.contextTokens ?? {}) };
|
||||
if (num > 0 && Number.isFinite(num)) {
|
||||
next[settings.model] = num;
|
||||
} else {
|
||||
delete next[settings.model];
|
||||
}
|
||||
onSave({ contextTokens: Object.keys(next).length > 0 ? next : undefined });
|
||||
}}
|
||||
placeholder={t("settings.contextTokensPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="section">
|
||||
<div className="stitle">{t("settings.effortSection")}</div>
|
||||
@@ -889,36 +1139,112 @@ function PageModels({
|
||||
|
||||
function PageMCP({
|
||||
specs,
|
||||
bridged,
|
||||
initialEditRaw,
|
||||
initialEditNonce,
|
||||
onImportCcSwitch,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onUpdate,
|
||||
onRetry,
|
||||
}: {
|
||||
specs: McpSpecInfo[];
|
||||
bridged: boolean;
|
||||
initialEditRaw?: string | null;
|
||||
initialEditNonce?: number;
|
||||
onImportCcSwitch: () => Promise<void>;
|
||||
onAdd: (spec: string) => void;
|
||||
onRemove: (spec: string) => void;
|
||||
onUpdate: (raw: string, server: ImportedMcpServer) => void;
|
||||
onRetry: (raw: string) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [editing, setEditing] = useState<McpSpecInfo | null>(null);
|
||||
const appliedEditNonceRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!initialEditRaw) return;
|
||||
const nonce = initialEditNonce ?? 0;
|
||||
if (appliedEditNonceRef.current === nonce) return;
|
||||
const target = specs.find((s) => s.raw === initialEditRaw);
|
||||
if (target && !target.parseError) {
|
||||
appliedEditNonceRef.current = nonce;
|
||||
setEditing(target);
|
||||
}
|
||||
}, [initialEditRaw, initialEditNonce, specs]);
|
||||
const connectedCount = specs.filter((s) => s.status === "connected").length;
|
||||
const failedCount = specs.filter((s) => s.status === "failed").length;
|
||||
const disabledCount = specs.filter((s) => s.status === "disabled").length;
|
||||
const connectingCount = specs.filter((s) => s.status === "configured" || s.status === "handshake")
|
||||
.length;
|
||||
const statusKind =
|
||||
specs.length === 0
|
||||
? "empty"
|
||||
: failedCount > 0
|
||||
? "failed"
|
||||
: connectedCount === specs.length
|
||||
? "connected"
|
||||
: connectingCount > 0
|
||||
? "connecting"
|
||||
: disabledCount > 0
|
||||
? "disabled"
|
||||
: "pending";
|
||||
const statusText =
|
||||
statusKind === "connected"
|
||||
? t("settings.mcpStatusConnected", { connected: connectedCount, total: specs.length })
|
||||
: statusKind === "failed"
|
||||
? t("settings.mcpStatusFailed", {
|
||||
connected: connectedCount,
|
||||
total: specs.length,
|
||||
failed: failedCount,
|
||||
})
|
||||
: statusKind === "connecting"
|
||||
? t("settings.mcpStatusConnecting", { connected: connectedCount, total: specs.length })
|
||||
: statusKind === "disabled"
|
||||
? t("settings.mcpStatusDisabled", { disabled: disabledCount, total: specs.length })
|
||||
: t("settings.mcpStatusPending");
|
||||
const submit = () => {
|
||||
const v = draft.trim();
|
||||
if (!v) return;
|
||||
onAdd(v);
|
||||
setDraft("");
|
||||
};
|
||||
if (editing) {
|
||||
return (
|
||||
<McpEditPage
|
||||
spec={editing}
|
||||
onBack={() => setEditing(null)}
|
||||
onSave={(raw, server) => {
|
||||
onUpdate(raw, server);
|
||||
setEditing(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<section className="section">
|
||||
<div className="stitle">
|
||||
{t("settings.mcpConfigured", { count: specs.length })}
|
||||
{bridged ? (
|
||||
<span style={{ color: "var(--accent)", marginLeft: 8, fontSize: 11 }}>
|
||||
{t("settings.mcpBridged")}
|
||||
<div className="mcp-section-head">
|
||||
<div className="stitle">
|
||||
{t("settings.mcpConfigured", { count: specs.length })}
|
||||
<span className="mcp-status-summary" data-status={statusKind}>
|
||||
{statusText}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--muted)", marginLeft: 8, fontSize: 11 }}>
|
||||
{t("settings.mcpNotBridged")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={importing}
|
||||
onClick={async () => {
|
||||
setImporting(true);
|
||||
try {
|
||||
await onImportCcSwitch();
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{importing ? t("settings.mcpImporting") : t("settings.mcpImport")}
|
||||
</button>
|
||||
</div>
|
||||
{specs.length === 0 ? (
|
||||
<div
|
||||
@@ -935,31 +1261,14 @@ function PageMCP({
|
||||
</div>
|
||||
) : (
|
||||
specs.map((s) => (
|
||||
<div className="scard" key={s.raw}>
|
||||
<div className="top">
|
||||
<span className="ico">
|
||||
<I.wrench size={14} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="nm">{s.name ?? "(anonymous)"}</div>
|
||||
<div className="sub">{s.summary}</div>
|
||||
</div>
|
||||
<span className="grow" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => onRemove(s.raw)}
|
||||
>
|
||||
{t("settings.mcpRemove")}
|
||||
</button>
|
||||
</div>
|
||||
{s.parseError ? (
|
||||
<div className="desc" style={{ color: "var(--danger)" }}>
|
||||
{t("settings.parseError", { error: s.parseError })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<McpServerCard
|
||||
key={s.raw}
|
||||
spec={s}
|
||||
mode="settings"
|
||||
onEdit={setEditing}
|
||||
onRetry={onRetry}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
@@ -990,6 +1299,235 @@ function PageMCP({
|
||||
);
|
||||
}
|
||||
|
||||
const MCP_NAME_PREFIX = /^([a-zA-Z_][a-zA-Z0-9_-]*)=(.*)$/;
|
||||
const MCP_STREAMABLE_PREFIX = /^streamable\+(https?:\/\/.+)$/i;
|
||||
const MCP_HTTP_URL = /^https?:\/\//i;
|
||||
|
||||
function splitMcpArgs(body: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
let escaping = false;
|
||||
for (const ch of body) {
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
else current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
out.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current) out.push(current);
|
||||
return out;
|
||||
}
|
||||
|
||||
function mcpServerFromRawSpec(spec: McpSpecInfo): ImportedMcpServer | undefined {
|
||||
const trimmed = spec.raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const match = MCP_NAME_PREFIX.exec(trimmed);
|
||||
const name = match?.[1] ?? spec.name ?? "";
|
||||
const body = (match?.[2] ?? trimmed).trim();
|
||||
if (!name || !body) return undefined;
|
||||
const streamable = MCP_STREAMABLE_PREFIX.exec(body);
|
||||
if (streamable) {
|
||||
return { name, transport: "streamable-http", url: streamable[1] };
|
||||
}
|
||||
if (MCP_HTTP_URL.test(body)) {
|
||||
return { name, transport: spec.transport === "streamable-http" ? "streamable-http" : "sse", url: body };
|
||||
}
|
||||
const argv = splitMcpArgs(body);
|
||||
const [command, ...args] = argv;
|
||||
if (!command) return undefined;
|
||||
return { name, transport: "stdio", command, args };
|
||||
}
|
||||
|
||||
function editableMcpServer(spec: McpSpecInfo): ImportedMcpServer | undefined {
|
||||
return spec.config ?? mcpServerFromRawSpec(spec);
|
||||
}
|
||||
|
||||
function mcpServerToJson(server: ImportedMcpServer | undefined): Record<string, unknown> {
|
||||
if (!server) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
if (server.transport === "stdio") {
|
||||
out.command = server.command ?? "";
|
||||
out.args = server.args ?? [];
|
||||
if (server.env && Object.keys(server.env).length > 0) out.env = server.env;
|
||||
if (server.cwd) out.cwd = server.cwd;
|
||||
} else {
|
||||
out.url = server.url ?? "";
|
||||
if (server.headers && Object.keys(server.headers).length > 0) out.headers = server.headers;
|
||||
}
|
||||
if (server.disabled === true) out.disabled = true;
|
||||
if (typeof server.requestTimeoutMs === "number") out.requestTimeoutMs = server.requestTimeoutMs;
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeStringRecordForMcp(value: unknown): Record<string, string> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry === "string") out[key] = entry;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function normalizeMcpJsonDraft(
|
||||
name: string,
|
||||
transport: ImportedMcpServer["transport"],
|
||||
value: unknown,
|
||||
): ImportedMcpServer {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(t("settings.mcpEditJsonObjectRequired"));
|
||||
}
|
||||
const raw = value as Record<string, unknown>;
|
||||
const requestTimeoutMs =
|
||||
typeof raw.requestTimeoutMs === "number" && Number.isFinite(raw.requestTimeoutMs)
|
||||
? raw.requestTimeoutMs
|
||||
: undefined;
|
||||
const disabled = raw.disabled === true ? true : undefined;
|
||||
if (transport === "stdio") {
|
||||
const command = typeof raw.command === "string" ? raw.command.trim() : "";
|
||||
if (!command) throw new Error(t("settings.mcpEditCommandRequired"));
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
command,
|
||||
args: Array.isArray(raw.args) ? raw.args.filter((a): a is string => typeof a === "string") : [],
|
||||
env: normalizeStringRecordForMcp(raw.env),
|
||||
cwd: typeof raw.cwd === "string" && raw.cwd.trim() ? raw.cwd.trim() : undefined,
|
||||
disabled,
|
||||
requestTimeoutMs,
|
||||
};
|
||||
}
|
||||
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
||||
if (!url) throw new Error(t("settings.mcpEditUrlRequired"));
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
url,
|
||||
headers: normalizeStringRecordForMcp(raw.headers),
|
||||
disabled,
|
||||
requestTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function McpEditPage({
|
||||
spec,
|
||||
onBack,
|
||||
onSave,
|
||||
}: {
|
||||
spec: McpSpecInfo;
|
||||
onBack: () => void;
|
||||
onSave: (raw: string, server: ImportedMcpServer) => void;
|
||||
}) {
|
||||
const initial = editableMcpServer(spec);
|
||||
const [name, setName] = useState(initial?.name ?? spec.name ?? "");
|
||||
const [transport, setTransport] = useState<ImportedMcpServer["transport"]>(
|
||||
initial?.transport ?? spec.transport,
|
||||
);
|
||||
const [jsonDraft, setJsonDraft] = useState(() =>
|
||||
JSON.stringify(mcpServerToJson(initial), null, 2),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonDraft);
|
||||
setJsonDraft(JSON.stringify(parsed, null, 2));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const nextName = name.trim();
|
||||
if (!nextName) {
|
||||
setError(t("settings.mcpEditNameRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonDraft);
|
||||
onSave(spec.raw, normalizeMcpJsonDraft(nextName, transport, parsed));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mcp-edit">
|
||||
<div className="mcp-edit-top">
|
||||
<button type="button" className="btn ghost mcp-back-btn" onClick={onBack}>
|
||||
<I.chevR size={14} className="mcp-back-icon" />
|
||||
{t("settings.mcpEditBack")}
|
||||
</button>
|
||||
<div className="stitle">{t("settings.mcpEditTitle")}</div>
|
||||
</div>
|
||||
<section className="section mcp-edit-section">
|
||||
<label className="mcp-edit-field">
|
||||
<span>{t("settings.mcpEditName")}</span>
|
||||
<input className="field mono" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="mcp-edit-field">
|
||||
<span>{t("settings.mcpEditTransport")}</span>
|
||||
<select
|
||||
className="field mono"
|
||||
value={transport}
|
||||
onChange={(e) => setTransport(e.target.value as ImportedMcpServer["transport"])}
|
||||
>
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="sse">sse</option>
|
||||
<option value="streamable-http">streamable-http</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
<section className="section mcp-edit-section">
|
||||
<div className="mcp-edit-json-head">
|
||||
<div className="stitle">{t("settings.mcpEditJson")}</div>
|
||||
<button type="button" className="btn ghost" onClick={formatJson}>
|
||||
{t("settings.mcpEditFormat")}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="mcp-json-editor"
|
||||
value={jsonDraft}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setJsonDraft(e.target.value)}
|
||||
/>
|
||||
{error ? <div className="mcp-edit-error">{error}</div> : null}
|
||||
</section>
|
||||
<div className="mcp-edit-footer">
|
||||
<button type="button" className="btn ghost" onClick={onBack}>
|
||||
{t("revision.cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={save}>
|
||||
<I.file size={14} />
|
||||
{t("settings.mcpEditSave")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageSkills({
|
||||
skills,
|
||||
subagentModels,
|
||||
@@ -1074,22 +1612,42 @@ function PageSkills({
|
||||
);
|
||||
}
|
||||
|
||||
function PageMemory() {
|
||||
function PageMemory({
|
||||
entries,
|
||||
detail,
|
||||
onRead,
|
||||
}: {
|
||||
entries: MemoryEntryInfo[];
|
||||
detail: MemoryDetail | null;
|
||||
onRead: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="stitle">{t("settings.memorySection")}</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
fontSize: 12,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{t("settings.memoryDesc")}
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="muted-card">{t("settings.memoryDesc")}</div>
|
||||
) : (
|
||||
<div className="memory-browser">
|
||||
<div className="memory-list">
|
||||
{entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
className="memory-item"
|
||||
data-active={detail?.path === m.path}
|
||||
key={m.path}
|
||||
onClick={() => onRead(m.path)}
|
||||
>
|
||||
<span className="memory-kind">{m.kind.replace("_", " ")}</span>
|
||||
<span className="memory-name">{m.description || m.name}</span>
|
||||
<span className="memory-path">{m.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="memory-detail">
|
||||
{detail ? detail.body : t("settings.memoryDesc")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1111,7 +1669,7 @@ function PageRules({
|
||||
<div className="h">{t("settings.editModeHint")}</div>
|
||||
</div>
|
||||
<div className="seg-ctrl">
|
||||
{(["review", "auto", "yolo"] as const).map((m) => (
|
||||
{(["plan", "review", "auto", "yolo"] as const).map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m}
|
||||
|
||||
+306
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type StartupFailureState = {
|
||||
details: string[];
|
||||
};
|
||||
|
||||
function toErrorText(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function coerceStartupFailure(
|
||||
error: unknown,
|
||||
stderrLines: readonly string[] = [],
|
||||
): StartupFailureState {
|
||||
const details = [toErrorText(error), ...stderrLines].map((line) => line.trim()).filter(Boolean);
|
||||
return { details: details.length > 0 ? details : [t("app.startupFailedUnknown")] };
|
||||
}
|
||||
|
||||
export function StartupFailure({
|
||||
details,
|
||||
onRetry,
|
||||
}: {
|
||||
details: readonly string[];
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main className="startup-failure" role="alert" aria-live="assertive">
|
||||
<section className="startup-failure-panel">
|
||||
<p className="startup-failure-kicker">{t("app.errorLabel")}</p>
|
||||
<h1>{t("app.startupFailedTitle")}</h1>
|
||||
<p>{t("app.startupFailedMessage")}</p>
|
||||
<pre>{details.join("\n")}</pre>
|
||||
<button type="button" onClick={onRetry}>
|
||||
{t("app.startupFailedRetry")}
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function getThreadMaxWidth({
|
||||
viewportWidth,
|
||||
visibleSide,
|
||||
visibleCtx,
|
||||
}: {
|
||||
viewportWidth: number;
|
||||
visibleSide: number;
|
||||
visibleCtx: number;
|
||||
}): number {
|
||||
return Math.max(580, Math.min(viewportWidth - visibleSide - visibleCtx - 80, 1120));
|
||||
}
|
||||
+231
-94
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,9 @@ export default defineConfig({
|
||||
host: "127.0.0.1",
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/target/**"],
|
||||
},
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
define: {
|
||||
|
||||
+22
-14
@@ -122,24 +122,32 @@ dragging 12KB through every future prompt.
|
||||
A proactive 40% context-ratio threshold runs the same shrink pre-emptively
|
||||
inside long multi-iter turns before the 80% emergency threshold fires.
|
||||
|
||||
#### 4.3 `/pro` single-turn arming
|
||||
#### 4.3 Model selection (`/model`)
|
||||
|
||||
Users who predict a hard task type `/pro`; the **next** turn runs on
|
||||
`v4-pro`, then auto-disarms. No preset churn, no forgotten revert. Armed
|
||||
state is visible as a yellow `⇧ pro armed` pill in the header.
|
||||
Users switch between flash and pro via `/model flash` or `/model pro`
|
||||
(persistent — applies to every turn until changed). Model can also be
|
||||
set in `.reasonix/settings.json` under the `model` key. No one-shot
|
||||
arming; no forgotten revert risk when switching is explicit and sticky.
|
||||
|
||||
#### 4.4 Failure-signal auto-escalation
|
||||
> **History.** Pre-0.50.0, `/pro` offered single-turn arming — type `/pro`,
|
||||
> the next turn ran on v4-pro then auto-disarmed. Removed in 0.50.0
|
||||
> (#1657, #1630) when presets were collapsed into direct model selection.
|
||||
|
||||
The loop counts visible "flash is struggling" events per turn:
|
||||
- `edit_file` / `write_file` SEARCH-not-found errors
|
||||
- ToolCallRepair fires (scavenge / truncation-fix / storm-break)
|
||||
#### 4.4 Model self-report escalation (`<<<NEEDS_PRO>>>`)
|
||||
|
||||
Once the count hits `FAILURE_ESCALATION_THRESHOLD` (3), the **remainder of
|
||||
the current turn** runs on `v4-pro`. Announced via a yellow warning row —
|
||||
no silent cost surprises. Counter + escalation flag reset at every turn
|
||||
start.
|
||||
The model itself decides when a task exceeds its current tier. If a task
|
||||
clearly needs stronger reasoning, the model emits a `<<<NEEDS_PRO>>>`
|
||||
marker as the first line of its response. The system aborts the current
|
||||
flash call and retries the turn on pro. Two forms:
|
||||
|
||||
Header shows a red `⇧ pro escalated` pill while the turn is on pro.
|
||||
- `<<<NEEDS_PRO>>>` — bare marker, no rationale.
|
||||
- `<<<NEEDS_PRO: <reason>>>>` — includes a one-sentence rationale the
|
||||
user sees in a warning row.
|
||||
|
||||
On the pro tier, the marker is a no-op — pro is the top, so the contract
|
||||
tells the model it can't escalate further. This is purely self-report:
|
||||
there is no failure-counter threshold, no scavenge/storm counting, no
|
||||
automatic escalation based on tool errors.
|
||||
|
||||
#### Cost transparency
|
||||
|
||||
@@ -170,7 +178,7 @@ src/
|
||||
│ ├── skills.ts # list + invoke SKILL.md playbooks
|
||||
│ ├── subagent.ts # spawn_subagent — flash+high by default
|
||||
│ ├── plan.ts # submit_plan (review gate)
|
||||
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)
|
||||
│ └── web.ts # web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)
|
||||
├── mcp/ # MCP client + bridge (stdio + SSE)
|
||||
├── memory.ts # ImmutablePrefix / AppendOnlyLog / VolatileScratch
|
||||
├── project-memory.ts # REASONIX.md loader
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -125,14 +125,13 @@
|
||||
</span>
|
||||
</a>
|
||||
<div class="nav-links" role="navigation">
|
||||
<a href="index.html#install">Install</a>
|
||||
<a href="index.html#agents">Pillars</a>
|
||||
<a href="index.html#features">Features</a>
|
||||
<a href="index.html#config">Config</a>
|
||||
<a href="configuration.html">Guide</a>
|
||||
<a href="download.html">Download</a>
|
||||
<a href="index.html#roadmap">Roadmap</a>
|
||||
<a href="index.html#faq">FAQ</a>
|
||||
<a class="nav-link-primary" href="index.html#install">Install</a>
|
||||
<a class="nav-link-secondary" href="index.html#agents">Pillars</a>
|
||||
<a class="nav-link-primary" href="index.html#features">Features</a>
|
||||
<a class="nav-link-secondary" href="index.html#config">Config</a>
|
||||
<a class="nav-link-persist" href="configuration.html">Guide</a>
|
||||
<a class="nav-link-tertiary" href="index.html#roadmap">Roadmap</a>
|
||||
<a class="nav-link-tertiary" href="index.html#faq">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-cta">
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
@@ -434,7 +433,7 @@
|
||||
│ ├── skills.ts <span class="hash"># list + invoke SKILL.md playbooks</span>
|
||||
│ ├── subagent.ts <span class="hash"># spawn_subagent — flash+high by default</span>
|
||||
│ ├── plan.ts <span class="hash"># submit_plan (review gate)</span>
|
||||
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)</span>
|
||||
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)</span>
|
||||
├── mcp/ <span class="hash"># MCP client + bridge (stdio + SSE)</span>
|
||||
├── memory.ts <span class="hash"># ImmutablePrefix / AppendOnlyLog / VolatileScratch</span>
|
||||
├── project-memory.ts <span class="hash"># REASONIX.md loader</span>
|
||||
|
||||
@@ -125,14 +125,13 @@
|
||||
</span>
|
||||
</a>
|
||||
<div class="nav-links" role="navigation">
|
||||
<a href="index.html#install">Install</a>
|
||||
<a href="index.html#agents">Pillars</a>
|
||||
<a href="index.html#features">Features</a>
|
||||
<a href="index.html#config">Config</a>
|
||||
<a href="configuration.html">Guide</a>
|
||||
<a href="download.html">Download</a>
|
||||
<a href="index.html#roadmap">Roadmap</a>
|
||||
<a href="index.html#faq">FAQ</a>
|
||||
<a class="nav-link-primary" href="index.html#install">Install</a>
|
||||
<a class="nav-link-secondary" href="index.html#agents">Pillars</a>
|
||||
<a class="nav-link-primary" href="index.html#features">Features</a>
|
||||
<a class="nav-link-secondary" href="index.html#config">Config</a>
|
||||
<a class="nav-link-persist" href="configuration.html">Guide</a>
|
||||
<a class="nav-link-tertiary" href="index.html#roadmap">Roadmap</a>
|
||||
<a class="nav-link-tertiary" href="index.html#faq">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-cta">
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
@@ -532,7 +531,7 @@
|
||||
<td>Session USD cap</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/search-engine <mojeek|searxng|metaso></code> (<code>/se</code>)</td>
|
||||
<td><code>/search-engine <bing|bing-intl|searxng|metaso|baidu|tavily|perplexity|exa|brave|ollama></code> (<code>/se</code>)</td>
|
||||
<td>Switch web search backend</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
+23
-14
@@ -125,14 +125,13 @@
|
||||
</span>
|
||||
</a>
|
||||
<div class="nav-links" role="navigation">
|
||||
<a href="index.html#install">Install</a>
|
||||
<a href="index.html#agents">Pillars</a>
|
||||
<a href="index.html#features">Features</a>
|
||||
<a href="index.html#config">Config</a>
|
||||
<a href="configuration.html" style="color: var(--accent)">Guide</a>
|
||||
<a href="download.html">Download</a>
|
||||
<a href="index.html#roadmap">Roadmap</a>
|
||||
<a href="index.html#faq">FAQ</a>
|
||||
<a class="nav-link-primary" href="index.html#install">Install</a>
|
||||
<a class="nav-link-secondary" href="index.html#agents">Pillars</a>
|
||||
<a class="nav-link-primary" href="index.html#features">Features</a>
|
||||
<a class="nav-link-secondary" href="index.html#config">Config</a>
|
||||
<a class="nav-link-persist" href="configuration.html" style="color: var(--accent)">Guide</a>
|
||||
<a class="nav-link-tertiary" href="index.html#roadmap">Roadmap</a>
|
||||
<a class="nav-link-tertiary" href="index.html#faq">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-cta">
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
@@ -201,7 +200,7 @@
|
||||
"reasoningEffort": "high", <span class="hash"># <span data-i18n="cfg.k.effort">high | max</span></span>
|
||||
"theme": "auto", <span class="hash"># <span data-i18n="cfg.k.theme">light | dark | auto</span></span>
|
||||
"search": false, <span class="hash"># <span data-i18n="cfg.k.search">enable web_search/web_fetch tools</span></span>
|
||||
"webSearchEngine": "mojeek", <span class="hash"># <span data-i18n="cfg.k.engine">mojeek | searxng | metaso</span></span>
|
||||
"webSearchEngine": "bing", <span class="hash"># <span data-i18n="cfg.k.engine">bing | baidu | searxng | metaso | ...</span></span>
|
||||
"webSearchEndpoint": "http://localhost:8080",
|
||||
"mcp": [], <span class="hash"># <span data-i18n="cfg.k.mcp">MCP server specs (legacy string format)</span></span>
|
||||
"mcpServers": {}, <span class="hash"># <span data-i18n="cfg.k.mcpsrv">MCP server objects (canonical format)</span></span>
|
||||
@@ -622,14 +621,16 @@ created: 2026-05-09
|
||||
<h2 data-i18n="ws.title">Web search</h2>
|
||||
<p data-i18n="ws.body1">
|
||||
<code>web_search</code> + <code>web_fetch</code> ship in the box. Default
|
||||
backend is <strong>Mojeek</strong> (no setup); switch to a self-hosted
|
||||
<strong>SearXNG</strong> when you want full control over upstream engines, or
|
||||
use <strong>Metaso</strong> (100 free searches/day, configure your own API key for more).
|
||||
backend is <strong>Bing</strong> (works from CN without proxy); switch to
|
||||
<strong>Baidu AI Search</strong> when you want Baidu Cloud Qianfan's official
|
||||
search API. You can also use self-hosted <strong>SearXNG</strong>,
|
||||
<strong>Metaso</strong>, or other API engines.
|
||||
</p>
|
||||
<pre class="code"><code>/search-engine mojeek
|
||||
<pre class="code"><code>/search-engine bing
|
||||
/search-engine searxng <span class="hash"># http://localhost:8080</span>
|
||||
/search-engine searxng http://192.168.1.5:8888
|
||||
/search-engine metaso</code></pre>
|
||||
/search-engine metaso
|
||||
/search-engine baidu</code></pre>
|
||||
<p data-i18n="ws.body.json">Equivalent <code>config.json</code>:</p>
|
||||
<pre class="code"><code>{
|
||||
"webSearchEngine": "searxng",
|
||||
@@ -639,6 +640,14 @@ created: 2026-05-09
|
||||
<pre class="code"><code>{
|
||||
"webSearchEngine": "metaso",
|
||||
"metasoApiKey": "mk-..." <span class="hash"># Optional: 100 free searches/day, configure your own API key for more.</span>
|
||||
}</code></pre>
|
||||
<p data-i18n="ws.body.baidu">
|
||||
Or for Baidu AI Search (Baidu's official docs currently list
|
||||
<strong>1,500 free AI Search calls per month</strong>):
|
||||
</p>
|
||||
<pre class="code"><code>{
|
||||
"webSearchEngine": "baidu",
|
||||
"baiduApiKey": "bce-v3/..." <span class="hash"># Or set BAIDU_API_KEY / QIANFAN_API_KEY</span>
|
||||
}</code></pre>
|
||||
<p data-i18n="ws.body.start">Start a local SearXNG:</p>
|
||||
<pre class="code"><code>podman run -d --replace --name searxng -p 8080:8080 docker.io/searxng/searxng</code></pre>
|
||||
|
||||
+8
-4
@@ -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
@@ -138,7 +138,7 @@
|
||||
"Per-workspace shell allowlist. Exact-prefix match. Interactive \"always allow\" persists.",
|
||||
"conf.ws.title": "Web search",
|
||||
"conf.ws.body":
|
||||
"Mojeek by default, no setup. Switch to self-hosted SearXNG or Metaso with <code>/search-engine</code>.",
|
||||
"Bing by default. Switch to Baidu AI Search, self-hosted SearXNG, Metaso, or other API engines with <code>/search-engine</code>.",
|
||||
"conf.cta": "Open the configuration guide →",
|
||||
|
||||
"cli.title": "CLI at a glance",
|
||||
@@ -319,7 +319,7 @@
|
||||
"按工作区的 shell 白名单。精确前缀匹配。交互式“永久允许”会持久化。",
|
||||
"conf.ws.title": "Web 搜索",
|
||||
"conf.ws.body":
|
||||
"默认 Mojeek,零配置。用 <code>/search-engine</code> 切自托管 SearXNG 或 Metaso。",
|
||||
"默认 Bing。用 <code>/search-engine</code> 切百度 AI Search、自托管 SearXNG、Metaso 或其他 API 引擎。",
|
||||
"conf.cta": "打开配置指南 →",
|
||||
|
||||
"cli.title": "CLI 速览",
|
||||
|
||||
+4
-4
@@ -172,10 +172,10 @@ function Hero() {
|
||||
</a>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<div className="hero-stat"><b>94<span style={{fontFamily:'var(--mono)',fontStyle:'normal',fontSize:18,marginLeft:2,color:'var(--cream-mute)'}}>%</span></b><span>Cache Hit</span></div>
|
||||
<div className="hero-stat"><b>2.5<span style={{fontFamily:'var(--mono)',fontStyle:'normal',fontSize:18,marginLeft:2,color:'var(--cream-mute)'}}>×</span></b><span>Cost Down</span></div>
|
||||
<div className="hero-stat"><b>2837</b><span>Tests</span></div>
|
||||
<div className="hero-stat"><b>MIT</b><span>License</span></div>
|
||||
<div className="hero-stat"><b>94<span className="metric-unit">%</span></b><span className="metric-label">Cache Hit</span></div>
|
||||
<div className="hero-stat"><b>2.5<span className="metric-unit">×</span></b><span className="metric-label">Cost Down</span></div>
|
||||
<div className="hero-stat"><b>2837</b><span className="metric-label">Tests</span></div>
|
||||
<div className="hero-stat"><b>MIT</b><span className="metric-label">License</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{position:'relative'}}>
|
||||
|
||||
+11
-9
@@ -12,14 +12,13 @@ function Nav({ active }) {
|
||||
}, []);
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "index.html#install", label: { zh: "安装", en: "Install" } },
|
||||
{ href: "index.html#agents", label: { zh: "原理", en: "How it works" } },
|
||||
{ href: "index.html#features", label: { zh: "特性", en: "Features" } },
|
||||
{ href: "index.html#config", label: { zh: "配置", en: "Config" } },
|
||||
{ href: "configuration.html", label: { zh: "Guide", en: "Guide" } },
|
||||
{ href: "download.html", label: { zh: "下载", en: "Download" }, key: "download" },
|
||||
{ href: "index.html#roadmap", label: { zh: "Roadmap", en: "Roadmap" } },
|
||||
{ href: "index.html#faq", label: { zh: "FAQ", en: "FAQ" } },
|
||||
{ href: "index.html#install", label: { zh: "安装", en: "Install" }, priority: "primary" },
|
||||
{ href: "index.html#agents", label: { zh: "原理", en: "How it works" }, priority: "secondary" },
|
||||
{ href: "index.html#features", label: { zh: "特性", en: "Features" }, priority: "primary" },
|
||||
{ href: "index.html#config", label: { zh: "配置", en: "Config" }, priority: "secondary" },
|
||||
{ href: "configuration.html", label: { zh: "Guide", en: "Guide" }, priority: "persist" },
|
||||
{ href: "index.html#roadmap", label: { zh: "Roadmap", en: "Roadmap" }, priority: "tertiary" },
|
||||
{ href: "index.html#faq", label: { zh: "FAQ", en: "FAQ" }, priority: "tertiary" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -36,7 +35,10 @@ function Nav({ active }) {
|
||||
<a
|
||||
key={t(l.label, "en")}
|
||||
href={l.href}
|
||||
className={l.key && active === l.key ? "on" : ""}
|
||||
className={[
|
||||
`nav-link-${l.priority}`,
|
||||
l.key && active === l.key ? "on" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
style={l.key && active === l.key ? { color: "var(--accent)" } : {}}
|
||||
>
|
||||
{t(l.label, lang)}
|
||||
|
||||
@@ -74,7 +74,7 @@ function Roadmap() {
|
||||
<h4>{t(c.title, lang)}</h4>
|
||||
</header>
|
||||
<ul>
|
||||
{c.items.map((it, idx) => <li key={idx}>{t(it, lang)}</li>)}
|
||||
{c.items.map((it, idx) => <li key={idx}><span>{t(it, lang)}</span></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+82
-27
@@ -75,16 +75,17 @@ body {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.nav-inner {
|
||||
max-width: var(--maxw);
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 16px var(--gutter);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
gap: clamp(20px, 2.5vw, 32px);
|
||||
}
|
||||
.brand {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
text-decoration: none; color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 22px; height: 22px;
|
||||
@@ -98,7 +99,12 @@ body {
|
||||
.brand-mark::after {
|
||||
display: none;
|
||||
}
|
||||
.brand-name { display: flex; align-items: baseline; gap: 8px; }
|
||||
.brand-name {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-name b {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
@@ -115,8 +121,10 @@ body {
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex; gap: 24px;
|
||||
margin-left: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(16px, 1.9vw, 24px);
|
||||
margin-left: clamp(12px, 2vw, 28px);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.nav-links a {
|
||||
@@ -128,6 +136,7 @@ body {
|
||||
transition: color 0.18s;
|
||||
position: relative;
|
||||
padding: 4px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-links a::after {
|
||||
content: "";
|
||||
@@ -142,6 +151,26 @@ body {
|
||||
.nav-cta {
|
||||
margin-left: auto;
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.nav-link-tertiary { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.nav-link-secondary { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.nav-link-primary { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.brand-name span,
|
||||
.nav-cta .btn-ghost {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── BUTTONS ──────────────────────────────────────────────── */
|
||||
@@ -300,8 +329,8 @@ body {
|
||||
.hero h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 400;
|
||||
font-size: clamp(54px, 8.6vw, 124px);
|
||||
line-height: 0.95;
|
||||
font-size: clamp(40px, 6.6vw, 76px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 28px;
|
||||
color: var(--cream);
|
||||
@@ -347,12 +376,16 @@ body {
|
||||
max-width: 600px;
|
||||
}
|
||||
.hero-stat {
|
||||
padding-right: 16px;
|
||||
padding: 0 20px;
|
||||
border-right: 1px solid var(--rule);
|
||||
text-align: center;
|
||||
}
|
||||
.hero-stat:last-child { border-right: 0; }
|
||||
.hero-stat b {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
@@ -361,7 +394,17 @@ body {
|
||||
line-height: 1;
|
||||
color: var(--cream);
|
||||
}
|
||||
.hero-stat span {
|
||||
.hero-stat .metric-unit {
|
||||
display: inline;
|
||||
margin-top: 0;
|
||||
font-family: var(--mono);
|
||||
font-style: normal;
|
||||
font-size: 0.48em;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--cream-mute);
|
||||
}
|
||||
.hero-stat .metric-label {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-family: var(--mono);
|
||||
@@ -601,23 +644,18 @@ body {
|
||||
@media (max-width: 880px) { .feat-grid { grid-template-columns: 1fr; } }
|
||||
.feat {
|
||||
position: relative;
|
||||
padding: 36px 28px 36px 0;
|
||||
padding: 36px 28px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule);
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.feat:nth-child(3n) { border-right: 0; padding-right: 0; }
|
||||
.feat:nth-child(3n) { border-right: 0; }
|
||||
@media (max-width: 880px) {
|
||||
.feat { border-right: 0; padding-right: 0; }
|
||||
.feat { border-right: 0; }
|
||||
}
|
||||
.feat:nth-child(n+4) { padding-top: 36px; }
|
||||
.feat > * { margin-left: 28px; }
|
||||
.feat:nth-child(3n+1) > * { margin-left: 0; }
|
||||
@media (max-width: 880px) {
|
||||
.feat > * { margin-left: 0; }
|
||||
}
|
||||
|
||||
.feat-num {
|
||||
font-family: var(--mono);
|
||||
@@ -1013,7 +1051,7 @@ body {
|
||||
border-right: 1px solid var(--rule);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-radius: 0;
|
||||
padding: 28px 24px 28px 0;
|
||||
padding: 28px 24px;
|
||||
position: relative;
|
||||
}
|
||||
.rm-col:last-child { border-right: 0; }
|
||||
@@ -1049,7 +1087,8 @@ body {
|
||||
.rm-col ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rm-col li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 8px 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--cream-dim);
|
||||
@@ -1057,27 +1096,40 @@ body {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.rm-col li:last-child { border-bottom: 0; }
|
||||
.rm-col li > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rm-col li::before {
|
||||
content: "·";
|
||||
content: "";
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--serif);
|
||||
color: var(--cream-mute);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
width: auto; height: auto;
|
||||
width: 16px;
|
||||
height: 1.5em;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
background: radial-gradient(circle at 50% calc(50% - 3px), var(--cream-mute) 0 2.5px, transparent 2.75px);
|
||||
flex-shrink: 0;
|
||||
margin-top: -2px;
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.rm-col.done li::before {
|
||||
content: "✓";
|
||||
background: none;
|
||||
font-size: 13px;
|
||||
color: var(--jade);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.rm-col.now li::before {
|
||||
content: "◐";
|
||||
background: none;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* ─── FAQ ──────────────────────────────────────────────────── */
|
||||
@@ -1272,7 +1324,7 @@ body {
|
||||
.config-bullets li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
font-size: 13px;
|
||||
@@ -1283,12 +1335,14 @@ body {
|
||||
}
|
||||
.bullet-dot {
|
||||
width: 5px; height: 5px;
|
||||
margin-top: 0;
|
||||
margin-top: 0.7em;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.config-bullets li > span:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.config-files {
|
||||
@@ -1631,6 +1685,7 @@ body {
|
||||
|
||||
.lang-switch {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--rule-2);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -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.
|
||||
@@ -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` 分支。
|
||||
Generated
+576
-1191
File diff suppressed because it is too large.
Load diff
+34
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.50.0",
|
||||
"version": "0.52.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -22,6 +22,8 @@
|
||||
"dashboard/index.html",
|
||||
"dashboard/app.css",
|
||||
"dashboard/dist",
|
||||
"patches",
|
||||
"scripts/postinstall.mjs",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
@@ -29,7 +31,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "npm --prefix dashboard ci --ignore-scripts && npm --prefix desktop ci --ignore-scripts",
|
||||
"postinstall": "node scripts/postinstall.mjs",
|
||||
"build:dashboard": "npm --prefix dashboard run build",
|
||||
"build": "npm run build:dashboard && tsup && node scripts/copy-dashboard-vendor-css.mjs && node scripts/copy-tree-sitter-grammars.mjs",
|
||||
"dev": "tsx src/cli/index.ts",
|
||||
@@ -44,7 +46,11 @@
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p dashboard",
|
||||
"verify": "npm run build && npm run lint && npm run typecheck && npm run test --silent",
|
||||
"prepare": "simple-git-hooks || true",
|
||||
"prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm run test"
|
||||
"prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm run test",
|
||||
"pitch:pptx": "npx -y @marp-team/marp-cli pitch/pitch.md -o pitch/dist/pitch.pptx --allow-local-files",
|
||||
"pitch:pdf": "npx -y @marp-team/marp-cli pitch/pitch.md -o pitch/dist/pitch.pdf --allow-local-files",
|
||||
"pitch:html": "npx -y @marp-team/marp-cli pitch/pitch.md -o pitch/dist/pitch.html --allow-local-files",
|
||||
"pitch:watch": "npx -y @marp-team/marp-cli pitch/pitch.md --preview --watch --allow-local-files"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "npm run lint",
|
||||
@@ -74,20 +80,41 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
"auto-bind": "^5.0.1",
|
||||
"bidi-js": "^1.0.3",
|
||||
"chalk": "^5.3.0",
|
||||
"cli-boxes": "^4.0.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"code-excerpt": "^4.0.0",
|
||||
"commander": "^12.1.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"grammy": "^1.43.0",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"ignore": "^7.0.5",
|
||||
"ink": "^7.0.2",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"indent-string": "^5.0.0",
|
||||
"ink": "workspace:*",
|
||||
"lodash-es": "^4.17.21",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.6",
|
||||
"react-reconciler": "^0.32.0",
|
||||
"semver": "^7.6.3",
|
||||
"signal-exit": "^4.1.0",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"stack-utils": "^2.0.6",
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"supports-hyperlinks": "^4.0.0",
|
||||
"undici": "^8.2.0",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"web-tree-sitter": "^0.26.9",
|
||||
"wrap-ansi": "^9.0.0",
|
||||
"ws": "^8.20.1",
|
||||
"yoga-layout": "^3.2.1",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -98,6 +125,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
@@ -108,6 +136,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"marked": "^15.0.12",
|
||||
"preact": "^10.22.0",
|
||||
"react-test-renderer": "^19.2.6",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tree-sitter-go": "^0.25.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsnix",
|
||||
"version": "0.49.0",
|
||||
"version": "0.52.0",
|
||||
"description": "Short alias for reasonix — DeepSeek-native coding agent. Forwards to the reasonix CLI.",
|
||||
"bin": {
|
||||
"dsnix": "bin.cjs"
|
||||
@@ -31,6 +31,6 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"reasonix": "0.49.0"
|
||||
"reasonix": "0.52.0"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import semver from "semver";
|
||||
import { logError } from "./log.js";
|
||||
|
||||
/** Tolerant `>=` comparison for terminal-reported version strings. */
|
||||
export function gte(a: string, b: string): boolean {
|
||||
try {
|
||||
return semver.gte(semver.coerce(a) ?? a, semver.coerce(b) ?? b);
|
||||
} catch (error) {
|
||||
logError(`semver gte(${a}, ${b}) failed: ${(error as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 568 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user