mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b21a6a0493 | ||
|
|
fe1bbb9aa9 |
No files matched your search
@@ -3,8 +3,3 @@ 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,11 +24,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
# macOS now ships two single-arch shards so Apple Silicon and Intel
|
||||
# users get separate DMGs. `rust` and `tauri` stay aligned per shard.
|
||||
# `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.
|
||||
- { os: ubuntu-22.04, label: "linux-x64", rust: "x86_64-unknown-linux-gnu", tauri: "x86_64-unknown-linux-gnu" }
|
||||
- { 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: macos-14, label: "macos-universal", rust: "aarch64-apple-darwin,x86_64-apple-darwin", tauri: "universal-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
|
||||
@@ -94,18 +96,14 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: npm run bundle:node
|
||||
|
||||
- name: Verify bundled Node matches host arch (macOS)
|
||||
- name: Verify bundled Node is universal (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 "expected arch: $expected_arch"
|
||||
if [ "$archs" != "$expected_arch" ]; then
|
||||
echo "Expected $expected_arch Node, got: $archs" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$archs" | grep -q "arm64"
|
||||
echo "$archs" | grep -q "x86_64"
|
||||
codesign -d --entitlements :- src-tauri/binaries/node 2>&1 | grep -q "com.apple.security.inherit"
|
||||
|
||||
- name: Build & verify desktop frontend
|
||||
|
||||
@@ -25,8 +25,6 @@ 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.
|
||||
|
||||
@@ -3,6 +3,93 @@
|
||||
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.53.1] — 2026-05-27
|
||||
|
||||
**Install fix — `npx reasonix` no longer crashes on `EUNSUPPORTEDPROTOCOL`.**
|
||||
The 0.52.0 refactor that inlined Ink as a workspace package (#1947)
|
||||
left `"ink": "workspace:*"` in runtime `dependencies`. tsup already
|
||||
bundles ink into `dist/`, so the runtime never resolves it — but npm
|
||||
on consumer machines parses the declaration and rejects the
|
||||
unresolvable `workspace:` protocol. Removed from runtime deps; the
|
||||
bundled ink in `dist/` is the implementation. 0.53.0 is deprecated
|
||||
on npm.
|
||||
|
||||
## [0.53.0] — 2026-05-27
|
||||
|
||||
**Force-summary respects the active model.** Context-guard and stuck-state
|
||||
recovery were calling `client.chat({ model: "deepseek-v4-flash" })`
|
||||
regardless of the user's configured model (#1979). Third-party
|
||||
DeepSeek-compatible endpoints (mimo, self-hosted, Azure forks) that
|
||||
don't expose flash returned 400 on every recovery — the session looked
|
||||
like it lost context (the messages were still there) and the user had
|
||||
no way back without `/clear`. Force-summary now uses `this.model` with
|
||||
`thinking: "disabled"` to keep reasoning tokens off the bill even when
|
||||
the active model is pro.
|
||||
|
||||
**Web search — Brave backend.** `web_search` now supports the Brave
|
||||
Search API as an alternative backend (#1916), alongside the existing
|
||||
Bing / Tavily / Ollama-cloud paths. Configurable per-instance.
|
||||
|
||||
**Desktop — workspace switcher.** Sidebar exposes a workspace
|
||||
switcher (#1908) so multiple project roots can be juggled without
|
||||
restarting the app. Bundled-app version surfacing for desktop is
|
||||
unified through the same sync pipeline as the CLI.
|
||||
|
||||
**Desktop — fixes.**
|
||||
- Per-tab hook firing for Pre/PostToolUse, UserPromptSubmit, Stop
|
||||
(#1984) — hooks were leaking across tabs in multi-session windows
|
||||
- Clear the pending assistant on error so the spinner stops (#1985,
|
||||
fixes #1660)
|
||||
- `/skill` and `/skills` slash commands wired through (#1978, fixes
|
||||
#1969)
|
||||
- Context token meter refreshes after each turn (#1964)
|
||||
- Preserve session context across abort (#1938)
|
||||
- `\[ \]` and `\( \)` math delimiters now render in markdown (#1923)
|
||||
- Windows file opening normalizes path separators (#1914)
|
||||
- Settings allow clearing base URL and editor fields (#1905)
|
||||
|
||||
**Dashboard / web.**
|
||||
- Dashboard session refresh no longer skips updates (#1917)
|
||||
- "New Chat" label stays on one line (#1924)
|
||||
- Server-snapshot helpers fully typed (#1961)
|
||||
|
||||
**Config / auth.** `saveApiKey` now overrides a stale `DEEPSEEK_API_KEY`
|
||||
env var so the UI save takes effect immediately (#1982) — previously
|
||||
the env shadow won and the user had to relaunch.
|
||||
|
||||
**QQ adapter.** `/qq` defaults to markdown replies (#1941); the
|
||||
previous plain-text default lost code blocks and lists.
|
||||
|
||||
**Networking / fetch.** `web_fetch` falls back to DNS-over-HTTPS when
|
||||
the system resolver returns RFC1918 / link-local addresses (#1944) —
|
||||
some captive portals and split-horizon DNS configs hand back fake
|
||||
internal IPs and the fetch then connects to the wrong host. CLI
|
||||
`doctor` accepts a custom `/models` endpoint instead of hard-coding
|
||||
DeepSeek's (#1972).
|
||||
|
||||
**Truncation / encoding.**
|
||||
- Slice boundaries align to UTF-16 codepoints so multi-code-unit
|
||||
characters can't be split mid-surrogate (#1976, includes #1970)
|
||||
- Truncated-result-saver falls back to `~/.reasonix` when `rootDir`
|
||||
resolves to the filesystem root (#1974)
|
||||
- Lone surrogates are sanitized in chat payloads before serialization
|
||||
(#1939)
|
||||
|
||||
**TUI — CardStream measure loop.** Break the
|
||||
measure→render→measure feedback loop that could pin CPU on resize
|
||||
(#1958).
|
||||
|
||||
**Errors / ACP.** Structured `errorDetail` propagates through loop,
|
||||
transcript, and ACP wire format (#1965 + follow-up) so external
|
||||
clients see the same fault classification the CLI does.
|
||||
|
||||
**Internals.** Inline the ink runtime as a workspace package (#1947) —
|
||||
last step of the `@esengine/ink` in-tree work started in 0.52.0.
|
||||
|
||||
**Misc.**
|
||||
- Generated-script shell `cwd` guidance fixed (#1937)
|
||||
- AtomGit star badge in README (#1954)
|
||||
|
||||
## [0.52.0] — 2026-05-26
|
||||
|
||||
**Ink renderer in-tree as `@esengine/ink`.** The vendored Ink fork now
|
||||
|
||||
-339
@@ -1,339 +0,0 @@
|
||||
<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,18 +2,11 @@
|
||||
<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>
|
||||
@@ -187,7 +180,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) | Bing by default; switch to Baidu AI Search, self-hosted SearXNG, Metaso, Tavily, Perplexity, Exa, Brave, or Ollama with `/search-engine`. |
|
||||
| [Web search](https://esengine.github.io/DeepSeek-Reasonix/configuration.html#search) | Mojeek by default; switch to self-hosted SearXNG or Metaso 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/>
|
||||
@@ -224,7 +217,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 (Bing + Baidu + SearXNG + API engines) | yes | yes | yes | yes |
|
||||
| Web search (Mojeek + SearXNG + Metaso) | 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.
|
||||
|
||||
+1
-8
@@ -2,18 +2,11 @@
|
||||
<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>
|
||||
@@ -143,7 +136,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) | 默认 Bing;可用 `/search-engine` 切到百度 AI Search、自托管 SearXNG、Metaso、Tavily、Perplexity、Exa、Brave 或 Ollama。 |
|
||||
| [Web 搜索](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#search) | 默认 Mojeek;用 `/search-engine` 可切到自托管的 SearXNG 或 Metaso。 |
|
||||
| [语义索引](https://esengine.github.io/DeepSeek-Reasonix/configuration.html?lang=zh#index) | `reasonix index` —— 本地 Ollama,或任何 OpenAI 兼容的 embedding 接口。 |
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -59,9 +59,8 @@ 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. 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.
|
||||
a summary message *appended* to the prefix; the prefix itself isn't
|
||||
rewritten, so the cache survives the fold.
|
||||
|
||||
DeepSeek gave us cacheable bytes. The four mechanisms above are how we keep
|
||||
the bytes cacheable.
|
||||
|
||||
+2
-69
@@ -69,7 +69,6 @@ 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 =
|
||||
@@ -194,20 +193,7 @@ export type Settings = {
|
||||
recentWorkspaces: string[];
|
||||
model: string;
|
||||
editor?: string;
|
||||
webSearchEngine?:
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "ollama";
|
||||
webSearchApiKeys?: {
|
||||
baidu?: string;
|
||||
};
|
||||
webSearchEngine?: "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
version: string;
|
||||
@@ -320,10 +306,6 @@ 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 {
|
||||
@@ -488,11 +470,6 @@ function reduceRaw(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"]);
|
||||
|
||||
@@ -571,10 +548,6 @@ 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];
|
||||
@@ -831,7 +804,6 @@ function applyIncomingRaw(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,
|
||||
@@ -1614,50 +1586,11 @@ 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,
|
||||
state.pendingConfirms,
|
||||
state.pendingPathAccess,
|
||||
resolveConfirm,
|
||||
resolvePathAccess,
|
||||
settingsOpen,
|
||||
jobsOpen,
|
||||
wdOpen,
|
||||
abort,
|
||||
newChat,
|
||||
openSettingsAt,
|
||||
]);
|
||||
}, [active, state.busy, abort, newChat, settingsOpen, openSettingsAt]);
|
||||
|
||||
const commands = buildCommands({
|
||||
newChat: () => {
|
||||
|
||||
@@ -250,21 +250,14 @@ export const de: typeof en = {
|
||||
webSearchNote: "web_fetch + web_search Werkzeuge",
|
||||
webSearchEngine: "Suchmaschine",
|
||||
webSearchEngineBing: "bing — Standard, funktioniert von CN ohne Proxy",
|
||||
webSearchEngineBingIntl: "bing-intl — internationaler Index über www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — selbst gehostet (Endpunkt via /se searxng <url>)",
|
||||
webSearchEngineMetaso: "metaso — 100/Tag kostenlos (CN-freundlich, kostenloser Schlüssel auf metaso.cn)",
|
||||
webSearchEngineBaidu: "baidu — Baidu AI Search (BAIDU_API_KEY oder QIANFAN_API_KEY setzen)",
|
||||
webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)",
|
||||
webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)",
|
||||
webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)",
|
||||
webSearchEngineBrave: "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)",
|
||||
webSearchEngineOllama: "ollama — Ollama Cloud-Websuche (OLLAMA_API_KEY setzen)",
|
||||
webSearchEngineNote: "gilt für den nächsten web_search-Aufruf",
|
||||
webSearchApiKeySignup: "(Schlüssel holen)",
|
||||
webSearchApiKeyClear: "Löschen",
|
||||
webSearchApiKey: {
|
||||
baidu: "Baidu API-Schlüssel",
|
||||
},
|
||||
sectionBudget: "Budget",
|
||||
budget: "Budget",
|
||||
budgetHint: "Ausgabengrenzen festlegen",
|
||||
@@ -330,18 +323,10 @@ export const de: typeof en = {
|
||||
themeStyleHint: "Theme-Stil wählen",
|
||||
themeStyleGraphite: "Graphit",
|
||||
themeStyleGraphiteDesc: "Kühle dunkle Neutrale — am besten für lange Sitzungen.",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleEmberDesc: "Warmes Dunkel mit stärkerem Reasonix-Orange.",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleAuroraDesc: "Türkisgrünes Dunkel für weicheres Arbeiten bei wenig Licht.",
|
||||
themeStyleSandstone: "Sandstein",
|
||||
themeStyleSandstoneDesc: "Warme helle Töne — angenehm in hellen Räumen.",
|
||||
themeStylePorcelain: "Porzellan",
|
||||
themeStylePorcelainDesc: "Sauberes Hell mit extra Kontrast.",
|
||||
themeStyleLinen: "Leinen",
|
||||
themeStyleLinenDesc: "Editoriales warmes Hell mit papierartigen Flächen.",
|
||||
themeStyleGlacier: "Gletscher",
|
||||
themeStyleGlacierDesc: "Kühles Hell mit klaren blauen Akzenten.",
|
||||
themeStyleMidnight: "Mitternacht",
|
||||
themeStyleMidnightDesc: "Tiefes Dunkel mit kühleren Highlights.",
|
||||
pageGeneralLabel: "Allgemein",
|
||||
@@ -1298,12 +1283,8 @@ export const de: typeof en = {
|
||||
themeDark: "Dunkel",
|
||||
themeLight: "Hell",
|
||||
themeStyleGraphite: "Graphit",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstein",
|
||||
themeStylePorcelain: "Porzellan",
|
||||
themeStyleLinen: "Leinen",
|
||||
themeStyleGlacier: "Gletscher",
|
||||
themeStyleMidnight: "Mitternacht",
|
||||
thisTurn: "Dieser Turn",
|
||||
tokens: "Tokens",
|
||||
|
||||
@@ -232,21 +232,14 @@ 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",
|
||||
@@ -312,18 +305,10 @@ 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",
|
||||
@@ -1250,12 +1235,8 @@ 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",
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { ja } from "./ja";
|
||||
import { ru } from "./ru";
|
||||
import { zhCN } from "./zh-CN";
|
||||
|
||||
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
|
||||
export type Lang = "en" | "zh-CN" | "de";
|
||||
|
||||
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
|
||||
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de"];
|
||||
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
|
||||
const LANG_LABELS: Record<Lang, string> = {
|
||||
en: "English",
|
||||
"zh-CN": "简体中文",
|
||||
de: "Deutsch",
|
||||
ja: "日本語",
|
||||
ru: "Русский",
|
||||
};
|
||||
const LANG_LABELS: Record<Lang, string> = { en: "English", "zh-CN": "简体中文", de: "Deutsch" };
|
||||
const STORAGE_KEY = "reasonix.lang";
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -32,8 +24,6 @@ function detectDefault(): Lang {
|
||||
const lower = nav.toLowerCase();
|
||||
if (lower.startsWith("zh")) return "zh-CN";
|
||||
if (lower.startsWith("de")) return "de";
|
||||
if (lower.startsWith("ja")) return "ja";
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
return "en";
|
||||
}
|
||||
|
||||
@@ -87,7 +77,7 @@ export function useLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
|
||||
const dicts = { en, "zh-CN": zhCN, de } as const;
|
||||
|
||||
type Dict = typeof en;
|
||||
type Path<T, K extends keyof T = keyof T> = K extends string
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
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)",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const ru: typeof en = {
|
||||
...en,
|
||||
};
|
||||
@@ -232,21 +232,14 @@ 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: "设置支出限额",
|
||||
@@ -312,18 +305,10 @@ export const zhCN = {
|
||||
themeStyleHint: "选择主题风格",
|
||||
themeStyleGraphite: "石墨",
|
||||
themeStyleGraphiteDesc: "冷色深色调,适合长时间使用。",
|
||||
themeStyleEmber: "余烬",
|
||||
themeStyleEmberDesc: "暖黑深色调,强化 Reasonix 橙色品牌感。",
|
||||
themeStyleAurora: "极光",
|
||||
themeStyleAuroraDesc: "青绿色深色调,低光环境更柔和。",
|
||||
themeStyleSandstone: "砂岩",
|
||||
themeStyleSandstoneDesc: "暖色浅色调,明亮环境更舒适。",
|
||||
themeStylePorcelain: "瓷白",
|
||||
themeStylePorcelainDesc: "高对比度浅色,简洁清爽。",
|
||||
themeStyleLinen: "亚麻",
|
||||
themeStyleLinenDesc: "偏纸张质感的温暖浅色调。",
|
||||
themeStyleGlacier: "冰川",
|
||||
themeStyleGlacierDesc: "冷白浅色调,蓝色强调更清爽。",
|
||||
themeStyleMidnight: "午夜",
|
||||
themeStyleMidnightDesc: "深色配冷色高光,更深邃。",
|
||||
pageGeneralLabel: "通用",
|
||||
@@ -1225,12 +1210,8 @@ export const zhCN = {
|
||||
themeDark: "深色",
|
||||
themeLight: "浅色",
|
||||
themeStyleGraphite: "石墨",
|
||||
themeStyleEmber: "余烬",
|
||||
themeStyleAurora: "极光",
|
||||
themeStyleSandstone: "砂岩",
|
||||
themeStylePorcelain: "瓷白",
|
||||
themeStyleLinen: "亚麻",
|
||||
themeStyleGlacier: "冰川",
|
||||
themeStyleMidnight: "午夜",
|
||||
thisTurn: "本轮",
|
||||
tokens: "Tokens",
|
||||
|
||||
@@ -331,7 +331,6 @@ 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 ?? "",
|
||||
|
||||
@@ -291,10 +291,8 @@ export type ReasoningEffort = "low" | "medium" | "high" | "max";
|
||||
|
||||
export type WebSearchEngineName =
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa"
|
||||
@@ -313,9 +311,6 @@ export type SettingsEvent = {
|
||||
model: string;
|
||||
editor?: string;
|
||||
webSearchEngine?: WebSearchEngineName;
|
||||
webSearchApiKeys?: {
|
||||
baidu?: string;
|
||||
};
|
||||
subagentModels?: Record<string, "flash" | "pro">;
|
||||
showSystemEvents?: boolean;
|
||||
version: string;
|
||||
@@ -347,11 +342,9 @@ 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;
|
||||
};
|
||||
|
||||
+2
-194
@@ -86,70 +86,6 @@ 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);
|
||||
@@ -216,70 +152,6 @@ 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);
|
||||
@@ -977,7 +849,6 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
font-weight: 500;
|
||||
}
|
||||
.side-head .new-btn .shortcut {
|
||||
display: none;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -993,9 +864,9 @@ html[data-platform="macos"] .titlebar .tb-left {
|
||||
padding: 1px 5px;
|
||||
box-shadow: none;
|
||||
}
|
||||
@container sidebar (min-width: 191px) {
|
||||
@container sidebar (max-width: 190px) {
|
||||
.side-head .new-btn .shortcut {
|
||||
display: inline-flex;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.side-head .icon-btn {
|
||||
@@ -3153,9 +3024,6 @@ 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;
|
||||
@@ -3177,12 +3045,8 @@ 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;
|
||||
@@ -3210,20 +3074,12 @@ 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;
|
||||
@@ -4600,30 +4456,6 @@ 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);
|
||||
@@ -4648,30 +4480,6 @@ 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);
|
||||
|
||||
@@ -11,12 +11,8 @@ 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;
|
||||
|
||||
@@ -29,35 +25,23 @@ 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,8 +668,7 @@ export function MetricStrip({
|
||||
costLabel,
|
||||
elapsed,
|
||||
}: {
|
||||
/** Pre-formatted cache percentage string (e.g. "72.12%"). */
|
||||
cacheHit?: string;
|
||||
cacheHit?: number;
|
||||
promptTokens?: number;
|
||||
outputTokens?: number;
|
||||
costLabel?: string;
|
||||
@@ -681,7 +680,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 ? (
|
||||
|
||||
@@ -64,7 +64,6 @@ export type SlashCmd = {
|
||||
};
|
||||
export type MentionItem = {
|
||||
name: string;
|
||||
path?: string;
|
||||
kind: "file" | "dir" | "url" | "agent" | "clip";
|
||||
desc?: string;
|
||||
};
|
||||
@@ -104,20 +103,6 @@ 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} />;
|
||||
@@ -255,15 +240,11 @@ 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) => {
|
||||
const display = displayMentionPath(path);
|
||||
return {
|
||||
name: display.name,
|
||||
path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: display.desc,
|
||||
};
|
||||
});
|
||||
const base: MentionItem[] = mentionResults.results.map((path) => ({
|
||||
name: path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: path,
|
||||
}));
|
||||
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
|
||||
const parent = parentOfAtQuery(popup.query);
|
||||
if (parent !== null) {
|
||||
@@ -336,10 +317,9 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
const mentionPath = mention.path ?? mention.name;
|
||||
setDraft(next ? `${next} @${mentionPath} ` : `@${mentionPath} `);
|
||||
setChips((c) => [...c, { kind: "at", label: mentionPath }]);
|
||||
onMentionPicked?.(mentionPath);
|
||||
setDraft(next ? `${next} @${mention.name} ` : `@${mention.name} `);
|
||||
setChips((c) => [...c, { kind: "at", label: mention.name }]);
|
||||
onMentionPicked?.(mention.name);
|
||||
}
|
||||
setPopup(null);
|
||||
textareaRef.current?.focus();
|
||||
@@ -381,7 +361,7 @@ export function Composer({
|
||||
setPopup({ kind: "at", query: parent, nonce });
|
||||
return;
|
||||
}
|
||||
const dirPath = (mention.path ?? mention.name).replace(/[\\/]+$/, "");
|
||||
const dirPath = mention.name.replace(/\/+$/, "");
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
|
||||
setDraft(next);
|
||||
const nonce = ++nonceRef.current;
|
||||
@@ -623,8 +603,7 @@ export function Composer({
|
||||
onHover={(i, item) => {
|
||||
setActiveIdx(i);
|
||||
if (popup.kind === "at" && onMentionPreview) {
|
||||
const mention = item as MentionItem;
|
||||
const path = mention.path ?? mention.name;
|
||||
const path = (item as MentionItem).name;
|
||||
onMentionPreview(path, popup.nonce);
|
||||
}
|
||||
}}
|
||||
@@ -661,10 +640,7 @@ function Popup({
|
||||
}, [activeIdx]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={kind === "at" ? "popup at-popup" : "popup"}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="ph">
|
||||
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
|
||||
<span>
|
||||
@@ -677,7 +653,7 @@ function Popup({
|
||||
<I.x size={11} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={kind === "at" ? "popup-list at-popup-list" : "popup-list"} ref={listRef}>
|
||||
<div className="popup-list" ref={listRef}>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
@@ -711,7 +687,7 @@ function Popup({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="mention-name">{(it as MentionItem).name}</span>
|
||||
<span>{(it as MentionItem).name}</span>
|
||||
{(it as MentionItem).desc ? (
|
||||
<div className="desc">{(it as MentionItem).desc}</div>
|
||||
) : null}
|
||||
|
||||
@@ -258,13 +258,7 @@ 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): 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;
|
||||
};
|
||||
const pct = (n: number) => Math.round((n / total) * 100);
|
||||
return (
|
||||
<div className="usage-full">
|
||||
<div className="uh">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
|
||||
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
|
||||
@@ -670,10 +669,8 @@ function PageGeneral({
|
||||
onSave({
|
||||
webSearchEngine: e.target.value as
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa"
|
||||
@@ -683,10 +680,8 @@ function PageGeneral({
|
||||
}
|
||||
>
|
||||
<option value="bing">{t("settings.webSearchEngineBing")}</option>
|
||||
<option value="bing-intl">{t("settings.webSearchEngineBingIntl")}</option>
|
||||
<option value="searxng">{t("settings.webSearchEngineSearxng")}</option>
|
||||
<option value="metaso">{t("settings.webSearchEngineMetaso")}</option>
|
||||
<option value="baidu">{t("settings.webSearchEngineBaidu")}</option>
|
||||
<option value="tavily">{t("settings.webSearchEngineTavily")}</option>
|
||||
<option value="perplexity">{t("settings.webSearchEnginePerplexity")}</option>
|
||||
<option value="exa">{t("settings.webSearchEngineExa")}</option>
|
||||
@@ -694,89 +689,11 @@ function PageGeneral({
|
||||
<option value="ollama">{t("settings.webSearchEngineOllama")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<WebSearchEngineCredentials settings={settings} onSave={onSave} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchEngineCredentials({
|
||||
settings,
|
||||
onSave,
|
||||
}: {
|
||||
settings: SettingsType;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
if (settings.webSearchEngine !== "baidu") return null;
|
||||
return (
|
||||
<WebSearchApiKeyRow
|
||||
prefix={settings.webSearchApiKeys?.baidu}
|
||||
signupUrl="https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy"
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSearchApiKeyRow({
|
||||
prefix,
|
||||
signupUrl,
|
||||
onSave,
|
||||
}: {
|
||||
prefix?: string;
|
||||
signupUrl: string;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
return (
|
||||
<div className="setting-row">
|
||||
<div className="l">
|
||||
<div className="n">{t("settings.webSearchApiKey.baidu")}</div>
|
||||
<div className="h">
|
||||
{prefix ? t("settings.apiKeySet", { prefix }) : t("settings.apiKeyNotSet")}{" "}
|
||||
<a
|
||||
href={signupUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUrl(signupUrl).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
{t("settings.webSearchApiKeySignup")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
className="field mono"
|
||||
type="password"
|
||||
value={draft}
|
||||
placeholder={prefix ?? ""}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) return;
|
||||
onSave({ baiduApiKey: trimmed });
|
||||
setDraft("");
|
||||
}}
|
||||
>
|
||||
{t("settings.apiKeySave")}
|
||||
</button>
|
||||
{prefix ? (
|
||||
<button type="button" className="btn" onClick={() => onSave({ baiduApiKey: null })}>
|
||||
{t("settings.webSearchApiKeyClear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeySection({
|
||||
baseUrl,
|
||||
apiKeyPrefix,
|
||||
|
||||
@@ -19,16 +19,6 @@ 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,
|
||||
@@ -63,12 +53,7 @@ export function StatusBar({
|
||||
onOpenWorkdir?: (anchor: { bottom: number; left: number }) => void;
|
||||
}) {
|
||||
const totalTokens = usage.cacheHitTokens + usage.cacheMissTokens;
|
||||
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 cacheHitPct = totalTokens > 0 ? Math.round((usage.cacheHitTokens / totalTokens) * 100) : 0;
|
||||
const runningJobs = jobs.filter((j) => j.running).length;
|
||||
const spent = formatMoney(usage.totalCostUsd, currency);
|
||||
const balanceLabel = balance
|
||||
@@ -100,10 +85,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={cacheHitDetail || t("statusbar.cacheHit")}>
|
||||
<span className="seg" title={t("statusbar.cacheHit")}>
|
||||
<I.zap size={11} style={{ color: "var(--accent)" }} />
|
||||
<span>{t("statusbar.cache")}</span>
|
||||
<span className="v acc">{cacheHitPctDisplay}%</span>
|
||||
<span className="v acc">{cacheHitPct}%</span>
|
||||
</span>
|
||||
<span className="seg">
|
||||
<I.cpu size={11} />
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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.52.0",
|
||||
"version": "0.50.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix-desktop",
|
||||
"version": "0.52.0",
|
||||
"version": "0.50.0",
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pulls Node 22 from nodejs.org into desktop/src-tauri/binaries/.
|
||||
// macOS downloads the host-arch binary so arm64 and x64 releases stay split.
|
||||
// Mac always produces a universal Mach-O so Intel + Apple Silicon ship from one .dmg (issue #1234).
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
||||
import https from "node:https";
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
@@ -54,16 +53,16 @@ if (existsSync(targetExe) && statSync(targetExe).size > 1024 * 1024) {
|
||||
if (PLAT === "darwin") {
|
||||
try {
|
||||
const archs = execSync(`lipo -archs "${targetExe}"`, { encoding: "utf8" }).trim();
|
||||
if (archs === MAC_ARCH) {
|
||||
if (archs.includes("arm64") && archs.includes("x86_64")) {
|
||||
if (!hasMacTccInheritance(targetExe)) {
|
||||
console.log(`${targetExe} ${MAC_ARCH} build missing TCC inheritance — applying ad-hoc sign`);
|
||||
console.log(`${targetExe} universal but missing TCC inheritance — applying ad-hoc sign`);
|
||||
adhocSignMac(targetExe);
|
||||
} else {
|
||||
console.log(`${targetExe} already ${MAC_ARCH} + TCC-inheriting — delete to refetch`);
|
||||
console.log(`${targetExe} already universal + TCC-inheriting (${archs}) — delete to refetch`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(`${targetExe} present but not ${MAC_ARCH} (${archs}) — rebuilding`);
|
||||
console.log(`${targetExe} present but not universal (${archs}) — rebuilding`);
|
||||
rmSync(targetExe);
|
||||
} catch {
|
||||
console.log(`${targetExe} present but not verifiable as Mach-O — rebuilding`);
|
||||
@@ -149,25 +148,36 @@ 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
-75
@@ -8,18 +8,6 @@ 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"
|
||||
@@ -974,18 +962,6 @@ 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"
|
||||
@@ -1474,15 +1450,6 @@ 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"
|
||||
@@ -1498,15 +1465,6 @@ 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"
|
||||
@@ -2056,17 +2014,6 @@ 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"
|
||||
@@ -2905,9 +2852,7 @@ name = "reasonix-desktop"
|
||||
version = "0.52.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
"parking_lot",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -3067,20 +3012,6 @@ 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"
|
||||
@@ -4672,12 +4603,6 @@ 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"
|
||||
|
||||
@@ -8,7 +8,7 @@ publish = false
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools", "macos-private-api", "tray-icon"] }
|
||||
tauri = { version = "2", features = ["devtools", "macos-private-api"] }
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
@@ -20,8 +20,6 @@ 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"]
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+14
-162
@@ -1,103 +1,16 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod cc_switch;
|
||||
mod rpc;
|
||||
|
||||
use cc_switch::import_cc_switch_mcp;
|
||||
use rpc::{rpc_kill, rpc_send, rpc_spawn, RpcState};
|
||||
use rpc::{RpcState, rpc_kill, rpc_send, rpc_spawn};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::menu::MenuBuilder;
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::Manager;
|
||||
|
||||
const TRAY_MENU_SHOW: &str = "show";
|
||||
const TRAY_MENU_QUIT: &str = "quit";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum DesktopCloseBehavior {
|
||||
CloseToTray,
|
||||
CloseToQuit,
|
||||
}
|
||||
|
||||
fn pasted_images_dir() -> PathBuf {
|
||||
std::env::temp_dir().join("reasonix-pasted-images")
|
||||
}
|
||||
|
||||
fn parse_desktop_close_behavior(value: &serde_json::Value) -> DesktopCloseBehavior {
|
||||
match value
|
||||
.get("desktopCloseBehavior")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
Some("closeToTray") => DesktopCloseBehavior::CloseToTray,
|
||||
_ => DesktopCloseBehavior::CloseToQuit,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
|
||||
Some(PathBuf::from(home).join(".reasonix").join("config.json"))
|
||||
}
|
||||
|
||||
fn desktop_close_behavior() -> DesktopCloseBehavior {
|
||||
let Some(path) = config_path() else {
|
||||
return DesktopCloseBehavior::CloseToQuit;
|
||||
};
|
||||
let Ok(raw) = std::fs::read_to_string(path) else {
|
||||
return DesktopCloseBehavior::CloseToQuit;
|
||||
};
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
return DesktopCloseBehavior::CloseToQuit;
|
||||
};
|
||||
parse_desktop_close_behavior(&value)
|
||||
}
|
||||
|
||||
fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn install_tray(app: &mut tauri::App) -> tauri::Result<()> {
|
||||
let menu = MenuBuilder::new(app)
|
||||
.text(TRAY_MENU_SHOW, "Show Window")
|
||||
.separator()
|
||||
.text(TRAY_MENU_QUIT, "Quit Reasonix")
|
||||
.build()?;
|
||||
|
||||
let mut tray = TrayIconBuilder::with_id("main")
|
||||
.tooltip("Reasonix")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
TRAY_MENU_SHOW => show_main_window(app),
|
||||
TRAY_MENU_QUIT => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
| TrayIconEvent::DoubleClick {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} => show_main_window(tray.app_handle()),
|
||||
_ => {}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
tray = tray.icon(icon);
|
||||
}
|
||||
|
||||
tray.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #892: bundled libwayland in AppImage can ABI-mismatch the host Wayland
|
||||
/// compositor → WebKitWebProcess `abort()`s on EGL display creation. Redirect
|
||||
/// the child to the host's libwayland via LD_PRELOAD before WebKit forks.
|
||||
@@ -175,9 +88,7 @@ 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 {
|
||||
@@ -223,10 +134,7 @@ 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;
|
||||
@@ -297,9 +205,7 @@ 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(())
|
||||
}
|
||||
@@ -316,11 +222,7 @@ fn sanitize_image_extension(raw: Option<&str>) -> String {
|
||||
let ok = !cleaned.is_empty()
|
||||
&& cleaned.len() <= 8
|
||||
&& cleaned.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
if ok {
|
||||
cleaned
|
||||
} else {
|
||||
"png".to_string()
|
||||
}
|
||||
if ok { cleaned } else { "png".to_string() }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -339,20 +241,12 @@ fn save_clipboard_image(bytes: Vec<u8>, extension: Option<String>) -> Result<Str
|
||||
|
||||
fn purge_old_pasted_images(max_age: Duration) {
|
||||
let dir = pasted_images_dir();
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { return };
|
||||
let cutoff = SystemTime::now().checked_sub(max_age);
|
||||
for entry in entries.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(metadata) = entry.metadata() else { continue };
|
||||
if !metadata.is_file() { continue }
|
||||
let Ok(modified) = metadata.modified() else { continue };
|
||||
if cutoff.is_some_and(|t| modified < t) {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
@@ -370,23 +264,11 @@ 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,
|
||||
@@ -394,8 +276,8 @@ fn main() {
|
||||
save_clipboard_image
|
||||
])
|
||||
.setup(|app| {
|
||||
use tauri::Manager;
|
||||
std::thread::spawn(|| purge_old_pasted_images(Duration::from_secs(24 * 60 * 60)));
|
||||
install_tray(app)?;
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
// HiDPI fit: the JSON config asks for 1024x720 logical px.
|
||||
// On Windows laptops at 200% scale (1920x1080 → 960x540
|
||||
@@ -427,27 +309,21 @@ fn main() {
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("tauri build failed")
|
||||
.run(|app, event| match event {
|
||||
.run(|app, 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.
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
use tauri::Manager;
|
||||
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;
|
||||
use super::sanitize_image_extension;
|
||||
|
||||
#[test]
|
||||
fn accepts_alphanumeric_extensions() {
|
||||
@@ -476,28 +352,4 @@ mod tests {
|
||||
fn rejects_overlong_extensions() {
|
||||
assert_eq!(sanitize_image_extension(Some("verylongext")), "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_close_behavior_defaults_to_quit() {
|
||||
assert_eq!(
|
||||
parse_desktop_close_behavior(&json!({})),
|
||||
DesktopCloseBehavior::CloseToQuit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_close_behavior_accepts_tray_mode() {
|
||||
assert_eq!(
|
||||
parse_desktop_close_behavior(&json!({ "desktopCloseBehavior": "closeToTray" })),
|
||||
DesktopCloseBehavior::CloseToTray
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_close_behavior_accepts_quit_mode() {
|
||||
assert_eq!(
|
||||
parse_desktop_close_behavior(&json!({ "desktopCloseBehavior": "closeToQuit" })),
|
||||
DesktopCloseBehavior::CloseToQuit
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
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,10 +69,7 @@ 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()],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -88,6 +85,7 @@ 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")?;
|
||||
@@ -125,11 +123,7 @@ 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 { "" },
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -269,9 +263,7 @@ 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);
|
||||
|
||||
|
||||
@@ -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 https://api.deepseek.com https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev https://github.com"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
+1
-43
@@ -34,7 +34,7 @@ vi.mock("./theme", () => ({
|
||||
themeForStyle: vi.fn(() => "dark"),
|
||||
}));
|
||||
|
||||
import { readWindowExpanded, reduce, toggleWindowExpanded } from "./App";
|
||||
import { reduce } from "./App";
|
||||
import { getThreadMaxWidth } from "./ui/thread-layout";
|
||||
|
||||
function initialState(): Parameters<typeof reduce>[0] {
|
||||
@@ -68,7 +68,6 @@ function initialState(): Parameters<typeof reduce>[0] {
|
||||
balance: null,
|
||||
mentionResults: null,
|
||||
mentionPreview: null,
|
||||
promptHistoryResult: null,
|
||||
mcpSpecs: [],
|
||||
mcpBridged: false,
|
||||
skills: [],
|
||||
@@ -214,47 +213,6 @@ describe("Desktop App reducer — usage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Desktop App window controls", () => {
|
||||
it("treats macOS zoom state as fullscreen", async () => {
|
||||
const win = {
|
||||
isFullscreen: vi.fn(async () => true),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
setFullscreen: vi.fn(async () => {}),
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await expect(readWindowExpanded(win, true)).resolves.toBe(true);
|
||||
expect(win.isFullscreen).toHaveBeenCalledTimes(1);
|
||||
expect(win.isMaximized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses native fullscreen for macOS zoom button", async () => {
|
||||
const win = {
|
||||
isFullscreen: vi.fn(async () => false),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
setFullscreen: vi.fn(async () => {}),
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await toggleWindowExpanded(win, true, false);
|
||||
expect(win.setFullscreen).toHaveBeenCalledWith(true);
|
||||
expect(win.toggleMaximize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps maximize behavior off macOS", async () => {
|
||||
const win = {
|
||||
isFullscreen: vi.fn(async () => false),
|
||||
isMaximized: vi.fn(async () => false),
|
||||
setFullscreen: vi.fn(async () => {}),
|
||||
toggleMaximize: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await toggleWindowExpanded(win, false, false);
|
||||
expect(win.toggleMaximize).toHaveBeenCalledTimes(1);
|
||||
expect(win.setFullscreen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Desktop App reducer — ApprovalPrompt integration", () => {
|
||||
it("stores shell confirm with prompt on $confirm_required", () => {
|
||||
const state = initialState();
|
||||
|
||||
+292
-580
File diff suppressed because it is too large.
Load diff
+16
-121
@@ -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,113 +24,26 @@ async function openWithEditor(
|
||||
abs: string,
|
||||
line?: number,
|
||||
): Promise<void> {
|
||||
if (editor?.trim()) {
|
||||
if (editor && editor.trim()) {
|
||||
await invoke("open_in_editor", { command: editor, path: abs, line: line ?? null });
|
||||
return;
|
||||
}
|
||||
await openPath(abs);
|
||||
}
|
||||
|
||||
type TrackedFile = { path: string; status: string };
|
||||
|
||||
type WorkspaceCtx = { dir?: string; editor?: string; sessionFiles?: TrackedFile[]; currentToolPaths?: string[] };
|
||||
type WorkspaceCtx = { dir?: string; editor?: string };
|
||||
const WorkspaceContext = createContext<WorkspaceCtx>({});
|
||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
function resolveAgainstWorkspace(rel: string, ws: string | undefined): string {
|
||||
if (!ws) return rel;
|
||||
const isWindows = ws.includes("\\");
|
||||
if (/^[a-zA-Z]:[\\/]/.test(rel) || rel.startsWith("/")) {
|
||||
return isWindows ? rel.replace(/\//g, "\\") : rel;
|
||||
}
|
||||
const sep = isWindows ? "\\" : "/";
|
||||
const trimmed = ws.replace(/[\\/]$/, "");
|
||||
const relative = basePath.replace(/^\.[\\/]/, "").replace(/\//g, sep);
|
||||
const relative = rel.replace(/^\.[\\/]/, "").replace(/\//g, sep);
|
||||
return `${trimmed}${sep}${relative}`;
|
||||
}
|
||||
|
||||
@@ -150,7 +63,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}$`);
|
||||
@@ -200,14 +113,14 @@ function parseFileHref(value: string): ParsedFileRef | null {
|
||||
return { ...parsed, line: parsed.line ?? hashLine };
|
||||
}
|
||||
|
||||
function FilePill({ path, line, matchIndex }: { path: string; line?: string; matchIndex: number }) {
|
||||
function FilePill({ path, line }: { path: string; line?: string }) {
|
||||
useLang();
|
||||
const ctx = useContext(WorkspaceContext);
|
||||
const [done, setDone] = useState<"open" | "copy" | null>(null);
|
||||
const display = line ? `${path}:${line}` : path;
|
||||
const 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);
|
||||
@@ -268,8 +181,7 @@ 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} matchIndex={_filePillIndex} />);
|
||||
_filePillIndex++;
|
||||
out.push(<FilePill key={`fp-${pillStart}`} path={path} line={line} />);
|
||||
last = pillStart + path.length + (line ? line.length + 1 : 0);
|
||||
m = FILE_PATH_RE.exec(text);
|
||||
}
|
||||
@@ -319,26 +231,10 @@ function normalizeMathDelimiters(source: string): string {
|
||||
.replace(/\\\)/g, "$$");
|
||||
// Restore protected sequences
|
||||
result = result.replace(/\x00LB\x00/g, "\\\\[");
|
||||
|
||||
// Replace | with \vert inside math to prevent GFM table column splitting.
|
||||
// \vert renders identically to | in KaTeX — it's the same vertical-bar
|
||||
// glyph — but the markdown parser won't mistake it for a table separator.
|
||||
result = result.replace(
|
||||
/\$\$([\s\S]*?)\$\$/g,
|
||||
(_: string, m: string) => `$$${m.replace(/\|/g, "\\vert ")}$$`,
|
||||
);
|
||||
result = result.replace(
|
||||
/\$([^$\n]+)\$/g,
|
||||
(_: string, m: string) => `$${m.replace(/\|/g, "\\vert ")}$`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
let _filePillIndex = 0;
|
||||
|
||||
export const Markdown = memo(function Markdown({ source }: { source: string }) {
|
||||
_filePillIndex = 0;
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
@@ -353,7 +249,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} matchIndex={_filePillIndex++} />;
|
||||
if (parsed) return <FilePill path={parsed.path} line={parsed.line} />;
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
a: ({ href, children }) => <SafeLink href={href}>{children}</SafeLink>,
|
||||
@@ -393,7 +289,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, ctx.sessionFiles, ctx.currentToolPaths, 0);
|
||||
const abs = resolveAgainstWorkspace(target.path, ctx.dir);
|
||||
await openWithEditor(ctx.editor, abs, firstLine(target.line));
|
||||
} catch {
|
||||
try {
|
||||
@@ -442,8 +338,7 @@ 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 "";
|
||||
}
|
||||
|
||||
|
||||
+4
-80
@@ -57,7 +57,6 @@ export const de: typeof en = {
|
||||
searchPlaceholder: "Letzte Pfade suchen...",
|
||||
empty: "Keine letzten Arbeitsbereiche",
|
||||
browse: "Lokal durchsuchen...",
|
||||
removeRecent: "Aus Liste entfernen",
|
||||
},
|
||||
sidebar: {
|
||||
...en.sidebar,
|
||||
@@ -74,9 +73,6 @@ export const de: typeof en = {
|
||||
copiedMd: "Unterhaltung als Markdown kopiert",
|
||||
newSession: "Neue Sitzung gestartet",
|
||||
cleared: "UI geleert",
|
||||
mcpStatusOpened: "MCP-Statusleiste geöffnet",
|
||||
mcpRetryNone: "Keine fehlgeschlagenen MCP-Server zum Wiederholen",
|
||||
mcpRetryQueued: "{count} fehlgeschlagene MCP-Server werden erneut versucht",
|
||||
aboutLine: "Reasonix v{version} · Cache-First-DeepSeek-Agent",
|
||||
},
|
||||
composer: {
|
||||
@@ -136,18 +132,10 @@ export const de: typeof en = {
|
||||
themeStyleHint: "Wähle die visuelle Palette für den gewählten hellen oder dunklen Modus.",
|
||||
themeStyleGraphite: "Graphit",
|
||||
themeStyleGraphiteDesc: "Originale dunkle Palette mit neutralen Graphit-Panels.",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleEmberDesc: "Warme dunkle Palette mit stärkerem Reasonix-Orange.",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleAuroraDesc: "Türkisgrüne dunkle Palette für weichere Arbeit bei wenig Licht.",
|
||||
themeStyleSandstone: "Sandstein",
|
||||
themeStyleSandstoneDesc: "Originale warme helle Palette.",
|
||||
themeStylePorcelain: "Porzellan",
|
||||
themeStylePorcelainDesc: "Saubere Codex-ähnliche helle Palette mit ruhigem Kontrast.",
|
||||
themeStyleLinen: "Leinen",
|
||||
themeStyleLinenDesc: "Editoriale warme helle Palette mit papierartigen Flächen.",
|
||||
themeStyleGlacier: "Gletscher",
|
||||
themeStyleGlacierDesc: "Kühle helle Palette mit klaren blauen Akzenten.",
|
||||
themeStyleMidnight: "Mitternacht",
|
||||
themeStyleMidnightDesc: "Marineblaue dunkle Palette mit kühlen blauen Oberflächen.",
|
||||
fontScale: "Schriftgröße",
|
||||
@@ -180,11 +168,6 @@ export const de: typeof en = {
|
||||
"Zeige leise Inline-Hinweise zur Kontextkomprimierung, Abbruch und Ratelimit-Pausen.",
|
||||
shown: "angezeigt",
|
||||
hidden: "ausgeblendet",
|
||||
desktopCloseBehavior: "Fenster schließen",
|
||||
desktopCloseBehaviorHint:
|
||||
"Standardmäßig beendet Schließen die App. In die Ablage ausblenden hält laufende Sitzungen aktiv.",
|
||||
closeToQuit: "beenden",
|
||||
closeToTray: "in Ablage",
|
||||
editModeReviewDesc: "jeden Schreibzugriff genehmigen",
|
||||
editModeAutoDesc: "Befehle genehmigen, Edits auto-anwenden",
|
||||
editModeYoloDesc: "nichts genehmigen — Vollautomatik",
|
||||
@@ -193,12 +176,9 @@ export const de: typeof en = {
|
||||
budgetPlaceholder: "keine Grenze",
|
||||
webSearchEngine: "Suchmaschine",
|
||||
webSearchEngineBing: "bing — Standard, funktioniert von CN ohne Proxy",
|
||||
webSearchEngineBingIntl: "bing-intl — internationaler Index über www.bing.com",
|
||||
webSearchEngineSearxng: "searxng — selbst gehostet (Endpunkt via /se searxng <url>)",
|
||||
webSearchEngineMetaso:
|
||||
"metaso — 100/Tag kostenlos (CN-freundlich, kostenloser Schlüssel auf metaso.cn)",
|
||||
webSearchEngineBaidu:
|
||||
"baidu — Baidu AI Search, laut Baidu-Doku 1500/Monat kostenlos (BAIDU_API_KEY oder QIANFAN_API_KEY setzen)",
|
||||
webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)",
|
||||
webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)",
|
||||
webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)",
|
||||
@@ -211,7 +191,6 @@ export const de: typeof en = {
|
||||
webSearchApiKeyClear: "Löschen",
|
||||
webSearchApiKey: {
|
||||
metaso: "Metaso API-Key",
|
||||
baidu: "Baidu API-Key",
|
||||
tavily: "Tavily API-Key",
|
||||
perplexity: "Perplexity API-Key",
|
||||
exa: "Exa API-Key",
|
||||
@@ -303,61 +282,20 @@ export const de: typeof en = {
|
||||
modelCustomHint:
|
||||
"Jede OpenAI-kompatible ID, die dein Endpunkt bereitstellt (vLLM, Ollama, Together, …).",
|
||||
modelCustomActive: "Läuft aktuell auf benutzerdefinierter ID: {model}",
|
||||
contextTokensLabel: "Kontextfenstergröße",
|
||||
contextTokensHint: "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.",
|
||||
contextTokensPlaceholder: "Automatisch",
|
||||
effortSection: "Reasoning-Effort",
|
||||
ctxWindow: "Kontext",
|
||||
maxOutput: "Ausgabe",
|
||||
mcpConfigured: "Konfiguriert · {count}",
|
||||
mcpBridged: "· Verbunden",
|
||||
mcpNotBridged: "· 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",
|
||||
},
|
||||
mcpNotBridged:
|
||||
"· Aktuelle Desktop-Sitzung nicht verbunden — starte reasonix code (TUI) neu, um wirksam zu werden",
|
||||
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.",
|
||||
@@ -388,8 +326,8 @@ export const de: typeof en = {
|
||||
},
|
||||
modal: {
|
||||
...en.modal,
|
||||
planFeedbackPlaceholder: "Welche Probleme hat der Plan? Was muss verbessert werden?",
|
||||
choiceCustomPlaceholder: "Wo kann der Plan weiter verfeinert werden?",
|
||||
planFeedbackPlaceholder: "Optional: Feedback — schreibe hier, wenn du Änderungen möchtest",
|
||||
choiceCustomPlaceholder: "Oder gib deine eigene Antwort ein…",
|
||||
},
|
||||
plan: {
|
||||
...en.plan,
|
||||
@@ -480,10 +418,6 @@ export const de: typeof en = {
|
||||
abort: "Streaming stoppen",
|
||||
copyLast: "Letzte Antwort kopieren",
|
||||
switchModel: "Modell wechseln",
|
||||
mcp: "MCP-Server-Einstellungen öffnen",
|
||||
mcpStatus: "MCP-Verbindungsstatus anzeigen",
|
||||
mcpRetry: "Fehlgeschlagene MCP-Verbindungen erneut versuchen",
|
||||
mcpImport: "MCP-Server aus cc-switch importieren",
|
||||
searchEngine: "Suchmaschine + API-Keys konfigurieren",
|
||||
skill: "Skills auflisten / verwalten (Projekt + Custom + Global + Eingebaut)",
|
||||
setMode: "Modus auf {mode} setzen",
|
||||
@@ -674,12 +608,8 @@ export const de: typeof en = {
|
||||
themeDark: "dunkel",
|
||||
themeLight: "hell",
|
||||
themeStyleGraphite: "Graphit",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstein",
|
||||
themeStylePorcelain: "Porzellan",
|
||||
themeStyleLinen: "Leinen",
|
||||
themeStyleGlacier: "Gletscher",
|
||||
themeStyleMidnight: "Mitternacht",
|
||||
},
|
||||
jobs: {
|
||||
@@ -802,8 +732,6 @@ export const de: typeof en = {
|
||||
},
|
||||
thread: {
|
||||
...en.thread,
|
||||
toolCalls: "{count} Tool-Aufrufe",
|
||||
oneToolCall: "1 Tool-Aufruf",
|
||||
keepSteps: "{n} verbleibende(n) Schritt(e) behalten",
|
||||
keepOriginal: "Original behalten",
|
||||
you: "Du",
|
||||
@@ -817,10 +745,6 @@ export const de: typeof en = {
|
||||
approve: "Genehmigen",
|
||||
cancel: "Abbrechen",
|
||||
refine: "Verfeinern",
|
||||
sendFeedback: "Senden",
|
||||
skipFeedback: "Überspringen",
|
||||
cancelFeedbackLabel: "Abbrechen — was ist falsch?",
|
||||
refineFeedbackLabel: "Verfeinern — was soll geändert werden?",
|
||||
checkpointKind: "Plan-Checkpoint",
|
||||
checkpointTitle: "Schritt {completed} / {total} abgeschlossen",
|
||||
checkpointSub: "Checkpoint · {completed} / {total}",
|
||||
|
||||
+4
-99
@@ -51,7 +51,6 @@ export const en = {
|
||||
searchPlaceholder: "Search recent paths…",
|
||||
empty: "No recent workspaces",
|
||||
browse: "Browse local…",
|
||||
removeRecent: "Remove from recent",
|
||||
},
|
||||
sidebar: {
|
||||
newChat: "New chat",
|
||||
@@ -66,14 +65,6 @@ 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: {
|
||||
@@ -130,18 +121,10 @@ 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",
|
||||
@@ -173,10 +156,6 @@ export const en = {
|
||||
"Show quiet inline notices for context compaction, abort, and rate-limit pauses.",
|
||||
shown: "shown",
|
||||
hidden: "hidden",
|
||||
desktopCloseBehavior: "Window close",
|
||||
desktopCloseBehaviorHint: "Close quits by default. Hide to tray keeps running sessions alive.",
|
||||
closeToQuit: "quit",
|
||||
closeToTray: "hide to tray",
|
||||
editModeReviewDesc: "approve every write",
|
||||
editModeAutoDesc: "approve commands, auto edits",
|
||||
editModeYoloDesc: "approve nothing — full auto",
|
||||
@@ -185,11 +164,8 @@ 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)",
|
||||
@@ -202,7 +178,6 @@ export const en = {
|
||||
webSearchApiKeyClear: "Clear",
|
||||
webSearchApiKey: {
|
||||
metaso: "Metaso API key",
|
||||
baidu: "Baidu API key",
|
||||
tavily: "Tavily API key",
|
||||
perplexity: "Perplexity API key",
|
||||
exa: "Exa API key",
|
||||
@@ -295,61 +270,21 @@ 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: "· 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",
|
||||
},
|
||||
mcpNotBridged:
|
||||
"· Current desktop session not bridged — restart reasonix code (TUI) to take effect",
|
||||
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:
|
||||
@@ -383,8 +318,8 @@ export const en = {
|
||||
shortcutSettings: "Settings",
|
||||
},
|
||||
modal: {
|
||||
planFeedbackPlaceholder: "What problems does the plan have? What needs to be improved?",
|
||||
choiceCustomPlaceholder: "Where can the plan be further refined?",
|
||||
planFeedbackPlaceholder: "Optional feedback — write here if you want changes",
|
||||
choiceCustomPlaceholder: "Or type your own answer…",
|
||||
},
|
||||
plan: {
|
||||
activeTitle: "Active plan",
|
||||
@@ -465,10 +400,6 @@ 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}",
|
||||
@@ -645,20 +576,10 @@ 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",
|
||||
@@ -697,16 +618,6 @@ 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",
|
||||
@@ -788,8 +699,6 @@ 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",
|
||||
@@ -803,10 +712,6 @@ 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}",
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import { ja } from "./ja";
|
||||
import { ru } from "./ru";
|
||||
import { zhCN } from "./zh-CN";
|
||||
|
||||
export type Lang = "en" | "zh-CN" | "de" | "ja" | "ru";
|
||||
export type Lang = "en" | "zh-CN" | "de";
|
||||
|
||||
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de", "ja", "ru"];
|
||||
const SUPPORTED_LANGS: readonly Lang[] = ["en", "zh-CN", "de"];
|
||||
const SUPPORTED: ReadonlySet<Lang> = new Set<Lang>(SUPPORTED_LANGS);
|
||||
const LANG_LABELS: Record<Lang, string> = {
|
||||
en: "English",
|
||||
"zh-CN": "简体中文",
|
||||
de: "Deutsch",
|
||||
ja: "日本語",
|
||||
ru: "Русский",
|
||||
};
|
||||
const LANG_LABELS: Record<Lang, string> = { en: "English", "zh-CN": "简体中文", de: "Deutsch" };
|
||||
const STORAGE_KEY = "reasonix.lang";
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -32,8 +24,6 @@ function detectDefault(): Lang {
|
||||
const lower = nav.toLowerCase();
|
||||
if (lower.startsWith("zh")) return "zh-CN";
|
||||
if (lower.startsWith("de")) return "de";
|
||||
if (lower.startsWith("ja")) return "ja";
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
return "en";
|
||||
}
|
||||
|
||||
@@ -87,7 +77,7 @@ export function useLang(): Lang {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
const dicts = { en, "zh-CN": zhCN, de, ja, ru } as const;
|
||||
const dicts = { en, "zh-CN": zhCN, de } as const;
|
||||
|
||||
type Dict = typeof en;
|
||||
type Path<T, K extends keyof T = keyof T> = K extends string
|
||||
|
||||
@@ -1,889 +0,0 @@
|
||||
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: "現在のタスクが完了し、レビュー可能です。",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { en } from "./en";
|
||||
|
||||
export const ru: typeof en = {
|
||||
...en,
|
||||
};
|
||||
@@ -53,7 +53,6 @@ export const zhCN: typeof en = {
|
||||
searchPlaceholder: "搜索最近路径…",
|
||||
empty: "暂无最近工作目录",
|
||||
browse: "浏览本地…",
|
||||
removeRecent: "移除此记录",
|
||||
},
|
||||
sidebar: {
|
||||
newChat: "新建会话",
|
||||
@@ -68,13 +67,6 @@ 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: {
|
||||
@@ -130,18 +122,10 @@ 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: "字体大小",
|
||||
@@ -172,10 +156,6 @@ export const zhCN: typeof en = {
|
||||
showSystemEventsHint: "在对话流中以细线显示压缩 / 中断 / 限速等系统事件",
|
||||
shown: "显示",
|
||||
hidden: "隐藏",
|
||||
desktopCloseBehavior: "关闭窗口",
|
||||
desktopCloseBehaviorHint: "默认关闭即退出。隐藏到托盘会让运行中的会话继续保活。",
|
||||
closeToQuit: "退出",
|
||||
closeToTray: "隐藏到托盘",
|
||||
editModeReviewDesc: "每次写入都要确认",
|
||||
editModeAutoDesc: "改文件自动,命令仍要确认",
|
||||
editModeYoloDesc: "全部放行 — 完全自动",
|
||||
@@ -184,11 +164,8 @@ 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)",
|
||||
@@ -201,7 +178,6 @@ export const zhCN: typeof en = {
|
||||
webSearchApiKeyClear: "清除",
|
||||
webSearchApiKey: {
|
||||
metaso: "Metaso API key",
|
||||
baidu: "百度 API key",
|
||||
tavily: "Tavily API key",
|
||||
perplexity: "Perplexity API key",
|
||||
exa: "Exa API key",
|
||||
@@ -288,60 +264,19 @@ 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: "· 正在应用到当前桌面会话",
|
||||
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: "连接失败 · 查看详情获取原始错误",
|
||||
},
|
||||
mcpNotBridged: "· 当前桌面会话未桥接,重启 reasonix code (TUI) 后生效",
|
||||
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。",
|
||||
@@ -369,8 +304,8 @@ export const zhCN: typeof en = {
|
||||
shortcutSettings: "设置",
|
||||
},
|
||||
modal: {
|
||||
planFeedbackPlaceholder: "计划存在哪些问题?需要改进哪些地方?",
|
||||
choiceCustomPlaceholder: "计划可以如何进一步细化?",
|
||||
planFeedbackPlaceholder: "可选反馈 — 想要修改时写这里",
|
||||
choiceCustomPlaceholder: "或者自己写一个答案…",
|
||||
},
|
||||
plan: {
|
||||
activeTitle: "进行中的计划",
|
||||
@@ -446,12 +381,8 @@ export const zhCN: typeof en = {
|
||||
themeDark: "深色",
|
||||
themeLight: "浅色",
|
||||
themeStyleGraphite: "Graphite",
|
||||
themeStyleEmber: "Ember",
|
||||
themeStyleAurora: "Aurora",
|
||||
themeStyleSandstone: "Sandstone",
|
||||
themeStylePorcelain: "Porcelain",
|
||||
themeStyleLinen: "Linen",
|
||||
themeStyleGlacier: "Glacier",
|
||||
themeStyleMidnight: "Midnight",
|
||||
},
|
||||
jobs: {
|
||||
@@ -488,10 +419,6 @@ export const zhCN: typeof en = {
|
||||
abort: "中断流式输出",
|
||||
copyLast: "复制最后一条回复",
|
||||
switchModel: "切换模型",
|
||||
mcp: "打开 MCP 服务器设置",
|
||||
mcpStatus: "查看 MCP 连接状态",
|
||||
mcpRetry: "重试失败的 MCP 连接",
|
||||
mcpImport: "从 cc-switch 导入 MCP",
|
||||
searchEngine: "配置搜索引擎 + API key",
|
||||
skill: "列出 / 管理用户技能(项目 + 自定义 + 全局 + 内置)",
|
||||
setMode: "切换模式为 {mode}",
|
||||
@@ -650,12 +577,6 @@ export const zhCN: typeof en = {
|
||||
cost: "花费",
|
||||
elapsed: "耗时",
|
||||
},
|
||||
mcpCard: {
|
||||
showTools: "工具",
|
||||
hideTools: "收起工具",
|
||||
availableTools: "{count} 个可用工具",
|
||||
noTools: "已连接,但该服务器未暴露可调用工具",
|
||||
},
|
||||
contextPanel: {
|
||||
reservedKey: "预留",
|
||||
usedKey: "已用",
|
||||
@@ -682,16 +603,6 @@ 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: "项目",
|
||||
@@ -773,8 +684,6 @@ export const zhCN: typeof en = {
|
||||
unknownKind: "# 未知卡片类型 — fallback 渲染",
|
||||
},
|
||||
thread: {
|
||||
toolCalls: "{count} 个工具调用",
|
||||
oneToolCall: "1 个工具调用",
|
||||
keepSteps: "保留剩余 {n} 步",
|
||||
keepOriginal: "保留原计划",
|
||||
you: "你",
|
||||
@@ -788,10 +697,6 @@ export const zhCN: typeof en = {
|
||||
approve: "批准",
|
||||
cancel: "取消",
|
||||
refine: "细化",
|
||||
sendFeedback: "发送",
|
||||
skipFeedback: "跳过",
|
||||
cancelFeedbackLabel: "取消 — 哪里有问题?",
|
||||
refineFeedbackLabel: "细化 — 还需要什么?",
|
||||
checkpointKind: "计划检查点",
|
||||
checkpointTitle: "step {completed} / {total} 完成",
|
||||
checkpointSub: "检查点 · {completed} / {total}",
|
||||
|
||||
@@ -77,13 +77,6 @@ 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;
|
||||
+2
-66
@@ -149,20 +149,6 @@ 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;
|
||||
@@ -181,19 +167,10 @@ 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 = {
|
||||
@@ -202,19 +179,6 @@ 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 = {
|
||||
@@ -332,17 +296,8 @@ 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"
|
||||
| "brave"
|
||||
| "ollama";
|
||||
export type WebSearchEngineName = "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa"
|
||||
| "brave" | "ollama";
|
||||
|
||||
export type SettingsEvent = {
|
||||
type: "$settings";
|
||||
@@ -355,12 +310,10 @@ 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;
|
||||
@@ -406,22 +359,17 @@ 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;
|
||||
};
|
||||
|
||||
@@ -555,7 +503,6 @@ export type IncomingEvent = { tabId?: string } & (
|
||||
| PlanClearedEvent
|
||||
| MentionResultsEvent
|
||||
| MentionPreviewEvent
|
||||
| PromptHistoryResultEvent
|
||||
| TabOpenedEvent
|
||||
| TabClosedEvent
|
||||
| McpSpecsEvent
|
||||
@@ -605,23 +552,12 @@ export type OutgoingCommand = { tabId?: string } & (
|
||||
| { cmd: "mention_query"; query: string; nonce: number }
|
||||
| { cmd: "mention_preview"; path: string; nonce: number }
|
||||
| { cmd: "mention_picked"; path: string }
|
||||
| {
|
||||
cmd: "prompt_history_step";
|
||||
nonce: number;
|
||||
direction: "older" | "newer";
|
||||
cursor?: PromptHistoryCursor | null;
|
||||
startSessionName?: string;
|
||||
stopSessionName?: string;
|
||||
}
|
||||
| { cmd: "tab_open"; workspaceDir?: string }
|
||||
| { cmd: "tab_close" }
|
||||
| { cmd: "tab_activate"; tabId: string }
|
||||
| { cmd: "mcp_specs_get" }
|
||||
| { cmd: "mcp_specs_add"; spec: string }
|
||||
| { cmd: "mcp_specs_remove"; spec: string }
|
||||
| { cmd: "mcp_import_servers"; servers: ImportedMcpServer[] }
|
||||
| { cmd: "mcp_specs_update"; raw: string; server: ImportedMcpServer }
|
||||
| { cmd: "mcp_specs_retry"; raw: string }
|
||||
| { cmd: "skills_get" }
|
||||
| { cmd: "skill_run"; name: string; args?: string }
|
||||
| { cmd: "jobs_list" }
|
||||
|
||||
+74
-802
File diff suppressed because it is too large.
Load diff
@@ -11,12 +11,8 @@ 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;
|
||||
|
||||
@@ -29,35 +25,23 @@ 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,7 +218,6 @@ export function ShellCard({
|
||||
onApprove,
|
||||
onReject,
|
||||
onAlwaysAllow,
|
||||
defaultOpen,
|
||||
}: {
|
||||
command: string;
|
||||
output?: string;
|
||||
@@ -227,7 +226,6 @@ export function ShellCard({
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onAlwaysAllow?: () => void;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
useLang();
|
||||
const tone: Tone = state === "failed" ? "danger" : state === "done" ? "success" : "warning";
|
||||
@@ -238,7 +236,7 @@ export function ShellCard({
|
||||
kind="shell"
|
||||
name="shell"
|
||||
compact
|
||||
defaultOpen={defaultOpen ?? false}
|
||||
defaultOpen={state !== "done"}
|
||||
meta={
|
||||
<>
|
||||
{state === "await" ? (
|
||||
@@ -337,14 +335,12 @@ export function ToolCard({
|
||||
result,
|
||||
ok,
|
||||
durationMs,
|
||||
defaultOpen,
|
||||
}: {
|
||||
name: string;
|
||||
args?: string;
|
||||
result?: string;
|
||||
ok?: boolean;
|
||||
durationMs?: number;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
useLang();
|
||||
const running = result === undefined;
|
||||
@@ -355,7 +351,7 @@ export function ToolCard({
|
||||
icon={<I.wrench size={12} />}
|
||||
kind="tool"
|
||||
name={name}
|
||||
defaultOpen={defaultOpen ?? false}
|
||||
defaultOpen={false}
|
||||
compact
|
||||
meta={
|
||||
<>
|
||||
@@ -737,8 +733,7 @@ export function MetricStrip({
|
||||
costLabel,
|
||||
elapsed,
|
||||
}: {
|
||||
/** Pre-formatted cache percentage string (e.g. "72.12%"). */
|
||||
cacheHit?: string;
|
||||
cacheHit?: number;
|
||||
promptTokens?: number;
|
||||
outputTokens?: number;
|
||||
costLabel?: string;
|
||||
@@ -750,7 +745,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 ? (
|
||||
|
||||
@@ -160,99 +160,4 @@ describe("desktop Composer @ popup", () => {
|
||||
|
||||
expect(container.querySelector(".popup-list.at-popup-list")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("delegates ArrowUp prompt-history navigation only at the input start", () => {
|
||||
const onPromptHistoryNavigate = vi.fn(() => true);
|
||||
const { container } = renderComposer({
|
||||
draft: "current prompt",
|
||||
onPromptHistoryNavigate,
|
||||
});
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
textarea.setSelectionRange(3, 3);
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
expect(onPromptHistoryNavigate).not.toHaveBeenCalled();
|
||||
|
||||
textarea.setSelectionRange(0, 0);
|
||||
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||
expect(onPromptHistoryNavigate).toHaveBeenCalledWith("older");
|
||||
});
|
||||
|
||||
it("allows ArrowDown prompt-history navigation while browsing at a boundary", () => {
|
||||
const onPromptHistoryNavigate = vi.fn(() => true);
|
||||
const { container } = renderComposer({
|
||||
draft: "recalled prompt",
|
||||
promptHistoryBrowsing: true,
|
||||
onPromptHistoryNavigate,
|
||||
});
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
textarea.setSelectionRange("recalled prompt".length, "recalled prompt".length);
|
||||
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||
|
||||
expect(onPromptHistoryNavigate).toHaveBeenCalledWith("newer");
|
||||
});
|
||||
|
||||
it("shows mention filenames first while preserving the full path for preview and pick", async () => {
|
||||
const setDraft = vi.fn();
|
||||
const onMentionPicked = vi.fn();
|
||||
const onMentionPreview = vi.fn();
|
||||
const { container, rerender, onMentionQuery } = renderComposer({
|
||||
setDraft,
|
||||
onMentionPicked,
|
||||
onMentionPreview,
|
||||
});
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) throw new Error("missing textarea");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "@foo" } });
|
||||
await waitFor(() => expect(onMentionQuery).toHaveBeenCalled());
|
||||
const nonce = onMentionQuery.mock.calls[0]?.[1] as number;
|
||||
|
||||
rerender(
|
||||
<Composer
|
||||
draft="@foo"
|
||||
setDraft={setDraft}
|
||||
onSend={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
disabled={false}
|
||||
busy={false}
|
||||
modelLabel="deepseek-v4-flash"
|
||||
reasoningEffort="high"
|
||||
onModelChange={vi.fn()}
|
||||
onEffortChange={vi.fn()}
|
||||
editMode="review"
|
||||
onEditModeChange={vi.fn()}
|
||||
textareaRef={createRef<HTMLTextAreaElement>()}
|
||||
slashCommands={[]}
|
||||
onMentionQuery={onMentionQuery}
|
||||
onMentionPreview={onMentionPreview}
|
||||
onMentionPicked={onMentionPicked}
|
||||
mentionResults={{ nonce, query: "foo", results: ["src/very/deep/foo.ts"] }}
|
||||
workspaceDir="/repo"
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = await waitFor(() => {
|
||||
const item = container.querySelector(".popup-item");
|
||||
expect(item).not.toBeNull();
|
||||
return item as HTMLElement;
|
||||
});
|
||||
|
||||
expect(row.querySelector(".nm > span")?.textContent).toBe("foo.ts");
|
||||
expect(row.querySelector(".desc")?.textContent).toBe("src/very/deep/");
|
||||
|
||||
fireEvent.mouseEnter(row);
|
||||
expect(onMentionPreview).toHaveBeenCalledWith("src/very/deep/foo.ts", nonce);
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(onMentionPicked).toHaveBeenCalledWith("src/very/deep/foo.ts");
|
||||
const draftUpdate = setDraft.mock.calls.at(-1)?.[0];
|
||||
expect(typeof draftUpdate).toBe("function");
|
||||
expect((draftUpdate as (current: string) => string)("@foo")).toBe(
|
||||
"@src/very/deep/foo.ts ",
|
||||
);
|
||||
});
|
||||
});
|
||||
+113
-250
@@ -70,7 +70,6 @@ export type SlashCmd = {
|
||||
};
|
||||
export type MentionItem = {
|
||||
name: string;
|
||||
path?: string;
|
||||
kind: "file" | "dir" | "url" | "agent" | "clip";
|
||||
desc?: string;
|
||||
};
|
||||
@@ -79,62 +78,11 @@ 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} />,
|
||||
@@ -161,20 +109,6 @@ 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} />;
|
||||
@@ -198,9 +132,6 @@ function guessImageExtension(mime: string): string {
|
||||
export function Composer({
|
||||
draft,
|
||||
setDraft,
|
||||
onDraftUserEdit,
|
||||
promptHistoryBrowsing,
|
||||
onPromptHistoryNavigate,
|
||||
onSend,
|
||||
onAbort,
|
||||
disabled,
|
||||
@@ -226,9 +157,6 @@ export function Composer({
|
||||
}: {
|
||||
draft: string;
|
||||
setDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
onDraftUserEdit?: () => void;
|
||||
promptHistoryBrowsing?: boolean;
|
||||
onPromptHistoryNavigate?: (direction: "older" | "newer") => boolean | void;
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
disabled?: boolean;
|
||||
@@ -255,56 +183,29 @@ 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 activeRangeRef = useRef<ActiveRange | null>(null);
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const historyRef = useRef<string[]>([]);
|
||||
const [browseIdx, setBrowseIdx] = useState(-1);
|
||||
const savedDraftRef = useRef("");
|
||||
|
||||
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"));
|
||||
setDraft((current) =>
|
||||
current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `,
|
||||
);
|
||||
setChips((c) => [...c, { kind: "at", label: rel }]);
|
||||
onMentionPicked?.(rel);
|
||||
setPopup(null);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
@@ -320,7 +221,6 @@ 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]);
|
||||
@@ -391,15 +291,11 @@ 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) => {
|
||||
const display = displayMentionPath(path);
|
||||
return {
|
||||
name: display.name,
|
||||
path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: display.desc,
|
||||
};
|
||||
});
|
||||
const base: MentionItem[] = mentionResults.results.map((path) => ({
|
||||
name: path,
|
||||
kind: path.endsWith("/") || path.endsWith("\\") ? "dir" : "file",
|
||||
desc: path,
|
||||
}));
|
||||
// "../" entry (#1019): one level up whenever the @ query is inside a subdir.
|
||||
const parent = parentOfAtQuery(popup.query);
|
||||
if (parent !== null) {
|
||||
@@ -428,44 +324,21 @@ export function Composer({
|
||||
onMentionQuery(popup.query, popup.nonce);
|
||||
}, [popup, onMentionQuery]);
|
||||
|
||||
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 {
|
||||
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 trail = v.match(/(^|\s)([/@])([^\s]*)$/);
|
||||
if (trail) {
|
||||
const sigil = trail[2];
|
||||
const query = trail[3] ?? "";
|
||||
if (sigil === "/") {
|
||||
setPopup({ kind: "slash", query });
|
||||
} else {
|
||||
const nonce = ++nonceRef.current;
|
||||
setPopup({ kind: "at", query, nonce });
|
||||
}
|
||||
} else if (popup) {
|
||||
setPopup(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -474,61 +347,66 @@ export function Composer({
|
||||
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 spacer = before && !before.endsWith(" ") ? " " : "";
|
||||
setDraft(`${before}${spacer}${cmd} ${after}`);
|
||||
setPickedChips((prev) => new Map(prev).set(cmd.replace(/^\//, ""), "slash"));
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next ? `${next} ${cmd} ` : `${cmd} `);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
} else {
|
||||
setDraft(`${before}${after}`);
|
||||
const next = draft.replace(/[/@][^\s]*$/, "").trimEnd();
|
||||
setDraft(next);
|
||||
setChips((c) => [...c, { kind: "slash", label: cmd.replace(/^\//, "") }]);
|
||||
(it as SlashCmd).run();
|
||||
}
|
||||
} else {
|
||||
const mention = it as MentionItem;
|
||||
if (mention.name === "..") {
|
||||
const parent = parentOfAtQuery(popup.query) ?? "";
|
||||
const newText = `@${parent}`;
|
||||
const next = `${before}${newText}${after}`;
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
|
||||
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 mentionPath = mention.path ?? mention.name;
|
||||
insertMention(mentionPath);
|
||||
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);
|
||||
}
|
||||
activeRangeRef.current = null;
|
||||
setPopup(null);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
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 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 handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -551,40 +429,25 @@ export function Composer({
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && popup.kind === "at" && items.length > 0) {
|
||||
e.preventDefault();
|
||||
// Tab on a directory enters it — replaces `@src` with `@src/`
|
||||
// and re-queries so the popup shows that directory's children.
|
||||
// `..` 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 newText = `@${parent}`;
|
||||
const next = `${before}${newText}${after}`;
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${parent}`);
|
||||
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.path ?? mention.name).replace(/[\\/]+$/, "");
|
||||
const newText = `@${dirPath}/`;
|
||||
const next = `${before}${newText}${after}`;
|
||||
const dirPath = mention.name.replace(/\/+$/, "");
|
||||
const next = draft.replace(/[@][^\s]*$/, `@${dirPath}/`);
|
||||
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;
|
||||
@@ -600,29 +463,31 @@ export function Composer({
|
||||
}
|
||||
}
|
||||
if (!popup) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 (composingRef.current || e.nativeEvent.isComposing || Date.now() - compositionEndedAtRef.current < 50) return;
|
||||
if (composingRef.current || 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);
|
||||
setPickedChips(new Map());
|
||||
setChips([]);
|
||||
}
|
||||
} else if (!disabled && draft.trim()) {
|
||||
recordSendAndReset();
|
||||
onSend();
|
||||
setPickedChips(new Map());
|
||||
setChips([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -688,40 +553,41 @@ export function Composer({
|
||||
{chips.length > 0 ? (
|
||||
<div className="composer-tags">
|
||||
{chips.map((c, i) => (
|
||||
<span key={`${c.kind}-${c.label}-${i}`} className={`chip ${c.kind}`} title={c.label}>
|
||||
<span key={i} className={`chip ${c.kind}`}>
|
||||
{c.kind === "slash" ? (
|
||||
<I.slash size={11} />
|
||||
) : (
|
||||
<I.at size={11} />
|
||||
)}
|
||||
<span>{chipLabel(c.label)}</span>
|
||||
<span>{c.label}</span>
|
||||
<span
|
||||
className="x"
|
||||
onClick={() =>
|
||||
setChips((cs) => cs.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
<I.x size={10} />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
placeholder={t("composer.placeholder")}
|
||||
onChange={handleChange}
|
||||
onPaste={(e) => void handlePaste(e)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true; }}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
compositionEndedAtRef.current = Date.now();
|
||||
}}
|
||||
rows={DEFAULT_COMPOSER_ROWS}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="composer-foot">
|
||||
<button
|
||||
@@ -814,8 +680,9 @@ export function Composer({
|
||||
disabled={disabled || !draft.trim()}
|
||||
onClick={() => {
|
||||
if (!disabled && draft.trim()) {
|
||||
recordSendAndReset();
|
||||
onSend();
|
||||
setPickedChips(new Map());
|
||||
setChips([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -834,8 +701,7 @@ export function Composer({
|
||||
onHover={(i, item) => {
|
||||
setActiveIdx(i);
|
||||
if (popup.kind === "at" && onMentionPreview) {
|
||||
const mention = item as MentionItem;
|
||||
const path = mention.path ?? mention.name;
|
||||
const path = (item as MentionItem).name;
|
||||
onMentionPreview(path, popup.nonce);
|
||||
}
|
||||
}}
|
||||
@@ -872,10 +738,7 @@ function Popup({
|
||||
}, [activeIdx]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={kind === "at" ? "popup at-popup" : "popup"}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="popup" onMouseDown={(e) => e.preventDefault()}>
|
||||
<div className="ph">
|
||||
<span className="tok">{kind === "slash" ? "/" : "@"}</span>
|
||||
<span>
|
||||
@@ -922,7 +785,7 @@ function Popup({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="mention-name">{(it as MentionItem).name}</span>
|
||||
<span>{(it as MentionItem).name}</span>
|
||||
{(it as MentionItem).desc ? (
|
||||
<div className="desc">{(it as MentionItem).desc}</div>
|
||||
) : null}
|
||||
|
||||
@@ -90,43 +90,4 @@ describe("ContextPanel files", () => {
|
||||
expect(screen.getByText("150 / 1,000,000")).toBeTruthy();
|
||||
expect(screen.getByText("100")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-applies the requested tab when activeTabNonce changes", () => {
|
||||
const { rerender } = render(
|
||||
<ContextPanel
|
||||
settings={settings}
|
||||
usage={usage}
|
||||
mcpSpecs={[]}
|
||||
mcpBridged={false}
|
||||
sessionFiles={[]}
|
||||
memory={[]}
|
||||
memoryDetail={null}
|
||||
activeTab="tools"
|
||||
activeTabNonce={1}
|
||||
onReadMemory={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Tools").getAttribute("data-active")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByText("Memory"));
|
||||
expect(screen.getByText("Memory").getAttribute("data-active")).toBe("true");
|
||||
|
||||
rerender(
|
||||
<ContextPanel
|
||||
settings={settings}
|
||||
usage={usage}
|
||||
mcpSpecs={[]}
|
||||
mcpBridged={false}
|
||||
sessionFiles={[]}
|
||||
memory={[]}
|
||||
memoryDetail={null}
|
||||
activeTab="tools"
|
||||
activeTabNonce={2}
|
||||
onReadMemory={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Tools").getAttribute("data-active")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SessionFile, Settings, UsageStats } from "../App";
|
||||
import { Markdown } from "../Markdown";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type { McpSpecInfo, MemoryDetail, MemoryEntryInfo } from "../protocol";
|
||||
import { PanelErrorBoundary } from "./error-boundary";
|
||||
import { McpServerCard } from "./mcp-server-card";
|
||||
|
||||
export type ContextPanelTab = "files" | "tools" | "memory" | "rules";
|
||||
type Tab = "files" | "tools" | "memory" | "rules";
|
||||
|
||||
const CONTEXT_MAX_TOKENS = 1_000_000;
|
||||
|
||||
@@ -21,12 +19,7 @@ export function ContextPanel({
|
||||
sessionFiles,
|
||||
memory,
|
||||
memoryDetail,
|
||||
activeTab,
|
||||
activeTabNonce,
|
||||
onReadMemory,
|
||||
onOpenMcpSettings,
|
||||
onEditMcpSpec,
|
||||
onRetryMcpSpec,
|
||||
}: {
|
||||
settings: Settings | null;
|
||||
usage: UsageStats;
|
||||
@@ -35,18 +28,10 @@ export function ContextPanel({
|
||||
sessionFiles: SessionFile[];
|
||||
memory: MemoryEntryInfo[];
|
||||
memoryDetail: MemoryDetail | null;
|
||||
activeTab?: ContextPanelTab;
|
||||
activeTabNonce?: number;
|
||||
onReadMemory: (path: string) => void;
|
||||
onOpenMcpSettings?: () => void;
|
||||
onEditMcpSpec?: (spec: McpSpecInfo) => void;
|
||||
onRetryMcpSpec?: (raw: string) => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [tab, setTab] = useState<ContextPanelTab>("files");
|
||||
useEffect(() => {
|
||||
if (activeTab) setTab(activeTab);
|
||||
}, [activeTab, activeTabNonce]);
|
||||
const [tab, setTab] = useState<Tab>("files");
|
||||
const reserved = usage.reservedTokens;
|
||||
const lastHit = usage.lastCallCacheHit ?? 0;
|
||||
const lastMiss = usage.lastCallCacheMiss ?? 0;
|
||||
@@ -76,7 +61,7 @@ export function ContextPanel({
|
||||
</div>
|
||||
|
||||
<div className="ctx-body">
|
||||
<div className="ctx-block ctx-body-tokens">
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.contextTokens")}</span>
|
||||
<span className="right">
|
||||
@@ -108,24 +93,14 @@ export function ContextPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<PanelErrorBoundary key={tab} label={tab}>
|
||||
{tab === "files" && <CtxFiles files={sessionFiles} settings={settings} />}
|
||||
{tab === "tools" && <CtxTools specs={mcpSpecs} bridged={mcpBridged} />}
|
||||
{tab === "memory" && (
|
||||
<CtxMemory entries={memory} detail={memoryDetail} onRead={onReadMemory} />
|
||||
)}
|
||||
{tab === "rules" && <CtxRules settings={settings} />}
|
||||
</PanelErrorBoundary>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -261,36 +236,8 @@ function CtxFiles({ files, settings }: { files: SessionFile[]; settings: Setting
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
function CtxTools({ specs, bridged }: { specs: McpSpecInfo[]; bridged: boolean }) {
|
||||
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">
|
||||
@@ -306,59 +253,42 @@ function CtxTools({
|
||||
{specs.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.mcpEmpty")}</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)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
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>
|
||||
);
|
||||
@@ -374,7 +304,7 @@ function CtxMemory({
|
||||
onRead: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctx-block" style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
|
||||
<div className="ctx-block">
|
||||
<div className="h">
|
||||
<span>{t("contextPanel.memoryTitle")}</span>
|
||||
<span className="right">
|
||||
@@ -384,28 +314,22 @@ function CtxMemory({
|
||||
{entries.length === 0 ? (
|
||||
<div className="ctx-empty">{t("contextPanel.noMemoriesMsg")}</div>
|
||||
) : (
|
||||
<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 className="mem">
|
||||
{entries.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
className="mem-row"
|
||||
data-active={detail?.path === m.path}
|
||||
key={m.path}
|
||||
onClick={() => onRead(m.path)}
|
||||
>
|
||||
<span className="scope" data-s={m.scope}>
|
||||
{m.scope === "project" ? t("contextPanel.scopeProject") : t("contextPanel.scopeGlobal")}
|
||||
</span>
|
||||
<span className="txt">{m.description || m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{detail ? <pre className="mem-detail">{detail.body}</pre> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -258,13 +258,7 @@ 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): 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;
|
||||
};
|
||||
const pct = (n: number) => Math.round((n / total) * 100);
|
||||
return (
|
||||
<div className="usage-full">
|
||||
<div className="uh">
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface JumpBarProps {
|
||||
messages: { kind: string; text?: string; turn?: number }[];
|
||||
threadEl?: HTMLElement | null;
|
||||
onScrollToTurn?: (turn: number) => void;
|
||||
threadEl: HTMLElement | null;
|
||||
}
|
||||
|
||||
export function JumpBar({ messages, threadEl, onScrollToTurn }: JumpBarProps) {
|
||||
export function JumpBar({ messages, threadEl }: JumpBarProps) {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
@@ -66,13 +65,9 @@ export function JumpBar({ messages, threadEl, onScrollToTurn }: JumpBarProps) {
|
||||
|
||||
const scrollTo = (turn: number) => {
|
||||
setActive(turn);
|
||||
if (onScrollToTurn) {
|
||||
onScrollToTurn(turn);
|
||||
} else {
|
||||
threadEl
|
||||
?.querySelector(`[data-turn="${turn}"]`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
threadEl
|
||||
?.querySelector(`[data-turn="${turn}"]`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const dotProps = (
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+43
-421
@@ -1,10 +1,9 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import type { Balance, Settings as SettingsType, UsageStats } from "../App";
|
||||
import { getLangLabel, getSupportedLangs, setLang, t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import type {
|
||||
ImportedMcpServer,
|
||||
McpSpecInfo,
|
||||
MemoryDetail,
|
||||
MemoryEntryInfo,
|
||||
@@ -28,7 +27,6 @@ import {
|
||||
type ThemeStyle,
|
||||
themeForStyle,
|
||||
} from "../theme";
|
||||
import { McpServerCard } from "./mcp-server-card";
|
||||
import { Shortcut, type ShortcutKey } from "./shortcut";
|
||||
|
||||
export type PageId =
|
||||
@@ -68,8 +66,6 @@ export function SettingsModal({
|
||||
customFontFamily,
|
||||
onSetCustomFontFamily,
|
||||
initialPage,
|
||||
initialMcpEditRaw,
|
||||
initialMcpEditNonce,
|
||||
mcpSpecs,
|
||||
mcpBridged,
|
||||
skills,
|
||||
@@ -85,11 +81,8 @@ export function SettingsModal({
|
||||
onSaveQQConfig,
|
||||
onOpenQQApplyLink,
|
||||
onPickWorkspace,
|
||||
onImportCcSwitchMcp,
|
||||
onAddMcpSpec,
|
||||
onRemoveMcpSpec,
|
||||
onUpdateMcpSpec,
|
||||
onRetryMcpSpec,
|
||||
onReadMemory,
|
||||
}: {
|
||||
settings: SettingsType;
|
||||
@@ -107,8 +100,6 @@ export function SettingsModal({
|
||||
customFontFamily: string;
|
||||
onSetCustomFontFamily: (family: string) => void;
|
||||
initialPage?: PageId;
|
||||
initialMcpEditRaw?: string | null;
|
||||
initialMcpEditNonce?: number;
|
||||
mcpSpecs: McpSpecInfo[];
|
||||
mcpBridged: boolean;
|
||||
skills: SkillInfo[];
|
||||
@@ -124,18 +115,12 @@ 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") {
|
||||
@@ -206,13 +191,8 @@ export function SettingsModal({
|
||||
<PageMCP
|
||||
specs={mcpSpecs}
|
||||
bridged={mcpBridged}
|
||||
initialEditRaw={initialMcpEditRaw}
|
||||
initialEditNonce={initialMcpEditNonce}
|
||||
onImportCcSwitch={onImportCcSwitchMcp}
|
||||
onAdd={onAddMcpSpec}
|
||||
onRemove={onRemoveMcpSpec}
|
||||
onUpdate={onUpdateMcpSpec}
|
||||
onRetry={onRetryMcpSpec}
|
||||
/>
|
||||
)}
|
||||
{page === "skills" && (
|
||||
@@ -721,28 +701,6 @@ 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>
|
||||
@@ -771,10 +729,8 @@ function PageGeneral({
|
||||
onSave({
|
||||
webSearchEngine: e.target.value as
|
||||
| "bing"
|
||||
| "bing-intl"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "baidu"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa"
|
||||
@@ -784,10 +740,8 @@ function PageGeneral({
|
||||
}
|
||||
>
|
||||
<option value="bing">{t("settings.webSearchEngineBing")}</option>
|
||||
<option value="bing-intl">{t("settings.webSearchEngineBingIntl")}</option>
|
||||
<option value="searxng">{t("settings.webSearchEngineSearxng")}</option>
|
||||
<option value="metaso">{t("settings.webSearchEngineMetaso")}</option>
|
||||
<option value="baidu">{t("settings.webSearchEngineBaidu")}</option>
|
||||
<option value="tavily">{t("settings.webSearchEngineTavily")}</option>
|
||||
<option value="perplexity">{t("settings.webSearchEnginePerplexity")}</option>
|
||||
<option value="exa">{t("settings.webSearchEngineExa")}</option>
|
||||
@@ -802,23 +756,11 @@ function PageGeneral({
|
||||
}
|
||||
|
||||
const SEARCH_ENGINE_API_KEY_FIELDS: ReadonlyArray<{
|
||||
engine: "metaso" | "baidu" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
|
||||
patchKey:
|
||||
| "metasoApiKey"
|
||||
| "baiduApiKey"
|
||||
| "tavilyApiKey"
|
||||
| "perplexityApiKey"
|
||||
| "exaApiKey"
|
||||
| "braveApiKey"
|
||||
| "ollamaApiKey";
|
||||
engine: "metaso" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
|
||||
patchKey: "metasoApiKey" | "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",
|
||||
@@ -838,7 +780,7 @@ function WebSearchEngineCredentials({
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
}) {
|
||||
const engine = settings.webSearchEngine ?? "bing";
|
||||
if (engine === "bing" || engine === "bing-intl") return null;
|
||||
if (engine === "bing") return null;
|
||||
if (engine === "searxng") {
|
||||
return <SearxngEndpointRow settings={settings} onSave={onSave} />;
|
||||
}
|
||||
@@ -895,15 +837,8 @@ function WebSearchApiKeyRow({
|
||||
prefix,
|
||||
onSave,
|
||||
}: {
|
||||
engine: "metaso" | "baidu" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
|
||||
patchKey:
|
||||
| "metasoApiKey"
|
||||
| "baiduApiKey"
|
||||
| "tavilyApiKey"
|
||||
| "perplexityApiKey"
|
||||
| "exaApiKey"
|
||||
| "braveApiKey"
|
||||
| "ollamaApiKey";
|
||||
engine: "metaso" | "tavily" | "perplexity" | "exa" | "brave" | "ollama";
|
||||
patchKey: "metasoApiKey" | "tavilyApiKey" | "perplexityApiKey" | "exaApiKey" | "braveApiKey" | "ollamaApiKey";
|
||||
signupUrl: string;
|
||||
prefix?: string;
|
||||
onSave: (patch: SettingsPatch) => void;
|
||||
@@ -1085,32 +1020,6 @@ 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>
|
||||
@@ -1139,112 +1048,36 @@ function PageModels({
|
||||
|
||||
function PageMCP({
|
||||
specs,
|
||||
initialEditRaw,
|
||||
initialEditNonce,
|
||||
onImportCcSwitch,
|
||||
bridged,
|
||||
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="mcp-section-head">
|
||||
<div className="stitle">
|
||||
{t("settings.mcpConfigured", { count: specs.length })}
|
||||
<span className="mcp-status-summary" data-status={statusKind}>
|
||||
{statusText}
|
||||
<div className="stitle">
|
||||
{t("settings.mcpConfigured", { count: specs.length })}
|
||||
{bridged ? (
|
||||
<span style={{ color: "var(--accent)", marginLeft: 8, fontSize: 11 }}>
|
||||
{t("settings.mcpBridged")}
|
||||
</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>
|
||||
) : (
|
||||
<span style={{ color: "var(--muted)", marginLeft: 8, fontSize: 11 }}>
|
||||
{t("settings.mcpNotBridged")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{specs.length === 0 ? (
|
||||
<div
|
||||
@@ -1261,14 +1094,32 @@ function PageMCP({
|
||||
</div>
|
||||
) : (
|
||||
specs.map((s) => (
|
||||
<McpServerCard
|
||||
key={s.raw}
|
||||
spec={s}
|
||||
mode="settings"
|
||||
onEdit={setEditing}
|
||||
onRetry={onRetry}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
<div className="scard" key={s.raw}>
|
||||
<div className="top">
|
||||
<span className="ico">
|
||||
<I.wrench size={14} />
|
||||
</span>
|
||||
<div className="mcp-spec-body">
|
||||
<div className="nm">{s.name ?? "(anonymous)"}</div>
|
||||
<div className="sub mcp-spec-summary" title={s.summary}>
|
||||
{s.summary}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost mcp-remove"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => onRemove(s.raw)}
|
||||
>
|
||||
{t("settings.mcpRemove")}
|
||||
</button>
|
||||
</div>
|
||||
{s.parseError ? (
|
||||
<div className="desc" style={{ color: "var(--danger)" }}>
|
||||
{t("settings.parseError", { error: s.parseError })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
@@ -1299,235 +1150,6 @@ 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,
|
||||
|
||||
@@ -284,11 +284,9 @@ export function Sidebar({
|
||||
<span className="title">{prettyName(s)}</span>
|
||||
)}
|
||||
<span className="meta">
|
||||
<span className="count">
|
||||
{t("sidebarPanel.messageCount", { count: s.messageCount })}
|
||||
</span>
|
||||
<span>{t("sidebarPanel.messageCount", { count: s.messageCount })}</span>
|
||||
<span className="sep">·</span>
|
||||
<span className="time">{updated}</span>
|
||||
<span>{updated}</span>
|
||||
</span>
|
||||
</div>
|
||||
{editing ? null : (
|
||||
|
||||
@@ -19,16 +19,6 @@ 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,
|
||||
@@ -67,12 +57,7 @@ export function StatusBar({
|
||||
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 cacheHitPct = cacheDenom > 0 ? Math.round((usage.cacheHitTokens / cacheDenom) * 100) : 0;
|
||||
const runningJobs = jobs.filter((j) => j.running).length;
|
||||
const spent = formatMoney(usage.totalCostUsd, currency);
|
||||
const balanceLabel = balance
|
||||
@@ -104,10 +89,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={cacheHitDetail || t("statusbar.cacheHit")}>
|
||||
<span className="seg" title={t("statusbar.cacheHit")}>
|
||||
<I.zap size={11} style={{ color: "var(--accent)" }} />
|
||||
<span>{t("statusbar.cache")}</span>
|
||||
<span className="v acc">{cacheHitPctDisplay}%</span>
|
||||
<span className="v acc">{cacheHitPct}%</span>
|
||||
</span>
|
||||
<span className="seg">
|
||||
<I.cpu size={11} />
|
||||
|
||||
+94
-231
@@ -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, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, memo, useState } from "react";
|
||||
import type {
|
||||
ActivePlan,
|
||||
AssistantSegment,
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
PendingRevision,
|
||||
SkillOrigin,
|
||||
} from "../App";
|
||||
import { Markdown, WithToolPaths } from "../Markdown";
|
||||
import { t, useLang } from "../i18n";
|
||||
import { I } from "../icons";
|
||||
import {
|
||||
@@ -103,26 +102,6 @@ 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,
|
||||
@@ -156,134 +135,6 @@ 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>
|
||||
@@ -293,7 +144,92 @@ export const AssistantMsg = memo(function AssistantMsg({
|
||||
{model ? <span className="model">{model}</span> : null}
|
||||
{time ? <span className="time">{time}</span> : null}
|
||||
</div>
|
||||
{rendered}
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{content ? (
|
||||
<div className="msg-actions">
|
||||
<button
|
||||
@@ -345,7 +281,7 @@ export function PlanBanner({
|
||||
{t("thread.planRunning", { step: Math.min(done + 1, total), total })}
|
||||
{current ? ` — ${current.title}` : ""}
|
||||
</div>
|
||||
<Markdown source={plan.plan} />
|
||||
<div className="s">{plan.plan}</div>
|
||||
</div>
|
||||
<div className="prog">
|
||||
<div className="meter-mini">
|
||||
@@ -390,81 +326,12 @@ export function PlanApprovalCard({
|
||||
}: {
|
||||
p: PendingPlan;
|
||||
onApprove: () => void;
|
||||
onRefine: (feedback?: string) => void;
|
||||
onCancel: (feedback?: string) => void;
|
||||
onRefine: () => void;
|
||||
onCancel: () => 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")}
|
||||
@@ -473,12 +340,8 @@ export function PlanApprovalCard({
|
||||
sub={sub}
|
||||
body={
|
||||
<>
|
||||
{p.summary ? (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<Markdown source={p.summary} />
|
||||
</div>
|
||||
) : null}
|
||||
<Markdown source={p.plan} />
|
||||
{p.summary ? <div style={{ marginBottom: 6 }}>{p.summary}</div> : null}
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{p.plan}</div>
|
||||
</>
|
||||
}
|
||||
meta={`plan/#${p.id}`}
|
||||
@@ -486,8 +349,8 @@ export function PlanApprovalCard({
|
||||
secondaryLabel={t("thread.cancel")}
|
||||
tertiaryLabel={t("thread.refine")}
|
||||
onPrimary={onApprove}
|
||||
onSecondary={() => setFeedbackMode("cancel")}
|
||||
onTertiary={() => setFeedbackMode("refine")}
|
||||
onSecondary={onCancel}
|
||||
onTertiary={onRefine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
type TextSegmentLike = {
|
||||
kind: "text" | "reasoning";
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ToolSegmentLike = {
|
||||
kind: "tool";
|
||||
args: string;
|
||||
result?: string;
|
||||
};
|
||||
|
||||
type SegmentLike = TextSegmentLike | ToolSegmentLike | { kind: string };
|
||||
|
||||
type AssistantMessageLike = {
|
||||
kind: "assistant";
|
||||
pending?: boolean;
|
||||
segments: SegmentLike[];
|
||||
};
|
||||
|
||||
export const TRANSCRIPT_RECENT_MESSAGE_WINDOW = 120;
|
||||
const MIN_ELIDE_CHARS = 4096;
|
||||
const ELIDED_PREFIX = "[elided -- older than the last ";
|
||||
|
||||
function isAssistantMessage(value: unknown): value is AssistantMessageLike {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as { kind?: unknown }).kind === "assistant" &&
|
||||
Array.isArray((value as { segments?: unknown }).segments)
|
||||
);
|
||||
}
|
||||
|
||||
function isElided(value: unknown): boolean {
|
||||
return typeof value === "string" && value.startsWith(ELIDED_PREFIX);
|
||||
}
|
||||
|
||||
function elidedStub(chars: number): string {
|
||||
return `${ELIDED_PREFIX}${TRANSCRIPT_RECENT_MESSAGE_WINDOW} messages; ${chars.toLocaleString()} chars dropped to keep long sessions responsive. Full content remains in the session log.]`;
|
||||
}
|
||||
|
||||
function elideField(value: string): string {
|
||||
if (value.length <= MIN_ELIDE_CHARS || isElided(value)) return value;
|
||||
return elidedStub(value.length);
|
||||
}
|
||||
|
||||
function elideSegment<S extends SegmentLike>(segment: S): S {
|
||||
if (
|
||||
(segment.kind === "text" || segment.kind === "reasoning") &&
|
||||
typeof (segment as TextSegmentLike).text === "string"
|
||||
) {
|
||||
const textSegment = segment as TextSegmentLike;
|
||||
const text = elideField(textSegment.text);
|
||||
return text === textSegment.text ? segment : ({ ...segment, text } as S);
|
||||
}
|
||||
if (segment.kind !== "tool") return segment;
|
||||
|
||||
const toolSegment = segment as ToolSegmentLike;
|
||||
let next: S | null = null;
|
||||
const args = elideField(toolSegment.args);
|
||||
if (args !== toolSegment.args) next = { ...segment, args } as S;
|
||||
if (typeof toolSegment.result === "string") {
|
||||
const result = elideField(toolSegment.result);
|
||||
if (result !== toolSegment.result) next = { ...(next ?? segment), result } as S;
|
||||
}
|
||||
return next ?? segment;
|
||||
}
|
||||
|
||||
function elideSegments<S extends SegmentLike>(segments: S[]): S[] {
|
||||
let next: S[] | null = null;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
if (!segment) continue;
|
||||
const elided = elideSegment(segment);
|
||||
if (elided !== segment) {
|
||||
if (next === null) next = segments.slice();
|
||||
next[i] = elided;
|
||||
}
|
||||
}
|
||||
return next ?? segments;
|
||||
}
|
||||
|
||||
export function elideTranscriptMessages<T>(messages: T[]): T[] {
|
||||
if (messages.length <= TRANSCRIPT_RECENT_MESSAGE_WINDOW) return messages;
|
||||
const cutoff = messages.length - TRANSCRIPT_RECENT_MESSAGE_WINDOW;
|
||||
let next: T[] | null = null;
|
||||
for (let i = 0; i < cutoff; i++) {
|
||||
const message = messages[i];
|
||||
if (!isAssistantMessage(message) || message.pending) continue;
|
||||
const segments = elideSegments(message.segments);
|
||||
if (segments !== message.segments) {
|
||||
if (next === null) next = messages.slice();
|
||||
next[i] = { ...message, segments } as T;
|
||||
}
|
||||
}
|
||||
return next ?? messages;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getThreadMaxWidth } from "./thread-layout";
|
||||
|
||||
const MIN_WIDTH = 200;
|
||||
const MIN_WIDTH = 160;
|
||||
const MAX_WIDTH_PCT = 0.4;
|
||||
const CSS_VAR = { side: "--side-width", ctx: "--ctx-width" } as const;
|
||||
const PERSIST_KEY_SIDE = "reasonix.sideWidth";
|
||||
|
||||
@@ -13,7 +13,6 @@ export function WorkdirPop({
|
||||
anchor,
|
||||
onPick,
|
||||
onBrowse,
|
||||
onRemoveRecent,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -22,7 +21,6 @@ export function WorkdirPop({
|
||||
anchor?: Anchor;
|
||||
onPick: (path: string) => void;
|
||||
onBrowse: () => void;
|
||||
onRemoveRecent?: (path: string) => void;
|
||||
}) {
|
||||
useLang();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -121,19 +119,6 @@ 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,9 +38,6 @@ export default defineConfig({
|
||||
host: "127.0.0.1",
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/target/**"],
|
||||
},
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
define: {
|
||||
|
||||
@@ -178,7 +178,7 @@ src/
|
||||
│ ├── skills.ts # list + invoke SKILL.md playbooks
|
||||
│ ├── subagent.ts # spawn_subagent — flash+high by default
|
||||
│ ├── plan.ts # submit_plan (review gate)
|
||||
│ └── web.ts # web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)
|
||||
│ └── web.ts # web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)
|
||||
├── 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 <bing\|bing-intl\|searxng\|metaso\|baidu\|tavily\|perplexity\|exa\|brave\|ollama>` (`/se`) | Switch web search backend |
|
||||
| `/search-engine <mojeek\|searxng\|metaso>` (`/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 |
|
||||
|
||||
@@ -433,7 +433,7 @@
|
||||
│ ├── skills.ts <span class="hash"># list + invoke SKILL.md playbooks</span>
|
||||
│ ├── subagent.ts <span class="hash"># spawn_subagent — flash+high by default</span>
|
||||
│ ├── plan.ts <span class="hash"># submit_plan (review gate)</span>
|
||||
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Bing, Baidu, SearXNG, Metaso and API engines)</span>
|
||||
│ └── web.ts <span class="hash"># web_search, web_fetch (multi-engine: Mojeek, SearXNG or Metaso)</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>
|
||||
|
||||
@@ -531,7 +531,7 @@
|
||||
<td>Session USD cap</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/search-engine <bing|bing-intl|searxng|metaso|baidu|tavily|perplexity|exa|brave|ollama></code> (<code>/se</code>)</td>
|
||||
<td><code>/search-engine <mojeek|searxng|metaso></code> (<code>/se</code>)</td>
|
||||
<td>Switch web search backend</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
+6
-16
@@ -200,7 +200,7 @@
|
||||
"reasoningEffort": "high", <span class="hash"># <span data-i18n="cfg.k.effort">high | max</span></span>
|
||||
"theme": "auto", <span class="hash"># <span data-i18n="cfg.k.theme">light | dark | auto</span></span>
|
||||
"search": false, <span class="hash"># <span data-i18n="cfg.k.search">enable web_search/web_fetch tools</span></span>
|
||||
"webSearchEngine": "bing", <span class="hash"># <span data-i18n="cfg.k.engine">bing | baidu | searxng | metaso | ...</span></span>
|
||||
"webSearchEngine": "mojeek", <span class="hash"># <span data-i18n="cfg.k.engine">mojeek | searxng | metaso</span></span>
|
||||
"webSearchEndpoint": "http://localhost:8080",
|
||||
"mcp": [], <span class="hash"># <span data-i18n="cfg.k.mcp">MCP server specs (legacy string format)</span></span>
|
||||
"mcpServers": {}, <span class="hash"># <span data-i18n="cfg.k.mcpsrv">MCP server objects (canonical format)</span></span>
|
||||
@@ -621,16 +621,14 @@ 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>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.
|
||||
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).
|
||||
</p>
|
||||
<pre class="code"><code>/search-engine bing
|
||||
<pre class="code"><code>/search-engine mojeek
|
||||
/search-engine searxng <span class="hash"># http://localhost:8080</span>
|
||||
/search-engine searxng http://192.168.1.5:8888
|
||||
/search-engine metaso
|
||||
/search-engine baidu</code></pre>
|
||||
/search-engine metaso</code></pre>
|
||||
<p data-i18n="ws.body.json">Equivalent <code>config.json</code>:</p>
|
||||
<pre class="code"><code>{
|
||||
"webSearchEngine": "searxng",
|
||||
@@ -640,14 +638,6 @@ 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>
|
||||
|
||||
+4
-8
@@ -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": "bing | baidu | searxng | metaso | ...",
|
||||
"cfg.k.engine": "mojeek | searxng | metaso",
|
||||
"cfg.k.mcp": "MCP server specs",
|
||||
"cfg.k.mcpoff": "names skipped at startup",
|
||||
"cfg.k.projects": "per-workspace overrides",
|
||||
@@ -154,12 +154,10 @@
|
||||
|
||||
"ws.title": "Web search",
|
||||
"ws.body1":
|
||||
"<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.",
|
||||
"<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).",
|
||||
"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":
|
||||
@@ -209,7 +207,7 @@
|
||||
"cfg.k.effort": "high | max",
|
||||
"cfg.k.theme": "light | dark | auto",
|
||||
"cfg.k.search": "启用 web_search / web_fetch 工具",
|
||||
"cfg.k.engine": "bing | baidu | searxng | metaso | ...",
|
||||
"cfg.k.engine": "mojeek | searxng | metaso",
|
||||
"cfg.k.mcp": "MCP 服务器列表",
|
||||
"cfg.k.mcpoff": "启动时跳过的服务器名",
|
||||
"cfg.k.projects": "按工作区的覆盖配置",
|
||||
@@ -329,12 +327,10 @@
|
||||
|
||||
"ws.title": "Web 搜索",
|
||||
"ws.body1":
|
||||
"<code>web_search</code> + <code>web_fetch</code> 开箱即用。默认走 <strong>Bing</strong>(国内裸 IP 直连);需要百度智能云千帆官方搜索 API 时可切到 <strong>百度 AI Search</strong>。也可以使用自托管 <strong>SearXNG</strong>、<strong>Metaso</strong> 或其他 API 引擎。",
|
||||
"<code>web_search</code> + <code>web_fetch</code> 开箱即用。默认走 <strong>Mojeek</strong>(无需配置);想完全控制上游引擎就切到自托管 <strong>SearXNG</strong>,或使用 <strong>Metaso</strong>(每天 100 次免费搜索,配置你自己的 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":
|
||||
"Bing by default. Switch to Baidu AI Search, self-hosted SearXNG, Metaso, or other API engines with <code>/search-engine</code>.",
|
||||
"Mojeek by default, no setup. Switch to self-hosted SearXNG or Metaso 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":
|
||||
"默认 Bing。用 <code>/search-engine</code> 切百度 AI Search、自托管 SearXNG、Metaso 或其他 API 引擎。",
|
||||
"默认 Mojeek,零配置。用 <code>/search-engine</code> 切自托管 SearXNG 或 Metaso。",
|
||||
"conf.cta": "打开配置指南 →",
|
||||
|
||||
"cli.title": "CLI 速览",
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,183 +0,0 @@
|
||||
# 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
+4
-353
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.52.0",
|
||||
"version": "0.53.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix",
|
||||
"version": "0.52.0",
|
||||
"version": "0.53.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -24,15 +24,12 @@
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"grammy": "^1.43.0",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"ignore": "^7.0.5",
|
||||
"indent-string": "^5.0.0",
|
||||
"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",
|
||||
@@ -62,7 +59,6 @@
|
||||
"@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",
|
||||
@@ -1387,10 +1383,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@grammyjs/types": {
|
||||
"version": "3.27.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@inquirer/ansi": {
|
||||
"version": "2.0.5",
|
||||
"dev": true,
|
||||
@@ -2371,16 +2363,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"dev": true,
|
||||
@@ -2561,16 +2543,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"dev": true,
|
||||
@@ -2778,15 +2750,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001792",
|
||||
"dev": true,
|
||||
@@ -3130,6 +3093,7 @@
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3143,15 +3107,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"dev": true,
|
||||
@@ -3188,12 +3143,6 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"dev": true,
|
||||
@@ -3379,13 +3328,6 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.8",
|
||||
"license": "MIT",
|
||||
@@ -3497,19 +3439,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fix-dts-default-cjs-exports": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
@@ -3701,19 +3630,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/grammy": {
|
||||
"version": "1.43.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@grammyjs/types": "3.27.3",
|
||||
"abort-controller": "^3.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"node-fetch": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
@@ -4110,18 +4026,6 @@
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
@@ -4257,6 +4161,7 @@
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mutation-server-protocol": {
|
||||
@@ -4330,40 +4235,6 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"dev": true,
|
||||
@@ -4441,42 +4312,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
@@ -4508,15 +4343,6 @@
|
||||
"version": "6.0.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"dev": true,
|
||||
@@ -4591,15 +4417,6 @@
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"dev": true,
|
||||
@@ -4742,154 +4559,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"dev": true,
|
||||
@@ -5041,12 +4710,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "5.0.0",
|
||||
"dev": true,
|
||||
@@ -5144,12 +4807,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
@@ -7059,12 +6716,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"dev": true,
|
||||
|
||||
+1
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.52.0",
|
||||
"version": "0.53.1",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -91,15 +91,12 @@
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"grammy": "^1.43.0",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"ignore": "^7.0.5",
|
||||
"indent-string": "^5.0.0",
|
||||
"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",
|
||||
@@ -125,7 +122,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -152,24 +152,11 @@ export default class App extends PureComponent<Props, State> {
|
||||
isRawModeSupported(): boolean {
|
||||
return this.props.stdin.isTTY;
|
||||
}
|
||||
// Memoize terminal size context value to prevent unnecessary re-renders
|
||||
// in consumers (useTerminalViewport). Without this, a new object is
|
||||
// created on every render, defeating dependency-array checks.
|
||||
private _lastTerminalColumns = 0;
|
||||
private _lastTerminalRows = 0;
|
||||
private _terminalSizeValue: { columns: number; rows: number } = { columns: 0, rows: 0 };
|
||||
|
||||
override render() {
|
||||
if (this.props.terminalColumns !== this._lastTerminalColumns ||
|
||||
this.props.terminalRows !== this._lastTerminalRows) {
|
||||
this._lastTerminalColumns = this.props.terminalColumns;
|
||||
this._lastTerminalRows = this.props.terminalRows;
|
||||
this._terminalSizeValue = {
|
||||
columns: this.props.terminalColumns,
|
||||
rows: this.props.terminalRows,
|
||||
};
|
||||
}
|
||||
return <TerminalSizeContext.Provider value={this._terminalSizeValue}>
|
||||
return <TerminalSizeContext.Provider value={{
|
||||
columns: this.props.terminalColumns,
|
||||
rows: this.props.terminalRows
|
||||
}}>
|
||||
<AppContext.Provider value={{
|
||||
exit: this.handleExit
|
||||
}}>
|
||||
|
||||
@@ -20,12 +20,6 @@ export function useTerminalViewport(): [
|
||||
elementRef.current = el
|
||||
}, [])
|
||||
|
||||
// `terminalSize` is the only reactive dependency — it changes when the
|
||||
// terminal resizes. `elementRef.current` is NOT a valid dependency:
|
||||
// React doesn't track ref mutations, so `Object.is` comparison against
|
||||
// the previous render's captured value means the effect runs once on
|
||||
// mount and never again unless conditional rendering swaps the DOM node.
|
||||
// The element reference is read imperatively inside the effect.
|
||||
useLayoutEffect(() => {
|
||||
const element = elementRef.current
|
||||
if (!element?.yogaNode || !terminalSize) {
|
||||
@@ -77,7 +71,7 @@ export function useTerminalViewport(): [
|
||||
if (visible !== entryRef.current.isVisible) {
|
||||
entryRef.current = { isVisible: visible }
|
||||
}
|
||||
}, [terminalSize])
|
||||
})
|
||||
|
||||
return [setElement, entryRef.current]
|
||||
}
|
||||
@@ -127,41 +127,14 @@ export function useBoxMetrics(ref: { current: DOMElement | null }): {
|
||||
height: number;
|
||||
} {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
const scheduledRef = useRef(false);
|
||||
const lastMeasureTimeRef = useRef(0);
|
||||
|
||||
// No dep array — this hook must re-measure when the parent layout
|
||||
// changes, which can only be detected by re-running on every render.
|
||||
// Using `ref.current` as a dependency is a React anti-pattern (refs
|
||||
// are not reactive) and would cause stale dimensions after parent
|
||||
// resizes.
|
||||
//
|
||||
// To prevent excessive measurements during rapid scrolling (10-20
|
||||
// wheel events/100ms on Windows), we throttle to at most one measure
|
||||
// per frame via the deferred setTimeout + scheduledRef guard, and
|
||||
// skip re-measurement if less than 16ms have elapsed since the last
|
||||
// successful measurement (one frame at 60fps).
|
||||
const lastRef = useRef<DOMElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
if (scheduledRef.current) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastMeasureTimeRef.current < 16) return;
|
||||
|
||||
scheduledRef.current = true;
|
||||
// Defer measure off the React commit batch — re-measuring
|
||||
// synchronously in the same depth-counted chain crashes with
|
||||
// "Maximum update depth exceeded" when Box content doesn't
|
||||
// converge in one layout pass.
|
||||
setTimeout(() => {
|
||||
scheduledRef.current = false;
|
||||
if (!ref.current) return;
|
||||
lastMeasureTimeRef.current = Date.now();
|
||||
const { width, height } = measureElement(ref.current);
|
||||
setSize((prev) =>
|
||||
prev.width === width && prev.height === height ? prev : { width, height },
|
||||
);
|
||||
}, 0);
|
||||
lastRef.current = ref.current;
|
||||
const { width, height } = measureElement(ref.current);
|
||||
setSize((prev) =>
|
||||
prev.width === width && prev.height === height ? prev : { width, height },
|
||||
);
|
||||
});
|
||||
return size;
|
||||
}
|
||||
@@ -105,9 +105,6 @@ export default class Ink {
|
||||
private backFrame: Frame;
|
||||
private lastPoolResetTime = performance.now();
|
||||
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Microtask coalescing: prevents double-render when leading + trailing
|
||||
// throttle edges both queue a microtask in the same event-loop tick.
|
||||
private renderMicrotaskPending = false;
|
||||
private lastYogaCounters: {
|
||||
ms: number;
|
||||
visited: number;
|
||||
@@ -225,14 +222,7 @@ export default class Ink {
|
||||
// a one-keystroke lag. Same event-loop tick, so throughput is unchanged.
|
||||
// Test env uses onImmediateRender (direct onRender, no throttle) so
|
||||
// existing synchronous lastFrame() tests are unaffected.
|
||||
const deferredRender = (): void => {
|
||||
if (this.renderMicrotaskPending) return;
|
||||
this.renderMicrotaskPending = true;
|
||||
queueMicrotask(() => {
|
||||
this.renderMicrotaskPending = false;
|
||||
this.onRender();
|
||||
});
|
||||
};
|
||||
const deferredRender = (): void => queueMicrotask(this.onRender);
|
||||
this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, {
|
||||
leading: true,
|
||||
trailing: true
|
||||
@@ -764,14 +754,11 @@ export default class Ink {
|
||||
// its scheduleRender path fires a render which clears this timer at
|
||||
// the top of onRender — no double.
|
||||
//
|
||||
// Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) but still
|
||||
// run the full render pipeline (React reconcile + diff + paint).
|
||||
// xterm.js (VS Code) is single-threaded for ANSI parsing — running at
|
||||
// 4ms (250fps) overwhelmed it. Use FRAME_INTERVAL_MS (16ms, ~60fps)
|
||||
// which matches the regular render budget and is well within xterm.js's
|
||||
// comfortable throughput. Native terminals handle this fine too.
|
||||
// Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) so run at
|
||||
// quarter interval (~250fps, setTimeout practical floor) for max scroll
|
||||
// speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle.
|
||||
if (frame.scrollDrainPending) {
|
||||
this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS);
|
||||
this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2);
|
||||
}
|
||||
const yogaMs = getLastYogaMs();
|
||||
const commitMs = getLastCommitMs();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const targets = [
|
||||
@@ -15,12 +15,6 @@ for (const [src, dst] of targets) {
|
||||
// Marks dist/cli/ as ESM so Node skips the CJS-then-ESM reparse warning when
|
||||
// the bundle is loaded outside its own npm install (e.g. desktop sidecar).
|
||||
const cliMarker = resolve("dist/cli/package.json");
|
||||
const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8"));
|
||||
const cliPackage = {
|
||||
name: rootPackage.name ?? "reasonix",
|
||||
version: rootPackage.version ?? "0.0.0-dev",
|
||||
type: "module",
|
||||
};
|
||||
mkdirSync(dirname(cliMarker), { recursive: true });
|
||||
writeFileSync(cliMarker, `${JSON.stringify(cliPackage, null, 2)}\n`);
|
||||
writeFileSync(cliMarker, `${JSON.stringify({ type: "module" }, null, 2)}\n`);
|
||||
console.log(`wrote ${cliMarker}`);
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Deterministic cache-shape probe.
|
||||
*
|
||||
* This does not call DeepSeek. It validates local invariants that keep live
|
||||
* prefix-cache hit rates high: stable tool-spec ordering, component prefix
|
||||
* hashes, and explicit log rewrite tracking.
|
||||
*
|
||||
* Run: npx tsx scripts/probe-cache-shape.mts
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { AppendOnlyLog, ImmutablePrefix } from "../src/memory/runtime.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
import type { ToolSpec } from "../src/types.js";
|
||||
|
||||
function tool(name: string): ToolSpec {
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
description: `${name} tool`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const registry = new ToolRegistry();
|
||||
registry.register({ name: "zeta", fn: async () => "z" });
|
||||
registry.register({ name: "alpha", fn: async () => "a" });
|
||||
assert.deepEqual(
|
||||
registry.specs().map((s) => s.function.name),
|
||||
["alpha", "zeta"],
|
||||
"ToolRegistry.specs() must be sorted by tool name",
|
||||
);
|
||||
|
||||
const prefixA = new ImmutablePrefix({ system: "s", toolSpecs: [tool("read"), tool("write")] });
|
||||
const prefixB = new ImmutablePrefix({ system: "s", toolSpecs: [tool("write"), tool("read")] });
|
||||
assert.equal(prefixB.fingerprint, prefixA.fingerprint, "reordered tools should hash identically");
|
||||
assert.equal(
|
||||
prefixB.componentHashes.tools,
|
||||
prefixA.componentHashes.tools,
|
||||
"tool component hash should be stable under reordered input",
|
||||
);
|
||||
|
||||
const log = new AppendOnlyLog();
|
||||
log.append({ role: "user", content: "hello" });
|
||||
assert.equal(log.rewriteVersion, 0, "append must not count as a rewrite");
|
||||
log.compactInPlace([{ role: "assistant", content: "summary" }]);
|
||||
assert.equal(log.rewriteVersion, 1, "compactInPlace must count as a rewrite");
|
||||
|
||||
console.log("PASS: deterministic cache-shape invariants hold");
|
||||
@@ -7,21 +7,13 @@
|
||||
* Surfaces: cache trajectory, cost shape, anything degrading over time.
|
||||
*
|
||||
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-long-session.mts
|
||||
* Reads DEEPSEEK_API_KEY from the environment, .env.testbak, or ~/.reasonix/config.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory/runtime.js";
|
||||
import {
|
||||
DEEPSEEK_CONTEXT_TOKENS,
|
||||
pricingFor,
|
||||
type CacheChurnReason,
|
||||
type TurnStats,
|
||||
} from "../src/telemetry/stats.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS } from "../src/telemetry/stats.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
|
||||
// Force a small ctx window so the 50% fold threshold trips in a few
|
||||
@@ -29,42 +21,18 @@ import { ToolRegistry } from "../src/tools.js";
|
||||
// id, real API call, just the local gauge is shrunk.
|
||||
DEEPSEEK_CONTEXT_TOKENS["deepseek-chat"] = 50_000;
|
||||
|
||||
function loadDotenv(path: string): void {
|
||||
if (!existsSync(path)) return;
|
||||
function loadDotenv(path: string) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
|
||||
function loadReasonixApiKey(): void {
|
||||
if (process.env.DEEPSEEK_API_KEY) return;
|
||||
const path = join(homedir(), ".reasonix", "config.json");
|
||||
if (!existsSync(path)) return;
|
||||
const cfg = JSON.parse(readFileSync(path, "utf8")) as {
|
||||
apiKey?: string;
|
||||
deepseekApiKey?: string;
|
||||
endpoint?: { apiKey?: string };
|
||||
};
|
||||
const key = cfg.apiKey ?? cfg.deepseekApiKey ?? cfg.endpoint?.apiKey ?? "";
|
||||
if (key) process.env.DEEPSEEK_API_KEY = key;
|
||||
}
|
||||
|
||||
loadDotenv("./.env.testbak");
|
||||
loadReasonixApiKey();
|
||||
|
||||
function cacheRatio(usage: {
|
||||
promptCacheHitTokens: number;
|
||||
promptCacheMissTokens: number;
|
||||
}): number {
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
return total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
}
|
||||
|
||||
function formatReasons(reasons: readonly CacheChurnReason[]): string {
|
||||
return reasons.length > 0 ? reasons.join(",") : "-";
|
||||
}
|
||||
const PRICE_HIT_PER_M = 0.07;
|
||||
const PRICE_MISS_PER_M = 0.27;
|
||||
const PRICE_COMPLETION_PER_M = 1.1;
|
||||
|
||||
const docLine = (i: number, sec: string) =>
|
||||
`[${sec}#${i}] section ${sec} entry ${i}: requirement traces to constraint ${(i % 7) + 1}, status ${i % 3 === 0 ? "open" : "closed"}, owner team-${(i % 5) + 1}, last touched 2026-04-${(i % 28) + 1}.`;
|
||||
@@ -101,11 +69,11 @@ async function main() {
|
||||
maxToolIters: 4,
|
||||
});
|
||||
|
||||
let rawRewrites = 0;
|
||||
let mutations = 0;
|
||||
let folds = 0;
|
||||
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
|
||||
loop.log.compactInPlace = (...args) => {
|
||||
rawRewrites++;
|
||||
mutations++;
|
||||
return origCompactInPlace(...args);
|
||||
};
|
||||
|
||||
@@ -132,57 +100,48 @@ async function main() {
|
||||
"api-v2",
|
||||
];
|
||||
|
||||
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum | churn");
|
||||
console.log("-----+----------+---------+---------+------+----------+--------+----------");
|
||||
console.log("turn | prompt | hit | miss | hit% | $/turn | $cum");
|
||||
console.log("-----+----------+---------+---------+------+----------+--------");
|
||||
|
||||
let cumCost = 0;
|
||||
let forceSummaryHit = false;
|
||||
const measured: Array<{
|
||||
turn: number;
|
||||
ratio: number;
|
||||
forcedSummary: boolean;
|
||||
reasons: CacheChurnReason[];
|
||||
}> = [];
|
||||
const pricing = pricingFor(model);
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const t0 = Date.now();
|
||||
let stats: TurnStats | null = null;
|
||||
let usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
promptCacheHitTokens: number;
|
||||
promptCacheMissTokens: number;
|
||||
} | null = null;
|
||||
let warning = "";
|
||||
let turnForcedSummary = false;
|
||||
for await (const ev of loop.step(`Read the "${sections[i]}" section.`)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
stats = ev.stats;
|
||||
usage = ev.stats.usage as typeof usage;
|
||||
}
|
||||
if (ev.role === "warning" && ev.content) {
|
||||
warning = ev.content;
|
||||
if (/folded \d+ messages/.test(ev.content)) folds++;
|
||||
}
|
||||
if (ev.forcedSummary) {
|
||||
forceSummaryHit = true;
|
||||
turnForcedSummary = true;
|
||||
}
|
||||
if (ev.forcedSummary) forceSummaryHit = true;
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
const usage = stats?.usage;
|
||||
if (!usage) {
|
||||
console.log(
|
||||
`${String(i).padStart(3)} | (no usage) -- ${warning ? `warning: ${warning}` : ""}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ratio = cacheRatio(usage);
|
||||
const cost = pricing
|
||||
? (usage.promptCacheHitTokens * pricing.inputCacheHit +
|
||||
usage.promptCacheMissTokens * pricing.inputCacheMiss +
|
||||
usage.completionTokens * pricing.output) /
|
||||
1_000_000
|
||||
: 0;
|
||||
const reasons = stats.cacheDiagnostics?.prefixChangeReasons ?? [];
|
||||
measured.push({ turn: i, ratio, forcedSummary: turnForcedSummary, reasons: [...reasons] });
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
const cost =
|
||||
(usage.promptCacheHitTokens * PRICE_HIT_PER_M +
|
||||
usage.promptCacheMissTokens * PRICE_MISS_PER_M +
|
||||
usage.completionTokens * PRICE_COMPLETION_PER_M) /
|
||||
1_000_000;
|
||||
cumCost += cost;
|
||||
console.log(
|
||||
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} | ${formatReasons(reasons)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
|
||||
`${String(i).padStart(3)} | ${String(usage.promptTokens).padStart(7)} | ${String(usage.promptCacheHitTokens).padStart(7)} | ${String(usage.promptCacheMissTokens).padStart(7)} | ${ratio.toFixed(1).padStart(4)} | $${cost.toFixed(5)} | $${cumCost.toFixed(4)} ${ms}ms${warning ? ` (${warning.slice(0, 60)}…)` : ""}`,
|
||||
);
|
||||
if (forceSummaryHit) {
|
||||
console.log(
|
||||
@@ -192,33 +151,14 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\ntotal raw log.compactInPlace() calls: ${rawRewrites} (${folds} fold warning events)`);
|
||||
console.log(`\ntotal log.compactInPlace() calls: ${mutations} (expected: ${folds} folds)`);
|
||||
console.log(`total cost across session: $${cumCost.toFixed(4)}`);
|
||||
|
||||
const sustained = measured.filter((entry) => entry.turn >= 4 && !entry.forcedSummary);
|
||||
const sustainedAvg =
|
||||
sustained.length > 0
|
||||
? sustained.reduce((sum, entry) => sum + entry.ratio, 0) / sustained.length
|
||||
: 0;
|
||||
const unstablePrefixTurns = measured
|
||||
.slice(1)
|
||||
.filter((entry) => entry.reasons.some((reason) => reason !== "log_rewrite"));
|
||||
console.log(`sustained cache hit average (turn >=4, pre-summary): ${sustainedAvg.toFixed(1)}%`);
|
||||
|
||||
if (unstablePrefixTurns.length > 0) {
|
||||
const detail = unstablePrefixTurns
|
||||
.map((entry) => `turn-${entry.turn}:${formatReasons(entry.reasons)}`)
|
||||
.join(", ");
|
||||
console.log(`FAIL: non-log prefix churn detected (${detail})`);
|
||||
if (mutations !== folds) {
|
||||
console.log(`FAIL: ${mutations} mutations but only ${folds} fold events — unexpected mutation`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (sustainedAvg < 85) {
|
||||
console.log(`FAIL: sustained cache hit average ${sustainedAvg.toFixed(1)}% below 85% threshold`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`\nVERDICT: cache stayed warm through oversized tool results; raw rewrites are diagnostic, and force-summary cold miss is expected when triggered.`,
|
||||
);
|
||||
console.log(`\nVERDICT: ${folds} fold(s) fired, no other mutations.`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -7,54 +7,23 @@
|
||||
* that the API-level append-vs-mutate primitive behaves as expected.
|
||||
*
|
||||
* Run: REASONIX_LOG_LEVEL=ERROR npx tsx scripts/probe-loop-cache.mts
|
||||
* Reads DEEPSEEK_API_KEY from the environment, .env.testbak, or ~/.reasonix/config.json.
|
||||
* Reads DEEPSEEK_API_KEY from .env.testbak.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { ImmutablePrefix } from "../src/memory/runtime.js";
|
||||
import type { CacheChurnReason, TurnStats } from "../src/telemetry/stats.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
|
||||
function loadDotenv(path: string): void {
|
||||
if (!existsSync(path)) return;
|
||||
function loadDotenv(path: string) {
|
||||
const txt = readFileSync(path, "utf8");
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
|
||||
function loadReasonixApiKey(): void {
|
||||
if (process.env.DEEPSEEK_API_KEY) return;
|
||||
const path = join(homedir(), ".reasonix", "config.json");
|
||||
if (!existsSync(path)) return;
|
||||
const cfg = JSON.parse(readFileSync(path, "utf8")) as {
|
||||
apiKey?: string;
|
||||
deepseekApiKey?: string;
|
||||
endpoint?: { apiKey?: string };
|
||||
};
|
||||
const key = cfg.apiKey ?? cfg.deepseekApiKey ?? cfg.endpoint?.apiKey ?? "";
|
||||
if (key) process.env.DEEPSEEK_API_KEY = key;
|
||||
}
|
||||
|
||||
loadDotenv("./.env.testbak");
|
||||
loadReasonixApiKey();
|
||||
|
||||
function cacheRatio(usage: {
|
||||
promptCacheHitTokens: number;
|
||||
promptCacheMissTokens: number;
|
||||
}): number {
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
return total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
}
|
||||
|
||||
function formatReasons(reasons: readonly CacheChurnReason[]): string {
|
||||
return reasons.length > 0 ? reasons.join(",") : "-";
|
||||
}
|
||||
|
||||
const filler = (label: string, n: number): string =>
|
||||
Array.from(
|
||||
@@ -98,61 +67,49 @@ async function main() {
|
||||
loop.log.append({ role: "assistant", content: "noted." });
|
||||
|
||||
const ratios: number[] = [];
|
||||
const observedChurn: Array<{ turn: number; reasons: CacheChurnReason[] }> = [];
|
||||
let rawRewrites = 0;
|
||||
let mutations = 0;
|
||||
const origCompactInPlace = loop.log.compactInPlace.bind(loop.log);
|
||||
loop.log.compactInPlace = (...args: Parameters<typeof origCompactInPlace>) => {
|
||||
rawRewrites++;
|
||||
mutations++;
|
||||
return origCompactInPlace(...args);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let stats: TurnStats | null = null;
|
||||
const rewritesBefore = rawRewrites;
|
||||
let usage: { promptTokens: number; promptCacheHitTokens: number; promptCacheMissTokens: number } | null = null;
|
||||
for await (const ev of loop.step(`Turn ${i}: just say "ok ${i}".`)) {
|
||||
if (ev.role === "assistant_final" && ev.stats?.usage) {
|
||||
stats = ev.stats;
|
||||
usage = ev.stats.usage as typeof usage;
|
||||
}
|
||||
}
|
||||
const usage = stats?.usage;
|
||||
if (!usage) {
|
||||
console.log(`turn-${i}: no usage captured`);
|
||||
ratios.push(0);
|
||||
continue;
|
||||
}
|
||||
const ratio = cacheRatio(usage);
|
||||
const reasons = stats.cacheDiagnostics?.prefixChangeReasons ?? [];
|
||||
observedChurn.push({ turn: i, reasons: [...reasons] });
|
||||
const total = usage.promptCacheHitTokens + usage.promptCacheMissTokens;
|
||||
const ratio = total > 0 ? (usage.promptCacheHitTokens / total) * 100 : 0;
|
||||
console.log(
|
||||
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)} rawRewrites=${rawRewrites - rewritesBefore} churn=${formatReasons(reasons)}`,
|
||||
`turn-${i}: prompt=${usage.promptTokens} hit=${usage.promptCacheHitTokens} miss=${usage.promptCacheMissTokens} hit%=${ratio.toFixed(1)}`,
|
||||
);
|
||||
ratios.push(ratio);
|
||||
}
|
||||
|
||||
console.log(`\ntotal raw log.compactInPlace() calls: ${rawRewrites}`);
|
||||
console.log(`\ntotal log.compactInPlace() calls: ${mutations}`);
|
||||
console.log(`cache hit % per turn: ${ratios.map((x) => x.toFixed(1)).join(", ")}`);
|
||||
|
||||
const warmRatios = ratios.slice(1);
|
||||
const avgWarm = warmRatios.reduce((a, b) => a + b, 0) / warmRatios.length;
|
||||
console.log(`warm-turn average (excluding cold start): ${avgWarm.toFixed(1)}%`);
|
||||
|
||||
const unstablePrefixTurns = observedChurn
|
||||
.slice(1)
|
||||
.filter((entry) => entry.reasons.some((reason) => reason !== "log_rewrite"));
|
||||
if (unstablePrefixTurns.length > 0) {
|
||||
const detail = unstablePrefixTurns
|
||||
.map((entry) => `turn-${entry.turn}:${formatReasons(entry.reasons)}`)
|
||||
.join(", ");
|
||||
console.log(`\nFAIL: non-log prefix churn detected (${detail})`);
|
||||
if (mutations > 0) {
|
||||
console.log(`\nFAIL: log was mutated ${mutations} time(s) — append-only invariant broken`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (avgWarm < 80) {
|
||||
console.log(`\nFAIL: warm-turn average ${avgWarm.toFixed(1)}% below 80% threshold`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`\nPASS: cache stayed warm (avg ${avgWarm.toFixed(1)}%); raw rewrites are reported for diagnosis, not treated as cache failure`,
|
||||
);
|
||||
console.log(`\nPASS: append-only sustained, cache hit avg ${avgWarm.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
loadApiKey,
|
||||
loadEditMode,
|
||||
loadEndpoint,
|
||||
loadMaxIterPerTurn,
|
||||
loadModel,
|
||||
loadReasoningEffort,
|
||||
normalizeMcpConfig,
|
||||
@@ -113,9 +112,9 @@ export async function loadMcpServers(
|
||||
process.stderr.write(`${formatMcpLifecycleEvent({ state: "handshake", name: label })}\n`);
|
||||
const t0 = Date.now();
|
||||
const prefix = resolveMcpPrefix(spec.name, normalizedSpecs.length, globalPrefix);
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
|
||||
mcp = new McpClient({ transport, workspaceDir, requestTimeoutMs: spec.requestTimeoutMs });
|
||||
mcp = new McpClient({ transport, workspaceDir });
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
registry: tools,
|
||||
@@ -185,7 +184,6 @@ async function buildSession(opts: {
|
||||
tools: toolset.tools,
|
||||
model,
|
||||
budgetUsd: opts.budgetUsd,
|
||||
maxIterPerTurn: loadMaxIterPerTurn(),
|
||||
session: `acp-${timestampSuffix()}`,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
loadApiKey,
|
||||
loadHistoryScrollMode,
|
||||
loadToolRateLimit,
|
||||
normalizeMcpConfig,
|
||||
readConfig,
|
||||
searchEnabled,
|
||||
} from "../../config.js";
|
||||
@@ -20,12 +19,10 @@ import {
|
||||
resolveSession,
|
||||
} from "../../memory/session.js";
|
||||
import { QQChannel } from "../../qq/channel.js";
|
||||
import { TelegramChannel } from "../../telegram/channel.js";
|
||||
import { ToolRegistry } from "../../tools.js";
|
||||
import { registerChoiceTool } from "../../tools/choice.js";
|
||||
import { registerMemoryTools } from "../../tools/memory.js";
|
||||
import { registerWebTools } from "../../tools/web.js";
|
||||
import { WeixinChannel } from "../../weixin/channel.js";
|
||||
import { stopAndSaveCpuProfile } from "../cpu-prof.js";
|
||||
import { markPhase } from "../startup-profile.js";
|
||||
import { App } from "../ui/App.js";
|
||||
@@ -132,16 +129,10 @@ interface RootProps extends ChatOptions {
|
||||
historyScrollMode: ResolvedHistoryScrollMode;
|
||||
/** Pre-created QQ channel (started before TUI mounts). */
|
||||
qqChannel?: QQChannel;
|
||||
telegramChannel?: TelegramChannel;
|
||||
weixinChannel?: WeixinChannel;
|
||||
/** App fills this ref on mount so QQ messages flow into the TUI input queue. */
|
||||
qqSubmitRef: { current: ((text: string) => void) | null };
|
||||
/** App fills this ref on mount so QQ errors appear in the TUI log. */
|
||||
qqErrorRef: { current: ((msg: string) => void) | null };
|
||||
telegramSubmitRef: { current: ((text: string) => void) | null };
|
||||
telegramErrorRef: { current: ((msg: string) => void) | null };
|
||||
weixinSubmitRef: { current: ((text: string) => void) | null };
|
||||
weixinErrorRef: { current: ((msg: string) => void) | null };
|
||||
}
|
||||
|
||||
function Root({
|
||||
@@ -254,14 +245,8 @@ function Root({
|
||||
dashboardHost={appProps.dashboardHost}
|
||||
dashboardToken={appProps.dashboardToken}
|
||||
qqChannel={appProps.qqChannel}
|
||||
telegramChannel={appProps.telegramChannel}
|
||||
weixinChannel={appProps.weixinChannel}
|
||||
qqSubmitRef={appProps.qqSubmitRef}
|
||||
qqErrorRef={appProps.qqErrorRef}
|
||||
telegramSubmitRef={appProps.telegramSubmitRef}
|
||||
telegramErrorRef={appProps.telegramErrorRef}
|
||||
weixinSubmitRef={appProps.weixinSubmitRef}
|
||||
weixinErrorRef={appProps.weixinErrorRef}
|
||||
historyScrollMode={historyScrollMode}
|
||||
onSwitchSession={setActiveSession}
|
||||
/>
|
||||
@@ -320,8 +305,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
platform: process.platform,
|
||||
});
|
||||
const startupInfoHints: string[] = [];
|
||||
const hasAnyMcp = normalizeMcpConfig(cfg).length > 0 || mcpSpecs.length > 0;
|
||||
if (cfg.setupCompleted === true && !hasAnyMcp) {
|
||||
if (cfg.setupCompleted === true && (cfg.mcp?.length ?? 0) === 0 && mcpSpecs.length === 0) {
|
||||
startupInfoHints.push(t("mcpHealth.emptyHint"));
|
||||
}
|
||||
|
||||
@@ -367,16 +351,8 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
// deterministic.
|
||||
const qqSubmitRef: { current: ((text: string) => void) | null } = { current: null };
|
||||
const qqErrorRef: { current: ((msg: string) => void) | null } = { current: null };
|
||||
const telegramSubmitRef: { current: ((text: string) => void) | null } = { current: null };
|
||||
const telegramErrorRef: { current: ((msg: string) => void) | null } = { current: null };
|
||||
const weixinSubmitRef: { current: ((text: string) => void) | null } = { current: null };
|
||||
const weixinErrorRef: { current: ((msg: string) => void) | null } = { current: null };
|
||||
const qqRequested = cfg.qq?.enabled === true;
|
||||
const telegramRequested = cfg.telegram?.enabled === true;
|
||||
const weixinRequested = cfg.weixin?.enabled === true;
|
||||
let qqChannel: QQChannel | undefined;
|
||||
let telegramChannel: TelegramChannel | undefined;
|
||||
let weixinChannel: WeixinChannel | undefined;
|
||||
if (qqRequested) {
|
||||
const channel = new QQChannel({
|
||||
onSubmitMessage: (text) => qqSubmitRef.current?.(text),
|
||||
@@ -391,34 +367,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
process.stderr.write(`QQ bot failed: ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
if (telegramRequested) {
|
||||
const channel = new TelegramChannel({
|
||||
onSubmitMessage: (text) => telegramSubmitRef.current?.(text),
|
||||
onError: (msg) => telegramErrorRef.current?.(msg),
|
||||
});
|
||||
process.stderr.write("Connecting Telegram bot...\n");
|
||||
try {
|
||||
await channel.start();
|
||||
telegramChannel = channel;
|
||||
process.stderr.write("Telegram bot connected\n");
|
||||
} catch (err) {
|
||||
process.stderr.write(`Telegram bot failed: ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
if (weixinRequested) {
|
||||
const channel = new WeixinChannel({
|
||||
onSubmitMessage: (text) => weixinSubmitRef.current?.(text),
|
||||
onError: (msg) => weixinErrorRef.current?.(msg),
|
||||
});
|
||||
process.stderr.write("Connecting Weixin channel...\n");
|
||||
try {
|
||||
await channel.start();
|
||||
weixinChannel = channel;
|
||||
process.stderr.write("Weixin channel connected\n");
|
||||
} catch (err) {
|
||||
process.stderr.write(`Weixin channel failed: ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Before render() — shims Ink's per-card useBoxMetrics resize subscribe
|
||||
// path so N cards don't accumulate N native stdout listeners.
|
||||
@@ -456,14 +404,8 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
codeMode={codeMode}
|
||||
session={resolvedSession}
|
||||
qqChannel={qqChannel}
|
||||
telegramChannel={telegramChannel}
|
||||
weixinChannel={weixinChannel}
|
||||
qqSubmitRef={qqSubmitRef}
|
||||
qqErrorRef={qqErrorRef}
|
||||
telegramSubmitRef={telegramSubmitRef}
|
||||
telegramErrorRef={telegramErrorRef}
|
||||
weixinSubmitRef={weixinSubmitRef}
|
||||
weixinErrorRef={weixinErrorRef}
|
||||
/>,
|
||||
{ exitOnCtrlC: true, incrementalRendering: true },
|
||||
);
|
||||
@@ -473,8 +415,6 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
disableMouseMode();
|
||||
await runtime.closeAll();
|
||||
qqChannel?.stop();
|
||||
telegramChannel?.stop();
|
||||
weixinChannel?.stop();
|
||||
await drainTtyResponses();
|
||||
}
|
||||
}
|
||||
@@ -70,13 +70,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
// Per-directory session so switching projects doesn't mix histories.
|
||||
// `code-<sanitized-basename>` fits the session name rules without
|
||||
// truncating most project names.
|
||||
// Per-directory session unless --no-session or autoResumeSession:false in config (#2238).
|
||||
// Explicit -r/--resume or --new flags override autoResumeSession so users can still
|
||||
// manually resume or start fresh even when the default behaviour has been toggled.
|
||||
const cfg = readConfig();
|
||||
const explicitResume = opts.forceResume || opts.forceNew;
|
||||
const autoResume = opts.noSession ? false : explicitResume || cfg.autoResumeSession !== false;
|
||||
const session = autoResume ? `code-${sanitizeName(basename(rootDir))}` : undefined;
|
||||
const session = opts.noSession ? undefined : `code-${sanitizeName(basename(rootDir))}`;
|
||||
|
||||
markPhase("semantic_bootstrap_start");
|
||||
const { tools, jobs, registerRooted, reBootstrapSemantic, semantic } = await buildCodeToolset({
|
||||
|
||||
+66
-773
File diff suppressed because it is too large.
Load diff
+3
-172
@@ -8,7 +8,6 @@ import {
|
||||
defaultConfigPath,
|
||||
loadEndpoint,
|
||||
loadProxyConfig,
|
||||
normalizeMcpConfig,
|
||||
readConfig,
|
||||
resolveSemanticEmbeddingConfig,
|
||||
} from "../../config.js";
|
||||
@@ -19,7 +18,6 @@ import { indexExists } from "../../index/semantic/builder.js";
|
||||
import { checkOllamaStatus } from "../../index/semantic/ollama-launcher.js";
|
||||
import { listSessions } from "../../memory/session.js";
|
||||
import { detectProxyUrl, matchesNoProxy, resolveNoProxy } from "../../net/proxy.js";
|
||||
import { isCacheDiagnosticEntry } from "../../telemetry/cache-diagnostics.js";
|
||||
import { resolveDataPath } from "../../tokenizer.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
|
||||
@@ -34,7 +32,6 @@ export interface DoctorCheck {
|
||||
|
||||
export interface DoctorOptions {
|
||||
json?: boolean;
|
||||
cache?: boolean;
|
||||
}
|
||||
|
||||
type Level = DoctorLevel;
|
||||
@@ -57,17 +54,6 @@ export async function runDoctorChecks(projectRoot: string): Promise<DoctorCheck[
|
||||
return [r[0], r[1], ...checkProxy(), r[2], r[3], r[4], r[5], r[6], r[7]];
|
||||
}
|
||||
|
||||
export async function runCacheDoctorChecks(projectRoot: string): Promise<DoctorCheck[]> {
|
||||
const r = await Promise.all([
|
||||
checkCacheDynamicPrompt(projectRoot),
|
||||
checkCacheMcpOrder(),
|
||||
checkCacheSkillsAndMemory(projectRoot),
|
||||
checkCacheHooks(projectRoot),
|
||||
checkCacheEvidence(),
|
||||
]);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Probe hosts used to show users what's going through the proxy vs. direct. Cheap (no I/O), purely a routing simulation against the same NO_PROXY patterns the dispatcher uses. */
|
||||
const PROXY_PROBE_HOSTS = ["api.deepseek.com", "github.com", "api.github.com"] as const;
|
||||
|
||||
@@ -206,8 +192,7 @@ async function checkConfig(): Promise<Check> {
|
||||
if (cfg.model) parts.push(`model=${cfg.model}`);
|
||||
if (cfg.reasoningEffort) parts.push(`effort=${cfg.reasoningEffort}`);
|
||||
if (cfg.editMode) parts.push(`editMode=${cfg.editMode}`);
|
||||
const mcpCount = normalizeMcpConfig(cfg).length;
|
||||
if (mcpCount > 0) parts.push(`mcp=${mcpCount}`);
|
||||
if (cfg.mcp && cfg.mcp.length > 0) parts.push(`mcp=${cfg.mcp.length}`);
|
||||
return {
|
||||
id: "config",
|
||||
label: "config ",
|
||||
@@ -392,155 +377,6 @@ async function checkHooks(projectRoot: string): Promise<Check> {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCacheDynamicPrompt(projectRoot: string): Promise<Check> {
|
||||
const path = join(projectRoot, "REASONIX.md");
|
||||
if (!existsSync(path)) {
|
||||
return {
|
||||
id: "cache-dynamic-prompt",
|
||||
label: "cache prompt ",
|
||||
level: "ok",
|
||||
detail: "no project REASONIX.md found; no obvious project-memory timestamp source",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const body = readFileSync(path, "utf8");
|
||||
const dynamicPattern =
|
||||
/\b(Date\.now|new Date|toISOString|timestamp|current time|today is|当前时间|今天是)\b/i;
|
||||
if (dynamicPattern.test(body)) {
|
||||
return {
|
||||
id: "cache-dynamic-prompt",
|
||||
label: "cache prompt ",
|
||||
level: "warn",
|
||||
detail:
|
||||
"REASONIX.md contains timestamp-like text/code; dynamic time in the system prompt changes the byte-stable prefix",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cache-dynamic-prompt",
|
||||
label: "cache prompt ",
|
||||
level: "ok",
|
||||
detail: "REASONIX.md has no obvious timestamp injection markers",
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: "cache-dynamic-prompt",
|
||||
label: "cache prompt ",
|
||||
level: "warn",
|
||||
detail: `could not read REASONIX.md: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCacheMcpOrder(): Promise<Check> {
|
||||
try {
|
||||
const cfg = readConfig();
|
||||
const specs = normalizeMcpConfig(cfg).filter((spec) => !spec.disabled);
|
||||
if (specs.length === 0) {
|
||||
return {
|
||||
id: "cache-mcp-order",
|
||||
label: "cache mcp ",
|
||||
level: "ok",
|
||||
detail: "no enabled MCP servers configured",
|
||||
};
|
||||
}
|
||||
const unnamed = specs.filter((spec) => !spec.name).length;
|
||||
if (unnamed > 0) {
|
||||
return {
|
||||
id: "cache-mcp-order",
|
||||
label: "cache mcp ",
|
||||
level: "warn",
|
||||
detail: `${unnamed}/${specs.length} MCP server specs have no stable name; name servers so tool prefixes stay deterministic`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cache-mcp-order",
|
||||
label: "cache mcp ",
|
||||
level: "ok",
|
||||
detail: `${specs.length} enabled MCP server${specs.length === 1 ? "" : "s"} have stable names; tool order/schema are normalized before prefix hashing`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: "cache-mcp-order",
|
||||
label: "cache mcp ",
|
||||
level: "warn",
|
||||
detail: `could not inspect MCP config: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCacheSkillsAndMemory(projectRoot: string): Promise<Check> {
|
||||
const markers = ["REASONIX.md", ".reasonix"].filter((name) =>
|
||||
existsSync(join(projectRoot, name)),
|
||||
);
|
||||
return {
|
||||
id: "cache-memory-skills",
|
||||
label: "cache memory ",
|
||||
level: "ok",
|
||||
detail:
|
||||
markers.length > 0
|
||||
? `${markers.join(", ")} present; project memory is loaded into the immutable prefix and should only change on /new or restart`
|
||||
: "no project memory markers found; skill/memory prefix churn unlikely from this workspace",
|
||||
};
|
||||
}
|
||||
|
||||
async function checkCacheHooks(projectRoot: string): Promise<Check> {
|
||||
try {
|
||||
const all = loadHooks({ projectRoot });
|
||||
if (all.length === 0) {
|
||||
return {
|
||||
id: "cache-hooks",
|
||||
label: "cache hooks ",
|
||||
level: "ok",
|
||||
detail: "no hooks loaded",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cache-hooks",
|
||||
label: "cache hooks ",
|
||||
level: "warn",
|
||||
detail: `${all.length} hook${all.length === 1 ? "" : "s"} loaded; ensure hook output does not inject timestamps or mutate prompt-visible files every turn`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: "cache-hooks",
|
||||
label: "cache hooks ",
|
||||
level: "warn",
|
||||
detail: `could not inspect hooks: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCacheEvidence(): Promise<Check> {
|
||||
try {
|
||||
const sessions = listSessions();
|
||||
const withEvidence = sessions.filter((s) =>
|
||||
s.meta.cacheDiagnostics?.some((entry) => isCacheDiagnosticEntry(entry)),
|
||||
);
|
||||
if (withEvidence.length === 0) {
|
||||
return {
|
||||
id: "cache-evidence",
|
||||
label: "cache report ",
|
||||
level: "warn",
|
||||
detail:
|
||||
"no session has cacheDiagnostics yet; run a model turn, then use /cache-miss-report",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cache-evidence",
|
||||
label: "cache report ",
|
||||
level: "ok",
|
||||
detail: `${withEvidence.length}/${sessions.length} saved session${sessions.length === 1 ? "" : "s"} include cacheDiagnostics evidence`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: "cache-evidence",
|
||||
label: "cache report ",
|
||||
level: "warn",
|
||||
detail: `could not inspect session meta: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOllama(projectRoot: string): Promise<Check> {
|
||||
let exists = false;
|
||||
try {
|
||||
@@ -672,12 +508,9 @@ export async function doctorCommand(opts: DoctorOptions = {}): Promise<void> {
|
||||
|
||||
const projectRoot = resolve(process.cwd());
|
||||
const json = !!opts.json;
|
||||
const cacheOnly = !!opts.cache;
|
||||
|
||||
if (!json) {
|
||||
console.log(
|
||||
`${color(`reasonix ${VERSION} · ${cacheOnly ? "doctor --cache" : "doctor"}`, "1")} (cwd: ${projectRoot})`,
|
||||
);
|
||||
console.log(`${color(`reasonix ${VERSION} · doctor`, "1")} (cwd: ${projectRoot})`);
|
||||
console.log(` home: ${homedir()}`);
|
||||
console.log("");
|
||||
}
|
||||
@@ -685,9 +518,7 @@ export async function doctorCommand(opts: DoctorOptions = {}): Promise<void> {
|
||||
// Run independent checks in parallel — saves ~5s when api-reach has
|
||||
// to time out. Each handler swallows its own throws into a `fail`
|
||||
// result so a thrown promise can't kill the whole report.
|
||||
const checks = cacheOnly
|
||||
? await runCacheDoctorChecks(projectRoot)
|
||||
: await runDoctorChecks(projectRoot);
|
||||
const checks = await runDoctorChecks(projectRoot);
|
||||
|
||||
const ok = checks.filter((c) => c.level === "ok").length;
|
||||
const warn = checks.filter((c) => c.level === "warn").length;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Box, Text, render, useApp, useInput } from "ink";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { normalizeMcpConfig, readConfig, writeConfig } from "../../config.js";
|
||||
import { readConfig, writeConfig } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import { loadOverlay } from "../../mcp/marketplace-overlay/loader.js";
|
||||
import {
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
specStringFor,
|
||||
} from "../../mcp/registry-fetch.js";
|
||||
import type { RegistryEntry } from "../../mcp/registry-types.js";
|
||||
import { FG } from "../ui/theme/tokens.js";
|
||||
|
||||
const VISIBLE_ROWS = 12;
|
||||
|
||||
@@ -116,10 +115,7 @@ function McpBrowseApp() {
|
||||
const spec = specStringFor(entry.name, entry.install);
|
||||
const cfg = readConfig();
|
||||
const existing = cfg.mcp ?? [];
|
||||
const installedName = entry.name;
|
||||
const normalized = normalizeMcpConfig(cfg);
|
||||
const nameCollision = normalized.some((s) => s.name === installedName);
|
||||
if (existing.includes(spec) || nameCollision) {
|
||||
if (existing.includes(spec)) {
|
||||
setStatus(`already installed: ${spec}`);
|
||||
return;
|
||||
}
|
||||
@@ -176,18 +172,16 @@ function McpBrowseApp() {
|
||||
<Text bold color="ansi:cyan">
|
||||
◈ MCP marketplace
|
||||
</Text>
|
||||
<Text color={FG.faint}>{` · ${state.status}`}</Text>
|
||||
<Text dim>{` · ${state.status}`}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>search: </Text>
|
||||
<Text color="ansi:white">{state.query || "(type to filter)"}</Text>
|
||||
<Text
|
||||
color={FG.faint}
|
||||
>{` ${filtered.length} match${filtered.length === 1 ? "" : "es"}`}</Text>
|
||||
<Text dim>{` ${filtered.length} match${filtered.length === 1 ? "" : "es"}`}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{window.length === 0 ? (
|
||||
<Text color={FG.faint}>{state.loading ? "loading…" : "no entries"}</Text>
|
||||
<Text dim>{state.loading ? "loading…" : "no entries"}</Text>
|
||||
) : (
|
||||
window.map((e, i) => {
|
||||
const idx = (start || 0) + i;
|
||||
@@ -199,7 +193,7 @@ function McpBrowseApp() {
|
||||
<Box key={e.name}>
|
||||
<Text color={active ? "ansi:cyan" : undefined}>{active ? "▸ " : " "}</Text>
|
||||
<Text bold={active}>{e.name.padEnd(40).slice(0, 40)}</Text>
|
||||
<Text color={FG.faint}>{` ${tag}${pop}`}</Text>
|
||||
<Text dim>{` ${tag}${pop}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
@@ -209,19 +203,17 @@ function McpBrowseApp() {
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold color="ansi:white">
|
||||
{overlay?.[selected.name]?.title ?? selected.title}
|
||||
{overlay?.[selected.name] ? (
|
||||
<Text color={FG.faint}>{` \u00b7 ${selected.title}`}</Text>
|
||||
) : null}
|
||||
{overlay?.[selected.name] ? <Text dim>{` \u00b7 ${selected.title}`}</Text> : null}
|
||||
</Text>
|
||||
<Text color={FG.faint}>
|
||||
<Text dim>
|
||||
{overlay?.[selected.name]?.description ?? selected.description?.slice(0, 160) ?? null}
|
||||
</Text>
|
||||
{selected.install ? (
|
||||
<Text color={FG.faint}>
|
||||
<Text dim>
|
||||
{`spec: ${selected.install.runtime} ${selected.install.packageId ?? selected.install.url ?? "—"} · ${selected.install.transport}`}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={FG.faint}>(smithery listing — install info not exposed)</Text>
|
||||
<Text dim>(smithery listing — install info not exposed)</Text>
|
||||
)}
|
||||
{selected.install?.requiredEnv?.length ? (
|
||||
<Text color="ansi:yellow">{`needs: ${selected.install.requiredEnv.join(", ")}`}</Text>
|
||||
@@ -229,9 +221,7 @@ function McpBrowseApp() {
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>
|
||||
type to filter · ↑↓ pick · enter install · tab load more · esc quit
|
||||
</Text>
|
||||
<Text dim>type to filter · ↑↓ pick · enter install · tab load more · esc quit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -19,14 +19,10 @@ export async function mcpInspectCommand(opts: McpInspectOptions): Promise<void>
|
||||
const normalized = normalizeMcpConfig(cfg);
|
||||
const matched = parsed.name ? normalized.find((s) => s.name === parsed.name) : undefined;
|
||||
const spec = overlayMatchedSpec(parsed, matched);
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const workspaceDir = process.cwd();
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
|
||||
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
|
||||
const client = new McpClient({
|
||||
transport,
|
||||
workspaceDir,
|
||||
requestTimeoutMs: spec.requestTimeoutMs,
|
||||
});
|
||||
const client = new McpClient({ transport, workspaceDir });
|
||||
try {
|
||||
await client.initialize();
|
||||
const report = await inspectMcpServer(client);
|
||||
|
||||
@@ -120,10 +120,7 @@ export interface McpRuntime {
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ ok: true; summary: McpServerSummary } | { ok: false; reason: string }>;
|
||||
removeSpec(raw: string, loop?: CacheFirstLoop): Promise<boolean>;
|
||||
reloadFromConfig(
|
||||
loop?: CacheFirstLoop,
|
||||
opts?: McpReloadOptions,
|
||||
): Promise<{
|
||||
reloadFromConfig(loop?: CacheFirstLoop): Promise<{
|
||||
added: string[];
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
@@ -134,10 +131,6 @@ export interface McpRuntime {
|
||||
setLifecycleSink(sink: McpLifecycleSink): void;
|
||||
}
|
||||
|
||||
interface McpReloadOptions {
|
||||
force?: Iterable<string>;
|
||||
}
|
||||
|
||||
export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
|
||||
const records = new Map<string, SpecRecord>();
|
||||
const insertionOrder: string[] = [];
|
||||
@@ -190,10 +183,10 @@ export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
|
||||
: ctx.getRequestedCount() === 1 && ctx.getMcpPrefix()
|
||||
? (ctx.getMcpPrefix() as string)
|
||||
: "";
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const workspaceDir = ctx.getWorkspaceDir?.();
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
|
||||
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
|
||||
mcp = new McpClient({ transport, workspaceDir, requestTimeoutMs: spec.requestTimeoutMs });
|
||||
mcp = new McpClient({ transport, workspaceDir });
|
||||
await mcp.initialize({ signal });
|
||||
const host: McpClientHost = { client: mcp };
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
@@ -338,10 +331,7 @@ export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reloadFromConfig(
|
||||
loop?: CacheFirstLoop,
|
||||
opts: McpReloadOptions = {},
|
||||
): Promise<{
|
||||
async function reloadFromConfig(loop?: CacheFirstLoop): Promise<{
|
||||
added: string[];
|
||||
removed: string[];
|
||||
failed: Array<{ spec: string; reason: string }>;
|
||||
@@ -351,17 +341,14 @@ export function createMcpRuntime(ctx: RuntimeContext): McpRuntime {
|
||||
const desired = normalized.map(specToRaw);
|
||||
const desiredSet = new Set(desired);
|
||||
const currentSet = new Set(records.keys());
|
||||
const forceSet = new Set(opts.force ?? []);
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const failed: Array<{ spec: string; reason: string }> = [];
|
||||
|
||||
for (const spec of [...currentSet]) {
|
||||
const noLongerDesired = !desiredSet.has(spec);
|
||||
if (noLongerDesired || forceSet.has(spec)) {
|
||||
if (!desiredSet.has(spec)) {
|
||||
await removeSpec(spec, loop);
|
||||
currentSet.delete(spec);
|
||||
if (noLongerDesired) removed.push(spec);
|
||||
removed.push(spec);
|
||||
}
|
||||
}
|
||||
for (const spec of desired) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defaultConfigPath, normalizeMcpConfig, readConfig, writeConfig } from "../../config.js";
|
||||
import { defaultConfigPath, readConfig, writeConfig } from "../../config.js";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import { MCP_CATALOG, mcpCommandFor } from "../../mcp/catalog.js";
|
||||
import {
|
||||
@@ -301,10 +301,7 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
|
||||
|
||||
const cfg = readConfig();
|
||||
const existing = cfg.mcp ?? [];
|
||||
const installedName = parseInstalledName(spec);
|
||||
const normalized = normalizeMcpConfig(cfg);
|
||||
const nameCollision = installedName && normalized.some((s) => s.name === installedName);
|
||||
if (existing.includes(spec) || nameCollision) {
|
||||
if (existing.includes(spec)) {
|
||||
console.log(t("mcpCli.alreadyInstalled", { spec }));
|
||||
return;
|
||||
}
|
||||
@@ -313,6 +310,7 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
|
||||
|
||||
console.log(t("mcpCli.installed", { spec: entry.name }));
|
||||
console.log(` spec: ${spec}`);
|
||||
const installedName = parseInstalledName(spec);
|
||||
if (entry.install.requiredEnv?.length) {
|
||||
console.log(` needs: ${entry.install.requiredEnv.join(", ")}`);
|
||||
console.log(" Either export these before launching, or add them to config:");
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
isPlausibleKey,
|
||||
loadApiKey,
|
||||
loadEndpoint,
|
||||
loadMaxIterPerTurn,
|
||||
loadToolRateLimit,
|
||||
normalizeMcpConfig,
|
||||
readConfig,
|
||||
@@ -103,9 +102,9 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
: normalizedSpecs.length === 1 && opts.mcpPrefix
|
||||
? opts.mcpPrefix
|
||||
: "";
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec, { cwd: workspaceDir });
|
||||
if (spec.transport === "stdio") preflightStdioSpec(spec);
|
||||
const transport = buildTransportFromSpec(spec, { cwd: workspaceDir });
|
||||
mcp = new McpClient({ transport, workspaceDir, requestTimeoutMs: spec.requestTimeoutMs });
|
||||
mcp = new McpClient({ transport, workspaceDir });
|
||||
await mcp.initialize();
|
||||
const bridge = await bridgeMcpTools(mcp, {
|
||||
registry: tools,
|
||||
@@ -152,7 +151,6 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
tools,
|
||||
model: opts.model,
|
||||
budgetUsd: opts.budgetUsd,
|
||||
maxIterPerTurn: loadMaxIterPerTurn(),
|
||||
});
|
||||
const prefixHash = prefix.fingerprint;
|
||||
|
||||
|
||||
+2
-46
@@ -211,45 +211,11 @@ program
|
||||
)
|
||||
.option("--system-append <prompt>", t("ui.systemAppendHint"))
|
||||
.option("--system-append-file <path>", t("ui.systemAppendFileHint"))
|
||||
.option(
|
||||
"--dry-run",
|
||||
"for ssh:// targets: parse the URI, check local SSH, print planned steps (no remote commands execute). RFC for #2140.",
|
||||
)
|
||||
.option(
|
||||
"--profile [path]",
|
||||
"record a V8 CPU profile; saved on exit. Send the .cpuprofile back if you're reporting a perf bug.",
|
||||
)
|
||||
.action(async (dir: string | undefined, opts) => {
|
||||
// ssh:// without --dry-run is not yet implemented (RFC #2140).
|
||||
if (typeof dir === "string" && dir.startsWith("ssh://") && !opts.dryRun) {
|
||||
process.stderr.write(
|
||||
"ssh:// remote workspaces require --dry-run (RFC #2140).\n" +
|
||||
"Full remote execution is not yet implemented.\n" +
|
||||
"\n" +
|
||||
"Short-term recommendation:\n" +
|
||||
" Run Reasonix directly on the remote host, then use SSH tunnel for the dashboard:\n" +
|
||||
" $ ssh -L 8420:127.0.0.1:8420 user@host\n" +
|
||||
" $ reasonix code\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// SSH remote workspace RFC — dry-run bootstrap for #2140.
|
||||
if (typeof dir === "string" && dir.startsWith("ssh://") && opts.dryRun) {
|
||||
const { generateSshDryRunReport, parseSshUri, probeSsh } = await import("./ssh-remote.js");
|
||||
const uri = parseSshUri(dir);
|
||||
if (!uri) {
|
||||
process.stderr.write(`invalid ssh:// URI: ${dir}\n`);
|
||||
process.stderr.write("expected format: ssh://[user@]host[:port][/path]\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const ssh = probeSsh();
|
||||
const report = generateSshDryRunReport(uri, ssh);
|
||||
console.log(report);
|
||||
if (!ssh) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
persistEffortFlag(opts.effort);
|
||||
const profiling = await maybeStartCpuProfile(opts.profile);
|
||||
try {
|
||||
@@ -348,7 +314,7 @@ program
|
||||
mcp: defaults.mcp,
|
||||
mcpPrefix: opts.mcpPrefix,
|
||||
forceResume: continueOpts.forceResume,
|
||||
forceNew: !!opts.new || !!defaults.forceNew,
|
||||
forceNew: !!opts.new,
|
||||
noDashboard: opts.dashboard === false || !loadDashboardEnabled(opts.config === false),
|
||||
openDashboard: opts.openDashboard === true,
|
||||
dashboardPort: resolveDashboardPort(
|
||||
@@ -472,19 +438,9 @@ program
|
||||
.command("doctor")
|
||||
.description(t("cli.doctor"))
|
||||
.option("--json", t("ui.jsonHint"))
|
||||
.option("--cache", "run cache-stability checks only")
|
||||
.action(async (opts) => {
|
||||
const { doctorCommand } = await import("./commands/doctor.js");
|
||||
await doctorCommand({ json: !!opts.json, cache: !!opts.cache });
|
||||
});
|
||||
|
||||
program
|
||||
.command("doctor-cache")
|
||||
.description("cache-stability health check")
|
||||
.option("--json", t("ui.jsonHint"))
|
||||
.action(async (opts) => {
|
||||
const { doctorCommand } = await import("./commands/doctor.js");
|
||||
await doctorCommand({ json: !!opts.json, cache: true });
|
||||
await doctorCommand({ json: !!opts.json });
|
||||
});
|
||||
|
||||
program
|
||||
|
||||
+3
-13
@@ -3,7 +3,6 @@ import {
|
||||
type ReasoningEffort,
|
||||
type ReasonixConfig,
|
||||
isReasoningEffort,
|
||||
loadModel,
|
||||
loadReasoningEffort,
|
||||
normalizeMcpConfig,
|
||||
readConfig,
|
||||
@@ -16,8 +15,6 @@ export interface ResolvedDefaults {
|
||||
reasoningEffort: ReasoningEffort;
|
||||
mcp: string[];
|
||||
session: string | undefined;
|
||||
/** True when autoResumeSession:false is in effect — tells callers to skip the session picker. */
|
||||
forceNew?: boolean;
|
||||
}
|
||||
|
||||
export interface RawCliFlags {
|
||||
@@ -33,8 +30,7 @@ export interface RawCliFlags {
|
||||
|
||||
export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
|
||||
const cfg: ReasonixConfig = flags.noConfig ? {} : readConfig();
|
||||
const model =
|
||||
flags.model?.trim() || (flags.noConfig ? cfg.model?.trim() : loadModel()) || DEFAULT_MODEL;
|
||||
const model = flags.model?.trim() || cfg.model?.trim() || DEFAULT_MODEL;
|
||||
|
||||
const flagEffort = flags.effort?.toLowerCase();
|
||||
const reasoningEffort: ReasoningEffort = isReasoningEffort(flagEffort)
|
||||
@@ -51,11 +47,9 @@ export function resolveDefaults(flags: RawCliFlags): ResolvedDefaults {
|
||||
);
|
||||
const mcp = normalizedMcp.map(specToRaw);
|
||||
|
||||
const session = resolveSession(flags.session, cfg.session, cfg.autoResumeSession);
|
||||
const forceNew =
|
||||
cfg.autoResumeSession === false && flags.session === undefined ? true : undefined;
|
||||
const session = resolveSession(flags.session, cfg.session);
|
||||
|
||||
return { model, reasoningEffort, mcp, session, forceNew };
|
||||
return { model, reasoningEffort, mcp, session };
|
||||
}
|
||||
|
||||
function mergeDotMcpJson(cfg: ReasonixConfig, projectRoot: string): ReasonixConfig {
|
||||
@@ -67,15 +61,11 @@ function mergeDotMcpJson(cfg: ReasonixConfig, projectRoot: string): ReasonixConf
|
||||
function resolveSession(
|
||||
flag: string | false | undefined,
|
||||
configSession: string | null | undefined,
|
||||
autoResume?: boolean,
|
||||
): string | undefined {
|
||||
if (flag === false) return undefined;
|
||||
if (typeof flag === "string" && flag.length > 0) return flag;
|
||||
if (configSession === null) return undefined;
|
||||
if (typeof configSession === "string" && configSession.length > 0) return configSession;
|
||||
// When autoResumeSession is explicitly false, don't default to "default" session
|
||||
// so each launch starts fresh instead of resuming the previous conversation (#2238).
|
||||
if (autoResume === false) return undefined;
|
||||
return "default";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// SSH remote workspace RFC / dry-run — #2140. Full remote execution is a future deliverable.
|
||||
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export interface SshUri {
|
||||
user: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Parse ssh://[user@]host[:port]/path. Trailing `:port` is optional; defaults to 22. */
|
||||
export function parseSshUri(raw: string): SshUri | null {
|
||||
const m = /^ssh:\/\/(?:([^@:]+)@)?([^/:]+)(?::(\d+))?(\/.*)?$/.exec(raw);
|
||||
if (!m) return null;
|
||||
const user = m[1] ?? inferSshUser();
|
||||
const host = m[2] ?? "";
|
||||
if (!host) return null;
|
||||
const port = m[3] ? Number.parseInt(m[3], 10) : 22;
|
||||
const path = m[4] ?? "/";
|
||||
return { user, host, port, path };
|
||||
}
|
||||
|
||||
function inferSshUser(): string {
|
||||
try {
|
||||
const out = execFileSync("id", ["-un"], { encoding: "utf8", timeout: 1000 }).trim();
|
||||
if (out) return out;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
// Best-effort fallback on Windows.
|
||||
if (platform() === "win32") {
|
||||
try {
|
||||
const out = execFileSync("whoami", [], { encoding: "utf8", timeout: 1000 }).trim();
|
||||
if (out) return out;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return "root";
|
||||
}
|
||||
|
||||
export interface SshProbe {
|
||||
sshBin: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function probeSsh(): SshProbe | null {
|
||||
try {
|
||||
// ssh -V writes to stderr on all platforms; merge into stdout.
|
||||
const version = execSync("ssh -V 2>&1", { encoding: "utf8", timeout: 3000 });
|
||||
return { sshBin: "ssh", version: version.trim() };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal POSIX shell quoting — wraps the value in single quotes and escapes
|
||||
* embedded single quotes. Sufficient for dry-run copy-paste safety. */
|
||||
function shellQuote(value: string): string {
|
||||
if (!/[^a-zA-Z0-9,._+:@%/-]/.test(value)) return value;
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function generateSshDryRunReport(uri: SshUri, ssh: SshProbe | null): string {
|
||||
const sq = shellQuote;
|
||||
const sections = [
|
||||
`reasonix ${VERSION} · SSH remote workspace RFC dry-run`,
|
||||
"issue: https://github.com/esengine/DeepSeek-Reasonix/issues/2140",
|
||||
"",
|
||||
`target: ${sq(`ssh://${uri.user}@${uri.host}:${uri.port}${uri.path}`)}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (!ssh) {
|
||||
sections.push(
|
||||
"WARNING: `ssh` binary not found on PATH. Install an SSH client before running the real command.",
|
||||
"",
|
||||
);
|
||||
} else {
|
||||
sections.push(`ssh: ${ssh.version}`);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
"--- parsed ---",
|
||||
` user: ${uri.user}`,
|
||||
` host: ${uri.host}`,
|
||||
` port: ${uri.port}`,
|
||||
` path: ${uri.path}`,
|
||||
"",
|
||||
"--- planned steps (dry run — no remote commands execute) ---",
|
||||
"",
|
||||
);
|
||||
|
||||
const remote = `${sq(uri.user)}@${sq(uri.host)}`;
|
||||
const cdCmd = `cd ${sq(uri.path)} && reasonix code --no-dashboard`;
|
||||
|
||||
if (ssh) {
|
||||
sections.push(
|
||||
"1. verify connectivity",
|
||||
` $ ssh -p ${uri.port} ${remote} -- 'echo ok'`,
|
||||
"",
|
||||
"2. probe remote environment",
|
||||
` $ ssh -p ${uri.port} ${remote} -- 'node --version && npm --version && uname -s'`,
|
||||
"",
|
||||
"3. install or update Reasonix on remote",
|
||||
` $ ssh -p ${uri.port} ${remote} -- 'npm i -g reasonix'`,
|
||||
"",
|
||||
"4. launch Reasonix in the target workspace on the remote host",
|
||||
` $ ssh -p ${uri.port} ${remote} -- '${cdCmd}'`,
|
||||
"",
|
||||
"5. (local) open an SSH tunnel to the remote dashboard",
|
||||
` $ ssh -N -L 8420:127.0.0.1:8420 -p ${uri.port} ${remote}`,
|
||||
" Then open http://127.0.0.1:8420 in your local browser.",
|
||||
"",
|
||||
);
|
||||
} else {
|
||||
sections.push(
|
||||
"1. install an SSH client",
|
||||
" macOS: built-in (OpenSSH)",
|
||||
" Windows: built-in (OpenSSH Client optional feature) or `winget install Microsoft.OpenSSH.Beta`",
|
||||
" Linux: `apt install openssh-client` / `dnf install openssh-clients`",
|
||||
"",
|
||||
"2. verify connectivity",
|
||||
` $ ssh -p ${uri.port} ${remote} -- 'echo ok'`,
|
||||
"",
|
||||
"3. install Reasonix on remote",
|
||||
` $ ssh -p ${uri.port} ${remote} -- 'npm i -g reasonix'`,
|
||||
"",
|
||||
"4. launch Reasonix remotely",
|
||||
` $ ssh -p ${uri.port} ${remote} -- '${cdCmd}'`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
"--- short-term recommendation ---",
|
||||
"",
|
||||
"Until native remote execution lands, the simplest working setup is:",
|
||||
" 1. Run Reasonix directly on the remote host (`ssh user@host`, then `reasonix code`).",
|
||||
" 2. Forward the dashboard port to your local machine:",
|
||||
` $ ssh -N -L 8420:127.0.0.1:8420 -p ${uri.port} ${remote}`,
|
||||
" 3. Open http://127.0.0.1:8420 locally. The dashboard token gates access.",
|
||||
"",
|
||||
"--- RFC scope ---",
|
||||
"",
|
||||
"This dry-run is a reviewable design bootstrap for #2140.",
|
||||
"It parses the URI, checks local tooling, and prints the steps Reasonix",
|
||||
"would take. No remote commands execute and no network connections are made.",
|
||||
"",
|
||||
"#2141 (GPU passthrough) is tracked separately and is not part of this RFC.",
|
||||
);
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
+82
-264
@@ -9,7 +9,6 @@ import {
|
||||
openEventSink,
|
||||
} from "../../adapters/event-sink-jsonl.js";
|
||||
import { type AtUrlExpansion, expandAtMentions, expandAtUrls } from "../../at-mentions.js";
|
||||
import { prepareAutoGitRollbackForEditBlocks } from "../../code/auto-git-rollback.js";
|
||||
import {
|
||||
type CheckpointMeta,
|
||||
createCheckpoint,
|
||||
@@ -40,8 +39,6 @@ import {
|
||||
loadEndpoint,
|
||||
loadEngineeringLifecycleMode,
|
||||
loadHistoryScrollMode,
|
||||
loadMaxIterPerTurn,
|
||||
loadMaxOutputTokens,
|
||||
loadMouseWheelRows,
|
||||
loadReasoningEffort,
|
||||
loadTheme,
|
||||
@@ -66,7 +63,6 @@ import {
|
||||
deleteSession,
|
||||
detectGitBranch,
|
||||
freshSessionName,
|
||||
hashSystemPrompt,
|
||||
type listSessions,
|
||||
listSessionsForWorkspace,
|
||||
loadSessionMessages,
|
||||
@@ -93,9 +89,11 @@ import {
|
||||
shouldAutoNameSession,
|
||||
} from "../../session-title.js";
|
||||
import { loadSlashUsage, recordSlashUse } from "../../slash-usage.js";
|
||||
import type { TelegramChannel } from "../../telegram/channel.js";
|
||||
import { useTelegramChannel } from "../../telegram/use-telegram-channel.js";
|
||||
import { type SessionSummary, resolveContextTokens } from "../../telemetry/stats.js";
|
||||
import {
|
||||
DEEPSEEK_CONTEXT_TOKENS,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
type SessionSummary,
|
||||
} from "../../telemetry/stats.js";
|
||||
import { defaultUsageLogPath } from "../../telemetry/usage.js";
|
||||
import { warmupTokenizer } from "../../tokenizer.js";
|
||||
import type { ToolRegistry } from "../../tools.js";
|
||||
@@ -106,8 +104,6 @@ import { registerSkillTools } from "../../tools/skills.js";
|
||||
import { formatSubagentResult, spawnSubagent } from "../../tools/subagent.js";
|
||||
import { webFetch } from "../../tools/web.js";
|
||||
import { openTranscriptFile } from "../../transcript/log.js";
|
||||
import type { WeixinChannel } from "../../weixin/channel.js";
|
||||
import { useWeixinChannel } from "../../weixin/use-weixin-channel.js";
|
||||
import { listKnownWorkspaces, rememberWorkspace } from "../../workspaces.js";
|
||||
import { openInExternalEditor } from "../edit/external-editor.js";
|
||||
import { dumpStartupProfile, markPhase } from "../startup-profile.js";
|
||||
@@ -124,7 +120,6 @@ import { ModelPicker } from "./ModelPicker.js";
|
||||
import { PathConfirm } from "./PathConfirm.js";
|
||||
import { PlanCheckpointConfirm } from "./PlanCheckpointConfirm.js";
|
||||
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
|
||||
import { PlanPanel } from "./PlanPanel.js";
|
||||
import { PlanRefineInput } from "./PlanRefineInput.js";
|
||||
import { PlanReviseConfirm, type ReviseChoice } from "./PlanReviseConfirm.js";
|
||||
import { PlanReviseEditor } from "./PlanReviseEditor.js";
|
||||
@@ -143,7 +138,7 @@ import type { PickerSnapshot, ViewerSnapshot } from "./dashboard/use-picker-broa
|
||||
import { useViewerBroadcast } from "./dashboard/use-picker-broadcast.js";
|
||||
import { formatEditResults, formatPendingPreview } from "./edit-history.js";
|
||||
import {
|
||||
buildEditToolBlocksForReview,
|
||||
buildEditToolBlocks,
|
||||
formatQueuedReviewToolResult,
|
||||
isReviewGatedEditTool,
|
||||
shouldApplyEditToolImmediately,
|
||||
@@ -180,8 +175,6 @@ import { ModeStatusBar } from "./layout/LiveRows.js";
|
||||
import { StaticCardStream } from "./layout/StaticCardStream.js";
|
||||
import { StatusRow } from "./layout/StatusRow.js";
|
||||
import type { StatusBarConfig } from "./layout/StatusRow.js";
|
||||
import { shouldEnterPlanModeForExplicitIntent } from "./lifecycle-explicit-intent.js";
|
||||
import { shouldSuggestPlanForEngineeringIntent } from "./lifecycle-plan-suggestion.js";
|
||||
import { formatLoopStatus } from "./loop.js";
|
||||
import { applyMcpAppend } from "./mcp-append.js";
|
||||
import { handleMcpBrowseSlash } from "./mcp-browse.js";
|
||||
@@ -206,7 +199,8 @@ import { InflightProvider } from "./state/inflight-context.js";
|
||||
import { AgentStoreProvider, useAgentState, useAgentStore } from "./state/provider.js";
|
||||
import { VerboseContext } from "./state/verbose-context.js";
|
||||
import { ThemeProvider } from "./theme/context.js";
|
||||
import { FG, SURFACE, type ThemeName, listThemeNames } from "./theme/tokens.js";
|
||||
import { listThemeNames } from "./theme/tokens.js";
|
||||
import { FG, type ThemeName } from "./theme/tokens.js";
|
||||
import { TickerProvider, useSlowTick } from "./ticker.js";
|
||||
import { handleTurnInterrupt } from "./turn-interrupt.js";
|
||||
import { codeUndoContextMessage, codeUndoInfo } from "./undo-context.js";
|
||||
@@ -313,16 +307,10 @@ export interface AppProps {
|
||||
startupInfoHints?: string[];
|
||||
/** Pre-created QQ channel (started before TUI mounts). */
|
||||
qqChannel?: QQChannel;
|
||||
telegramChannel?: TelegramChannel;
|
||||
weixinChannel?: WeixinChannel;
|
||||
/** Ref filled by App on mount so QQ messages flow into the TUI input queue. */
|
||||
qqSubmitRef?: { current: ((text: string) => void) | null };
|
||||
/** Ref filled by App on mount so QQ errors appear in the TUI log. */
|
||||
qqErrorRef?: { current: ((msg: string) => void) | null };
|
||||
telegramSubmitRef?: { current: ((text: string) => void) | null };
|
||||
telegramErrorRef?: { current: ((msg: string) => void) | null };
|
||||
weixinSubmitRef?: { current: ((text: string) => void) | null };
|
||||
weixinErrorRef?: { current: ((msg: string) => void) | null };
|
||||
/** Resolved chat-history scroll mode, computed by the launcher from config/env. */
|
||||
historyScrollMode?: ResolvedHistoryScrollMode;
|
||||
}
|
||||
@@ -471,14 +459,8 @@ function AppInner({
|
||||
onSwitchSession,
|
||||
startupInfoHints,
|
||||
qqChannel,
|
||||
telegramChannel,
|
||||
weixinChannel,
|
||||
qqSubmitRef,
|
||||
qqErrorRef,
|
||||
telegramSubmitRef,
|
||||
telegramErrorRef,
|
||||
weixinSubmitRef,
|
||||
weixinErrorRef,
|
||||
historyScrollMode,
|
||||
themeName,
|
||||
setThemeName,
|
||||
@@ -496,7 +478,9 @@ function AppInner({
|
||||
const sessionModel = useAgentState((s) => s.session.model);
|
||||
const sessionEffort = useAgentState((s) => s.status.reasoningEffort);
|
||||
const ctxTokens = useAgentState((s) => s.status.promptTokens);
|
||||
const ctxCap = useAgentState((s) => s.status.promptCap ?? resolveContextTokens(s.session.model));
|
||||
const ctxCap = useAgentState(
|
||||
(s) => s.status.promptCap ?? DEEPSEEK_CONTEXT_TOKENS[s.session.model] ?? DEFAULT_CONTEXT_TOKENS,
|
||||
);
|
||||
const sessionCostUsd = useAgentState((s) => s.status.sessionCost);
|
||||
const lastTurnCostUsd = useAgentState((s) => s.status.cost);
|
||||
const cacheHitRatio = useAgentState((s) => s.status.cacheHit);
|
||||
@@ -579,14 +563,10 @@ function AppInner({
|
||||
// reads from a ref populated AFTER useSessionInfo loads balance, so the
|
||||
// subagent-end cost suffix renders in the live wallet's symbol.
|
||||
const walletCurrencyRef = useRef<string | undefined>(undefined);
|
||||
const loopRef = useRef<CacheFirstLoop | null>(null);
|
||||
const { activities: subagentActivities, sinkRef: subagentSinkRef } = useSubagent({
|
||||
session,
|
||||
log,
|
||||
getWalletCurrency: () => walletCurrencyRef.current,
|
||||
// Fold subagent usage into the parent session's stats so the
|
||||
// stats panel reflects total cost including child loops. (#2008)
|
||||
onSubagentEnd: (model, usage) => loopRef.current?.stats.recordExternal(model, usage),
|
||||
});
|
||||
const { currentRootDir, setCurrentRootDir, currentRootDirRef } = useWorkspaceRoot(
|
||||
codeMode?.rootDir,
|
||||
@@ -635,7 +615,6 @@ function AppInner({
|
||||
mode: engineeringLifecycleBaseModeRef.current,
|
||||
});
|
||||
}
|
||||
const lifecyclePlanSuggestionSessionRef = useRef<string | null | undefined>(undefined);
|
||||
// Refs that mirror state for stable read-callbacks handed to the
|
||||
// embedded dashboard server. The server's `getXxx()` closures are
|
||||
// captured once at startDashboard time; without ref-mirrors the
|
||||
@@ -805,11 +784,14 @@ function AppInner({
|
||||
const noTakeoverOverlay =
|
||||
!pendingShell &&
|
||||
!pendingPath &&
|
||||
!pendingPlan &&
|
||||
!pendingReviseEditor &&
|
||||
!pendingSessionsPicker &&
|
||||
!pendingEditPicker &&
|
||||
!pendingWorkspacePicker &&
|
||||
!pendingCheckpointPicker &&
|
||||
!pendingMcpHub &&
|
||||
!stagedInput &&
|
||||
!pendingEditReview;
|
||||
// Plan-mode indicator —displayed in the StatsPanel, mirrored onto
|
||||
// the ToolRegistry so dispatch enforces read-only. Toggled via the
|
||||
@@ -913,14 +895,6 @@ function AppInner({
|
||||
// identify plans by intent rather than by filename.
|
||||
const planBodyRef = useRef<string | null>(null);
|
||||
const planSummaryRef = useRef<string | null>(null);
|
||||
const planPanelOpen = !!(
|
||||
pendingPlan ||
|
||||
stagedInput ||
|
||||
pendingCheckpoint ||
|
||||
pendingRevision ||
|
||||
pendingReviseEditor ||
|
||||
(planStepsRef.current && planStepsRef.current.length > 0 && !planMode)
|
||||
);
|
||||
// Wall-clock when the latest tool_start fired. Cleared when the
|
||||
// matching `tool` event arrives (or at turn end). Tools are
|
||||
// dispatched serially in the loop, so a single ref is enough —no
|
||||
@@ -959,12 +933,6 @@ function AppInner({
|
||||
cacheHitRatio: 0,
|
||||
lastPromptTokens: 0,
|
||||
lastTurnCostUsd: 0,
|
||||
totalCacheHitTokens: 0,
|
||||
totalCacheMissTokens: 0,
|
||||
lastCacheMissTokens: 0,
|
||||
lastToolSchemaTokens: 0,
|
||||
lastPrefixChanged: false,
|
||||
lastPrefixChangeReasons: [],
|
||||
});
|
||||
|
||||
const transcriptRef = useRef<WriteStream | null>(null);
|
||||
@@ -996,6 +964,7 @@ function AppInner({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loopRef = useRef<CacheFirstLoop | null>(null);
|
||||
// hookList + currentRootDir intentionally NOT in deps —they seed
|
||||
// the loop on first construction (loopRef guards a single
|
||||
// instantiation), and later edits flow in through the mutable
|
||||
@@ -1056,8 +1025,6 @@ function AppInner({
|
||||
hooks: hookList,
|
||||
hookCwd: currentRootDir,
|
||||
reasoningEffort: initialReasoningEffort ?? loadReasoningEffort(),
|
||||
maxOutputTokens: loadMaxOutputTokens(),
|
||||
maxIterPerTurn: loadMaxIterPerTurn(),
|
||||
rebuildSystem,
|
||||
});
|
||||
loopRef.current = l;
|
||||
@@ -1605,12 +1572,6 @@ function AppInner({
|
||||
log.pushInfo(t("ui.ephemeralSession"));
|
||||
} else if (loop.resumedMessageCount > 0) {
|
||||
log.pushInfo(t("ui.resumedSession", { name: session, count: loop.resumedMessageCount }));
|
||||
// Warn if system prompt changed since the session was last used — REASONIX.md
|
||||
// or global memory edits cause a cache miss on the first resumed turn (#2212).
|
||||
const savedFingerprint = loadSessionMeta(session).systemFingerprint;
|
||||
if (savedFingerprint && savedFingerprint !== hashSystemPrompt(system)) {
|
||||
log.pushWarning(t("ui.systemPromptChanged"), t("ui.systemPromptChangedDetail"));
|
||||
}
|
||||
} else {
|
||||
log.pushInfo(t("ui.newSession", { name: session }));
|
||||
}
|
||||
@@ -1693,7 +1654,7 @@ function AppInner({
|
||||
log.pushTip({ topic: tip.topic, sections: tip.sections, footer: tip.footer });
|
||||
markMouseClipboardHintShown();
|
||||
}
|
||||
}, [session, loop, codeMode, syncPendingCount, log, pendingEdits, startupInfoHints, system]);
|
||||
}, [session, loop, codeMode, syncPendingCount, log, pendingEdits, startupInfoHints]);
|
||||
|
||||
// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
|
||||
const quitProcess = useQuit(transcriptRef);
|
||||
@@ -2072,7 +2033,7 @@ function AppInner({
|
||||
// otherwise edit_file writes to the OLD root while read_file looks in
|
||||
// the NEW one, producing ENOENT on the next read of a just-edited file.
|
||||
const rootForEdit = currentRootDirRef.current;
|
||||
const blocks = await buildEditToolBlocksForReview(name, args, rootForEdit, loop.readTracker);
|
||||
const blocks = buildEditToolBlocks(name, args, rootForEdit);
|
||||
if (!blocks || blocks.length === 0) return null;
|
||||
|
||||
// Helper: apply the current block(s) + record into history + arm
|
||||
@@ -2085,8 +2046,6 @@ function AppInner({
|
||||
// after —ToolCard renders that with the same text. Pushing here
|
||||
// would produce "result shown twice".
|
||||
const applyNow = (): string => {
|
||||
const guard = prepareAutoGitRollbackForEditBlocks(rootForEdit, blocks, {});
|
||||
if (guard) return guard;
|
||||
const snaps = snapshotBeforeEdits(blocks, rootForEdit);
|
||||
const results = applyEditBlocks(blocks, rootForEdit);
|
||||
const good = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
@@ -2300,7 +2259,7 @@ function AppInner({
|
||||
// StatsPanel reads). `balance` comes from useSessionInfo via a
|
||||
// ref-mirror so this callback stays cheap.
|
||||
const s = loop.stats.summary();
|
||||
const ctxCap = resolveContextTokens(loop.model);
|
||||
const ctxCap = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
return {
|
||||
turns: s.turns,
|
||||
totalCostUsd: s.totalCostUsd,
|
||||
@@ -2749,74 +2708,11 @@ function AppInner({
|
||||
onChoiceResolveRef: handleChoiceResolveRef,
|
||||
});
|
||||
|
||||
const telegram = useTelegramChannel({
|
||||
codeMode: !!codeMode,
|
||||
initialChannel: telegramChannel,
|
||||
log,
|
||||
setQueuedSubmit,
|
||||
telegramSubmitRef,
|
||||
telegramErrorRef,
|
||||
sessionName: session,
|
||||
currentRootDir,
|
||||
pendingGateIdRef,
|
||||
completedStepIdsRef,
|
||||
planStepsRef,
|
||||
onCreateSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
|
||||
onSelectSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
|
||||
onModelPick: handleQQModelPick,
|
||||
onThemePick: handleQQThemePick,
|
||||
onShellConfirmRef: handleShellConfirmRef,
|
||||
onPathConfirmRef: handlePathConfirmRef,
|
||||
onPlanCancelRef: handlePlanCancelRef,
|
||||
onPlanFeedbackRef: handlePlanFeedbackRef,
|
||||
onCheckpointConfirmRef: handleCheckpointConfirmRef,
|
||||
onCheckpointReviseRef: handleCheckpointReviseSubmitRef,
|
||||
onPlanRevisionRef: handleReviseConfirmRef,
|
||||
onChoiceResolveRef: handleChoiceResolveRef,
|
||||
});
|
||||
|
||||
const weixin = useWeixinChannel({
|
||||
codeMode: !!codeMode,
|
||||
initialChannel: weixinChannel,
|
||||
log,
|
||||
setQueuedSubmit,
|
||||
weixinSubmitRef,
|
||||
weixinErrorRef,
|
||||
sessionName: session,
|
||||
currentRootDir,
|
||||
pendingGateIdRef,
|
||||
completedStepIdsRef,
|
||||
planStepsRef,
|
||||
onCreateSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
|
||||
onSelectSession: onSwitchSession ? (name) => onSwitchSession(name) : undefined,
|
||||
onModelPick: handleQQModelPick,
|
||||
onThemePick: handleQQThemePick,
|
||||
onShellConfirmRef: handleShellConfirmRef,
|
||||
onPathConfirmRef: handlePathConfirmRef,
|
||||
onPlanCancelRef: handlePlanCancelRef,
|
||||
onPlanFeedbackRef: handlePlanFeedbackRef,
|
||||
onCheckpointConfirmRef: handleCheckpointConfirmRef,
|
||||
onCheckpointReviseRef: handleCheckpointReviseSubmitRef,
|
||||
onPlanRevisionRef: handleReviseConfirmRef,
|
||||
onChoiceResolveRef: handleChoiceResolveRef,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (raw: string) => {
|
||||
const qqIncoming = qq.parseSubmit(raw);
|
||||
const telegramIncoming =
|
||||
qqIncoming?.handled || qqIncoming?.fromQQ ? null : telegram.parseSubmit(raw);
|
||||
const incoming =
|
||||
qqIncoming?.handled || qqIncoming?.fromQQ
|
||||
? qqIncoming
|
||||
: telegramIncoming?.handled || telegramIncoming?.fromTelegram
|
||||
? telegramIncoming
|
||||
: weixin.parseSubmit(raw);
|
||||
const incoming = qq.parseSubmit(raw);
|
||||
if (!incoming) return;
|
||||
let { text } = incoming;
|
||||
const fromQQ = "fromQQ" in incoming && incoming.fromQQ;
|
||||
const fromTelegram = "fromTelegram" in incoming && incoming.fromTelegram;
|
||||
const fromWeixin = "fromWeixin" in incoming && incoming.fromWeixin;
|
||||
let { text, fromQQ } = incoming;
|
||||
if (incoming.handled) {
|
||||
return;
|
||||
}
|
||||
@@ -3058,28 +2954,12 @@ function AppInner({
|
||||
disconnect: qq.disconnect,
|
||||
status: qq.status,
|
||||
},
|
||||
telegram: {
|
||||
connect: telegram.connect,
|
||||
disconnect: telegram.disconnect,
|
||||
status: telegram.status,
|
||||
},
|
||||
weixin: {
|
||||
connect: weixin.connect,
|
||||
disconnect: weixin.disconnect,
|
||||
status: weixin.status,
|
||||
},
|
||||
sessionId: session,
|
||||
getEngineeringLifecycleSnapshot: codeMode
|
||||
? () => engineeringLifecycleRef.current?.snapshot() ?? null
|
||||
: undefined,
|
||||
jobs: codeMode?.jobs,
|
||||
postInfo: fromQQ
|
||||
? qq.sendInfo
|
||||
: fromTelegram
|
||||
? telegram.sendInfo
|
||||
: fromWeixin
|
||||
? weixin.sendInfo
|
||||
: log.pushInfo,
|
||||
postInfo: fromQQ ? qq.sendInfo : log.pushInfo,
|
||||
postDoctor: (checks) => log.showDoctor(checks),
|
||||
postUsage: (args) => log.showUsageVerbose(args),
|
||||
postKeys: (args) =>
|
||||
@@ -3139,14 +3019,8 @@ function AppInner({
|
||||
generateSessionTitle: generateCurrentSessionTitle,
|
||||
});
|
||||
if (
|
||||
(fromQQ
|
||||
? qq
|
||||
: fromTelegram
|
||||
? telegram
|
||||
: fromWeixin
|
||||
? weixin
|
||||
: null
|
||||
)?.handleRemoteSlashResult({
|
||||
fromQQ &&
|
||||
qq.handleRemoteSlashResult({
|
||||
result,
|
||||
codeMode: !!codeMode,
|
||||
sessions: listSessionsForWorkspace(currentRootDir),
|
||||
@@ -3234,8 +3108,6 @@ function AppInner({
|
||||
text,
|
||||
});
|
||||
if (fromQQ && result.info) qq.sendText(result.info);
|
||||
if (fromTelegram && result.info) telegram.sendText(result.info);
|
||||
if (fromWeixin && result.info) weixin.sendText(result.info);
|
||||
if (outcome.kind === "resubmit") {
|
||||
text = outcome.text;
|
||||
} else {
|
||||
@@ -3262,26 +3134,6 @@ function AppInner({
|
||||
}
|
||||
|
||||
if (codeMode) {
|
||||
const explicitPlanIntent = shouldEnterPlanModeForExplicitIntent({
|
||||
text,
|
||||
codeMode: true,
|
||||
planMode,
|
||||
});
|
||||
if (explicitPlanIntent) {
|
||||
togglePlanMode(true, "explicit-intent");
|
||||
log.pushInfo(t("app.explicitPlanIntentArmed"));
|
||||
} else if (
|
||||
shouldSuggestPlanForEngineeringIntent({
|
||||
text,
|
||||
codeMode: true,
|
||||
planMode,
|
||||
lifecycleMode: engineeringLifecycleRef.current?.snapshot().mode ?? "off",
|
||||
alreadySuggested: lifecyclePlanSuggestionSessionRef.current === (session ?? null),
|
||||
})
|
||||
) {
|
||||
log.pushInfo(t("app.lifecyclePlanSuggestion"));
|
||||
lifecyclePlanSuggestionSessionRef.current = session ?? null;
|
||||
}
|
||||
const before = engineeringLifecycleRef.current?.snapshot().state;
|
||||
engineeringLifecycleRef.current?.observeUserPrompt(text);
|
||||
const after = engineeringLifecycleRef.current?.snapshot().state;
|
||||
@@ -3306,9 +3158,6 @@ function AppInner({
|
||||
if (!existing.summary) patch.summary = text.replace(/\s+/g, " ").slice(0, 80);
|
||||
if (!existing.branch) patch.branch = detectGitBranch(currentRootDir);
|
||||
if (!existing.workspace) patch.workspace = currentRootDir;
|
||||
// Always refresh the system-prompt fingerprint so the next resume can
|
||||
// detect REASONIX.md / memory changes that happened since this turn.
|
||||
patch.systemFingerprint = hashSystemPrompt(system);
|
||||
if (Object.keys(patch).length > 0) patchSessionMeta(session, patch);
|
||||
}
|
||||
|
||||
@@ -3333,8 +3182,6 @@ function AppInner({
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
qq.noteTurnFromQQ(fromQQ);
|
||||
telegram.noteTurnFromTelegram(fromTelegram);
|
||||
weixin.noteTurnFromWeixin(fromWeixin);
|
||||
abortedThisTurn.current = false;
|
||||
// Seal the in-progress history entry so this turn's edits open
|
||||
// a new one —prior turns are preserved intact for /history and
|
||||
@@ -3496,7 +3343,7 @@ function AppInner({
|
||||
armUndoBanner,
|
||||
pendingEdits,
|
||||
syncPendingCount,
|
||||
ctxMax: resolveContextTokens(loop.model),
|
||||
ctxMax: DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS,
|
||||
});
|
||||
if (session) {
|
||||
const m = loadSessionMeta(session);
|
||||
@@ -3590,8 +3437,7 @@ function AppInner({
|
||||
payload: {
|
||||
event: "Stop",
|
||||
cwd: currentRootDir,
|
||||
lastAssistantText,
|
||||
last_assistant_message: lastAssistantText,
|
||||
lastAssistantText: streamRef.text,
|
||||
turn: loop.stats.summary().turns,
|
||||
},
|
||||
});
|
||||
@@ -3601,8 +3447,6 @@ function AppInner({
|
||||
}
|
||||
}
|
||||
qq.maybeSendFinalReply(lastAssistantText);
|
||||
telegram.maybeSendFinalReply(lastAssistantText);
|
||||
weixin.maybeSendFinalReply(lastAssistantText);
|
||||
} finally {
|
||||
flush();
|
||||
// Esc aborted the turn —close any in-flight cards (streaming /
|
||||
@@ -3618,8 +3462,6 @@ function AppInner({
|
||||
setBusy(false);
|
||||
submittingRef.current = false;
|
||||
qq.clearTurnReply();
|
||||
telegram.clearTurnReply();
|
||||
weixin.clearTurnReply();
|
||||
// Refresh balance lazily —don't block the return.
|
||||
refreshBalance();
|
||||
}
|
||||
@@ -3675,8 +3517,6 @@ function AppInner({
|
||||
startLoop,
|
||||
getLoopStatus,
|
||||
qq,
|
||||
telegram,
|
||||
weixin,
|
||||
isLoopActive,
|
||||
isLoopFiring,
|
||||
clearFiringFlag,
|
||||
@@ -3696,7 +3536,6 @@ function AppInner({
|
||||
liveMcpServers,
|
||||
generateCurrentSessionTitle,
|
||||
switchWorkspaceRoot,
|
||||
system,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3778,10 +3617,8 @@ function AppInner({
|
||||
setStagedCheckpointRevise(null);
|
||||
pendingGateIdRef.current = null;
|
||||
qq.resetInteractions();
|
||||
telegram.resetInteractions();
|
||||
weixin.resetInteractions();
|
||||
pauseGate.cancelAll();
|
||||
}, [qq, telegram, weixin]);
|
||||
}, [qq]);
|
||||
|
||||
// Drain queued submits after the in-flight turn tears down.
|
||||
// QQ pause-gate replies are the one exception: they need to re-enter
|
||||
@@ -3789,16 +3626,13 @@ function AppInner({
|
||||
// pauseGate.ask() can be resolved from the remote reply.
|
||||
useEffect(() => {
|
||||
if (queuedSubmit === null) return;
|
||||
const canBypassBusy =
|
||||
qq.canBypassBusy(queuedSubmit) ||
|
||||
telegram.canBypassBusy(queuedSubmit) ||
|
||||
weixin.canBypassBusy(queuedSubmit);
|
||||
const canBypassBusy = qq.canBypassBusy(queuedSubmit);
|
||||
if ((!busy && !submittingRef.current) || canBypassBusy) {
|
||||
const text = queuedSubmit;
|
||||
setQueuedSubmit(null);
|
||||
void handleSubmit(text);
|
||||
}
|
||||
}, [busy, queuedSubmit, handleSubmit, qq, telegram, weixin]);
|
||||
}, [busy, queuedSubmit, handleSubmit, qq]);
|
||||
|
||||
/**
|
||||
* PlanConfirm callback. Three outcomes, all ending with a synthetic
|
||||
@@ -4060,8 +3894,6 @@ function AppInner({
|
||||
pendingGateIdRef.current = request.id;
|
||||
|
||||
qq.handlePauseRequest(request.kind, payload);
|
||||
telegram.handlePauseRequest(request.kind, payload);
|
||||
weixin.handlePauseRequest(request.kind, payload);
|
||||
|
||||
switch (request.kind) {
|
||||
case "run_command":
|
||||
@@ -4438,12 +4270,8 @@ function AppInner({
|
||||
<>
|
||||
<TickerProvider disabled={tickerSuspended}>
|
||||
<InflightProvider inflight={loop.inflight}>
|
||||
<Box flexDirection="row" backgroundColor={SURFACE.bg}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexGrow={planPanelOpen ? 0 : 1}
|
||||
width={planPanelOpen ? "35%" : undefined}
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<LiveExpandContext.Provider value={liveExpand}>
|
||||
<VerboseContext.Provider value={verboseMode}>
|
||||
@@ -4472,7 +4300,6 @@ function AppInner({
|
||||
) : null}
|
||||
<LiveActivityArea
|
||||
noTakeoverOverlay={noTakeoverOverlay}
|
||||
suppressPlanLiveRow={planPanelOpen}
|
||||
ongoingTool={ongoingTool}
|
||||
toolProgress={toolProgress}
|
||||
subagentActivities={subagentActivities}
|
||||
@@ -4500,46 +4327,12 @@ function AppInner({
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{planPanelOpen ? (
|
||||
<ComposerArea
|
||||
editMode={editMode}
|
||||
pendingCount={pendingCount}
|
||||
modeFlash={modeFlash}
|
||||
planMode={planMode}
|
||||
undoArmed={!!undoBanner || hasUndoable()}
|
||||
jobs={codeMode ? codeMode.jobs : undefined}
|
||||
activeLoop={activeLoop}
|
||||
statusBar={statusBar}
|
||||
showShortcuts={pendingShortcuts}
|
||||
mode={
|
||||
editMode === "yolo"
|
||||
? t("statsPanel.modeYolo")
|
||||
: editMode === "auto"
|
||||
? t("statsPanel.modeAuto")
|
||||
: editMode === "review"
|
||||
? t("statsPanel.modeReview")
|
||||
: editMode
|
||||
}
|
||||
model={`${sessionModel} \u00b7 ${sessionEffort ?? loop.reasoningEffort}`}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
busy={busy}
|
||||
steerBusy={busy}
|
||||
onSubmit={handleSubmit}
|
||||
onHistoryPrev={handleHistoryPrev}
|
||||
onHistoryNext={handleHistoryNext}
|
||||
onOpenExternalEditor={handleOpenExternalEditor}
|
||||
onCursorChange={setComposerCursor}
|
||||
isHistoryMode={isHistoryMode}
|
||||
slashMatches={slashMatches}
|
||||
slashSelected={slashSelected}
|
||||
slashGroupMode={slashGroupMode}
|
||||
slashAdvancedHidden={slashAdvancedHidden}
|
||||
atState={atState}
|
||||
atSelected={atSelected}
|
||||
slashArgContext={slashArgContext}
|
||||
slashArgMatches={slashArgMatches}
|
||||
slashArgSelected={slashArgSelected}
|
||||
{stagedInput ? (
|
||||
<PlanRefineInput
|
||||
mode={stagedInput.mode}
|
||||
questions={stagedInput.questions}
|
||||
onSubmit={handleStagedInputSubmit}
|
||||
onCancel={handleStagedInputCancel}
|
||||
/>
|
||||
) : stagedChoiceCustom ? (
|
||||
<PlanRefineInput
|
||||
@@ -4560,6 +4353,26 @@ function AppInner({
|
||||
allowCustom={pendingChoice.allowCustom}
|
||||
onChoose={stableHandleChoiceConfirm}
|
||||
/>
|
||||
) : pendingRevision ? (
|
||||
<PlanReviseConfirm
|
||||
reason={pendingRevision.reason}
|
||||
oldRemaining={(planStepsRef.current ?? []).filter(
|
||||
(s) => !completedStepIdsRef.current.has(s.id),
|
||||
)}
|
||||
newRemaining={pendingRevision.remainingSteps}
|
||||
summary={pendingRevision.summary}
|
||||
onChoose={stableHandleReviseConfirm}
|
||||
/>
|
||||
) : pendingCheckpoint ? (
|
||||
<PlanCheckpointConfirm
|
||||
stepId={pendingCheckpoint.stepId}
|
||||
title={pendingCheckpoint.title}
|
||||
completed={pendingCheckpoint.completed}
|
||||
total={pendingCheckpoint.total}
|
||||
steps={planStepsRef.current ?? undefined}
|
||||
completedStepIds={completedStepIdsRef.current}
|
||||
onChoose={stableHandleCheckpointConfirm}
|
||||
/>
|
||||
) : pendingCheckpointPicker ? (
|
||||
<CheckpointPicker
|
||||
checkpoints={checkpointPickerList}
|
||||
@@ -4756,6 +4569,32 @@ function AppInner({
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : pendingPlan ? (
|
||||
<PlanConfirm
|
||||
plan={pendingPlan}
|
||||
steps={planStepsRef.current ?? undefined}
|
||||
summary={planSummaryRef.current ?? undefined}
|
||||
onChoose={stableHandlePlanConfirm}
|
||||
projectRoot={currentRootDir}
|
||||
/>
|
||||
) : pendingReviseEditor ? (
|
||||
<PlanReviseEditor
|
||||
steps={planStepsRef.current ?? []}
|
||||
completedStepIds={completedStepIdsRef.current}
|
||||
onAccept={(revised, skippedIds) => {
|
||||
planStepsRef.current = revised;
|
||||
for (const id of skippedIds) completedStepIdsRef.current.add(id);
|
||||
persistPlanState();
|
||||
const planText = pendingReviseEditor;
|
||||
setPendingReviseEditor(null);
|
||||
setPendingPlan(planText);
|
||||
}}
|
||||
onCancel={() => {
|
||||
const planText = pendingReviseEditor;
|
||||
setPendingReviseEditor(null);
|
||||
setPendingPlan(planText);
|
||||
}}
|
||||
/>
|
||||
) : pendingShell ? (
|
||||
<ShellConfirm
|
||||
prompt={toApprovalPrompt({
|
||||
@@ -4849,27 +4688,6 @@ function AppInner({
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{planPanelOpen ? (
|
||||
<PlanPanel
|
||||
planBody={planBodyRef.current}
|
||||
planSummary={planSummaryRef.current}
|
||||
planSteps={planStepsRef.current}
|
||||
completedStepIds={completedStepIdsRef.current}
|
||||
pendingPlan={pendingPlan}
|
||||
stagedInput={stagedInput}
|
||||
pendingCheckpoint={pendingCheckpoint}
|
||||
pendingRevision={pendingRevision}
|
||||
pendingReviseEditor={pendingReviseEditor}
|
||||
isExecuting={
|
||||
planStepsRef.current !== null && planStepsRef.current.length > 0 && !planMode
|
||||
}
|
||||
onPlanConfirm={stableHandlePlanConfirm}
|
||||
onStagedInputSubmit={handleStagedInputSubmit}
|
||||
onStagedInputCancel={handleStagedInputCancel}
|
||||
onCheckpointChoose={stableHandleCheckpointConfirm}
|
||||
onReviseConfirm={stableHandleReviseConfirm}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</InflightProvider>
|
||||
</TickerProvider>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import { GLYPH, useColor } from "./theme.js";
|
||||
import { FG, SURFACE } from "./theme/tokens.js";
|
||||
import { SURFACE } from "./theme/tokens.js";
|
||||
import type { AtPickerEntry, AtPickerState } from "./useCompletionPickers.js";
|
||||
|
||||
export interface AtMentionSuggestionsProps {
|
||||
@@ -42,7 +42,7 @@ export function AtMentionSuggestions({
|
||||
isSelected={windowStart + i === selectedIndex}
|
||||
/>
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text color={FG.faint}>{` ↓ ${hiddenBelow} below`}</Text> : null}
|
||||
{hiddenBelow > 0 ? <Text dim>{` ↓ ${hiddenBelow} below`}</Text> : null}
|
||||
<FooterRow isBrowse={isBrowse} hasFolder={shown.some((e) => e.isDir)} />
|
||||
</Box>
|
||||
);
|
||||
@@ -70,8 +70,8 @@ function HeaderRow({
|
||||
return (
|
||||
<Box>
|
||||
{lead}
|
||||
<Text color={FG.faint}>{`${where} ${counter}`}</Text>
|
||||
{hiddenAbove > 0 ? <Text color={FG.faint}>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
<Text dim>{`${where} ${counter}`}</Text>
|
||||
{hiddenAbove > 0 ? <Text dim>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -81,8 +81,8 @@ function HeaderRow({
|
||||
return (
|
||||
<Box>
|
||||
{lead}
|
||||
<Text color={FG.faint}>{status}</Text>
|
||||
{hiddenAbove > 0 ? <Text color={FG.faint}>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
<Text dim>{status}</Text>
|
||||
{hiddenAbove > 0 ? <Text dim>{` ↑ ${hiddenAbove} above`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function EmptyRow({ state, color }: { state: AtPickerState; color: ReturnType<ty
|
||||
if (state.searching) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={FG.faint}>{t("atMentions.scanning")}</Text>
|
||||
<Text dim>{t("atMentions.scanning")}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -131,7 +131,7 @@ function EntryRow({ entry, isSelected }: { entry: AtPickerEntry; isSelected: boo
|
||||
<Text color={labelColor} bold={isSelected}>
|
||||
{labelText.padEnd(20)}
|
||||
</Text>
|
||||
{entry.dirSuffix ? <Text color={FG.faint}>{` ${entry.dirSuffix}`}</Text> : null}
|
||||
{entry.dirSuffix ? <Text dim>{` ${entry.dirSuffix}`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +140,7 @@ function FooterRow({ isBrowse, hasFolder }: { isBrowse: boolean; hasFolder: bool
|
||||
const hintKey = isBrowse && hasFolder ? "atMentions.footerBrowse" : "atMentions.footerInsert";
|
||||
return (
|
||||
<Box marginTop={0}>
|
||||
<Text color={FG.faint}>{` ${t(hintKey)}`}</Text>
|
||||
<Text dim>{` ${t(hintKey)}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
|
||||
import { StatusRow } from "./layout/StatusRow.js";
|
||||
import { formatLoopStatus } from "./loop.js";
|
||||
import { SURFACE } from "./theme/tokens.js";
|
||||
import { useSlowTick } from "./ticker.js";
|
||||
|
||||
import type { StatusBarConfig } from "./layout/StatusRow.js";
|
||||
@@ -111,12 +110,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
|
||||
}) => {
|
||||
useRenderTrace("ComposerArea");
|
||||
const inputArea = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
flexWrap="nowrap"
|
||||
backgroundColor={SURFACE.bgInput}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={0} flexWrap="nowrap">
|
||||
<Box flexDirection="column" flexShrink={0} flexWrap="nowrap">
|
||||
{slashMatches !== null ? (
|
||||
<SlashSuggestions
|
||||
|
||||
+11
-12
@@ -19,7 +19,6 @@ import {
|
||||
findPrevDivergence,
|
||||
} from "../../transcript/diff.js";
|
||||
import { RecordView } from "./RecordView.js";
|
||||
import { FG } from "./theme/tokens.js";
|
||||
|
||||
export interface DiffAppProps {
|
||||
report: DiffReport;
|
||||
@@ -87,7 +86,7 @@ export function DiffApp({ report }: DiffAppProps) {
|
||||
) : null}
|
||||
|
||||
<Box marginTop={1} paddingX={1} borderStyle="single" borderColor="ansi:blackBright">
|
||||
<Text color={FG.faint}>
|
||||
<Text dim>
|
||||
<Text bold>j</Text>/<Text bold>\u2193</Text> next \u00b7 <Text bold>k</Text>/
|
||||
<Text bold>\u2191</Text> prev \u00b7 <Text bold>n</Text> next-diverge \u00b7{" "}
|
||||
<Text bold>N</Text>/<Text bold>p</Text> prev-diverge \u00b7 <Text bold>g</Text>/
|
||||
@@ -130,19 +129,19 @@ function DiffHeader({ report }: { report: DiffReport }) {
|
||||
<Text color="ansi:cyan" bold>
|
||||
{t("diffApp.title")}
|
||||
</Text>
|
||||
<Text color={FG.faint}> \u00b7 A=</Text>
|
||||
<Text dim> \u00b7 A=</Text>
|
||||
<Text color="ansi:blue">{a.label}</Text>
|
||||
<Text color={FG.faint}> vs B=</Text>
|
||||
<Text dim> vs B=</Text>
|
||||
<Text color="ansi:magenta">{b.label}</Text>
|
||||
</Text>
|
||||
<Text color={FG.faint}>{t("diffApp.turnsAligned", { count: report.pairs.length })}</Text>
|
||||
<Text dim>{t("diffApp.turnsAligned", { count: report.pairs.length })}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} gap={3}>
|
||||
<Text>
|
||||
<Text color={FG.faint}>cache </Text>
|
||||
<Text dim>cache </Text>
|
||||
<Text>{(a.stats.cacheHitRatio * 100).toFixed(1)}%</Text>
|
||||
<Text color={FG.faint}> → </Text>
|
||||
<Text dim> → </Text>
|
||||
<Text>{(b.stats.cacheHitRatio * 100).toFixed(1)}%</Text>
|
||||
<Text color={cacheDelta >= 0 ? "ansi:green" : "ansi:red"} bold>
|
||||
{" "}
|
||||
@@ -151,9 +150,9 @@ function DiffHeader({ report }: { report: DiffReport }) {
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color={FG.faint}>cost </Text>
|
||||
<Text dim>cost </Text>
|
||||
<Text>${a.stats.totalCostUsd.toFixed(6)}</Text>
|
||||
<Text color={FG.faint}> → </Text>
|
||||
<Text dim> → </Text>
|
||||
<Text>${b.stats.totalCostUsd.toFixed(6)}</Text>
|
||||
<Text color={costDelta <= 0 ? "ansi:green" : "ansi:red"} bold>
|
||||
{" "}
|
||||
@@ -162,7 +161,7 @@ function DiffHeader({ report }: { report: DiffReport }) {
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color={FG.faint}>model calls </Text>
|
||||
<Text dim>model calls </Text>
|
||||
<Text>
|
||||
{a.stats.turns} → {b.stats.turns}
|
||||
</Text>
|
||||
@@ -171,7 +170,7 @@ function DiffHeader({ report }: { report: DiffReport }) {
|
||||
|
||||
{prefixLine ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint} italic>
|
||||
<Text dim italic>
|
||||
{prefixLine}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -202,7 +201,7 @@ function Pane({
|
||||
</Text>
|
||||
{records.length === 0 ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint} italic>
|
||||
<Text dim italic>
|
||||
{t("diffApp.paneEmpty")}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DenyContextInput } from "./DenyContextInput.js";
|
||||
import { SplitDiff } from "./SplitDiff.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { FG } from "./theme/tokens.js";
|
||||
|
||||
export type EditReviewChoice = "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto";
|
||||
|
||||
@@ -134,7 +133,7 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
|
||||
footerHint={t("editConfirm.footer")}
|
||||
>
|
||||
{hiddenAbove > 0 ? (
|
||||
<Text color={FG.faint}>
|
||||
<Text dim>
|
||||
{t(hiddenAbove === 1 ? "editConfirm.linesAbove" : "editConfirm.linesAbovePlural", {
|
||||
count: hiddenAbove,
|
||||
})}
|
||||
@@ -149,10 +148,10 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
|
||||
<Text color="#bef0c8" backgroundColor="#0c2718">
|
||||
{t("editConfirm.newLabel")}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{t("editConfirm.sideBySide")}</Text>
|
||||
<Text dim>{t("editConfirm.sideBySide")}</Text>
|
||||
</Box>
|
||||
{hiddenBelow > 0 ? (
|
||||
<Text color={FG.faint}>
|
||||
<Text dim>
|
||||
{t(hiddenBelow === 1 ? "editConfirm.linesBelow" : "editConfirm.linesBelowPlural", {
|
||||
count: hiddenBelow,
|
||||
})}
|
||||
|
||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user