Merge main into Baidu search provider
This commit is contained in:
@@ -38,6 +38,8 @@ jobs:
|
||||
components: rustfmt
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Check provider registry drift
|
||||
run: python3 scripts/check-provider-registry.py
|
||||
- name: Linux clippy location
|
||||
run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches."
|
||||
|
||||
|
||||
Generated
+15
-2
@@ -847,6 +847,7 @@ dependencies = [
|
||||
"codewhale-config",
|
||||
"codewhale-execpolicy",
|
||||
"codewhale-mcp",
|
||||
"codewhale-release",
|
||||
"codewhale-secrets",
|
||||
"codewhale-state",
|
||||
"dirs",
|
||||
@@ -931,6 +932,17 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-release"
|
||||
version = "0.8.46"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-secrets"
|
||||
version = "0.8.46"
|
||||
@@ -983,6 +995,7 @@ dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codewhale-config",
|
||||
"codewhale-release",
|
||||
"codewhale-secrets",
|
||||
"codewhale-tools",
|
||||
"colored",
|
||||
@@ -4912,9 +4925,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/hooks",
|
||||
"crates/mcp",
|
||||
"crates/protocol",
|
||||
"crates/release",
|
||||
"crates/secrets",
|
||||
"crates/state",
|
||||
"crates/tools",
|
||||
|
||||
+9
-2
@@ -4,6 +4,8 @@
|
||||
|
||||
[English README](README.md)
|
||||
[简体中文 README](README.zh-CN.md)
|
||||
[Tiếng Việt README](README.vi.md)
|
||||
|
||||
|
||||
## インストール
|
||||
|
||||
@@ -214,6 +216,10 @@ codewhale --provider wanjie-ark --model deepseek-reasoner
|
||||
codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY"
|
||||
codewhale --provider openrouter --model deepseek/deepseek-v4-pro
|
||||
|
||||
# Xiaomi MiMo
|
||||
codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY"
|
||||
codewhale --provider xiaomi-mimo --model mimo-v2.5-pro
|
||||
|
||||
# Novita
|
||||
codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY"
|
||||
codewhale --provider novita --model deepseek/deepseek-v4-pro
|
||||
@@ -319,15 +325,16 @@ codewhale update # バイナリ更新の確認
|
||||
| `DEEPSEEK_HTTP_HEADERS` | 任意のモデルリクエストヘッダー |
|
||||
| `DEEPSEEK_MODEL` | デフォルトモデル |
|
||||
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | ストリームのアイドルタイムアウト秒数 |
|
||||
| `DEEPSEEK_PROVIDER` | `codewhale`(デフォルト)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` |
|
||||
| `DEEPSEEK_PROVIDER` | `codewhale`(デフォルト)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`xiaomi-mimo`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` |
|
||||
| `DEEPSEEK_PROFILE` | 設定プロファイル名 |
|
||||
| `DEEPSEEK_MEMORY` | `on` に設定するとユーザーメモリを有効化 |
|
||||
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 信頼できるネットワークで非ローカル `http://` API ベース URL を許可 |
|
||||
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | プロバイダー認証 |
|
||||
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | プロバイダー認証 |
|
||||
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | 汎用 OpenAI 互換エンドポイントとモデル ID |
|
||||
| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud エンドポイントとモデル上書き |
|
||||
| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark エンドポイントとモデル上書き |
|
||||
| `OPENROUTER_BASE_URL` | OpenRouter エンドポイント上書き |
|
||||
| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo エンドポイントとモデル上書き |
|
||||
| `NOVITA_BASE_URL` | Novita エンドポイント上書き |
|
||||
| `FIREWORKS_BASE_URL` | Fireworks エンドポイント上書き |
|
||||
| `SGLANG_BASE_URL` | セルフホスト SGLang のエンドポイント |
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
[简体中文 README](README.zh-CN.md)
|
||||
[日本語 README](README.ja-JP.md)
|
||||
[Tiếng Việt README](README.vi.md)
|
||||
|
||||
|
||||
## Install
|
||||
|
||||
@@ -140,10 +142,10 @@ transcripts stay behind bounded handles through `agent_eval`. See
|
||||
[docs/SUBAGENTS.md](docs/SUBAGENTS.md).
|
||||
|
||||
The rest of the surface: LSP diagnostics after every edit (rust-analyzer,
|
||||
pyright, typescript-language-server, gopls, clangd), RLM sessions for
|
||||
batched analysis, MCP protocol, HTTP/SSE runtime API, persistent task
|
||||
queue, ACP adapter for Zed, SWE-bench export, and live cost tracking with
|
||||
cache hit/miss breakdowns.
|
||||
pyright, typescript-language-server, gopls, clangd, jdtls,
|
||||
vue-language-server), RLM sessions for batched analysis, MCP protocol,
|
||||
HTTP/SSE runtime API, persistent task queue, ACP adapter for Zed,
|
||||
SWE-bench export, and live cost tracking with cache hit/miss breakdowns.
|
||||
|
||||
---
|
||||
|
||||
@@ -311,6 +313,10 @@ codewhale --provider wanjie-ark --model deepseek-reasoner
|
||||
codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY"
|
||||
codewhale --provider openrouter --model deepseek/deepseek-v4-pro
|
||||
|
||||
# Xiaomi MiMo
|
||||
codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY"
|
||||
codewhale --provider xiaomi-mimo --model mimo-v2.5-pro
|
||||
|
||||
# Novita
|
||||
codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY"
|
||||
codewhale --provider novita --model deepseek/deepseek-v4-pro
|
||||
@@ -323,6 +329,11 @@ codewhale --provider fireworks --model deepseek-v4-pro
|
||||
codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"
|
||||
OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5
|
||||
|
||||
# Custom DeepSeek-compatible endpoint
|
||||
DEEPSEEK_BASE_URL="https://your-provider.example/v1" \
|
||||
DEEPSEEK_MODEL="deepseek-ai/DeepSeek-V4-Pro" \
|
||||
codewhale --provider deepseek
|
||||
|
||||
# Self-hosted SGLang
|
||||
SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash
|
||||
|
||||
@@ -468,6 +479,12 @@ Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md).
|
||||
|
||||
User config: `~/.codewhale/config.toml` (legacy `~/.deepseek/config.toml` fallback). Project overlay: `<workspace>/.codewhale/config.toml` (legacy `<workspace>/.deepseek/config.toml`) (denied: `api_key`, `base_url`, `provider`, `mcp_config_path`). [config.example.toml](config.example.toml) has every option.
|
||||
|
||||
Custom DeepSeek-compatible endpoints usually do not need a new provider. Keep
|
||||
`provider = "deepseek"` and set `[providers.deepseek].base_url` / `model`, or
|
||||
use `provider = "openai"` for generic OpenAI-compatible gateways. Keep
|
||||
`provider`, `api_key`, and `base_url` in user config or environment variables;
|
||||
project overlays cannot set them.
|
||||
|
||||
Key environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
@@ -477,15 +494,16 @@ Key environment variables:
|
||||
| `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` |
|
||||
| `DEEPSEEK_MODEL` | Default model |
|
||||
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` |
|
||||
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` |
|
||||
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` |
|
||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||
| `DEEPSEEK_MEMORY` | Set to `on` to enable user memory |
|
||||
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks |
|
||||
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth |
|
||||
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth |
|
||||
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID |
|
||||
| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override |
|
||||
| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override |
|
||||
| `OPENROUTER_BASE_URL` | OpenRouter endpoint override |
|
||||
| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo endpoint and model override |
|
||||
| `NOVITA_BASE_URL` | Novita endpoint override |
|
||||
| `FIREWORKS_BASE_URL` | Fireworks endpoint override |
|
||||
| `SGLANG_BASE_URL` | Self-hosted SGLang endpoint |
|
||||
@@ -553,8 +571,10 @@ without recreating skills the user deliberately deleted.
|
||||
|
||||
| Doc | Topic |
|
||||
|---|---|
|
||||
| [GUIDE.md](docs/GUIDE.md) | First-run user guide |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals |
|
||||
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
|
||||
| [PROVIDERS.md](docs/PROVIDERS.md) | Provider IDs, auth, model defaults, and capability metadata |
|
||||
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
|
||||
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
|
||||
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
|
||||
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
# 🐳 CodeWhale
|
||||
|
||||
> **Agent lập trình gốc terminal dành cho DeepSeek V4. Chương trình chạy từ lệnh `codewhale`, hỗ trợ stream các khối suy nghĩ (reasoning blocks), chỉnh sửa workspace cục bộ thông qua các lớp phê duyệt, và đi kèm chế độ tự động để tự chọn mô hình cũng như mức độ suy nghĩ phù hợp cho mỗi lượt.**
|
||||
|
||||
[English README](README.md)
|
||||
[简体中文 README](README.zh-CN.md)
|
||||
[日本語 README](README.ja-JP.md)
|
||||
|
||||
## Cài đặt
|
||||
|
||||
`codewhale` được cài đặt dưới dạng một cặp binary tự chạy bằng Rust đồng bộ với nhau:
|
||||
Lệnh điều phối `codewhale` (dispatcher) và môi trường chạy giao diện `codewhale-tui` (runtime) do nó khởi chạy để thực hiện các phiên làm việc tương tác. Các trình quản lý gói như npm, Homebrew, và Docker sẽ tự động cài đặt cả hai cho bạn; đối với Cargo hoặc cài đặt thủ công, bạn phải đặt cả hai tệp binary này trong cùng một thư mục (thông thường là một thư mục nằm trong biến môi trường `PATH` của bạn). Gói npm chỉ là một trình cài đặt/bao bọc (wrapper) cho các tệp binary phát hành này; agent không chạy trên môi trường Node.js.
|
||||
|
||||
```bash
|
||||
# 1. npm — dễ nhất nếu bạn đã cài đặt Node. Gói này sẽ tự động tải các
|
||||
# binary Rust dựng sẵn tương ứng từ GitHub Releases.
|
||||
npm install -g codewhale
|
||||
|
||||
# 2. Cargo — không cần Node. Yêu cầu phiên bản Rust từ 1.88 trở lên (các crate sử dụng
|
||||
# phiên bản Rust edition 2024; các toolchain cũ hơn sẽ báo lỗi "feature `edition2024` is
|
||||
# required"). Hãy chạy lệnh `rustup update` trước, hoặc sử dụng các cách cài đặt không qua Cargo ở dưới.
|
||||
cargo install codewhale-cli --locked # cài đặt `codewhale` (điểm truy cập CLI chính)
|
||||
cargo install codewhale-tui --locked # cài đặt `codewhale-tui` (giao diện TUI)
|
||||
|
||||
# 3. Homebrew — trình quản lý gói dành cho macOS.
|
||||
# Tên tap/formula là tên cũ (legacy); nó sẽ cài đặt cả codewhale và codewhale-tui.
|
||||
brew tap Hmbown/deepseek-tui
|
||||
brew install deepseek-tui
|
||||
|
||||
# 4. Tải xuống trực tiếp — các gói lưu trữ theo nền tảng từ GitHub Releases.
|
||||
# https://github.com/Hmbown/CodeWhale/releases
|
||||
# Gói nén bao gồm cả codewhale và codewhale-tui cùng một tập lệnh cài đặt.
|
||||
# Các binary riêng lẻ cũng được đính kèm cho các tập lệnh; hãy giữ cặp này ở cùng một nơi.
|
||||
|
||||
# 5. Docker — hình ảnh phát hành dựng sẵn.
|
||||
docker volume create codewhale-home
|
||||
docker run --rm -it \
|
||||
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
|
||||
-v codewhale-home:/home/codewhale/.codewhale \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
ghcr.io/hmbown/codewhale:latest
|
||||
```
|
||||
|
||||
> Tại Trung Quốc đại lục, bạn có thể tăng tốc độ tải qua npm bằng tham số
|
||||
> `--registry=https://registry.npmmirror.com`, hoặc sử dụng
|
||||
> [Cargo mirror](#china--cai-dat-than-thien-qua-mirror) bên dưới.
|
||||
>
|
||||
> An toàn tải xuống: Các binary phát hành chính thức chỉ nằm tại
|
||||
> `https://github.com/Hmbown/CodeWhale/releases`. Nếu tải thủ công,
|
||||
> vui lòng xác minh mã băm SHA-256 manifest và tránh các kho lưu trữ giả mạo hoặc các
|
||||
> trang web mirror trên kết quả tìm kiếm. Xem [an toàn tải xuống và mã xác thực](docs/INSTALL.md#2-download-safety-and-checksums).
|
||||
|
||||
Đã cài đặt từ trước? Sử dụng lệnh cập nhật tương ứng với cách bạn đã cài đặt:
|
||||
|
||||
```bash
|
||||
codewhale update # trình cập nhật binary phát hành trực tiếp
|
||||
npm install -g codewhale@latest # thông qua trình bao bọc npm
|
||||
brew update && brew upgrade deepseek-tui
|
||||
cargo install codewhale-cli --locked --force
|
||||
cargo install codewhale-tui --locked --force
|
||||
```
|
||||
|
||||
[](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/codewhale)
|
||||
[](https://crates.io/crates/codewhale-cli)
|
||||
[Mục lục dự án DeepWiki](https://deepwiki.com/Hmbown/CodeWhale)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## CodeWhale là gì?
|
||||
|
||||
Mô hình AI chỉ trả lời câu hỏi. Agent hoàn thành một nhiệm vụ. Sự khác biệt nằm ở
|
||||
**khung ràng buộc (harness)** — một hệ thống các quy tắc, bằng chứng và phản hồi giúp giữ cho
|
||||
mô hình đi đúng hướng thay vì bị trôi lệch mục tiêu.
|
||||
|
||||
CodeWhale chính là khung ràng buộc đó, được xây dựng xung quanh DeepSeek V4 và được dẫn dắt bởi ba ý tưởng chính:
|
||||
|
||||
| Nguyên tắc | Cách thức hoạt động |
|
||||
|---|---|
|
||||
| **Bắt đầu với sự tin tưởng** | Mỗi lượt bắt đầu bằng chữ "A" — tìm kiếm khả năng trước khi khẳng định chắc chắn, chú trọng chất lượng trước sự tiện lợi |
|
||||
| **Thẩm quyền rõ ràng** | Một bản Hiến pháp bằng văn bản với chín cấp bậc thẩm quyền. Ý định của người dùng quan trọng hơn các hướng dẫn cũ kỹ. Sự xác minh quan trọng hơn sự tự tin. |
|
||||
| **Cải tiến đệ quy** | V4 đã tham gia viết nên một phần của khung ràng buộc này. Khi khung ràng buộc tốt lên, V4 hoạt động hiệu quả hơn — và giúp cải tiến khung ràng buộc hơn nữa. Mỗi lượt chạy mới đều bắt đầu mạnh mẽ hơn. |
|
||||
|
||||
Dự án này là mã nguồn mở, hoạt động trực tiếp trên terminal và được đóng gói thành một cặp binary Rust đồng bộ là `codewhale` / `codewhale-tui`.
|
||||
|
||||
## Khung Ràng Buộc Hoạt Động Thế Nào?
|
||||
|
||||
Các mô hình dạng Agent phải xử lý lượng thông tin xung đột rất lớn trên quy mô lớn: ý định của người dùng, quy tắc dự án, cấu hình mặc định của hệ thống, đầu ra của công cụ và bộ nhớ cũ đều cạnh tranh thẩm quyền trong một lượt chạy duy nhất. LLM hoạt động như một thẩm phán cần có thẩm quyền rõ ràng — nguồn thông tin nào sẽ thắng thế khi xảy ra xung đột?
|
||||
|
||||
CodeWhale giải quyết vấn đề này bằng một bản **Hiến pháp** (`prompts/base.md`). Đây là một hệ thống phân cấp luật chính thức — Điều VII xếp hạng chín nguồn thông tin từ các điều khoản của chính Hiến pháp xuống đến thông tin bàn giao từ phiên làm việc trước. Tin nhắn hiện tại của người dùng có thẩm quyền cao hơn các hướng dẫn dự án cũ kỹ. Đầu ra trực tiếp từ công cụ có thẩm quyền cao hơn các giả định. Việc xác minh thực tế có thẩm quyền cao hơn sự tự tin của mô hình. Mô hình kế thừa một chuỗi thẩm quyền rõ ràng qua từng lượt và không bao giờ phải đoán xem nên làm theo chỉ thị nào.
|
||||
|
||||
Có bảy điều khoản đứng đầu hệ thống phân cấp này, định nghĩa danh tính, nghĩa vụ và quyền hạn của mô hình: yêu cầu xác minh (Điều V — mọi hành động phải để lại bằng chứng thực tế, không bao giờ tuyên bố thành công dựa trên niềm tin mơ hồ), di sản điều phối (Điều VI — giữ cho workspace dễ đọc để trí tuệ tiếp theo có thể tiếp quản), và điều khoản ưu tiên sự thật (Điều II — không có quy tắc cấp dưới nào được phép ghi đè lên nó).
|
||||
|
||||
Bộ nhớ đệm tiền tố (prefix caching) của DeepSeek V4 làm cho điều này trở nên khả thi và thực tế. Bản Hiến pháp rất dài và chi tiết, nhưng một khi đã được cache, nó sẽ tốn ít hơn khoảng 100 lần chi phí cho mỗi lượt so với một lần đọc mới hoàn toàn. Mô hình tham chiếu nó một cách đệ quy — xem qua, quét và truy vấn thông qua các phiên RLM — truy cập lại thông tin theo nhu cầu thay vì chỉ dựa trên một lượt ghi nhớ duy nhất. Nó hoạt động giống như một bài kiểm tra mở sách hơn là kiểm tra đóng sách.
|
||||
|
||||
Bởi vì cấu trúc thẩm quyền là tường minh, các lỗi và thất bại không bao giờ bị che giấu. Các mã thoát (exit codes) khác không, lỗi kiểu dữ liệu từ rust-analyzer trả về giữa các lượt, từ chối của sandbox — tất cả đều được đưa ngược lại như các vectơ sửa lỗi. Mô hình sử dụng chính sự chệch hướng của mình để tự sửa sai.
|
||||
|
||||
Ba chế độ kiểm soát không gian hành động: **Plan** là chế độ chỉ đọc. **Agent** chặn các thao tác can thiệp thay đổi file đằng sau quyền phê duyệt của người dùng. **YOLO** tự động phê duyệt tất cả các công cụ trong các workspace đáng tin cậy. Chế độ Sandbox hoạt động trên macOS Seatbelt; Linux Landlock đã được phát hiện nhưng chưa được áp dụng bắt buộc; chế độ sandboxing trên Windows hiện chưa được hỗ trợ.
|
||||
|
||||
**Fin** — một cuộc gọi Flash giá rẻ và tắt chức năng suy nghĩ — xử lý việc tự động định tuyến mô hình cho mỗi lượt. Tham số mặc định là `--model auto`.
|
||||
|
||||
Mỗi lượt chạy đều ghi lại một ảnh chụp nhanh side-git bên ngoài thư mục `.git` của repo. Các lệnh `/restore` và `revert_turn` giúp khôi phục nhanh workspace về trạng thái trước đó.
|
||||
|
||||
Các sub-agent chạy đồng thời (tối đa 20). Lệnh `agent_open` trả về kết quả ngay lập tức; kết quả trả về nội tuyến dưới dạng các sentinel hoàn thành kèm theo bản tóm tắt. Nhật ký chi tiết của sub-agent được lưu trữ và truy cập thông qua `agent_eval`. Xem chi tiết tại [docs/SUBAGENTS.md](docs/SUBAGENTS.md).
|
||||
|
||||
Các tính năng khác của hệ thống bao gồm: chẩn đoán lỗi LSP sau mỗi lần chỉnh sửa file (rust-analyzer, pyright, typescript-language-server, gopls, clangd), các phiên làm việc RLM để phân tích hàng loạt, giao thức MCP, API runtime HTTP/SSE, hàng đợi tác vụ liên tục, adapter ACP cho trình soạn thảo Zed, xuất kết quả định dạng SWE-bench và theo dõi chi phí trực tiếp với bảng phân tích chi tiết lượt hit/miss cache.
|
||||
|
||||
---
|
||||
|
||||
## Khung Kết Nối (Harness)
|
||||
|
||||
`codewhale` (CLI điều phối) → `codewhale-tui` (binary giao diện) → giao diện ratatui ↔ công cụ bất đồng bộ ↔ máy khách streaming tương thích với OpenAI. Các lượt gọi công cụ được định tuyến qua một registry có phân loại (shell, thao tác file, git, web, sub-agent, MCP, RLM) và kết quả được truyền trực tuyến trở lại transcript. Công cụ quản lý trạng thái phiên làm việc, theo dõi lượt chạy, hàng đợi tác vụ bền bỉ và một phân hệ LSP cung cấp thông tin chẩn đoán sau khi chỉnh sửa vào ngữ cảnh của mô hình trước bước suy nghĩ tiếp theo.
|
||||
|
||||
Xem tài liệu [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) để biết chi tiết toàn bộ luồng hoạt động.
|
||||
|
||||
### Sub-agents: Khởi chạy Tác vụ Nền Đồng thời
|
||||
|
||||
CodeWhale có thể điều phối nhiều sub-agent chạy song song — hoạt động giống như một hàng đợi tác vụ đồng thời:
|
||||
|
||||
- **Khởi chạy không chặn:** Lệnh `agent_open` trả về ngay lập tức. Sub-agent con có một ngữ cảnh độc lập mới và hệ thống đăng ký công cụ riêng để chạy tự chủ. Agent cha vẫn tiếp tục làm việc bình thường.
|
||||
- **Thực thi dưới nền:** Các sub-agent chạy đồng thời (giới hạn mặc định: 10, có thể cấu hình lên đến 20). Hệ thống tự quản lý pool tài nguyên này mà không cần vòng lặp thăm dò (polling loop).
|
||||
- **Thông báo hoàn thành:** Khi một sub-agent hoàn thành, hệ thống sẽ chèn một khóa sentinel `<codewhale:subagent.done>` vào transcript của agent cha. Một bản tóm tắt thân thiện với con người — bao gồm phát hiện của sub-agent con, các file đã thay đổi và các rủi ro có thể xảy ra — nằm ngay dòng phía trên khóa sentinel. Mô hình cha sẽ đọc tóm tắt đó và tích hợp kết quả thu được mà không cần phải thực hiện thêm bất kỳ lệnh gọi công cụ nào khác.
|
||||
- **Truy xuất kết quả có giới hạn:** Nhật ký chi tiết của agent con nằm dưới dạng một `transcript_handle` có thể truy cập qua `agent_eval`. Khi bản tóm tắt là chưa đủ, agent cha có thể gọi `handle_read` để đọc một phần, các dòng cụ thể hoặc lọc qua JSONPath — giúp ngữ cảnh của agent cha luôn tinh gọn mà không làm mất đi các chi tiết quan trọng.
|
||||
|
||||
Xem thêm tài liệu [docs/SUBAGENTS.md](docs/SUBAGENTS.md) để tham khảo thông tin đầy đủ về sub-agent.
|
||||
|
||||
---
|
||||
|
||||
## Khởi động nhanh
|
||||
|
||||
```bash
|
||||
npm install -g codewhale
|
||||
codewhale --version
|
||||
codewhale --model auto
|
||||
```
|
||||
|
||||
Cặp binary dựng sẵn và gói nén nền tảng được phát hành cho các kiến trúc **Linux x64**, **Linux ARM64** (từ v0.8.8 trở lên), **macOS x64**, **macOS ARM64**, và **Windows x64**. Đối với các mục tiêu khác (musl, riscv64, FreeBSD, v.v.), xem phần [Cài đặt từ nguồn](#install-from-source) hoặc tài liệu [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
Trong lần chạy đầu tiên, bạn sẽ được nhắc nhập [API key của DeepSeek](https://platform.deepseek.com/api_keys). Khóa này được lưu vào tệp cấu hình `~/.codewhale/config.toml` (tương thích cả tệp cũ `~/.deepseek/config.toml`) để nó hoạt động từ bất kỳ thư mục nào mà không cần nhắc thông tin đăng nhập của hệ điều hành.
|
||||
|
||||
Bạn cũng có thể thiết lập trước:
|
||||
|
||||
```bash
|
||||
codewhale auth set --provider deepseek # lưu vào ~/.codewhale/config.toml
|
||||
codewhale auth status # hiển thị nguồn thông tin đăng nhập đang hoạt động
|
||||
|
||||
export DEEPSEEK_API_KEY="YOUR_KEY" # cách thiết lập qua biến môi trường thay thế; sử dụng ~/.zshenv cho terminal không tương tác
|
||||
codewhale
|
||||
|
||||
codewhale doctor # kiểm tra và xác minh thiết lập
|
||||
```
|
||||
|
||||
Nếu lệnh `codewhale doctor` báo lỗi API key bị từ chối đến từ biến môi trường `DEEPSEEK_API_KEY`, hãy xóa cấu hình xuất biến môi trường cũ trong tệp khởi chạy shell của bạn, mở một shell mới hoặc chạy lệnh `codewhale auth set --provider deepseek`. Sử dụng `codewhale auth status` để xem trạng thái của cấu hình, keyring hệ thống và biến môi trường mà không hiển thị trực tiếp khóa API. Các khóa lưu trong file cấu hình sẽ được ưu tiên cao hơn keyring và môi trường để dễ dàng thay đổi khi cần.
|
||||
|
||||
> Để thay đổi hoặc xóa khóa đã lưu: `codewhale auth clear --provider deepseek`.
|
||||
|
||||
### Tencent Cloud / CNB Remote-First Path
|
||||
|
||||
Đối với không gian làm việc luôn trực tuyến mà bạn có thể điều khiển từ điện thoại, hãy sử dụng đường dẫn gốc của Tencent: CNB mirror/source, Tencent Lighthouse HK, cầu kết nối dài hạn Feishu/Lark, và EdgeOne tùy chọn cho một cổng HTTPS công cộng có kiểm soát. API runtime luôn được giới hạn chạy tại localhost; EdgeOne không được sử dụng để hiển thị công khai đường dẫn `/v1/*`.
|
||||
|
||||
Bắt đầu với tài liệu [docs/TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md), sau đó xem thêm tài liệu [docs/TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) để biết các vận hành máy chủ.
|
||||
|
||||
### Chế độ Tự động (Auto Mode)
|
||||
|
||||
Sử dụng `codewhale --model auto` hoặc gõ lệnh `/model auto` khi bạn muốn hệ thống tự động quyết định sức mạnh của mô hình và cấp độ suy nghĩ cần thiết cho mỗi lượt.
|
||||
|
||||
Chế độ tự động điều khiển hai cài đặt cùng nhau:
|
||||
|
||||
- Mô hình: `deepseek-v4-flash` hoặc `deepseek-v4-pro`
|
||||
- Cấp độ suy nghĩ: `off`, `high`, hoặc `max`
|
||||
|
||||
Trước khi lượt gửi chính thức được thực hiện, ứng dụng sẽ thực hiện một cuộc gọi định tuyến nhỏ thông qua mô hình `deepseek-v4-flash` tắt chế độ suy nghĩ. Trình định tuyến đó sẽ đánh giá yêu cầu mới nhất và ngữ cảnh gần đây, từ đó chọn mô hình cụ thể và cấp độ suy nghĩ phù hợp cho lượt gọi thực tế. Các lượt tương tác ngắn/đơn giản sẽ được chạy trên mô hình Flash tắt suy nghĩ; các công việc lập trình phức tạp, gỡ lỗi, phát hành, kiến trúc phần mềm, kiểm tra bảo mật hoặc các tác vụ nhiều bước mơ hồ sẽ được đẩy lên mô hình Pro với cấp độ suy nghĩ cao hơn.
|
||||
|
||||
Cơ chế `auto` hoạt động hoàn toàn cục bộ trên máy của bạn. API ở máy chủ upstream không bao giờ nhận được chuỗi `model: "auto"`; nó luôn nhận được mô hình cụ thể và cấu hình suy nghĩ đã được chọn cho lượt chạy đó. Giao diện TUI hiển thị tuyến đường định tuyến được chọn và bộ theo dõi chi phí sẽ tính tiền cho mô hình thực tế đã chạy. Nếu cuộc gọi định tuyến thất bại hoặc trả về câu trả lời không hợp lệ, ứng dụng sẽ chuyển sang thuật toán phỏng đoán cục bộ. Các sub-agent con sẽ kế thừa chế độ tự động này trừ khi bạn chỉ định rõ một mô hình cho chúng.
|
||||
|
||||
Hãy chỉ định mô hình hoặc cấp độ suy nghĩ cố định nếu bạn muốn chạy benchmark lặp lại nhất quán, kiểm soát nghiêm ngặt chi phí trần hoặc có cấu hình ánh xạ nhà cung cấp/mô hình tùy chỉnh cụ thể.
|
||||
|
||||
### Linux ARM64 (Raspberry Pi, Asahi, Graviton, HarmonyOS PC)
|
||||
|
||||
Lệnh cài đặt `npm i -g codewhale` hoạt động trên môi trường Linux ARM64 nền glibc từ phiên bản v0.8.8 trở đi. Bạn cũng có thể tải trực tiếp các tệp binary dựng sẵn từ [trang phát hành Releases](https://github.com/Hmbown/CodeWhale/releases) và đặt chúng cạnh nhau trong một thư mục thuộc biến `PATH`.
|
||||
|
||||
### Cài đặt thân thiện qua Mirror (Tại Trung Quốc)
|
||||
|
||||
Nếu việc tải xuống từ GitHub hoặc npm bị chậm từ Trung Quốc đại lục, bạn hãy sử dụng mirror registry cho Cargo:
|
||||
|
||||
```toml
|
||||
# ~/.cargo/config.toml
|
||||
[source.crates-io]
|
||||
replace-with = "tuna"
|
||||
|
||||
[source.tuna]
|
||||
registry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"
|
||||
```
|
||||
|
||||
Sau đó cài đặt cả hai binary (trình điều phối sẽ ủy quyền cho TUI tại thời điểm chạy):
|
||||
|
||||
```bash
|
||||
cargo install codewhale-cli --locked # cung cấp lệnh `codewhale`
|
||||
cargo install codewhale-tui --locked # cung cấp giao diện `codewhale-tui`
|
||||
codewhale --version
|
||||
```
|
||||
|
||||
Các binary dựng sẵn cũng có thể được tải từ [GitHub Releases](https://github.com/Hmbown/CodeWhale/releases). Thiết lập biến `DEEPSEEK_TUI_RELEASE_BASE_URL` để sử dụng mirror tải các tệp tài nguyên phát hành.
|
||||
|
||||
### Windows (Scoop)
|
||||
|
||||
[Scoop](https://scoop.sh) là một trình quản lý gói phổ biến trên Windows. Gói `codewhale` đã được liệt kê trong bucket chính của Scoop, tuy nhiên gói cài đặt này hoạt động độc lập và đôi khi cập nhật chậm hơn các bản phát hành chính thức trên GitHub/npm/Cargo. Chạy lệnh `scoop update` trước, sau đó xác minh phiên bản đã cài bằng `codewhale --version`:
|
||||
|
||||
```bash
|
||||
scoop update
|
||||
scoop install codewhale
|
||||
codewhale --version
|
||||
```
|
||||
|
||||
Vui lòng sử dụng phương pháp npm hoặc tải trực tiếp từ GitHub Releases nếu bạn muốn trải nghiệm phiên bản mới nhất trước khi Scoop cập nhật.
|
||||
|
||||
<details id="install-from-source">
|
||||
<summary>Cài đặt từ mã nguồn</summary>
|
||||
|
||||
Cách này hoạt động trên bất kỳ kiến trúc mục tiêu Tier-1 nào được Rust hỗ trợ — bao gồm cả musl, riscv64, FreeBSD và các bản phân phối ARM64 Linux cũ.
|
||||
|
||||
```bash
|
||||
# Các thư viện phụ thuộc để build trên Linux (Debian/Ubuntu/RHEL):
|
||||
# sudo apt-get install -y build-essential pkg-config libdbus-1-dev
|
||||
# sudo dnf install -y gcc make pkgconf-pkg-config dbus-devel
|
||||
|
||||
git clone https://github.com/Hmbown/CodeWhale.git
|
||||
cd CodeWhale
|
||||
|
||||
cargo install --path crates/cli --locked # yêu cầu Rust 1.88+; cung cấp `codewhale`
|
||||
cargo install --path crates/tui --locked # cung cấp giao diện `codewhale-tui`
|
||||
```
|
||||
|
||||
Cả hai tệp binary đều bắt buộc phải cài đặt. Xem hướng dẫn biên dịch chéo và ghi chú riêng theo nền tảng tại: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
</details>
|
||||
|
||||
### Các Nhà Cung Cấp API Khác
|
||||
|
||||
Để xem danh sách đầy đủ tất cả các nhà cung cấp được hỗ trợ chính thức, bao gồm mã định danh mô hình, biến xác thực, URL cơ sở và ranh giới tính năng, xem thêm tài liệu [docs/PROVIDERS.md](docs/PROVIDERS.md).
|
||||
|
||||
```bash
|
||||
# NVIDIA NIM
|
||||
codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
|
||||
codewhale --provider nvidia-nim
|
||||
|
||||
# AtlasCloud
|
||||
codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"
|
||||
codewhale --provider atlascloud
|
||||
|
||||
# Wanjie Ark
|
||||
codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"
|
||||
codewhale --provider wanjie-ark --model deepseek-reasoner
|
||||
|
||||
# OpenRouter
|
||||
codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY"
|
||||
codewhale --provider openrouter --model deepseek/deepseek-v4-pro
|
||||
|
||||
# Novita
|
||||
codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY"
|
||||
codewhale --provider novita --model deepseek/deepseek-v4-pro
|
||||
|
||||
# Fireworks
|
||||
codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
|
||||
codewhale --provider fireworks --model deepseek-v4-pro
|
||||
|
||||
# Các endpoint tương thích định dạng OpenAI chung
|
||||
codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"
|
||||
OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5
|
||||
|
||||
# Tự host bằng SGLang
|
||||
SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash
|
||||
|
||||
# Tự host bằng vLLM
|
||||
VLLM_BASE_URL="http://localhost:8000/v1" codewhale --provider vllm --model deepseek-v4-flash
|
||||
# Sử dụng vLLM qua kết nối HTTP trong mạng LAN đáng tin cậy
|
||||
DEEPSEEK_ALLOW_INSECURE_HTTP=1 VLLM_BASE_URL="http://192.168.0.110:8000/v1" codewhale --provider vllm --model deepseek-v4-flash
|
||||
|
||||
# Tự host bằng Ollama
|
||||
ollama pull codewhale-coder:1.3b
|
||||
codewhale --provider ollama --model codewhale-coder:1.3b
|
||||
```
|
||||
|
||||
Bên trong giao diện TUI, lệnh `/provider` mở bảng chọn nhà cung cấp và `/model` mở bảng chọn mô hình/cấp độ suy nghĩ cục bộ. Lệnh `/provider openrouter` và `/model <id>` chuyển đổi trực tiếp, trong khi lệnh `/models` sẽ truy vấn trực tiếp và hiển thị danh sách các mô hình API trực tuyến từ nhà cung cấp (nếu nhà cung cấp hỗ trợ tính năng liệt kê mô hình).
|
||||
|
||||
---
|
||||
|
||||
## Nhật ký thay đổi (Release Notes)
|
||||
|
||||
Chi tiết thay đổi giữa các phiên bản được cập nhật tại [CHANGELOG.md](CHANGELOG.md). File README này chỉ tập trung vào các đường dẫn cài đặt hiện tại, quy trình làm việc cốt lõi, thiết lập nhà cung cấp API, giao diện và các điểm mở rộng tính năng của dự án.
|
||||
|
||||
---
|
||||
|
||||
## Cách sử dụng
|
||||
|
||||
```bash
|
||||
codewhale # giao diện tương tác TUI chính
|
||||
codewhale "explain this function" # thực thi prompt nhanh một lượt
|
||||
codewhale exec --auto --output-format stream-json "fix this bug" # truyền phát luồng dữ liệu NDJSON backend
|
||||
codewhale exec --resume <SESSION_ID> "follow up" # tiếp tục phiên làm việc không tương tác cũ
|
||||
codewhale --model deepseek-v4-flash "summarize" # ghi đè mô hình chạy chỉ định
|
||||
codewhale --model auto "fix this bug" # tự động chọn mô hình và cấp độ suy nghĩ thích hợp
|
||||
codewhale --yolo # tự động phê duyệt chạy các công cụ
|
||||
codewhale auth set --provider deepseek # lưu trữ API key
|
||||
codewhale doctor # tự động kiểm tra cài đặt và kết nối mạng
|
||||
codewhale doctor --json # trả về chuẩn đoán định dạng máy đọc được
|
||||
codewhale setup --status # chỉ đọc trạng thái thiết lập hiện tại
|
||||
codewhale setup --tools --plugins # tạo sẵn cấu trúc thư mục tool/plugin
|
||||
codewhale models # liệt kê các mô hình khả dụng trực tuyến
|
||||
codewhale sessions # liệt kê các phiên làm việc đã lưu
|
||||
codewhale resume --last # tiếp tục phiên làm việc gần nhất trong thư mục này
|
||||
codewhale resume <SESSION_ID> # tiếp tục một phiên làm việc cụ thể theo mã UUID
|
||||
codewhale fork <SESSION_ID> # tạo một nhánh (fork) phiên làm việc đã lưu sang đường dẫn mới
|
||||
codewhale serve --http # khởi chạy máy chủ API định dạng HTTP/SSE
|
||||
codewhale serve --acp # khởi chạy adapter ACP qua stdio cho trình soạn thảo Zed/agent tùy chỉnh
|
||||
codewhale run pr <N> # tải PR về và nạp sẵn vào prompt đánh giá
|
||||
codewhale mcp list # liệt kê các máy chủ MCP đã cấu hình
|
||||
codewhale mcp validate # kiểm tra cấu hình và kết nối máy chủ MCP
|
||||
codewhale mcp-server # khởi chạy máy chủ MCP điều phối qua cổng stdio
|
||||
codewhale update # kiểm tra và cài đặt phiên bản binary mới nhất
|
||||
```
|
||||
|
||||
### Tạo nhánh phiên làm việc (Branching)
|
||||
|
||||
Các phiên làm việc được lưu có thể được phân nhánh một cách có chủ đích. Lệnh `codewhale fork <SESSION_ID>` sao chép toàn bộ phiên làm việc cũ sang một phiên mới song song, lưu trữ mã ID của phiên cha trong siêu dữ liệu (metadata) và mở phiên fork đó ra để bạn có thể thử nghiệm hướng phát triển mới mà không làm ảnh hưởng đến lịch sử phiên làm việc gốc. Trình chọn phiên làm việc và danh sách `codewhale sessions` sẽ đánh dấu rõ ràng các phiên được fork kèm theo mã ID của phiên cha.
|
||||
|
||||
Bên trong giao diện TUI, bạn có thể nhấn phím `Esc` hai lần (`Esc-Esc`) để quay ngược lại transcript và đưa prompt cũ về lại phần soạn thảo để chỉnh sửa lại nội dung. Các lệnh `/restore` và `revert_turn` là công cụ khôi phục workspace độc lập: chúng khôi phục lại các tệp tin dựa trên ảnh chụp nhanh side-git nhưng không làm thay đổi hay ghi đè lịch sử trò chuyện của phiên làm việc.
|
||||
|
||||
Các hình ảnh Docker được phát hành lên GHCR cho các bản dựng phát hành chính thức:
|
||||
|
||||
```bash
|
||||
docker volume create codewhale-home
|
||||
|
||||
docker run --rm -it \
|
||||
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
|
||||
-v codewhale-home:/home/codewhale/.codewhale \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
ghcr.io/hmbown/codewhale:latest
|
||||
```
|
||||
|
||||
Xem tài liệu [docs/DOCKER.md](docs/DOCKER.md) để biết thêm thông tin về thẻ phiên bản (pinned tags), cách tự dựng image cục bộ, lưu ý quyền sở hữu volume và cách sử dụng cho pipeline không tương tác.
|
||||
|
||||
### Zed / ACP
|
||||
|
||||
DeepSeek có thể chạy dưới dạng một máy chủ Agent Client Protocol (ACP) cục bộ cho các trình soạn thảo mã nguồn hỗ trợ giao tiếp ACP qua cổng stdio. Trong trình soạn thảo Zed, bạn hãy thêm cấu hình máy chủ agent tùy chỉnh sau:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"DeepSeek": {
|
||||
"type": "custom",
|
||||
"command": "codewhale",
|
||||
"args": ["serve", "--acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Phân hệ ACP ban đầu hỗ trợ khởi tạo phiên làm việc mới và nhận phản hồi prompt qua cấu hình và API key hiện tại của DeepSeek. Tính năng chỉnh sửa tích hợp công cụ và phát lại checkpoint hiện chưa được hỗ trợ qua giao diện ACP.
|
||||
|
||||
Adapter do cộng đồng phát triển: [acp-codewhale-adapter](https://github.com/rockeverm3m/acp-codewhale-adapter) hỗ trợ cầu nối lệnh `codewhale exec --auto` với `cc-connect` cho người dùng cần quy trình làm việc ACP có tích hợp công cụ bên ngoài trình soạn thảo Zed.
|
||||
|
||||
### Phím Tắt Tiêu Biểu
|
||||
|
||||
| Phím | Hành động |
|
||||
|---|---|
|
||||
| `Tab` | Hoàn thành gợi ý lệnh `/` hoặc các nhãn tệp `@`; khi đang chạy, xếp tin nhắn nháp vào hàng đợi chạy tiếp theo; hoặc chuyển đổi qua lại giữa các chế độ |
|
||||
| `Shift+Tab` | Thay đổi nhanh cấp độ suy nghĩ: off → high → max |
|
||||
| `F1` | Mở màn hình trợ giúp phím tắt có thanh tìm kiếm |
|
||||
| `Esc` | Quay lại / đóng cửa sổ popup |
|
||||
| `Ctrl+K` | Mở bảng lệnh nhanh (Command palette) |
|
||||
| `Ctrl+R` | Tiếp tục một phiên làm việc cũ |
|
||||
| `Alt+R` | Tìm kiếm lịch sử prompt cũ để khôi phục tin nháp đã xóa |
|
||||
| `Ctrl+S` | Cất tin nháp hiện tại vào bộ nhớ tạm (dùng `/stash list`, `/stash pop` để lấy lại) |
|
||||
| `@path` | Đính kèm ngữ cảnh file hoặc thư mục trực tiếp tại trình soạn thảo văn bản |
|
||||
| `↑` (tại đầu composer) | Chọn hàng tệp tin đính kèm để xóa |
|
||||
|
||||
Xem danh sách phím tắt đầy đủ tại: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md).
|
||||
|
||||
---
|
||||
|
||||
## Chế độ hoạt động (Modes)
|
||||
|
||||
| Chế độ | Hành vi hoạt động |
|
||||
| --- | --- |
|
||||
| **Plan** 🔍 | Chế độ khảo sát chỉ đọc — mô hình tìm hiểu cấu trúc và đề xuất kế hoạch hành động cụ thể trước khi sửa đổi file; các cuộc khảo sát nhiều bước sử dụng công cụ `checklist_write` |
|
||||
| **Agent** 🤖 | Chế độ tương tác mặc định — thực thi tác vụ nhiều bước có kiểm soát đằng sau các cổng phê duyệt; các tác vụ lớn sẽ được theo dõi qua `checklist_write` |
|
||||
| **YOLO** ⚡ | Tự động phê duyệt tất cả các lệnh gọi công cụ trong các workspace tin cậy; các tác vụ nhiều bước vẫn duy trì checklist hiển thị trực quan |
|
||||
|
||||
---
|
||||
|
||||
## Cấu hình
|
||||
|
||||
Cấu hình của người dùng lưu tại: `~/.codewhale/config.toml` (tự động fallback về tệp cũ `~/.deepseek/config.toml` nếu có). Cấu hình riêng của dự án ghi đè tại: `<workspace>/.codewhale/config.toml` (hoặc `<workspace>/.deepseek/config.toml`) (lưu ý các trường sau bị cấm ghi đè ở cấp dự án: `api_key`, `base_url`, `provider`, `mcp_config_path`). Tham khảo tệp [config.example.toml](config.example.toml) để xem đầy đủ tất cả cấu hình mẫu.
|
||||
|
||||
Các biến môi trường chính:
|
||||
|
||||
| Biến môi trường | Mục đích sử dụng |
|
||||
|---|---|
|
||||
| `DEEPSEEK_API_KEY` | Khóa API key chính |
|
||||
| `DEEPSEEK_BASE_URL` | Địa chỉ URL cơ sở của máy chủ API |
|
||||
| `DEEPSEEK_HTTP_HEADERS` | Các header tùy chỉnh gửi kèm yêu cầu API, ví dụ `X-Model-Provider-Id=your-model-provider` |
|
||||
| `DEEPSEEK_MODEL` | Mô hình mặc định |
|
||||
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Thời gian chờ tối đa khi stream bị rảnh (giây), mặc định là `300`, giới hạn trong khoảng `1..=3600` |
|
||||
| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | Các nhà cung cấp: `deepseek` (mặc định), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` |
|
||||
| `DEEPSEEK_PROFILE` | Tên cấu hình profile sử dụng |
|
||||
| `DEEPSEEK_MEMORY` | Thiết lập là `on` để kích hoạt tính năng tự ghi nhớ thông tin người dùng |
|
||||
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Cho phép sử dụng các đường dẫn API dạng `http://` không mã hóa trong các mạng LAN tin cậy |
|
||||
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Thông tin đăng nhập theo từng nhà cung cấp tương ứng |
|
||||
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | Điểm cuối (endpoint) và mã mô hình cho nhà cung cấp tương thích định dạng OpenAI chung |
|
||||
| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | Endpoint và mô hình ghi đè cho AtlasCloud |
|
||||
| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Endpoint và mô hình ghi đè cho Wanjie Ark |
|
||||
| `OPENROUTER_BASE_URL` | Endpoint ghi đè cho OpenRouter |
|
||||
| `NOVITA_BASE_URL` | Endpoint ghi đè cho Novita |
|
||||
| `FIREWORKS_BASE_URL` | Endpoint ghi đè cho Fireworks |
|
||||
| `SGLANG_BASE_URL` | Endpoint cho máy chủ SGLang tự host |
|
||||
| `SGLANG_MODEL` | Mã mô hình cho máy chủ SGLang tự host |
|
||||
| `VLLM_BASE_URL` | Endpoint cho máy chủ vLLM tự host |
|
||||
| `VLLM_MODEL` | Mã mô hình cho máy chủ vLLM tự host |
|
||||
| `OLLAMA_BASE_URL` | Endpoint cho máy chủ Ollama tự host |
|
||||
| `OLLAMA_MODEL` | Thẻ mô hình (model tag) cho máy chủ Ollama tự host |
|
||||
| `NO_ANIMATIONS=1` | Bắt buộc chạy ở chế độ hỗ trợ khả năng tiếp cận (Accessibility mode), tắt hiệu ứng khi khởi động |
|
||||
| `SSL_CERT_FILE` | Đường dẫn file CA bundle tùy chỉnh khi sử dụng proxy nội bộ doanh nghiệp |
|
||||
|
||||
Thiết lập thuộc tính `locale` trong file `settings.toml`, sử dụng lệnh `/config locale vi`, hoặc dựa vào cài đặt biến `LC_ALL`/`LANG` của hệ điều hành để lựa chọn ngôn ngữ cho giao diện TUI và ngôn ngữ nhắc nhở gửi kèm tới các mô hình V4. Tin nhắn mới nhất của người dùng vẫn có mức độ ưu tiên cao nhất để mô hình tự động chọn ngôn ngữ phản hồi tương ứng, do đó các câu hỏi bằng Tiếng Việt của người dùng vẫn sẽ luôn nhận được câu trả lời bằng Tiếng Việt ngay cả khi hệ điều hành đang thiết lập giao diện hiển thị mặc định bằng tiếng Anh. Xem tài liệu hướng dẫn cấu hình tại [docs/CONFIGURATION.md](docs/CONFIGURATION.md) và [docs/MCP.md](docs/MCP.md).
|
||||
|
||||
---
|
||||
|
||||
## Mô hình & Giá cả
|
||||
|
||||
| Mô hình | Ngữ cảnh | Đầu vào (Hit Cache) | Đầu vào (Miss Cache) | Đầu ra |
|
||||
|---|---|---|---|---|
|
||||
| `deepseek-v4-pro` | 1M | $0.003625 / 1M | $0.435 / 1M | $0.87 / 1M |
|
||||
| `deepseek-v4-flash` | 1M | $0.0028 / 1M | $0.14 / 1M | $0.28 / 1M |
|
||||
|
||||
Nền tảng DeepSeek mặc định sử dụng đường dẫn `https://api.deepseek.com/beta` để bạn có thể trải nghiệm các tính năng API beta mà không cần thiết lập cấu hình phức tạp. Thiết lập thuộc tính `base_url = "https://api.deepseek.com"` nếu muốn tắt tính năng này.
|
||||
|
||||
Các tên định danh cũ `deepseek-chat` / `deepseek-reasoner` sẽ được tự động ánh xạ đến `deepseek-v4-flash` và sẽ chính thức dừng hoạt động sau ngày 24 tháng 7 năm 2026. Các biến thể NVIDIA NIM sẽ áp dụng theo điều khoản tài khoản NVIDIA của bạn.
|
||||
|
||||
> [!Note]
|
||||
> Trang cấu trúc giá của DeepSeek hiện đã cập nhật bảng giá trên của dòng V4 Pro làm mức giá cố định vĩnh viễn: Chương trình khuyến mãi giảm giá 75% trước đó đã được chính thức tích hợp thẳng vào giá cơ sở từ sau khi thời hạn khuyến mãi kết thúc vào lúc 15:59 UTC ngày 31 tháng 5 năm 2026. Trình tính toán chi phí trên giao diện TUI của CodeWhale đã cập nhật các giá trị mới này, do đó bạn không cần thực hiện thêm thay đổi nào. Để theo dõi các thay đổi giá trong tương lai, vui lòng tham khảo [trang giá chính thức của DeepSeek](https://api-docs.deepseek.com/zh-cn/quick_start/pricing).
|
||||
|
||||
---
|
||||
|
||||
## Chia Sẻ Skill Tự Viết
|
||||
|
||||
CodeWhale sẽ tự động quét và tìm kiếm các skill được định nghĩa từ các thư mục của dự án (`.agents/skills` → `skills` → `.opencode/skills` → `.claude/skills` → `.cursor/skills`) và các thư mục cấu hình toàn cục (`~/.agents/skills` → `~/.claude/skills` → `~/.codewhale/skills` → `~/.deepseek/skills`). Mỗi skill là một thư mục chứa một tệp tin `SKILL.md`:
|
||||
|
||||
```text
|
||||
~/.agents/skills/my-skill/
|
||||
└── SKILL.md
|
||||
```
|
||||
|
||||
Yêu cầu định nghĩa phần Frontmatter ở đầu file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: Sử dụng skill này khi bạn muốn DeepSeek tuân thủ theo quy trình làm việc tùy chỉnh của tôi.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
Các hướng dẫn chi tiết dành cho agent được viết tại đây.
|
||||
```
|
||||
|
||||
Các lệnh tương tác: `/skills` (liệt kê), `/skill <name>` (kích hoạt), `/skill new` (tạo khung mẫu), `/skill install github:<owner>/<repo>` (cài đặt từ cộng đồng GitHub), `/skill update` / `uninstall` / `trust` để quản lý. Cài đặt các skill từ cộng đồng GitHub không yêu cầu chạy thêm bất kỳ dịch vụ nền nào. Các skill sau khi cài đặt sẽ hiển thị trong phần ngữ cảnh phiên làm việc mà mô hình AI có thể đọc được; agent có thể tự chọn skill phù hợp qua công cụ `load_skill` khi nhiệm vụ của bạn khớp với phần mô tả của skill.
|
||||
|
||||
Trong lần chạy đầu tiên, chương trình cũng tự động cài đặt sẵn một số skill hệ thống cho các quy trình phổ biến:
|
||||
`skill-creator`, `delegate`, `v4-best-practices`, `plugin-creator`, `skill-installer`, `mcp-builder`, `documents`, `presentations`, `spreadsheets`, `pdf`, và `feishu`. Các skill này nằm trong thư mục `~/.codewhale/skills` (hoặc thư mục cũ `~/.deepseek/skills`) và được quản lý phiên bản để các bản nâng cấp mới được cài đặt tự động mà không làm ảnh hưởng đến các skill do người dùng tự chủ động xóa trước đó.
|
||||
|
||||
---
|
||||
|
||||
## Tài liệu hướng dẫn
|
||||
|
||||
| Tài liệu | Chủ đề chi tiết |
|
||||
|---|---|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Cấu trúc bên trong của cơ sở mã nguồn |
|
||||
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Hướng dẫn cấu hình chi tiết và đầy đủ nhất |
|
||||
| [MODES.md](docs/MODES.md) | Các chế độ hoạt động: Plan / Agent / YOLO |
|
||||
| [MCP.md](docs/MCP.md) | Tích hợp giao thức Model Context Protocol |
|
||||
| [RUNTIME_API.md](docs/RUNTIME_API.md) | Hướng dẫn sử dụng máy chủ API HTTP/SSE |
|
||||
| [INSTALL.md](docs/INSTALL.md) | Hướng dẫn cài đặt riêng theo từng nền tảng |
|
||||
| [DOCKER.md](docs/DOCKER.md) | Sử dụng Docker image trên GHCR, volume lưu trữ |
|
||||
| [CNB_MIRROR.md](docs/CNB_MIRROR.md) | CNB mirror và các lưu ý cài đặt tại Trung Quốc |
|
||||
| [TENCENT_CLOUD_REMOTE_FIRST.md](docs/TENCENT_CLOUD_REMOTE_FIRST.md) | Hướng dẫn kết nối Tencent/CNB/Lighthouse/Feishu từ xa |
|
||||
| [TENCENT_LIGHTHOUSE_HK.md](docs/TENCENT_LIGHTHOUSE_HK.md) | Thiết lập máy chủ Lighthouse Hồng Kông |
|
||||
| [MEMORY.md](docs/MEMORY.md) | Hướng dẫn tính năng tự ghi nhớ thông tin người dùng |
|
||||
| [SUBAGENTS.md](docs/SUBAGENTS.md) | Phân loại vai trò và vòng đời của các sub-agent con |
|
||||
| [KEYBINDINGS.md](docs/KEYBINDINGS.md) | Danh sách phím tắt đầy đủ |
|
||||
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Quy trình đóng gói và phát hành phiên bản mới |
|
||||
| [LOCALIZATION.md](docs/LOCALIZATION.md) | Ma trận đa ngôn ngữ giao diện & cách chuyển đổi |
|
||||
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Vận hành và phục hồi hệ thống |
|
||||
|
||||
Lịch sử cập nhật chi tiết: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Lời cảm ơn
|
||||
|
||||
- **[DeepSeek](https://github.com/deepseek-ai)** — Xin chân thành cảm ơn sự hỗ trợ và các mô hình AI mạnh mẽ giúp tiếp sức cho mọi tương tác trong dự án. 感谢 DeepSeek 提供模型与支持,让每一次交互成为可能。
|
||||
- **[DataWhale](https://github.com/datawhalechina)** 🐋 — Xin cảm ơn sự hỗ trợ nhiệt tình và đã chào đón chúng tôi gia nhập gia đình lớn "Whale Brother". 感谢 DataWhale 的支持,并欢迎 chúng tôi gia nhập “鲸兄弟”大家庭。
|
||||
- **[OpenWarp](https://github.com/zerx-lab/warp)** — Cảm ơn vì đã ưu tiên hỗ trợ codewhale và hợp tác để mang lại trải nghiệm agent terminal tốt hơn.
|
||||
- **[Open Design](https://github.com/nexu-io/open-design)** — Cảm ơn vì sự hỗ trợ và hợp tác xung quanh quy trình làm việc chú trọng thiết kế của agent.
|
||||
|
||||
Dự án này được phát triển và vận hành trơn tru với sự đóng góp của cộng đồng các nhà phát triển ngày càng lớn mạnh:
|
||||
|
||||
- **[merchloubna70-dot](https://github.com/merchloubna70-dot)** — Đóng góp 28 PR bao gồm tính năng mới, sửa lỗi và dựng sẵn extension cho VS Code (#645–#681)
|
||||
- **[WyxBUPT-22](https://github.com/WyxBUPT-22)** — Xây dựng trình kết xuất Markdown hỗ trợ bảng biểu, chữ đậm/nghiêng và đường kẻ ngang (#579)
|
||||
- **[loongmiaow-pixel](https://github.com/loongmiaow-pixel)** — Tài liệu cài đặt cho Windows và Trung Quốc (#578)
|
||||
- **[20bytes](https://github.com/20bytes)** — Cải tiến tài liệu tính năng tự ghi nhớ và giao diện trợ giúp (#569)
|
||||
- **[staryxchen](https://github.com/staryxchen)** — Kiểm tra độ tương thích của thư viện glibc trước khi chạy (#556)
|
||||
- **[Vishnu1837](https://github.com/Vishnu1837)** — Tối ưu hóa tính tương thích glibc và tự phục hồi trạng thái terminal khi nhận tín hiệu SIGINT/SIGTERM (#565, #1586)
|
||||
- **[shentoumengxin](https://github.com/shentoumengxin)** — Kiểm tra hợp lệ ranh giới thư mục làm việc `cwd` của Shell (#524)
|
||||
- **[toi500](https://github.com/toi500)** — Báo cáo và sửa lỗi dán văn bản trên hệ điều hành Windows
|
||||
- **[xsstomy](https://github.com/xsstomy)** — Báo cáo lỗi vẽ lại màn hình khi khởi động terminal
|
||||
- **Melody0709** — Báo cáo lỗi kích hoạt phím Enter với tiền tố lệnh gạch chéo
|
||||
- **[lloydzhou](https://github.com/lloydzhou)** và **[jeoor](https://github.com/jeoor)** — Báo cáo lỗi chi phí nén dữ liệu; lloydzhou cũng đóng góp ngữ cảnh môi trường xác định (#813, #922) và ổn định bộ nhớ đệm KV prefix-cache (#1080)
|
||||
- **[Agent-Skill-007](https://github.com/Agent-Skill-007)** — Tinh chỉnh diễn đạt rõ ràng cho file giới thiệu README (#685)
|
||||
- **[woyxiang](https://github.com/woyxiang)** — Tài liệu hướng dẫn cài đặt qua Scoop trên Windows (#696)
|
||||
- **[wangfeng](mailto:wangfengcsu@qq.com)** — Cập nhật thông tin giá cả và chương trình khuyến mãi (#692)
|
||||
- **[zichen0116](https://github.com/zichen0116)** — Xây dựng tài liệu quy tắc ứng xử cộng đồng CODE_OF_CONDUCT.md (#686)
|
||||
- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — Báo cáo tính tương thích chữ hoa/thường của ID mô hình (#729)
|
||||
- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — Báo cáo lỗi trạng thái `working...` bị kẹt, cơ chế dự phòng khay nhớ tạm (clipboard) trên Windows, sửa lỗi phiên kết nối HTTP dạng MCP Streamable, và tự động hóa brew tap (#738, #850, #1643, #1631)
|
||||
- **[reidliu41](https://github.com/reidliu41)** — Ý tưởng gợi ý tiếp tục phiên, lưu trữ độ tin cậy workspace, hỗ trợ nhà cung cấp Ollama, hoàn thiện stream khối suy nghĩ, tăng cường cache cho CI, xử lý wrap dòng stream, và hoàn thành tính năng autocomplete cho DeepSeek (#863, #870, #921, #1078, #1603, #1628, #1601)
|
||||
- **[xieshutao](https://github.com/xieshutao)** — Cơ chế dự phòng skill dạng Markdown thuần (#869)
|
||||
- **[GK012](https://github.com/GK012)** — Cơ chế dự phòng lệnh `--version` của wrapper npm (#885)
|
||||
- **[y0sif](https://github.com/y0sif)** — Xử lý đánh thức vòng lặp agent cha sau khi các sub-agent con hoàn thành tác vụ (#901)
|
||||
- **[mac119](https://github.com/mac119)** và **[leo119](https://github.com/leo119)** — Viết tài liệu hướng dẫn cho lệnh `codewhale update` (#838, #917)
|
||||
- **[dumbjack](https://github.com/dumbjack)** / **浩淼的mac** — Tăng cường bảo mật chống mã độc qua lệnh shell byte rỗng (#706, #918)
|
||||
- **macworkers** — Cải tiến xác nhận rẽ nhánh (fork) kèm mã phiên làm việc mới (#600, #919)
|
||||
- **zero** và **[zerx-lab](https://github.com/zerx-lab)** — Cấu hình điều kiện nhận thông báo và làm phong phú nội dung thông báo qua OSC 9 (#820, #920)
|
||||
- **[chnjames](https://github.com/chnjames)** — Gợi ý hoàn thành @mentions từ cache, cải tiến phục hồi file cấu hình lỗi, và hiển thị chuẩn UTF-8 cho Shell trên Windows (#849, #927, #982, #1018)
|
||||
- **[angziii](https://github.com/angziii)** — Bảo mật cấu hình, dọn dẹp tài nguyên bất đồng bộ, tăng cường bảo mật Docker và vá lỗi an toàn thực thi lệnh (#822, #824, #827, #831, #833, #835, #837)
|
||||
- **[elowen53](https://github.com/elowen53)** — Giải mã UTF-8 và bổ sung các ca kiểm thử xác định (#825, #840)
|
||||
- **[wdw8276](https://github.com/wdw8276)** — Bổ sung lệnh `/rename` để đổi tên tiêu đề phiên làm việc tùy chỉnh (#836)
|
||||
- **[banqii](https://github.com/banqii)** — Hỗ trợ đường dẫn tìm kiếm skill dạng `.cursor/skills` (#817)
|
||||
- **[junskyeed](https://github.com/junskyeed)** — Tính toán động giá trị `max_tokens` cho các yêu cầu API (#826)
|
||||
- **Hafeez Pizofreude** — Triển khai cơ chế chống tấn công SSRF trong công cụ `fetch_url` và biểu đồ lịch sử Star History.
|
||||
- **Unic (YuniqueUnic)** — Xây dựng giao diện cấu hình tự động dựa trên schema (cả TUI và web).
|
||||
- **Jason** — Tăng cường bảo mật an toàn mạng chống tấn công giả mạo yêu cầu từ phía máy chủ (SSRF).
|
||||
- **[axobase001](https://github.com/axobase001)** — Dọn dẹp snapshot mồ côi, bổ sung bộ bảo vệ khi cài npm, sửa lỗi đo lường phiên làm việc, xóa cache phạm vi mô hình, hỗ trợ các liên kết tượng trưng (symlinks) cho skill, hướng dẫn cơ chế thoát lỗi cài đặt npm mirror, và duy trì cấu hình proxy cho các tác vụ con (#975, #1032, #1047, #1049, #1052, #1019, #1051, #1056, #1608)
|
||||
- **[MengZ-super](https://github.com/MengZ-super)** — Xây dựng nền tảng cho lệnh `/theme` và giải nén dữ liệu nén dạng gzip/brotli cho kết nối SSE (#1057, #1061)
|
||||
- **[DI-HUO-MING-YI](https://github.com/DI-HUO-MING-YI)** — Vá lỗi bảo mật sandbox chỉ đọc trong chế độ Plan (#1077)
|
||||
- **[bevis-wong](https://github.com/bevis-wong)** — Cung cấp ca tái hiện chính xác lỗi tự động gửi tin khi dán văn bản kèm ký tự xuống dòng (#1073)
|
||||
- **[Duducoco](https://github.com/Duducoco)** và **[AlphaGogoo](https://github.com/AlphaGogoo)** — Xây dựng thanh menu gạch chéo cho skill và sửa lỗi bao phủ lệnh `/skills` (#1068, #1083)
|
||||
- **[ArronAI007](https://github.com/ArronAI007)** — Sửa lỗi hiển thị tài nguyên artifact khi thay đổi kích thước cửa sổ trên macOS Terminal.app và ConHost (#993)
|
||||
- **[THINKER-ONLY](https://github.com/THINKER-ONLY)** — Duy trì mã mô hình tùy chỉnh cho OpenRouter và endpoint riêng (#1066)
|
||||
- **[Jefsky](https://github.com/Jefsky)** — Báo cáo sửa lỗi địa chỉ endpoint chính thức của DeepSeek (#1079, #1084)
|
||||
- **[wlon](https://github.com/wlon)** — Chẩn đoán và ưu tiên lựa chọn khóa xác thực cho nhà cung cấp NVIDIA NIM (#1081)
|
||||
- **[Horace Liu](https://github.com/liuhq)** — Đóng gói hỗ trợ Nix package và viết tài liệu hướng dẫn cài đặt (#1173)
|
||||
- **[jieshu666](https://github.com/jieshu666)** — Giảm thiểu hiện tượng nhấp nháy màn hình khi vẽ lại giao diện TUI (#1563)
|
||||
- **[gordonlu](https://github.com/gordonlu)** — Sửa lỗi nhận dạng phím Enter / mã nhập CSI-u trên Windows (#1612)
|
||||
- **[mdrkrg](https://github.com/mdrkrg)** — Vá lỗi sập ứng dụng trong lần chạy đầu tiên khi thiếu khóa API (#1598)
|
||||
- **[Aitensa](https://github.com/Aitensa)** — Xử lý tự động xuống dòng CJK cho các khối diff và kết quả đầu ra trang giấy (#1622)
|
||||
- **[qiyan233](https://github.com/qiyan233)** — Đảm bảo tương thích với các bí danh cũ của nhà cung cấp DeepSeek Trung Quốc (#1645)
|
||||
- **[zlh124](https://github.com/zlh124)** — Báo cáo khởi động không đầu WSL2 và sửa lỗi khay nhớ tạm (#1772, #1773)
|
||||
- **[aboimpinto](https://github.com/aboimpinto)** — Sửa lỗi ghi nhật ký màn hình phụ trên Windows, hoàn thiện phím Home/End tại bộ soạn thảo và theo dõi log runtime (#1774, #1776, #1748, #1749, #1782, #1783)
|
||||
- **[LeoLin990405](https://github.com/LeoLin990405)** — Bổ sung cơ chế truyền thẳng mô hình qua provider, phát lại luồng suy nghĩ, tối ưu lượt chạy chỉ suy nghĩ, và sửa lỗi trích dẫn trên Windows (#1740, #1743, #1742, #1744)
|
||||
- **[nightt5879](https://github.com/nightt5879)** — Khắc phục lỗi khôi phục giao diện nhắc nhở khi bấm phím Ctrl+C (#1764)
|
||||
- **[donglovejava](https://github.com/donglovejava)** — Hợp nhất kéo thả dán tệp `@file`, vá lỗi sập chữ CJK, thu thập phản hồi người dùng, định tuyến RLM, và thử lại khi `edit_file` bị kẹt (#2154–#2168)
|
||||
- **[encyc](https://github.com/encyc)** — Hiển thị chi tiết số lượng token tiêu thụ ở chân trang và lệnh `/status` (#2152)
|
||||
- **[saieswar237](https://github.com/saieswar237)** — Bổ sung tài liệu hướng dẫn về quy trình review code (#2178)
|
||||
- **[sximelon](https://github.com/sximelon)** — Chặn sự kiện tự gửi tin khi dán văn bản và tách phân hệ quản lý phím bấm (#2174, #2042)
|
||||
- **[nanookclaw](https://github.com/nanookclaw)** — Bổ sung hiển thị nhà cung cấp tìm kiếm trong kết quả của lệnh doctor (#2135)
|
||||
- **[Sskift](https://github.com/Sskift)** — Ngăn chặn việc ghi đè biến môi trường mặc định trên CLI (#2119)
|
||||
- **[xin1104](https://github.com/xin1104)** — Tạo brew formula cài binary codewhale độc lập (#2105)
|
||||
- **[mrluanma](https://github.com/mrluanma)** — Bổ sung nhà cung cấp dịch vụ tìm kiếm Metaso (#2059)
|
||||
- **[Lellansin](https://github.com/Lellansin)** — Bỏ qua việc gộp cấu hình tại thư mục home người dùng (#2055)
|
||||
- **[zhuangbiaowei](https://github.com/zhuangbiaowei)** — Cập nhật các kênh phát hành chính thức của sản phẩm (#2145)
|
||||
|
||||
---
|
||||
|
||||
## Đóng góp cho dự án
|
||||
|
||||
Xem tài liệu hướng dẫn đóng góp tại [CONTRIBUTING.md](CONTRIBUTING.md). Chúng tôi luôn hoan nghênh các yêu cầu kéo Pull Requests — vui lòng xem danh sách các [vấn đề mở (open issues)](https://github.com/Hmbown/CodeWhale/issues) để bắt đầu đóng góp những phần việc đầu tiên.
|
||||
|
||||
Ủng hộ nhà phát triển: [Buy me a coffee](https://www.buymeacoffee.com/hmbown).
|
||||
|
||||
> [!Note]
|
||||
> *Dự án này độc lập và không trực thuộc công ty DeepSeek Inc.*
|
||||
|
||||
## Bản quyền
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Biểu đồ Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date&logscale=&legend=top-left)
|
||||
+10
-3
@@ -4,6 +4,8 @@
|
||||
|
||||
[English README](README.md)
|
||||
[日本語 README](README.ja-JP.md)
|
||||
[Tiếng Việt README](README.vi.md)
|
||||
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -103,7 +105,7 @@ Fin——关闭思考的廉价 Flash 调用——每轮处理模型自动路由
|
||||
|
||||
子智能体并发运行(最多 20 个)。`agent_open` 立即返回;结果以内联完成哨兵形式到达,携带摘要。完整对话记录通过 `agent_eval` 的有界句柄保存。详见 [docs/SUBAGENTS.md](docs/SUBAGENTS.md)。
|
||||
|
||||
其余功能面:每次编辑后的 LSP 诊断(rust-analyzer、pyright、typescript-language-server、gopls、clangd)、RLM 会话批量分析、MCP 协议、HTTP/SSE 运行时 API、持久化任务队列、Zed 的 ACP 适配器、SWE-bench 导出、以及带缓存命中/未命中明细的实时成本追踪。
|
||||
其余功能面:每次编辑后的 LSP 诊断(rust-analyzer、pyright、typescript-language-server、gopls、clangd、jdtls、vue-language-server)、RLM 会话批量分析、MCP 协议、HTTP/SSE 运行时 API、持久化任务队列、Zed 的 ACP 适配器、SWE-bench 导出、以及带缓存命中/未命中明细的实时成本追踪。
|
||||
|
||||
---
|
||||
|
||||
@@ -259,6 +261,10 @@ codewhale --provider wanjie-ark --model deepseek-reasoner
|
||||
codewhale auth set --provider openrouter --api-key "YOUR_OPENROUTER_API_KEY"
|
||||
codewhale --provider openrouter --model deepseek/deepseek-v4-pro
|
||||
|
||||
# Xiaomi MiMo
|
||||
codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY"
|
||||
codewhale --provider xiaomi-mimo --model mimo-v2.5-pro
|
||||
|
||||
# Novita
|
||||
codewhale auth set --provider novita --api-key "YOUR_NOVITA_API_KEY"
|
||||
codewhale --provider novita --model deepseek/deepseek-v4-pro
|
||||
@@ -400,15 +406,16 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等
|
||||
| `DEEPSEEK_HTTP_HEADERS` | 可选模型请求头,例如 `X-Model-Provider-Id=your-model-provider` |
|
||||
| `DEEPSEEK_MODEL` | 默认模型 |
|
||||
| `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | 流式响应空闲超时秒数,默认 `300`,限制在 `1..=3600` |
|
||||
| `DEEPSEEK_PROVIDER` | `codewhale`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` |
|
||||
| `DEEPSEEK_PROVIDER` | `codewhale`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`xiaomi-mimo`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` |
|
||||
| `DEEPSEEK_PROFILE` | 配置 profile 名称 |
|
||||
| `DEEPSEEK_MEMORY` | 设为 `on` 启用用户记忆 |
|
||||
| `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 在可信网络上允许非本机 `http://` API base URL |
|
||||
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 |
|
||||
| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 |
|
||||
| `OPENAI_BASE_URL` / `OPENAI_MODEL` | 通用 OpenAI 兼容端点和模型 ID |
|
||||
| `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud 端点和模型覆盖 |
|
||||
| `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark 端点和模型覆盖 |
|
||||
| `OPENROUTER_BASE_URL` | OpenRouter 端点覆盖 |
|
||||
| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo 端点和模型覆盖 |
|
||||
| `NOVITA_BASE_URL` | Novita 端点覆盖 |
|
||||
| `FIREWORKS_BASE_URL` | Fireworks 端点覆盖 |
|
||||
| `SGLANG_BASE_URL` | 自托管 SGLang 端点 |
|
||||
|
||||
+36
-3
@@ -13,11 +13,12 @@
|
||||
# `[providers.*]` sections near the bottom of
|
||||
# this file — keeping both stored at once means `/provider deepseek` and
|
||||
# `/provider nvidia-nim` (or `--provider openai`, `--provider wanjie-ark`,
|
||||
# `--provider fireworks`, `/provider sglang`, `/provider vllm`, `/provider ollama`)
|
||||
# toggle without having to re-enter keys. Top-level `api_key` / `base_url` are
|
||||
# `--provider xiaomi-mimo`, `--provider fireworks`, `/provider sglang`,
|
||||
# `/provider vllm`, `/provider ollama`) toggle without having to re-enter keys.
|
||||
# Top-level `api_key` / `base_url` are
|
||||
# still read as DeepSeek defaults when `[providers.deepseek]` is absent
|
||||
# (backward compatibility).
|
||||
provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | openrouter | novita | fireworks | sglang | vllm | ollama
|
||||
provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | openrouter | xiaomi-mimo | novita | fireworks | sglang | vllm | ollama
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
base_url = "https://api.deepseek.com/beta"
|
||||
# provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com)
|
||||
@@ -37,6 +38,7 @@ base_url = "https://api.deepseek.com/beta"
|
||||
# gpt-4.1 — default generic OpenAI-compatible model ID
|
||||
# deepseek-ai/deepseek-v4-flash — default AtlasCloud model ID
|
||||
# deepseek-reasoner — default Wanjie Ark model ID
|
||||
# mimo-v2.5-pro — default Xiaomi MiMo model ID
|
||||
# accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID
|
||||
# deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID
|
||||
# deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID
|
||||
@@ -186,17 +188,27 @@ max_subagents = 10 # optional (1-20)
|
||||
# OpenAI-compatible: OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL
|
||||
# Wanjie Ark: WANJIE_ARK_API_KEY (or WANJIE_API_KEY), WANJIE_ARK_BASE_URL, WANJIE_ARK_MODEL
|
||||
# OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL
|
||||
# Xiaomi MiMo: XIAOMI_MIMO_API_KEY (or MIMO_API_KEY), XIAOMI_MIMO_BASE_URL, XIAOMI_MIMO_MODEL
|
||||
# Novita: NOVITA_API_KEY, NOVITA_BASE_URL, NOVITA_MODEL
|
||||
# Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL
|
||||
# SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY
|
||||
# vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY
|
||||
# Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, optional OLLAMA_API_KEY
|
||||
#
|
||||
# Custom DeepSeek-compatible APIs usually do not need a new provider table:
|
||||
# set `provider = "deepseek"` and override [providers.deepseek].base_url/model.
|
||||
# For generic OpenAI-compatible gateways, use `provider = "openai"` and the
|
||||
# [providers.openai] table below. Keep provider/api_key/base_url in user config
|
||||
# or environment variables; project overlays are not allowed to set them.
|
||||
|
||||
# DeepSeek Platform (https://platform.deepseek.com)
|
||||
[providers.deepseek]
|
||||
# api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
# base_url = "https://api.deepseek.com/beta"
|
||||
# model = "deepseek-v4-pro"
|
||||
# Custom DeepSeek-compatible example:
|
||||
# base_url = "https://your-provider.example/v1"
|
||||
# model = "deepseek-ai/DeepSeek-V4-Pro"
|
||||
# http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
|
||||
@@ -213,6 +225,9 @@ max_subagents = 10 # optional (1-20)
|
||||
# api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY"
|
||||
# base_url = "https://api.openai.com/v1"
|
||||
# model = "gpt-4.1"
|
||||
# Gateway example:
|
||||
# base_url = "https://gateway.example/v1"
|
||||
# model = "your-deepseek-compatible-model"
|
||||
|
||||
# AtlasCloud OpenAI-compatible endpoint (https://www.atlascloud.ai/docs/models/llm)
|
||||
[providers.atlascloud]
|
||||
@@ -232,6 +247,12 @@ max_subagents = 10 # optional (1-20)
|
||||
# base_url = "https://openrouter.ai/api/v1"
|
||||
# model = "deepseek/deepseek-v4-pro" # or deepseek/deepseek-v4-flash
|
||||
|
||||
# Xiaomi MiMo OpenAI-compatible endpoint (https://platform.xiaomimimo.com)
|
||||
[providers.xiaomi_mimo]
|
||||
# api_key = "YOUR_XIAOMI_MIMO_API_KEY"
|
||||
# base_url = "https://api.xiaomimimo.com/v1"
|
||||
# model = "mimo-v2.5-pro"
|
||||
|
||||
# Novita AI-hosted inference (https://novita.ai)
|
||||
[providers.novita]
|
||||
# api_key = "YOUR_NOVITA_API_KEY"
|
||||
@@ -372,6 +393,11 @@ exec_policy = true
|
||||
# model = "gemini-3.1-flash-lite-preview" # Required: vision-capable model ID
|
||||
# api_key = "YOUR_API_KEY" # Optional: defaults to main api_key
|
||||
# base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" # Optional
|
||||
#
|
||||
# Xiaomi MiMo image understanding can be configured through the same tool:
|
||||
# model = "mimo-v2.5"
|
||||
# api_key = "YOUR_XIAOMI_MIMO_API_KEY"
|
||||
# base_url = "https://api.xiaomimimo.com/v1"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Retry Configuration
|
||||
@@ -535,8 +561,13 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
# go → gopls serve
|
||||
# python → pyright-langserver --stdio
|
||||
# typescript → typescript-language-server --stdio
|
||||
# java → jdtls
|
||||
# vue → vue-language-server --stdio
|
||||
# c, cpp → clangd
|
||||
#
|
||||
# Java support uses Eclipse JDT LS via the `jdtls` command. IntelliJ IDEA is
|
||||
# not required, and installing IntelliJ IDEA alone does not install `jdtls`.
|
||||
#
|
||||
# Override the defaults via the `servers` table below.
|
||||
[lsp]
|
||||
# enabled = true
|
||||
@@ -546,6 +577,8 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
|
||||
# [lsp.servers]
|
||||
# rust = ["rust-analyzer"]
|
||||
# go = ["gopls", "serve"]
|
||||
# java = ["jdtls"]
|
||||
# vue = ["vue-language-server", "--stdio"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Hooks (optional)
|
||||
|
||||
@@ -119,6 +119,20 @@ impl Default for ModelRegistry {
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mimo-v2.5-pro".to_string(),
|
||||
provider: ProviderKind::XiaomiMimo,
|
||||
aliases: vec!["mimo".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mimo-v2.5".to_string(),
|
||||
provider: ProviderKind::XiaomiMimo,
|
||||
aliases: vec!["xiaomi-mimo-v2.5".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek/deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Novita,
|
||||
@@ -382,6 +396,16 @@ mod tests {
|
||||
assert_eq!(resolved.resolved.id, "deepseek/deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_default_uses_canonical_model_id() {
|
||||
let registry = ModelRegistry::default();
|
||||
let resolved = registry.resolve(None, Some(ProviderKind::XiaomiMimo));
|
||||
|
||||
assert_eq!(resolved.resolved.provider, ProviderKind::XiaomiMimo);
|
||||
assert_eq!(resolved.resolved.id, "mimo-v2.5-pro");
|
||||
assert!(resolved.resolved.supports_reasoning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wanjie_ark_default_uses_reasoner_model_id() {
|
||||
let registry = ModelRegistry::default();
|
||||
|
||||
@@ -30,6 +30,7 @@ codewhale-app-server = { path = "../app-server", version = "0.8.46" }
|
||||
codewhale-config = { path = "../config", version = "0.8.46" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.46" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.46" }
|
||||
codewhale-release = { path = "../release", version = "0.8.46" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.46" }
|
||||
codewhale-state = { path = "../state", version = "0.8.46" }
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -37,12 +37,12 @@ fn spawn_codewhale(args: &[String]) -> std::io::Result<std::process::ExitStatus>
|
||||
// same directory as this shim but not on PATH (#2006).
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(exe_path) = env::current_exe() {
|
||||
if let Some(dir) = exe_path.parent() {
|
||||
let sibling = dir.join("codewhale.exe");
|
||||
if sibling.is_file() {
|
||||
return Command::new(sibling).args(args).status();
|
||||
}
|
||||
if let Ok(exe_path) = env::current_exe()
|
||||
&& let Some(dir) = exe_path.parent()
|
||||
{
|
||||
let sibling = dir.join("codewhale.exe");
|
||||
if sibling.is_file() {
|
||||
return Command::new(sibling).args(args).status();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,12 @@ fn spawn_codewhale(args: &[String]) -> std::io::Result<std::process::ExitStatus>
|
||||
// same directory as this shim but not on PATH (#2006).
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(exe_path) = env::current_exe() {
|
||||
if let Some(dir) = exe_path.parent() {
|
||||
let sibling = dir.join("codewhale.exe");
|
||||
if sibling.is_file() {
|
||||
return Command::new(sibling).args(args).status();
|
||||
}
|
||||
if let Ok(exe_path) = env::current_exe()
|
||||
&& let Some(dir) = exe_path.parent()
|
||||
{
|
||||
let sibling = dir.join("codewhale.exe");
|
||||
if sibling.is_file() {
|
||||
return Command::new(sibling).args(args).status();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-7
@@ -29,6 +29,7 @@ enum ProviderArg {
|
||||
Atlascloud,
|
||||
WanjieArk,
|
||||
Openrouter,
|
||||
XiaomiMimo,
|
||||
Novita,
|
||||
Fireworks,
|
||||
Moonshot,
|
||||
@@ -46,6 +47,7 @@ impl From<ProviderArg> for ProviderKind {
|
||||
ProviderArg::Atlascloud => ProviderKind::Atlascloud,
|
||||
ProviderArg::WanjieArk => ProviderKind::WanjieArk,
|
||||
ProviderArg::Openrouter => ProviderKind::Openrouter,
|
||||
ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
|
||||
ProviderArg::Novita => ProviderKind::Novita,
|
||||
ProviderArg::Fireworks => ProviderKind::Fireworks,
|
||||
ProviderArg::Moonshot => ProviderKind::Moonshot,
|
||||
@@ -240,6 +242,9 @@ struct UpdateArgs {
|
||||
/// Update to the latest beta release instead of the latest stable release.
|
||||
#[arg(long)]
|
||||
beta: bool,
|
||||
/// Only check the latest release; do not download or replace binaries.
|
||||
#[arg(long)]
|
||||
check: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -569,7 +574,7 @@ fn run() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
Some(Commands::Metrics(args)) => run_metrics_command(args),
|
||||
Some(Commands::Update(args)) => update::run_update(args.beta),
|
||||
Some(Commands::Update(args)) => update::run_update(args.beta, args.check),
|
||||
None => {
|
||||
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
||||
let forwarded = root_tui_passthrough(&cli)?;
|
||||
@@ -718,6 +723,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::Atlascloud => "atlascloud",
|
||||
ProviderKind::WanjieArk => "wanjie-ark",
|
||||
ProviderKind::Openrouter => "openrouter",
|
||||
ProviderKind::XiaomiMimo => "xiaomi-mimo",
|
||||
ProviderKind::Novita => "novita",
|
||||
ProviderKind::Fireworks => "fireworks",
|
||||
ProviderKind::Moonshot => "moonshot",
|
||||
@@ -728,13 +734,14 @@ fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
}
|
||||
|
||||
/// Provider order used by the `auth list` and `auth status` outputs.
|
||||
const PROVIDER_LIST: [ProviderKind; 12] = [
|
||||
const PROVIDER_LIST: [ProviderKind; 13] = [
|
||||
ProviderKind::Deepseek,
|
||||
ProviderKind::NvidiaNim,
|
||||
ProviderKind::Openai,
|
||||
ProviderKind::Atlascloud,
|
||||
ProviderKind::WanjieArk,
|
||||
ProviderKind::Openrouter,
|
||||
ProviderKind::XiaomiMimo,
|
||||
ProviderKind::Novita,
|
||||
ProviderKind::Fireworks,
|
||||
ProviderKind::Moonshot,
|
||||
@@ -789,6 +796,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
|
||||
match provider {
|
||||
ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
|
||||
ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
|
||||
ProviderKind::XiaomiMimo => &["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"],
|
||||
ProviderKind::Novita => &["NOVITA_API_KEY"],
|
||||
ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
|
||||
ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
|
||||
@@ -1473,6 +1481,7 @@ fn build_tui_command(
|
||||
| ProviderKind::Atlascloud
|
||||
| ProviderKind::WanjieArk
|
||||
| ProviderKind::Openrouter
|
||||
| ProviderKind::XiaomiMimo
|
||||
| ProviderKind::Novita
|
||||
| ProviderKind::Fireworks
|
||||
| ProviderKind::Moonshot
|
||||
@@ -1481,7 +1490,7 @@ fn build_tui_command(
|
||||
| ProviderKind::Ollama
|
||||
) {
|
||||
bail!(
|
||||
"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.",
|
||||
"The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Xiaomi MiMo, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.",
|
||||
resolved_runtime.provider.as_str()
|
||||
);
|
||||
}
|
||||
@@ -1817,13 +1826,28 @@ mod tests {
|
||||
let cli = parse_ok(&["codewhale", "update"]);
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Some(Commands::Update(UpdateArgs { beta: false }))
|
||||
Some(Commands::Update(UpdateArgs {
|
||||
beta: false,
|
||||
check: false
|
||||
}))
|
||||
));
|
||||
|
||||
let cli = parse_ok(&["codewhale", "update", "--beta"]);
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Some(Commands::Update(UpdateArgs { beta: true }))
|
||||
Some(Commands::Update(UpdateArgs {
|
||||
beta: true,
|
||||
check: false
|
||||
}))
|
||||
));
|
||||
|
||||
let cli = parse_ok(&["codewhale", "update", "--check"]);
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Some(Commands::Update(UpdateArgs {
|
||||
beta: false,
|
||||
check: true
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2879,6 +2903,11 @@ mod tests {
|
||||
"openrouter",
|
||||
&["OPENROUTER_API_KEY"],
|
||||
),
|
||||
(
|
||||
ProviderKind::XiaomiMimo,
|
||||
"xiaomi-mimo",
|
||||
&["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"],
|
||||
),
|
||||
(ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]),
|
||||
(
|
||||
ProviderKind::NvidiaNim,
|
||||
@@ -2958,11 +2987,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_top_level_prompt_flag_for_canonical_one_shot() {
|
||||
fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
|
||||
let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
|
||||
|
||||
assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
|
||||
assert!(cli.prompt.is_empty());
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2976,7 +3009,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_continue_rejects_one_shot_prompt() {
|
||||
fn top_level_continue_rejects_startup_prompt() {
|
||||
let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
|
||||
|
||||
let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
|
||||
@@ -2992,6 +3025,10 @@ mod tests {
|
||||
|
||||
assert_eq!(cli.prompt, vec!["hello", "world"]);
|
||||
assert!(cli.command.is_none());
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--prompt".to_string(), "hello world".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3000,6 +3037,10 @@ mod tests {
|
||||
|
||||
assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
|
||||
assert_eq!(cli.prompt, vec!["world"]);
|
||||
assert_eq!(
|
||||
root_tui_passthrough(&cli).unwrap(),
|
||||
vec!["--prompt".to_string(), "hello world".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+61
-192
@@ -5,28 +5,21 @@
|
||||
//! platform-correct binary, verifies its SHA256 checksum, and atomically
|
||||
//! replaces the currently running binary.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use codewhale_release::{
|
||||
CHECKSUM_MANIFEST_ASSET, ReleaseChannel, ReleaseQuery, UPDATE_USER_AGENT,
|
||||
compare_release_versions, fetch_release_json_blocking, is_beta_tag,
|
||||
latest_release_tag_blocking, mirror_asset_url, resolve_release_query, update_is_needed,
|
||||
update_network_fallback_hint,
|
||||
};
|
||||
use std::io::Write;
|
||||
|
||||
const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
|
||||
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases/latest";
|
||||
const RELEASES_URL: &str = "https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100";
|
||||
const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
|
||||
const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL";
|
||||
const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
|
||||
const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
|
||||
const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR";
|
||||
/// Base URL for CNB binary release asset downloads (China-friendly mirror).
|
||||
const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases";
|
||||
const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
|
||||
const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
|
||||
const UPDATE_USER_AGENT: &str = "codewhale-updater";
|
||||
|
||||
/// Run the self-update workflow.
|
||||
pub fn run_update(beta: bool) -> Result<()> {
|
||||
pub fn run_update(beta: bool, check_only: bool) -> Result<()> {
|
||||
let current_exe =
|
||||
std::env::current_exe().context("failed to determine current executable path")?;
|
||||
let targets = update_targets_for_exe(¤t_exe);
|
||||
@@ -37,13 +30,32 @@ pub fn run_update(beta: bool) -> Result<()> {
|
||||
println!("Current binary: {}", current_exe.display());
|
||||
println!("Current version: v{current_version}");
|
||||
|
||||
if check_only {
|
||||
let latest_tag =
|
||||
latest_release_tag_blocking(channel).with_context(update_network_fallback_hint)?;
|
||||
println!("Latest {} release: {latest_tag}", channel.label());
|
||||
if update_is_needed(channel, current_version, &latest_tag)? {
|
||||
println!("Update available. Run `codewhale update` to install {latest_tag}.");
|
||||
} else {
|
||||
match compare_release_versions(current_version, &latest_tag)? {
|
||||
Ordering::Greater => {
|
||||
println!("Current build is newer than the latest published release.");
|
||||
}
|
||||
Ordering::Less | Ordering::Equal => {
|
||||
println!("Already up to date.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Step 1: Fetch latest release metadata
|
||||
let fetched = fetch_latest_release(channel).with_context(update_network_fallback_hint)?;
|
||||
let release = &fetched.release;
|
||||
let latest_tag = &release.tag_name;
|
||||
println!("Latest {} release: {latest_tag}", channel.label());
|
||||
|
||||
if let ReleaseSource::Mirror { base_url } = &fetched.source {
|
||||
if let UpdateReleaseSource::Mirror { base_url } = &fetched.source {
|
||||
if channel == ReleaseChannel::Beta {
|
||||
println!(
|
||||
"Using release mirror {}; --beta does not select GitHub beta releases in mirror mode.",
|
||||
@@ -143,33 +155,14 @@ pub fn run_update(beta: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ReleaseChannel {
|
||||
Stable,
|
||||
Beta,
|
||||
}
|
||||
|
||||
impl ReleaseChannel {
|
||||
fn from_beta_flag(beta: bool) -> Self {
|
||||
if beta { Self::Beta } else { Self::Stable }
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Beta => "beta",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct FetchedRelease {
|
||||
release: Release,
|
||||
source: ReleaseSource,
|
||||
source: UpdateReleaseSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum ReleaseSource {
|
||||
enum UpdateReleaseSource {
|
||||
GitHub,
|
||||
Mirror { base_url: String },
|
||||
}
|
||||
@@ -351,63 +344,25 @@ fn update_http_client() -> Result<reqwest::blocking::Client> {
|
||||
|
||||
/// Fetch the latest release metadata from GitHub.
|
||||
fn fetch_latest_release(channel: ReleaseChannel) -> Result<FetchedRelease> {
|
||||
let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
|
||||
if let Some(base_url) = release_base_url_from_env(&version) {
|
||||
return Ok(FetchedRelease {
|
||||
match resolve_release_query(channel) {
|
||||
ReleaseQuery::Mirror { base_url, version } => Ok(FetchedRelease {
|
||||
release: release_from_mirror_base_url(
|
||||
&base_url,
|
||||
&version,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
),
|
||||
source: ReleaseSource::Mirror { base_url },
|
||||
});
|
||||
source: UpdateReleaseSource::Mirror { base_url },
|
||||
}),
|
||||
ReleaseQuery::GitHubLatest { url } => Ok(FetchedRelease {
|
||||
release: fetch_latest_release_from_url(url)?,
|
||||
source: UpdateReleaseSource::GitHub,
|
||||
}),
|
||||
ReleaseQuery::GitHubReleaseList { url } => Ok(FetchedRelease {
|
||||
release: fetch_latest_beta_release_from_url(url)?,
|
||||
source: UpdateReleaseSource::GitHub,
|
||||
}),
|
||||
}
|
||||
let release = match channel {
|
||||
ReleaseChannel::Stable => fetch_latest_release_from_url(LATEST_RELEASE_URL),
|
||||
ReleaseChannel::Beta => fetch_latest_beta_release_from_url(RELEASES_URL),
|
||||
}?;
|
||||
Ok(FetchedRelease {
|
||||
release,
|
||||
source: ReleaseSource::GitHub,
|
||||
})
|
||||
}
|
||||
|
||||
fn release_base_url_from_env(version: &str) -> Option<String> {
|
||||
// Check canonical env first, then legacy envs
|
||||
for env_name in [
|
||||
RELEASE_BASE_URL_ENV,
|
||||
LEGACY_RELEASE_BASE_URL_ENV,
|
||||
DEEPSEEK_RELEASE_BASE_URL_ENV,
|
||||
] {
|
||||
if let Ok(value) = std::env::var(env_name) {
|
||||
let trimmed = value.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-detect CNB mirror when CODEWHALE_USE_CNB_MIRROR is set
|
||||
if std::env::var(CNB_MIRROR_ENV).is_ok() {
|
||||
return Some(cnb_release_base_url(version));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cnb_release_base_url(version: &str) -> String {
|
||||
format!(
|
||||
"{}/v{}",
|
||||
CNB_RELEASE_ASSET_BASE.trim_end_matches('/'),
|
||||
version.trim_start_matches('v')
|
||||
)
|
||||
}
|
||||
|
||||
fn update_version_from_env() -> Option<String> {
|
||||
std::env::var(UPDATE_VERSION_ENV)
|
||||
.ok()
|
||||
.or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok())
|
||||
.map(|value| value.trim().trim_start_matches('v').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn release_from_mirror_base_url(
|
||||
@@ -437,39 +392,8 @@ fn release_from_mirror_base_url(
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
|
||||
}
|
||||
|
||||
fn update_network_fallback_hint() -> String {
|
||||
format!(
|
||||
"GitHub release downloads may be blocked or slow on this network.\n\
|
||||
For mainland China, use one of these fallback paths:\n\
|
||||
1. Source build from the CNB mirror, installing both shipped binaries:\n\
|
||||
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
|
||||
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\
|
||||
2. Use a binary asset mirror:\n\
|
||||
{RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\
|
||||
The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries."
|
||||
)
|
||||
}
|
||||
|
||||
fn fetch_latest_release_from_url(url: &str) -> Result<Release> {
|
||||
let client = update_http_client()?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.send()
|
||||
.with_context(|| format!("failed to fetch release info from {url}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.with_context(|| format!("failed to read release response from {url}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
bail!("GitHub release request failed with HTTP {status}: {body}");
|
||||
}
|
||||
|
||||
let body = fetch_release_json_blocking(url, "release info")?;
|
||||
let release: Release = serde_json::from_str(&body).with_context(|| {
|
||||
format!("failed to parse release JSON from GitHub API. Response: {body}")
|
||||
})?;
|
||||
@@ -478,21 +402,7 @@ fn fetch_latest_release_from_url(url: &str) -> Result<Release> {
|
||||
}
|
||||
|
||||
fn fetch_latest_beta_release_from_url(url: &str) -> Result<Release> {
|
||||
let client = update_http_client()?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.send()
|
||||
.with_context(|| format!("failed to fetch release list from {url}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.with_context(|| format!("failed to read release list response from {url}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
bail!("GitHub release list request failed with HTTP {status}: {body}");
|
||||
}
|
||||
|
||||
let body = fetch_release_json_blocking(url, "release list")?;
|
||||
// GitHub caps this endpoint at 100 releases per page. CodeWhale uses the
|
||||
// first page as the latest-beta search window, matching GitHub's ordering.
|
||||
let releases: Vec<Release> = serde_json::from_str(&body).with_context(|| {
|
||||
@@ -501,57 +411,10 @@ fn fetch_latest_beta_release_from_url(url: &str) -> Result<Release> {
|
||||
|
||||
releases
|
||||
.into_iter()
|
||||
.find(is_beta_release)
|
||||
.find(|release| is_beta_tag(&release.tag_name))
|
||||
.context("no beta release found in GitHub releases")
|
||||
}
|
||||
|
||||
fn is_beta_release(release: &Release) -> bool {
|
||||
release.tag_name.to_ascii_lowercase().contains("beta")
|
||||
}
|
||||
|
||||
fn update_is_needed(
|
||||
channel: ReleaseChannel,
|
||||
current_version: &str,
|
||||
latest_tag: &str,
|
||||
) -> Result<bool> {
|
||||
let current = parse_release_version(current_version)
|
||||
.with_context(|| format!("failed to parse current version {current_version:?}"))?;
|
||||
let latest = parse_release_version(latest_tag)
|
||||
.with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
|
||||
|
||||
match channel {
|
||||
ReleaseChannel::Stable => Ok(current < latest),
|
||||
ReleaseChannel::Beta => {
|
||||
if current == latest {
|
||||
return Ok(false);
|
||||
}
|
||||
let latest_is_beta = version_is_beta(&latest);
|
||||
let current_is_stable = current.pre.is_empty();
|
||||
let same_release_line = current.major == latest.major
|
||||
&& current.minor == latest.minor
|
||||
&& current.patch == latest.patch;
|
||||
if current > latest && !(current_is_stable && same_release_line) {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(latest_is_beta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_release_version(value: &str) -> Result<semver::Version> {
|
||||
let version = value
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}"))
|
||||
}
|
||||
|
||||
fn version_is_beta(version: &semver::Version) -> bool {
|
||||
version.pre.as_str().to_ascii_lowercase().contains("beta")
|
||||
}
|
||||
|
||||
/// Download a URL to bytes.
|
||||
fn download_url(url: &str) -> Result<Vec<u8>> {
|
||||
let client = update_http_client()?;
|
||||
@@ -1004,11 +867,11 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
#[test]
|
||||
fn cnb_release_base_url_includes_tag_directory() {
|
||||
assert_eq!(
|
||||
cnb_release_base_url("0.8.47"),
|
||||
codewhale_release::cnb_release_base_url("0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
);
|
||||
assert_eq!(
|
||||
cnb_release_base_url("v0.8.47"),
|
||||
codewhale_release::cnb_release_base_url("v0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
);
|
||||
}
|
||||
@@ -1037,11 +900,11 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
#[test]
|
||||
fn parse_release_version_accepts_tags_and_build_suffixes() {
|
||||
assert_eq!(
|
||||
parse_release_version("v0.9.0-beta.1").unwrap(),
|
||||
codewhale_release::parse_release_version("v0.9.0-beta.1").unwrap(),
|
||||
semver::Version::parse("0.9.0-beta.1").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
parse_release_version("0.8.45 (abcdef123456)").unwrap(),
|
||||
codewhale_release::parse_release_version("0.8.45 (abcdef123456)").unwrap(),
|
||||
semver::Version::parse("0.8.45").unwrap()
|
||||
);
|
||||
}
|
||||
@@ -1064,18 +927,24 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win
|
||||
assets: vec![],
|
||||
};
|
||||
|
||||
assert!(!is_beta_release(&rc_prerelease));
|
||||
assert!(is_beta_release(&beta_tag));
|
||||
assert!(!is_beta_release(&stable));
|
||||
assert!(!is_beta_tag(&rc_prerelease.tag_name));
|
||||
assert!(is_beta_tag(&beta_tag.tag_name));
|
||||
assert!(!is_beta_tag(&stable.tag_name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() {
|
||||
let hint = update_network_fallback_hint();
|
||||
|
||||
assert!(hint.contains(CNB_REPO_URL), "{hint}");
|
||||
assert!(hint.contains(RELEASE_BASE_URL_ENV), "{hint}");
|
||||
assert!(hint.contains(UPDATE_VERSION_ENV), "{hint}");
|
||||
assert!(hint.contains(codewhale_release::CNB_REPO_URL), "{hint}");
|
||||
assert!(
|
||||
hint.contains(codewhale_release::RELEASE_BASE_URL_ENV),
|
||||
"{hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains(codewhale_release::UPDATE_VERSION_ENV),
|
||||
"{hint}"
|
||||
);
|
||||
assert!(hint.contains("codewhale-cli"), "{hint}");
|
||||
assert!(hint.contains("codewhale-tui --locked"), "{hint}");
|
||||
}
|
||||
|
||||
+143
-1
@@ -27,6 +27,7 @@ const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
|
||||
const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
|
||||
const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
|
||||
const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
|
||||
const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
|
||||
const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
|
||||
const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
|
||||
const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
|
||||
@@ -37,6 +38,7 @@ const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
|
||||
const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
|
||||
const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
|
||||
const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||
const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
|
||||
const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1";
|
||||
const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
|
||||
const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
|
||||
@@ -71,6 +73,8 @@ pub enum ProviderKind {
|
||||
)]
|
||||
WanjieArk,
|
||||
Openrouter,
|
||||
#[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
|
||||
XiaomiMimo,
|
||||
Novita,
|
||||
Fireworks,
|
||||
Moonshot,
|
||||
@@ -89,6 +93,7 @@ impl ProviderKind {
|
||||
Self::Atlascloud => "atlascloud",
|
||||
Self::WanjieArk => "wanjie-ark",
|
||||
Self::Openrouter => "openrouter",
|
||||
Self::XiaomiMimo => "xiaomi-mimo",
|
||||
Self::Novita => "novita",
|
||||
Self::Fireworks => "fireworks",
|
||||
Self::Moonshot => "moonshot",
|
||||
@@ -109,6 +114,9 @@ impl ProviderKind {
|
||||
"wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
|
||||
| "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk),
|
||||
"openrouter" | "open_router" => Some(Self::Openrouter),
|
||||
"xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
|
||||
Some(Self::XiaomiMimo)
|
||||
}
|
||||
"novita" => Some(Self::Novita),
|
||||
"fireworks" | "fireworks-ai" => Some(Self::Fireworks),
|
||||
"moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot),
|
||||
@@ -145,6 +153,8 @@ pub struct ProvidersToml {
|
||||
#[serde(default)]
|
||||
pub openrouter: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub xiaomi_mimo: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub novita: ProviderConfigToml,
|
||||
#[serde(default)]
|
||||
pub fireworks: ProviderConfigToml,
|
||||
@@ -168,6 +178,7 @@ impl ProvidersToml {
|
||||
ProviderKind::Atlascloud => &self.atlascloud,
|
||||
ProviderKind::WanjieArk => &self.wanjie_ark,
|
||||
ProviderKind::Openrouter => &self.openrouter,
|
||||
ProviderKind::XiaomiMimo => &self.xiaomi_mimo,
|
||||
ProviderKind::Novita => &self.novita,
|
||||
ProviderKind::Fireworks => &self.fireworks,
|
||||
ProviderKind::Moonshot => &self.moonshot,
|
||||
@@ -185,6 +196,7 @@ impl ProvidersToml {
|
||||
ProviderKind::Atlascloud => &mut self.atlascloud,
|
||||
ProviderKind::WanjieArk => &mut self.wanjie_ark,
|
||||
ProviderKind::Openrouter => &mut self.openrouter,
|
||||
ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo,
|
||||
ProviderKind::Novita => &mut self.novita,
|
||||
ProviderKind::Fireworks => &mut self.fireworks,
|
||||
ProviderKind::Moonshot => &mut self.moonshot,
|
||||
@@ -405,6 +417,10 @@ impl ConfigToml {
|
||||
&mut self.providers.openrouter,
|
||||
&project.providers.openrouter,
|
||||
);
|
||||
merge_project_provider_config(
|
||||
&mut self.providers.xiaomi_mimo,
|
||||
&project.providers.xiaomi_mimo,
|
||||
);
|
||||
merge_project_provider_config(&mut self.providers.novita, &project.providers.novita);
|
||||
merge_project_provider_config(&mut self.providers.fireworks, &project.providers.fireworks);
|
||||
merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang);
|
||||
@@ -464,6 +480,12 @@ impl ConfigToml {
|
||||
"providers.openrouter.http_headers" => {
|
||||
serialize_http_headers(&self.providers.openrouter.http_headers)
|
||||
}
|
||||
"providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key.clone(),
|
||||
"providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url.clone(),
|
||||
"providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model.clone(),
|
||||
"providers.xiaomi_mimo.http_headers" => {
|
||||
serialize_http_headers(&self.providers.xiaomi_mimo.http_headers)
|
||||
}
|
||||
"providers.novita.api_key" => self.providers.novita.api_key.clone(),
|
||||
"providers.novita.base_url" => self.providers.novita.base_url.clone(),
|
||||
"providers.novita.model" => self.providers.novita.model.clone(),
|
||||
@@ -609,6 +631,18 @@ impl ConfigToml {
|
||||
"providers.openrouter.http_headers" => {
|
||||
self.providers.openrouter.http_headers = parse_http_headers(value)?;
|
||||
}
|
||||
"providers.xiaomi_mimo.api_key" => {
|
||||
self.providers.xiaomi_mimo.api_key = Some(value.to_string());
|
||||
}
|
||||
"providers.xiaomi_mimo.base_url" => {
|
||||
self.providers.xiaomi_mimo.base_url = Some(value.to_string());
|
||||
}
|
||||
"providers.xiaomi_mimo.model" => {
|
||||
self.providers.xiaomi_mimo.model = Some(value.to_string());
|
||||
}
|
||||
"providers.xiaomi_mimo.http_headers" => {
|
||||
self.providers.xiaomi_mimo.http_headers = parse_http_headers(value)?;
|
||||
}
|
||||
"providers.novita.api_key" => {
|
||||
self.providers.novita.api_key = Some(value.to_string());
|
||||
}
|
||||
@@ -744,6 +778,12 @@ impl ConfigToml {
|
||||
"providers.openrouter.base_url" => self.providers.openrouter.base_url = None,
|
||||
"providers.openrouter.model" => self.providers.openrouter.model = None,
|
||||
"providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(),
|
||||
"providers.xiaomi_mimo.api_key" => self.providers.xiaomi_mimo.api_key = None,
|
||||
"providers.xiaomi_mimo.base_url" => self.providers.xiaomi_mimo.base_url = None,
|
||||
"providers.xiaomi_mimo.model" => self.providers.xiaomi_mimo.model = None,
|
||||
"providers.xiaomi_mimo.http_headers" => {
|
||||
self.providers.xiaomi_mimo.http_headers.clear();
|
||||
}
|
||||
"providers.novita.api_key" => self.providers.novita.api_key = None,
|
||||
"providers.novita.base_url" => self.providers.novita.base_url = None,
|
||||
"providers.novita.model" => self.providers.novita.model = None,
|
||||
@@ -886,6 +926,21 @@ impl ConfigToml {
|
||||
if let Some(v) = serialize_http_headers(&self.providers.openrouter.http_headers) {
|
||||
out.insert("providers.openrouter.http_headers".to_string(), v);
|
||||
}
|
||||
if let Some(v) = self.providers.xiaomi_mimo.api_key.as_ref() {
|
||||
out.insert(
|
||||
"providers.xiaomi_mimo.api_key".to_string(),
|
||||
redact_secret(v),
|
||||
);
|
||||
}
|
||||
if let Some(v) = self.providers.xiaomi_mimo.base_url.as_ref() {
|
||||
out.insert("providers.xiaomi_mimo.base_url".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.providers.xiaomi_mimo.model.as_ref() {
|
||||
out.insert("providers.xiaomi_mimo.model".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = serialize_http_headers(&self.providers.xiaomi_mimo.http_headers) {
|
||||
out.insert("providers.xiaomi_mimo.http_headers".to_string(), v);
|
||||
}
|
||||
if let Some(v) = self.providers.novita.api_key.as_ref() {
|
||||
out.insert("providers.novita.api_key".to_string(), redact_secret(v));
|
||||
}
|
||||
@@ -1023,6 +1078,7 @@ impl ConfigToml {
|
||||
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL.to_string(),
|
||||
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL.to_string(),
|
||||
ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(),
|
||||
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(),
|
||||
ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(),
|
||||
ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(),
|
||||
ProviderKind::Moonshot => {
|
||||
@@ -1225,7 +1281,10 @@ pub fn load_project_config(workspace: &Path) -> Option<ConfigToml> {
|
||||
fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
|
||||
if matches!(
|
||||
provider,
|
||||
ProviderKind::Atlascloud | ProviderKind::WanjieArk | ProviderKind::Ollama
|
||||
ProviderKind::Atlascloud
|
||||
| ProviderKind::WanjieArk
|
||||
| ProviderKind::XiaomiMimo
|
||||
| ProviderKind::Ollama
|
||||
) {
|
||||
return model.to_string();
|
||||
}
|
||||
@@ -1288,6 +1347,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
|
||||
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
|
||||
ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
|
||||
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
|
||||
ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
|
||||
ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
|
||||
ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
|
||||
@@ -1305,6 +1365,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
|
||||
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
|
||||
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
|
||||
ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
|
||||
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
|
||||
ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
|
||||
ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
|
||||
ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
|
||||
@@ -1800,6 +1861,7 @@ struct EnvRuntimeOverrides {
|
||||
model: Option<String>,
|
||||
wanjie_ark_model: Option<String>,
|
||||
moonshot_model: Option<String>,
|
||||
xiaomi_mimo_model: Option<String>,
|
||||
output_mode: Option<String>,
|
||||
auth_mode: Option<String>,
|
||||
log_level: Option<String>,
|
||||
@@ -1814,6 +1876,7 @@ struct EnvRuntimeOverrides {
|
||||
atlascloud_base_url: Option<String>,
|
||||
wanjie_ark_base_url: Option<String>,
|
||||
openrouter_base_url: Option<String>,
|
||||
xiaomi_mimo_base_url: Option<String>,
|
||||
novita_base_url: Option<String>,
|
||||
fireworks_base_url: Option<String>,
|
||||
moonshot_base_url: Option<String>,
|
||||
@@ -1844,6 +1907,10 @@ impl EnvRuntimeOverrides {
|
||||
.or_else(|_| std::env::var("KIMI_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
|
||||
.or_else(|_| std::env::var("MIMO_MODEL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(),
|
||||
auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(),
|
||||
log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(),
|
||||
@@ -1882,6 +1949,10 @@ impl EnvRuntimeOverrides {
|
||||
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
|
||||
.or_else(|_| std::env::var("MIMO_BASE_URL"))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
novita_base_url: std::env::var("NOVITA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty()),
|
||||
@@ -1914,6 +1985,7 @@ impl EnvRuntimeOverrides {
|
||||
ProviderKind::Atlascloud => self.atlascloud_base_url.clone(),
|
||||
ProviderKind::WanjieArk => self.wanjie_ark_base_url.clone(),
|
||||
ProviderKind::Openrouter => self.openrouter_base_url.clone(),
|
||||
ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(),
|
||||
ProviderKind::Novita => self.novita_base_url.clone(),
|
||||
ProviderKind::Fireworks => self.fireworks_base_url.clone(),
|
||||
ProviderKind::Moonshot => self.moonshot_base_url.clone(),
|
||||
@@ -1927,6 +1999,7 @@ impl EnvRuntimeOverrides {
|
||||
match provider {
|
||||
ProviderKind::WanjieArk => self.wanjie_ark_model.clone(),
|
||||
ProviderKind::Moonshot => self.moonshot_model.clone(),
|
||||
ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1975,6 +2048,12 @@ mod tests {
|
||||
nvidia_nim_base_url: Option<OsString>,
|
||||
openrouter_api_key: Option<OsString>,
|
||||
openrouter_base_url: Option<OsString>,
|
||||
xiaomi_mimo_api_key: Option<OsString>,
|
||||
mimo_api_key: Option<OsString>,
|
||||
xiaomi_mimo_base_url: Option<OsString>,
|
||||
mimo_base_url: Option<OsString>,
|
||||
xiaomi_mimo_model: Option<OsString>,
|
||||
mimo_model: Option<OsString>,
|
||||
wanjie_ark_api_key: Option<OsString>,
|
||||
wanjie_ark_base_url: Option<OsString>,
|
||||
wanjie_base_url: Option<OsString>,
|
||||
@@ -2024,6 +2103,12 @@ mod tests {
|
||||
nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
|
||||
openrouter_api_key: env::var_os("OPENROUTER_API_KEY"),
|
||||
openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"),
|
||||
xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"),
|
||||
mimo_api_key: env::var_os("MIMO_API_KEY"),
|
||||
xiaomi_mimo_base_url: env::var_os("XIAOMI_MIMO_BASE_URL"),
|
||||
mimo_base_url: env::var_os("MIMO_BASE_URL"),
|
||||
xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"),
|
||||
mimo_model: env::var_os("MIMO_MODEL"),
|
||||
wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"),
|
||||
wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"),
|
||||
wanjie_base_url: env::var_os("WANJIE_BASE_URL"),
|
||||
@@ -2068,6 +2153,12 @@ mod tests {
|
||||
env::remove_var("NVIDIA_NIM_BASE_URL");
|
||||
env::remove_var("OPENROUTER_API_KEY");
|
||||
env::remove_var("OPENROUTER_BASE_URL");
|
||||
env::remove_var("XIAOMI_MIMO_API_KEY");
|
||||
env::remove_var("MIMO_API_KEY");
|
||||
env::remove_var("XIAOMI_MIMO_BASE_URL");
|
||||
env::remove_var("MIMO_BASE_URL");
|
||||
env::remove_var("XIAOMI_MIMO_MODEL");
|
||||
env::remove_var("MIMO_MODEL");
|
||||
env::remove_var("WANJIE_ARK_API_KEY");
|
||||
env::remove_var("WANJIE_ARK_BASE_URL");
|
||||
env::remove_var("WANJIE_BASE_URL");
|
||||
@@ -2129,6 +2220,12 @@ mod tests {
|
||||
Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
|
||||
Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
|
||||
Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
|
||||
Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
|
||||
Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
|
||||
Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
|
||||
Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
|
||||
Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
|
||||
Self::restore_var("MIMO_MODEL", self.mimo_model.take());
|
||||
Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
|
||||
Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
|
||||
Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
|
||||
@@ -2712,6 +2809,14 @@ mod tests {
|
||||
ProviderKind::parse("OPEN_ROUTER"),
|
||||
Some(ProviderKind::Openrouter)
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderKind::parse("xiaomi-mimo"),
|
||||
Some(ProviderKind::XiaomiMimo)
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderKind::parse("xiaomi"),
|
||||
Some(ProviderKind::XiaomiMimo)
|
||||
);
|
||||
assert_eq!(ProviderKind::parse("novita"), Some(ProviderKind::Novita));
|
||||
assert_eq!(ProviderKind::parse("Novita"), Some(ProviderKind::Novita));
|
||||
assert_eq!(
|
||||
@@ -2777,6 +2882,22 @@ mod tests {
|
||||
assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
let config = ConfigToml {
|
||||
provider: ProviderKind::XiaomiMimo,
|
||||
..ConfigToml::default()
|
||||
};
|
||||
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
|
||||
assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL);
|
||||
assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn novita_provider_defaults_to_canonical_endpoint_and_model() {
|
||||
let _lock = env_lock();
|
||||
@@ -3181,6 +3302,27 @@ mod tests {
|
||||
assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_env_overrides_provider_key_base_url_and_model() {
|
||||
let _lock = env_lock();
|
||||
let _env = EnvGuard::without_deepseek_runtime_overrides();
|
||||
// Safety: test-only environment mutation guarded by a module mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "xiaomi-mimo");
|
||||
env::set_var("MIMO_API_KEY", "mimo-env-key");
|
||||
env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
|
||||
env::set_var("MIMO_MODEL", "mimo-v2.5");
|
||||
}
|
||||
|
||||
let resolved =
|
||||
ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
|
||||
assert_eq!(resolved.provider, ProviderKind::XiaomiMimo);
|
||||
assert_eq!(resolved.api_key.as_deref(), Some("mimo-env-key"));
|
||||
assert_eq!(resolved.base_url, "https://mimo-gateway.example/v1");
|
||||
assert_eq!(resolved.model, "mimo-v2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn novita_env_api_key_falls_back_when_config_missing() {
|
||||
let _lock = env_lock();
|
||||
|
||||
@@ -643,6 +643,7 @@ impl ThreadManager {
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
memory_mode: None,
|
||||
current_leaf_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "codewhale-release"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Shared CodeWhale release discovery and version comparison helpers"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const CHECKSUM_MANIFEST_ASSET: &str = "codewhale-artifacts-sha256.txt";
|
||||
pub const LATEST_RELEASE_URL: &str =
|
||||
"https://api.github.com/repos/Hmbown/CodeWhale/releases/latest";
|
||||
pub const RELEASES_URL: &str =
|
||||
"https://api.github.com/repos/Hmbown/CodeWhale/releases?per_page=100";
|
||||
pub const CNB_REPO_URL: &str = "https://cnb.cool/codewhale.net/codewhale";
|
||||
pub const RELEASE_BASE_URL_ENV: &str = "CODEWHALE_RELEASE_BASE_URL";
|
||||
pub const LEGACY_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_TUI_RELEASE_BASE_URL";
|
||||
pub const DEEPSEEK_RELEASE_BASE_URL_ENV: &str = "DEEPSEEK_RELEASE_BASE_URL";
|
||||
pub const CNB_MIRROR_ENV: &str = "CODEWHALE_USE_CNB_MIRROR";
|
||||
pub const UPDATE_VERSION_ENV: &str = "DEEPSEEK_TUI_VERSION";
|
||||
pub const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION";
|
||||
pub const UPDATE_USER_AGENT: &str = "codewhale-updater";
|
||||
|
||||
const CNB_RELEASE_ASSET_BASE: &str = "https://cnb.cool/Hmbown/CodeWhale/-/releases";
|
||||
const RELEASE_METADATA_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReleaseChannel {
|
||||
Stable,
|
||||
Beta,
|
||||
}
|
||||
|
||||
impl ReleaseChannel {
|
||||
pub fn from_beta_flag(beta: bool) -> Self {
|
||||
if beta { Self::Beta } else { Self::Stable }
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Beta => "beta",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReleaseQuery {
|
||||
Mirror { base_url: String, version: String },
|
||||
GitHubLatest { url: &'static str },
|
||||
GitHubReleaseList { url: &'static str },
|
||||
}
|
||||
|
||||
pub fn resolve_release_query(channel: ReleaseChannel) -> ReleaseQuery {
|
||||
let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into());
|
||||
if let Some(base_url) = release_base_url_from_env(&version) {
|
||||
return ReleaseQuery::Mirror { base_url, version };
|
||||
}
|
||||
|
||||
match channel {
|
||||
ReleaseChannel::Stable => ReleaseQuery::GitHubLatest {
|
||||
url: LATEST_RELEASE_URL,
|
||||
},
|
||||
ReleaseChannel::Beta => ReleaseQuery::GitHubReleaseList { url: RELEASES_URL },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn release_base_url_from_env(version: &str) -> Option<String> {
|
||||
for env_name in [
|
||||
RELEASE_BASE_URL_ENV,
|
||||
LEGACY_RELEASE_BASE_URL_ENV,
|
||||
DEEPSEEK_RELEASE_BASE_URL_ENV,
|
||||
] {
|
||||
if let Ok(value) = std::env::var(env_name) {
|
||||
let trimmed = value.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if std::env::var(CNB_MIRROR_ENV).is_ok() {
|
||||
return Some(cnb_release_base_url(version));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn cnb_release_base_url(version: &str) -> String {
|
||||
format!(
|
||||
"{}/v{}",
|
||||
CNB_RELEASE_ASSET_BASE.trim_end_matches('/'),
|
||||
version.trim_start_matches('v')
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_version_from_env() -> Option<String> {
|
||||
std::env::var(UPDATE_VERSION_ENV)
|
||||
.ok()
|
||||
.or_else(|| std::env::var(LEGACY_UPDATE_VERSION_ENV).ok())
|
||||
.map(|value| value.trim().trim_start_matches('v').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn mirror_asset_url(base_url: &str, asset_name: &str) -> String {
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), asset_name)
|
||||
}
|
||||
|
||||
pub fn update_network_fallback_hint() -> String {
|
||||
format!(
|
||||
"GitHub release downloads may be blocked or slow on this network.\n\
|
||||
For mainland China, use one of these fallback paths:\n\
|
||||
1. Source build from the CNB mirror, installing both shipped binaries:\n\
|
||||
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-cli --locked --force\n\
|
||||
cargo install --git {CNB_REPO_URL} --tag vX.Y.Z codewhale-tui --locked --force\n\
|
||||
2. Use a binary asset mirror:\n\
|
||||
{RELEASE_BASE_URL_ENV}=https://<mirror>/<release-assets>/ {UPDATE_VERSION_ENV}=X.Y.Z codewhale update\n\
|
||||
The mirror directory must contain {CHECKSUM_MANIFEST_ASSET} and the platform binaries."
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fetch_release_json_blocking(url: &str, description: &str) -> Result<String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.user_agent(UPDATE_USER_AGENT)
|
||||
.timeout(RELEASE_METADATA_TIMEOUT)
|
||||
.build()
|
||||
.context("failed to build release check HTTP client")?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.send()
|
||||
.with_context(|| format!("failed to fetch {description} from {url}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.with_context(|| format!("failed to read {description} response from {url}"));
|
||||
release_response_body(status, body, url, description)
|
||||
}
|
||||
|
||||
pub async fn fetch_release_json_async(url: &str, description: &str) -> Result<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(UPDATE_USER_AGENT)
|
||||
.timeout(RELEASE_METADATA_TIMEOUT)
|
||||
.build()
|
||||
.context("failed to build release check HTTP client")?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to fetch {description} from {url}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.with_context(|| format!("failed to read {description} response from {url}"));
|
||||
release_response_body(status, body, url, description)
|
||||
}
|
||||
|
||||
fn release_response_body(
|
||||
status: reqwest::StatusCode,
|
||||
body: Result<String>,
|
||||
url: &str,
|
||||
description: &str,
|
||||
) -> Result<String> {
|
||||
let body = body.with_context(|| format!("failed to read {description} response from {url}"))?;
|
||||
if !status.is_success() {
|
||||
bail!("GitHub release request failed with HTTP {status}: {body}");
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReleaseTag {
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReleaseListEntry {
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
pub fn latest_tag_from_release_json(body: &str) -> Result<String> {
|
||||
let release: ReleaseTag = serde_json::from_str(body).with_context(|| {
|
||||
format!("failed to parse release JSON from GitHub API. Response: {body}")
|
||||
})?;
|
||||
Ok(release.tag_name)
|
||||
}
|
||||
|
||||
pub fn latest_beta_tag_from_release_list_json(body: &str) -> Result<String> {
|
||||
let releases: Vec<ReleaseListEntry> = serde_json::from_str(body).with_context(|| {
|
||||
format!("failed to parse release list JSON from GitHub API. Response: {body}")
|
||||
})?;
|
||||
releases
|
||||
.into_iter()
|
||||
.find(|release| is_beta_tag(&release.tag_name))
|
||||
.map(|release| release.tag_name)
|
||||
.context("no beta release found in GitHub releases")
|
||||
}
|
||||
|
||||
pub async fn latest_release_tag_async(channel: ReleaseChannel) -> Result<String> {
|
||||
match resolve_release_query(channel) {
|
||||
ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
|
||||
ReleaseQuery::GitHubLatest { url } => {
|
||||
let body = fetch_release_json_async(url, "latest release").await?;
|
||||
latest_tag_from_release_json(&body)
|
||||
}
|
||||
ReleaseQuery::GitHubReleaseList { url } => {
|
||||
let body = fetch_release_json_async(url, "release list").await?;
|
||||
latest_beta_tag_from_release_list_json(&body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest_release_tag_blocking(channel: ReleaseChannel) -> Result<String> {
|
||||
match resolve_release_query(channel) {
|
||||
ReleaseQuery::Mirror { version, .. } => Ok(format!("v{}", version.trim_start_matches('v'))),
|
||||
ReleaseQuery::GitHubLatest { url } => {
|
||||
let body = fetch_release_json_blocking(url, "latest release")?;
|
||||
latest_tag_from_release_json(&body)
|
||||
}
|
||||
ReleaseQuery::GitHubReleaseList { url } => {
|
||||
let body = fetch_release_json_blocking(url, "release list")?;
|
||||
latest_beta_tag_from_release_list_json(&body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compare_release_versions(
|
||||
current_version: &str,
|
||||
latest_tag: &str,
|
||||
) -> Result<std::cmp::Ordering> {
|
||||
let current = parse_release_version(current_version)
|
||||
.with_context(|| format!("failed to parse current version {current_version:?}"))?;
|
||||
let latest = parse_release_version(latest_tag)
|
||||
.with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
|
||||
Ok(current.cmp(&latest))
|
||||
}
|
||||
|
||||
pub fn update_is_needed(
|
||||
channel: ReleaseChannel,
|
||||
current_version: &str,
|
||||
latest_tag: &str,
|
||||
) -> Result<bool> {
|
||||
let current = parse_release_version(current_version)
|
||||
.with_context(|| format!("failed to parse current version {current_version:?}"))?;
|
||||
let latest = parse_release_version(latest_tag)
|
||||
.with_context(|| format!("failed to parse latest release tag {latest_tag:?}"))?;
|
||||
|
||||
match channel {
|
||||
ReleaseChannel::Stable => Ok(current < latest),
|
||||
ReleaseChannel::Beta => {
|
||||
if current == latest {
|
||||
return Ok(false);
|
||||
}
|
||||
let latest_is_beta = version_is_beta(&latest);
|
||||
let current_is_stable = current.pre.is_empty();
|
||||
let same_release_line = current.major == latest.major
|
||||
&& current.minor == latest.minor
|
||||
&& current.patch == latest.patch;
|
||||
if current > latest && !(current_is_stable && same_release_line) {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(latest_is_beta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_release_version(value: &str) -> Result<semver::Version> {
|
||||
let version = value
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
semver::Version::parse(version).with_context(|| format!("invalid semver: {value:?}"))
|
||||
}
|
||||
|
||||
pub fn is_beta_tag(tag_name: &str) -> bool {
|
||||
tag_name.to_ascii_lowercase().contains("beta")
|
||||
}
|
||||
|
||||
fn version_is_beta(version: &semver::Version) -> bool {
|
||||
version.pre.as_str().to_ascii_lowercase().contains("beta")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cnb_release_base_url_includes_tag_directory() {
|
||||
assert_eq!(
|
||||
cnb_release_base_url("0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
);
|
||||
assert_eq!(
|
||||
cnb_release_base_url("v0.8.47"),
|
||||
"https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_update_is_needed_only_when_latest_is_newer() {
|
||||
assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
|
||||
assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
|
||||
assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
|
||||
assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
|
||||
assert!(
|
||||
!update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_update_allows_switching_from_same_stable_to_beta() {
|
||||
assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
|
||||
assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
|
||||
assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
|
||||
assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
|
||||
assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
|
||||
assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_release_version_accepts_tags_and_build_suffixes() {
|
||||
assert_eq!(
|
||||
parse_release_version("v0.9.0-beta.1").unwrap(),
|
||||
semver::Version::parse("0.9.0-beta.1").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
parse_release_version("0.8.45 (abcdef123456)").unwrap(),
|
||||
semver::Version::parse("0.8.45").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_version_compare_ignores_v_prefix_and_build_sha() {
|
||||
assert_eq!(
|
||||
compare_release_versions("0.8.39 (eeccf7d)", "v0.8.39").unwrap(),
|
||||
std::cmp::Ordering::Equal
|
||||
);
|
||||
assert_eq!(
|
||||
compare_release_versions("0.8.39", "v0.8.40").unwrap(),
|
||||
std::cmp::Ordering::Less
|
||||
);
|
||||
assert_eq!(
|
||||
compare_release_versions("0.8.40", "v0.8.39").unwrap(),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_beta_tag_selects_first_beta_release() {
|
||||
let body = r#"[
|
||||
{ "tag_name": "v0.9.0" },
|
||||
{ "tag_name": "v0.9.0-rc.1" },
|
||||
{ "tag_name": "v0.9.0-beta.2" },
|
||||
{ "tag_name": "v0.9.0-beta.1" }
|
||||
]"#;
|
||||
assert_eq!(
|
||||
latest_beta_tag_from_release_list_json(body).unwrap(),
|
||||
"v0.9.0-beta.2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_beta_tag_reports_missing_beta() {
|
||||
let body = r#"[{ "tag_name": "v0.9.0" }]"#;
|
||||
let err = latest_beta_tag_from_release_list_json(body).expect_err("missing beta");
|
||||
assert!(
|
||||
err.to_string().contains("no beta release found"),
|
||||
"unexpected error: {err:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -525,6 +525,9 @@ pub fn env_for(name: &str) -> Option<String> {
|
||||
let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
|
||||
"deepseek" => &["DEEPSEEK_API_KEY"],
|
||||
"openrouter" => &["OPENROUTER_API_KEY"],
|
||||
"xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
|
||||
&["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"]
|
||||
}
|
||||
"novita" => &["NOVITA_API_KEY"],
|
||||
// NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
|
||||
// catalog endpoint accepts the same DeepSeek-issued key when no
|
||||
@@ -587,6 +590,8 @@ mod tests {
|
||||
"WANJIE_ARK_API_KEY",
|
||||
"WANJIE_API_KEY",
|
||||
"WANJIE_MAAS_API_KEY",
|
||||
"XIAOMI_MIMO_API_KEY",
|
||||
"MIMO_API_KEY",
|
||||
SECRET_BACKEND_ENV,
|
||||
] {
|
||||
// Safety: tests serialise on env_lock(); the broader
|
||||
@@ -764,6 +769,20 @@ mod tests {
|
||||
clear_known_envs();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_env_aliases_resolve() {
|
||||
let _guard = env_lock();
|
||||
clear_known_envs();
|
||||
unsafe { std::env::set_var("MIMO_API_KEY", "mimo-key") };
|
||||
|
||||
assert_eq!(env_for("xiaomi-mimo").as_deref(), Some("mimo-key"));
|
||||
assert_eq!(env_for("xiaomimimo").as_deref(), Some("mimo-key"));
|
||||
assert_eq!(env_for("mimo").as_deref(), Some("mimo-key"));
|
||||
assert_eq!(env_for("xiaomi").as_deref(), Some("mimo-key"));
|
||||
|
||||
clear_known_envs();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireworks_env_aliases_resolve() {
|
||||
let _lock = env_lock();
|
||||
|
||||
+295
-84
@@ -53,6 +53,7 @@ pub struct ThreadMetadata {
|
||||
pub git_branch: Option<String>,
|
||||
pub git_origin_url: Option<String>,
|
||||
pub memory_mode: Option<String>,
|
||||
pub current_leaf_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -71,6 +72,7 @@ pub struct MessageRecord {
|
||||
pub content: String,
|
||||
pub item: Option<Value>,
|
||||
pub created_at: i64,
|
||||
pub parent_entry_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -162,82 +164,113 @@ impl StateStore {
|
||||
|
||||
fn init_schema(&self) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT,
|
||||
preview TEXT NOT NULL,
|
||||
ephemeral INTEGER NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
path TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
cli_version TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
title TEXT,
|
||||
sandbox_policy TEXT,
|
||||
approval_mode TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
memory_mode TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC);
|
||||
let user_version: u32 = conn.query_row("PRAGMA user_version;", [], |row| row.get(0))?;
|
||||
if user_version == 0 {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT,
|
||||
preview TEXT NOT NULL,
|
||||
ephemeral INTEGER NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
path TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
cli_version TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
title TEXT,
|
||||
sandbox_policy TEXT,
|
||||
approval_mode TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
memory_mode TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_updated_at ON threads(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_at ON threads(archived_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_archived_updated ON threads(archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
||||
thread_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
input_schema TEXT NOT NULL,
|
||||
PRIMARY KEY (thread_id, position),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
|
||||
thread_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
input_schema TEXT NOT NULL,
|
||||
PRIMARY KEY (thread_id, position),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
item_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
item_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_thread_created_at ON messages(thread_id, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(thread_id, checkpoint_id),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(thread_id, checkpoint_id),
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checkpoints_thread_created_at ON checkpoints(thread_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER,
|
||||
detail TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
|
||||
"#,
|
||||
)
|
||||
.context("failed to initialize thread schema")?;
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER,
|
||||
detail TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_updated_at ON jobs(updated_at DESC);
|
||||
|
||||
-- Add parent_entry_id column, and set to last message before current message
|
||||
ALTER TABLE messages ADD COLUMN parent_entry_id INTEGER NULL;
|
||||
UPDATE messages
|
||||
SET parent_entry_id = (
|
||||
SELECT m2.id
|
||||
FROM messages m2
|
||||
WHERE m2.created_at < messages.created_at AND m2.thread_id = messages.thread_id
|
||||
ORDER BY m2.id DESC
|
||||
LIMIT 1
|
||||
);
|
||||
CREATE INDEX idx_messages_parent_entry_id ON messages(parent_entry_id);
|
||||
|
||||
-- Add current_leaf_id column, and set to last message in thread
|
||||
ALTER TABLE threads ADD COLUMN current_leaf_id INTEGER NULL;
|
||||
UPDATE threads
|
||||
SET current_leaf_id = (
|
||||
SELECT m.id
|
||||
FROM messages m
|
||||
WHERE m.thread_id = threads.id
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
PRAGMA user_version = 1;
|
||||
COMMIT;
|
||||
"#,
|
||||
)
|
||||
.context("failed to initialize thread schema")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert thread metadata(will not set current_leaf_id)
|
||||
pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
@@ -314,7 +347,7 @@ impl StateStore {
|
||||
r#"
|
||||
SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
|
||||
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
|
||||
git_sha, git_branch, git_origin_url, memory_mode
|
||||
git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id
|
||||
FROM threads
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
@@ -328,9 +361,9 @@ impl StateStore {
|
||||
pub fn list_threads(&self, filters: ThreadListFilters) -> Result<Vec<ThreadMetadata>> {
|
||||
let conn = self.conn()?;
|
||||
let sql = if filters.include_archived {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads ORDER BY updated_at DESC LIMIT ?1"
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads ORDER BY updated_at DESC LIMIT ?1"
|
||||
} else {
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1"
|
||||
"SELECT id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd, cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at, git_sha, git_branch, git_origin_url, memory_mode, current_leaf_id FROM threads WHERE archived = 0 ORDER BY updated_at DESC LIMIT ?1"
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(sql).context("failed to prepare list query")?;
|
||||
@@ -398,6 +431,54 @@ impl StateStore {
|
||||
.map(Option::flatten)
|
||||
}
|
||||
|
||||
pub fn list_leaf_messages(&self, thread_id: &str) -> Result<Vec<MessageRecord>> {
|
||||
let conn = self.conn()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
r#"
|
||||
SELECT m1.id, m1.thread_id, m1.role, m1.content, m1.item_json, m1.created_at, m1.parent_entry_id
|
||||
FROM messages m1
|
||||
LEFT JOIN messages m2 ON m1.id = m2.parent_entry_id
|
||||
WHERE m1.thread_id = ?1 AND m2.id IS NULL
|
||||
"#,
|
||||
)
|
||||
.context("failed to prepare message listing query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id])
|
||||
.with_context(|| format!("failed to list leaf messages for thread {thread_id}"))?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate message rows")? {
|
||||
let item_json: Option<String> = row.get(4).context("failed to read item json")?;
|
||||
let item = item_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.with_context(|| {
|
||||
format!("failed to parse message item json in thread {thread_id}")
|
||||
})?;
|
||||
out.push(MessageRecord {
|
||||
id: row.get(0).context("failed to read message id")?,
|
||||
thread_id: row.get(1).context("failed to read message thread id")?,
|
||||
role: row.get(2).context("failed to read message role")?,
|
||||
content: row.get(3).context("failed to read message content")?,
|
||||
item,
|
||||
created_at: row.get(5).context("failed to read message timestamp")?,
|
||||
parent_entry_id: row.get(6).context("failed to read parent entry id")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn set_current_leaf_id(&self, thread_id: &str, current_leaf_id: &str) -> Result<()> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"UPDATE threads SET current_leaf_id = ?1 WHERE id = ?2",
|
||||
params![current_leaf_id, thread_id],
|
||||
)
|
||||
.context("failed to update thread current leaf id")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn persist_dynamic_tools(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -464,19 +545,52 @@ impl StateStore {
|
||||
content: &str,
|
||||
item: Option<Value>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn()?;
|
||||
let mut conn = self.conn()?;
|
||||
let created_at = Utc::now().timestamp();
|
||||
let item_json = item
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.context("failed to serialize message item payload")?;
|
||||
conn.execute(
|
||||
"INSERT INTO messages(thread_id, role, content, item_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![thread_id, role, content, item_json, created_at],
|
||||
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.context("failed to begin append message transaction")?;
|
||||
|
||||
let current_leaf_id: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT current_leaf_id FROM threads WHERE id = ?1",
|
||||
params![thread_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.with_context(|| {
|
||||
format!("failed to query thread current leaf id for thread {thread_id}")
|
||||
})?;
|
||||
|
||||
let next_leaf_id: i64 = tx.query_row(
|
||||
r#"
|
||||
INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id)
|
||||
SELECT ?1, ?2, ?3, ?4, ?5, ?6
|
||||
RETURNING id
|
||||
"#, params![thread_id, role, content, item_json, created_at, current_leaf_id], |row| row.get(0)
|
||||
).with_context(|| format!("failed to append message for thread {thread_id}"))?;
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
UPDATE threads
|
||||
SET current_leaf_id = ?1
|
||||
WHERE id = ?2;
|
||||
"#,
|
||||
params![next_leaf_id, thread_id],
|
||||
)
|
||||
.with_context(|| format!("failed to append message for thread {thread_id}"))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
.with_context(|| {
|
||||
format!("failed to update thread current leaf id for thread {thread_id}")
|
||||
})?;
|
||||
|
||||
tx.commit()
|
||||
.context("failed to commit append message transaction")?;
|
||||
|
||||
Ok(next_leaf_id)
|
||||
}
|
||||
|
||||
pub fn list_messages(
|
||||
@@ -488,11 +602,30 @@ impl StateStore {
|
||||
let limit = i64::try_from(limit.unwrap_or(500)).unwrap_or(500);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, thread_id, role, content, item_json, created_at FROM messages WHERE thread_id = ?1 ORDER BY created_at ASC LIMIT ?2",
|
||||
r#"
|
||||
WITH RECURSIVE
|
||||
leaf_id AS (
|
||||
SELECT current_leaf_id FROM threads WHERE id = ?1
|
||||
),
|
||||
ancestors AS (
|
||||
SELECT id, thread_id, role, content, item_json, created_at, parent_entry_id, 0 AS depth
|
||||
FROM messages
|
||||
WHERE id = (SELECT current_leaf_id FROM leaf_id)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT m.id, m.thread_id, m.role, m.content, m.item_json, m.created_at, m.parent_entry_id, a.depth + 1
|
||||
FROM messages m
|
||||
JOIN ancestors a ON m.id = a.parent_entry_id
|
||||
WHERE a.depth < ?2
|
||||
)
|
||||
SELECT id, thread_id, role, content, item_json, created_at, parent_entry_id FROM ancestors
|
||||
ORDER BY depth DESC
|
||||
"#
|
||||
)
|
||||
.context("failed to prepare message listing query")?;
|
||||
let mut rows = stmt
|
||||
.query(params![thread_id, limit])
|
||||
.query(params![thread_id, limit - 1])
|
||||
.with_context(|| format!("failed to list messages for thread {thread_id}"))?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(row) = rows.next().context("failed to iterate message rows")? {
|
||||
@@ -511,18 +644,95 @@ impl StateStore {
|
||||
content: row.get(3).context("failed to read message content")?,
|
||||
item,
|
||||
created_at: row.get(5).context("failed to read message timestamp")?,
|
||||
parent_entry_id: row.get(6).context("failed to read parent entry id")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn fork_at_message(
|
||||
&self,
|
||||
message_id: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
item: Option<Value>,
|
||||
) -> Result<i64> {
|
||||
let mut conn = self.conn()?;
|
||||
let created_at = Utc::now().timestamp();
|
||||
let item_json = item
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.context("failed to serialize message item payload")?;
|
||||
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.context("failed to begin fork message transaction")?;
|
||||
|
||||
let thread_id: String = tx
|
||||
.query_row(
|
||||
"SELECT thread_id FROM messages WHERE id = ?1",
|
||||
params![message_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.with_context(|| format!("failed to query thread id for message {message_id}"))?;
|
||||
|
||||
let next_leaf_id: i64 = tx.query_row(
|
||||
r#"
|
||||
INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id)
|
||||
SELECT ?1, ?2, ?3, ?4, ?5, ?6
|
||||
RETURNING id
|
||||
"#, params![thread_id, role, content, item_json, created_at, message_id], |row| row.get(0)
|
||||
).with_context(|| format!("failed to fork at message for thread {:?}", thread_id))?;
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
UPDATE threads
|
||||
SET current_leaf_id = ?1
|
||||
WHERE id = ?2;
|
||||
"#,
|
||||
params![next_leaf_id, thread_id],
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to update thread current leaf id for thread {:?}",
|
||||
thread_id
|
||||
)
|
||||
})?;
|
||||
|
||||
tx.commit()
|
||||
.context("failed to commit fork message transaction")?;
|
||||
|
||||
Ok(next_leaf_id)
|
||||
}
|
||||
|
||||
pub fn clear_messages(&self, thread_id: &str) -> Result<usize> {
|
||||
let conn = self.conn()?;
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE thread_id = ?1",
|
||||
let mut conn = self.conn()?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.context("failed to begin clear messages transaction")?;
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
UPDATE threads
|
||||
SET current_leaf_id = NULL
|
||||
WHERE id = ?1;
|
||||
"#,
|
||||
params![thread_id],
|
||||
)
|
||||
.with_context(|| format!("failed to clear messages for thread {thread_id}"))
|
||||
.with_context(|| format!("failed to clear messages for thread {thread_id}"))?;
|
||||
let result = tx
|
||||
.execute(
|
||||
r#"
|
||||
DELETE FROM messages WHERE thread_id = ?1
|
||||
"#,
|
||||
params![thread_id],
|
||||
)
|
||||
.with_context(|| format!("failed to clear messages for thread {thread_id}"))?;
|
||||
tx.commit()
|
||||
.context("failed to commit clear messages transaction")?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn save_checkpoint(
|
||||
@@ -946,5 +1156,6 @@ fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
|
||||
git_branch: row.get(18)?,
|
||||
git_origin_url: row.get(19)?,
|
||||
memory_mode: row.get(20)?,
|
||||
current_leaf_id: row.get(21)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codewhale_state::{SessionSource, StateStore, ThreadListFilters, ThreadMetadata, ThreadStatus};
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn temp_state_path(label: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
@@ -38,6 +39,7 @@ fn upsert_and_resume_thread_metadata() {
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
memory_mode: Some("extended".to_string()),
|
||||
current_leaf_id: None,
|
||||
};
|
||||
store.upsert_thread(&thread).expect("upsert thread");
|
||||
|
||||
@@ -70,3 +72,212 @@ fn upsert_and_resume_thread_metadata() {
|
||||
.expect("list threads");
|
||||
assert!(!listed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_schema_migration() {
|
||||
let path = temp_state_path("init_schema_migration");
|
||||
let conn = Connection::open(&path).expect("open state db");
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT,
|
||||
preview TEXT NOT NULL,
|
||||
ephemeral INTEGER NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
path TEXT,
|
||||
cwd TEXT NOT NULL,
|
||||
cli_version TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
title TEXT,
|
||||
sandbox_policy TEXT,
|
||||
approval_mode TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
memory_mode TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
item_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO threads (
|
||||
id, preview, ephemeral, model_provider, created_at, updated_at, status, cwd, cli_version, source, archived
|
||||
)
|
||||
VALUES (
|
||||
'thread-test-1', 'hello', false, 'deepseek', 0, 0, 'running', '/tmp/project', '0.0.0-test', 'interactive', false
|
||||
);
|
||||
INSERT INTO messages (thread_id, role, content, created_at) VALUES
|
||||
('thread-test-1', 'foo0', 'bar0', 0),
|
||||
('thread-test-1', 'foo1', 'bar1', 1),
|
||||
('thread-test-1', 'foo2', 'bar2', 2);
|
||||
"#,
|
||||
)
|
||||
.expect("init schema migration");
|
||||
|
||||
let store = StateStore::open(Some(path.clone())).expect("open state store");
|
||||
let thread = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("read thread")
|
||||
.unwrap();
|
||||
assert_eq!(thread.id, "thread-test-1");
|
||||
assert_eq!(thread.preview, "hello");
|
||||
assert!(!thread.ephemeral);
|
||||
assert_eq!(thread.model_provider, "deepseek");
|
||||
assert_eq!(thread.created_at, 0);
|
||||
assert_eq!(thread.updated_at, 0);
|
||||
assert_eq!(thread.status, ThreadStatus::Running);
|
||||
assert_eq!(thread.cwd, PathBuf::from("/tmp/project"));
|
||||
assert_eq!(thread.cli_version, "0.0.0-test");
|
||||
assert_eq!(thread.source, SessionSource::Interactive);
|
||||
assert!(thread.current_leaf_id.is_some());
|
||||
|
||||
let messages = store
|
||||
.list_messages("thread-test-1", None)
|
||||
.expect("list messages");
|
||||
assert_eq!(messages.len(), 3);
|
||||
for (i, message) in messages.iter().enumerate() {
|
||||
assert_eq!(message.thread_id, "thread-test-1");
|
||||
assert_eq!(message.role, format!("foo{}", i));
|
||||
assert_eq!(message.content, format!("bar{}", i));
|
||||
assert_eq!(message.created_at, i as i64);
|
||||
}
|
||||
|
||||
// Test idempotent
|
||||
StateStore::open(Some(path.clone())).expect("open state store");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fork() {
|
||||
let path = temp_state_path("test_fork");
|
||||
let store = StateStore::open(Some(path.clone())).expect("open state store");
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let thread = ThreadMetadata {
|
||||
id: "thread-test-1".to_string(),
|
||||
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
||||
preview: "hello".to_string(),
|
||||
ephemeral: false,
|
||||
model_provider: "deepseek".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
status: ThreadStatus::Running,
|
||||
path: Some(PathBuf::from("/tmp/project")),
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
cli_version: "0.0.0-test".to_string(),
|
||||
source: SessionSource::Interactive,
|
||||
name: Some("Test Thread".to_string()),
|
||||
sandbox_policy: Some("workspace-write".to_string()),
|
||||
approval_mode: Some("on-request".to_string()),
|
||||
archived: false,
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
git_origin_url: None,
|
||||
memory_mode: Some("extended".to_string()),
|
||||
current_leaf_id: None,
|
||||
};
|
||||
|
||||
store.upsert_thread(&thread).expect("upsert thread");
|
||||
store
|
||||
.append_message("thread-test-1", "foo0", "bar0", None)
|
||||
.expect("append message");
|
||||
store
|
||||
.append_message("thread-test-1", "foo1", "bar1", None)
|
||||
.expect("append message");
|
||||
store
|
||||
.append_message("thread-test-1", "foo2", "bar2", None)
|
||||
.expect("append message");
|
||||
store
|
||||
.append_message("thread-test-1", "foo3", "bar3", None)
|
||||
.expect("append message");
|
||||
store
|
||||
.append_message("thread-test-1", "foo4", "bar4", None)
|
||||
.expect("append message");
|
||||
|
||||
let messages = store
|
||||
.list_messages("thread-test-1", None)
|
||||
.expect("list messages");
|
||||
assert_eq!(messages.len(), 5);
|
||||
let ids = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, message)| {
|
||||
assert_eq!(message.thread_id, "thread-test-1");
|
||||
assert_eq!(message.role, format!("foo{}", i));
|
||||
assert_eq!(message.content, format!("bar{}", i));
|
||||
message.id.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
store.upsert_thread(&thread).expect("upsert thread");
|
||||
|
||||
store
|
||||
.fork_at_message(&ids[2], "foo5", "bar5", None)
|
||||
.expect("fork at message");
|
||||
let messages = store
|
||||
.list_messages("thread-test-1", None)
|
||||
.expect("list messages");
|
||||
assert_eq!(messages.len(), 4);
|
||||
const LIST_1: [i64; 4] = [0, 1, 2, 5];
|
||||
messages
|
||||
.iter()
|
||||
.zip(LIST_1.iter())
|
||||
.for_each(|(message, &i)| {
|
||||
assert_eq!(message.thread_id, "thread-test-1");
|
||||
assert_eq!(message.role, format!("foo{}", i));
|
||||
assert_eq!(message.content, format!("bar{}", i));
|
||||
});
|
||||
let leaves = store
|
||||
.list_leaf_messages("thread-test-1")
|
||||
.expect("list leaf messages");
|
||||
assert_eq!(leaves.len(), 2);
|
||||
|
||||
store
|
||||
.set_current_leaf_id("thread-test-1", &ids[4])
|
||||
.expect("set current leaf id");
|
||||
store
|
||||
.append_message("thread-test-1", "foo6", "bar6", None)
|
||||
.expect("append message");
|
||||
let messages = store
|
||||
.list_messages("thread-test-1", None)
|
||||
.expect("list messages");
|
||||
assert_eq!(messages.len(), 6);
|
||||
const LIST_2: [i64; 6] = [0, 1, 2, 3, 4, 6];
|
||||
messages
|
||||
.iter()
|
||||
.zip(LIST_2.iter())
|
||||
.for_each(|(message, &i)| {
|
||||
assert_eq!(message.thread_id, "thread-test-1");
|
||||
assert_eq!(message.role, format!("foo{}", i));
|
||||
assert_eq!(message.content, format!("bar{}", i));
|
||||
});
|
||||
|
||||
let leaves = store
|
||||
.list_leaf_messages("thread-test-1")
|
||||
.expect("list leaf messages");
|
||||
assert_eq!(leaves.len(), 2);
|
||||
|
||||
store
|
||||
.clear_messages("thread-test-1")
|
||||
.expect("clear messages");
|
||||
let leaves = store
|
||||
.list_leaf_messages("thread-test-1")
|
||||
.expect("list leaf messages");
|
||||
assert_eq!(leaves.len(), 0);
|
||||
let thread = store
|
||||
.get_thread("thread-test-1")
|
||||
.expect("get thread")
|
||||
.unwrap();
|
||||
dbg!(&thread);
|
||||
assert!(thread.current_leaf_id.is_none());
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ path = "src/bin/deepseek_tui_legacy_shim.rs"
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
codewhale-config = { path = "../config", version = "0.8.46" }
|
||||
codewhale-release = { path = "../release", version = "0.8.46" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.46" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.46" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
|
||||
@@ -882,6 +882,7 @@ pub(super) fn apply_reasoning_effort(
|
||||
ApiProvider::Deepseek
|
||||
| ApiProvider::DeepseekCN
|
||||
| ApiProvider::Openrouter
|
||||
| ApiProvider::XiaomiMimo
|
||||
| ApiProvider::Novita
|
||||
| ApiProvider::Sglang => {
|
||||
body["thinking"] = json!({ "type": "disabled" });
|
||||
@@ -930,6 +931,9 @@ pub(super) fn apply_reasoning_effort(
|
||||
body["reasoning_effort"] = json!(value);
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
ApiProvider::XiaomiMimo => {
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
ApiProvider::Fireworks => {
|
||||
body["reasoning_effort"] = json!("high");
|
||||
}
|
||||
@@ -967,6 +971,9 @@ pub(super) fn apply_reasoning_effort(
|
||||
body["reasoning_effort"] = json!("xhigh");
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
ApiProvider::XiaomiMimo => {
|
||||
body["thinking"] = json!({ "type": "enabled" });
|
||||
}
|
||||
ApiProvider::Fireworks => {
|
||||
body["reasoning_effort"] = json!("max");
|
||||
}
|
||||
@@ -2044,6 +2051,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_uses_xiaomi_mimo_thinking_parameter_only() {
|
||||
for input in ["low", "medium", "max", "xhigh"] {
|
||||
let mut body = json!({});
|
||||
apply_reasoning_effort(&mut body, Some(input), ApiProvider::XiaomiMimo);
|
||||
|
||||
assert_eq!(
|
||||
body.pointer("/thinking/type").and_then(Value::as_str),
|
||||
Some("enabled"),
|
||||
"MiMo thinking mapping for {input}"
|
||||
);
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
let mut body = json!({});
|
||||
apply_reasoning_effort(&mut body, Some("off"), ApiProvider::XiaomiMimo);
|
||||
assert_eq!(
|
||||
body.pointer("/thinking/type").and_then(Value::as_str),
|
||||
Some("disabled")
|
||||
);
|
||||
assert!(body.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_parser_accepts_nvidia_nim_reasoning_field() -> Result<()> {
|
||||
let response = parse_chat_message(&json!({
|
||||
|
||||
@@ -71,6 +71,17 @@ use super::{
|
||||
release_stream_buffer, system_to_instructions, to_api_tool_name,
|
||||
};
|
||||
|
||||
fn apply_provider_token_limit(body: &mut Value, provider: ApiProvider, max_tokens: u32) {
|
||||
if provider != ApiProvider::XiaomiMimo {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
object.remove("max_tokens");
|
||||
}
|
||||
body["max_completion_tokens"] = json!(max_tokens);
|
||||
}
|
||||
|
||||
impl DeepSeekClient {
|
||||
pub(super) async fn create_message_chat(
|
||||
&self,
|
||||
@@ -82,6 +93,7 @@ impl DeepSeekClient {
|
||||
"messages": messages,
|
||||
"max_tokens": request.max_tokens,
|
||||
});
|
||||
apply_provider_token_limit(&mut body, self.api_provider, request.max_tokens);
|
||||
|
||||
if let Some(temperature) = request.temperature {
|
||||
body["temperature"] = json!(temperature);
|
||||
@@ -156,6 +168,7 @@ impl DeepSeekClient {
|
||||
"include_usage": true
|
||||
},
|
||||
});
|
||||
apply_provider_token_limit(&mut body, self.api_provider, request.max_tokens);
|
||||
|
||||
if let Some(temperature) = request.temperature {
|
||||
body["temperature"] = json!(temperature);
|
||||
@@ -1729,6 +1742,7 @@ fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool {
|
||||
| ApiProvider::DeepseekCN
|
||||
| ApiProvider::NvidiaNim
|
||||
| ApiProvider::Openrouter
|
||||
| ApiProvider::XiaomiMimo
|
||||
| ApiProvider::Novita
|
||||
| ApiProvider::Fireworks
|
||||
| ApiProvider::Sglang
|
||||
@@ -3092,11 +3106,12 @@ mod alias_thinking_detection_tests {
|
||||
//! turn. See upstream API docs:
|
||||
//! https://api-docs.deepseek.com/guides/thinking_mode
|
||||
use super::{
|
||||
is_reasoning_model_for_stream, provider_accepts_reasoning_content,
|
||||
requires_reasoning_content, should_replay_reasoning_content,
|
||||
should_replay_reasoning_content_for_provider,
|
||||
apply_provider_token_limit, is_reasoning_model_for_stream,
|
||||
provider_accepts_reasoning_content, requires_reasoning_content,
|
||||
should_replay_reasoning_content, should_replay_reasoning_content_for_provider,
|
||||
};
|
||||
use crate::config::ApiProvider;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn aliases_routed_to_v4_require_reasoning_content() {
|
||||
@@ -3162,6 +3177,25 @@ mod alias_thinking_detection_tests {
|
||||
assert!(!provider_accepts_reasoning_content(ApiProvider::Openai));
|
||||
assert!(provider_accepts_reasoning_content(ApiProvider::Deepseek));
|
||||
assert!(provider_accepts_reasoning_content(ApiProvider::NvidiaNim));
|
||||
assert!(provider_accepts_reasoning_content(ApiProvider::XiaomiMimo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_uses_max_completion_tokens_payload_key() {
|
||||
let mut body = json!({
|
||||
"model": "mimo-v2.5-pro",
|
||||
"messages": [],
|
||||
"max_tokens": 8192,
|
||||
});
|
||||
|
||||
apply_provider_token_limit(&mut body, ApiProvider::XiaomiMimo, 8192);
|
||||
|
||||
assert!(body.get("max_tokens").is_none());
|
||||
assert_eq!(
|
||||
body.get("max_completion_tokens")
|
||||
.and_then(serde_json::Value::as_u64),
|
||||
Some(8192)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn change(app: &mut App, version: Option<&str>) -> CommandResult {
|
||||
Locale::Ja => "Japanese (日本語)",
|
||||
Locale::PtBr => "Brazilian Portuguese (Português)",
|
||||
Locale::Es419 => "Latin American Spanish (Español latinoamericano)",
|
||||
Locale::Vi => "Vietnamese (Tiếng Việt)",
|
||||
// Fallback — should never reach here since we check En above.
|
||||
Locale::En => "English",
|
||||
};
|
||||
|
||||
@@ -93,6 +93,7 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
|
||||
crate::localization::Locale::Ja => "ja",
|
||||
crate::localization::Locale::PtBr => "pt-BR",
|
||||
crate::localization::Locale::Es419 => "es-419",
|
||||
crate::localization::Locale::Vi => "vi",
|
||||
}
|
||||
}
|
||||
fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str {
|
||||
@@ -473,9 +474,9 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
|
||||
}
|
||||
}
|
||||
return CommandResult::error(format!(
|
||||
"base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving."
|
||||
));
|
||||
return CommandResult::error(
|
||||
"base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,9 @@ pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
if matches!(arg, Some("warmup")) {
|
||||
return CommandResult::action(AppAction::CacheWarmup);
|
||||
}
|
||||
if matches!(arg, Some("stats")) {
|
||||
return CommandResult::message(format_cache_stats(app));
|
||||
}
|
||||
|
||||
let want = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
|
||||
let cap = app.session.turn_cache_history.len();
|
||||
@@ -233,6 +236,140 @@ fn format_cache_inspect(app: &mut App) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a prefix-cache stability and health summary for `/cache stats`.
|
||||
///
|
||||
/// Surfaces the current prefix fingerprint, stability ratio, change history,
|
||||
/// and an aggregated cache-hit summary from per-turn telemetry. When the
|
||||
/// prefix has changed, a prominent warning is included so users can
|
||||
/// correlate cache misses with prefix drift.
|
||||
fn format_cache_stats(app: &App) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("Cache Stats\n");
|
||||
|
||||
// ── Prefix stability ──────────────────────────────────────────────
|
||||
out.push_str("\n── Prefix Stability\n");
|
||||
match app.prefix_stability_pct {
|
||||
Some(pct) => {
|
||||
let checks = app.prefix_checks_total;
|
||||
let changes = app.prefix_change_count;
|
||||
let stable_checks = checks.saturating_sub(changes);
|
||||
|
||||
if changes == 0 {
|
||||
out.push_str(&format!(
|
||||
" Stability: {pct}% ({stable_checks}/{checks} checks)\n"
|
||||
));
|
||||
out.push_str(" Status: stable (no prefix changes this session)\n");
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
" Stability: {pct}% ({stable_checks}/{checks} checks, {changes} change{})\n",
|
||||
if changes == 1 { "" } else { "s" }
|
||||
));
|
||||
out.push_str(" Status: WARNING — prefix has changed\n");
|
||||
if let Some(ref desc) = app.last_prefix_change_desc {
|
||||
out.push_str(&format!(" Last change: {desc}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push_str(" Stability: unknown (no checks recorded yet)\n");
|
||||
out.push_str(" Run a turn first to collect prefix stability data.\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prefix fingerprint ────────────────────────────────────────────
|
||||
out.push_str("\n── Prefix Fingerprint\n");
|
||||
match &app.last_pinned_prefix_hash {
|
||||
Some(hash) => {
|
||||
out.push_str(&format!(" Pinned hash: {hash}\n"));
|
||||
let short = if hash.len() >= 12 { &hash[..12] } else { hash };
|
||||
out.push_str(&format!(" Short id: {short}\n"));
|
||||
if app.prefix_change_count > 0 {
|
||||
out.push_str(" Drift: WARNING — hash has changed during this session\n");
|
||||
out.push_str(&format!(
|
||||
" ({change} change{plural} detected)\n",
|
||||
change = app.prefix_change_count,
|
||||
plural = if app.prefix_change_count == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
));
|
||||
} else {
|
||||
out.push_str(" Drift: none (hash stable)\n");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push_str(" Pinned hash: unavailable\n");
|
||||
out.push_str(" Run a turn first, or use /cache inspect.\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache hit-rate summary ────────────────────────────────────────
|
||||
out.push_str("\n── Cache Hit Rate\n");
|
||||
let history = &app.session.turn_cache_history;
|
||||
if history.is_empty() {
|
||||
out.push_str(" No turn telemetry recorded yet.\n");
|
||||
} else {
|
||||
// Aggregate only cache-aware turns; skip turns where the provider
|
||||
// did not report cache telemetry (cache_hit_tokens is None).
|
||||
// When cache_miss_tokens is None, infer it as
|
||||
// input_tokens − cache_hit_tokens (matches /cache table logic).
|
||||
let mut turns = 0u64;
|
||||
let (hit, miss, input) = app.session.turn_cache_history.iter().fold(
|
||||
(0u64, 0u64, 0u64),
|
||||
|(hit, miss, input), rec| {
|
||||
let Some(hit_tokens) = rec.cache_hit_tokens else {
|
||||
return (hit, miss, input);
|
||||
};
|
||||
let h = u64::from(hit_tokens);
|
||||
let m = u64::from(
|
||||
rec.cache_miss_tokens
|
||||
.unwrap_or(rec.input_tokens.saturating_sub(hit_tokens)),
|
||||
);
|
||||
turns += 1;
|
||||
(hit + h, miss + m, input + u64::from(rec.input_tokens))
|
||||
},
|
||||
);
|
||||
let total_cache = hit + miss;
|
||||
let avg_pct = if total_cache > 0 {
|
||||
(hit as f64 / total_cache as f64 * 100.0).clamp(0.0, 100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
out.push_str(&format!(" Turns recorded: {turns}\n"));
|
||||
out.push_str(&format!(
|
||||
" Cache hit tokens: {hit} ({avg_pct:.1}% of {total_cache} cache-aware tokens)\n",
|
||||
hit = format_tokens(hit),
|
||||
total_cache = format_tokens(total_cache),
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" Cache miss tokens: {miss}\n",
|
||||
miss = format_tokens(miss),
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" Total input tokens: {input}\n",
|
||||
input = format_tokens(input),
|
||||
));
|
||||
if avg_pct < 80.0 {
|
||||
out.push_str(" NOTE: cache hit rate is low (< 80%). Check prefix stability above or consider /compact.\n");
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Formats a u64 token count with a compact suffix: K for thousands,
|
||||
/// M for millions. Never returns scientific notation.
|
||||
fn format_tokens(n: u64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
} else if n >= 1_000 {
|
||||
format!("{:.1}K", n as f64 / 1_000.0)
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_static_prefix_status(
|
||||
previous: Option<&PromptInspection>,
|
||||
current: &PromptInspection,
|
||||
@@ -1402,6 +1539,136 @@ mod tests {
|
||||
ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call-a"
|
||||
));
|
||||
}
|
||||
|
||||
// ── /cache stats tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cache_stats_no_data_before_first_turn() {
|
||||
let mut app = create_test_app();
|
||||
let result = cache(&mut app, Some("stats"));
|
||||
let msg = result.message.expect("cache stats produces a message");
|
||||
assert!(msg.contains("Cache Stats"), "got: {msg}");
|
||||
assert!(
|
||||
msg.contains("unknown (no checks recorded yet)"),
|
||||
"got: {msg}"
|
||||
);
|
||||
assert!(msg.contains("Pinned hash: unavailable"), "got: {msg}");
|
||||
assert!(msg.contains("No turn telemetry recorded yet"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_stats_shows_stable_prefix_with_hash() {
|
||||
let mut app = create_test_app();
|
||||
app.prefix_stability_pct = Some(100);
|
||||
app.prefix_checks_total = 5;
|
||||
app.prefix_change_count = 0;
|
||||
app.last_pinned_prefix_hash =
|
||||
Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string());
|
||||
|
||||
let result = cache(&mut app, Some("stats"));
|
||||
let msg = result.message.expect("cache stats produces a message");
|
||||
|
||||
assert!(msg.contains("Stability: 100%"), "got: {msg}");
|
||||
assert!(msg.contains("stable (no prefix changes"), "got: {msg}");
|
||||
assert!(msg.contains("Pinned hash: a1b2c3d4e5f6"), "got: {msg}");
|
||||
assert!(
|
||||
msg.contains("Drift: none (hash stable)"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_stats_warns_on_prefix_change() {
|
||||
let mut app = create_test_app();
|
||||
app.prefix_stability_pct = Some(67);
|
||||
app.prefix_checks_total = 3;
|
||||
app.prefix_change_count = 1;
|
||||
app.last_prefix_change_desc =
|
||||
Some("prefix cache invalidated: system prompt changed".to_string());
|
||||
app.last_pinned_prefix_hash = Some(
|
||||
"deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef0000deadbeef".to_string(),
|
||||
);
|
||||
|
||||
let result = cache(&mut app, Some("stats"));
|
||||
let msg = result.message.expect("cache stats produces a message");
|
||||
|
||||
assert!(msg.contains("Stability: 67%"), "got: {msg}");
|
||||
assert!(msg.contains("WARNING — prefix has changed"), "got: {msg}");
|
||||
assert!(msg.contains("system prompt changed"), "got: {msg}");
|
||||
assert!(msg.contains("Drift: WARNING"), "got: {msg}");
|
||||
assert!(msg.contains("1 change detected"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_stats_shows_cache_hit_summary() {
|
||||
let mut app = create_test_app();
|
||||
app.prefix_stability_pct = Some(100);
|
||||
app.prefix_checks_total = 1;
|
||||
app.last_pinned_prefix_hash =
|
||||
Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string());
|
||||
|
||||
app.push_turn_cache_record(TurnCacheRecord {
|
||||
input_tokens: 10_000,
|
||||
output_tokens: 1_000,
|
||||
cache_hit_tokens: Some(8_000),
|
||||
cache_miss_tokens: Some(2_000),
|
||||
reasoning_replay_tokens: None,
|
||||
recorded_at: Instant::now(),
|
||||
});
|
||||
app.push_turn_cache_record(TurnCacheRecord {
|
||||
input_tokens: 5_000,
|
||||
output_tokens: 500,
|
||||
cache_hit_tokens: Some(4_500),
|
||||
cache_miss_tokens: Some(500),
|
||||
reasoning_replay_tokens: None,
|
||||
recorded_at: Instant::now(),
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("stats"));
|
||||
let msg = result.message.expect("cache stats produces a message");
|
||||
|
||||
assert!(msg.contains("Turns recorded: 2"), "got: {msg}");
|
||||
// Total: 12,500 hit out of 15,000 cache-aware = 83.3%
|
||||
assert!(msg.contains("83.3%"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_stats_low_hit_rate_shows_note() {
|
||||
let mut app = create_test_app();
|
||||
app.prefix_stability_pct = Some(100);
|
||||
app.prefix_checks_total = 1;
|
||||
app.last_pinned_prefix_hash =
|
||||
Some("abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234".to_string());
|
||||
|
||||
app.push_turn_cache_record(TurnCacheRecord {
|
||||
input_tokens: 10_000,
|
||||
output_tokens: 1_000,
|
||||
cache_hit_tokens: Some(1_000),
|
||||
cache_miss_tokens: Some(9_000),
|
||||
reasoning_replay_tokens: None,
|
||||
recorded_at: Instant::now(),
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("stats"));
|
||||
let msg = result.message.expect("cache stats produces a message");
|
||||
|
||||
// 10% hit rate → below 80% threshold
|
||||
assert!(msg.contains("10.0%"), "got: {msg}");
|
||||
assert!(
|
||||
msg.contains("cache hit rate is low"),
|
||||
"should show low-hit-rate advisory, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_tokens_handles_all_scales() {
|
||||
assert_eq!(format_tokens(0), "0");
|
||||
assert_eq!(format_tokens(999), "999");
|
||||
assert_eq!(format_tokens(1_000), "1.0K");
|
||||
assert_eq!(format_tokens(15_500), "15.5K");
|
||||
assert_eq!(format_tokens(1_000_000), "1.0M");
|
||||
assert_eq!(format_tokens(2_500_000), "2.5M");
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove last message pair (user + assistant).
|
||||
|
||||
@@ -56,9 +56,15 @@ fn parse_add(parts: Vec<&str>) -> CommandResult {
|
||||
command: parts[2].to_string(),
|
||||
args: parts[3..].iter().map(|s| (*s).to_string()).collect(),
|
||||
})),
|
||||
"http" | "sse" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp {
|
||||
"http" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp {
|
||||
name: parts[1].to_string(),
|
||||
url: parts[2].to_string(),
|
||||
transport: None,
|
||||
})),
|
||||
"sse" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp {
|
||||
name: parts[1].to_string(),
|
||||
url: parts[2].to_string(),
|
||||
transport: Some("sse".to_string()),
|
||||
})),
|
||||
_ => CommandResult::error(
|
||||
"Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>",
|
||||
|
||||
@@ -31,7 +31,7 @@ mod skills;
|
||||
mod stash;
|
||||
mod status;
|
||||
mod task;
|
||||
mod user_commands;
|
||||
pub mod user_commands;
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
@@ -543,7 +543,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "cache",
|
||||
aliases: &[],
|
||||
usage: "/cache [count|inspect|warmup]",
|
||||
usage: "/cache [count|inspect|stats|warmup]",
|
||||
description_id: MessageId::CmdCacheDescription,
|
||||
},
|
||||
];
|
||||
@@ -966,6 +966,7 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> {
|
||||
///
|
||||
/// `workspace` is used to also scan workspace-local command directories;
|
||||
/// pass `None` when no workspace context is available.
|
||||
#[allow(dead_code)]
|
||||
pub fn all_command_names_matching(
|
||||
prefix: &str,
|
||||
workspace: Option<&std::path::Path>,
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
|
||||
let Some(target) = ApiProvider::parse(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama."
|
||||
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, xiaomi-mimo, novita, fireworks, sglang, vllm, or ollama."
|
||||
));
|
||||
};
|
||||
|
||||
@@ -112,6 +112,7 @@ mod tests {
|
||||
let msg = result.message.expect("expected error message");
|
||||
assert!(msg.contains("Unknown provider"));
|
||||
assert!(msg.contains("openrouter"));
|
||||
assert!(msg.contains("xiaomi-mimo"));
|
||||
assert!(msg.contains("novita"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
@@ -129,6 +130,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_xiaomi_mimo_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = provider(&mut app, Some("xiaomi-mimo"));
|
||||
match result.action {
|
||||
Some(AppAction::SwitchProvider { provider, model }) => {
|
||||
assert_eq!(provider, ApiProvider::XiaomiMimo);
|
||||
assert_eq!(model, None);
|
||||
}
|
||||
other => panic!("expected SwitchProvider, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_to_atlascloud_emits_action() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! (without `.md` extension) becomes a slash command. When invoked via
|
||||
//! `/name`, the file contents are sent as a user message.
|
||||
//!
|
||||
//! Files may include optional YAML-like frontmatter between `---` markers.
|
||||
//! Supported fields are `description`, `argument-hint`, and `allowed-tools`.
|
||||
//! Frontmatter is stripped before the command body is sent to the model.
|
||||
//!
|
||||
//! ## Precedence
|
||||
//!
|
||||
//! Workspace-local directories shadow user-global by name:
|
||||
@@ -95,6 +99,72 @@ pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> {
|
||||
commands
|
||||
}
|
||||
|
||||
pub(crate) fn parse_frontmatter(content: &str) -> (Vec<(String, String)>, &str) {
|
||||
let Some(first_line_end) = content.find('\n') else {
|
||||
return (Vec::new(), content);
|
||||
};
|
||||
let first = content[..first_line_end].trim_end_matches('\r');
|
||||
|
||||
if first.trim().chars().all(|ch| ch == '-') && first.trim().len() >= 3 {
|
||||
let mut metadata = Vec::new();
|
||||
let mut offset = first_line_end + 1;
|
||||
let mut unclosed_body_start = None;
|
||||
for raw_line in content[offset..].split_inclusive('\n') {
|
||||
let line_start = offset;
|
||||
let line = raw_line.trim_end_matches(['\r', '\n']);
|
||||
offset += raw_line.len();
|
||||
let trimmed = line.trim();
|
||||
if unclosed_body_start.is_none() {
|
||||
if trimmed.chars().all(|ch| ch == '-') && trimmed.len() >= 3 {
|
||||
let body = content[offset..].trim_start_matches(['\r', '\n']);
|
||||
return (metadata, body);
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let raw_value = value.trim();
|
||||
let value = if key == "allowed-tools" {
|
||||
raw_value.to_string()
|
||||
} else {
|
||||
strip_matched_quotes(raw_value).to_string()
|
||||
};
|
||||
if !key.is_empty() {
|
||||
metadata.push((key, value));
|
||||
}
|
||||
} else if !trimmed.is_empty() {
|
||||
unclosed_body_start = Some(line_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
let body_start = unclosed_body_start.unwrap_or(content.len());
|
||||
let body = content[body_start..].trim_start_matches(['\r', '\n']);
|
||||
return (metadata, body);
|
||||
}
|
||||
|
||||
(Vec::new(), content)
|
||||
}
|
||||
|
||||
fn strip_matched_quotes(value: &str) -> &str {
|
||||
if let Some(stripped) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||
return stripped;
|
||||
}
|
||||
if let Some(stripped) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
|
||||
return stripped;
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn parse_allowed_tools(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|tool| {
|
||||
strip_matched_quotes(tool.trim())
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
})
|
||||
.filter(|tool| !tool.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if the input matches a user-defined command and return the
|
||||
/// content as a `SendMessage` action.
|
||||
///
|
||||
@@ -121,7 +191,23 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe
|
||||
|
||||
for (name, content) in &user_commands {
|
||||
if name == command {
|
||||
let message = apply_template(content, args);
|
||||
let (metadata, body) = parse_frontmatter(content);
|
||||
app.goal.goal_objective = None;
|
||||
app.goal.goal_started_at = None;
|
||||
app.active_allowed_tools = None;
|
||||
for (key, value) in &metadata {
|
||||
match key.as_str() {
|
||||
"description" => {
|
||||
app.goal.goal_objective = Some(value.clone());
|
||||
app.goal.goal_started_at = Some(std::time::Instant::now());
|
||||
}
|
||||
"allowed-tools" => {
|
||||
app.active_allowed_tools = Some(parse_allowed_tools(value));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let message = apply_template(body, args);
|
||||
return Some(CommandResult::action(AppAction::SendMessage(message)));
|
||||
}
|
||||
}
|
||||
@@ -217,6 +303,30 @@ mod tests {
|
||||
std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
|
||||
}
|
||||
|
||||
fn test_options(workspace: PathBuf) -> crate::tui::app::TuiOptions {
|
||||
crate::tui::app::TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace,
|
||||
config_path: None,
|
||||
config_profile: None,
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
use_bracketed_paste: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
initial_input: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_user_commands_scans_workspace_local_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -363,4 +473,174 @@ mod tests {
|
||||
"got: {matches:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_is_stripped_before_dispatch() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"secure",
|
||||
"---\ndescription: Secure scan\nallowed-tools: Bash, Read\n---\nRun $ARGUMENTS",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let result = try_dispatch_user_command(&mut app, "/secure checks").unwrap();
|
||||
match result.action {
|
||||
Some(AppAction::SendMessage(msg)) => assert_eq!(msg, "Run checks"),
|
||||
other => panic!("expected SendMessage action, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_unclosed_frontmatter_keeps_metadata_and_strips_header() {
|
||||
let (metadata, body) = parse_frontmatter(
|
||||
"---\ndescription: Broken command\nallowed-tools: Bash\nRun the safe body",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
vec![
|
||||
("description".to_string(), "Broken command".to_string()),
|
||||
("allowed-tools".to_string(), "Bash".to_string())
|
||||
]
|
||||
);
|
||||
assert_eq!(body, "Run the safe body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_unclosed_frontmatter_without_metadata_strips_header() {
|
||||
let (metadata, body) =
|
||||
parse_frontmatter("---\nRun the command body without a closing delimiter");
|
||||
|
||||
assert!(metadata.is_empty());
|
||||
assert_eq!(body, "Run the command body without a closing delimiter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_frontmatter_strips_only_matched_quote_pairs() {
|
||||
let (metadata, body) = parse_frontmatter("---\ndescription: 'Read\"\n---\nrun");
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
vec![("description".to_string(), "'Read\"".to_string())]
|
||||
);
|
||||
assert_eq!(body, "run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_frontmatter_sets_app_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"secure",
|
||||
"---\nallowed-tools: Bash, Grep\n---\nrun tests",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/secure").unwrap();
|
||||
assert_eq!(
|
||||
app.active_allowed_tools,
|
||||
Some(vec!["bash".to_string(), "grep".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_empty_allowed_tools_blocks_all_tools() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"locked",
|
||||
"---\nallowed-tools: \"\"\n---\nrun nothing",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/locked").unwrap();
|
||||
assert_eq!(app.active_allowed_tools, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_allowed_tools_accepts_per_item_quotes() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"quoted",
|
||||
"---\nallowed-tools: \"exec_shell\", 'read_file'\n---\nrun quoted tools",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/quoted").unwrap();
|
||||
assert_eq!(
|
||||
app.active_allowed_tools,
|
||||
Some(vec!["exec_shell".to_string(), "read_file".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_dispatch_without_frontmatter_resets_previous_command_state() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
let commands_dir = ws.join(".deepseek").join("commands");
|
||||
write_command(
|
||||
&commands_dir,
|
||||
"described",
|
||||
"---\ndescription: Scan repos\nallowed-tools: Bash\n---\nscan",
|
||||
);
|
||||
write_command(&commands_dir, "plain", "plain command");
|
||||
|
||||
let mut app = App::new(test_options(ws), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/described").unwrap();
|
||||
assert_eq!(app.goal.goal_objective.as_deref(), Some("Scan repos"));
|
||||
assert!(app.goal.goal_started_at.is_some());
|
||||
assert_eq!(app.active_allowed_tools, Some(vec!["bash".to_string()]));
|
||||
|
||||
let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
|
||||
assert_eq!(app.goal.goal_objective, None);
|
||||
assert_eq!(app.goal.goal_started_at, None);
|
||||
assert_eq!(app.active_allowed_tools, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_frontmatter_sets_work_objective_and_autocomplete_description() {
|
||||
use crate::config::Config;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws = tmp.path().to_path_buf();
|
||||
write_command(
|
||||
&ws.join(".deepseek").join("commands"),
|
||||
"git-scan",
|
||||
"---\ndescription: Scan nested git repositories\nargument-hint: <root>\n---\nscan",
|
||||
);
|
||||
|
||||
let mut app = App::new(test_options(ws.clone()), &Config::default());
|
||||
let _ = try_dispatch_user_command(&mut app, "/git-scan").unwrap();
|
||||
assert_eq!(
|
||||
app.goal.goal_objective.as_deref(),
|
||||
Some("Scan nested git repositories")
|
||||
);
|
||||
let commands = load_user_commands(Some(&ws));
|
||||
let (_, content) = commands
|
||||
.iter()
|
||||
.find(|(name, _)| name == "git-scan")
|
||||
.expect("git-scan command should load");
|
||||
let (metadata, _) = parse_frontmatter(content);
|
||||
assert!(metadata.contains(&(
|
||||
"description".to_string(),
|
||||
"Scan nested git repositories".to_string()
|
||||
)));
|
||||
assert!(metadata.contains(&("argument-hint".to_string(), "<root>".to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,29 +79,44 @@ pub fn append_history(entry: &str) {
|
||||
/// write if the channel send fails) so callers never block on disk I/O.
|
||||
fn append_history_dispatched(path: &Path, entry: &str) {
|
||||
let entry = entry.to_string();
|
||||
if writer_sender()
|
||||
.send((path.to_path_buf(), entry.clone()))
|
||||
.is_err()
|
||||
{
|
||||
append_history_to(path, &entry);
|
||||
if let Err(err) = writer_sender().send(HistoryWrite::Append(path.to_path_buf(), entry)) {
|
||||
match err.0 {
|
||||
HistoryWrite::Append(path, entry) => append_history_to(&path, &entry),
|
||||
#[cfg(test)]
|
||||
HistoryWrite::Flush(_) => unreachable!("flush messages are only sent by tests"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HistoryWrite {
|
||||
Append(PathBuf, String),
|
||||
#[cfg(test)]
|
||||
Flush(Sender<()>),
|
||||
}
|
||||
|
||||
/// Lazy singleton sender for the dedicated composer-history writer
|
||||
/// thread. Initialised on first use; the thread runs for the lifetime
|
||||
/// of the process and drains queued writes in arrival order.
|
||||
fn writer_sender() -> &'static Sender<(PathBuf, String)> {
|
||||
static SENDER: OnceLock<Sender<(PathBuf, String)>> = OnceLock::new();
|
||||
fn writer_sender() -> &'static Sender<HistoryWrite> {
|
||||
static SENDER: OnceLock<Sender<HistoryWrite>> = OnceLock::new();
|
||||
SENDER.get_or_init(|| {
|
||||
let (tx, rx) = channel::<(PathBuf, String)>();
|
||||
let (tx, rx) = channel::<HistoryWrite>();
|
||||
let spawn_result = std::thread::Builder::new()
|
||||
.name("composer-history-writer".to_string())
|
||||
.spawn(move || {
|
||||
// recv() returns Err when all senders have dropped, which
|
||||
// only happens at process shutdown because the singleton
|
||||
// sender lives in a static for the lifetime of the process.
|
||||
while let Ok(first) = rx.recv() {
|
||||
append_history_batch(&rx, first);
|
||||
while let Ok(message) = rx.recv() {
|
||||
match message {
|
||||
HistoryWrite::Append(path, entry) => {
|
||||
append_history_batch(&rx, (path, entry));
|
||||
}
|
||||
#[cfg(test)]
|
||||
HistoryWrite::Flush(done) => {
|
||||
let _ = done.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(err) = spawn_result {
|
||||
@@ -111,12 +126,19 @@ fn writer_sender() -> &'static Sender<(PathBuf, String)> {
|
||||
})
|
||||
}
|
||||
|
||||
fn append_history_batch(rx: &Receiver<(PathBuf, String)>, first: (PathBuf, String)) {
|
||||
fn append_history_batch(rx: &Receiver<HistoryWrite>, first: (PathBuf, String)) {
|
||||
let mut pending = vec![first];
|
||||
#[cfg(test)]
|
||||
let mut flush = None;
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(2)) {
|
||||
Ok(next) => pending.push(next),
|
||||
Ok(HistoryWrite::Append(path, entry)) => pending.push((path, entry)),
|
||||
#[cfg(test)]
|
||||
Ok(HistoryWrite::Flush(done)) => {
|
||||
flush = Some(done);
|
||||
break;
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
@@ -125,6 +147,11 @@ fn append_history_batch(rx: &Receiver<(PathBuf, String)>, first: (PathBuf, Strin
|
||||
for (path, entries) in group_history_writes_by_path(pending) {
|
||||
append_history_entries_to(&path, entries.iter().map(String::as_str));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(done) = flush {
|
||||
let _ = done.send(());
|
||||
}
|
||||
}
|
||||
|
||||
fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec<String>)> {
|
||||
@@ -190,7 +217,7 @@ fn append_history_entries_to<'a>(
|
||||
}
|
||||
|
||||
let payload = entries.join("\n") + "\n";
|
||||
if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) {
|
||||
if let Err(err) = write_history_atomic(path, payload.as_bytes()) {
|
||||
tracing::warn!(
|
||||
"Failed to persist composer history at {}: {err}",
|
||||
path.display()
|
||||
@@ -198,9 +225,44 @@ fn append_history_entries_to<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
fn write_history_atomic(path: &Path, payload: &[u8]) -> std::io::Result<()> {
|
||||
const RETRY_DELAYS: &[Duration] = &[
|
||||
Duration::from_millis(5),
|
||||
Duration::from_millis(10),
|
||||
Duration::from_millis(25),
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(400),
|
||||
];
|
||||
|
||||
for (attempt, delay) in RETRY_DELAYS
|
||||
.iter()
|
||||
.map(Some)
|
||||
.chain(std::iter::once(None))
|
||||
.enumerate()
|
||||
{
|
||||
match crate::utils::write_atomic(path, payload) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) if delay.is_some() => {
|
||||
tracing::debug!(
|
||||
"Retrying composer history write to {} after attempt {} failed: {err}",
|
||||
path.display(),
|
||||
attempt + 1
|
||||
);
|
||||
std::thread::sleep(*delay.expect("delay checked"));
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!("retry iterator always ends with a final write attempt")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Tests use the path-injecting `*_from` / `*_to` helpers so they
|
||||
/// don't have to mutate `HOME` (which is not honored by
|
||||
@@ -213,6 +275,16 @@ mod tests {
|
||||
(tmp, path)
|
||||
}
|
||||
|
||||
fn flush_history_writer_for_tests(timeout: Duration) {
|
||||
let (done_tx, done_rx) = channel();
|
||||
writer_sender()
|
||||
.send(HistoryWrite::Flush(done_tx))
|
||||
.expect("history writer accepts flush");
|
||||
done_rx
|
||||
.recv_timeout(timeout)
|
||||
.expect("history writer flush timed out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_and_load_round_trip() {
|
||||
let (_tmp, path) = temp_history_path();
|
||||
@@ -283,8 +355,6 @@ mod tests {
|
||||
/// stall the user reports.
|
||||
#[test]
|
||||
fn append_history_dispatched_does_not_block_the_caller() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let (_tmp, path) = temp_history_path();
|
||||
// Seed close to the cap so a synchronous rewrite is non-trivial.
|
||||
let seed = (0..(MAX_HISTORY_ENTRIES - 50))
|
||||
@@ -311,26 +381,16 @@ mod tests {
|
||||
(likely re-introduced #1927: caller blocked on disk write)"
|
||||
);
|
||||
|
||||
// Give the writer thread time to drain the queue, then verify the
|
||||
// new entries landed.
|
||||
// Use 10s on Windows (slow CI I/O) vs 5s on other platforms.
|
||||
let deadline = Instant::now() + Duration::from_secs(if cfg!(windows) { 10 } else { 5 });
|
||||
loop {
|
||||
let loaded = load_history_from(&path);
|
||||
if loaded.iter().any(|line| line == "new entry 49") {
|
||||
// Last dispatched entry observed; queue is drained.
|
||||
assert!(loaded.iter().any(|line| line == "new entry 0"));
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!(
|
||||
"writer thread did not persist the dispatched entries; \
|
||||
loaded {} entries, last = {:?}",
|
||||
loaded.len(),
|
||||
loaded.last()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
flush_history_writer_for_tests(Duration::from_secs(if cfg!(windows) { 10 } else { 5 }));
|
||||
|
||||
let loaded = load_history_from(&path);
|
||||
assert!(
|
||||
loaded.iter().any(|line| line == "new entry 49"),
|
||||
"writer thread did not persist the dispatched entries; \
|
||||
loaded {} entries, last = {:?}",
|
||||
loaded.len(),
|
||||
loaded.last()
|
||||
);
|
||||
assert!(loaded.iter().any(|line| line == "new entry 0"));
|
||||
}
|
||||
}
|
||||
|
||||
+195
-2
@@ -46,6 +46,8 @@ pub const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.c
|
||||
pub const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
|
||||
pub const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
|
||||
pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||
pub const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
|
||||
pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
|
||||
pub const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
|
||||
pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
|
||||
pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1";
|
||||
@@ -91,6 +93,7 @@ pub enum ApiProvider {
|
||||
Atlascloud,
|
||||
WanjieArk,
|
||||
Openrouter,
|
||||
XiaomiMimo,
|
||||
Novita,
|
||||
Fireworks,
|
||||
Moonshot,
|
||||
@@ -113,6 +116,9 @@ impl ApiProvider {
|
||||
"wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark"
|
||||
| "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk),
|
||||
"openrouter" | "open_router" => Some(Self::Openrouter),
|
||||
"xiaomi-mimo" | "xiaomi_mimo" | "xiaomimimo" | "mimo" | "xiaomi" => {
|
||||
Some(Self::XiaomiMimo)
|
||||
}
|
||||
"novita" => Some(Self::Novita),
|
||||
"fireworks" | "fireworks-ai" => Some(Self::Fireworks),
|
||||
"moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot),
|
||||
@@ -133,6 +139,7 @@ impl ApiProvider {
|
||||
Self::Atlascloud => "atlascloud",
|
||||
Self::WanjieArk => "wanjie-ark",
|
||||
Self::Openrouter => "openrouter",
|
||||
Self::XiaomiMimo => "xiaomi-mimo",
|
||||
Self::Novita => "novita",
|
||||
Self::Fireworks => "fireworks",
|
||||
Self::Moonshot => "moonshot",
|
||||
@@ -153,6 +160,7 @@ impl ApiProvider {
|
||||
Self::Atlascloud => "AtlasCloud",
|
||||
Self::WanjieArk => "Wanjie Ark",
|
||||
Self::Openrouter => "OpenRouter",
|
||||
Self::XiaomiMimo => "Xiaomi MiMo",
|
||||
Self::Novita => "Novita AI",
|
||||
Self::Fireworks => "Fireworks AI",
|
||||
Self::Moonshot => "Moonshot/Kimi",
|
||||
@@ -172,6 +180,7 @@ impl ApiProvider {
|
||||
Self::Atlascloud,
|
||||
Self::WanjieArk,
|
||||
Self::Openrouter,
|
||||
Self::XiaomiMimo,
|
||||
Self::Novita,
|
||||
Self::Fireworks,
|
||||
Self::Moonshot,
|
||||
@@ -259,6 +268,19 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi
|
||||
};
|
||||
}
|
||||
|
||||
if matches!(provider, ApiProvider::XiaomiMimo) {
|
||||
return ProviderCapability {
|
||||
provider,
|
||||
resolved_model: resolved_model.to_string(),
|
||||
context_window: 1_000_000,
|
||||
max_output: 128_000,
|
||||
thinking_supported: true,
|
||||
cache_telemetry_supported: false,
|
||||
request_payload_mode: RequestPayloadMode::ChatCompletions,
|
||||
alias_deprecation: None,
|
||||
};
|
||||
}
|
||||
|
||||
if matches!(provider, ApiProvider::Ollama) {
|
||||
return ProviderCapability {
|
||||
provider,
|
||||
@@ -443,6 +465,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(),
|
||||
ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL],
|
||||
ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL],
|
||||
ApiProvider::XiaomiMimo => vec![DEFAULT_XIAOMI_MIMO_MODEL, "mimo-v2.5"],
|
||||
ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL],
|
||||
ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL],
|
||||
ApiProvider::Moonshot => vec![DEFAULT_MOONSHOT_MODEL],
|
||||
@@ -662,7 +685,12 @@ pub enum SearchProvider {
|
||||
#[serde(alias = "metaso")]
|
||||
Metaso,
|
||||
/// Baidu AI Search API (<https://qianfan.baidubce.com>). Requires api_key.
|
||||
#[serde(alias = "baidu-search", alias = "baidu_ai_search")]
|
||||
#[serde(
|
||||
alias = "baidu-search",
|
||||
alias = "baidu_ai_search",
|
||||
alias = "baidu_search",
|
||||
alias = "baidu-ai-search"
|
||||
)]
|
||||
Baidu,
|
||||
}
|
||||
|
||||
@@ -806,6 +834,7 @@ impl StatusItem {
|
||||
StatusItem::Agents,
|
||||
StatusItem::ReasoningReplay,
|
||||
StatusItem::Cache,
|
||||
StatusItem::GitBranch,
|
||||
StatusItem::Tokens,
|
||||
]
|
||||
}
|
||||
@@ -1350,6 +1379,8 @@ pub struct ProvidersConfig {
|
||||
#[serde(default)]
|
||||
pub openrouter: ProviderConfig,
|
||||
#[serde(default)]
|
||||
pub xiaomi_mimo: ProviderConfig,
|
||||
#[serde(default)]
|
||||
pub novita: ProviderConfig,
|
||||
#[serde(default)]
|
||||
pub fireworks: ProviderConfig,
|
||||
@@ -1509,6 +1540,7 @@ impl Config {
|
||||
ApiProvider::Atlascloud => "providers.atlascloud",
|
||||
ApiProvider::WanjieArk => "providers.wanjie_ark",
|
||||
ApiProvider::Openrouter => "providers.openrouter",
|
||||
ApiProvider::XiaomiMimo => "providers.xiaomi_mimo",
|
||||
ApiProvider::Novita => "providers.novita",
|
||||
ApiProvider::Fireworks => "providers.fireworks",
|
||||
ApiProvider::Moonshot => "providers.moonshot",
|
||||
@@ -1531,7 +1563,7 @@ impl Config {
|
||||
&& ApiProvider::parse(provider).is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama."
|
||||
"Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, xiaomi-mimo, novita, fireworks, sglang, vllm, or ollama."
|
||||
);
|
||||
}
|
||||
if let Some(ref key) = self.api_key
|
||||
@@ -1651,6 +1683,7 @@ impl Config {
|
||||
ApiProvider::Atlascloud => &providers.atlascloud,
|
||||
ApiProvider::WanjieArk => &providers.wanjie_ark,
|
||||
ApiProvider::Openrouter => &providers.openrouter,
|
||||
ApiProvider::XiaomiMimo => &providers.xiaomi_mimo,
|
||||
ApiProvider::Novita => &providers.novita,
|
||||
ApiProvider::Fireworks => &providers.fireworks,
|
||||
ApiProvider::Moonshot => &providers.moonshot,
|
||||
@@ -1741,6 +1774,7 @@ impl Config {
|
||||
ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
|
||||
ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
|
||||
ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL,
|
||||
ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
|
||||
ApiProvider::Novita => DEFAULT_NOVITA_MODEL,
|
||||
ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL,
|
||||
ApiProvider::Moonshot => DEFAULT_MOONSHOT_MODEL,
|
||||
@@ -1773,6 +1807,7 @@ impl Config {
|
||||
| ApiProvider::Atlascloud
|
||||
| ApiProvider::WanjieArk
|
||||
| ApiProvider::Openrouter
|
||||
| ApiProvider::XiaomiMimo
|
||||
| ApiProvider::Novita
|
||||
| ApiProvider::Fireworks
|
||||
| ApiProvider::Moonshot
|
||||
@@ -1789,6 +1824,7 @@ impl Config {
|
||||
ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
|
||||
ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
|
||||
ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
|
||||
ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
|
||||
ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,
|
||||
ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
|
||||
ApiProvider::Moonshot => {
|
||||
@@ -1832,6 +1868,7 @@ impl Config {
|
||||
ApiProvider::Atlascloud => "atlascloud",
|
||||
ApiProvider::WanjieArk => "wanjie-ark",
|
||||
ApiProvider::Openrouter => "openrouter",
|
||||
ApiProvider::XiaomiMimo => "xiaomi-mimo",
|
||||
ApiProvider::Novita => "novita",
|
||||
ApiProvider::Fireworks => "fireworks",
|
||||
ApiProvider::Moonshot => "moonshot",
|
||||
@@ -1917,6 +1954,10 @@ impl Config {
|
||||
"OpenRouter API key not found. Run 'codewhale auth set --provider openrouter', \
|
||||
set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.deepseek/config.toml."
|
||||
),
|
||||
ApiProvider::XiaomiMimo => anyhow::bail!(
|
||||
"Xiaomi MiMo API key not found. Run 'codewhale auth set --provider xiaomi-mimo', \
|
||||
set XIAOMI_MIMO_API_KEY/MIMO_API_KEY, or add [providers.xiaomi_mimo] api_key in ~/.deepseek/config.toml."
|
||||
),
|
||||
ApiProvider::Novita => anyhow::bail!(
|
||||
"Novita API key not found. Run 'codewhale auth set --provider novita', \
|
||||
set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml."
|
||||
@@ -2505,6 +2546,13 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
.openrouter
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::XiaomiMimo => {
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.xiaomi_mimo
|
||||
.base_url = Some(value);
|
||||
}
|
||||
ApiProvider::WanjieArk => {
|
||||
config
|
||||
.providers
|
||||
@@ -2607,6 +2655,17 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
.openrouter
|
||||
.base_url = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
|
||||
&& let Ok(value) =
|
||||
std::env::var("XIAOMI_MIMO_BASE_URL").or_else(|_| std::env::var("MIMO_BASE_URL"))
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.xiaomi_mimo
|
||||
.base_url = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::WanjieArk)
|
||||
&& let Ok(value) = std::env::var("WANJIE_ARK_BASE_URL")
|
||||
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
|
||||
@@ -2690,6 +2749,7 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
ApiProvider::Atlascloud => &mut providers.atlascloud,
|
||||
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
|
||||
ApiProvider::Openrouter => &mut providers.openrouter,
|
||||
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
|
||||
ApiProvider::Novita => &mut providers.novita,
|
||||
ApiProvider::Fireworks => &mut providers.fireworks,
|
||||
ApiProvider::Moonshot => &mut providers.moonshot,
|
||||
@@ -2735,6 +2795,16 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
.openai
|
||||
.model = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::XiaomiMimo)
|
||||
&& let Ok(value) =
|
||||
std::env::var("XIAOMI_MIMO_MODEL").or_else(|_| std::env::var("MIMO_MODEL"))
|
||||
{
|
||||
config
|
||||
.providers
|
||||
.get_or_insert_with(ProvidersConfig::default)
|
||||
.xiaomi_mimo
|
||||
.model = Some(value);
|
||||
}
|
||||
if matches!(config.api_provider(), ApiProvider::Atlascloud)
|
||||
&& let Ok(value) = std::env::var("ATLASCLOUD_MODEL")
|
||||
{
|
||||
@@ -2794,6 +2864,7 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
ApiProvider::Atlascloud => &mut providers.atlascloud,
|
||||
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
|
||||
ApiProvider::Openrouter => &mut providers.openrouter,
|
||||
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
|
||||
ApiProvider::Novita => &mut providers.novita,
|
||||
ApiProvider::Fireworks => &mut providers.fireworks,
|
||||
ApiProvider::Moonshot => &mut providers.moonshot,
|
||||
@@ -3066,6 +3137,7 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {
|
||||
ApiProvider::Openai
|
||||
| ApiProvider::Atlascloud
|
||||
| ApiProvider::WanjieArk
|
||||
| ApiProvider::XiaomiMimo
|
||||
| ApiProvider::Moonshot
|
||||
| ApiProvider::Ollama
|
||||
)
|
||||
@@ -3087,6 +3159,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str {
|
||||
ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
|
||||
ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
|
||||
ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
|
||||
ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
|
||||
ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL,
|
||||
ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
|
||||
ApiProvider::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
|
||||
@@ -3344,6 +3417,7 @@ fn merge_providers(
|
||||
atlascloud: merge_provider_config(base.atlascloud, override_cfg.atlascloud),
|
||||
wanjie_ark: merge_provider_config(base.wanjie_ark, override_cfg.wanjie_ark),
|
||||
openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter),
|
||||
xiaomi_mimo: merge_provider_config(base.xiaomi_mimo, override_cfg.xiaomi_mimo),
|
||||
novita: merge_provider_config(base.novita, override_cfg.novita),
|
||||
fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks),
|
||||
moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot),
|
||||
@@ -3764,6 +3838,10 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool {
|
||||
ApiProvider::Openrouter => {
|
||||
std::env::var("OPENROUTER_API_KEY").is_ok_and(|k| !k.trim().is_empty())
|
||||
}
|
||||
ApiProvider::XiaomiMimo => {
|
||||
std::env::var("XIAOMI_MIMO_API_KEY").is_ok_and(|k| !k.trim().is_empty())
|
||||
|| std::env::var("MIMO_API_KEY").is_ok_and(|k| !k.trim().is_empty())
|
||||
}
|
||||
ApiProvider::Novita => std::env::var("NOVITA_API_KEY").is_ok_and(|k| !k.trim().is_empty()),
|
||||
ApiProvider::Fireworks => {
|
||||
std::env::var("FIREWORKS_API_KEY").is_ok_and(|k| !k.trim().is_empty())
|
||||
@@ -3795,6 +3873,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
|
||||
ApiProvider::Atlascloud => "ATLASCLOUD_API_KEY",
|
||||
ApiProvider::WanjieArk => "WANJIE_ARK_API_KEY",
|
||||
ApiProvider::Openrouter => "OPENROUTER_API_KEY",
|
||||
ApiProvider::XiaomiMimo => "XIAOMI_MIMO_API_KEY",
|
||||
ApiProvider::Novita => "NOVITA_API_KEY",
|
||||
ApiProvider::Fireworks => "FIREWORKS_API_KEY",
|
||||
ApiProvider::Moonshot => "MOONSHOT_API_KEY",
|
||||
@@ -3816,6 +3895,11 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if matches!(provider, ApiProvider::XiaomiMimo)
|
||||
&& std::env::var("MIMO_API_KEY").is_ok_and(|k| !k.trim().is_empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if matches!(provider, ApiProvider::Moonshot)
|
||||
&& std::env::var("KIMI_API_KEY").is_ok_and(|k| !k.trim().is_empty())
|
||||
{
|
||||
@@ -3889,6 +3973,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
|
||||
ApiProvider::Atlascloud => "providers.atlascloud",
|
||||
ApiProvider::WanjieArk => "providers.wanjie_ark",
|
||||
ApiProvider::Openrouter => "providers.openrouter",
|
||||
ApiProvider::XiaomiMimo => "providers.xiaomi_mimo",
|
||||
ApiProvider::Novita => "providers.novita",
|
||||
ApiProvider::Fireworks => "providers.fireworks",
|
||||
ApiProvider::Moonshot => "providers.moonshot",
|
||||
@@ -3926,6 +4011,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
|
||||
ApiProvider::Atlascloud => "atlascloud",
|
||||
ApiProvider::WanjieArk => "wanjie_ark",
|
||||
ApiProvider::Openrouter => "openrouter",
|
||||
ApiProvider::XiaomiMimo => "xiaomi_mimo",
|
||||
ApiProvider::Novita => "novita",
|
||||
ApiProvider::Fireworks => "fireworks",
|
||||
ApiProvider::Moonshot => "moonshot",
|
||||
@@ -4015,6 +4101,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> {
|
||||
ApiProvider::Atlascloud => Ok("atlascloud"),
|
||||
ApiProvider::WanjieArk => Ok("wanjie_ark"),
|
||||
ApiProvider::Openrouter => Ok("openrouter"),
|
||||
ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"),
|
||||
ApiProvider::Novita => Ok("novita"),
|
||||
ApiProvider::Fireworks => Ok("fireworks"),
|
||||
ApiProvider::Moonshot => Ok("moonshot"),
|
||||
@@ -4472,6 +4559,12 @@ mod tests {
|
||||
wanjie_maas_model: Option<OsString>,
|
||||
openrouter_api_key: Option<OsString>,
|
||||
openrouter_base_url: Option<OsString>,
|
||||
xiaomi_mimo_api_key: Option<OsString>,
|
||||
mimo_api_key: Option<OsString>,
|
||||
xiaomi_mimo_base_url: Option<OsString>,
|
||||
mimo_base_url: Option<OsString>,
|
||||
xiaomi_mimo_model: Option<OsString>,
|
||||
mimo_model: Option<OsString>,
|
||||
novita_api_key: Option<OsString>,
|
||||
novita_base_url: Option<OsString>,
|
||||
fireworks_api_key: Option<OsString>,
|
||||
@@ -4537,6 +4630,12 @@ mod tests {
|
||||
let wanjie_maas_model_prev = env::var_os("WANJIE_MAAS_MODEL");
|
||||
let openrouter_api_key_prev = env::var_os("OPENROUTER_API_KEY");
|
||||
let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL");
|
||||
let xiaomi_mimo_api_key_prev = env::var_os("XIAOMI_MIMO_API_KEY");
|
||||
let mimo_api_key_prev = env::var_os("MIMO_API_KEY");
|
||||
let xiaomi_mimo_base_url_prev = env::var_os("XIAOMI_MIMO_BASE_URL");
|
||||
let mimo_base_url_prev = env::var_os("MIMO_BASE_URL");
|
||||
let xiaomi_mimo_model_prev = env::var_os("XIAOMI_MIMO_MODEL");
|
||||
let mimo_model_prev = env::var_os("MIMO_MODEL");
|
||||
let novita_api_key_prev = env::var_os("NOVITA_API_KEY");
|
||||
let novita_base_url_prev = env::var_os("NOVITA_BASE_URL");
|
||||
let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY");
|
||||
@@ -4597,6 +4696,12 @@ mod tests {
|
||||
env::remove_var("WANJIE_MAAS_MODEL");
|
||||
env::remove_var("OPENROUTER_API_KEY");
|
||||
env::remove_var("OPENROUTER_BASE_URL");
|
||||
env::remove_var("XIAOMI_MIMO_API_KEY");
|
||||
env::remove_var("MIMO_API_KEY");
|
||||
env::remove_var("XIAOMI_MIMO_BASE_URL");
|
||||
env::remove_var("MIMO_BASE_URL");
|
||||
env::remove_var("XIAOMI_MIMO_MODEL");
|
||||
env::remove_var("MIMO_MODEL");
|
||||
env::remove_var("NOVITA_API_KEY");
|
||||
env::remove_var("NOVITA_BASE_URL");
|
||||
env::remove_var("FIREWORKS_API_KEY");
|
||||
@@ -4657,6 +4762,12 @@ mod tests {
|
||||
wanjie_maas_model: wanjie_maas_model_prev,
|
||||
openrouter_api_key: openrouter_api_key_prev,
|
||||
openrouter_base_url: openrouter_base_url_prev,
|
||||
xiaomi_mimo_api_key: xiaomi_mimo_api_key_prev,
|
||||
mimo_api_key: mimo_api_key_prev,
|
||||
xiaomi_mimo_base_url: xiaomi_mimo_base_url_prev,
|
||||
mimo_base_url: mimo_base_url_prev,
|
||||
xiaomi_mimo_model: xiaomi_mimo_model_prev,
|
||||
mimo_model: mimo_model_prev,
|
||||
novita_api_key: novita_api_key_prev,
|
||||
novita_base_url: novita_base_url_prev,
|
||||
fireworks_api_key: fireworks_api_key_prev,
|
||||
@@ -4726,6 +4837,12 @@ mod tests {
|
||||
Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
|
||||
Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
|
||||
Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
|
||||
Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
|
||||
Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
|
||||
Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
|
||||
Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
|
||||
Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
|
||||
Self::restore_var("MIMO_MODEL", self.mimo_model.take());
|
||||
Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
|
||||
Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
|
||||
Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
|
||||
@@ -6068,6 +6185,54 @@ http_headers = { "X-Model-Provider-Id" = "from-file" }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_provider_uses_documented_defaults() -> Result<()> {
|
||||
let config = Config {
|
||||
provider: Some("xiaomi-mimo".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
config.validate()?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
|
||||
assert_eq!(config.default_model(), DEFAULT_XIAOMI_MIMO_MODEL);
|
||||
assert_eq!(config.deepseek_base_url(), DEFAULT_XIAOMI_MIMO_BASE_URL);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_env_overrides_provider_base_url_model_and_key() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"codewhale-tui-xiaomi-mimo-env-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_PROVIDER", "mimo");
|
||||
env::set_var("MIMO_API_KEY", "mimo-env-key");
|
||||
env::set_var("MIMO_BASE_URL", "https://mimo-gateway.example/v1");
|
||||
env::set_var("MIMO_MODEL", "mimo-v2.5");
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo);
|
||||
assert_eq!(config.deepseek_api_key()?, "mimo-env-key");
|
||||
assert_eq!(
|
||||
config.deepseek_base_url(),
|
||||
"https://mimo-gateway.example/v1"
|
||||
);
|
||||
assert_eq!(config.default_model(), "mimo-v2.5");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlascloud_provider_uses_documented_defaults() -> Result<()> {
|
||||
let config = Config {
|
||||
@@ -7115,6 +7280,7 @@ api_key = "moonshot-platform-key"
|
||||
assert!(!has_api_key_for(&config, ApiProvider::Openai));
|
||||
assert!(!has_api_key_for(&config, ApiProvider::WanjieArk));
|
||||
assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
|
||||
assert!(!has_api_key_for(&config, ApiProvider::XiaomiMimo));
|
||||
assert!(
|
||||
has_api_key_for(&config, ApiProvider::Sglang),
|
||||
"SGLang is self-hosted and does not require a key by default"
|
||||
@@ -7129,10 +7295,12 @@ api_key = "moonshot-platform-key"
|
||||
env::set_var("OPENROUTER_API_KEY", "or-env");
|
||||
env::set_var("OPENAI_API_KEY", "openai-env");
|
||||
env::set_var("WANJIE_API_KEY", "wanjie-env");
|
||||
env::set_var("MIMO_API_KEY", "mimo-env");
|
||||
}
|
||||
assert!(has_api_key_for(&config, ApiProvider::Openai));
|
||||
assert!(has_api_key_for(&config, ApiProvider::WanjieArk));
|
||||
assert!(has_api_key_for(&config, ApiProvider::Openrouter));
|
||||
assert!(has_api_key_for(&config, ApiProvider::XiaomiMimo));
|
||||
assert!(!has_api_key_for(&config, ApiProvider::Novita));
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
@@ -7140,14 +7308,17 @@ api_key = "moonshot-platform-key"
|
||||
env::remove_var("OPENROUTER_API_KEY");
|
||||
env::remove_var("OPENAI_API_KEY");
|
||||
env::remove_var("WANJIE_API_KEY");
|
||||
env::remove_var("MIMO_API_KEY");
|
||||
}
|
||||
let mut providers = ProvidersConfig::default();
|
||||
providers.openai.api_key = Some("file-openai".to_string());
|
||||
providers.wanjie_ark.api_key = Some("file-wanjie".to_string());
|
||||
providers.xiaomi_mimo.api_key = Some("file-mimo".to_string());
|
||||
providers.novita.api_key = Some("file-novita".to_string());
|
||||
config.providers = Some(providers);
|
||||
assert!(has_api_key_for(&config, ApiProvider::Openai));
|
||||
assert!(has_api_key_for(&config, ApiProvider::WanjieArk));
|
||||
assert!(has_api_key_for(&config, ApiProvider::XiaomiMimo));
|
||||
assert!(has_api_key_for(&config, ApiProvider::Novita));
|
||||
assert!(!has_api_key_for(&config, ApiProvider::Openrouter));
|
||||
Ok(())
|
||||
@@ -7240,6 +7411,7 @@ api_key = "moonshot-platform-key"
|
||||
save_api_key_for(ApiProvider::Openai, "openai-saved-key")?;
|
||||
save_api_key_for(ApiProvider::WanjieArk, "wanjie-saved-key")?;
|
||||
save_api_key_for(ApiProvider::Fireworks, "fireworks-saved-key")?;
|
||||
save_api_key_for(ApiProvider::XiaomiMimo, "mimo-saved-key")?;
|
||||
save_api_key_for(ApiProvider::Sglang, "sglang-saved-key")?;
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let parsed: toml::Value = toml::from_str(&contents)?;
|
||||
@@ -7267,6 +7439,14 @@ api_key = "moonshot-platform-key"
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("fireworks-saved-key")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("providers")
|
||||
.and_then(|p| p.get("xiaomi_mimo"))
|
||||
.and_then(|t| t.get("api_key"))
|
||||
.and_then(toml::Value::as_str),
|
||||
Some("mimo-saved-key")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("providers")
|
||||
@@ -7502,6 +7682,19 @@ model = "deepseek-ai/deepseek-v4-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_capability_xiaomi_mimo_has_thinking_no_cache() {
|
||||
let cap = provider_capability(ApiProvider::XiaomiMimo, DEFAULT_XIAOMI_MIMO_MODEL);
|
||||
assert_eq!(cap.context_window, 1_000_000);
|
||||
assert_eq!(cap.max_output, 128_000);
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
cap.request_payload_mode,
|
||||
RequestPayloadMode::ChatCompletions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_capability_novita_v4_pro_has_thinking_no_cache() {
|
||||
let cap = provider_capability(ApiProvider::Novita, DEFAULT_NOVITA_MODEL);
|
||||
|
||||
@@ -91,11 +91,15 @@ pub struct EngineConfig {
|
||||
pub mcp_config_path: PathBuf,
|
||||
/// Directory containing discoverable skills.
|
||||
pub skills_dir: PathBuf,
|
||||
/// Additional instruction files concatenated into the system
|
||||
/// prompt (#454). Loaded in declared order from the user's
|
||||
/// `instructions = [...]` config (or the per-project override).
|
||||
/// Resolved via `expand_path` so `~` works.
|
||||
pub instructions: Vec<PathBuf>,
|
||||
/// Sources injected as `<instructions source="…">` blocks in the system
|
||||
/// prompt (#454). Each entry is either a disk path (read at render time)
|
||||
/// or an inline string. Loaded in declared order from the user's
|
||||
/// `instructions = [...]` config or constructed by embedders.
|
||||
///
|
||||
/// Generalized from `Vec<PathBuf>` so embedders can inject inline content
|
||||
/// without staging a disk file. `From<PathBuf>` impl keeps existing callers
|
||||
/// working with `.into()` at the call site.
|
||||
pub instructions: Vec<crate::prompts::InstructionSource>,
|
||||
pub project_context_pack_enabled: bool,
|
||||
/// When true, the model is instructed to respond in the current locale
|
||||
/// and a post-hoc translation layer replaces remaining English output.
|
||||
@@ -158,6 +162,9 @@ pub struct EngineConfig {
|
||||
pub memory_path: PathBuf,
|
||||
pub vision_config: Option<crate::config::VisionModelConfig>,
|
||||
pub goal_objective: Option<String>,
|
||||
/// Tool restriction from custom slash command frontmatter.
|
||||
/// `None` means the current turn may use the normal tool set.
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Resolved BCP-47 locale tag (e.g. `"en"`, `"zh-Hans"`, `"ja"`)
|
||||
/// for the `## Environment` block in the system prompt. The
|
||||
/// caller resolves this from `Settings` once at engine
|
||||
@@ -224,6 +231,7 @@ impl Default for EngineConfig {
|
||||
vision_config: None,
|
||||
strict_tool_mode: false,
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
locale_tag: "en".to_string(),
|
||||
workshop: None,
|
||||
search_provider: crate::config::SearchProvider::default(),
|
||||
@@ -388,6 +396,7 @@ impl Engine {
|
||||
ApiProvider::Atlascloud => "ATLASCLOUD_API_KEY",
|
||||
ApiProvider::WanjieArk => "WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY",
|
||||
ApiProvider::Openrouter => "OPENROUTER_API_KEY",
|
||||
ApiProvider::XiaomiMimo => "XIAOMI_MIMO_API_KEY/MIMO_API_KEY",
|
||||
ApiProvider::Novita => "NOVITA_API_KEY",
|
||||
ApiProvider::Fireworks => "FIREWORKS_API_KEY",
|
||||
ApiProvider::Moonshot => "MOONSHOT_API_KEY/KIMI_API_KEY",
|
||||
@@ -627,6 +636,7 @@ impl Engine {
|
||||
approval_mode,
|
||||
translation_enabled,
|
||||
show_thinking,
|
||||
allowed_tools,
|
||||
} => {
|
||||
self.handle_send_message(
|
||||
content,
|
||||
@@ -642,6 +652,7 @@ impl Engine {
|
||||
approval_mode,
|
||||
translation_enabled,
|
||||
show_thinking,
|
||||
allowed_tools,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -849,6 +860,7 @@ impl Engine {
|
||||
self.session.approval_mode,
|
||||
self.config.translation_enabled,
|
||||
self.config.show_thinking,
|
||||
self.config.allowed_tools.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -938,6 +950,7 @@ impl Engine {
|
||||
approval_mode: crate::tui::approval::ApprovalMode,
|
||||
translation_enabled: bool,
|
||||
show_thinking: bool,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
) {
|
||||
// Reset cancel token for fresh turn (in case previous was cancelled)
|
||||
self.reset_cancel_token();
|
||||
@@ -1035,6 +1048,7 @@ impl Engine {
|
||||
false,
|
||||
);
|
||||
}
|
||||
self.config.allowed_tools = allowed_tools;
|
||||
self.session.reasoning_effort = reasoning_effort;
|
||||
self.session.reasoning_effort_auto = reasoning_effort_auto;
|
||||
self.session.auto_model = auto_model;
|
||||
@@ -2086,6 +2100,10 @@ mod tool_execution;
|
||||
mod tool_setup;
|
||||
mod turn_loop;
|
||||
|
||||
pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] {
|
||||
tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS
|
||||
}
|
||||
|
||||
use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision};
|
||||
#[cfg(test)]
|
||||
use self::dispatch::should_parallelize_tool_batch;
|
||||
@@ -2116,7 +2134,7 @@ use self::tool_catalog::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::tool_catalog::{
|
||||
TOOL_SEARCH_BM25_NAME, maybe_activate_requested_deferred_tool,
|
||||
TOOL_SEARCH_BM25_NAME, TOOL_SEARCH_REGEX_NAME, maybe_activate_requested_deferred_tool,
|
||||
preflight_requested_deferred_tool, should_default_defer_tool,
|
||||
};
|
||||
use self::tool_execution::emit_tool_audit;
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
//! or whenever a tool requests live user input (`await_user_input`). Channels
|
||||
//! and engine state stay private to the parent module.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::core::events::Event;
|
||||
use crate::tools::spec::ToolError;
|
||||
use crate::tools::user_input::{UserInputRequest, UserInputResponse};
|
||||
|
||||
const USER_INPUT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
use super::Engine;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -123,22 +127,43 @@ impl Engine {
|
||||
format!("Request cancelled while awaiting user input{suffix}"),
|
||||
));
|
||||
}
|
||||
decision = self.rx_user_input.recv() => {
|
||||
let Some(decision) = decision else {
|
||||
return Err(ToolError::execution_failed(
|
||||
"User input channel closed".to_string(),
|
||||
));
|
||||
};
|
||||
match decision {
|
||||
UserInputDecision::Submitted { id, response } if id == tool_id => {
|
||||
return Ok(response);
|
||||
result = tokio::time::timeout(USER_INPUT_TIMEOUT, self.rx_user_input.recv()) => {
|
||||
match result {
|
||||
Ok(Some(decision)) => {
|
||||
match decision {
|
||||
UserInputDecision::Submitted { id, response } if id == tool_id => {
|
||||
return Ok(response);
|
||||
}
|
||||
UserInputDecision::Cancelled { id } if id == tool_id => {
|
||||
return Err(ToolError::execution_failed(
|
||||
"User input cancelled".to_string(),
|
||||
));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
UserInputDecision::Cancelled { id } if id == tool_id => {
|
||||
Ok(None) => {
|
||||
return Err(ToolError::execution_failed(
|
||||
"User input cancelled".to_string(),
|
||||
"User input channel closed".to_string(),
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::Status {
|
||||
message: format!(
|
||||
"User input timed out after {}s",
|
||||
USER_INPUT_TIMEOUT.as_secs()
|
||||
),
|
||||
})
|
||||
.await;
|
||||
return Err(ToolError::execution_failed(
|
||||
format!(
|
||||
"User input timed out after {}s",
|
||||
USER_INPUT_TIMEOUT.as_secs()
|
||||
),
|
||||
));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,16 @@ fn non_yolo_mode_retains_default_defer_policy() {
|
||||
AppMode::Agent,
|
||||
&always_load
|
||||
));
|
||||
assert!(!should_default_defer_tool(
|
||||
"task_shell_start",
|
||||
AppMode::Agent,
|
||||
&always_load
|
||||
));
|
||||
assert!(!should_default_defer_tool(
|
||||
"task_shell_wait",
|
||||
AppMode::Agent,
|
||||
&always_load
|
||||
));
|
||||
assert!(should_default_defer_tool(
|
||||
"git_show",
|
||||
AppMode::Agent,
|
||||
@@ -2097,6 +2107,96 @@ fn tool_search_activates_discovered_deferred_tools() {
|
||||
assert!(active.contains("read_file"));
|
||||
}
|
||||
|
||||
fn tool_search_catalog_with_matches(count: usize) -> Vec<Tool> {
|
||||
let mut catalog = (0..count)
|
||||
.map(|idx| Tool {
|
||||
tool_type: None,
|
||||
name: format!("matching_tool_{idx:03}"),
|
||||
description: "Matching deferred test tool".to_string(),
|
||||
input_schema: json!({"type":"object","properties":{"query":{"type":"string"}}}),
|
||||
allowed_callers: Some(vec!["direct".to_string()]),
|
||||
defer_loading: Some(true),
|
||||
input_examples: None,
|
||||
strict: None,
|
||||
cache_control: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let always_load = HashSet::new();
|
||||
ensure_advanced_tooling(&mut catalog, AppMode::Agent, &always_load);
|
||||
catalog
|
||||
}
|
||||
|
||||
fn tool_search_reference_count(result: &ToolResult) -> usize {
|
||||
result
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("tool_references"))
|
||||
.and_then(|references| references.as_array())
|
||||
.map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_defaults_to_twenty_results_for_regex_and_bm25() {
|
||||
let catalog = tool_search_catalog_with_matches(25);
|
||||
|
||||
for tool_name in [TOOL_SEARCH_REGEX_NAME, TOOL_SEARCH_BM25_NAME] {
|
||||
let mut active = initial_active_tools(&catalog);
|
||||
let result = execute_tool_search(
|
||||
tool_name,
|
||||
&json!({"query":"matching"}),
|
||||
&catalog,
|
||||
&mut active,
|
||||
)
|
||||
.expect("search succeeds");
|
||||
|
||||
assert_eq!(tool_search_reference_count(&result), 20);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_respects_and_caps_max_results() {
|
||||
let catalog = tool_search_catalog_with_matches(120);
|
||||
|
||||
let mut active = initial_active_tools(&catalog);
|
||||
let limited = execute_tool_search(
|
||||
TOOL_SEARCH_BM25_NAME,
|
||||
&json!({"query":"matching","max_results":7}),
|
||||
&catalog,
|
||||
&mut active,
|
||||
)
|
||||
.expect("search succeeds");
|
||||
assert_eq!(tool_search_reference_count(&limited), 7);
|
||||
|
||||
let mut active = initial_active_tools(&catalog);
|
||||
let capped = execute_tool_search(
|
||||
TOOL_SEARCH_REGEX_NAME,
|
||||
&json!({"query":"matching","max_results":999}),
|
||||
&catalog,
|
||||
&mut active,
|
||||
)
|
||||
.expect("search succeeds");
|
||||
assert_eq!(tool_search_reference_count(&capped), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_schema_exposes_max_results_default_and_cap() {
|
||||
let mut catalog = Vec::new();
|
||||
let always_load = HashSet::new();
|
||||
ensure_advanced_tooling(&mut catalog, AppMode::Agent, &always_load);
|
||||
|
||||
for tool_name in [TOOL_SEARCH_REGEX_NAME, TOOL_SEARCH_BM25_NAME] {
|
||||
let tool = catalog
|
||||
.iter()
|
||||
.find(|tool| tool.name == tool_name)
|
||||
.expect("tool search definition exists");
|
||||
let schema = &tool.input_schema["properties"]["max_results"];
|
||||
|
||||
assert_eq!(schema["default"], 20);
|
||||
assert_eq!(schema["maximum"], 100);
|
||||
assert_eq!(schema["minimum"], 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_execution_runs_python_and_returns_result_payload() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::time::Duration;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::models::Tool;
|
||||
use crate::tools::spec::{ToolError, ToolResult, required_str};
|
||||
use crate::tools::spec::{ToolError, ToolResult, optional_u64, required_str};
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
pub(super) const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel";
|
||||
@@ -20,10 +20,12 @@ pub(super) const REQUEST_USER_INPUT_NAME: &str = "request_user_input";
|
||||
pub(super) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution";
|
||||
const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825";
|
||||
pub(super) use crate::tools::js_execution::JS_EXECUTION_TOOL_NAME;
|
||||
const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex";
|
||||
pub(super) const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex";
|
||||
const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119";
|
||||
pub(super) const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25";
|
||||
const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119";
|
||||
const TOOL_SEARCH_DEFAULT_MAX_RESULTS: usize = 20;
|
||||
const TOOL_SEARCH_MAX_RESULTS_LIMIT: usize = 100;
|
||||
|
||||
pub(super) fn is_tool_search_tool(name: &str) -> bool {
|
||||
matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME)
|
||||
@@ -34,7 +36,11 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
|
||||
"apply_patch",
|
||||
"checklist_write",
|
||||
"edit_file",
|
||||
"exec_interact",
|
||||
"exec_shell",
|
||||
"exec_shell_interact",
|
||||
"exec_shell_wait",
|
||||
"exec_wait",
|
||||
"fetch_url",
|
||||
"file_search",
|
||||
"git_diff",
|
||||
@@ -46,6 +52,8 @@ pub(super) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
|
||||
"task_create",
|
||||
"task_list",
|
||||
"task_read",
|
||||
"task_shell_start",
|
||||
"task_shell_wait",
|
||||
"update_plan",
|
||||
"web_search",
|
||||
"write_file",
|
||||
@@ -178,7 +186,14 @@ pub(super) fn ensure_advanced_tooling(
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." }
|
||||
"query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." },
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": TOOL_SEARCH_MAX_RESULTS_LIMIT,
|
||||
"default": TOOL_SEARCH_DEFAULT_MAX_RESULTS,
|
||||
"description": "Maximum number of matching tool references to return."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
@@ -198,7 +213,14 @@ pub(super) fn ensure_advanced_tooling(
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Natural language query for tool discovery." }
|
||||
"query": { "type": "string", "description": "Natural language query for tool discovery." },
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": TOOL_SEARCH_MAX_RESULTS_LIMIT,
|
||||
"default": TOOL_SEARCH_DEFAULT_MAX_RESULTS,
|
||||
"description": "Maximum number of matching tool references to return."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
@@ -280,7 +302,11 @@ fn tool_search_haystack(tool: &Tool) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result<Vec<String>, ToolError> {
|
||||
fn discover_tools_with_regex(
|
||||
catalog: &[Tool],
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
) -> Result<Vec<String>, ToolError> {
|
||||
let regex = regex::Regex::new(query)
|
||||
.map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?;
|
||||
|
||||
@@ -293,14 +319,14 @@ fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result<Vec<String
|
||||
if regex.is_match(&hay) {
|
||||
matches.push(tool.name.clone());
|
||||
}
|
||||
if matches.len() >= 5 {
|
||||
if matches.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec<String> {
|
||||
fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str, max_results: usize) -> Vec<String> {
|
||||
let terms: Vec<String> = query
|
||||
.split_whitespace()
|
||||
.map(|term| term.trim().to_lowercase())
|
||||
@@ -330,7 +356,11 @@ fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
|
||||
scored.into_iter().take(5).map(|(_, name)| name).collect()
|
||||
scored
|
||||
.into_iter()
|
||||
.take(max_results)
|
||||
.map(|(_, name)| name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
@@ -645,10 +675,17 @@ pub(super) fn execute_tool_search(
|
||||
active_tools: &mut HashSet<String>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let query = required_str(input, "query")?;
|
||||
let max_results = usize::try_from(optional_u64(
|
||||
input,
|
||||
"max_results",
|
||||
TOOL_SEARCH_DEFAULT_MAX_RESULTS as u64,
|
||||
))
|
||||
.unwrap_or(TOOL_SEARCH_DEFAULT_MAX_RESULTS)
|
||||
.clamp(1, TOOL_SEARCH_MAX_RESULTS_LIMIT);
|
||||
let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME {
|
||||
discover_tools_with_regex(catalog, query)?
|
||||
discover_tools_with_regex(catalog, query, max_results)?
|
||||
} else {
|
||||
discover_tools_with_bm25_like(catalog, query)
|
||||
discover_tools_with_bm25_like(catalog, query, max_results)
|
||||
};
|
||||
|
||||
for name in &discovered {
|
||||
|
||||
@@ -79,14 +79,9 @@ impl Engine {
|
||||
if self.config.features.enabled(Feature::WebSearch) {
|
||||
builder = builder.with_web_tools();
|
||||
}
|
||||
// Plan mode is strictly read-only: do not expose shell execution at
|
||||
// all, even if the session would otherwise allow it.
|
||||
if mode != AppMode::Plan
|
||||
&& self.config.features.enabled(Feature::ShellTool)
|
||||
&& self.session.allow_shell
|
||||
{
|
||||
builder = builder.with_shell_tools();
|
||||
}
|
||||
// Shell tools (exec_shell, task_shell_start, etc.) are already gated
|
||||
// behind `allow_shell` inside `with_agent_tools`. No separate
|
||||
// feature-flag gate here to avoid double-registration.
|
||||
|
||||
// Register the `remember` tool only when the user has opted in to
|
||||
// user-memory (#489). Without that opt-in the tool would always
|
||||
|
||||
@@ -23,7 +23,7 @@ impl Engine {
|
||||
// Signal to the terminal / taskbar that a turn is in progress
|
||||
// (OSC 9 ; 4 indeterminate progress + title spinner).
|
||||
crate::tui::notifications::set_taskbar_progress_busy();
|
||||
crate::tui::notifications::start_title_animation("DeepSeek TUI");
|
||||
crate::tui::notifications::start_title_animation("CodeWhale");
|
||||
|
||||
let client = self
|
||||
.deepseek_client
|
||||
@@ -249,6 +249,10 @@ impl Engine {
|
||||
let tools_ref: Option<&[crate::models::Tool]> = active_tools.as_deref();
|
||||
match pm.check_and_update(&system_text, tools_ref) {
|
||||
Err(change) => {
|
||||
let pinned_hash = pm
|
||||
.pinned_fingerprint()
|
||||
.map(|fp| fp.combined_sha256.clone())
|
||||
.unwrap_or_default();
|
||||
tracing::debug!(
|
||||
target: "prefix_cache",
|
||||
"{}",
|
||||
@@ -262,10 +266,15 @@ impl Engine {
|
||||
tools_changed: change.tools_changed,
|
||||
stability_pct: (pm.stability_ratio() * 100.0).round() as u32,
|
||||
changed: true,
|
||||
pinned_combined_hash: pinned_hash,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(_) => {
|
||||
let pinned_hash = pm
|
||||
.pinned_fingerprint()
|
||||
.map(|fp| fp.combined_sha256.clone())
|
||||
.unwrap_or_default();
|
||||
// Stable check — keep the TUI counter in sync.
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -275,6 +284,7 @@ impl Engine {
|
||||
tools_changed: false,
|
||||
stability_pct: (pm.stability_ratio() * 100.0).round() as u32,
|
||||
changed: false,
|
||||
pinned_combined_hash: pinned_hash,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1188,6 +1198,13 @@ impl Engine {
|
||||
"Planning tool '{tool_name}' with input: {tool_input:?}"
|
||||
));
|
||||
|
||||
let requested_tool_name = tool_name.clone();
|
||||
let tool_def =
|
||||
resolve_tool_definition(&mut tool_name, &tool_catalog, tool_registry);
|
||||
if requested_tool_name != tool_name {
|
||||
tool.name = tool_name.clone();
|
||||
}
|
||||
|
||||
let interactive = (tool_name == "exec_shell"
|
||||
&& tool_input
|
||||
.get("interactive")
|
||||
@@ -1219,25 +1236,10 @@ impl Engine {
|
||||
)));
|
||||
}
|
||||
|
||||
let requested_tool_name = tool_name.clone();
|
||||
let mut tool_def = tool_catalog.iter().find(|def| def.name == tool_name);
|
||||
|
||||
// Resolve hallucinated tool names when the model emits a
|
||||
// non-canonical variant (Read_file, readFile, read-file, etc.).
|
||||
if tool_def.is_none()
|
||||
&& let Some(registry) = tool_registry
|
||||
&& let Some(canonical) = registry.resolve(&tool_name)
|
||||
{
|
||||
crate::logging::info(format!(
|
||||
"Resolved hallucinated tool name '{tool_name}' -> '{canonical}'"
|
||||
));
|
||||
tool_def = tool_catalog.iter().find(|d| d.name == canonical);
|
||||
if tool_def.is_some() {
|
||||
tool_name = canonical.to_string();
|
||||
// Update the tool_uses entry so the result is
|
||||
// attributed to the canonical name.
|
||||
tool.name = tool_name.clone();
|
||||
}
|
||||
if !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name) {
|
||||
blocked_error = Some(ToolError::permission_denied(format!(
|
||||
"Tool '{tool_name}' is not in the allowed-tools list for the current command"
|
||||
)));
|
||||
}
|
||||
|
||||
if !caller_allowed_for_tool(tool_caller.as_ref(), tool_def) {
|
||||
@@ -1696,6 +1698,7 @@ impl Engine {
|
||||
.send(Event::ApprovalRequired {
|
||||
id: tool_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
input: tool_input.clone(),
|
||||
description: plan.approval_description.clone(),
|
||||
approval_key,
|
||||
approval_grouping_key,
|
||||
@@ -2112,6 +2115,40 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u
|
||||
queued_completions > 0 || running_children > 0
|
||||
}
|
||||
|
||||
fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
|
||||
let Some(allowed_tools) = allowed_tools else {
|
||||
return true;
|
||||
};
|
||||
allowed_tools.contains(&tool_name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn resolve_tool_definition<'a>(
|
||||
tool_name: &mut String,
|
||||
tool_catalog: &'a [Tool],
|
||||
tool_registry: Option<&crate::tools::ToolRegistry>,
|
||||
) -> Option<&'a Tool> {
|
||||
let mut tool_def = tool_catalog
|
||||
.iter()
|
||||
.find(|def| def.name.as_str() == tool_name.as_str());
|
||||
|
||||
// Resolve hallucinated tool names before policy gates run, so aliases like
|
||||
// ReadFile are checked against the canonical registered tool name.
|
||||
if tool_def.is_none()
|
||||
&& let Some(registry) = tool_registry
|
||||
&& let Some(canonical) = registry.resolve(tool_name.as_str())
|
||||
{
|
||||
crate::logging::info(format!(
|
||||
"Resolved hallucinated tool name '{tool_name}' -> '{canonical}'"
|
||||
));
|
||||
tool_def = tool_catalog.iter().find(|d| d.name == canonical);
|
||||
if tool_def.is_some() {
|
||||
*tool_name = canonical.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
tool_def
|
||||
}
|
||||
|
||||
/// Issue #1727: decide whether to surface a "thinking-only, no output" status.
|
||||
///
|
||||
/// Reached when the assistant turn had no sendable content (no Text, no
|
||||
@@ -2383,4 +2420,45 @@ mod tests {
|
||||
"auto thinking should classify the user request, not stored metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_gate_blocks_unlisted_tool() {
|
||||
let allowed = vec!["bash".to_string(), "grep".to_string()];
|
||||
assert!(!command_allows_tool(Some(&allowed), "read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_gate_allows_listed_tool_case_insensitively() {
|
||||
let allowed = vec!["bash".to_string(), "read".to_string()];
|
||||
assert!(command_allows_tool(Some(&allowed), "Read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_tools_gate_allows_all_tools_when_not_set() {
|
||||
assert!(command_allows_tool(None, "write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_allowed_tools_gate_blocks_all_tools_when_empty() {
|
||||
let allowed = Vec::new();
|
||||
assert!(!command_allows_tool(Some(&allowed), "bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_allowed_tools_gate_checks_canonical_tool_name() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let context = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf());
|
||||
let registry = crate::tools::ToolRegistryBuilder::new()
|
||||
.with_file_tools()
|
||||
.build(context);
|
||||
let catalog = registry.to_api_tools();
|
||||
let mut tool_name = "ReadFile".to_string();
|
||||
|
||||
let tool_def = resolve_tool_definition(&mut tool_name, &catalog, Some(®istry));
|
||||
|
||||
assert!(tool_def.is_some());
|
||||
assert_eq!(tool_name, "read_file");
|
||||
let allowed = vec!["read_file".to_string()];
|
||||
assert!(command_allows_tool(Some(&allowed), &tool_name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,9 @@ pub enum Event {
|
||||
id: String,
|
||||
tool_name: String,
|
||||
description: String,
|
||||
/// Tool parameters for approval display. Carried on the event so the
|
||||
/// TUI does not need to reconstruct them from `pending_tool_uses`.
|
||||
input: Value,
|
||||
/// Exact-argument fingerprint, used to scope *denials* (#1617).
|
||||
approval_key: String,
|
||||
/// Lossy / arity-aware fingerprint, used to scope *approvals* so an
|
||||
@@ -281,6 +284,10 @@ pub enum Event {
|
||||
/// True when the prefix actually changed (cache invalidated).
|
||||
/// False for routine stable-check heartbeats.
|
||||
changed: bool,
|
||||
/// Current pinned prefix combined hash (SHA-256, 64 hex chars).
|
||||
/// Carried so `/cache stats` can surface it without reaching
|
||||
/// into the engine's PrefixStabilityManager.
|
||||
pinned_combined_hash: String,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ pub enum Op {
|
||||
approval_mode: ApprovalMode,
|
||||
translation_enabled: bool,
|
||||
show_thinking: bool,
|
||||
/// Tool restriction from custom slash command frontmatter.
|
||||
/// `None` means the current turn may use the normal tool set.
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Cancel the current request
|
||||
|
||||
+4
-52
@@ -11,26 +11,17 @@ use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EvalShellPlatform {
|
||||
Windows,
|
||||
Unix,
|
||||
}
|
||||
|
||||
impl EvalShellPlatform {
|
||||
fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
Self::Windows
|
||||
} else {
|
||||
Self::Unix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct EvalShellInvocation {
|
||||
program: &'static str,
|
||||
@@ -38,10 +29,7 @@ struct EvalShellInvocation {
|
||||
raw_payload_on_windows: bool,
|
||||
}
|
||||
|
||||
fn eval_shell_invocation(command: &str) -> EvalShellInvocation {
|
||||
eval_shell_invocation_for_platform(command, EvalShellPlatform::current())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn eval_shell_invocation_for_platform(
|
||||
command: &str,
|
||||
platform: EvalShellPlatform,
|
||||
@@ -60,24 +48,6 @@ fn eval_shell_invocation_for_platform(
|
||||
}
|
||||
}
|
||||
|
||||
fn push_eval_shell_args(cmd: &mut Command, invocation: &EvalShellInvocation) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
if invocation.raw_payload_on_windows
|
||||
&& invocation.program.eq_ignore_ascii_case("cmd")
|
||||
&& invocation.args.len() == 2
|
||||
&& invocation.args[0].eq_ignore_ascii_case("/C")
|
||||
{
|
||||
cmd.raw_arg(&invocation.args[0]);
|
||||
cmd.raw_arg(&invocation.args[1]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cmd.args(&invocation.args);
|
||||
}
|
||||
|
||||
/// Representative tool steps covered by the evaluation harness.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
pub enum ScenarioStepKind {
|
||||
@@ -767,25 +737,7 @@ fn apply_patch(root: &Path, patch: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
fn exec_shell(root: &Path, command: &str) -> Result<String> {
|
||||
let invocation = eval_shell_invocation(command);
|
||||
let mut cmd = Command::new(invocation.program);
|
||||
push_eval_shell_args(&mut cmd, &invocation);
|
||||
let output = cmd
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.with_context(|| format!("failed to execute shell command: {command}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"shell command failed (status={}): {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(stdout.trim().to_string())
|
||||
crate::shell_dispatcher::global_dispatcher().run_foreground(command, root)
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str, max_chars: usize) -> String {
|
||||
|
||||
@@ -63,6 +63,21 @@ use super::{LlmClient, StreamEventBox};
|
||||
/// the mock does not require `MessageStart` to be present.
|
||||
pub type CannedTurn = Vec<StreamEvent>;
|
||||
|
||||
/// A queued mock response step.
|
||||
pub enum FauxStep {
|
||||
Canned(CannedTurn),
|
||||
/// Build a canned turn from the live outgoing request.
|
||||
///
|
||||
/// Tests can assert DeepSeek V4's thinking-mode tool-call invariant here:
|
||||
/// on the assistant turn that produced the previous tool call, the next
|
||||
/// outgoing request must still carry `reasoning_content` (represented in
|
||||
/// this model as a [`ContentBlock::Thinking`] block). If it is missing,
|
||||
/// DeepSeek V4 returns HTTP 400 on the follow-up turn. This guards the
|
||||
/// [v0.4.9-v0.5.1 regression range](https://github.com/Hmbown/CodeWhale/compare/v0.4.9...v0.5.1)
|
||||
/// where that content was dropped.
|
||||
Factory(Box<dyn Fn(&MessageRequest) -> CannedTurn + Send + Sync>),
|
||||
}
|
||||
|
||||
/// A queue-driven mock LLM client.
|
||||
///
|
||||
/// The mock holds a FIFO queue of canned response turns. Each call to
|
||||
@@ -75,7 +90,7 @@ pub type CannedTurn = Vec<StreamEvent>;
|
||||
/// can assert on the outgoing payload (e.g. that prior `reasoning_content` is
|
||||
/// preserved across turns).
|
||||
pub struct MockLlmClient {
|
||||
canned: Mutex<VecDeque<CannedTurn>>,
|
||||
canned: Mutex<VecDeque<FauxStep>>,
|
||||
captured_requests: Mutex<Vec<MessageRequest>>,
|
||||
calls: AtomicUsize,
|
||||
provider_name: &'static str,
|
||||
@@ -91,7 +106,7 @@ impl MockLlmClient {
|
||||
#[must_use]
|
||||
pub fn new(canned: Vec<CannedTurn>) -> Self {
|
||||
Self {
|
||||
canned: Mutex::new(canned.into()),
|
||||
canned: Mutex::new(canned.into_iter().map(FauxStep::Canned).collect()),
|
||||
captured_requests: Mutex::new(Vec::new()),
|
||||
calls: AtomicUsize::new(0),
|
||||
provider_name: "mock",
|
||||
@@ -119,7 +134,22 @@ impl MockLlmClient {
|
||||
self.canned
|
||||
.lock()
|
||||
.expect("MockLlmClient.canned mutex poisoned")
|
||||
.push_back(turn);
|
||||
.push_back(FauxStep::Canned(turn));
|
||||
}
|
||||
|
||||
/// Push a factory step onto the back of the queue.
|
||||
///
|
||||
/// The closure receives the live outgoing [`MessageRequest`] before the
|
||||
/// response stream is built, so assertions panic directly from the client
|
||||
/// call rather than later while polling the returned stream.
|
||||
pub fn push_factory<F>(&self, factory: F)
|
||||
where
|
||||
F: Fn(&MessageRequest) -> CannedTurn + Send + Sync + 'static,
|
||||
{
|
||||
self.canned
|
||||
.lock()
|
||||
.expect("MockLlmClient.canned mutex poisoned")
|
||||
.push_back(FauxStep::Factory(Box::new(factory)));
|
||||
}
|
||||
|
||||
/// Push a canned non-streaming `MessageResponse`. Consumed by
|
||||
@@ -175,13 +205,20 @@ impl MockLlmClient {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn pop_turn(&self) -> Option<CannedTurn> {
|
||||
fn pop_step(&self) -> Option<FauxStep> {
|
||||
self.canned
|
||||
.lock()
|
||||
.expect("MockLlmClient.canned mutex poisoned")
|
||||
.pop_front()
|
||||
}
|
||||
|
||||
fn turn_from_step(&self, step: FauxStep, request: &MessageRequest) -> CannedTurn {
|
||||
match step {
|
||||
FauxStep::Canned(turn) => turn,
|
||||
FauxStep::Factory(factory) => factory(request),
|
||||
}
|
||||
}
|
||||
|
||||
fn pop_message(&self) -> Option<MessageResponse> {
|
||||
self.canned_messages
|
||||
.lock()
|
||||
@@ -207,26 +244,28 @@ impl LlmClient for MockLlmClient {
|
||||
}
|
||||
|
||||
// Fallback: synthesize a MessageResponse from the next streaming turn.
|
||||
let Some(turn) = self.pop_turn() else {
|
||||
let Some(step) = self.pop_step() else {
|
||||
return Err(anyhow!(
|
||||
"MockLlmClient: create_message called but no canned response queued (request #{})",
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
));
|
||||
};
|
||||
|
||||
let turn = self.turn_from_step(step, &request);
|
||||
Ok(synthesize_message_response(turn, &self.model))
|
||||
}
|
||||
|
||||
async fn create_message_stream(&self, request: MessageRequest) -> Result<StreamEventBox> {
|
||||
self.record_request(&request);
|
||||
|
||||
let Some(turn) = self.pop_turn() else {
|
||||
let Some(step) = self.pop_step() else {
|
||||
return Err(anyhow!(
|
||||
"MockLlmClient: create_message_stream called but no canned turn queued (call #{})",
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
));
|
||||
};
|
||||
|
||||
let turn = self.turn_from_step(step, &request);
|
||||
Ok(stream_from_canned(turn))
|
||||
}
|
||||
|
||||
@@ -561,6 +600,22 @@ mod tests {
|
||||
assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_message_synthesizes_from_factory_turn() {
|
||||
let mock = MockLlmClient::new(Vec::new());
|
||||
mock.push_factory(|request| {
|
||||
assert_eq!(request.model, "mock-model");
|
||||
canned::simple_text_turn("from factory")
|
||||
});
|
||||
|
||||
let resp = mock.create_message(empty_request()).await.unwrap();
|
||||
let text = match &resp.content[0] {
|
||||
ContentBlock::Text { text, .. } => text.clone(),
|
||||
_ => panic!("expected text"),
|
||||
};
|
||||
assert_eq!(text, "from factory");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_and_model_are_overridable() {
|
||||
let mock = MockLlmClient::new(vec![canned::simple_text_turn("x")])
|
||||
|
||||
+648
-18
@@ -39,6 +39,7 @@ pub enum Locale {
|
||||
ZhHant,
|
||||
PtBr,
|
||||
Es419,
|
||||
Vi,
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
@@ -50,6 +51,7 @@ impl Locale {
|
||||
Self::ZhHant => "zh-Hant",
|
||||
Self::PtBr => "pt-BR",
|
||||
Self::Es419 => "es-419",
|
||||
Self::Vi => "vi",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ impl Locale {
|
||||
Self::ZhHant => "Traditional Chinese (繁體中文)",
|
||||
Self::PtBr => "Brazilian Portuguese (Português do Brasil)",
|
||||
Self::Es419 => "Latin American Spanish (Español latinoamericano)",
|
||||
Self::Vi => "Vietnamese (Tiếng Việt)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +118,14 @@ impl Locale {
|
||||
fallback: "en",
|
||||
coverage: LocaleCoverage::V076Core,
|
||||
},
|
||||
Self::Vi => LocaleSpec {
|
||||
tag: "vi",
|
||||
display_name: "Vietnamese",
|
||||
script: "Latin",
|
||||
direction: TextDirection::Ltr,
|
||||
fallback: "en",
|
||||
coverage: LocaleCoverage::V076Core,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +138,7 @@ impl Locale {
|
||||
Self::ZhHant,
|
||||
Self::PtBr,
|
||||
Self::Es419,
|
||||
Self::Vi,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -165,14 +177,6 @@ pub const PLANNED_QA_LOCALES: &[LocaleSpec] = &[
|
||||
fallback: "en",
|
||||
coverage: LocaleCoverage::PlannedQa,
|
||||
},
|
||||
LocaleSpec {
|
||||
tag: "vi",
|
||||
display_name: "Vietnamese",
|
||||
script: "Latin",
|
||||
direction: TextDirection::Ltr,
|
||||
fallback: "en",
|
||||
coverage: LocaleCoverage::PlannedQa,
|
||||
},
|
||||
LocaleSpec {
|
||||
tag: "sw",
|
||||
display_name: "Swahili",
|
||||
@@ -455,6 +459,32 @@ pub enum MessageId {
|
||||
OnboardTipsLine4,
|
||||
OnboardTipsFooterEnter,
|
||||
OnboardTipsFooterAction,
|
||||
// Context menu.
|
||||
CtxMenuTitle,
|
||||
CtxMenuCopySelection,
|
||||
CtxMenuCopySelectionDesc,
|
||||
CtxMenuOpenSelection,
|
||||
CtxMenuOpenSelectionDesc,
|
||||
CtxMenuClearSelection,
|
||||
CtxMenuOpenDetails,
|
||||
CtxMenuCopyMessage,
|
||||
CtxMenuCopyMessageDesc,
|
||||
CtxMenuOpenInEditor,
|
||||
CtxMenuOpenInEditorDesc,
|
||||
CtxMenuShowCell,
|
||||
CtxMenuShowCellDesc,
|
||||
CtxMenuHideCell,
|
||||
CtxMenuHideCellDesc,
|
||||
CtxMenuShowHidden,
|
||||
CtxMenuShowHiddenDesc,
|
||||
CtxMenuPaste,
|
||||
CtxMenuPasteDesc,
|
||||
CtxMenuCmdPalette,
|
||||
CtxMenuCmdPaletteDesc,
|
||||
CtxMenuContextInspector,
|
||||
CtxMenuContextInspectorDesc,
|
||||
CtxMenuHelp,
|
||||
CtxMenuHelpDesc,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -690,6 +720,32 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::OnboardTipsLine4,
|
||||
MessageId::OnboardTipsFooterEnter,
|
||||
MessageId::OnboardTipsFooterAction,
|
||||
// Context menu.
|
||||
MessageId::CtxMenuTitle,
|
||||
MessageId::CtxMenuCopySelection,
|
||||
MessageId::CtxMenuCopySelectionDesc,
|
||||
MessageId::CtxMenuOpenSelection,
|
||||
MessageId::CtxMenuOpenSelectionDesc,
|
||||
MessageId::CtxMenuClearSelection,
|
||||
MessageId::CtxMenuOpenDetails,
|
||||
MessageId::CtxMenuCopyMessage,
|
||||
MessageId::CtxMenuCopyMessageDesc,
|
||||
MessageId::CtxMenuOpenInEditor,
|
||||
MessageId::CtxMenuOpenInEditorDesc,
|
||||
MessageId::CtxMenuShowCell,
|
||||
MessageId::CtxMenuShowCellDesc,
|
||||
MessageId::CtxMenuHideCell,
|
||||
MessageId::CtxMenuHideCellDesc,
|
||||
MessageId::CtxMenuShowHidden,
|
||||
MessageId::CtxMenuShowHiddenDesc,
|
||||
MessageId::CtxMenuPaste,
|
||||
MessageId::CtxMenuPasteDesc,
|
||||
MessageId::CtxMenuCmdPalette,
|
||||
MessageId::CtxMenuCmdPaletteDesc,
|
||||
MessageId::CtxMenuContextInspector,
|
||||
MessageId::CtxMenuContextInspectorDesc,
|
||||
MessageId::CtxMenuHelp,
|
||||
MessageId::CtxMenuHelpDesc,
|
||||
];
|
||||
|
||||
pub fn tr(locale: Locale, id: MessageId) -> &'static str {
|
||||
@@ -704,6 +760,7 @@ pub fn thinking_translation_placeholder(locale: Locale) -> &'static str {
|
||||
Locale::ZhHant => "正在思考,完成後翻譯為繁體中文...",
|
||||
Locale::PtBr => "Pensando; traduzindo ao concluir...",
|
||||
Locale::Es419 => "Pensando; traduciendo al finalizar...",
|
||||
Locale::Vi => "Đang suy nghĩ; sẽ dịch sau khi hoàn thành...",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +772,7 @@ pub fn thinking_translation_in_progress(locale: Locale) -> &'static str {
|
||||
Locale::ZhHant => "正在翻譯思考內容...",
|
||||
Locale::PtBr => "Traduzindo o conteúdo de raciocínio...",
|
||||
Locale::Es419 => "Traduciendo el contenido de razonamiento...",
|
||||
Locale::Vi => "Đang dịch nội dung suy nghĩ...",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +784,7 @@ pub fn thinking_translation_complete(locale: Locale) -> &'static str {
|
||||
Locale::ZhHant => "思考內容翻譯完成",
|
||||
Locale::PtBr => "Tradução do raciocínio concluída",
|
||||
Locale::Es419 => "Traducción del razonamiento completada",
|
||||
Locale::Vi => "Đã dịch xong nội dung suy nghĩ",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,6 +796,7 @@ pub fn thinking_translation_failed(locale: Locale) -> &'static str {
|
||||
Locale::ZhHant => "思考內容翻譯失敗",
|
||||
Locale::PtBr => "Falha ao traduzir o raciocínio",
|
||||
Locale::Es419 => "Falló la traducción del razonamiento",
|
||||
Locale::Vi => "Dịch nội dung suy nghĩ thất bại",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +808,7 @@ pub fn hidden_translation_failed(locale: Locale) -> &'static str {
|
||||
Locale::ZhHant => "翻譯失敗,原文已隱藏。",
|
||||
Locale::PtBr => "A tradução falhou; o texto original está oculto.",
|
||||
Locale::Es419 => "La traducción falló; el texto original está oculto.",
|
||||
Locale::Vi => "Dịch thất bại; văn bản gốc đã bị ẩn.",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,6 +918,9 @@ fn parse_locale(value: &str) -> Option<Locale> {
|
||||
if value.starts_with("es") {
|
||||
return Some(Locale::Es419);
|
||||
}
|
||||
if value.starts_with("vi") {
|
||||
return Some(Locale::Vi);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -959,7 +1023,7 @@ fn english(id: MessageId) -> &'static str {
|
||||
MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes",
|
||||
MessageId::CmdThemeDescription => "Switch theme or open the theme picker",
|
||||
MessageId::CmdProviderDescription => {
|
||||
"Switch or view the active LLM backend (codewhale | nvidia-nim | ollama)"
|
||||
"Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)"
|
||||
}
|
||||
MessageId::CmdQueueDescription => "View or edit queued messages",
|
||||
MessageId::CmdRecallDescription => "Search prior cycle archives (BM25 over message text)",
|
||||
@@ -1172,7 +1236,7 @@ fn english(id: MessageId) -> &'static str {
|
||||
"Pick the UI language. You can change it any time with `/settings set locale <tag>`."
|
||||
}
|
||||
MessageId::OnboardLanguageFooter => {
|
||||
"Press 1-6 to choose, or Enter to keep the current setting"
|
||||
"Press 1-7 to choose, or Enter to keep the current setting"
|
||||
}
|
||||
// Onboarding — API key entry.
|
||||
MessageId::OnboardApiKeyTitle => "Connect your DeepSeek API key",
|
||||
@@ -1218,6 +1282,32 @@ fn english(id: MessageId) -> &'static str {
|
||||
}
|
||||
MessageId::OnboardTipsFooterEnter => "Press Enter",
|
||||
MessageId::OnboardTipsFooterAction => " to open the workspace",
|
||||
// Context menu.
|
||||
MessageId::CtxMenuTitle => " Right click ",
|
||||
MessageId::CtxMenuCopySelection => "Copy selection",
|
||||
MessageId::CtxMenuCopySelectionDesc => "write selected transcript text",
|
||||
MessageId::CtxMenuOpenSelection => "Open selection",
|
||||
MessageId::CtxMenuOpenSelectionDesc => "show selected text in pager",
|
||||
MessageId::CtxMenuClearSelection => "Clear selection",
|
||||
MessageId::CtxMenuOpenDetails => "Open details",
|
||||
MessageId::CtxMenuCopyMessage => "Copy message",
|
||||
MessageId::CtxMenuCopyMessageDesc => "write clicked transcript cell",
|
||||
MessageId::CtxMenuOpenInEditor => "Open in editor",
|
||||
MessageId::CtxMenuOpenInEditorDesc => "open file:line in $EDITOR",
|
||||
MessageId::CtxMenuShowCell => "Show cell",
|
||||
MessageId::CtxMenuShowCellDesc => "unhide this transcript cell",
|
||||
MessageId::CtxMenuHideCell => "Hide cell",
|
||||
MessageId::CtxMenuHideCellDesc => "collapse this transcript cell",
|
||||
MessageId::CtxMenuShowHidden => "Show hidden",
|
||||
MessageId::CtxMenuShowHiddenDesc => "unhide all collapsed cells",
|
||||
MessageId::CtxMenuPaste => "Paste",
|
||||
MessageId::CtxMenuPasteDesc => "insert clipboard into composer",
|
||||
MessageId::CtxMenuCmdPalette => "Command palette",
|
||||
MessageId::CtxMenuCmdPaletteDesc => "commands, skills, and tools",
|
||||
MessageId::CtxMenuContextInspector => "Context inspector",
|
||||
MessageId::CtxMenuContextInspectorDesc => "active context and cache hints",
|
||||
MessageId::CtxMenuHelp => "Help",
|
||||
MessageId::CtxMenuHelpDesc => "keybindings and commands",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,9 +1319,428 @@ fn translation(locale: Locale, id: MessageId) -> Option<&'static str> {
|
||||
Locale::ZhHant => traditional_chinese(id),
|
||||
Locale::PtBr => portuguese_brazil(id),
|
||||
Locale::Es419 => spanish_latin_america(id),
|
||||
Locale::Vi => vietnamese(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn vietnamese(id: MessageId) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
MessageId::ComposerPlaceholder => "Nhập nhiệm vụ hoặc sử dụng /.",
|
||||
MessageId::HistorySearchPlaceholder => "Tìm kiếm lịch sử câu lệnh...",
|
||||
MessageId::HistorySearchTitle => "Tìm kiếm lịch sử",
|
||||
MessageId::HistoryHintMove => "Lên/Xuống để di chuyển",
|
||||
MessageId::HistoryHintAccept => "Enter để chấp nhận",
|
||||
MessageId::HistoryHintRestore => "Esc để khôi phục",
|
||||
MessageId::HistoryNoMatches => " Không tìm thấy kết quả",
|
||||
MessageId::ConfigTitle => "Cấu hình phiên làm việc",
|
||||
MessageId::ConfigModalTitle => " Cấu hình ",
|
||||
MessageId::ConfigSearchPlaceholder => "Nhập để lọc kết quả",
|
||||
MessageId::ConfigNoSettings => " Không có cài đặt nào khả dụng.",
|
||||
MessageId::ConfigNoMatchesPrefix => " Không có cài đặt nào khớp với ",
|
||||
MessageId::ConfigFilteredSettings => " Cài đặt đã lọc",
|
||||
MessageId::ConfigShowing => " Đang hiển thị",
|
||||
MessageId::ConfigFooterDefault => " gõ=lọc, Lên/Xuống=chọn, Enter/e=sửa, Esc/q=đóng ",
|
||||
MessageId::ConfigFooterScrollable => {
|
||||
" gõ=lọc, Lên/Xuống=chọn, Enter/e=sửa, PgUp/PgDn=cuộn, Esc/q=đóng "
|
||||
}
|
||||
MessageId::ConfigFooterFiltered => {
|
||||
" gõ=lọc, Backspace=xóa, Ctrl+U/Esc=xóa sạch, Enter=sửa "
|
||||
}
|
||||
MessageId::HelpTitle => "Trợ giúp",
|
||||
MessageId::HelpFilterPlaceholder => "Nhập để lọc",
|
||||
MessageId::HelpFilterPrefix => "Bộ lọc: ",
|
||||
MessageId::HelpNoMatches => " Không tìm thấy kết quả.",
|
||||
MessageId::HelpSlashCommands => "Các lệnh bắt đầu bằng dấu gạch chéo (/)",
|
||||
MessageId::HelpKeybindings => "Phím tắt",
|
||||
MessageId::HelpFooterTypeFilter => " nhập để lọc ",
|
||||
MessageId::HelpFooterMove => " Lên/Xuống để di chuyển ",
|
||||
MessageId::HelpFooterJump => " PgUp/PgDn để nhảy trang ",
|
||||
MessageId::HelpFooterClose => " Esc để đóng ",
|
||||
MessageId::CmdAnchorDescription => {
|
||||
"Ghim một dữ kiện không bị ảnh hưởng khi nén (tự động đưa vào ngữ cảnh)"
|
||||
}
|
||||
MessageId::CmdAttachDescription => {
|
||||
"Đính kèm hình ảnh/video; sử dụng @path cho tệp văn bản hoặc thư mục"
|
||||
}
|
||||
MessageId::CmdCacheDescription => {
|
||||
"Hiển thị thống kê hit/miss của bộ nhớ đệm tiền tố DeepSeek trong N lượt gần nhất"
|
||||
}
|
||||
MessageId::CmdChangeDescription => "Hiển thị thông tin nhật ký thay đổi mới nhất",
|
||||
MessageId::CmdChangeHeader => "Nhật Ký Thay Đổi Mới Nhất",
|
||||
MessageId::CmdChangeTranslationQueued => {
|
||||
"Ghi chú phát hành bằng tiếng Anh hiển thị bên dưới. Bản dịch sẽ được yêu cầu tiếp theo; nếu nhà cung cấp không khả dụng, văn bản tiếng Anh này sẽ được dùng làm dự phòng."
|
||||
}
|
||||
MessageId::CmdChangeTranslationUnavailable => {
|
||||
"Ghi chú phát hành bằng tiếng Anh hiển thị bên dưới. Bản dịch không khả dụng vì phiên hiện tại không có mã khóa API hoặc đang ngoại tuyến."
|
||||
}
|
||||
MessageId::CmdChangePreviousVersion => {
|
||||
"Phiên bản trước: {version} — chạy `/change {version}` để xem"
|
||||
}
|
||||
MessageId::CmdBalanceDescription => {
|
||||
"Kiểm tra số dư tài khoản của nhà cung cấp dịch vụ đang hoạt động"
|
||||
}
|
||||
MessageId::CmdClearDescription => "Xóa lịch sử trò chuyện",
|
||||
MessageId::CmdCompactDescription => {
|
||||
"Kích hoạt nén ngữ cảnh để giải phóng không gian (cũ; v0.6.6 ưu tiên khởi động lại chu kỳ)"
|
||||
}
|
||||
MessageId::CmdConfigDescription => "Mở trình chỉnh sửa cấu hình tương tác",
|
||||
MessageId::CmdContextDescription => "Mở trình kiểm tra ngữ cảnh phiên thu gọn",
|
||||
MessageId::CmdCostDescription => "Hiển thị chi tiết chi phí của phiên làm việc",
|
||||
MessageId::CmdCycleDescription => "Hiển thị báo cáo chuyển tiếp cho một chu kỳ cụ thể",
|
||||
MessageId::CmdCyclesDescription => {
|
||||
"Liệt kê các lần bàn giao chu kỳ checkpoint-restart trong phiên này"
|
||||
}
|
||||
MessageId::CmdDiffDescription => "Hiển thị các thay đổi của tệp kể từ khi bắt đầu phiên",
|
||||
MessageId::CmdEditDescription => "Chỉnh sửa và gửi lại tin nhắn gần nhất",
|
||||
MessageId::CmdExitDescription => "Thoát ứng dụng",
|
||||
MessageId::CmdExportDescription => "Xuất cuộc trò chuyện sang định dạng Markdown",
|
||||
MessageId::CmdFeedbackDescription => "Tạo một URL để gửi phản hồi trên GitHub",
|
||||
MessageId::CmdHelpDescription => "Hiển thị thông tin trợ giúp",
|
||||
MessageId::CmdHomeDescription => {
|
||||
"Hiển thị bảng điều khiển trang chủ với số liệu thống kê và hành động nhanh"
|
||||
}
|
||||
MessageId::CmdHooksDescription => "Liệt kê các lifecycle hook đã cấu hình (chỉ đọc)",
|
||||
MessageId::CmdAgentDescription => "Mở một phiên sub-agent nền: /agent [0-3] <nhiệm_vụ>",
|
||||
MessageId::CmdGoalDescription => "Đặt mục tiêu cho phiên với giới hạn token tùy chọn",
|
||||
MessageId::CmdInitDescription => "Tạo tệp AGENTS.md cho dự án",
|
||||
MessageId::CmdLspDescription => "Bật hoặc tắt tính năng chẩn đoán LSP",
|
||||
MessageId::CmdShareDescription => {
|
||||
"Xuất phiên hiện tại thành một liên kết web có thể chia sẻ"
|
||||
}
|
||||
MessageId::CmdJobsDescription => "Kiểm tra và kiểm soát các lệnh chạy ngầm",
|
||||
MessageId::CmdLinksDescription => {
|
||||
"Hiển thị các liên kết đến bảng điều khiển và tài liệu của DeepSeek"
|
||||
}
|
||||
MessageId::CmdLoadDescription => "Tải phiên làm việc từ tệp",
|
||||
MessageId::CmdLogoutDescription => "Xóa khóa API và quay lại bước thiết lập",
|
||||
MessageId::CmdMcpDescription => "Mở hoặc quản lý các máy chủ MCP",
|
||||
MessageId::CmdMemoryDescription => "Kiểm tra hoặc quản lý tệp bộ nhớ người dùng liên tục",
|
||||
MessageId::CmdModeDescription => {
|
||||
"Chuyển đổi chế độ hoặc mở bảng chọn: /mode [agent|plan|yolo|1|2|3]"
|
||||
}
|
||||
MessageId::CmdModelDescription => "Chuyển đổi hoặc xem mô hình AI hiện tại",
|
||||
MessageId::CmdModelsDescription => "Liệt kê các mô hình khả dụng từ API",
|
||||
MessageId::CmdNetworkDescription => "Quản lý các quy tắc cho phép và từ chối mạng",
|
||||
MessageId::CmdNoteDescription => {
|
||||
"Thêm, liệt kê, sửa hoặc xóa ghi chú trong không gian làm việc"
|
||||
}
|
||||
MessageId::CmdThemeDescription => "Chuyển đổi giao diện hoặc mở bảng chọn giao diện",
|
||||
MessageId::CmdProviderDescription => {
|
||||
"Chuyển đổi hoặc xem backend LLM đang hoạt động (deepseek | nvidia-nim | ollama)"
|
||||
}
|
||||
MessageId::CmdQueueDescription => "Xem hoặc chỉnh sửa các tin nhắn đang chờ xử lý",
|
||||
MessageId::CmdRecallDescription => {
|
||||
"Tìm kiếm kho lưu trữ chu kỳ trước (BM25 trên văn bản tin nhắn)"
|
||||
}
|
||||
MessageId::CmdRelayDescription => "Tạo một phiên tiếp sức cho một luồng mới",
|
||||
MessageId::CmdRenameDescription => "Đổi tên phiên làm việc hiện tại",
|
||||
MessageId::CmdRestoreDescription => {
|
||||
"Khôi phục không gian làm việc về bản chụp trước/sau lượt. Nếu không có đối số, hiển thị các bản chụp gần đây."
|
||||
}
|
||||
MessageId::CmdRetryDescription => "Thử lại yêu cầu gần nhất",
|
||||
MessageId::CmdReviewDescription => {
|
||||
"Chạy một quy trình xem xét mã nguồn có cấu trúc trên tệp, diff hoặc PR"
|
||||
}
|
||||
MessageId::CmdRlmDescription => {
|
||||
"Mở một ngữ cảnh RLM liên tục: /rlm [0-3] <tệp_hoặc_văn_bản>"
|
||||
}
|
||||
MessageId::CmdSaveDescription => "Lưu phiên làm việc vào tệp",
|
||||
MessageId::CmdForkDescription => {
|
||||
"Rẽ nhánh (fork) cuộc hội thoại hiện tại thành một phiên song song"
|
||||
}
|
||||
MessageId::CmdNewDescription => "Bắt đầu một phiên lưu mới",
|
||||
MessageId::CmdSessionsDescription => "Mở bảng chọn lịch sử phiên làm việc",
|
||||
MessageId::CmdSettingsDescription => "Hiển thị các cài đặt liên tục",
|
||||
MessageId::CmdSkillDescription => {
|
||||
"Kích hoạt một kỹ năng, hoặc cài đặt/cập nhật/gỡ bỏ/tin cậy một kỹ năng cộng đồng"
|
||||
}
|
||||
MessageId::CmdSkillsDescription => {
|
||||
"Liệt kê các kỹ năng cục bộ (lọc bằng `/skills <tiền_tố>`; --remote để duyệt kho lưu trữ được kiểm duyệt)"
|
||||
}
|
||||
MessageId::CmdStashDescription => {
|
||||
"Tạm cất hoặc khôi phục bản nháp (Ctrl+S để cất, /stash list/pop để xem/lấy ra)"
|
||||
}
|
||||
MessageId::CmdStatusDescription => "Hiển thị trạng thái thời gian chạy của phiên",
|
||||
MessageId::CmdStatuslineDescription => {
|
||||
"Cấu hình các mục hiển thị ở thanh trạng thái dưới cùng"
|
||||
}
|
||||
MessageId::CmdSubagentsDescription => "Liệt kê trạng thái của các sub-agent",
|
||||
MessageId::CmdSwarmDescription => {
|
||||
"Khởi chạy chế độ đa agent (sequential | mixture | distill | deliberate)"
|
||||
}
|
||||
MessageId::CmdSystemDescription => "Hiển thị prompt hệ thống hiện tại",
|
||||
MessageId::CmdTaskDescription => "Quản lý các nhiệm vụ chạy ngầm",
|
||||
MessageId::CmdTokensDescription => "Hiển thị lượng token đã sử dụng cho phiên",
|
||||
MessageId::CmdTranslateDescription => {
|
||||
"Bật/Tắt chế độ dịch đầu ra sang ngôn ngữ hệ thống hiện tại"
|
||||
}
|
||||
MessageId::CmdTranslateOff => {
|
||||
"Đã tắt chế độ dịch đầu ra (hiển thị câu trả lời gốc của mô hình)"
|
||||
}
|
||||
MessageId::CmdTranslateOn => {
|
||||
"Đã bật chế độ dịch đầu ra: câu trả lời của mô hình sẽ được hiển thị bằng tiếng Việt"
|
||||
}
|
||||
MessageId::TranslationInProgress => "Đang dịch câu trả lời của trợ lý...",
|
||||
MessageId::TranslationComplete => "Đã dịch xong",
|
||||
MessageId::TranslationFailed => "Dịch thất bại",
|
||||
MessageId::CmdTrustDescription => {
|
||||
"Quản lý quyền tin cậy không gian làm việc và danh sách trắng theo đường dẫn (`/trust add <path>`, `/trust list`, `/trust on|off`)"
|
||||
}
|
||||
MessageId::CmdWorkspaceDescription => {
|
||||
"Hiển thị hoặc chuyển đổi không gian làm việc hiện tại"
|
||||
}
|
||||
MessageId::CmdUndoDescription => "Xóa cặp tin nhắn gần nhất",
|
||||
MessageId::CmdVerboseDescription => {
|
||||
"Bật/Tắt chế độ hiển thị đầy đủ quá trình suy nghĩ trực tiếp"
|
||||
}
|
||||
MessageId::CmdCacheAdvice => {
|
||||
"Tỷ lệ hit/miss trên ~70% sau lượt thứ ba cho thấy tiền tố bộ nhớ đệm ổn định; \nthấp hơn mức đó trong các phiên dài cho thấy có sự biến động tiền tố cần kiểm tra (#263)."
|
||||
}
|
||||
MessageId::CmdCacheFootnote => {
|
||||
"* miss được suy ra từ đầu vào − hit khi nhà cung cấp không báo cáo rõ ràng.\n"
|
||||
}
|
||||
MessageId::CmdCacheHeader => {
|
||||
"Thông tin cache — {count} lượt gần nhất trong tổng số {total} lượt (mô hình: {model})\n"
|
||||
}
|
||||
MessageId::CmdCacheNoData => {
|
||||
"Lịch sử bộ nhớ đệm: chưa có lượt nào được ghi nhận.\n\n\
|
||||
DeepSeek cung cấp `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` \
|
||||
trên mỗi lượt API mà mô hình hỗ trợ (dòng V4). Hãy chạy một lượt \
|
||||
và thử lại lệnh /cache."
|
||||
}
|
||||
MessageId::CmdCacheTotals => {
|
||||
"Σ vào: {sum_in} Σ hit: {sum_hit} Σ miss: {sum_miss} tỷ lệ hit trung bình: {avg}\n"
|
||||
}
|
||||
MessageId::CmdCostReport => {
|
||||
"Chi Phí Phiên Làm Việc:\n\
|
||||
─────────────────────────────\n\
|
||||
Tổng chi tiêu ước tính: {cost}\n\n\
|
||||
Các ước tính chi phí mang tính xấp xỉ và sử dụng dữ liệu viễn trắc từ nhà cung cấp nếu có.\n\n\
|
||||
Bảng Giá API DeepSeek:\n\
|
||||
─────────────────────────────\n\
|
||||
Thông tin chi tiết về giá chưa được cấu hình trong CLI này."
|
||||
}
|
||||
MessageId::CmdTokensCacheBoth => "{hit} hit / {miss} miss",
|
||||
MessageId::CmdTokensCacheHitOnly => "{hit} hit / không báo cáo miss",
|
||||
MessageId::CmdTokensCacheMissOnly => "không báo cáo hit / {miss} miss",
|
||||
MessageId::CmdTokensContextUnknownWindow => "~{estimated} / không rõ cửa sổ ngữ cảnh",
|
||||
MessageId::CmdTokensContextWithWindow => "~{used} / {window} ({percent}%)",
|
||||
MessageId::FooterAgentSingular => "1 tác nhân",
|
||||
MessageId::FooterAgentsPlural => "{count} tác nhân",
|
||||
MessageId::FooterPressCtrlCAgain => "Nhấn Ctrl+C một lần nữa để thoát",
|
||||
MessageId::FooterWorking => "đang xử lý",
|
||||
MessageId::HelpSectionActions => "Hành động",
|
||||
MessageId::HelpSectionClipboard => "Bộ nhớ tạm",
|
||||
MessageId::HelpSectionEditing => "Chỉnh sửa đầu vào",
|
||||
MessageId::HelpSectionHelp => "Trợ giúp",
|
||||
MessageId::HelpSectionModes => "Chế độ",
|
||||
MessageId::HelpSectionNavigation => "Điều hướng",
|
||||
MessageId::HelpSectionSessions => "Phiên",
|
||||
MessageId::CmdTokensNotReported => "không được báo cáo",
|
||||
MessageId::CmdTokensReport => {
|
||||
"Lượng Token Sử Dụng:\n\
|
||||
─────────────────────────────\n\
|
||||
Ngữ cảnh hoạt động: {active}\n\
|
||||
Đầu vào API gần nhất: {input} (viễn trắc theo lượt; có thể đếm lặp lại tiền tố qua các vòng công cụ)\n\
|
||||
Đầu ra API gần nhất: {output}\n\
|
||||
Hit/miss bộ nhớ đệm: {cache} (chỉ dành cho viễn trắc/chi phí)\n\
|
||||
Token tích lũy: {total} (dữ liệu viễn trắc sử dụng của phiên)\n\
|
||||
Chi phí phiên xấp xỉ: {cost}\n\
|
||||
Tin nhắn API: {api_messages}\n\
|
||||
Tin nhắn trò chuyện: {chat_messages}\n\
|
||||
Mô hình: {model}"
|
||||
}
|
||||
MessageId::KbScrollTranscript => {
|
||||
"Cuộn bản ghi trò chuyện, điều hướng lịch sử nhập hoặc chọn tệp đính kèm"
|
||||
}
|
||||
MessageId::KbNavigateHistory => "Điều hướng lịch sử nhập",
|
||||
MessageId::KbBrowseHistory => "Duyệt lịch sử cuộc trò chuyện",
|
||||
MessageId::KbScrollTranscriptAlt => "Cuộn bản ghi trò chuyện",
|
||||
MessageId::KbScrollPage => "Cuộn bản ghi trò chuyện theo trang",
|
||||
MessageId::KbJumpTopBottom => "Nhảy lên đầu / xuống cuối bản ghi trò chuyện",
|
||||
MessageId::KbJumpTopBottomEmpty => "Nhảy lên đầu / xuống cuối (khi khung nhập trống)",
|
||||
MessageId::KbJumpToolBlocks => "Nhảy giữa các khối đầu ra của công cụ",
|
||||
MessageId::KbMoveCursor => "Di chuyển con trỏ trong khung soạn thảo",
|
||||
MessageId::KbJumpLineStartEnd => "Nhảy về đầu / cuối dòng",
|
||||
MessageId::KbDeleteChar => "Xóa ký tự trước / sau con trỏ, hoặc xóa tệp đính kèm đã chọn",
|
||||
MessageId::KbClearDraft => "Xóa bản nháp hiện tại",
|
||||
MessageId::KbStashDraft => "Tạm cất bản nháp hiện tại (dùng `/stash pop` để khôi phục)",
|
||||
MessageId::KbSearchHistory => "Tìm kiếm lịch sử câu lệnh và khôi phục các bản nháp cục bộ",
|
||||
MessageId::KbInsertNewline => "Chèn một dòng mới trong khung soạn thảo",
|
||||
MessageId::KbSendDraft => "Gửi bản nháp hiện tại",
|
||||
MessageId::KbCloseMenu => "Đóng menu, hủy yêu cầu, hủy bản nháp hoặc xóa sạch đầu vào",
|
||||
MessageId::KbCancelOrExit => "Hủy yêu cầu, hoặc thoát khi rảnh",
|
||||
MessageId::KbShellControls => "Mở các điều khiển shell cho một lệnh đang chạy ở tiền cảnh",
|
||||
MessageId::KbExitEmpty => "Thoát khi khung nhập trống",
|
||||
MessageId::KbCommandPalette => "Mở bảng lệnh (command palette)",
|
||||
MessageId::KbFuzzyFilePicker => {
|
||||
"Mở trình tìm file nhanh (fuzzy) (chèn @path khi nhấn Enter)"
|
||||
}
|
||||
MessageId::KbCompactInspector => "Mở trình kiểm tra ngữ cảnh phiên thu gọn",
|
||||
MessageId::KbLastMessagePager => {
|
||||
"Mở trang xem cho tin nhắn cuối cùng (khi khung nhập trống)"
|
||||
}
|
||||
MessageId::KbSelectedDetails => {
|
||||
"Mở chi tiết cho công cụ hoặc tin nhắn được chọn (khi khung nhập trống)"
|
||||
}
|
||||
MessageId::KbToolDetailsPager => "Mở trang xem chi tiết công cụ",
|
||||
MessageId::KbThinkingPager => "Mở Chi Tiết Hoạt Động (Activity Detail)",
|
||||
MessageId::KbLiveTranscript => "Mở lớp phủ bản ghi trực tiếp (tự động cuộn theo đuôi)",
|
||||
MessageId::KbBacktrackMessage => {
|
||||
"Quay lại tin nhắn trước đó của người dùng (nhấn Trái/Phải để chuyển bước, Enter để lùi lại)"
|
||||
}
|
||||
MessageId::KbCompleteCycleModes => {
|
||||
"Hoàn thành /command, xếp hàng theo dõi lượt đang chạy, chuyển đổi chế độ; Shift+Tab để chuyển đổi mức độ suy luận"
|
||||
}
|
||||
MessageId::KbJumpPlanAgentYolo => "Nhảy trực tiếp sang chế độ Plan / Agent / YOLO",
|
||||
MessageId::KbAltJumpPlanAgentYolo => {
|
||||
"Phím tắt thay thế để nhảy sang chế độ Plan / Agent / YOLO"
|
||||
}
|
||||
MessageId::KbFocusSidebar => {
|
||||
"Focus vào thanh bên Work / Tasks / Agents / Context / Auto; Ctrl+Alt+0 để ẩn"
|
||||
}
|
||||
MessageId::KbTogglePlanAgent => "Chuyển đổi giữa chế độ Plan và Agent",
|
||||
MessageId::KbSessionPicker => "Mở bảng chọn phiên làm việc",
|
||||
MessageId::KbPasteAttach => "Dán văn bản hoặc đính kèm hình ảnh từ bộ nhớ tạm",
|
||||
MessageId::KbCopySelection => "Sao chép vùng chọn hiện tại (Cmd+C trên macOS)",
|
||||
MessageId::KbContextMenu => {
|
||||
"Mở các hành động ngữ cảnh cho dán, vùng chọn, chi tiết tin nhắn, ngữ cảnh và trợ giúp"
|
||||
}
|
||||
MessageId::KbAttachPath => "Thêm một tệp văn bản cục bộ hoặc thư mục vào ngữ cảnh",
|
||||
MessageId::KbHelpOverlay => "Mở lớp phủ trợ giúp này (khi khung nhập trống)",
|
||||
MessageId::KbToggleHelp => "Bật/Tắt lớp phủ trợ giúp",
|
||||
MessageId::KbToggleHelpSlash => "Bật/Tắt lớp phủ trợ giúp",
|
||||
MessageId::HelpUsageLabel => "Sử dụng:",
|
||||
MessageId::HelpAliasesLabel => "Bí danh:",
|
||||
MessageId::SettingsTitle => "Cài đặt:",
|
||||
MessageId::SettingsConfigFile => "Tệp cấu hình:",
|
||||
MessageId::ClearConversation => "Đã xóa cuộc trò chuyện",
|
||||
MessageId::ClearConversationBusy => {
|
||||
"Đã xóa cuộc trò chuyện (trạng thái plan đang bận; chạy lại /clear nếu cần)"
|
||||
}
|
||||
MessageId::ModelChanged => "Đã thay đổi mô hình: {old} \u{2192} {new}",
|
||||
MessageId::LinksTitle => "Liên kết DeepSeek:",
|
||||
MessageId::LinksDashboard => "Bảng điều khiển:",
|
||||
MessageId::LinksDocs => "Tài liệu:",
|
||||
MessageId::LinksTip => "Mẹo: Mã khóa API có sẵn trong bảng điều khiển console.",
|
||||
MessageId::SubagentsFetching => "Đang lấy trạng thái của các sub-agent...",
|
||||
MessageId::HelpUnknownCommand => "Lệnh không xác định: {topic}",
|
||||
MessageId::HomeDashboardTitle => "Bảng Điều Khiển Trang Chủ codewhale",
|
||||
MessageId::HomeModel => "Mô hình:",
|
||||
MessageId::HomeMode => "Chế độ:",
|
||||
MessageId::HomeWorkspace => "Không gian làm việc:",
|
||||
MessageId::HomeHistory => "Lịch sử:",
|
||||
MessageId::HomeTokens => "Token:",
|
||||
MessageId::HomeQueued => "Trong hàng đợi:",
|
||||
MessageId::HomeSubagents => "Sub-agent:",
|
||||
MessageId::HomeSkill => "Kỹ năng:",
|
||||
MessageId::HomeQuickActions => "Hành động nhanh",
|
||||
MessageId::HomeQuickLinks => "/links - Các liên kết đến Dashboard & API",
|
||||
MessageId::HomeQuickSkills => "/skills - Liệt kê các kỹ năng khả dụng",
|
||||
MessageId::HomeQuickConfig => "/config - Mở trình chỉnh sửa cấu hình tương tác",
|
||||
MessageId::HomeQuickSettings => "/settings - Hiển thị các cài đặt liên tục",
|
||||
MessageId::HomeQuickModel => "/model - Xem hoặc chuyển đổi mô hình",
|
||||
MessageId::HomeQuickSubagents => "/subagents - Liệt kê trạng thái sub-agent",
|
||||
MessageId::HomeQuickTaskList => "/task list - Hiển thị hàng đợi nhiệm vụ ngầm",
|
||||
MessageId::HomeQuickHelp => "/help - Hiển thị trợ giúp",
|
||||
MessageId::HomeModeTips => "Mẹo về Chế độ",
|
||||
MessageId::HomeAgentModeTip => "Chế độ Agent - Sử dụng công cụ cho các nhiệm vụ tự chủ",
|
||||
MessageId::HomeAgentModeReviewTip => {
|
||||
" Sử dụng Ctrl+X để xem xét ở chế độ Plan trước khi thực thi"
|
||||
}
|
||||
MessageId::HomeAgentModeYoloTip => " Nhập /mode yolo để bật toàn quyền truy cập công cụ",
|
||||
MessageId::HomeYoloModeTip => {
|
||||
"Chế độ YOLO - Toàn quyền truy cập công cụ, không cần phê duyệt"
|
||||
}
|
||||
MessageId::HomeYoloModeCaution => " Hãy cẩn thận với các thao tác mang tính phá hủy!",
|
||||
MessageId::HomePlanModeTip => "Chế độ Plan - Thiết kế trước khi triển khai",
|
||||
MessageId::HomePlanModeChecklistTip => {
|
||||
" Sử dụng /mode plan để tạo danh sách kiểm tra có cấu trúc"
|
||||
}
|
||||
MessageId::HomeGoalModeTip => {
|
||||
"Theo dõi mục tiêu - Dùng /goal <mục_tiêu> để đặt mục tiêu làm việc"
|
||||
}
|
||||
// Onboarding — language picker.
|
||||
MessageId::OnboardLanguageTitle => "Chọn ngôn ngữ của bạn",
|
||||
MessageId::OnboardLanguageBlurb => {
|
||||
"Chọn ngôn ngữ hiển thị. Bạn có thể thay đổi bất kỳ lúc nào bằng lệnh `/settings set locale <tag>`."
|
||||
}
|
||||
MessageId::OnboardLanguageFooter => {
|
||||
"Nhấn phím từ 1-7 để chọn, hoặc Enter để giữ cài đặt hiện tại"
|
||||
}
|
||||
// Onboarding — API key entry.
|
||||
MessageId::OnboardApiKeyTitle => "Kết nối khóa API DeepSeek của bạn",
|
||||
MessageId::OnboardApiKeyStep1 => {
|
||||
"Bước 1. Truy cập https://platform.deepseek.com/api_keys và tạo một khóa."
|
||||
}
|
||||
MessageId::OnboardApiKeyStep2 => "Bước 2. Dán khóa vào bên dưới và nhấn Enter.",
|
||||
MessageId::OnboardApiKeySavedHint => {
|
||||
"Được lưu vào ~/.codewhale/config.toml để có thể hoạt động từ mọi thư mục."
|
||||
}
|
||||
MessageId::OnboardApiKeyFormatHint => {
|
||||
"Dán chính xác toàn bộ khóa (không chứa khoảng trắng hoặc xuống dòng)."
|
||||
}
|
||||
MessageId::OnboardApiKeyPlaceholder => "(dán khóa vào đây)",
|
||||
MessageId::OnboardApiKeyLabel => "Khóa: ",
|
||||
MessageId::OnboardApiKeyFooter => "Nhấn Enter để lưu, Esc để quay lại.",
|
||||
// Onboarding — workspace trust.
|
||||
MessageId::OnboardTrustTitle => "Tin cậy không gian làm việc",
|
||||
MessageId::OnboardTrustQuestion => "Bạn có tin cậy nội dung của thư mục này không?",
|
||||
MessageId::OnboardTrustLocationPrefix => "Bạn đang ở ",
|
||||
MessageId::OnboardTrustRiskHint => {
|
||||
"Làm việc với các nội dung không tin cậy sẽ tăng nguy cơ bị tấn công prompt injection."
|
||||
}
|
||||
MessageId::OnboardTrustEffectHint => {
|
||||
"Tin cậy thư mục này sẽ lưu lại vào cấu hình toàn cục và bật chế độ không gian làm việc tin cậy."
|
||||
}
|
||||
MessageId::OnboardTrustFooterPrefix => "Nhấn ",
|
||||
MessageId::OnboardTrustFooterMiddle => " để tin cậy và tiếp tục, ",
|
||||
MessageId::OnboardTrustFooterSuffix => " để thoát",
|
||||
// Onboarding — final tips.
|
||||
MessageId::OnboardTipsTitle => "Bắt đầu đơn giản",
|
||||
MessageId::OnboardTipsLine1 => {
|
||||
"Viết nhiệm vụ bằng ngôn ngữ tự nhiên. Sử dụng /help hoặc Ctrl+K khi bạn muốn dùng lệnh."
|
||||
}
|
||||
MessageId::OnboardTipsLine2 => {
|
||||
"Khung nhập văn bản bên dưới hỗ trợ viết nhiều dòng: Enter để gửi, Alt+Enter hoặc Ctrl+J để xuống dòng."
|
||||
}
|
||||
MessageId::OnboardTipsLine3 => {
|
||||
"Chỉ chuyển đổi chế độ khi tính chất công việc thay đổi: Plan để lập kế hoạch trước khi làm, Agent để tự động thực hiện, YOLO khi bạn muốn tự động phê duyệt."
|
||||
}
|
||||
MessageId::OnboardTipsLine4 => {
|
||||
"Ctrl+R để khôi phục lại các phiên làm việc trước đó, và Esc để thoát khỏi bản nháp hoặc lớp phủ hiện tại."
|
||||
}
|
||||
MessageId::OnboardTipsFooterEnter => "Nhấn Enter",
|
||||
MessageId::OnboardTipsFooterAction => " để mở không gian làm việc",
|
||||
// Context menu.
|
||||
MessageId::CtxMenuTitle => " Nhấp chuột phải ",
|
||||
MessageId::CtxMenuCopySelection => "Sao chép vùng chọn",
|
||||
MessageId::CtxMenuCopySelectionDesc => "ghi văn bản transcript đã chọn",
|
||||
MessageId::CtxMenuOpenSelection => "Mở vùng chọn",
|
||||
MessageId::CtxMenuOpenSelectionDesc => "hiển thị văn bản đã chọn trong trình xem",
|
||||
MessageId::CtxMenuClearSelection => "Xóa vùng chọn",
|
||||
MessageId::CtxMenuOpenDetails => "Mở chi tiết",
|
||||
MessageId::CtxMenuCopyMessage => "Sao chép tin nhắn",
|
||||
MessageId::CtxMenuCopyMessageDesc => "ghi ô transcript đã bấm",
|
||||
MessageId::CtxMenuOpenInEditor => "Mở trong trình soạn thảo",
|
||||
MessageId::CtxMenuOpenInEditorDesc => "mở file:line trong $EDITOR",
|
||||
MessageId::CtxMenuShowCell => "Hiển thị ô",
|
||||
MessageId::CtxMenuShowCellDesc => "hiển thị lại ô transcript này",
|
||||
MessageId::CtxMenuHideCell => "Ẩn ô",
|
||||
MessageId::CtxMenuHideCellDesc => "thu gọn ô transcript này",
|
||||
MessageId::CtxMenuShowHidden => "Hiển thị mục ẩn",
|
||||
MessageId::CtxMenuShowHiddenDesc => "hiển thị lại tất cả ô đã thu gọn",
|
||||
MessageId::CtxMenuPaste => "Dán",
|
||||
MessageId::CtxMenuPasteDesc => "chèn clipboard vào khung nhập",
|
||||
MessageId::CtxMenuCmdPalette => "Bảng lệnh",
|
||||
MessageId::CtxMenuCmdPaletteDesc => "lệnh, kỹ năng và công cụ",
|
||||
MessageId::CtxMenuContextInspector => "Trình kiểm tra ngữ cảnh",
|
||||
MessageId::CtxMenuContextInspectorDesc => "ngữ cảnh đang hoạt động và gợi ý bộ nhớ đệm",
|
||||
MessageId::CtxMenuHelp => "Trợ giúp",
|
||||
MessageId::CtxMenuHelpDesc => "phím tắt và lệnh",
|
||||
})
|
||||
}
|
||||
|
||||
fn traditional_chinese(id: MessageId) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
MessageId::CmdRelayDescription => "為新執行緒建立會話接力摘要",
|
||||
@@ -1346,7 +1855,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
"テーマを切り替え(ダーク/ライト/グレースケール/システム)"
|
||||
}
|
||||
MessageId::CmdProviderDescription => {
|
||||
"現在の LLM バックエンドを切り替え・確認(codewhale | nvidia-nim | ollama)"
|
||||
"現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)"
|
||||
}
|
||||
MessageId::CmdQueueDescription => "キューされたメッセージを確認・編集",
|
||||
MessageId::CmdRecallDescription => {
|
||||
@@ -1561,7 +2070,7 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::OnboardLanguageBlurb => {
|
||||
"UI 言語を選んでください。`/settings set locale <tag>` でいつでも変更できます。"
|
||||
}
|
||||
MessageId::OnboardLanguageFooter => "1〜6 で選択、または Enter で現在の設定を維持",
|
||||
MessageId::OnboardLanguageFooter => "1〜7 で選択、または Enter で現在の設定を維持",
|
||||
// Onboarding — API key entry.
|
||||
MessageId::OnboardApiKeyTitle => "DeepSeek API キーを設定",
|
||||
MessageId::OnboardApiKeyStep1 => {
|
||||
@@ -1606,6 +2115,32 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
}
|
||||
MessageId::OnboardTipsFooterEnter => "Enter を押す",
|
||||
MessageId::OnboardTipsFooterAction => " とワークスペースが開きます",
|
||||
// Context menu.
|
||||
MessageId::CtxMenuTitle => " 右クリック ",
|
||||
MessageId::CtxMenuCopySelection => "選択をコピー",
|
||||
MessageId::CtxMenuCopySelectionDesc => "選択したトランスクリプトのテキストを書き込む",
|
||||
MessageId::CtxMenuOpenSelection => "選択を開く",
|
||||
MessageId::CtxMenuOpenSelectionDesc => "選択したテキストをページャで表示",
|
||||
MessageId::CtxMenuClearSelection => "選択を解除",
|
||||
MessageId::CtxMenuOpenDetails => "詳細を開く",
|
||||
MessageId::CtxMenuCopyMessage => "メッセージをコピー",
|
||||
MessageId::CtxMenuCopyMessageDesc => "クリックしたトランスクリプトセルを書き込む",
|
||||
MessageId::CtxMenuOpenInEditor => "エディタで開く",
|
||||
MessageId::CtxMenuOpenInEditorDesc => "$EDITOR で file:line を開く",
|
||||
MessageId::CtxMenuShowCell => "セルを表示",
|
||||
MessageId::CtxMenuShowCellDesc => "このトランスクリプトセルを再表示",
|
||||
MessageId::CtxMenuHideCell => "セルを隠す",
|
||||
MessageId::CtxMenuHideCellDesc => "このトランスクリプトセルを折りたたむ",
|
||||
MessageId::CtxMenuShowHidden => "非表示を表示",
|
||||
MessageId::CtxMenuShowHiddenDesc => "すべての折りたたまれたセルを再表示",
|
||||
MessageId::CtxMenuPaste => "貼り付け",
|
||||
MessageId::CtxMenuPasteDesc => "クリップボードをコンポーザに挿入",
|
||||
MessageId::CtxMenuCmdPalette => "コマンドパレット",
|
||||
MessageId::CtxMenuCmdPaletteDesc => "コマンド、スキル、ツール",
|
||||
MessageId::CtxMenuContextInspector => "コンテキストインスペクタ",
|
||||
MessageId::CtxMenuContextInspectorDesc => "アクティブなコンテキストとキャッシュヒント",
|
||||
MessageId::CtxMenuHelp => "ヘルプ",
|
||||
MessageId::CtxMenuHelpDesc => "キー操作とコマンド",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1692,7 +2227,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdNoteDescription => "添加、列出、编辑或删除工作区笔记",
|
||||
MessageId::CmdThemeDescription => "切换主题:深色、浅色、灰度或系统",
|
||||
MessageId::CmdProviderDescription => {
|
||||
"切换或查看当前 LLM 后端(codewhale | nvidia-nim | ollama)"
|
||||
"切换或查看当前 LLM 后端(deepseek | nvidia-nim | ollama)"
|
||||
}
|
||||
MessageId::CmdQueueDescription => "查看或编辑已排队的消息",
|
||||
MessageId::CmdRecallDescription => "搜索此前的循环归档(基于消息文本的 BM25 检索)",
|
||||
@@ -1879,7 +2414,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::OnboardLanguageBlurb => {
|
||||
"选择界面语言。可随时使用 `/settings set locale <tag>` 修改。"
|
||||
}
|
||||
MessageId::OnboardLanguageFooter => "按 1-6 选择,或按 Enter 保留当前设置",
|
||||
MessageId::OnboardLanguageFooter => "按 1-7 选择,或按 Enter 保留当前设置",
|
||||
// Onboarding — API key entry.
|
||||
MessageId::OnboardApiKeyTitle => "连接你的 DeepSeek API 密钥",
|
||||
MessageId::OnboardApiKeyStep1 => {
|
||||
@@ -1914,6 +2449,32 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::OnboardTipsLine4 => "Ctrl+R 恢复历史会话,Esc 退出当前输入或弹层。",
|
||||
MessageId::OnboardTipsFooterEnter => "按 Enter",
|
||||
MessageId::OnboardTipsFooterAction => " 进入工作区",
|
||||
// Context menu.
|
||||
MessageId::CtxMenuTitle => " 右键菜单 ",
|
||||
MessageId::CtxMenuCopySelection => "复制所选",
|
||||
MessageId::CtxMenuCopySelectionDesc => "将选中的记录区域文本写入剪贴板",
|
||||
MessageId::CtxMenuOpenSelection => "打开所选",
|
||||
MessageId::CtxMenuOpenSelectionDesc => "在翻阅器中查看选中文本",
|
||||
MessageId::CtxMenuClearSelection => "清除选择",
|
||||
MessageId::CtxMenuOpenDetails => "打开详情",
|
||||
MessageId::CtxMenuCopyMessage => "复制消息",
|
||||
MessageId::CtxMenuCopyMessageDesc => "将点击的记录条目写入剪贴板",
|
||||
MessageId::CtxMenuOpenInEditor => "在编辑器中打开",
|
||||
MessageId::CtxMenuOpenInEditorDesc => "在 $EDITOR 中打开 file:line",
|
||||
MessageId::CtxMenuShowCell => "显示条目",
|
||||
MessageId::CtxMenuShowCellDesc => "取消隐藏此记录条目",
|
||||
MessageId::CtxMenuHideCell => "隐藏条目",
|
||||
MessageId::CtxMenuHideCellDesc => "折叠此记录条目",
|
||||
MessageId::CtxMenuShowHidden => "显示已隐藏",
|
||||
MessageId::CtxMenuShowHiddenDesc => "取消隐藏所有已折叠条目",
|
||||
MessageId::CtxMenuPaste => "粘贴",
|
||||
MessageId::CtxMenuPasteDesc => "将剪贴板插入输入框",
|
||||
MessageId::CtxMenuCmdPalette => "命令面板",
|
||||
MessageId::CtxMenuCmdPaletteDesc => "命令、技能和工具",
|
||||
MessageId::CtxMenuContextInspector => "上下文检查器",
|
||||
MessageId::CtxMenuContextInspectorDesc => "活动上下文和缓存提示",
|
||||
MessageId::CtxMenuHelp => "帮助",
|
||||
MessageId::CtxMenuHelpDesc => "快捷键和命令",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2022,7 +2583,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdNoteDescription => "Adicionar, listar, editar ou remover notas do workspace",
|
||||
MessageId::CmdThemeDescription => "Alternar tema: escuro, claro, tons de cinza ou sistema",
|
||||
MessageId::CmdProviderDescription => {
|
||||
"Trocar ou exibir o backend LLM ativo (codewhale | nvidia-nim | ollama)"
|
||||
"Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)"
|
||||
}
|
||||
MessageId::CmdQueueDescription => "Ver ou editar mensagens enfileiradas",
|
||||
MessageId::CmdRecallDescription => {
|
||||
@@ -2256,7 +2817,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
"Escolha o idioma da interface. Você pode mudá-lo a qualquer momento com `/settings set locale <tag>`."
|
||||
}
|
||||
MessageId::OnboardLanguageFooter => {
|
||||
"Pressione 1-6 para escolher, ou Enter para manter a configuração atual"
|
||||
"Pressione 1-7 para escolher, ou Enter para manter a configuração atual"
|
||||
}
|
||||
// Onboarding — API key entry.
|
||||
MessageId::OnboardApiKeyTitle => "Conecte sua chave de API DeepSeek",
|
||||
@@ -2302,6 +2863,32 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
|
||||
}
|
||||
MessageId::OnboardTipsFooterEnter => "Pressione Enter",
|
||||
MessageId::OnboardTipsFooterAction => " para abrir o workspace",
|
||||
// Context menu.
|
||||
MessageId::CtxMenuTitle => " Clique direito ",
|
||||
MessageId::CtxMenuCopySelection => "Copiar seleção",
|
||||
MessageId::CtxMenuCopySelectionDesc => "copiar texto selecionado da transcrição",
|
||||
MessageId::CtxMenuOpenSelection => "Abrir seleção",
|
||||
MessageId::CtxMenuOpenSelectionDesc => "mostrar texto selecionado no visualizador",
|
||||
MessageId::CtxMenuClearSelection => "Limpar seleção",
|
||||
MessageId::CtxMenuOpenDetails => "Abrir detalhes",
|
||||
MessageId::CtxMenuCopyMessage => "Copiar mensagem",
|
||||
MessageId::CtxMenuCopyMessageDesc => "copiar célula da transcrição clicada",
|
||||
MessageId::CtxMenuOpenInEditor => "Abrir no editor",
|
||||
MessageId::CtxMenuOpenInEditorDesc => "abrir file:line no $EDITOR",
|
||||
MessageId::CtxMenuShowCell => "Mostrar célula",
|
||||
MessageId::CtxMenuShowCellDesc => "reexibir esta célula da transcrição",
|
||||
MessageId::CtxMenuHideCell => "Ocultar célula",
|
||||
MessageId::CtxMenuHideCellDesc => "recolher esta célula da transcrição",
|
||||
MessageId::CtxMenuShowHidden => "Mostrar ocultas",
|
||||
MessageId::CtxMenuShowHiddenDesc => "reexibir todas as células recolhidas",
|
||||
MessageId::CtxMenuPaste => "Colar",
|
||||
MessageId::CtxMenuPasteDesc => "inserir área de transferência no compositor",
|
||||
MessageId::CtxMenuCmdPalette => "Paleta de comandos",
|
||||
MessageId::CtxMenuCmdPaletteDesc => "comandos, habilidades e ferramentas",
|
||||
MessageId::CtxMenuContextInspector => "Inspetor de contexto",
|
||||
MessageId::CtxMenuContextInspectorDesc => "contexto ativo e dicas de cache",
|
||||
MessageId::CtxMenuHelp => "Ajuda",
|
||||
MessageId::CtxMenuHelpDesc => "atalhos de teclado e comandos",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2414,7 +3001,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdNoteDescription => "Agregar nota al archivo persistente (.deepseek/notes.md)",
|
||||
MessageId::CmdThemeDescription => "Alternar entre tema claro y oscuro",
|
||||
MessageId::CmdProviderDescription => {
|
||||
"Cambiar o mostrar el backend LLM activo (codewhale | nvidia-nim | ollama)"
|
||||
"Cambiar o mostrar el backend LLM activo (deepseek | nvidia-nim | ollama)"
|
||||
}
|
||||
MessageId::CmdQueueDescription => "Ver o editar mensajes en cola",
|
||||
MessageId::CmdRecallDescription => {
|
||||
@@ -2653,7 +3240,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
|
||||
"Elige el idioma de la interfaz. Puedes cambiarlo en cualquier momento con `/settings set locale <etiqueta>`."
|
||||
}
|
||||
MessageId::OnboardLanguageFooter => {
|
||||
"Presiona 1-5 para elegir, o Enter para mantener la configuración actual"
|
||||
"Presiona 1-7 para elegir, o Enter para mantener la configuración actual"
|
||||
}
|
||||
MessageId::OnboardApiKeyTitle => "Conecta tu clave de API DeepSeek",
|
||||
MessageId::OnboardApiKeyStep1 => {
|
||||
@@ -2696,6 +3283,32 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
|
||||
}
|
||||
MessageId::OnboardTipsFooterEnter => "Presiona Enter",
|
||||
MessageId::OnboardTipsFooterAction => " para abrir el workspace",
|
||||
// Context menu.
|
||||
MessageId::CtxMenuTitle => " Clic derecho ",
|
||||
MessageId::CtxMenuCopySelection => "Copiar selección",
|
||||
MessageId::CtxMenuCopySelectionDesc => "copiar texto seleccionado de la transcripción",
|
||||
MessageId::CtxMenuOpenSelection => "Abrir selección",
|
||||
MessageId::CtxMenuOpenSelectionDesc => "mostrar texto seleccionado en el visor",
|
||||
MessageId::CtxMenuClearSelection => "Limpiar selección",
|
||||
MessageId::CtxMenuOpenDetails => "Abrir detalles",
|
||||
MessageId::CtxMenuCopyMessage => "Copiar mensaje",
|
||||
MessageId::CtxMenuCopyMessageDesc => "copiar celda de transcripción seleccionada",
|
||||
MessageId::CtxMenuOpenInEditor => "Abrir en editor",
|
||||
MessageId::CtxMenuOpenInEditorDesc => "abrir file:line en $EDITOR",
|
||||
MessageId::CtxMenuShowCell => "Mostrar celda",
|
||||
MessageId::CtxMenuShowCellDesc => "volver a mostrar esta celda de transcripción",
|
||||
MessageId::CtxMenuHideCell => "Ocultar celda",
|
||||
MessageId::CtxMenuHideCellDesc => "colapsar esta celda de transcripción",
|
||||
MessageId::CtxMenuShowHidden => "Mostrar ocultas",
|
||||
MessageId::CtxMenuShowHiddenDesc => "volver a mostrar todas las celdas colapsadas",
|
||||
MessageId::CtxMenuPaste => "Pegar",
|
||||
MessageId::CtxMenuPasteDesc => "insertar portapapeles en el compositor",
|
||||
MessageId::CtxMenuCmdPalette => "Paleta de comandos",
|
||||
MessageId::CtxMenuCmdPaletteDesc => "comandos, habilidades y herramientas",
|
||||
MessageId::CtxMenuContextInspector => "Inspector de contexto",
|
||||
MessageId::CtxMenuContextInspectorDesc => "contexto activo y sugerencias de caché",
|
||||
MessageId::CtxMenuHelp => "Ayuda",
|
||||
MessageId::CtxMenuHelpDesc => "atajos de teclado y comandos",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2770,6 +3383,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_description_names_deepseek_backend() {
|
||||
for locale in Locale::shipped() {
|
||||
let description = tr(*locale, MessageId::CmdProviderDescription);
|
||||
assert!(
|
||||
description.contains("deepseek"),
|
||||
"{} provider description should mention deepseek: {description}",
|
||||
locale.tag()
|
||||
);
|
||||
assert!(
|
||||
!description.contains("codewhale |"),
|
||||
"{} provider description should not name codewhale as a backend: {description}",
|
||||
locale.tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_truncation_handles_cjk_rtl_indic_and_latin_samples() {
|
||||
let samples = [
|
||||
|
||||
@@ -6,12 +6,27 @@ use colored::Colorize;
|
||||
|
||||
use crate::palette;
|
||||
static VERBOSE: AtomicBool = AtomicBool::new(false);
|
||||
#[cfg(windows)]
|
||||
static VERBOSE_SNAPSHOT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Enable or disable verbose logging output.
|
||||
pub fn set_verbose(enabled: bool) {
|
||||
VERBOSE.store(enabled, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Capture the current verbose state so the TUI can restore it after
|
||||
/// temporarily suppressing Windows alt-screen output.
|
||||
#[cfg(windows)]
|
||||
pub fn snapshot_verbose_state() {
|
||||
VERBOSE_SNAPSHOT.store(is_verbose(), Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Restore the last captured verbose state.
|
||||
#[cfg(windows)]
|
||||
pub fn restore_verbose_state() {
|
||||
set_verbose(VERBOSE_SNAPSHOT.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// Return true when `DEEPSEEK_LOG_LEVEL` requests verbose output.
|
||||
///
|
||||
/// Note: `RUST_LOG` is intentionally NOT checked here — it controls the
|
||||
@@ -61,8 +76,12 @@ pub fn warn(message: impl AsRef<str>) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(windows)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static TEST_GUARD: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn log_value_parser_accepts_common_rust_log_directives() {
|
||||
@@ -74,4 +93,40 @@ mod tests {
|
||||
assert!(!log_value_enables_verbose("warn"));
|
||||
assert!(!log_value_enables_verbose("codewhale_tui=off"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_and_restore_verbose_state_round_trip() {
|
||||
let _guard = TEST_GUARD.lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
set_verbose(false);
|
||||
snapshot_verbose_state();
|
||||
set_verbose(true);
|
||||
restore_verbose_state();
|
||||
assert!(!is_verbose());
|
||||
|
||||
set_verbose(true);
|
||||
snapshot_verbose_state();
|
||||
set_verbose(false);
|
||||
restore_verbose_state();
|
||||
assert!(is_verbose());
|
||||
|
||||
set_verbose(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_keeps_cli_verbose_state_even_when_env_is_not_verbose() {
|
||||
let _guard = TEST_GUARD.lock().unwrap_or_else(|err| err.into_inner());
|
||||
|
||||
set_verbose(true);
|
||||
snapshot_verbose_state();
|
||||
|
||||
// Simulate the Windows alt-screen suppression path. The restore must
|
||||
// bring back the pre-suppression CLI state without depending on the
|
||||
// environment.
|
||||
set_verbose(false);
|
||||
restore_verbose_state();
|
||||
|
||||
assert!(is_verbose());
|
||||
set_verbose(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub enum Language {
|
||||
Python,
|
||||
TypeScript,
|
||||
JavaScript,
|
||||
Java,
|
||||
Vue,
|
||||
C,
|
||||
Cpp,
|
||||
Other,
|
||||
@@ -34,6 +36,8 @@ impl Language {
|
||||
Language::Python => "python",
|
||||
Language::TypeScript => "typescript",
|
||||
Language::JavaScript => "javascript",
|
||||
Language::Java => "java",
|
||||
Language::Vue => "vue",
|
||||
Language::C => "c",
|
||||
Language::Cpp => "cpp",
|
||||
Language::Other => "other",
|
||||
@@ -42,7 +46,7 @@ impl Language {
|
||||
|
||||
/// LSP `languageId` value used in `textDocument/didOpen`. We follow the
|
||||
/// LSP-spec values: `rust`, `go`, `python`, `typescript`, `javascript`,
|
||||
/// `c`, `cpp`.
|
||||
/// `java`, `vue`, `c`, `cpp`.
|
||||
#[must_use]
|
||||
pub fn language_id(self) -> &'static str {
|
||||
match self {
|
||||
@@ -51,6 +55,8 @@ impl Language {
|
||||
Language::Python => "python",
|
||||
Language::TypeScript => "typescript",
|
||||
Language::JavaScript => "javascript",
|
||||
Language::Java => "java",
|
||||
Language::Vue => "vue",
|
||||
Language::C => "c",
|
||||
Language::Cpp => "cpp",
|
||||
Language::Other => "plaintext",
|
||||
@@ -73,6 +79,8 @@ pub fn detect_language(path: &Path) -> Language {
|
||||
"py" | "pyi" => Language::Python,
|
||||
"ts" | "tsx" => Language::TypeScript,
|
||||
"js" | "jsx" | "mjs" | "cjs" => Language::JavaScript,
|
||||
"java" => Language::Java,
|
||||
"vue" => Language::Vue,
|
||||
"c" | "h" => Language::C,
|
||||
"cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Language::Cpp,
|
||||
_ => Language::Other,
|
||||
@@ -91,6 +99,8 @@ pub fn server_for(lang: Language) -> Option<(&'static str, &'static [&'static st
|
||||
Language::TypeScript | Language::JavaScript => {
|
||||
Some(("typescript-language-server", &["--stdio"]))
|
||||
}
|
||||
Language::Java => Some(("jdtls", &[])),
|
||||
Language::Vue => Some(("vue-language-server", &["--stdio"])),
|
||||
Language::C | Language::Cpp => Some(("clangd", &[])),
|
||||
Language::Other => None,
|
||||
}
|
||||
@@ -132,6 +142,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_java_extension() {
|
||||
assert_eq!(detect_language(&PathBuf::from("App.java")), Language::Java);
|
||||
assert_eq!(detect_language(&PathBuf::from("APP.JAVA")), Language::Java);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_vue_extension() {
|
||||
assert_eq!(
|
||||
detect_language(&PathBuf::from("Component.vue")),
|
||||
Language::Vue
|
||||
);
|
||||
assert_eq!(
|
||||
detect_language(&PathBuf::from("COMPONENT.VUE")),
|
||||
Language::Vue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_ids_for_java_and_vue_match_lsp_values() {
|
||||
assert_eq!(Language::Java.as_key(), "java");
|
||||
assert_eq!(Language::Java.language_id(), "java");
|
||||
assert_eq!(Language::Vue.as_key(), "vue");
|
||||
assert_eq!(Language::Vue.language_id(), "vue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_for_rust_is_rust_analyzer() {
|
||||
let (cmd, args) = server_for(Language::Rust).expect("rust has a server");
|
||||
@@ -139,6 +175,20 @@ mod tests {
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_for_java_is_jdtls() {
|
||||
let (cmd, args) = server_for(Language::Java).expect("java has a server");
|
||||
assert_eq!(cmd, "jdtls");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_for_vue_is_vue_language_server() {
|
||||
let (cmd, args) = server_for(Language::Vue).expect("vue has a server");
|
||||
assert_eq!(cmd, "vue-language-server");
|
||||
assert_eq!(args, &["--stdio"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_for_other_is_none() {
|
||||
assert!(server_for(Language::Other).is_none());
|
||||
|
||||
+106
-10
@@ -65,6 +65,7 @@ mod seam_manager;
|
||||
mod session_failure_classifier;
|
||||
mod session_manager;
|
||||
mod settings;
|
||||
mod shell_dispatcher;
|
||||
mod skill_state;
|
||||
mod skills;
|
||||
mod snapshot;
|
||||
@@ -78,6 +79,7 @@ mod tui;
|
||||
mod utils;
|
||||
mod vision;
|
||||
mod working_set;
|
||||
mod workspace_discovery;
|
||||
mod workspace_trust;
|
||||
|
||||
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
|
||||
@@ -120,7 +122,7 @@ struct Cli {
|
||||
#[command(flatten)]
|
||||
feature_toggles: FeatureToggles,
|
||||
|
||||
/// Send a one-shot prompt (non-interactive)
|
||||
/// Initial prompt to submit in the interactive TUI. Use `exec` for non-interactive runs.
|
||||
#[arg(short, long, value_name = "PROMPT", num_args = 1..)]
|
||||
prompt: Vec<String>,
|
||||
|
||||
@@ -426,6 +428,10 @@ fn join_prompt_parts(parts: &[String]) -> String {
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
|
||||
(!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
|
||||
}
|
||||
|
||||
fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
|
||||
if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
|
||||
return Ok(Some(id.clone()));
|
||||
@@ -631,6 +637,9 @@ enum McpCommand {
|
||||
/// URL for streamable HTTP/SSE server
|
||||
#[arg(long, conflicts_with = "command")]
|
||||
url: Option<String>,
|
||||
/// Explicit URL transport override. Use "sse" for legacy SSE endpoints.
|
||||
#[arg(long, requires = "url")]
|
||||
transport: Option<String>,
|
||||
/// Arguments for command-based servers
|
||||
#[arg(long = "arg")]
|
||||
args: Vec<String>,
|
||||
@@ -934,7 +943,7 @@ async fn main() -> Result<()> {
|
||||
bail!("Choose exactly one server mode: --mcp, --http, or --acp");
|
||||
}
|
||||
if args.mcp {
|
||||
mcp_server::run_mcp_server(workspace)
|
||||
tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
|
||||
} else if args.http {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
|
||||
@@ -974,12 +983,12 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
}
|
||||
|
||||
// One-shot prompt mode
|
||||
// Top-level prompt mode: submit the initial prompt, then keep the TUI alive
|
||||
// for follow-up messages. Use `codewhale exec` for explicit non-interactive
|
||||
// one-shot behavior (#2370).
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
if !cli.prompt.is_empty() {
|
||||
let prompt = join_prompt_parts(&cli.prompt);
|
||||
let model = config.default_model();
|
||||
return run_one_shot(&config, &model, &prompt).await;
|
||||
if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
|
||||
return run_interactive(&cli, &config, None, Some(initial_input)).await;
|
||||
}
|
||||
|
||||
// Handle session resume. Plain `codewhale` starts fresh: interrupted
|
||||
@@ -1417,6 +1426,7 @@ fn mcp_template_json() -> Result<String> {
|
||||
args: vec!["./path/to/your-mcp-server.js".to_string()],
|
||||
env: std::collections::HashMap::new(),
|
||||
url: None,
|
||||
transport: None,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
@@ -1866,6 +1876,10 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
"OPENROUTER_API_KEY",
|
||||
"codewhale auth set --provider openrouter --api-key \"...\"",
|
||||
),
|
||||
crate::config::ApiProvider::XiaomiMimo => (
|
||||
"XIAOMI_MIMO_API_KEY/MIMO_API_KEY",
|
||||
"codewhale auth set --provider xiaomi-mimo --api-key \"...\"",
|
||||
),
|
||||
crate::config::ApiProvider::Novita => (
|
||||
"NOVITA_API_KEY",
|
||||
"codewhale auth set --provider novita --api-key \"...\"",
|
||||
@@ -1902,6 +1916,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
crate::config::ApiProvider::Atlascloud => "atlascloud",
|
||||
crate::config::ApiProvider::WanjieArk => "wanjie_ark",
|
||||
crate::config::ApiProvider::Openrouter => "openrouter",
|
||||
crate::config::ApiProvider::XiaomiMimo => "xiaomi_mimo",
|
||||
crate::config::ApiProvider::Novita => "novita",
|
||||
crate::config::ApiProvider::Fireworks => "fireworks",
|
||||
crate::config::ApiProvider::Moonshot => "moonshot",
|
||||
@@ -2065,6 +2080,51 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(" rust: {}", rustc_version());
|
||||
println!();
|
||||
|
||||
println!("{}", "Updates:".bold());
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
println!(" · current: v{current_version}");
|
||||
match codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable)
|
||||
.await
|
||||
{
|
||||
Ok(latest_tag) => {
|
||||
match codewhale_release::compare_release_versions(current_version, &latest_tag) {
|
||||
Ok(std::cmp::Ordering::Less) => {
|
||||
println!(
|
||||
" {} latest: {latest_tag}",
|
||||
"!".truecolor(sky_r, sky_g, sky_b)
|
||||
);
|
||||
println!(" Update available. Run `codewhale update` to install.");
|
||||
}
|
||||
Ok(std::cmp::Ordering::Equal) => {
|
||||
println!(
|
||||
" {} latest: {latest_tag}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
);
|
||||
println!(" Already up to date.");
|
||||
}
|
||||
Ok(std::cmp::Ordering::Greater) => {
|
||||
println!(" {} latest: {latest_tag}", "·".dimmed());
|
||||
println!(" Current build is newer than the latest published release.");
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
" {} latest: {latest_tag}",
|
||||
"!".truecolor(sky_r, sky_g, sky_b)
|
||||
);
|
||||
println!(" Version comparison failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
" {} latest release check failed: {err}",
|
||||
"!".truecolor(sky_r, sky_g, sky_b)
|
||||
);
|
||||
println!(" Run `codewhale update --check` to retry.");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
// Configuration summary
|
||||
println!("{}", "Configuration:".bold());
|
||||
let config_path = config_path_override
|
||||
@@ -2163,6 +2223,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
"openrouter",
|
||||
&["OPENROUTER_API_KEY"][..],
|
||||
),
|
||||
(
|
||||
crate::config::ApiProvider::XiaomiMimo,
|
||||
"xiaomi-mimo",
|
||||
&["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"][..],
|
||||
),
|
||||
(
|
||||
crate::config::ApiProvider::Novita,
|
||||
"novita",
|
||||
@@ -3782,7 +3847,13 @@ async fn run_pr(
|
||||
} else {
|
||||
cli.resume.clone()
|
||||
};
|
||||
run_interactive(cli, config, resume_session_id, Some(prompt)).await
|
||||
run_interactive(
|
||||
cli,
|
||||
config,
|
||||
resume_session_id,
|
||||
Some(tui::InitialInput::Prefill(prompt)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Return true if `name` resolves to an executable on the current `PATH`.
|
||||
@@ -4131,11 +4202,17 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
name,
|
||||
command,
|
||||
url,
|
||||
transport,
|
||||
args,
|
||||
} => {
|
||||
if command.is_none() && url.is_none() {
|
||||
bail!("Provide either --command or --url for `mcp add`.");
|
||||
}
|
||||
if let Some(transport) = transport.as_deref() {
|
||||
if !transport.trim().eq_ignore_ascii_case("sse") {
|
||||
bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
|
||||
}
|
||||
}
|
||||
let mut cfg = load_mcp_config(&config_path)?;
|
||||
cfg.servers.insert(
|
||||
name.clone(),
|
||||
@@ -4144,6 +4221,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
args,
|
||||
env: std::collections::HashMap::new(),
|
||||
url,
|
||||
transport,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
@@ -4230,6 +4308,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
args,
|
||||
env: std::collections::HashMap::new(),
|
||||
url: None,
|
||||
transport: None,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
@@ -4794,7 +4873,7 @@ async fn run_interactive(
|
||||
cli: &Cli,
|
||||
config: &Config,
|
||||
resume_session_id: Option<String>,
|
||||
initial_input: Option<String>,
|
||||
initial_input: Option<tui::InitialInput>,
|
||||
) -> Result<()> {
|
||||
let workspace = cli
|
||||
.workspace
|
||||
@@ -5181,7 +5260,11 @@ async fn run_exec_agent(
|
||||
notes_path: config.notes_path(),
|
||||
mcp_config_path: config.mcp_config_path(),
|
||||
skills_dir: config.skills_dir(),
|
||||
instructions: config.instructions_paths(),
|
||||
instructions: config
|
||||
.instructions_paths()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
translation_enabled: false,
|
||||
show_thinking: settings.show_thinking,
|
||||
@@ -5211,6 +5294,7 @@ async fn run_exec_agent(
|
||||
vision_config: config.vision_model_config(),
|
||||
strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
locale_tag: crate::localization::resolve_locale(&settings.locale)
|
||||
.tag()
|
||||
.to_string(),
|
||||
@@ -5265,6 +5349,7 @@ async fn run_exec_agent(
|
||||
mode,
|
||||
model: effective_model.clone(),
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
reasoning_effort: effective_reasoning_effort,
|
||||
reasoning_effort_auto: auto_model,
|
||||
auto_model,
|
||||
@@ -5837,6 +5922,16 @@ mod terminal_mode_tests {
|
||||
assert_eq!(cli.prompt, vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_flag_starts_interactive_submit_input() {
|
||||
let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
|
||||
|
||||
assert_eq!(
|
||||
top_level_prompt_initial_input(&cli.prompt),
|
||||
Some(tui::InitialInput::Submit("read the project".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_binary_reports_its_own_name() {
|
||||
assert_eq!(Cli::command().get_name(), "codewhale-tui");
|
||||
@@ -6718,6 +6813,7 @@ mod doctor_mcp_tests {
|
||||
args: args.iter().map(|s| s.to_string()).collect(),
|
||||
env: std::collections::HashMap::new(),
|
||||
url: url.map(String::from),
|
||||
transport: None,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
|
||||
+1084
-93
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -265,6 +266,27 @@ fn host_matches(entry: &str, normalized_host: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an IPv4 CIDR string such as `"198.18.0.0/15"` into `(base, prefix)`.
|
||||
/// Returns `None` for malformed input or a prefix length above 32.
|
||||
fn parse_ipv4_cidr(cidr: &str) -> Option<(Ipv4Addr, u8)> {
|
||||
let (addr, prefix) = cidr.split_once('/')?;
|
||||
let base: Ipv4Addr = addr.trim().parse().ok()?;
|
||||
let prefix: u8 = prefix.trim().parse().ok()?;
|
||||
if prefix > 32 {
|
||||
return None;
|
||||
}
|
||||
Some((base, prefix))
|
||||
}
|
||||
|
||||
/// Whether `ip` is contained in the `base/prefix` IPv4 CIDR block.
|
||||
fn ipv4_in_cidr(ip: Ipv4Addr, base: Ipv4Addr, prefix: u8) -> bool {
|
||||
if prefix == 0 {
|
||||
return true;
|
||||
}
|
||||
let mask: u32 = u32::MAX << (32 - prefix);
|
||||
(u32::from(ip) & mask) == (u32::from(base) & mask)
|
||||
}
|
||||
|
||||
/// Best-effort writer for the network audit log.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkAuditor {
|
||||
@@ -415,6 +437,12 @@ pub struct NetworkPolicyDecider {
|
||||
policy: NetworkPolicy,
|
||||
cache: NetworkSessionCache,
|
||||
auditor: Option<NetworkAuditor>,
|
||||
/// IPv4 CIDR ranges that are treated as benign fake-IP placeholders (e.g.
|
||||
/// a transparent-proxy / TUN setup running in `fake-ip` mode, where DNS
|
||||
/// resolves every hostname into a reserved range like `198.18.0.0/15`).
|
||||
/// A resolved IP inside one of these ranges bypasses the restricted-IP SSRF
|
||||
/// block; real private/loopback/link-local/metadata IPs are unaffected.
|
||||
trusted_fakeip_cidrs: Vec<(Ipv4Addr, u8)>,
|
||||
}
|
||||
|
||||
impl NetworkPolicyDecider {
|
||||
@@ -425,6 +453,38 @@ impl NetworkPolicyDecider {
|
||||
policy,
|
||||
cache: NetworkSessionCache::new(),
|
||||
auditor,
|
||||
trusted_fakeip_cidrs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register IPv4 CIDR ranges to treat as benign fake-IP placeholders.
|
||||
/// Invalid CIDR strings are skipped. See [`Self::is_trusted_fakeip_addr`].
|
||||
#[must_use]
|
||||
pub fn with_trusted_fakeip_cidrs(mut self, cidrs: &[&str]) -> Self {
|
||||
for cidr in cidrs {
|
||||
if let Some(parsed) = parse_ipv4_cidr(cidr) {
|
||||
self.trusted_fakeip_cidrs.push(parsed);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether `ip` falls inside a configured fake-IP placeholder range.
|
||||
///
|
||||
/// In `fake-ip` proxy/TUN setups the local resolver maps every hostname to
|
||||
/// a reserved range (commonly `198.18.0.0/15`), so the DNS-resolution SSRF
|
||||
/// check would otherwise reject every request. This narrowly trusts only
|
||||
/// those placeholder addresses — real private/loopback/link-local/cloud-
|
||||
/// metadata IPs are *not* matched and stay blocked.
|
||||
#[must_use]
|
||||
pub fn is_trusted_fakeip_addr(&self, ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => self
|
||||
.trusted_fakeip_cidrs
|
||||
.iter()
|
||||
.any(|(base, prefix)| ipv4_in_cidr(*v4, *base, *prefix)),
|
||||
// fake-ip placeholders are IPv4-only in practice.
|
||||
IpAddr::V6(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +703,30 @@ mod tests {
|
||||
assert!(p.trusts_proxy_fakeip_host("avatars.githubusercontent.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_fakeip_cidr_allows_placeholder_but_not_real_private() {
|
||||
let decider = NetworkPolicyDecider::new(NetworkPolicy::default(), None)
|
||||
.with_trusted_fakeip_cidrs(&["198.18.0.0/15"]);
|
||||
|
||||
// fake-ip placeholder range (clash default / IETF benchmark) is trusted
|
||||
assert!(decider.is_trusted_fakeip_addr(&"198.18.0.5".parse::<std::net::IpAddr>().unwrap()));
|
||||
assert!(
|
||||
decider.is_trusted_fakeip_addr(&"198.19.255.255".parse::<std::net::IpAddr>().unwrap())
|
||||
);
|
||||
|
||||
// real private / loopback / link-local / cloud-metadata are NOT trusted
|
||||
for ip in ["192.168.1.1", "10.0.0.1", "127.0.0.1", "169.254.169.254"] {
|
||||
assert!(
|
||||
!decider.is_trusted_fakeip_addr(&ip.parse::<std::net::IpAddr>().unwrap()),
|
||||
"{ip} must not be treated as a fake-ip placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
// no ranges configured → nothing trusted
|
||||
let bare = NetworkPolicyDecider::new(NetworkPolicy::default(), None);
|
||||
assert!(!bare.is_trusted_fakeip_addr(&"198.18.0.5".parse::<std::net::IpAddr>().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_from_url_extracts_host() {
|
||||
assert_eq!(
|
||||
|
||||
+106
-9
@@ -825,6 +825,63 @@ pub const DRACULA_UI_THEME: UiTheme = UiTheme {
|
||||
tool_failed: Color::Rgb(0xff, 0x55, 0x55), // red
|
||||
};
|
||||
|
||||
/// "Terminal" theme: lets the host terminal's color scheme show through
|
||||
/// instead of painting any RGB surface. Backgrounds use `Color::Reset`
|
||||
/// (the terminal's own default bg) and most text uses `Color::Reset`
|
||||
/// (terminal's own default fg). Accents are ANSI named colors so they
|
||||
/// also inherit the user's terminal palette (Solarized, Nord, custom
|
||||
/// schemes, etc.) rather than DeepSeek brand RGB.
|
||||
pub const TERMINAL_UI_THEME: UiTheme = UiTheme {
|
||||
name: "terminal",
|
||||
// Mode is reported as Dark to avoid the dark→light cell remap kicking
|
||||
// in; the terminal-theme cell remap already normalizes everything to
|
||||
// `Color::Reset`, and we never want a second pass overwriting that.
|
||||
mode: PaletteMode::Dark,
|
||||
surface_bg: Color::Reset,
|
||||
panel_bg: Color::Reset,
|
||||
elevated_bg: Color::Reset,
|
||||
composer_bg: Color::Reset,
|
||||
selection_bg: Color::Reset,
|
||||
header_bg: Color::Reset,
|
||||
footer_bg: Color::Reset,
|
||||
text_dim: Color::Reset,
|
||||
text_hint: Color::Reset,
|
||||
text_muted: Color::Reset,
|
||||
text_body: Color::Reset,
|
||||
text_soft: Color::Reset,
|
||||
border: Color::Reset,
|
||||
accent_primary: Color::Blue,
|
||||
accent_secondary: Color::Cyan,
|
||||
accent_action: Color::Yellow,
|
||||
error_fg: Color::Red,
|
||||
error_hover: Color::Red,
|
||||
error_surface: Color::Reset,
|
||||
error_border: Color::Red,
|
||||
error_text: Color::Red,
|
||||
warning: Color::Yellow,
|
||||
success: Color::Green,
|
||||
info: Color::Cyan,
|
||||
mode_agent: Color::Blue,
|
||||
mode_yolo: Color::Red,
|
||||
// Magenta keeps Plan visually distinct from `status_warning` (yellow)
|
||||
// so the mode indicator and warning chip don't collide on themes that
|
||||
// render both in the status row.
|
||||
mode_plan: Color::Magenta,
|
||||
mode_goal: Color::Green,
|
||||
// DarkGray gives "Ready" a low-contrast but still distinguishable hue
|
||||
// versus default body text (which is `Color::Reset` on this theme).
|
||||
status_ready: Color::DarkGray,
|
||||
status_working: Color::Cyan,
|
||||
status_warning: Color::Yellow,
|
||||
diff_added_fg: Color::Green,
|
||||
diff_deleted_fg: Color::Red,
|
||||
diff_added_bg: Color::Reset,
|
||||
diff_deleted_bg: Color::Reset,
|
||||
tool_running: Color::Cyan,
|
||||
tool_success: Color::Green,
|
||||
tool_failed: Color::Red,
|
||||
};
|
||||
|
||||
pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
|
||||
name: "gruvbox-dark",
|
||||
mode: PaletteMode::Dark,
|
||||
@@ -874,6 +931,7 @@ pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ThemeId {
|
||||
System,
|
||||
Terminal,
|
||||
Whale,
|
||||
WhaleLight,
|
||||
Grayscale,
|
||||
@@ -891,6 +949,7 @@ impl ThemeId {
|
||||
pub fn from_name(value: &str) -> Option<Self> {
|
||||
match normalize_theme_name(value)? {
|
||||
"system" => Some(Self::System),
|
||||
"terminal" => Some(Self::Terminal),
|
||||
"dark" => Some(Self::Whale),
|
||||
"light" => Some(Self::WhaleLight),
|
||||
"grayscale" => Some(Self::Grayscale),
|
||||
@@ -908,6 +967,7 @@ impl ThemeId {
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Whale => "dark",
|
||||
Self::WhaleLight => "light",
|
||||
Self::Grayscale => "grayscale",
|
||||
@@ -923,6 +983,7 @@ impl ThemeId {
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Terminal => "Terminal",
|
||||
Self::Whale => "Whale (Dark)",
|
||||
Self::WhaleLight => "Whale Light",
|
||||
Self::Grayscale => "Grayscale",
|
||||
@@ -938,6 +999,7 @@ impl ThemeId {
|
||||
pub const fn tagline(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Follow terminal background (COLORFGBG / macOS appearance)",
|
||||
Self::Terminal => "Inherit terminal colors fully (transparent surfaces, ANSI accents)",
|
||||
Self::Whale => "Whale dark — deep navy & gold",
|
||||
Self::WhaleLight => "DeepSeek light, paper-ish",
|
||||
Self::Grayscale => "Color-minimal high contrast",
|
||||
@@ -956,6 +1018,7 @@ impl ThemeId {
|
||||
pub fn ui_theme(self) -> UiTheme {
|
||||
match self {
|
||||
Self::System => UiTheme::detect(),
|
||||
Self::Terminal => TERMINAL_UI_THEME,
|
||||
Self::Whale => UI_THEME,
|
||||
Self::WhaleLight => LIGHT_UI_THEME,
|
||||
Self::Grayscale => GRAYSCALE_UI_THEME,
|
||||
@@ -970,6 +1033,7 @@ impl ThemeId {
|
||||
/// Themes shown in the `/theme` picker, in display order.
|
||||
pub const SELECTABLE_THEMES: &[ThemeId] = &[
|
||||
ThemeId::System,
|
||||
ThemeId::Terminal,
|
||||
ThemeId::Whale,
|
||||
ThemeId::WhaleLight,
|
||||
ThemeId::Grayscale,
|
||||
@@ -1012,6 +1076,7 @@ impl UiTheme {
|
||||
pub fn normalize_theme_name(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "auto" | "system" | "default" => Some("system"),
|
||||
"terminal" | "term" | "transparent" | "follow-terminal" | "inherit" => Some("terminal"),
|
||||
"dark" | "whale" | "whale-dark" => Some("dark"),
|
||||
"light" | "whale-light" => Some("light"),
|
||||
"grayscale" | "greyscale" | "gray" | "grey" | "mono" | "monochrome" | "black-white"
|
||||
@@ -1189,7 +1254,11 @@ const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color {
|
||||
pub const fn theme_remap_active(theme: ThemeId) -> bool {
|
||||
matches!(
|
||||
theme,
|
||||
ThemeId::CatppuccinMocha | ThemeId::TokyoNight | ThemeId::Dracula | ThemeId::GruvboxDark
|
||||
ThemeId::Terminal
|
||||
| ThemeId::CatppuccinMocha
|
||||
| ThemeId::TokyoNight
|
||||
| ThemeId::Dracula
|
||||
| ThemeId::GruvboxDark
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1677,14 +1746,15 @@ fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
|
||||
mod tests {
|
||||
use super::{
|
||||
ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY,
|
||||
DEEPSEEK_SLATE, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING,
|
||||
GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT,
|
||||
GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED, LIGHT_PANEL, LIGHT_REASONING,
|
||||
LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT, LIGHT_UI_THEME, PaletteMode,
|
||||
SURFACE_REASONING, SURFACE_REASONING_TINT, TEXT_BODY, TEXT_HINT, TEXT_REASONING,
|
||||
TEXT_TOOL_OUTPUT, UI_THEME, WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB,
|
||||
WHALE_TEXT_BODY_RGB, adapt_bg, adapt_bg_for_palette_mode, adapt_color,
|
||||
adapt_fg_for_palette_mode, blend, luma, nearest_ansi16, normalize_hex_rgb_color,
|
||||
DEEPSEEK_SLATE, DIFF_ADDED, DIFF_ADDED_BG, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED,
|
||||
GRAYSCALE_PANEL, GRAYSCALE_REASONING, GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY,
|
||||
GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, GRAYSCALE_UI_THEME, LIGHT_BORDER, LIGHT_ELEVATED,
|
||||
LIGHT_PANEL, LIGHT_REASONING, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_HINT,
|
||||
LIGHT_UI_THEME, PaletteMode, SURFACE_REASONING, SURFACE_REASONING_TINT, TERMINAL_UI_THEME,
|
||||
TEXT_BODY, TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, ThemeId, UI_THEME,
|
||||
WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB, WHALE_TEXT_BODY_RGB, adapt_bg,
|
||||
adapt_bg_for_palette_mode, adapt_bg_for_theme, adapt_color, adapt_fg_for_palette_mode,
|
||||
adapt_fg_for_theme, blend, luma, nearest_ansi16, normalize_hex_rgb_color,
|
||||
normalize_theme_name, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint,
|
||||
rgb_to_ansi256, theme_label_for_mode, ui_theme_from_settings,
|
||||
};
|
||||
@@ -1770,12 +1840,39 @@ mod tests {
|
||||
assert_eq!(normalize_theme_name("system"), Some("system"));
|
||||
assert_eq!(normalize_theme_name("default"), Some("system"));
|
||||
assert_eq!(normalize_theme_name("whale"), Some("dark"));
|
||||
assert_eq!(normalize_theme_name("transparent"), Some("terminal"));
|
||||
assert_eq!(normalize_theme_name("inherit"), Some("terminal"));
|
||||
assert_eq!(normalize_theme_name("black-white"), Some("grayscale"));
|
||||
assert_eq!(normalize_theme_name("mono"), Some("grayscale"));
|
||||
assert_eq!(normalize_theme_name("solarized"), None);
|
||||
assert_eq!(theme_label_for_mode(PaletteMode::Grayscale), "grayscale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_theme_resets_surfaces_and_remaps_direct_palette_constants() {
|
||||
assert_eq!(ThemeId::from_name("terminal"), Some(ThemeId::Terminal));
|
||||
assert_eq!(TERMINAL_UI_THEME.surface_bg, Color::Reset);
|
||||
assert_eq!(TERMINAL_UI_THEME.footer_bg, Color::Reset);
|
||||
assert_eq!(TERMINAL_UI_THEME.text_body, Color::Reset);
|
||||
|
||||
assert_eq!(
|
||||
adapt_bg_for_theme(DEEPSEEK_INK, ThemeId::Terminal, &TERMINAL_UI_THEME),
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_bg_for_theme(DIFF_ADDED_BG, ThemeId::Terminal, &TERMINAL_UI_THEME),
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_fg_for_theme(TEXT_BODY, ThemeId::Terminal, &TERMINAL_UI_THEME),
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_fg_for_theme(DIFF_ADDED, ThemeId::Terminal, &TERMINAL_UI_THEME),
|
||||
Color::Green
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_palette_has_quiet_layer_separation() {
|
||||
assert_eq!(LIGHT_SURFACE, Color::Rgb(246, 248, 251));
|
||||
|
||||
@@ -35,10 +35,13 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[
|
||||
|
||||
/// User-level project instructions loaded as a fallback when the workspace and
|
||||
/// its parents do not define project context. `.codewhale/` takes priority
|
||||
/// over `.deepseek/` for both WHALE.md and AGENTS.md.
|
||||
/// over vendor-neutral `.agents/`, which takes priority over legacy
|
||||
/// `.deepseek/`, for both WHALE.md and AGENTS.md.
|
||||
const GLOBAL_AGENTS_RELATIVE_PATH: &[&str] = &[".codewhale", "AGENTS.md"];
|
||||
const GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "AGENTS.md"];
|
||||
const GLOBAL_AGENTS_LEGACY_PATH: &[&str] = &[".deepseek", "AGENTS.md"];
|
||||
const GLOBAL_WHALE_RELATIVE_PATH: &[&str] = &[".codewhale", "WHALE.md"];
|
||||
const GLOBAL_WHALE_VENDOR_NEUTRAL_PATH: &[&str] = &[".agents", "WHALE.md"];
|
||||
const GLOBAL_WHALE_LEGACY_PATH: &[&str] = &[".deepseek", "WHALE.md"];
|
||||
|
||||
/// Maximum size for project context files (to prevent loading huge files)
|
||||
@@ -50,6 +53,7 @@ const PACK_MAX_CONFIG_FILES: usize = 60;
|
||||
const PACK_MAX_DEPTH: usize = 4;
|
||||
const PACK_IGNORED_DIRS: &[&str] = &[
|
||||
".git",
|
||||
".worktrees",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
@@ -436,7 +440,7 @@ fn load_project_context_with_parents_and_home(
|
||||
}
|
||||
}
|
||||
|
||||
// Always check `~/.deepseek/AGENTS.md` so user-wide preferences
|
||||
// Always check global instruction files so user-wide preferences
|
||||
// travel into every session (#1157). When both global and project
|
||||
// instructions exist, the global block prepends the project's so
|
||||
// workspace overrides win the last word; when only global exists,
|
||||
@@ -486,12 +490,11 @@ fn load_project_context_with_parents_and_home(
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Combine `~/.deepseek/AGENTS.md` (global, user-wide preferences) with a
|
||||
/// project-local AGENTS.md/CLAUDE.md/instructions.md. Global comes first
|
||||
/// so workspace-specific rules can override it — the model reads in
|
||||
/// declared order. Each block is wrapped in a labelled fence so the
|
||||
/// model can tell which level any rule comes from when the two sets
|
||||
/// disagree (#1157).
|
||||
/// Combine global user-wide preferences with a project-local
|
||||
/// AGENTS.md/CLAUDE.md/instructions.md. Global comes first so
|
||||
/// workspace-specific rules can override it — the model reads in declared
|
||||
/// order. Each block is wrapped in a labelled fence so the model can tell
|
||||
/// which level any rule comes from when the two sets disagree (#1157).
|
||||
fn merge_global_and_project_instructions(
|
||||
global: &str,
|
||||
global_source: Option<&Path>,
|
||||
@@ -513,11 +516,15 @@ fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Opti
|
||||
// Priority order:
|
||||
// 1. ~/.codewhale/WHALE.md (CodeWhale-native)
|
||||
// 2. ~/.codewhale/AGENTS.md (new config directory)
|
||||
// 3. ~/.deepseek/WHALE.md (legacy fallback)
|
||||
// 4. ~/.deepseek/AGENTS.md (legacy fallback)
|
||||
// 3. ~/.agents/WHALE.md (vendor-neutral fallback)
|
||||
// 4. ~/.agents/AGENTS.md (vendor-neutral fallback)
|
||||
// 5. ~/.deepseek/WHALE.md (legacy fallback)
|
||||
// 6. ~/.deepseek/AGENTS.md (legacy fallback)
|
||||
let candidates: &[&[&str]] = &[
|
||||
GLOBAL_WHALE_RELATIVE_PATH,
|
||||
GLOBAL_AGENTS_RELATIVE_PATH,
|
||||
GLOBAL_WHALE_VENDOR_NEUTRAL_PATH,
|
||||
GLOBAL_AGENTS_VENDOR_NEUTRAL_PATH,
|
||||
GLOBAL_WHALE_LEGACY_PATH,
|
||||
GLOBAL_AGENTS_LEGACY_PATH,
|
||||
];
|
||||
@@ -1043,6 +1050,58 @@ mod tests {
|
||||
assert_eq!(ctx.source_path, Some(global_agents));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_global_agents_falls_back_to_vendor_neutral_path() {
|
||||
let workspace = tempdir().expect("workspace tempdir");
|
||||
let home = tempdir().expect("home tempdir");
|
||||
let global_dir = home.path().join(".agents");
|
||||
fs::create_dir(&global_dir).expect("mkdir .agents");
|
||||
let global_agents = global_dir.join("AGENTS.md");
|
||||
fs::write(&global_agents, "Vendor-neutral instructions").expect("write global agents");
|
||||
|
||||
let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
|
||||
|
||||
assert!(ctx.has_instructions());
|
||||
assert!(
|
||||
ctx.instructions
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("Vendor-neutral instructions")
|
||||
);
|
||||
assert_eq!(ctx.source_path, Some(global_agents));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codewhale_specific_path_wins_over_agents_path() {
|
||||
let workspace = tempdir().expect("workspace tempdir");
|
||||
let home = tempdir().expect("home tempdir");
|
||||
|
||||
let codewhale_dir = home.path().join(".codewhale");
|
||||
fs::create_dir(&codewhale_dir).expect("mkdir .codewhale");
|
||||
let codewhale_agents = codewhale_dir.join("AGENTS.md");
|
||||
fs::write(&codewhale_agents, "CodeWhale-specific instructions")
|
||||
.expect("write codewhale agents");
|
||||
|
||||
let agents_dir = home.path().join(".agents");
|
||||
fs::create_dir(&agents_dir).expect("mkdir .agents");
|
||||
fs::write(agents_dir.join("AGENTS.md"), "Vendor-neutral instructions")
|
||||
.expect("write vendor-neutral agents");
|
||||
|
||||
let ctx = load_project_context_with_parents_and_home(workspace.path(), Some(home.path()));
|
||||
|
||||
assert!(ctx.has_instructions());
|
||||
let instructions = ctx.instructions.as_ref().unwrap();
|
||||
assert!(
|
||||
instructions.contains("CodeWhale-specific instructions"),
|
||||
"CodeWhale-specific global file should win:\n{instructions}"
|
||||
);
|
||||
assert!(
|
||||
!instructions.contains("Vendor-neutral instructions"),
|
||||
"lower-priority .agents file should be skipped:\n{instructions}"
|
||||
);
|
||||
assert_eq!(ctx.source_path, Some(codewhale_agents));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_and_global_agents_merge_when_both_exist() {
|
||||
// #1157: when both `~/.deepseek/AGENTS.md` and a project AGENTS.md
|
||||
|
||||
+467
-76
@@ -2,7 +2,7 @@
|
||||
//! System prompts for different modes.
|
||||
//!
|
||||
//! Prompts are assembled from composable layers loaded at compile time:
|
||||
//! base.md → personality overlay → mode delta → approval policy
|
||||
//! tool taxonomy → base.md → personality overlay → mode delta → approval policy
|
||||
//!
|
||||
//! This keeps each concern in its own file and makes prompt tuning
|
||||
//! a single-file operation.
|
||||
@@ -106,6 +106,8 @@ fn translation_target_language_for_tag(locale_tag: &str) -> &'static str {
|
||||
"Simplified Chinese (简体中文)"
|
||||
} else if normalized.starts_with("pt") {
|
||||
"Brazilian Portuguese (Português do Brasil)"
|
||||
} else if normalized.starts_with("vi") {
|
||||
"Vietnamese (Tiếng Việt)"
|
||||
} else {
|
||||
"English"
|
||||
}
|
||||
@@ -142,7 +144,10 @@ for the current turn."
|
||||
fn render_environment_block(workspace: &Path, locale_tag: &str) -> String {
|
||||
let deepseek_version = env!("CARGO_PKG_VERSION");
|
||||
let platform = std::env::consts::OS;
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string());
|
||||
let shell = crate::shell_dispatcher::global_dispatcher()
|
||||
.kind()
|
||||
.binary()
|
||||
.to_string();
|
||||
let pwd = workspace.display();
|
||||
|
||||
format!(
|
||||
@@ -156,44 +161,88 @@ fn render_environment_block(workspace: &Path, locale_tag: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Source for an `EngineConfig.instructions` entry. Either a disk file (loaded
|
||||
/// at render time, original semantics) or an inline string (content baked into
|
||||
/// `EngineConfig`, no disk I/O at render time).
|
||||
///
|
||||
/// The inline variant is useful for embedders that compute instructions at
|
||||
/// runtime (e.g. rendering a template with workspace-specific substitutions)
|
||||
/// and don't want to stage the content to a disk file just to satisfy a path
|
||||
/// API. Staging adds two problems the inline path avoids:
|
||||
///
|
||||
/// 1. The disk file looks like editable config but gets overwritten on
|
||||
/// every launch — confusing for users browsing the install dir.
|
||||
/// 2. Multi-engine setups need per-engine paths to avoid `rehydrate`
|
||||
/// reading another session's instructions; with inline sources the
|
||||
/// content lives in the per-engine `EngineConfig` and the race
|
||||
/// surface goes away.
|
||||
///
|
||||
/// `From<PathBuf>` is provided so existing callers passing `Vec<PathBuf>` can
|
||||
/// keep working with a `.into()` upgrade at the call site.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InstructionSource {
|
||||
/// Load this file from disk at prompt-render time. Original behavior:
|
||||
/// missing files are skipped with a warning, oversized files are
|
||||
/// truncated to `INSTRUCTIONS_FILE_MAX_BYTES` with an `[…elided]`
|
||||
/// marker.
|
||||
File(PathBuf),
|
||||
/// Use the provided string directly. `name` becomes the
|
||||
/// `<instructions source="…">` attribute (typically a synthetic
|
||||
/// identifier like `embedded:my-template` or a logical path).
|
||||
Inline { name: String, content: String },
|
||||
}
|
||||
|
||||
impl From<PathBuf> for InstructionSource {
|
||||
fn from(path: PathBuf) -> Self {
|
||||
InstructionSource::File(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PathBuf> for InstructionSource {
|
||||
fn from(path: &PathBuf) -> Self {
|
||||
InstructionSource::File(path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the `instructions = [...]` config array as a single
|
||||
/// system-prompt block (#454). Each path is loaded in declared order;
|
||||
/// missing files are skipped with a tracing warning so a stale entry
|
||||
/// in `~/.deepseek/config.toml` doesn't fail the launch. Empty input
|
||||
/// (or all paths missing) returns `None` so callers append nothing.
|
||||
fn render_instructions_block(paths: &[PathBuf]) -> Option<String> {
|
||||
/// system-prompt block (#454). Each source is processed in declared order;
|
||||
/// missing `File` sources are skipped with a tracing warning so a stale entry
|
||||
/// doesn't fail the launch. Empty input (or all sources missing/empty)
|
||||
/// returns `None` so callers append nothing.
|
||||
fn render_instructions_block(sources: &[InstructionSource]) -> Option<String> {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
for path in paths {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(raw) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
for source in sources {
|
||||
let (raw_source_name, raw_content): (String, String) = match source {
|
||||
InstructionSource::File(path) => match std::fs::read_to_string(path) {
|
||||
Ok(raw) => (path.display().to_string(), raw),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "instructions",
|
||||
?err,
|
||||
?path,
|
||||
"skipping unreadable instructions file"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
|
||||
let head_end = (0..=INSTRUCTIONS_FILE_MAX_BYTES)
|
||||
.rev()
|
||||
.find(|&i| trimmed.is_char_boundary(i))
|
||||
.unwrap_or(0);
|
||||
format!("{}\n[…elided]", &trimmed[..head_end])
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
sections.push(format!(
|
||||
"<instructions source=\"{}\">\n{}\n</instructions>",
|
||||
path.display(),
|
||||
body
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
target: "instructions",
|
||||
?err,
|
||||
?path,
|
||||
"skipping unreadable instructions file"
|
||||
);
|
||||
}
|
||||
},
|
||||
InstructionSource::Inline { name, content } => (name.clone(), content.clone()),
|
||||
};
|
||||
let trimmed = raw_content.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES {
|
||||
let head_end = (0..=INSTRUCTIONS_FILE_MAX_BYTES)
|
||||
.rev()
|
||||
.find(|&i| trimmed.is_char_boundary(i))
|
||||
.unwrap_or(0);
|
||||
format!("{}\n[…elided]", &trimmed[..head_end])
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
sections.push(format!(
|
||||
"<instructions source=\"{raw_source_name}\">\n{body}\n</instructions>"
|
||||
));
|
||||
}
|
||||
if sections.is_empty() {
|
||||
None
|
||||
@@ -228,6 +277,126 @@ fn load_handoff_block(workspace: &Path) -> Option<String> {
|
||||
/// "When NOT to use" guidance, sub-agent sentinel protocol.
|
||||
pub const BASE_PROMPT: &str = include_str!("prompts/base.md");
|
||||
|
||||
// ── Embedder prompt overrides ──
|
||||
// Let an embedder replace these compile-time prompt constants at startup,
|
||||
// so brand / slimming customizations live in the embedder crate instead of
|
||||
// editing these files in-tree. Unset → the bundled constant (fully
|
||||
// backward compatible). Intended to be set once at process start, before
|
||||
// any engine spawns; later sets return the rejected override string.
|
||||
static BASE_PROMPT_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_PREAMBLE_ZH_HANS_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_PREAMBLE_JA_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_PREAMBLE_PT_BR_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_PREAMBLE_VI_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_CLOSER_ZH_HANS_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_CLOSER_JA_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_CLOSER_PT_BR_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static LOCALE_CLOSER_VI_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
static AUTHORITY_RECAP_OVERRIDE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
|
||||
/// Replace `BASE_PROMPT` for all subsequent prompt composition. First call
|
||||
/// wins; later calls return the rejected string. Set before spawning any
|
||||
/// engine.
|
||||
pub fn set_base_prompt_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&BASE_PROMPT_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Simplified-Chinese locale preamble (`## 语言要求`).
|
||||
pub fn set_locale_preamble_zh_hans_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_PREAMBLE_ZH_HANS_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Japanese locale preamble.
|
||||
pub fn set_locale_preamble_ja_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_PREAMBLE_JA_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Brazilian-Portuguese locale preamble.
|
||||
pub fn set_locale_preamble_pt_br_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_PREAMBLE_PT_BR_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Vietnamese locale preamble.
|
||||
pub fn set_locale_preamble_vi_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_PREAMBLE_VI_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Simplified-Chinese locale closer (`## 语言再次提醒`).
|
||||
pub fn set_locale_closer_zh_hans_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_CLOSER_ZH_HANS_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Japanese locale closer.
|
||||
pub fn set_locale_closer_ja_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_CLOSER_JA_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Brazilian-Portuguese locale closer.
|
||||
pub fn set_locale_closer_pt_br_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_CLOSER_PT_BR_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the Vietnamese locale closer.
|
||||
pub fn set_locale_closer_vi_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&LOCALE_CLOSER_VI_OVERRIDE, s)
|
||||
}
|
||||
|
||||
/// Replace the trailing `## Authority Recap` block.
|
||||
pub fn set_authority_recap_override(s: String) -> Result<(), String> {
|
||||
set_prompt_override(&AUTHORITY_RECAP_OVERRIDE, s)
|
||||
}
|
||||
|
||||
fn set_prompt_override(cell: &std::sync::OnceLock<String>, s: String) -> Result<(), String> {
|
||||
cell.set(s)
|
||||
}
|
||||
|
||||
fn effective_prompt_override<'a>(
|
||||
cell: &'a std::sync::OnceLock<String>,
|
||||
fallback: &'static str,
|
||||
) -> &'a str {
|
||||
cell.get().map(String::as_str).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn effective_base_prompt() -> &'static str {
|
||||
effective_prompt_override(&BASE_PROMPT_OVERRIDE, BASE_PROMPT)
|
||||
}
|
||||
|
||||
fn effective_locale_preamble_zh_hans() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_PREAMBLE_ZH_HANS_OVERRIDE, LOCALE_PREAMBLE_ZH_HANS)
|
||||
}
|
||||
|
||||
fn effective_locale_preamble_ja() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_PREAMBLE_JA_OVERRIDE, LOCALE_PREAMBLE_JA)
|
||||
}
|
||||
|
||||
fn effective_locale_preamble_pt_br() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_PREAMBLE_PT_BR_OVERRIDE, LOCALE_PREAMBLE_PT_BR)
|
||||
}
|
||||
|
||||
fn effective_locale_preamble_vi() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_PREAMBLE_VI_OVERRIDE, LOCALE_PREAMBLE_VI)
|
||||
}
|
||||
|
||||
fn effective_locale_closer_zh_hans() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_CLOSER_ZH_HANS_OVERRIDE, LOCALE_CLOSER_ZH_HANS)
|
||||
}
|
||||
|
||||
fn effective_locale_closer_ja() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_CLOSER_JA_OVERRIDE, LOCALE_CLOSER_JA)
|
||||
}
|
||||
|
||||
fn effective_locale_closer_pt_br() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_CLOSER_PT_BR_OVERRIDE, LOCALE_CLOSER_PT_BR)
|
||||
}
|
||||
|
||||
fn effective_locale_closer_vi() -> &'static str {
|
||||
effective_prompt_override(&LOCALE_CLOSER_VI_OVERRIDE, LOCALE_CLOSER_VI)
|
||||
}
|
||||
|
||||
fn effective_authority_recap() -> &'static str {
|
||||
effective_prompt_override(&AUTHORITY_RECAP_OVERRIDE, AUTHORITY_RECAP)
|
||||
}
|
||||
|
||||
/// Optional locale-native reinforcement preamble prepended to the system
|
||||
/// prompt when the user's UI locale is non-English.
|
||||
///
|
||||
@@ -293,9 +462,10 @@ pub const BASE_PROMPT: &str = include_str!("prompts/base.md");
|
||||
/// and the closer position would all carry over unchanged.
|
||||
pub(crate) fn locale_reinforcement_preamble(locale_tag: &str) -> Option<&'static str> {
|
||||
match locale_tag {
|
||||
"zh-Hans" | "zh-CN" | "zh" => Some(LOCALE_PREAMBLE_ZH_HANS),
|
||||
"ja" | "ja-JP" => Some(LOCALE_PREAMBLE_JA),
|
||||
"pt-BR" | "pt" => Some(LOCALE_PREAMBLE_PT_BR),
|
||||
"zh-Hans" | "zh-CN" | "zh" => Some(effective_locale_preamble_zh_hans()),
|
||||
"ja" | "ja-JP" => Some(effective_locale_preamble_ja()),
|
||||
"pt-BR" | "pt" => Some(effective_locale_preamble_pt_br()),
|
||||
"vi" | "vi-VN" => Some(effective_locale_preamble_vi()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -318,9 +488,10 @@ pub(crate) fn locale_reinforcement_preamble(locale_tag: &str) -> Option<&'static
|
||||
/// behavior.
|
||||
pub(crate) fn locale_reinforcement_closer(locale_tag: &str) -> Option<&'static str> {
|
||||
match locale_tag {
|
||||
"zh-Hans" | "zh-CN" | "zh" => Some(LOCALE_CLOSER_ZH_HANS),
|
||||
"ja" | "ja-JP" => Some(LOCALE_CLOSER_JA),
|
||||
"pt-BR" | "pt" => Some(LOCALE_CLOSER_PT_BR),
|
||||
"zh-Hans" | "zh-CN" | "zh" => Some(effective_locale_closer_zh_hans()),
|
||||
"ja" | "ja-JP" => Some(effective_locale_closer_ja()),
|
||||
"pt-BR" | "pt" => Some(effective_locale_closer_pt_br()),
|
||||
"vi" | "vi-VN" => Some(effective_locale_closer_vi()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -388,6 +559,24 @@ idioma. A menos que o usuário peça explicitamente a troca (por exemplo, \
|
||||
\"think in English\"), continue pensando e respondendo em português do \
|
||||
Brasil.";
|
||||
|
||||
const LOCALE_PREAMBLE_VI: &str = "## Yêu cầu ngôn ngữ\n\n\
|
||||
Bạn đang chạy trong codewhale. Cho dù ngữ cảnh tác vụ (mã nguồn, nhật ký lỗi, tên tệp) \
|
||||
là tiếng Anh, cho dù phần còn lại của system prompt là tiếng Anh, bạn đều phải sử dụng \
|
||||
tiếng Việt cho phần `reasoning_content` (suy nghĩ nội bộ) và câu trả lời cuối cùng. Các từ \
|
||||
mã nguồn, đường dẫn tệp, tên công cụ (ví dụ `read_file`, `exec_shell`), biến môi trường, \
|
||||
tham số dòng lệnh và URL giữ nguyên dạng gốc —— chỉ các văn bản giải thích bằng ngôn ngữ \
|
||||
tự nhiên mới được chuyển sang tiếng Việt.\n\n\
|
||||
Nếu người dùng chuyển sang ngôn ngữ khác trong phiên làm việc, hãy chuyển theo từ lượt tiếp theo. \
|
||||
Nếu người dùng yêu cầu rõ ràng (ví dụ \"think in English\"), hãy ghi đè quy tắc này.";
|
||||
|
||||
const LOCALE_CLOSER_VI: &str = "## Nhắc nhở ngôn ngữ một lần nữa\n\n\
|
||||
**Quan trọng: phần `reasoning_content` (suy nghĩ nội bộ) và phản hồi cuối cùng của bạn phải được viết bằng tiếng Việt.** \
|
||||
Dù bạn có đọc bao nhiêu mã nguồn tiếng Anh, nhật ký lỗi hay tài liệu trong phiên làm việc này, và dù ngữ cảnh \
|
||||
dự án có là tiếng Anh, quá trình suy nghĩ của bạn cũng không được chuyển sang tiếng Anh. Đây là yêu cầu cứng \
|
||||
ở cấp phiên làm việc —— ngôn ngữ của người dùng quyết định ngôn ngữ của bạn, không phụ thuộc vào nội dung tiếng Anh \
|
||||
tích lũy trong ngữ cảnh. Trừ khi người dùng yêu cầu rõ ràng việc chuyển đổi (ví dụ \"think in English\"), \
|
||||
hãy tiếp tục suy nghĩ và trả lời bằng tiếng Việt.";
|
||||
|
||||
/// Personality overlays — voice and tone.
|
||||
pub const CALM_PERSONALITY: &str = include_str!("prompts/personalities/calm.md");
|
||||
pub const PLAYFUL_PERSONALITY: &str = include_str!("prompts/personalities/playful.md");
|
||||
@@ -490,10 +679,11 @@ fn approval_prompt_for_mode(mode: AppMode, approval_mode: ApprovalMode) -> &'sta
|
||||
}
|
||||
|
||||
/// Compose the full system prompt in deterministic order:
|
||||
/// 1. base.md — core identity, toolbox, execution contract
|
||||
/// 2. personality — voice and tone overlay
|
||||
/// 3. mode delta — mode-specific permissions and workflow
|
||||
/// 4. approval policy — tool-approval behavior
|
||||
/// 1. tool taxonomy — compact hints generated from the eager core tools
|
||||
/// 2. base.md — core identity, toolbox, execution contract
|
||||
/// 3. personality — voice and tone overlay
|
||||
/// 4. mode delta — mode-specific permissions and workflow
|
||||
/// 5. approval policy — tool-approval behavior
|
||||
///
|
||||
/// Each layer is separated by a blank line for readability in the
|
||||
/// rendered prompt (the model sees them as contiguous sections).
|
||||
@@ -506,6 +696,51 @@ fn apply_model_template(prompt: &str, model_id: &str) -> String {
|
||||
prompt.replace("{model_id}", model_id)
|
||||
}
|
||||
|
||||
const TOOL_TAXONOMY_DISCOVERY: &[&str] = &["grep_files", "file_search"];
|
||||
const TOOL_TAXONOMY_GIT: &[&str] = &["git_status", "git_diff"];
|
||||
const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["run_tests"];
|
||||
|
||||
fn render_core_tool_taxonomy_block(mode: AppMode) -> String {
|
||||
let core_tools = core_taxonomy_tools_for_mode(mode);
|
||||
let mut sentences = Vec::new();
|
||||
|
||||
if let Some(discovery) = render_core_tool_group(TOOL_TAXONOMY_DISCOVERY, &core_tools) {
|
||||
sentences.push(format!("Use {discovery} for discovery."));
|
||||
}
|
||||
if let Some(git) = render_core_tool_group(TOOL_TAXONOMY_GIT, &core_tools) {
|
||||
sentences.push(format!("Use {git} for git inspection."));
|
||||
}
|
||||
if let Some(verification) = render_core_tool_group(TOOL_TAXONOMY_VERIFICATION, &core_tools) {
|
||||
sentences.push(format!("Use {verification} for verification."));
|
||||
}
|
||||
|
||||
debug_assert!(
|
||||
!sentences.is_empty(),
|
||||
"core tool taxonomy has no active tool groups"
|
||||
);
|
||||
format!("## Core Tool Taxonomy\n\n{}", sentences.join(" "))
|
||||
}
|
||||
|
||||
fn core_taxonomy_tools_for_mode(mode: AppMode) -> Vec<&'static str> {
|
||||
let core_tools = crate::core::engine::default_active_native_tool_names();
|
||||
core_tools
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|tool| mode != AppMode::Plan || *tool != "run_tests")
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_core_tool_group(group: &[&str], core_tools: &[&str]) -> Option<String> {
|
||||
let rendered = group
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|tool| core_tools.contains(tool))
|
||||
.map(|tool| format!("`{tool}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
(!rendered.is_empty()).then_some(rendered)
|
||||
}
|
||||
|
||||
/// Authority recap block — appended at the end of the system prompt,
|
||||
/// just before the user's first message. Uses recency bias constructively:
|
||||
/// this is the last thing the model reads before generating, so it
|
||||
@@ -541,8 +776,11 @@ pub fn compose_prompt_with_approval_and_model(
|
||||
approval_mode: ApprovalMode,
|
||||
model_id: &str,
|
||||
) -> String {
|
||||
let parts: [&str; 4] = [
|
||||
&apply_model_template(BASE_PROMPT.trim(), model_id),
|
||||
let tool_taxonomy = render_core_tool_taxonomy_block(mode);
|
||||
let base_prompt = apply_model_template(effective_base_prompt().trim(), model_id);
|
||||
let parts: [&str; 5] = [
|
||||
tool_taxonomy.as_str(),
|
||||
base_prompt.as_str(),
|
||||
personality.prompt().trim(),
|
||||
mode_prompt(mode).trim(),
|
||||
approval_prompt_for_mode(mode, approval_mode).trim(),
|
||||
@@ -630,7 +868,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
|
||||
workspace: &Path,
|
||||
working_set_summary: Option<&str>,
|
||||
skills_dir: Option<&Path>,
|
||||
instructions: Option<&[PathBuf]>,
|
||||
instructions: Option<&[InstructionSource]>,
|
||||
user_memory_block: Option<&str>,
|
||||
) -> SystemPrompt {
|
||||
system_prompt_for_mode_with_context_skills_and_session(
|
||||
@@ -656,7 +894,7 @@ pub fn system_prompt_for_mode_with_context_skills_and_session(
|
||||
workspace: &Path,
|
||||
_working_set_summary: Option<&str>,
|
||||
skills_dir: Option<&Path>,
|
||||
instructions: Option<&[PathBuf]>,
|
||||
instructions: Option<&[InstructionSource]>,
|
||||
session_context: PromptSessionContext<'_>,
|
||||
) -> SystemPrompt {
|
||||
system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
@@ -675,7 +913,7 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
workspace: &Path,
|
||||
_working_set_summary: Option<&str>,
|
||||
skills_dir: Option<&Path>,
|
||||
instructions: Option<&[PathBuf]>,
|
||||
instructions: Option<&[InstructionSource]>,
|
||||
session_context: PromptSessionContext<'_>,
|
||||
approval_mode: ApprovalMode,
|
||||
) -> SystemPrompt {
|
||||
@@ -722,17 +960,6 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
full_prompt = format!("{full_prompt}\n\n{pack}");
|
||||
}
|
||||
|
||||
// 2.25. Environment block — locale, platform, shell, pwd. All
|
||||
// four inputs are session-stable (workspace path is fixed for
|
||||
// the run; locale is loaded once by the caller; platform/shell
|
||||
// come from process env). Inserted above skills so it remains in
|
||||
// the workspace-static cache layer alongside the mode prompt and
|
||||
// project context.
|
||||
full_prompt = format!(
|
||||
"{full_prompt}\n\n{}",
|
||||
render_environment_block(workspace, session_context.locale_tag),
|
||||
);
|
||||
|
||||
// 2.3a. Translation output instruction — when enabled, instruct
|
||||
// the model to respond in the resolved session locale. Stays
|
||||
// above the volatile-content boundary because it's a per-session
|
||||
@@ -792,13 +1019,31 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
// so DeepSeek's KV prefix cache can hit on the entire system prompt
|
||||
// regardless of per-session edits to memory, goals, or instructions.
|
||||
|
||||
// 6. Environment block — platform, shell, pwd, locale.
|
||||
//
|
||||
// Placed below the volatile-content boundary. The original comment claimed
|
||||
// "workspace path is fixed for the run" → static-cacheable, which is true
|
||||
// for the terminal use case (one process owns one workspace for its
|
||||
// lifetime). It is **not** true for embedders that swap workspaces between
|
||||
// sessions (the Op::SyncSession path, multi-engine pools, IDE
|
||||
// integrations binding the engine to a per-tab workspace, etc.):
|
||||
// `pwd` drifts session-to-session and drags the entire static prefix
|
||||
// out of cache reuse. Moving the block below the volatile boundary keeps
|
||||
// mode / project / skills / context-mgmt / compact-template byte-stable
|
||||
// across sessions while preserving the pwd info the model needs for
|
||||
// `exec_shell` and structured search tools.
|
||||
full_prompt = format!(
|
||||
"{full_prompt}\n\n{}",
|
||||
render_environment_block(workspace, session_context.locale_tag),
|
||||
);
|
||||
|
||||
// 6a. Configured `instructions = [...]` files (#454). Loaded
|
||||
// and concatenated in declared order. Placed below the volatile boundary
|
||||
// because these files are workspace-scoped and may differ between
|
||||
// sessions; any edit to them would otherwise bust the prefix cache for
|
||||
// all subsequent static layers.
|
||||
if let Some(paths) = instructions
|
||||
&& let Some(block) = render_instructions_block(paths)
|
||||
if let Some(sources) = instructions
|
||||
&& let Some(block) = render_instructions_block(sources)
|
||||
{
|
||||
full_prompt = format!("{full_prompt}\n\n{block}");
|
||||
}
|
||||
@@ -833,7 +1078,8 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
// 7a. Authority recap — the final tier reminder before user messages.
|
||||
// Uses recency bias constructively: this is the last content the model
|
||||
// sees before the user's turn, reinforcing the Constitutional hierarchy.
|
||||
full_prompt = format!("{full_prompt}\n\n{AUTHORITY_RECAP}");
|
||||
let authority_recap = effective_authority_recap();
|
||||
full_prompt = format!("{full_prompt}\n\n{authority_recap}");
|
||||
|
||||
// 8. Locale-native closing reinforcement (#1118 follow-up #2). The
|
||||
// opening preamble alone wasn't enough — community feedback (the
|
||||
@@ -883,6 +1129,20 @@ mod tests {
|
||||
/// agent prompt's own discussion of the convention).
|
||||
const HANDOFF_BLOCK_MARKER: &str = "left a relay artifact at `.codewhale/handoff.md`";
|
||||
|
||||
#[test]
|
||||
fn prompt_override_storage_reports_duplicate_sets() {
|
||||
let cell = std::sync::OnceLock::new();
|
||||
|
||||
assert_eq!(effective_prompt_override(&cell, "fallback"), "fallback");
|
||||
assert!(set_prompt_override(&cell, "first".to_string()).is_ok());
|
||||
assert_eq!(effective_prompt_override(&cell, "fallback"), "first");
|
||||
assert_eq!(
|
||||
set_prompt_override(&cell, "second".to_string()),
|
||||
Err("second".to_string())
|
||||
);
|
||||
assert_eq!(effective_prompt_override(&cell, "fallback"), "first");
|
||||
}
|
||||
|
||||
fn contains_cjk(text: &str) -> bool {
|
||||
text.chars().any(|ch| {
|
||||
matches!(
|
||||
@@ -993,6 +1253,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_prompt_starts_with_core_tool_taxonomy() {
|
||||
let prompt = compose_prompt_with_approval_and_model(
|
||||
AppMode::Agent,
|
||||
Personality::Calm,
|
||||
ApprovalMode::Suggest,
|
||||
"deepseek-v4-pro",
|
||||
);
|
||||
let expected_taxonomy = render_core_tool_taxonomy_block(AppMode::Agent);
|
||||
|
||||
assert!(
|
||||
prompt.starts_with(&expected_taxonomy),
|
||||
"composed prompt should start with the compact generated tool taxonomy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_prompt_taxonomy_omits_run_tests() {
|
||||
let prompt = compose_prompt_with_approval_and_model(
|
||||
AppMode::Plan,
|
||||
Personality::Calm,
|
||||
ApprovalMode::Never,
|
||||
"deepseek-v4-pro",
|
||||
);
|
||||
let expected_taxonomy = render_core_tool_taxonomy_block(AppMode::Plan);
|
||||
|
||||
assert!(
|
||||
prompt.starts_with(&expected_taxonomy),
|
||||
"Plan prompt should start with its mode-specific tool taxonomy"
|
||||
);
|
||||
assert!(
|
||||
expected_taxonomy.contains("for discovery")
|
||||
&& expected_taxonomy.contains("for git inspection"),
|
||||
"Plan taxonomy should keep read-only discovery and git guidance"
|
||||
);
|
||||
assert!(
|
||||
!expected_taxonomy.contains("run_tests")
|
||||
&& !expected_taxonomy.contains("for verification")
|
||||
&& !expected_taxonomy.contains("Use "),
|
||||
"Plan taxonomy must not advertise unavailable verification tools: {expected_taxonomy:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_tool_taxonomy_only_references_default_active_tools() {
|
||||
let core_tools = crate::core::engine::default_active_native_tool_names();
|
||||
for tool in TOOL_TAXONOMY_DISCOVERY
|
||||
.iter()
|
||||
.chain(TOOL_TAXONOMY_GIT)
|
||||
.chain(TOOL_TAXONOMY_VERIFICATION)
|
||||
{
|
||||
assert!(
|
||||
core_tools.contains(tool),
|
||||
"tool taxonomy references {tool}, but it is not in the eager native-tool list"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authority_recap_appears_in_full_prompt() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -1338,9 +1656,20 @@ mod tests {
|
||||
"English locale must not get a pt-BR closer: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
!contains_cjk(&text),
|
||||
"English system prompt should avoid native-script priming tokens: {text:?}"
|
||||
!contains_cjk(BASE_PROMPT),
|
||||
"base prompt must not contain static CJK priming tokens"
|
||||
);
|
||||
for mode in [AppMode::Agent, AppMode::Plan, AppMode::Yolo] {
|
||||
let taxonomy = render_core_tool_taxonomy_block(mode);
|
||||
assert!(
|
||||
!contains_cjk(&taxonomy),
|
||||
"tool taxonomy must not contain static CJK priming tokens: {taxonomy:?}"
|
||||
);
|
||||
}
|
||||
// Do not assert on arbitrary CJK in the full system prompt: project
|
||||
// context may legitimately contain localized file names, README text,
|
||||
// or user-authored instructions. The locale bookend markers above are
|
||||
// the priming tokens this test is meant to guard.
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1898,13 +2227,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Tier 5 Local Law must explicitly cover `EngineConfig.instructions`
|
||||
/// files. Without this clause, embedders that inject instructions via the
|
||||
/// config field (rather than via the four hard-coded path conventions)
|
||||
/// get their files classified by path — and since those embedder-supplied
|
||||
/// paths aren't `AGENTS.md` / `CLAUDE.md` / `.codewhale/instructions.md` /
|
||||
/// `.deepseek/instructions.md`, the model defaults to treating their
|
||||
/// imperatives as Tier 7 Memory (the lowest tier per Article VII),
|
||||
/// overridable by a single user sentence.
|
||||
#[test]
|
||||
fn local_law_tier_covers_engine_config_instructions() {
|
||||
let prompt = compose_prompt(AppMode::Agent, Personality::Calm);
|
||||
assert!(
|
||||
prompt.contains("any file configured via `EngineConfig.instructions`"),
|
||||
"Tier 5 must explicitly cover EngineConfig.instructions so \
|
||||
embedder-injected instructions are not default-classified as Tier 7 Memory."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_orientation_guidance_present() {
|
||||
let prompt = compose_prompt(AppMode::Agent, Personality::Calm);
|
||||
// Workspace orientation guidance is now distributed across the
|
||||
// Constitutional preamble (project context loading) and the
|
||||
// Local Law tier (AGENTS.md/instructions.md). Verify the
|
||||
// key guidance anchors are still present.
|
||||
assert!(prompt.contains("AGENTS.md"));
|
||||
assert!(prompt.contains("Local Law"));
|
||||
assert!(
|
||||
@@ -2146,7 +2489,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn render_instructions_block_returns_none_for_empty_input() {
|
||||
assert!(super::render_instructions_block(&[]).is_none());
|
||||
let empty: &[super::InstructionSource] = &[];
|
||||
assert!(super::render_instructions_block(empty).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2156,7 +2500,7 @@ mod tests {
|
||||
std::fs::write(&real, "real content here").unwrap();
|
||||
let bogus = tmp.path().join("does-not-exist.md");
|
||||
|
||||
let block = super::render_instructions_block(&[bogus.clone(), real.clone()])
|
||||
let block = super::render_instructions_block(&[bogus.clone().into(), real.clone().into()])
|
||||
.expect("present file should produce a block");
|
||||
assert!(block.contains("real content here"));
|
||||
assert!(block.contains(&real.display().to_string()));
|
||||
@@ -2172,7 +2516,7 @@ mod tests {
|
||||
std::fs::write(&a, "ALPHA_MARKER").unwrap();
|
||||
std::fs::write(&b, "BRAVO_MARKER").unwrap();
|
||||
|
||||
let block = super::render_instructions_block(&[a, b]).expect("non-empty");
|
||||
let block = super::render_instructions_block(&[a.into(), b.into()]).expect("non-empty");
|
||||
let alpha_pos = block.find("ALPHA_MARKER").expect("alpha rendered");
|
||||
let bravo_pos = block.find("BRAVO_MARKER").expect("bravo rendered");
|
||||
assert!(
|
||||
@@ -2189,7 +2533,8 @@ mod tests {
|
||||
std::fs::write(&empty, " \n \n").unwrap();
|
||||
std::fs::write(&real, "real content").unwrap();
|
||||
|
||||
let block = super::render_instructions_block(&[empty, real]).expect("non-empty");
|
||||
let block =
|
||||
super::render_instructions_block(&[empty.into(), real.into()]).expect("non-empty");
|
||||
// Empty file produces no `<instructions>` section, only the real one.
|
||||
let count = block.matches("<instructions").count();
|
||||
assert_eq!(count, 1, "only the non-empty file should produce a section");
|
||||
@@ -2202,7 +2547,7 @@ mod tests {
|
||||
// 200 KiB of content — well above the 100 KiB cap.
|
||||
std::fs::write(&big, "X".repeat(200 * 1024)).unwrap();
|
||||
|
||||
let block = super::render_instructions_block(&[big]).expect("non-empty");
|
||||
let block = super::render_instructions_block(&[big.into()]).expect("non-empty");
|
||||
assert!(block.contains("[…elided]"), "truncation marker missing");
|
||||
// Block should be much smaller than the original file.
|
||||
assert!(
|
||||
@@ -2211,6 +2556,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `InstructionSource::Inline` bypasses disk reads — the content is used
|
||||
/// directly and `name` becomes the `<instructions source="…">` attribute.
|
||||
/// Empty / oversize handling mirrors `File` variant.
|
||||
#[test]
|
||||
fn render_instructions_block_handles_inline_source() {
|
||||
let block = super::render_instructions_block(&[super::InstructionSource::Inline {
|
||||
name: "embedded:test/template".to_string(),
|
||||
content: "INLINE_MARKER_CONTENT".to_string(),
|
||||
}])
|
||||
.expect("non-empty");
|
||||
assert!(block.contains("INLINE_MARKER_CONTENT"));
|
||||
assert!(block.contains("source=\"embedded:test/template\""));
|
||||
|
||||
// Empty inline → skipped just like empty file.
|
||||
let empty_inline = super::InstructionSource::Inline {
|
||||
name: "empty".to_string(),
|
||||
content: " ".to_string(),
|
||||
};
|
||||
assert!(super::render_instructions_block(&[empty_inline]).is_none());
|
||||
|
||||
// Oversize inline → truncated with elided marker.
|
||||
let big_inline = super::InstructionSource::Inline {
|
||||
name: "huge".to_string(),
|
||||
content: "Y".repeat(200 * 1024),
|
||||
};
|
||||
let trimmed = super::render_instructions_block(&[big_inline]).expect("non-empty");
|
||||
assert!(trimmed.contains("[…elided]"));
|
||||
|
||||
// File + Inline 混用,顺序保持。
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let file_path = tmp.path().join("file-first.md");
|
||||
std::fs::write(&file_path, "FILE_MARKER").unwrap();
|
||||
let mixed = super::render_instructions_block(&[
|
||||
file_path.into(),
|
||||
super::InstructionSource::Inline {
|
||||
name: "inline-second".to_string(),
|
||||
content: "INLINE_MARKER".to_string(),
|
||||
},
|
||||
])
|
||||
.expect("non-empty");
|
||||
let file_pos = mixed.find("FILE_MARKER").expect("file rendered");
|
||||
let inline_pos = mixed.find("INLINE_MARKER").expect("inline rendered");
|
||||
assert!(file_pos < inline_pos, "声明顺序必须保留(File then Inline)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_block_appears_in_system_prompt_when_configured() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -2218,12 +2608,13 @@ mod tests {
|
||||
let extra = workspace.join("extra-instructions.md");
|
||||
std::fs::write(&extra, "EXTRA_INSTRUCTIONS_MARKER_BODY").unwrap();
|
||||
|
||||
let extra_source: super::InstructionSource = extra.clone().into();
|
||||
let prompt = match super::system_prompt_for_mode_with_context_and_skills(
|
||||
AppMode::Agent,
|
||||
workspace,
|
||||
None,
|
||||
None,
|
||||
Some(std::slice::from_ref(&extra)),
|
||||
Some(std::slice::from_ref(&extra_source)),
|
||||
None,
|
||||
) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
|
||||
@@ -52,7 +52,7 @@ When directives from different sources conflict, resolve in this order:
|
||||
|
||||
4. **Regulations.** Composition patterns, sub-agent strategy, language rules, thinking budget. Best-practice guidance that yields to user intent when the two conflict.
|
||||
|
||||
5. **Local Law.** Project instructions — AGENTS.md, CLAUDE.md, `.codewhale/instructions.md`, `.deepseek/instructions.md`. Project-specific rules that are subordinate to all higher tiers.
|
||||
5. **Local Law.** Project instructions — AGENTS.md, CLAUDE.md, `.codewhale/instructions.md`, `.deepseek/instructions.md`, **and any file configured via `EngineConfig.instructions` (rendered as `<instructions source="…">` blocks above)**. Project-specific rules that are subordinate to all higher tiers but supersede Memory (Tier 7), even when written in imperative voice — `EngineConfig.instructions` files are declared by the embedder (not user-collected like memory), so their imperatives are Local Law, not Memory preferences.
|
||||
|
||||
6. **Evidence.** Tool output, file contents, command results, live repository state. Evidence is truth. Never contradict verified tool output. If memory and evidence conflict, evidence wins.
|
||||
|
||||
|
||||
@@ -296,6 +296,25 @@ struct DecideApprovalResponse {
|
||||
delivered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SubmitUserInputBody {
|
||||
answers: Vec<UserInputAnswerBody>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserInputAnswerBody {
|
||||
id: String,
|
||||
label: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SubmitUserInputResponse {
|
||||
ok: bool,
|
||||
input_id: String,
|
||||
delivered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RuntimeInfoResponse {
|
||||
bind_host: String,
|
||||
@@ -500,6 +519,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/threads/{id}/compact", post(compact_thread))
|
||||
.route("/v1/threads/{id}/events", get(stream_thread_events))
|
||||
.route("/v1/approvals/{approval_id}", post(decide_approval))
|
||||
.route(
|
||||
"/v1/user-input/{thread_id}/{input_id}",
|
||||
post(submit_user_input),
|
||||
)
|
||||
.route("/v1/tasks", get(list_tasks).post(create_task))
|
||||
.route("/v1/tasks/{id}", get(get_task))
|
||||
.route("/v1/tasks/{id}/cancel", post(cancel_task))
|
||||
@@ -707,7 +730,38 @@ fn session_to_detail(session: SavedSession) -> SessionDetailResponse {
|
||||
crate::models::ContentBlock::Thinking { thinking, .. } => {
|
||||
json!({ "type": "thinking", "text": thinking })
|
||||
}
|
||||
_ => json!({ "type": "other" }),
|
||||
crate::models::ContentBlock::ToolUse { id, name, input, caller } => {
|
||||
let mut obj =
|
||||
json!({ "type": "tool_use", "id": id, "name": name, "input": input });
|
||||
if let Some(caller) = caller {
|
||||
obj["caller"] = json!(caller);
|
||||
}
|
||||
obj
|
||||
}
|
||||
crate::models::ContentBlock::ToolResult { tool_use_id, content, is_error, content_blocks, .. } => {
|
||||
let mut obj = json!({ "type": "tool_result", "tool_use_id": tool_use_id });
|
||||
if let Some(cbs) = content_blocks {
|
||||
obj["content_blocks"] = json!(cbs);
|
||||
if !content.is_empty() {
|
||||
obj["content"] = json!(content);
|
||||
}
|
||||
} else {
|
||||
obj["content"] = json!(content);
|
||||
}
|
||||
if let Some(e) = is_error {
|
||||
obj["is_error"] = json!(e);
|
||||
}
|
||||
obj
|
||||
}
|
||||
crate::models::ContentBlock::ServerToolUse { id, name, input } => {
|
||||
json!({ "type": "tool_use", "id": id, "name": name, "input": input })
|
||||
}
|
||||
crate::models::ContentBlock::ToolSearchToolResult { tool_use_id, content } => {
|
||||
json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content })
|
||||
}
|
||||
crate::models::ContentBlock::CodeExecutionToolResult { tool_use_id, content } => {
|
||||
json!({ "type": "tool_result", "tool_use_id": tool_use_id, "content": content })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
@@ -984,6 +1038,34 @@ async fn decide_approval(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn submit_user_input(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path((thread_id, input_id)): Path<(String, String)>,
|
||||
Json(req): Json<SubmitUserInputBody>,
|
||||
) -> Result<Json<SubmitUserInputResponse>, ApiError> {
|
||||
use crate::tools::user_input::{UserInputAnswer, UserInputResponse};
|
||||
let answers: Vec<UserInputAnswer> = req
|
||||
.answers
|
||||
.into_iter()
|
||||
.map(|a| UserInputAnswer {
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
value: a.value,
|
||||
})
|
||||
.collect();
|
||||
let response = UserInputResponse { answers };
|
||||
let delivered = state
|
||||
.runtime_threads
|
||||
.submit_user_input(&thread_id, &input_id, response)
|
||||
.await
|
||||
.map_err(map_thread_err)?;
|
||||
Ok(Json(SubmitUserInputResponse {
|
||||
ok: true,
|
||||
input_id,
|
||||
delivered,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn runtime_info(State(state): State<RuntimeApiState>) -> Json<RuntimeInfoResponse> {
|
||||
Json(RuntimeInfoResponse {
|
||||
bind_host: state.bind_host.clone(),
|
||||
@@ -1906,6 +1988,78 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_session_with_blocks(blocks: Vec<crate::models::ContentBlock>) -> SavedSession {
|
||||
SavedSession {
|
||||
schema_version: 1,
|
||||
metadata: SessionMetadata {
|
||||
id: "session-1".to_string(),
|
||||
title: "test session".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
message_count: 1,
|
||||
total_tokens: 0,
|
||||
model: "test-model".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
mode: None,
|
||||
cost: Default::default(),
|
||||
parent_session_id: None,
|
||||
forked_from_message_count: None,
|
||||
cumulative_turn_secs: 0,
|
||||
},
|
||||
messages: vec![crate::models::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: blocks,
|
||||
}],
|
||||
system_prompt: None,
|
||||
context_references: Vec::new(),
|
||||
artifacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_detail_tool_use_preserves_caller_metadata() {
|
||||
let detail = session_to_detail(saved_session_with_blocks(vec![
|
||||
crate::models::ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "task_shell_start".to_string(),
|
||||
input: json!({ "cmd": "cargo test" }),
|
||||
caller: Some(crate::models::ToolCaller {
|
||||
caller_type: "subagent".to_string(),
|
||||
tool_id: Some("parent-tool".to_string()),
|
||||
}),
|
||||
},
|
||||
]));
|
||||
|
||||
let block = &detail.messages[0]["content"][0];
|
||||
assert_eq!(block["type"].as_str(), Some("tool_use"));
|
||||
assert_eq!(block["caller"]["type"].as_str(), Some("subagent"));
|
||||
assert_eq!(block["caller"]["tool_id"].as_str(), Some("parent-tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_detail_tool_result_keeps_fallback_content_with_blocks() {
|
||||
let detail = session_to_detail(saved_session_with_blocks(vec![
|
||||
crate::models::ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "fallback text".to_string(),
|
||||
is_error: Some(false),
|
||||
content_blocks: Some(vec![json!({
|
||||
"type": "text",
|
||||
"text": "structured text"
|
||||
})]),
|
||||
},
|
||||
]));
|
||||
|
||||
let block = &detail.messages[0]["content"][0];
|
||||
assert_eq!(block["type"].as_str(), Some("tool_result"));
|
||||
assert_eq!(block["content"].as_str(), Some("fallback text"));
|
||||
assert_eq!(
|
||||
block["content_blocks"][0]["text"].as_str(),
|
||||
Some("structured text")
|
||||
);
|
||||
assert_eq!(block["is_error"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_auth_generates_token_by_default() {
|
||||
let auth = resolve_runtime_auth(None, None, false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! TUI runtime logging. Initializes a `tracing-subscriber` that writes to a
|
||||
//! per-process file under `~/.deepseek/logs/tui-YYYY-MM-DD-PID.log`, and (on
|
||||
//! per-process file under `~/.codewhale/logs/tui-YYYY-MM-DD-PID.log`, and (on
|
||||
//! Unix) redirects the process's `stderr` fd to that same file for the lifetime
|
||||
//! of the alt-screen TUI.
|
||||
//!
|
||||
@@ -22,7 +22,7 @@
|
||||
//!
|
||||
//! Defence-in-depth:
|
||||
//! 1. A `tracing-subscriber` writes formatted logs to
|
||||
//! `~/.deepseek/logs/tui-YYYY-MM-DD-PID.log` so `tracing::warn!` /
|
||||
//! `~/.codewhale/logs/tui-YYYY-MM-DD-PID.log` so `tracing::warn!` /
|
||||
//! `tracing::error!` calls go somewhere observable instead of
|
||||
//! disappearing into the void (the TUI previously had no global
|
||||
//! subscriber, so contributors reached for `eprintln!`).
|
||||
@@ -156,13 +156,17 @@ pub fn init() -> Result<TuiLogGuard> {
|
||||
})
|
||||
}
|
||||
|
||||
fn log_directory() -> Option<PathBuf> {
|
||||
pub(crate) fn log_directory() -> Option<PathBuf> {
|
||||
let resolve = |base: PathBuf| -> Option<PathBuf> {
|
||||
let primary = base.join(".codewhale").join("logs");
|
||||
if primary.exists() {
|
||||
return Some(primary);
|
||||
}
|
||||
Some(base.join(".deepseek").join("logs"))
|
||||
let legacy = base.join(".deepseek").join("logs");
|
||||
if legacy.exists() {
|
||||
return Some(legacy);
|
||||
}
|
||||
Some(primary)
|
||||
};
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
|
||||
&& !home.as_os_str().is_empty()
|
||||
@@ -174,7 +178,7 @@ fn log_directory() -> Option<PathBuf> {
|
||||
{
|
||||
return resolve(userprofile);
|
||||
}
|
||||
dirs::home_dir().and_then(|h| resolve(h))
|
||||
dirs::home_dir().and_then(resolve)
|
||||
}
|
||||
|
||||
fn log_file_name(date: &str, pid: u32) -> String {
|
||||
@@ -270,7 +274,37 @@ mod tests {
|
||||
}
|
||||
|
||||
let resolved = log_directory().expect("log_directory should resolve");
|
||||
assert_eq!(resolved, tmp.path().join(".deepseek").join("logs"));
|
||||
assert_eq!(resolved, tmp.path().join(".codewhale").join("logs"));
|
||||
|
||||
// SAFETY: cleanup under the same lock.
|
||||
unsafe {
|
||||
match prev_home {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match prev_userprofile {
|
||||
Some(v) => std::env::set_var("USERPROFILE", v),
|
||||
None => std::env::remove_var("USERPROFILE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_directory_uses_existing_legacy_deepseek_logs() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let legacy = tmp.path().join(".deepseek").join("logs");
|
||||
fs::create_dir_all(&legacy).unwrap();
|
||||
let prev_home = std::env::var_os("HOME");
|
||||
let prev_userprofile = std::env::var_os("USERPROFILE");
|
||||
// SAFETY: serialised by lock_test_env.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", tmp.path());
|
||||
std::env::set_var("USERPROFILE", "");
|
||||
}
|
||||
|
||||
let resolved = log_directory().expect("log_directory should resolve");
|
||||
assert_eq!(resolved, legacy);
|
||||
|
||||
// SAFETY: cleanup under the same lock.
|
||||
unsafe {
|
||||
|
||||
@@ -833,6 +833,30 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_user_input(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
input_id: &str,
|
||||
response: crate::tools::user_input::UserInputResponse,
|
||||
) -> Result<bool> {
|
||||
let active = self.active.lock().await;
|
||||
let Some(state) = active.engines.get(thread_id) else {
|
||||
bail!("thread '{thread_id}' not found");
|
||||
};
|
||||
state.engine.submit_user_input(input_id, response).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn cancel_user_input(&self, thread_id: &str, input_id: &str) -> Result<bool> {
|
||||
let active = self.active.lock().await;
|
||||
let Some(state) = active.engines.get(thread_id) else {
|
||||
bail!("thread '{thread_id}' not found");
|
||||
};
|
||||
state.engine.cancel_user_input(input_id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn pending_approvals_count(&self) -> usize {
|
||||
self.pending_approvals
|
||||
@@ -1629,6 +1653,7 @@ impl RuntimeThreadManager {
|
||||
auto_approve,
|
||||
translation_enabled: false,
|
||||
show_thinking,
|
||||
allowed_tools: None,
|
||||
approval_mode: if auto_approve {
|
||||
crate::tui::approval::ApprovalMode::Auto
|
||||
} else {
|
||||
@@ -1944,7 +1969,12 @@ impl RuntimeThreadManager {
|
||||
notes_path: self.config.notes_path(),
|
||||
mcp_config_path: self.config.mcp_config_path(),
|
||||
skills_dir: self.config.skills_dir(),
|
||||
instructions: self.config.instructions_paths(),
|
||||
instructions: self
|
||||
.config
|
||||
.instructions_paths()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
project_context_pack_enabled: self.config.project_context_pack_enabled(),
|
||||
translation_enabled: false,
|
||||
show_thinking: settings.show_thinking,
|
||||
@@ -1989,6 +2019,7 @@ impl RuntimeThreadManager {
|
||||
vision_config: self.config.vision_model_config(),
|
||||
strict_tool_mode: self.config.strict_tool_mode.unwrap_or(false),
|
||||
goal_objective: None,
|
||||
allowed_tools: None,
|
||||
locale_tag: crate::localization::resolve_locale(&settings.locale)
|
||||
.tag()
|
||||
.to_string(),
|
||||
@@ -2784,6 +2815,19 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
EngineEvent::UserInputRequired { id, request } => {
|
||||
self.emit_event(
|
||||
&thread_id,
|
||||
Some(&turn_id),
|
||||
None,
|
||||
"user_input.required",
|
||||
json!({
|
||||
"id": id,
|
||||
"request": request,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::Status { message } => {
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
@@ -4172,6 +4216,7 @@ mod tests {
|
||||
id: "tool_stale".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
description: "stale approval".to_string(),
|
||||
input: serde_json::json!({}),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4245,6 +4290,7 @@ mod tests {
|
||||
id: "tool_external_allow".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
description: "external allow".to_string(),
|
||||
input: serde_json::json!({}),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4322,6 +4368,7 @@ mod tests {
|
||||
id: "tool_external_deny".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
description: "external deny".to_string(),
|
||||
input: serde_json::json!({}),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4508,6 +4555,7 @@ mod tests {
|
||||
id: "tool_remember".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
description: "remember=true".to_string(),
|
||||
input: serde_json::json!({}),
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
+117
-47
@@ -86,20 +86,28 @@ pub struct CommandSpec {
|
||||
impl CommandSpec {
|
||||
/// Create a `CommandSpec` for running a shell command via the platform shell.
|
||||
pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self {
|
||||
let dispatcher = crate::shell_dispatcher::global_dispatcher();
|
||||
|
||||
#[cfg(windows)]
|
||||
let (program, args) = {
|
||||
// Force UTF-8 output on Windows by running `chcp 65001` before the
|
||||
// actual command. Without this, subprocesses output in the system's
|
||||
// ANSI code page (e.g. GBK for Chinese locales), causing garbled
|
||||
// text in the shell output panel. See issue #982.
|
||||
let cmd = format!("chcp 65001 >NUL & {command}");
|
||||
("cmd".to_string(), vec!["/C".to_string(), cmd])
|
||||
// Force UTF-8 output. cmd.exe uses chcp; PowerShell sets the
|
||||
// console output encoding directly. See issue #982.
|
||||
let kind = dispatcher.kind();
|
||||
let cmd = if matches!(
|
||||
kind,
|
||||
crate::shell_dispatcher::ShellKind::Pwsh
|
||||
| crate::shell_dispatcher::ShellKind::WindowsPowerShell
|
||||
) {
|
||||
format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {command}")
|
||||
} else if matches!(kind, crate::shell_dispatcher::ShellKind::Cmd) {
|
||||
format!("chcp 65001 >NUL & {command}")
|
||||
} else {
|
||||
command.to_string()
|
||||
};
|
||||
dispatcher.build_command_parts(&cmd)
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let (program, args) = (
|
||||
"sh".to_string(),
|
||||
vec!["-c".to_string(), command.to_string()],
|
||||
);
|
||||
let (program, args) = dispatcher.build_command_parts(command);
|
||||
|
||||
Self {
|
||||
program,
|
||||
@@ -151,9 +159,24 @@ impl CommandSpec {
|
||||
|
||||
/// Get the original command as a single string (for display).
|
||||
pub fn display_command(&self) -> String {
|
||||
if self.program == "sh" && self.args.len() == 2 && self.args[0] == "-c" {
|
||||
if self.args.len() == 2
|
||||
&& self.args[0] == "-c"
|
||||
&& matches!(
|
||||
self.program.as_str(),
|
||||
"sh" | "bash" | "/bin/sh" | "/bin/bash" | "/usr/bin/sh" | "/usr/bin/bash"
|
||||
)
|
||||
{
|
||||
// For shell commands, show the actual command
|
||||
self.args[1].clone()
|
||||
} else if self.args.len() == 2
|
||||
&& self.args[0] == "-c"
|
||||
&& !self.program.eq_ignore_ascii_case("cmd")
|
||||
&& !self.program.eq_ignore_ascii_case("pwsh")
|
||||
&& !self.program.eq_ignore_ascii_case("pwsh.exe")
|
||||
&& !self.program.eq_ignore_ascii_case("powershell")
|
||||
&& !self.program.eq_ignore_ascii_case("powershell.exe")
|
||||
{
|
||||
self.args[1].clone()
|
||||
} else if self.program.eq_ignore_ascii_case("cmd")
|
||||
&& self.args.len() == 2
|
||||
&& self.args[0].eq_ignore_ascii_case("/C")
|
||||
@@ -164,6 +187,21 @@ impl CommandSpec {
|
||||
raw.strip_prefix("chcp 65001 >NUL & ")
|
||||
.unwrap_or(raw)
|
||||
.to_string()
|
||||
} else if {
|
||||
let program = self.program.to_ascii_lowercase();
|
||||
program == "pwsh"
|
||||
|| program == "pwsh.exe"
|
||||
|| program == "powershell"
|
||||
|| program == "powershell.exe"
|
||||
} && self.args.len() >= 3
|
||||
&& self.args[0].eq_ignore_ascii_case("-NoProfile")
|
||||
&& self.args[1].eq_ignore_ascii_case("-Command")
|
||||
{
|
||||
// Strip the PowerShell encoding prefix.
|
||||
let raw = &self.args[2];
|
||||
raw.strip_prefix("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ")
|
||||
.unwrap_or(raw)
|
||||
.to_string()
|
||||
} else {
|
||||
// For other commands, join program and args
|
||||
let mut parts = vec![self.program.clone()];
|
||||
@@ -584,35 +622,28 @@ impl SandboxManager {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn expected_shell_command(command: &str) -> Vec<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
vec![
|
||||
"cmd".to_string(),
|
||||
"/C".to_string(),
|
||||
format!("chcp 65001 >NUL & {command}"),
|
||||
]
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec!["sh".to_string(), "-c".to_string(), command.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_spec_shell() {
|
||||
let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(spec.program, "cmd");
|
||||
assert_eq!(spec.args, vec!["/C", "chcp 65001 >NUL & echo hello"]);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
assert_eq!(spec.program, "sh");
|
||||
assert_eq!(spec.args, vec!["-c", "echo hello"]);
|
||||
}
|
||||
// Program and args depend on the detected shell.
|
||||
assert!(!spec.program.is_empty(), "program must not be empty");
|
||||
assert!(!spec.args.is_empty(), "args must not be empty");
|
||||
assert_eq!(spec.display_command(), "echo hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_spec_shell_custom_posix_path_display() {
|
||||
let spec = CommandSpec {
|
||||
program: "/bin/zsh".to_string(),
|
||||
args: vec!["-c".to_string(), "echo hello".to_string()],
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
env: HashMap::new(),
|
||||
timeout: Duration::from_secs(30),
|
||||
sandbox_policy: SandboxPolicy::default(),
|
||||
justification: None,
|
||||
};
|
||||
|
||||
assert_eq!(spec.display_command(), "echo hello");
|
||||
}
|
||||
|
||||
@@ -626,19 +657,28 @@ mod tests {
|
||||
let cmd = r#"git commit -m "feat: complete sub-pages""#;
|
||||
let spec = CommandSpec::shell(cmd, PathBuf::from("/tmp"), Duration::from_secs(30));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(spec.program, "cmd");
|
||||
let dispatcher = crate::shell_dispatcher::global_dispatcher();
|
||||
assert_eq!(spec.program, dispatcher.kind().binary());
|
||||
if dispatcher.kind().is_powershell() {
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
vec!["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")]
|
||||
vec![
|
||||
dispatcher.kind().command_flag().to_string(),
|
||||
"-Command".to_string(),
|
||||
format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {cmd}")
|
||||
]
|
||||
);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
assert_eq!(spec.program, "sh");
|
||||
assert_eq!(spec.args, vec!["-c".to_string(), cmd.to_string()]);
|
||||
// The quoted message is intact in a single argv slot — `sh -c`
|
||||
} else {
|
||||
let expected = if matches!(dispatcher.kind(), crate::shell_dispatcher::ShellKind::Cmd) {
|
||||
vec!["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")]
|
||||
} else {
|
||||
vec![
|
||||
dispatcher.kind().command_flag().to_string(),
|
||||
cmd.to_string(),
|
||||
]
|
||||
};
|
||||
assert_eq!(spec.args, expected);
|
||||
// The quoted message is intact in a single argv slot — shell `-c`
|
||||
// performs POSIX tokenization, yielding the correct argv:
|
||||
// ["git","commit","-m","feat: complete sub-pages"].
|
||||
assert_eq!(spec.args.len(), 2);
|
||||
@@ -700,9 +740,39 @@ mod tests {
|
||||
.with_policy(SandboxPolicy::DangerFullAccess);
|
||||
|
||||
let env = manager.prepare(&spec);
|
||||
let dispatcher = crate::shell_dispatcher::global_dispatcher();
|
||||
|
||||
assert_eq!(env.sandbox_type, SandboxType::None);
|
||||
assert_eq!(env.command, expected_shell_command("echo test"));
|
||||
if dispatcher.kind().is_powershell() {
|
||||
assert_eq!(
|
||||
env.command,
|
||||
vec![
|
||||
dispatcher.kind().binary().to_string(),
|
||||
dispatcher.kind().command_flag().to_string(),
|
||||
"-Command".to_string(),
|
||||
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; echo test"
|
||||
.to_string(),
|
||||
]
|
||||
);
|
||||
} else if matches!(dispatcher.kind(), crate::shell_dispatcher::ShellKind::Cmd) {
|
||||
assert_eq!(
|
||||
env.command,
|
||||
vec![
|
||||
dispatcher.kind().binary().to_string(),
|
||||
"/C".to_string(),
|
||||
"chcp 65001 >NUL & echo test".to_string(),
|
||||
]
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
env.command,
|
||||
vec![
|
||||
dispatcher.kind().binary().to_string(),
|
||||
dispatcher.kind().command_flag().to_string(),
|
||||
"echo test".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
assert!(!env.is_sandboxed());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
//! Shell abstraction layer for DeepSeek TUI.
|
||||
//!
|
||||
//! Detects the user's shell at startup and provides a single entry point for
|
||||
//! all command execution. DeepSeek TUI never calls `Command::new("cmd")` (or
|
||||
//! `"sh"`, `"pwsh"`, ...) directly — it asks the [`ShellDispatcher`] to build
|
||||
//! a correctly configured [`std::process::Command`].
|
||||
//!
|
||||
//! ## Responsibilities
|
||||
//!
|
||||
//! 1. **Shell detection** — find the user's actual shell (PowerShell, pwsh,
|
||||
//! bash via WSL / Git Bash, cmd.exe fallback on Windows, /bin/sh on Unix).
|
||||
//! 2. **Quoting correctness** — each shell's argument-passing convention is
|
||||
//! respected so quoted strings survive the spawn boundary intact.
|
||||
//! 3. **Terminal state** — foreground shell execution saves and restores
|
||||
//! crossterm raw-mode so the TUI input pipeline is not broken after a
|
||||
//! child process exits (issue #1690).
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static LOG_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell kind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The concrete shell that the dispatcher will use.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ShellKind {
|
||||
/// PowerShell 7+ (`pwsh.exe`).
|
||||
Pwsh,
|
||||
/// Windows PowerShell 5.1 (`powershell.exe`).
|
||||
WindowsPowerShell,
|
||||
/// Command Prompt (`cmd.exe`).
|
||||
Cmd,
|
||||
/// Unix `/bin/sh` (or `$SHELL`-detected bash/zsh).
|
||||
Sh,
|
||||
/// Bash — detected via `$SHELL` on either Unix or WSL/Git Bash on Windows.
|
||||
Bash,
|
||||
/// Any other POSIX shell from $SHELL (zsh, fish, dash, ...).
|
||||
Custom { binary: String, flag: String },
|
||||
}
|
||||
|
||||
impl ShellKind {
|
||||
/// Binary name for the shell. Appends `.exe` on Windows where needed.
|
||||
pub fn binary(&self) -> &str {
|
||||
match self {
|
||||
#[cfg(windows)]
|
||||
ShellKind::Pwsh => "pwsh.exe",
|
||||
#[cfg(not(windows))]
|
||||
ShellKind::Pwsh => "pwsh",
|
||||
|
||||
#[cfg(windows)]
|
||||
ShellKind::WindowsPowerShell => "powershell.exe",
|
||||
#[cfg(not(windows))]
|
||||
ShellKind::WindowsPowerShell => "powershell",
|
||||
|
||||
#[cfg(windows)]
|
||||
ShellKind::Cmd => "cmd.exe",
|
||||
#[cfg(not(windows))]
|
||||
ShellKind::Cmd => "cmd",
|
||||
|
||||
ShellKind::Sh => "sh",
|
||||
ShellKind::Bash => "bash",
|
||||
ShellKind::Custom { binary, .. } => binary,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flag that tells the shell to execute the following argument as a
|
||||
/// command string.
|
||||
pub fn command_flag(&self) -> &str {
|
||||
match self {
|
||||
ShellKind::Pwsh | ShellKind::WindowsPowerShell => "-NoProfile",
|
||||
ShellKind::Cmd => "/C",
|
||||
ShellKind::Sh | ShellKind::Bash => "-c",
|
||||
ShellKind::Custom { flag, .. } => flag,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this shell needs an extra `-Command` flag after the profile
|
||||
/// flag (PowerShell-specific).
|
||||
pub fn needs_command_flag(&self) -> bool {
|
||||
matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Returns true when this is a PowerShell-family shell.
|
||||
pub fn is_powershell(&self) -> bool {
|
||||
matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Central shell abstraction. Created once at startup via
|
||||
/// [`ShellDispatcher::detect`] and then used everywhere a command needs to
|
||||
/// be spawned.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellDispatcher {
|
||||
kind: ShellKind,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ShellDispatcher {
|
||||
/// Detect the user's shell from the environment.
|
||||
///
|
||||
/// ## Detection order (Windows)
|
||||
///
|
||||
/// 1. `$env:SHELL` — WSL interop or Git Bash often set this.
|
||||
/// 2. `pwsh.exe` found on `PATH` — PowerShell 7+.
|
||||
/// 3. `powershell.exe` found on `PATH` — Windows PowerShell 5.1.
|
||||
/// 4. `cmd.exe` — always available, last resort.
|
||||
///
|
||||
/// ## Detection order (Unix)
|
||||
///
|
||||
/// 1. `$SHELL` — if it contains `bash`, use `Bash`; otherwise use the
|
||||
/// actual binary path via `Custom`.
|
||||
/// 2. `/bin/sh` fallback.
|
||||
pub fn detect() -> Self {
|
||||
let kind = Self::detect_shell();
|
||||
Self::log_startup(&kind);
|
||||
ShellDispatcher { kind }
|
||||
}
|
||||
|
||||
/// Log a shell execution line when `SHELL_DISPATCHER_LOG` is set.
|
||||
pub fn log_exec(command: &str) {
|
||||
if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
|
||||
let _ = Self::append_log_static(&path, command);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_startup(kind: &ShellKind) {
|
||||
let _lock = LOG_MUTEX.lock();
|
||||
if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
|
||||
let init_line = format!(
|
||||
"--- ShellDispatcher log started pid={} ---\n",
|
||||
std::process::id()
|
||||
);
|
||||
let _ = Self::append_log(&path, &init_line);
|
||||
let detect_line = format!("[{}] detect: {kind:?}\n", now_iso());
|
||||
let _ = Self::append_log(&path, &detect_line);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_log(path: &str, line: &str) -> std::io::Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(Path::new(path))?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.flush()
|
||||
}
|
||||
|
||||
fn append_log_static(path: &str, command: &str) -> std::io::Result<()> {
|
||||
// Resolve kind outside the lock — `global_dispatcher()` may trigger
|
||||
// `detect()` which calls `log_startup()` which also acquires the mutex.
|
||||
let kind = global_dispatcher().kind();
|
||||
let _lock = LOG_MUTEX.lock();
|
||||
let line = format!("[{}] exec via {kind:?}: {command}\n", now_iso());
|
||||
Self::append_log(path, &line)
|
||||
}
|
||||
|
||||
/// The detected shell kind.
|
||||
pub fn kind(&self) -> &ShellKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
// -- Public builders --------------------------------------------------
|
||||
|
||||
/// Build a `std::process::Command` for the given shell command string.
|
||||
pub fn build_command(&self, shell_command: &str) -> Command {
|
||||
let mut cmd = Command::new(self.kind.binary());
|
||||
|
||||
if self.kind.needs_command_flag() {
|
||||
cmd.arg(self.kind.command_flag());
|
||||
cmd.arg("-Command");
|
||||
cmd.arg(shell_command);
|
||||
} else if matches!(self.kind, ShellKind::Cmd) {
|
||||
cmd.arg(self.kind.command_flag());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.raw_arg(shell_command);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
cmd.arg(shell_command);
|
||||
}
|
||||
} else {
|
||||
cmd.arg(self.kind.command_flag());
|
||||
cmd.arg(shell_command);
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Build the program + args tuple. Useful when the caller needs to
|
||||
/// inspect or modify the args before passing them to `Command`.
|
||||
pub fn build_command_parts(&self, shell_command: &str) -> (String, Vec<String>) {
|
||||
let program = self.kind.binary().to_string();
|
||||
let args = if self.kind.needs_command_flag() {
|
||||
vec![
|
||||
self.kind.command_flag().to_string(),
|
||||
"-Command".to_string(),
|
||||
shell_command.to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
self.kind.command_flag().to_string(),
|
||||
shell_command.to_string(),
|
||||
]
|
||||
};
|
||||
(program, args)
|
||||
}
|
||||
|
||||
/// Build a `Command` from separate program + args (bypasses the shell).
|
||||
/// Used when the caller already has a resolved executable and argument
|
||||
/// vector — e.g. `ExecEnv` from the sandbox.
|
||||
#[cfg(test)]
|
||||
pub fn build_direct(&self, program: &str, args: &[String]) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
cmd.args(args);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Execute a foreground command with raw-mode save/restore.
|
||||
///
|
||||
/// A scope guard ensures raw mode is restored even if the command fails
|
||||
/// to spawn or returns early (review feedback, issue #1690).
|
||||
pub fn run_foreground(
|
||||
&self,
|
||||
shell_command: &str,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
use anyhow::Context;
|
||||
|
||||
// Log the execution
|
||||
{
|
||||
let _lock = LOG_MUTEX.lock();
|
||||
if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
|
||||
let kind = self.kind();
|
||||
let line = format!("[{}] exec via {kind:?}: {shell_command}\n", now_iso());
|
||||
let _ = Self::append_log(&path, &line);
|
||||
}
|
||||
}
|
||||
|
||||
// Disable raw mode; guard restores it only if it was already enabled.
|
||||
let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
|
||||
if raw_mode_was_enabled {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
}
|
||||
struct FgRawModeGuard {
|
||||
restore: bool,
|
||||
}
|
||||
impl Drop for FgRawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.restore {
|
||||
let _ = crossterm::terminal::enable_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = FgRawModeGuard {
|
||||
restore: raw_mode_was_enabled,
|
||||
};
|
||||
|
||||
let mut cmd = self.build_command(shell_command);
|
||||
cmd.current_dir(cwd);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.with_context(|| format!("failed to execute shell command: {shell_command}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!(
|
||||
"shell command failed (status={}): {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
// -- Detection --------------------------------------------------------
|
||||
|
||||
fn detect_shell() -> ShellKind {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 1. $env:SHELL — WSL interop or Git Bash often set this.
|
||||
if let Ok(shell) = std::env::var("SHELL") {
|
||||
let lower = shell.to_lowercase();
|
||||
if lower.contains("bash") {
|
||||
return ShellKind::Bash;
|
||||
}
|
||||
if lower.contains("pwsh") {
|
||||
return ShellKind::Pwsh;
|
||||
}
|
||||
if lower.contains("powershell") {
|
||||
return ShellKind::WindowsPowerShell;
|
||||
}
|
||||
}
|
||||
|
||||
if Self::find_exe("pwsh.exe") {
|
||||
return ShellKind::Pwsh;
|
||||
}
|
||||
if Self::find_exe("powershell.exe") {
|
||||
return ShellKind::WindowsPowerShell;
|
||||
}
|
||||
return ShellKind::Cmd;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 1. $SHELL environment variable (Unix)
|
||||
if let Ok(shell) = std::env::var("SHELL") {
|
||||
let lower = shell.to_lowercase();
|
||||
if lower.contains("bash") {
|
||||
return ShellKind::Bash;
|
||||
}
|
||||
if lower.contains("pwsh") {
|
||||
return ShellKind::Pwsh;
|
||||
}
|
||||
if lower.contains("powershell") {
|
||||
return ShellKind::WindowsPowerShell;
|
||||
}
|
||||
return ShellKind::Custom {
|
||||
binary: shell,
|
||||
flag: "-c".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
ShellKind::Sh
|
||||
}
|
||||
}
|
||||
|
||||
/// Check PATH first, then fall back to well-known install directories.
|
||||
#[cfg(windows)]
|
||||
fn find_exe(name: &str) -> bool {
|
||||
if Self::binary_on_path(name) {
|
||||
return true;
|
||||
}
|
||||
// Well-known install locations (order by preference).
|
||||
let known_dirs: &[&str] = &[
|
||||
r"C:\Program Files\PowerShell\7",
|
||||
r"C:\Windows\System32\WindowsPowerShell\v1.0",
|
||||
];
|
||||
known_dirs
|
||||
.iter()
|
||||
.any(|dir| std::path::Path::new(dir).join(name).is_file())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn binary_on_path(name: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.map(|path| {
|
||||
std::env::split_paths(&path).any(|dir| {
|
||||
let candidate = dir.join(name);
|
||||
candidate.is_file()
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers ---------------------------------------------------------------
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3f")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Global dispatcher instance, detected once at startup.
|
||||
///
|
||||
/// Any code path that needs to spawn a shell command can use
|
||||
/// `global_dispatcher()` instead of threading the dispatcher through
|
||||
/// every function signature.
|
||||
pub fn global_dispatcher() -> &'static ShellDispatcher {
|
||||
use std::sync::LazyLock;
|
||||
static DISPATCHER: LazyLock<ShellDispatcher> = LazyLock::new(ShellDispatcher::detect);
|
||||
&DISPATCHER
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shell_kind_binary_names() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(ShellKind::Pwsh.binary(), "pwsh.exe");
|
||||
assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell.exe");
|
||||
assert_eq!(ShellKind::Cmd.binary(), "cmd.exe");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
assert_eq!(ShellKind::Pwsh.binary(), "pwsh");
|
||||
assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell");
|
||||
assert_eq!(ShellKind::Cmd.binary(), "cmd");
|
||||
}
|
||||
assert_eq!(ShellKind::Sh.binary(), "sh");
|
||||
assert_eq!(ShellKind::Bash.binary(), "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_some_shell() {
|
||||
let dispatcher = global_dispatcher();
|
||||
let _kind = dispatcher.kind();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_build_command_includes_no_profile_and_command_flags() {
|
||||
let dispatcher = ShellDispatcher {
|
||||
kind: ShellKind::Pwsh,
|
||||
};
|
||||
let cmd = dispatcher.build_command("echo hello");
|
||||
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
|
||||
assert!(args.contains(&"-NoProfile"));
|
||||
assert!(args.contains(&"-Command"));
|
||||
assert!(args.contains(&"echo hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_build_command_uses_c_flag() {
|
||||
let dispatcher = ShellDispatcher {
|
||||
kind: ShellKind::Cmd,
|
||||
};
|
||||
let cmd = dispatcher.build_command("echo hello");
|
||||
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
|
||||
assert!(args.contains(&"/C"));
|
||||
assert!(args.contains(&"echo hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sh_build_command_uses_dash_c() {
|
||||
let dispatcher = ShellDispatcher {
|
||||
kind: ShellKind::Sh,
|
||||
};
|
||||
let cmd = dispatcher.build_command("echo hello");
|
||||
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
|
||||
assert!(args.contains(&"-c"));
|
||||
assert!(args.contains(&"echo hello"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn build_direct_preserves_args() {
|
||||
let dispatcher = ShellDispatcher {
|
||||
kind: ShellKind::Cmd,
|
||||
};
|
||||
let args = vec!["-m".to_string(), "commit message".to_string()];
|
||||
let cmd = dispatcher.build_direct("git", &args);
|
||||
let cmd_args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
|
||||
assert_eq!(cmd_args, vec!["-m", "commit message"]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn powershell_flags_are_correct() {
|
||||
assert!(ShellKind::Pwsh.needs_command_flag());
|
||||
assert!(ShellKind::WindowsPowerShell.needs_command_flag());
|
||||
assert!(!ShellKind::Cmd.needs_command_flag());
|
||||
assert!(!ShellKind::Sh.needs_command_flag());
|
||||
assert!(!ShellKind::Bash.needs_command_flag());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn is_powershell_detects_both_variants() {
|
||||
assert!(ShellKind::Pwsh.is_powershell());
|
||||
assert!(ShellKind::WindowsPowerShell.is_powershell());
|
||||
assert!(!ShellKind::Cmd.is_powershell());
|
||||
assert!(!ShellKind::Sh.is_powershell());
|
||||
assert!(!ShellKind::Bash.is_powershell());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn build_command_quotes_spaces_for_cmd() {
|
||||
let dispatcher = ShellDispatcher {
|
||||
kind: ShellKind::Cmd,
|
||||
};
|
||||
let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
|
||||
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(args[0], "/C");
|
||||
assert!(args[1].contains("msg with spaces"));
|
||||
assert!(args[1].starts_with("git "));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn build_command_quotes_spaces_for_pwsh() {
|
||||
let dispatcher = ShellDispatcher {
|
||||
kind: ShellKind::Pwsh,
|
||||
};
|
||||
let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
|
||||
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
|
||||
assert_eq!(args.len(), 3);
|
||||
assert_eq!(args[0], "-NoProfile");
|
||||
assert_eq!(args[1], "-Command");
|
||||
assert!(args[2].contains("msg with spaces"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn build_direct_handles_empty_args() {
|
||||
let dispatcher = ShellDispatcher {
|
||||
kind: ShellKind::Sh,
|
||||
};
|
||||
let cmd = dispatcher.build_direct("echo", &[]);
|
||||
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn find_exe_finds_cmd_on_path() {
|
||||
// cmd.exe is always on PATH on Windows.
|
||||
assert!(ShellDispatcher::find_exe("cmd.exe"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn find_exe_rejects_nonexistent_binary() {
|
||||
assert!(!ShellDispatcher::find_exe("nonexistent_xyz_12345.exe"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn find_exe_falls_back_to_known_dirs() {
|
||||
// Verify the known-dirs fallback path actually exists on this system.
|
||||
let ps_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
|
||||
if std::path::Path::new(ps_path).is_file() {
|
||||
// The fallback directory exists — find_exe should locate it.
|
||||
assert!(ShellDispatcher::find_exe("powershell.exe"));
|
||||
} else {
|
||||
eprintln!("Skipping: {ps_path} not present on this system");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_shell_uses_provided_binary_and_flag() {
|
||||
let kind = ShellKind::Custom {
|
||||
binary: "/bin/zsh".to_string(),
|
||||
flag: "-c".to_string(),
|
||||
};
|
||||
assert_eq!(kind.binary(), "/bin/zsh");
|
||||
assert_eq!(kind.command_flag(), "-c");
|
||||
}
|
||||
}
|
||||
@@ -389,8 +389,14 @@ fn validate_dns_resolved_ip(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allow the resolved IP past the restricted-IP block if either:
|
||||
// * it falls inside a configured fake-IP placeholder range (a TUN /
|
||||
// transparent-proxy setup in `fake-ip` mode resolves every host into a
|
||||
// reserved range such as `198.18.0.0/15`), or
|
||||
// * the host is on the explicitly-trusted proxy list.
|
||||
// Real private/loopback/link-local/metadata IPs match neither and stay blocked.
|
||||
if let Some(decider) = decider
|
||||
&& decider.trusts_proxy_fakeip_host(host)
|
||||
&& (decider.is_trusted_fakeip_addr(ip) || decider.trusts_proxy_fakeip_host(host))
|
||||
{
|
||||
decider.record_trusted_proxy_fakeip_allow(host, "fetch_url");
|
||||
return Ok(());
|
||||
|
||||
@@ -256,6 +256,49 @@ fn parse_pages_arg(spec: &str) -> Option<(u32, u32)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean PDF-extracted text for TUI display: collapse consecutive blank
|
||||
/// lines (more than 1 becomes 1), replace NUL bytes with U+FFFD, replace
|
||||
/// non-breaking spaces with regular spaces, and trim trailing whitespace
|
||||
/// on each line. Produces output that won't clutter the transcript with
|
||||
/// vertical gaps or invisible control characters.
|
||||
fn clean_pdf_text(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut blank_run = 0usize;
|
||||
let mut any_content = false;
|
||||
for line in raw.lines() {
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
blank_run = blank_run.saturating_add(1);
|
||||
if blank_run <= 1 {
|
||||
out.push('\n');
|
||||
}
|
||||
} else {
|
||||
blank_run = 0;
|
||||
any_content = true;
|
||||
// Push cleaned characters directly — avoids a per-line
|
||||
// temporary String allocation.
|
||||
for c in trimmed.chars() {
|
||||
match c {
|
||||
'\0' => out.push('\u{FFFD}'),
|
||||
'\u{A0}' => out.push(' '),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
// Trim leading blank lines only — don't use str::trim() which
|
||||
// would also strip intentional indentation (e.g. centred titles).
|
||||
if any_content {
|
||||
let start = out.find(|c: char| c != '\n').unwrap_or(0);
|
||||
// Walk back from end to find the last non-newline character.
|
||||
let end = out.rfind(|c: char| c != '\n').map_or(out.len(), |i| i + 1);
|
||||
out[start..end].to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_pdf(path: &Path, pages: Option<&str>) -> Result<ToolResult, ToolError> {
|
||||
// Validate the `pages` spec once, up front, so both extractor paths
|
||||
// surface the same error shape on bad input.
|
||||
@@ -325,7 +368,7 @@ fn read_pdf_via_pdf_extract(
|
||||
))
|
||||
})?
|
||||
};
|
||||
Ok(ToolResult::success(text))
|
||||
Ok(ToolResult::success(clean_pdf_text(&text)))
|
||||
}
|
||||
|
||||
fn read_pdf_via_pdftotext(
|
||||
@@ -382,7 +425,7 @@ fn read_pdf_via_pdftotext(
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(ToolResult::success(text))
|
||||
Ok(ToolResult::success(clean_pdf_text(&text)))
|
||||
}
|
||||
|
||||
// === WriteFileTool ===
|
||||
@@ -1226,6 +1269,43 @@ mod tests {
|
||||
std::path::Path::new(SAMPLE_PDF_PATH).exists()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_pdf_text_collapses_consecutive_blank_lines() {
|
||||
let raw = "line1\n\n\n\n\nline2\n\n\nline3";
|
||||
let cleaned = super::clean_pdf_text(raw);
|
||||
assert_eq!(cleaned, "line1\n\nline2\n\nline3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_pdf_text_replaces_nul_bytes_with_replacement_char() {
|
||||
let raw = "hello\0world";
|
||||
let cleaned = super::clean_pdf_text(raw);
|
||||
assert!(!cleaned.contains('\0'));
|
||||
assert!(cleaned.contains('\u{FFFD}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_pdf_text_replaces_non_breaking_spaces() {
|
||||
let raw = "hello\u{A0}world";
|
||||
let cleaned = super::clean_pdf_text(raw);
|
||||
assert!(!cleaned.contains('\u{A0}'));
|
||||
assert_eq!(cleaned, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_pdf_text_trims_trailing_whitespace() {
|
||||
let raw = "hello ";
|
||||
let cleaned = super::clean_pdf_text(raw);
|
||||
assert_eq!(cleaned, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_pdf_text_preserves_leading_indentation() {
|
||||
let raw = " indented line\nregular line";
|
||||
let cleaned = super::clean_pdf_text(raw);
|
||||
assert_eq!(cleaned, " indented line\nregular line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_pdf_via_pdf_extract_finds_known_title() {
|
||||
// Skip when the fixture isn't checked out (sparse clones, shallow
|
||||
|
||||
@@ -542,6 +542,10 @@ impl ToolRegistryBuilder {
|
||||
}
|
||||
|
||||
/// Include durable task, gate, PR-attempt, GitHub, and automation tools.
|
||||
///
|
||||
/// Shell-related task tools (`task_shell_start`, `task_shell_wait`) are
|
||||
/// *not* included here — use [`with_runtime_task_shell_tools`] to register
|
||||
/// them when `allow_shell` is true.
|
||||
#[must_use]
|
||||
pub fn with_runtime_task_tools(self) -> Self {
|
||||
use super::automation::{
|
||||
@@ -555,7 +559,6 @@ impl ToolRegistryBuilder {
|
||||
use super::tasks::{
|
||||
PrAttemptListTool, PrAttemptPreflightTool, PrAttemptReadTool, PrAttemptRecordTool,
|
||||
TaskCancelTool, TaskCreateTool, TaskGateRunTool, TaskListTool, TaskReadTool,
|
||||
TaskShellStartTool, TaskShellWaitTool,
|
||||
};
|
||||
|
||||
self.with_tool(Arc::new(TaskCreateTool))
|
||||
@@ -563,8 +566,6 @@ impl ToolRegistryBuilder {
|
||||
.with_tool(Arc::new(TaskReadTool))
|
||||
.with_tool(Arc::new(TaskCancelTool))
|
||||
.with_tool(Arc::new(TaskGateRunTool))
|
||||
.with_tool(Arc::new(TaskShellStartTool))
|
||||
.with_tool(Arc::new(TaskShellWaitTool))
|
||||
.with_tool(Arc::new(GithubIssueContextTool))
|
||||
.with_tool(Arc::new(GithubPrContextTool))
|
||||
.with_tool(Arc::new(PrAttemptRecordTool))
|
||||
@@ -584,6 +585,18 @@ impl ToolRegistryBuilder {
|
||||
.with_tool(Arc::new(GithubClosePrTool))
|
||||
}
|
||||
|
||||
/// Include shell-related task tools (`task_shell_start`, `task_shell_wait`).
|
||||
///
|
||||
/// These are gated behind `allow_shell` because `task_shell_start`
|
||||
/// delegates directly to `ExecShellTool`, providing the same shell
|
||||
/// execution capability as `exec_shell`.
|
||||
#[must_use]
|
||||
pub fn with_runtime_task_shell_tools(self) -> Self {
|
||||
use super::tasks::{TaskShellStartTool, TaskShellWaitTool};
|
||||
self.with_tool(Arc::new(TaskShellStartTool))
|
||||
.with_tool(Arc::new(TaskShellWaitTool))
|
||||
}
|
||||
|
||||
/// Include only read-only durable task, PR-attempt, GitHub, and automation
|
||||
/// inspection tools. Plan mode uses this surface so it can observe state
|
||||
/// without starting work, changing remotes, or mutating automation config.
|
||||
@@ -786,7 +799,7 @@ impl ToolRegistryBuilder {
|
||||
.with_image_ocr_tools();
|
||||
|
||||
if allow_shell {
|
||||
builder.with_shell_tools()
|
||||
builder.with_shell_tools().with_runtime_task_shell_tools()
|
||||
} else {
|
||||
builder
|
||||
}
|
||||
@@ -1379,4 +1392,48 @@ mod tests {
|
||||
|
||||
assert!(registry.contains("finance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_tools_with_allow_shell_false_excludes_shell_tools() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
|
||||
let registry = ToolRegistryBuilder::new()
|
||||
.with_agent_tools(false)
|
||||
.build(ctx);
|
||||
|
||||
assert!(
|
||||
!registry.contains("exec_shell"),
|
||||
"exec_shell should be excluded when allow_shell is false"
|
||||
);
|
||||
assert!(
|
||||
!registry.contains("task_shell_start"),
|
||||
"task_shell_start should be excluded when allow_shell is false"
|
||||
);
|
||||
assert!(
|
||||
!registry.contains("task_shell_wait"),
|
||||
"task_shell_wait should be excluded when allow_shell is false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_tools_with_allow_shell_true_includes_shell_tools() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
|
||||
let registry = ToolRegistryBuilder::new().with_agent_tools(true).build(ctx);
|
||||
|
||||
assert!(
|
||||
registry.contains("exec_shell"),
|
||||
"exec_shell should be included when allow_shell is true"
|
||||
);
|
||||
assert!(
|
||||
registry.contains("task_shell_start"),
|
||||
"task_shell_start should be included when allow_shell is true"
|
||||
);
|
||||
assert!(
|
||||
registry.contains("task_shell_wait"),
|
||||
"task_shell_wait should be included when allow_shell is true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,6 +731,9 @@ impl ShellManager {
|
||||
policy_override: Option<ExecutionSandboxPolicy>,
|
||||
extra_env: HashMap<String, String>,
|
||||
) -> Result<ShellResult> {
|
||||
// Log execution via ShellDispatcher when SHELL_DISPATCHER_LOG is set.
|
||||
crate::shell_dispatcher::ShellDispatcher::log_exec(command);
|
||||
|
||||
let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
|
||||
|
||||
// Clamp timeout to max 10 minutes (600000ms)
|
||||
@@ -794,6 +797,8 @@ impl ShellManager {
|
||||
policy_override: Option<ExecutionSandboxPolicy>,
|
||||
extra_env: HashMap<String, String>,
|
||||
) -> Result<ShellResult> {
|
||||
crate::shell_dispatcher::ShellDispatcher::log_exec(command);
|
||||
|
||||
let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
|
||||
|
||||
let timeout_ms = timeout_ms.clamp(1000, 600_000);
|
||||
@@ -841,6 +846,26 @@ impl ShellManager {
|
||||
|
||||
child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
|
||||
|
||||
// Disable raw mode before spawn; restore only if raw mode was active
|
||||
// on entry (issue #1690).
|
||||
let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
|
||||
if raw_mode_was_enabled {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
}
|
||||
struct SyncRawModeGuard {
|
||||
restore: bool,
|
||||
}
|
||||
impl Drop for SyncRawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.restore {
|
||||
let _ = crossterm::terminal::enable_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = SyncRawModeGuard {
|
||||
restore: raw_mode_was_enabled,
|
||||
};
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("Failed to execute: {original_command}"))?;
|
||||
@@ -975,6 +1000,26 @@ impl ShellManager {
|
||||
}
|
||||
install_parent_death_signal(&mut cmd);
|
||||
|
||||
// Disable raw mode before spawn; restore only if raw mode was active
|
||||
// on entry (issue #1690).
|
||||
let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
|
||||
if raw_mode_was_enabled {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
}
|
||||
struct InteractiveRawModeGuard {
|
||||
restore: bool,
|
||||
}
|
||||
impl Drop for InteractiveRawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.restore {
|
||||
let _ = crossterm::terminal::enable_raw_mode();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _guard = InteractiveRawModeGuard {
|
||||
restore: raw_mode_was_enabled,
|
||||
};
|
||||
|
||||
child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
|
||||
|
||||
let mut child = cmd
|
||||
|
||||
@@ -21,6 +21,10 @@ fn echo_command(message: &str) -> String {
|
||||
}
|
||||
|
||||
fn sleep_command(seconds: u64) -> String {
|
||||
let dispatcher = crate::shell_dispatcher::global_dispatcher();
|
||||
if dispatcher.kind().is_powershell() {
|
||||
return format!("Start-Sleep -Seconds {seconds}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let ping_count = seconds.saturating_add(1);
|
||||
@@ -33,6 +37,10 @@ fn sleep_command(seconds: u64) -> String {
|
||||
}
|
||||
|
||||
fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
|
||||
let dispatcher = crate::shell_dispatcher::global_dispatcher();
|
||||
if dispatcher.kind().is_powershell() {
|
||||
return format!("Start-Sleep -Seconds {seconds}; echo {message}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let ping_count = seconds.saturating_add(1);
|
||||
@@ -45,6 +53,10 @@ fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
|
||||
}
|
||||
|
||||
fn echo_stdin_command() -> String {
|
||||
let dispatcher = crate::shell_dispatcher::global_dispatcher();
|
||||
if dispatcher.kind().is_powershell() {
|
||||
return "[Console]::In.ReadToEnd()".to_string();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"more".to_string()
|
||||
@@ -910,41 +922,48 @@ fn issue_1691_quoted_commit_message_round_trips() {
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// `sh -c <cmd>`: the whole command (with quotes) is a single argv
|
||||
// entry. `sh` then POSIX-tokenizes it → correct git argv. We never
|
||||
// split the command string ourselves.
|
||||
assert_eq!(spec.program, "sh");
|
||||
assert_eq!(spec.args, ["-c".to_string(), cmd.to_string()]);
|
||||
assert_eq!(spec.args.len(), 2);
|
||||
|
||||
// push_shell_args is a faithful pass-through on Unix.
|
||||
let mut built = Command::new(&spec.program);
|
||||
push_shell_args(&mut built, &spec.program, &spec.args);
|
||||
let got: Vec<String> = built
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(got, ["-c".to_string(), cmd.to_string()]);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// `cmd /C <payload>`: payload carries the quotes verbatim. The fix
|
||||
// routes /C + payload through `raw_arg` so `cmd.exe` (not MSVCRT)
|
||||
// parses it, matching what a terminal does.
|
||||
assert_eq!(spec.program, "cmd");
|
||||
let dispatcher = crate::shell_dispatcher::global_dispatcher();
|
||||
// The whole command (with quotes) is a single argv entry. The actual
|
||||
// shell binary can vary by platform, but the payload itself must stay
|
||||
// intact in one shell arg. We never split the command string ourselves.
|
||||
assert_eq!(spec.program, dispatcher.kind().binary());
|
||||
if dispatcher.kind().is_powershell() {
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
[
|
||||
dispatcher.kind().command_flag().to_string(),
|
||||
"-Command".to_string(),
|
||||
format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {cmd}")
|
||||
]
|
||||
);
|
||||
} else if matches!(dispatcher.kind(), crate::shell_dispatcher::ShellKind::Cmd) {
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")]
|
||||
);
|
||||
let mut built = Command::new(&spec.program);
|
||||
push_shell_args(&mut built, &spec.program, &spec.args);
|
||||
let got: Vec<String> = built
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(got, spec.args);
|
||||
} else {
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
[
|
||||
dispatcher.kind().command_flag().to_string(),
|
||||
cmd.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
spec.args.len(),
|
||||
if dispatcher.kind().is_powershell() {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
}
|
||||
);
|
||||
|
||||
let mut built = Command::new(&spec.program);
|
||||
push_shell_args(&mut built, &spec.program, &spec.args);
|
||||
let got: Vec<String> = built
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(got, spec.args);
|
||||
}
|
||||
|
||||
@@ -4988,7 +4988,9 @@ const SUBAGENT_OUTPUT_FORMAT: &str = include_str!("../../prompts/subagent_output
|
||||
const GENERAL_AGENT_INTRO: &str = concat!(
|
||||
"You are a general-purpose sub-agent spawned to handle a specific task autonomously.\n",
|
||||
"Stay inside the assigned scope; put adjacent work under RISKS/BLOCKERS.\n",
|
||||
"Plan multi-step work with `checklist_write`; add `update_plan` for complex strategy.\n\n"
|
||||
"Plan multi-step work with `checklist_write`; add `update_plan` for complex strategy.\n",
|
||||
"**Stop quickly on failure**: if the same tool call fails 2 times in a row, stop retrying and return what you have so far with a one-line note explaining what's missing. Do not loop on impossible queries (e.g. external API unreachable, rate-limited, or returning empty).\n",
|
||||
"**Bounded effort**: prefer one focused attempt over many speculative retries. If you cannot complete the task with available data within 3-5 tool calls, return your current partial findings — the parent agent can compensate with its own knowledge.\n\n"
|
||||
);
|
||||
|
||||
const EXPLORE_AGENT_INTRO: &str = concat!(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (page open, click, screenshot) use a direct URL approach instead.
|
||||
//!
|
||||
//! Set `[search]` in config.toml to switch providers:
|
||||
//! provider = "duckduckgo" # or tavily/bocha/metaso
|
||||
//! provider = "duckduckgo" # or tavily/bocha/metaso/baidu
|
||||
//! api_key = "tvly-..."
|
||||
|
||||
use super::spec::{
|
||||
@@ -1059,6 +1059,14 @@ fn normalize_url(href: &str) -> String {
|
||||
}
|
||||
|
||||
fn normalize_bing_url(href: &str) -> String {
|
||||
// Bing wraps every SERP result URL in a `/ck/a?...&u=<base64>` click-tracking
|
||||
// redirect, and in the raw HTML the separators are `&` entities. Without
|
||||
// decoding entities first, `extract_query_param` looks for `u` but the actual
|
||||
// key is `amp;u`, so the real URL is never recovered: every result collapses to
|
||||
// a `bing.com` root domain, which the spam heuristic then rejects — yielding
|
||||
// zero results for the default Bing backend. Decode entities before parsing.
|
||||
let href = decode_html_entities(href);
|
||||
let href = href.as_str();
|
||||
if let Some(encoded) = extract_query_param(href, "u") {
|
||||
let decoded = percent_decode(&encoded);
|
||||
let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
|
||||
@@ -1185,12 +1193,23 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
ERROR_BODY_PREVIEW_BYTES, WebSearchEntry, WebSearchTool, baidu_search_payload,
|
||||
decode_html_entities, extract_search_query, is_likely_spam_results,
|
||||
decode_html_entities, extract_search_query, is_likely_spam_results, normalize_bing_url,
|
||||
optional_search_max_results, parse_baidu_results, root_domain, sanitize_error_body,
|
||||
truncate_error_body,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// Regression guard: Bing /ck/a redirect hrefs are HTML-entity-encoded
|
||||
// (`&`). normalize_bing_url must decode entities before extracting the
|
||||
// `u=` base64 payload, otherwise the real URL is never recovered and the
|
||||
// result's root domain collapses to bing.com (then dropped as spam → 0
|
||||
// results for the default Bing backend).
|
||||
#[test]
|
||||
fn bing_ckurl_with_html_entities_decodes_real_url() {
|
||||
let href = "https://www.bing.com/ck/a?!&&p=abc&u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&ntb=1";
|
||||
assert_eq!(normalize_bing_url(href), "https://rust-lang.org/");
|
||||
}
|
||||
|
||||
fn entry(url: &str) -> WebSearchEntry {
|
||||
WebSearchEntry {
|
||||
title: "x".into(),
|
||||
|
||||
+82
-17
@@ -86,6 +86,9 @@ pub(crate) fn looks_like_slash_command_input(input: &str) -> bool {
|
||||
let Some(rest) = input.trim_start().strip_prefix('/') else {
|
||||
return false;
|
||||
};
|
||||
if rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
|
||||
return false;
|
||||
}
|
||||
let Some(command) = rest.split_whitespace().next() else {
|
||||
return rest.is_empty();
|
||||
};
|
||||
@@ -811,7 +814,19 @@ pub struct TuiOptions {
|
||||
/// Used by `deepseek pr <N>` (#451) to drop the model into a
|
||||
/// session with the PR context already typed — the user can edit
|
||||
/// before sending or hit Enter to fire as-is.
|
||||
pub initial_input: Option<String>,
|
||||
pub initial_input: Option<InitialInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InitialInput {
|
||||
/// Pre-populate the composer and wait for the user to press Enter.
|
||||
///
|
||||
/// Used by `codewhale pr <N>` (#451) to drop the model into a session
|
||||
/// with the PR context already typed so the user can edit before sending.
|
||||
Prefill(String),
|
||||
/// Pre-populate the composer, submit it once startup is ready, then keep
|
||||
/// the interactive session open for follow-up messages (#2370).
|
||||
Submit(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -1093,6 +1108,9 @@ pub struct App {
|
||||
pub goal: GoalState,
|
||||
/// Session sub-state (cost, tokens, telemetry).
|
||||
pub session: SessionState,
|
||||
/// Active tool restriction from custom slash command frontmatter.
|
||||
/// `None` means the current turn may use the normal tool set.
|
||||
pub active_allowed_tools: Option<Vec<String>>,
|
||||
pub history: Vec<HistoryCell>,
|
||||
pub history_version: u64,
|
||||
/// Per-cell revision counter, kept in lockstep with `history`.
|
||||
@@ -1114,10 +1132,6 @@ pub struct App {
|
||||
pub status_toasts: VecDeque<StatusToast>,
|
||||
/// Sticky status toast used for important warnings/errors.
|
||||
pub sticky_status: Option<StatusToast>,
|
||||
/// Version-update hint shown in the footer when a newer release
|
||||
/// is available. Set by a background GitHub API check after app
|
||||
/// startup; `None` until the check completes or if up-to-date.
|
||||
pub version_hint: Option<String>,
|
||||
/// Last status text already promoted from `status_message` into toast state.
|
||||
pub last_status_message_seen: Option<String>,
|
||||
pub model: String,
|
||||
@@ -1441,6 +1455,8 @@ pub struct App {
|
||||
/// Most recent user prompt accepted for an active engine turn. Ctrl+C can
|
||||
/// restore this into an empty composer after cancelling that turn.
|
||||
pub last_submitted_prompt: Option<String>,
|
||||
/// Startup prompt should be submitted automatically after the engine is ready.
|
||||
pub auto_submit_initial_input: bool,
|
||||
/// Two-tap quit confirmation. When set, a prior Ctrl+C in idle state has
|
||||
/// armed the quit shortcut; a second Ctrl+C before this `Instant` exits
|
||||
/// the app, while expiry silently re-arms the prompt for next time.
|
||||
@@ -1466,6 +1482,10 @@ pub struct App {
|
||||
pub prefix_stability_pct: Option<u32>,
|
||||
/// Description of the last prefix change, if any.
|
||||
pub last_prefix_change_desc: Option<String>,
|
||||
/// Current pinned prefix combined hash (SHA-256, 64 hex chars).
|
||||
/// Updated per-turn via PrefixCacheChange events; surfaced by
|
||||
/// `/cache stats` for cache-hit debugging.
|
||||
pub last_pinned_prefix_hash: Option<String>,
|
||||
|
||||
/// Active cycle configuration (token threshold, briefing cap, per-model
|
||||
/// overrides). Loaded from config and forwarded to the engine.
|
||||
@@ -1475,6 +1495,10 @@ pub struct App {
|
||||
/// Transcript cells the user has collapsed (hidden from view).
|
||||
/// Stores **original** virtual cell indices (pre-filtering).
|
||||
pub collapsed_cells: HashSet<usize>,
|
||||
/// Thinking cells the user has folded (showing summary instead of full
|
||||
/// content). Stores **original** virtual cell indices. Toggled by Space
|
||||
/// when the composer is empty and the cursor is on a thinking cell.
|
||||
pub folded_thinking: HashSet<usize>,
|
||||
/// Mapping from filtered cell index → original virtual index.
|
||||
/// Populated during `ChatWidget::new` by filtering out collapsed cells.
|
||||
/// Used by `build_context_menu_entries` to convert line-meta indices
|
||||
@@ -1624,6 +1648,7 @@ impl App {
|
||||
self.session.last_prompt_cache_miss_tokens = None;
|
||||
self.session.last_reasoning_replay_tokens = None;
|
||||
self.session.turn_cache_history.clear();
|
||||
self.last_pinned_prefix_hash = None;
|
||||
}
|
||||
|
||||
pub fn tr(&self, id: MessageId) -> &'static str {
|
||||
@@ -1795,17 +1820,22 @@ impl App {
|
||||
let cached_skills = Self::discover_cached_skills(&workspace, &skills_dir);
|
||||
|
||||
let input_history = crate::composer_history::load_history();
|
||||
let (initial_input_text, initial_input_cursor) = match initial_input {
|
||||
// #451: pre-populate the composer when invoked via
|
||||
// `deepseek pr <N>` (or any future caller that wants to
|
||||
// drop the model into a session with context already
|
||||
// typed). Cursor lands at the end so Enter sends as-is.
|
||||
Some(text) if !text.is_empty() => {
|
||||
let cursor = text.len();
|
||||
(text, cursor)
|
||||
}
|
||||
_ => (String::new(), 0),
|
||||
};
|
||||
let (initial_input_text, initial_input_cursor, auto_submit_initial_input) =
|
||||
match initial_input {
|
||||
// #451: pre-populate the composer when invoked via
|
||||
// `deepseek pr <N>` (or any future caller that wants to
|
||||
// drop the model into a session with context already
|
||||
// typed). Cursor lands at the end so Enter sends as-is.
|
||||
Some(InitialInput::Prefill(text)) if !text.is_empty() => {
|
||||
let cursor = text.chars().count();
|
||||
(text, cursor, false)
|
||||
}
|
||||
Some(InitialInput::Submit(text)) if !text.is_empty() => {
|
||||
let cursor = text.chars().count();
|
||||
(text, cursor, true)
|
||||
}
|
||||
_ => (String::new(), 0, false),
|
||||
};
|
||||
Self {
|
||||
mode: initial_mode,
|
||||
composer: ComposerState {
|
||||
@@ -1833,6 +1863,7 @@ impl App {
|
||||
viewport: ViewportState::default(),
|
||||
goal: GoalState::default(),
|
||||
session: SessionState::default(),
|
||||
active_allowed_tools: None,
|
||||
history: Vec::new(),
|
||||
history_version: 0,
|
||||
history_revisions: Vec::new(),
|
||||
@@ -1844,7 +1875,6 @@ impl App {
|
||||
status_message: None,
|
||||
status_toasts: VecDeque::new(),
|
||||
sticky_status: None,
|
||||
version_hint: None,
|
||||
last_status_message_seen: None,
|
||||
model,
|
||||
auto_model,
|
||||
@@ -1996,6 +2026,7 @@ impl App {
|
||||
coherence_state: CoherenceState::default(),
|
||||
last_send_at: None,
|
||||
last_submitted_prompt: None,
|
||||
auto_submit_initial_input,
|
||||
quit_armed_until: None,
|
||||
cycle_count: 0,
|
||||
cycle_briefings: Vec::new(),
|
||||
@@ -2003,8 +2034,10 @@ impl App {
|
||||
prefix_checks_total: 0,
|
||||
prefix_stability_pct: None,
|
||||
last_prefix_change_desc: None,
|
||||
last_pinned_prefix_hash: None,
|
||||
cycle: CycleConfig::default(),
|
||||
collapsed_cells: HashSet::new(),
|
||||
folded_thinking: HashSet::new(),
|
||||
collapsed_cell_map: Vec::new(),
|
||||
edit_in_progress: false,
|
||||
lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true),
|
||||
@@ -4796,6 +4829,7 @@ pub enum McpUiAction {
|
||||
AddHttp {
|
||||
name: String,
|
||||
url: String,
|
||||
transport: Option<String>,
|
||||
},
|
||||
Enable {
|
||||
name: String,
|
||||
@@ -4846,6 +4880,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_input_prefill_waits_for_manual_submit() {
|
||||
let mut options = test_options(false);
|
||||
options.initial_input = Some(InitialInput::Prefill("review this PR".to_string()));
|
||||
|
||||
let app = App::new(options, &Config::default());
|
||||
|
||||
assert_eq!(app.input, "review this PR");
|
||||
assert_eq!(app.cursor_position, "review this PR".chars().count());
|
||||
assert!(!app.auto_submit_initial_input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_input_submit_marks_startup_dispatch() {
|
||||
let mut options = test_options(false);
|
||||
options.initial_input = Some(InitialInput::Submit(
|
||||
"阅读项目 and wait for instructions".to_string(),
|
||||
));
|
||||
|
||||
let app = App::new(options, &Config::default());
|
||||
|
||||
assert_eq!(app.input, "阅读项目 and wait for instructions");
|
||||
assert_eq!(
|
||||
app.cursor_position,
|
||||
"阅读项目 and wait for instructions".chars().count()
|
||||
);
|
||||
assert!(app.auto_submit_initial_input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_arrows_scroll_default_is_true_without_mouse_capture() {
|
||||
assert!(default_composer_arrows_scroll_for_platform(false, false));
|
||||
@@ -5010,6 +5073,8 @@ mod tests {
|
||||
assert!(looks_like_slash_command_input("/"));
|
||||
assert!(looks_like_slash_command_input("/help"));
|
||||
assert!(looks_like_slash_command_input("/model deepseek-v4-pro"));
|
||||
assert!(!looks_like_slash_command_input("/ hello"));
|
||||
assert!(!looks_like_slash_command_input(" / hello"));
|
||||
assert!(!looks_like_slash_command_input(
|
||||
"/usr/lib/x86_64-linux-gnu/ 是标准路径吗?"
|
||||
));
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! as stray green/cyan backgrounds. This backend adapts every cell to the
|
||||
//! detected color depth before handing it to crossterm.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
|
||||
use ratatui::{
|
||||
@@ -16,6 +18,9 @@ use ratatui::{
|
||||
|
||||
use crate::palette::{self, ColorDepth, PaletteMode, ThemeId, UiTheme};
|
||||
|
||||
const RENDER_DEBUG_ENV: &str = "CODEWHALE_TUI_DEBUG";
|
||||
const RENDER_DEBUG_SAMPLE_LIMIT: usize = 24;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ColorCompatBackend<W: Write> {
|
||||
inner: CrosstermBackend<W>,
|
||||
@@ -38,6 +43,7 @@ pub(crate) struct ColorCompatBackend<W: Write> {
|
||||
/// Forcing the expected size prevents ratatui's internal `autoresize` from
|
||||
/// shrinking the viewport back to the stale dimension inside `draw()`.
|
||||
forced_size: Option<Size>,
|
||||
render_debug: Option<RenderDebugLog>,
|
||||
}
|
||||
|
||||
impl<W: Write> ColorCompatBackend<W> {
|
||||
@@ -53,6 +59,7 @@ impl<W: Write> ColorCompatBackend<W> {
|
||||
// to a community preset.
|
||||
active_ui_theme: UiTheme::detect(),
|
||||
forced_size: None,
|
||||
render_debug: RenderDebugLog::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +111,14 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
|
||||
(x, y, cell)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let viewport = if self.render_debug.is_some() {
|
||||
self.size().ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(render_debug) = &mut self.render_debug {
|
||||
render_debug.record(viewport, &adapted);
|
||||
}
|
||||
self.inner
|
||||
.draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))
|
||||
}
|
||||
@@ -152,6 +167,88 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RenderDebugLog {
|
||||
file: File,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl RenderDebugLog {
|
||||
fn from_env() -> Option<Self> {
|
||||
if !render_debug_enabled_from_value(std::env::var(RENDER_DEBUG_ENV).ok().as_deref()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let log_dir = crate::runtime_log::log_directory()?;
|
||||
if let Err(err) = fs::create_dir_all(&log_dir) {
|
||||
tracing::debug!(?err, "failed to create TUI render debug log directory");
|
||||
return None;
|
||||
}
|
||||
let path = log_dir.join("tui-render.log");
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|err| {
|
||||
tracing::debug!(?err, path = %path.display(), "failed to open TUI render debug log");
|
||||
err
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
Some(Self { file, frame: 0 })
|
||||
}
|
||||
|
||||
fn record(&mut self, viewport: Option<Size>, diff: &[(u16, u16, Cell)]) {
|
||||
self.frame = self.frame.saturating_add(1);
|
||||
let sample = diff
|
||||
.iter()
|
||||
.take(RENDER_DEBUG_SAMPLE_LIMIT)
|
||||
.map(|(x, y, _)| (*x, *y))
|
||||
.collect::<Vec<_>>();
|
||||
let line = render_debug_line(self.frame, viewport, diff.len(), &sample);
|
||||
let _ = self.file.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn render_debug_enabled_from_value(value: Option<&str>) -> bool {
|
||||
matches!(
|
||||
value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
|
||||
Some("1" | "true" | "yes" | "on")
|
||||
)
|
||||
}
|
||||
|
||||
fn render_debug_line(
|
||||
frame: u64,
|
||||
viewport: Option<Size>,
|
||||
diff_cells: usize,
|
||||
sample: &[(u16, u16)],
|
||||
) -> String {
|
||||
let mut line = String::new();
|
||||
match viewport {
|
||||
Some(size) => {
|
||||
let _ = write!(
|
||||
&mut line,
|
||||
"frame={frame} size={}x{} diff_cells={diff_cells} sample=",
|
||||
size.width, size.height
|
||||
);
|
||||
}
|
||||
None => {
|
||||
let _ = write!(
|
||||
&mut line,
|
||||
"frame={frame} size=unknown diff_cells={diff_cells} sample="
|
||||
);
|
||||
}
|
||||
}
|
||||
for (index, (x, y)) in sample.iter().enumerate() {
|
||||
if index > 0 {
|
||||
line.push(',');
|
||||
}
|
||||
let _ = write!(&mut line, "{x}:{y}");
|
||||
}
|
||||
line.push('\n');
|
||||
line
|
||||
}
|
||||
|
||||
fn adapt_cell_colors(
|
||||
cell: &mut Cell,
|
||||
depth: ColorDepth,
|
||||
@@ -177,12 +274,13 @@ fn adapt_cell_colors(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{cell::RefCell, io::Write, rc::Rc};
|
||||
use std::{cell::RefCell, env, ffi::OsString, fs, io::Write, rc::Rc};
|
||||
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::{buffer::Cell, style::Color};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::lock_test_env;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SharedWriter(Rc<RefCell<Vec<u8>>>);
|
||||
@@ -198,6 +296,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvRestore {
|
||||
key: &'static str,
|
||||
value: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvRestore {
|
||||
fn capture(key: &'static str) -> Self {
|
||||
Self {
|
||||
key,
|
||||
value: env::var_os(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvRestore {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: environment mutation is serialized by lock_test_env.
|
||||
unsafe {
|
||||
match &self.value {
|
||||
Some(value) => env::set_var(self.key, value),
|
||||
None => env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapts_rgb_cells_to_indexed_on_ansi256() {
|
||||
let mut cell = Cell::default();
|
||||
@@ -318,4 +442,58 @@ mod tests {
|
||||
backend.set_palette_mode(PaletteMode::Grayscale);
|
||||
assert_eq!(backend.palette_mode, PaletteMode::Grayscale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_debug_env_parser_accepts_truthy_values_only() {
|
||||
assert!(!render_debug_enabled_from_value(None));
|
||||
assert!(!render_debug_enabled_from_value(Some("")));
|
||||
assert!(!render_debug_enabled_from_value(Some("0")));
|
||||
assert!(!render_debug_enabled_from_value(Some("false")));
|
||||
assert!(render_debug_enabled_from_value(Some("1")));
|
||||
assert!(render_debug_enabled_from_value(Some("true")));
|
||||
assert!(render_debug_enabled_from_value(Some("YES")));
|
||||
assert!(render_debug_enabled_from_value(Some("on")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_debug_line_records_frame_size_and_diff_sample() {
|
||||
let line = render_debug_line(7, Some(Size::new(80, 24)), 42, &[(0, 0), (12, 3), (79, 23)]);
|
||||
|
||||
assert_eq!(
|
||||
line,
|
||||
"frame=7 size=80x24 diff_cells=42 sample=0:0,12:3,79:23\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_writes_render_debug_log_when_enabled() {
|
||||
let _lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = EnvRestore::capture("HOME");
|
||||
let _userprofile = EnvRestore::capture("USERPROFILE");
|
||||
let _debug = EnvRestore::capture(RENDER_DEBUG_ENV);
|
||||
|
||||
// SAFETY: environment mutation is serialized by lock_test_env.
|
||||
unsafe {
|
||||
env::set_var("HOME", tmp.path());
|
||||
env::set_var("USERPROFILE", "");
|
||||
env::set_var(RENDER_DEBUG_ENV, "1");
|
||||
}
|
||||
|
||||
let writer = SharedWriter::default();
|
||||
let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
|
||||
let mut cell = Cell::default();
|
||||
cell.set_symbol("x");
|
||||
backend.draw(std::iter::once((3, 4, &cell))).unwrap();
|
||||
|
||||
let log_path = tmp
|
||||
.path()
|
||||
.join(".codewhale")
|
||||
.join("logs")
|
||||
.join("tui-render.log");
|
||||
let body = fs::read_to_string(log_path).expect("render debug log");
|
||||
assert!(body.contains("frame=1"), "{body}");
|
||||
assert!(body.contains("diff_cells=1"), "{body}");
|
||||
assert!(body.contains("sample=3:4"), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +28,18 @@ pub struct ContextMenuView {
|
||||
column: u16,
|
||||
row: u16,
|
||||
last_rect: Cell<Option<Rect>>,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl ContextMenuView {
|
||||
pub fn new(entries: Vec<ContextMenuEntry>, column: u16, row: u16) -> Self {
|
||||
pub fn new(entries: Vec<ContextMenuEntry>, column: u16, row: u16, title: String) -> Self {
|
||||
Self {
|
||||
entries,
|
||||
selected: 0,
|
||||
column,
|
||||
row,
|
||||
last_rect: Cell::new(None),
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +201,7 @@ impl ModalView for ContextMenuView {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let block = Block::default()
|
||||
.title(" Right click ")
|
||||
.title(self.title.as_str())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::DEEPSEEK_SKY))
|
||||
.style(Style::default().bg(palette::SURFACE_ELEVATED))
|
||||
@@ -256,6 +258,7 @@ mod tests {
|
||||
],
|
||||
5,
|
||||
5,
|
||||
" Right click ".to_string(),
|
||||
);
|
||||
|
||||
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
@@ -271,7 +274,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn menu_clamps_to_render_area() {
|
||||
let view = ContextMenuView::new(vec![entry("Paste", ContextMenuAction::Paste)], 200, 80);
|
||||
let view = ContextMenuView::new(
|
||||
vec![entry("Paste", ContextMenuAction::Paste)],
|
||||
200,
|
||||
80,
|
||||
" Right click ".to_string(),
|
||||
);
|
||||
|
||||
let rect = view.menu_rect(Rect {
|
||||
x: 0,
|
||||
@@ -293,6 +301,7 @@ mod tests {
|
||||
],
|
||||
2,
|
||||
2,
|
||||
" Right click ".to_string(),
|
||||
);
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
|
||||
@@ -24,6 +24,7 @@ use ratatui::{
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
use crate::workspace_discovery::{DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery};
|
||||
|
||||
/// Maximum number of candidates collected from the initial walk. Keeps memory
|
||||
/// bounded for very large monorepos; matches the limits codex-rs uses for the
|
||||
@@ -437,7 +438,7 @@ fn collect_candidates(root: &Path) -> Vec<String> {
|
||||
|
||||
// Whitelist AI-tool dot-directories so they're discoverable even when
|
||||
// gitignored. Walk each one separately with gitignore disabled.
|
||||
for dir in [".deepseek", ".cursor", ".claude", ".agents"] {
|
||||
for dir in DISCOVERY_ALWAYS_DIRS {
|
||||
let dot_dir = root.join(dir);
|
||||
if !dot_dir.is_dir() {
|
||||
continue;
|
||||
@@ -451,7 +452,7 @@ fn collect_candidates(root: &Path) -> Vec<String> {
|
||||
.max_depth(Some(WALK_DEPTH.saturating_sub(1)));
|
||||
for entry in dot_builder.build().flatten() {
|
||||
// Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
|
||||
if entry.path().starts_with(root.join(".deepseek/snapshots")) {
|
||||
if path_is_excluded_from_discovery(root, entry.path()) {
|
||||
continue;
|
||||
}
|
||||
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||
@@ -733,4 +734,58 @@ mod tests {
|
||||
"skipme.txt should be filtered by .ignore: {visible:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_skips_generated_worktree_bulk_inside_unignored_dot_dirs() {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("src")).unwrap();
|
||||
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
|
||||
|
||||
fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
|
||||
fs::write(root.join(".deepseek/commands/build.md"), "build").unwrap();
|
||||
fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/.git/objects")).unwrap();
|
||||
fs::write(
|
||||
root.join(".deepseek/snapshots/deadbeef/.git/objects/snapshot.pack"),
|
||||
"pack",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::create_dir_all(root.join(".claude/commands")).unwrap();
|
||||
fs::write(root.join(".claude/commands/test.md"), "test").unwrap();
|
||||
fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
|
||||
fs::write(
|
||||
root.join(".claude/worktrees/agent/src/agent-only.md"),
|
||||
"agent",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let candidates = collect_candidates(root);
|
||||
|
||||
assert!(candidates.iter().any(|path| path == "src/main.rs"));
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|path| path == ".deepseek/commands/build.md"),
|
||||
"normal .deepseek command files should stay discoverable: {candidates:?}",
|
||||
);
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|path| path == ".claude/commands/test.md"),
|
||||
"normal .claude command files should stay discoverable: {candidates:?}",
|
||||
);
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.all(|path| !path.starts_with(".deepseek/snapshots/")),
|
||||
"snapshot side repo files must not enter picker candidates: {candidates:?}",
|
||||
);
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.all(|path| !path.starts_with(".claude/worktrees/")),
|
||||
".claude worktree files must not enter picker candidates: {candidates:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,21 +43,12 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let toast = quit_prompt
|
||||
.or_else(|| {
|
||||
// Version-update hint takes precedence over ephemeral status toasts
|
||||
// so the user sees it even when status traffic would hide it.
|
||||
app.version_hint.as_ref().map(|hint| FooterToast {
|
||||
text: hint.clone(),
|
||||
color: palette::STATUS_INFO,
|
||||
})
|
||||
let toast = quit_prompt.or_else(|| {
|
||||
app.active_status_toast().map(|toast| FooterToast {
|
||||
text: toast.text,
|
||||
color: status_color(toast.level),
|
||||
})
|
||||
.or_else(|| {
|
||||
app.active_status_toast().map(|toast| FooterToast {
|
||||
text: toast.text,
|
||||
color: status_color(toast.level),
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// Drive every cluster from the user's configured `status_items`. Mode
|
||||
// and Model are always rendered by `FooterProps` itself (their position
|
||||
@@ -88,15 +79,8 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
let mut label = active_subagent_status_label(app)
|
||||
.or_else(|| active_tool_status_label(app))
|
||||
.unwrap_or_else(|| {
|
||||
// Show a more specific label when the model is still loading
|
||||
// or compacting, not just a generic "working…".
|
||||
let base = if app.is_loading {
|
||||
crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale)
|
||||
} else if app.is_compacting {
|
||||
"compacting".to_string()
|
||||
} else {
|
||||
crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale)
|
||||
};
|
||||
// Show the working label during active turns (loading, compacting, etc.).
|
||||
let base = crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale);
|
||||
if elapsed_secs > 0 {
|
||||
format!("{base} ({elapsed_secs}s)")
|
||||
} else {
|
||||
@@ -538,11 +522,15 @@ pub(crate) fn render_footer_from(
|
||||
}
|
||||
|
||||
pub(crate) fn footer_git_branch_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let Some(branch) = workspace_context::branch(&app.workspace) else {
|
||||
let Some(branch) = app
|
||||
.workspace_context
|
||||
.as_deref()
|
||||
.and_then(workspace_context::branch_from_context)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
vec![Span::styled(
|
||||
branch,
|
||||
branch.to_string(),
|
||||
Style::default().fg(app.ui_theme.text_muted),
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -249,6 +249,21 @@ impl HistoryCell {
|
||||
&self,
|
||||
width: u16,
|
||||
options: TranscriptRenderOptions,
|
||||
) -> Vec<Line<'static>> {
|
||||
self.lines_with_options_folded(width, options, false)
|
||||
}
|
||||
|
||||
/// Render with an explicit per-cell fold override for thinking cells.
|
||||
///
|
||||
/// Uses XOR with the `verbose` flag so that pressing Space toggles
|
||||
/// the collapsed state *relative* to the global setting:
|
||||
/// - verbose off (default): thinking is collapsed; Space unfolds it
|
||||
/// - verbose on: thinking is expanded; Space folds it
|
||||
pub fn lines_with_options_folded(
|
||||
&self,
|
||||
width: u16,
|
||||
options: TranscriptRenderOptions,
|
||||
folded: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
match self {
|
||||
HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(),
|
||||
@@ -261,7 +276,7 @@ impl HistoryCell {
|
||||
width,
|
||||
*streaming,
|
||||
*duration_secs,
|
||||
!options.verbose,
|
||||
folded ^ !options.verbose,
|
||||
options.low_motion,
|
||||
),
|
||||
HistoryCell::Tool(cell) if !options.show_tool_details => {
|
||||
@@ -303,19 +318,25 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn lines_with_copy_metadata(
|
||||
&self,
|
||||
width: u16,
|
||||
options: TranscriptRenderOptions,
|
||||
) -> Vec<RenderedTranscriptLine> {
|
||||
self.lines_with_copy_metadata_folded(width, options, false)
|
||||
}
|
||||
|
||||
pub(crate) fn lines_with_copy_metadata_folded(
|
||||
&self,
|
||||
width: u16,
|
||||
options: TranscriptRenderOptions,
|
||||
folded: bool,
|
||||
) -> Vec<RenderedTranscriptLine> {
|
||||
match self {
|
||||
HistoryCell::User { content } => render_message_with_copy_metadata(
|
||||
USER_GLYPH,
|
||||
user_label_style(),
|
||||
user_body_style(),
|
||||
content,
|
||||
width,
|
||||
),
|
||||
HistoryCell::User { content } => {
|
||||
hard_break_copy_lines(render_user_message(content, width))
|
||||
}
|
||||
HistoryCell::Assistant { content, streaming } => render_message_with_copy_metadata(
|
||||
ASSISTANT_GLYPH,
|
||||
assistant_label_style_for(*streaming, options.low_motion),
|
||||
@@ -332,7 +353,7 @@ impl HistoryCell {
|
||||
width,
|
||||
)
|
||||
}
|
||||
_ => hard_break_copy_lines(self.lines_with_options(width, options)),
|
||||
_ => hard_break_copy_lines(self.lines_with_options_folded(width, options, folded)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2215,7 +2236,7 @@ fn render_thinking(
|
||||
let label = if streaming {
|
||||
"More reasoning in Ctrl+O"
|
||||
} else {
|
||||
"Full reasoning in Ctrl+O"
|
||||
"Space to expand · Full reasoning in Ctrl+O"
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(REASONING_RAIL.to_string(), rail_style),
|
||||
|
||||
@@ -76,5 +76,5 @@ pub mod workspace_context;
|
||||
|
||||
// === Re-exports ===
|
||||
|
||||
pub use app::TuiOptions;
|
||||
pub use app::{InitialInput, TuiOptions};
|
||||
pub use ui::run_tui;
|
||||
|
||||
@@ -5,6 +5,7 @@ use ratatui::layout::Rect;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::localization::MessageId;
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::command_palette::{
|
||||
CommandPaletteView, build_entries as build_command_palette_entries,
|
||||
@@ -434,8 +435,13 @@ pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) {
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
app.view_stack
|
||||
.push(ContextMenuView::new(entries, mouse.column, mouse.row));
|
||||
let title = app.tr(MessageId::CtxMenuTitle).to_string();
|
||||
app.view_stack.push(ContextMenuView::new(
|
||||
entries,
|
||||
mouse.column,
|
||||
mouse.row,
|
||||
title,
|
||||
));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
@@ -444,17 +450,17 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<Co
|
||||
|
||||
if selection_has_content(app) {
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Copy selection".to_string(),
|
||||
description: "write selected transcript text".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuCopySelection).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuCopySelectionDesc).to_string(),
|
||||
action: ContextMenuAction::CopySelection,
|
||||
});
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Open selection".to_string(),
|
||||
description: "show selected text in pager".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuOpenSelection).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuOpenSelectionDesc).to_string(),
|
||||
action: ContextMenuAction::OpenSelection,
|
||||
});
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Clear selection".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuClearSelection).to_string(),
|
||||
description: String::new(),
|
||||
action: ContextMenuAction::ClearSelection,
|
||||
});
|
||||
@@ -474,31 +480,31 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<Co
|
||||
.map(|label| truncate_line_to_width(label.as_str(), 28))
|
||||
.unwrap_or_else(|| "message".to_string());
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Open details".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuOpenDetails).to_string(),
|
||||
description: target,
|
||||
action: ContextMenuAction::OpenDetails { cell_index },
|
||||
});
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Copy message".to_string(),
|
||||
description: "write clicked transcript cell".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuCopyMessage).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuCopyMessageDesc).to_string(),
|
||||
action: ContextMenuAction::CopyCell { cell_index },
|
||||
});
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Open in editor".to_string(),
|
||||
description: "open file:line in $EDITOR".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuOpenInEditor).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuOpenInEditorDesc).to_string(),
|
||||
action: ContextMenuAction::OpenFileAtLine { cell_index },
|
||||
});
|
||||
// Hide/show cell toggle.
|
||||
if app.collapsed_cells.contains(&cell_index) {
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Show cell".to_string(),
|
||||
description: "unhide this transcript cell".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuShowCell).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuShowCellDesc).to_string(),
|
||||
action: ContextMenuAction::ShowCell { cell_index },
|
||||
});
|
||||
} else {
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Hide cell".to_string(),
|
||||
description: "collapse this transcript cell".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuHideCell).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuHideCellDesc).to_string(),
|
||||
action: ContextMenuAction::HideCell { cell_index },
|
||||
});
|
||||
}
|
||||
@@ -507,31 +513,32 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<Co
|
||||
// When cells are hidden, offer a way to show them all.
|
||||
if !app.collapsed_cells.is_empty() {
|
||||
let count = app.collapsed_cells.len();
|
||||
let label = app.tr(MessageId::CtxMenuShowHidden).to_string();
|
||||
entries.push(ContextMenuEntry {
|
||||
label: format!("Show hidden ({count})"),
|
||||
description: "unhide all collapsed cells".to_string(),
|
||||
label: format!("{label} ({count})"),
|
||||
description: app.tr(MessageId::CtxMenuShowHiddenDesc).to_string(),
|
||||
action: ContextMenuAction::ShowAllHidden,
|
||||
});
|
||||
}
|
||||
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Paste".to_string(),
|
||||
description: "insert clipboard into composer".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuPaste).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuPasteDesc).to_string(),
|
||||
action: ContextMenuAction::Paste,
|
||||
});
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Command palette".to_string(),
|
||||
description: "commands, skills, and tools".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuCmdPalette).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuCmdPaletteDesc).to_string(),
|
||||
action: ContextMenuAction::OpenCommandPalette,
|
||||
});
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Context inspector".to_string(),
|
||||
description: "active context and cache hints".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuContextInspector).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuContextInspectorDesc).to_string(),
|
||||
action: ContextMenuAction::OpenContextInspector,
|
||||
});
|
||||
entries.push(ContextMenuEntry {
|
||||
label: "Help".to_string(),
|
||||
description: "keybindings and commands".to_string(),
|
||||
label: app.tr(MessageId::CtxMenuHelp).to_string(),
|
||||
description: app.tr(MessageId::CtxMenuHelpDesc).to_string(),
|
||||
action: ContextMenuAction::OpenHelp,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ pub enum Method {
|
||||
Osc9,
|
||||
/// Plain BEL character: `\x07`
|
||||
Bel,
|
||||
/// osascript
|
||||
MacOS,
|
||||
/// Kitty notification protocol (OSC 99) with ST terminator.
|
||||
/// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL.
|
||||
Kitty,
|
||||
@@ -96,6 +98,10 @@ fn resolve_method() -> Method {
|
||||
return Method::Bel;
|
||||
}
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
return Method::MacOS;
|
||||
}
|
||||
|
||||
// Ghostty-based terminals (cmux, etc.) may not set their own
|
||||
// TERM_PROGRAM but do set TERM=xterm-ghostty. Likewise for Kitty.
|
||||
let term = std::env::var("TERM").unwrap_or_default();
|
||||
@@ -153,8 +159,8 @@ fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
|
||||
let seq = format!("\x1b]777;notify;codewhale;{msg}\x07");
|
||||
wrap_for_multiplexer(&seq, in_tmux).into_bytes()
|
||||
}
|
||||
// Auto and Off should not reach build_escape.
|
||||
Method::Auto | Method::Off => vec![],
|
||||
// Auto and Off and MacOS should not reach build_escape.
|
||||
Method::Auto | Method::Off | Method::MacOS => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +184,14 @@ pub fn notify_done_to<W: Write>(
|
||||
Method::Auto => resolve_method(),
|
||||
other => other,
|
||||
};
|
||||
|
||||
// macOS Notification Center: handled via osascript, not terminal escapes.
|
||||
#[cfg(target_os = "macos")]
|
||||
if Method::MacOS == effective {
|
||||
macos_display_notification(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes = build_escape(effective, in_tmux, msg);
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
@@ -315,7 +329,7 @@ pub fn stop_title_animation() {
|
||||
// terminal-level visual indicator (flash/icon).
|
||||
let mode = COMPLETION_SOUND_MODE.load(Ordering::SeqCst);
|
||||
if mode == 1 {
|
||||
set_terminal_title("✅ DeepSeek TUI");
|
||||
set_terminal_title("✅ CodeWhale");
|
||||
}
|
||||
play_completion_sound();
|
||||
}
|
||||
@@ -326,7 +340,7 @@ pub fn stop_title_animation() {
|
||||
/// marker doesn't persist once the user is back at the terminal.
|
||||
pub fn reset_title_on_interaction() {
|
||||
if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) {
|
||||
set_terminal_title("DeepSeek TUI");
|
||||
set_terminal_title("CodeWhale");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +392,106 @@ fn bell_sound() {
|
||||
let _ = io::stdout().write_all(b"\x07");
|
||||
}
|
||||
|
||||
/// Show a macOS Notification Center alert via `osascript`.
|
||||
///
|
||||
/// Runs on a dedicated background thread so the caller is not blocked.
|
||||
///
|
||||
/// The notification includes:
|
||||
/// - **Title**: "CodeWhale"
|
||||
/// - **Subtitle**: First line of `msg` (when the message contains a newline,
|
||||
/// e.g. the response preview from a completed turn)
|
||||
/// - **Body**: Remaining lines of `msg`, or the full `msg` if single-line
|
||||
/// - **Sound**: Default macOS notification sound
|
||||
///
|
||||
/// The message body is capped at 200 **characters** (not bytes) to keep the
|
||||
/// bubble readable while correctly handling multi-byte text.
|
||||
///
|
||||
/// **Security**: The message is passed to `osascript` as a command-line
|
||||
/// argument via `ARGV`, never embedded inline in the AppleScript source.
|
||||
/// AppleScript does not treat backslash as an escape inside double-quoted
|
||||
/// string literals, so the previous `\"` approach would terminate the
|
||||
/// string at the `"` and leave any text between unbalanced quotes
|
||||
/// evaluated as raw AppleScript code — a code-injection vector for
|
||||
/// AI-generated notification text. Passing via `ARGV` avoids this
|
||||
/// entirely because the message is never parsed as AppleScript syntax.
|
||||
///
|
||||
/// This is best-effort: if `osascript` is not available (e.g. headless SSH
|
||||
/// session) the error is logged via `tracing::warn!` instead of silently
|
||||
/// swallowed.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_display_notification(msg: &str) {
|
||||
let body = msg.to_string();
|
||||
|
||||
// Spawn on a background thread so we don't block the caller.
|
||||
// osascript itself is fast (~50 ms), but spawning a subprocess
|
||||
// synchronously from an async context steals a tokio thread.
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("osascript-notif".into())
|
||||
.spawn(move || {
|
||||
// Char-bounded truncation (not byte-bounded) so we don't slice
|
||||
// through a multi-byte sequence and emit invalid UTF-8.
|
||||
let body_str: String = body.chars().take(200).collect();
|
||||
|
||||
// Build AppleScript that receives the message via ARGV
|
||||
// instead of inline string interpolation. AppleScript does
|
||||
// not treat backslash as an escape inside double-quoted
|
||||
// string literals, so `\"` would terminate the string at
|
||||
// the `"` and leave a dangling `\`. Passing the message as
|
||||
// a command-line argument avoids any injection risk.
|
||||
//
|
||||
// When the message has multiple lines, the first line
|
||||
// becomes the subtitle and the rest becomes the body —
|
||||
// this lets turn notifications show the response preview
|
||||
// in the subtitle and the duration/cost summary in the body.
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(idx) = body_str.find('\n') {
|
||||
let subtitle = body_str[..idx].trim();
|
||||
let body_text = body_str[idx + 1..].trim();
|
||||
args.extend_from_slice(&[
|
||||
"-e".into(),
|
||||
"on run argv".into(),
|
||||
"-e".into(),
|
||||
"set theBody to item 1 of argv".into(),
|
||||
"-e".into(),
|
||||
"set theSubtitle to item 2 of argv".into(),
|
||||
"-e".into(),
|
||||
"display notification theBody with title \"CodeWhale\" subtitle theSubtitle sound name \"default\"".into(),
|
||||
"-e".into(),
|
||||
"end run".into(),
|
||||
"--".into(),
|
||||
body_text.into(),
|
||||
subtitle.into(),
|
||||
]);
|
||||
} else {
|
||||
args.extend_from_slice(&[
|
||||
"-e".into(),
|
||||
"on run argv".into(),
|
||||
"-e".into(),
|
||||
"display notification (item 1 of argv) with title \"CodeWhale\" sound name \"default\"".into(),
|
||||
"-e".into(),
|
||||
"end run".into(),
|
||||
"--".into(),
|
||||
body_str,
|
||||
]);
|
||||
}
|
||||
|
||||
match std::process::Command::new("osascript")
|
||||
.args(&args)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if !output.status.success() => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::warn!(stderr = %stderr, "osascript notification failed");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "osascript notification error");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Return a human-readable duration string, capped at two units so
|
||||
/// it stays compact in headers and notifications.
|
||||
///
|
||||
@@ -795,7 +909,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn auto_detect_picks_bel_for_unknown_on_unix() {
|
||||
let _lock = env_lock();
|
||||
let prev_tp = std::env::var_os("TERM_PROGRAM");
|
||||
@@ -878,7 +992,7 @@ mod tests {
|
||||
/// `TERM_PROGRAM` but do set `TERM=xterm-ghostty`. The `$TERM`
|
||||
/// fallback should catch them.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn auto_detect_picks_osc9_for_xterm_ghostty_term_fallback() {
|
||||
let _lock = env_lock();
|
||||
let prev_tp = std::env::var_os("TERM_PROGRAM");
|
||||
@@ -946,7 +1060,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn auto_detect_picks_kitty_from_term_fallback() {
|
||||
let _lock = env_lock();
|
||||
let prev_tp = std::env::var_os("TERM_PROGRAM");
|
||||
@@ -979,8 +1093,11 @@ mod tests {
|
||||
|
||||
/// When neither `TERM_PROGRAM` nor `TERM` suggests a known capable
|
||||
/// terminal, the fallback on Unix is `Bel`.
|
||||
///
|
||||
/// On macOS the `MacOS` method takes priority, so this test is
|
||||
/// excluded there.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
fn auto_detect_falls_back_to_bel_for_unrelated_term() {
|
||||
let _lock = env_lock();
|
||||
let prev_tp = std::env::var_os("TERM_PROGRAM");
|
||||
|
||||
+18
-10
@@ -49,15 +49,24 @@ pub fn handle_paste_burst_key(app: &mut App, key: &KeyEvent, now: Instant) -> bo
|
||||
}
|
||||
KeyCode::Char(c) if !has_ctrl_alt_or_super => {
|
||||
if !c.is_ascii() {
|
||||
// IME-committed characters (Chinese, Japanese, Korean)
|
||||
// arrive as individual KeyCode::Char events, typically with
|
||||
// tens-of-milliseconds gaps between each committed character.
|
||||
// Paste-burst buffering would lose characters when the IME
|
||||
// commits slower than the burst heuristic's timing window.
|
||||
//
|
||||
// We still call note_plain_char + extend_window so that:
|
||||
// 1. The burst timing counter advances for non-IME fast
|
||||
// typing on terminals without bracketed paste support.
|
||||
// 2. The Enter-suppression window stays open during a rapid
|
||||
// non-ASCII sequence, preventing premature submission.
|
||||
// But the character is inserted directly into the composer
|
||||
// rather than placed into the paste-burst buffer.
|
||||
if let Some(pending) = app.paste_burst.flush_before_modified_input() {
|
||||
app.insert_str(&pending);
|
||||
}
|
||||
if app.paste_burst.try_append_char_if_active(c, now) {
|
||||
return true;
|
||||
}
|
||||
if let Some(decision) = app.paste_burst.on_plain_char_no_hold(now) {
|
||||
return handle_paste_burst_decision(app, decision, c, now);
|
||||
}
|
||||
app.paste_burst.note_plain_char(now);
|
||||
app.paste_burst.extend_window(now);
|
||||
app.insert_char(c);
|
||||
return true;
|
||||
}
|
||||
@@ -190,10 +199,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
assert!(app.flush_paste_burst_if_due(
|
||||
t0 + Duration::from_millis(pasted.chars().count() as u64)
|
||||
+ crate::tui::paste_burst::PasteBurst::recommended_active_flush_delay()
|
||||
));
|
||||
// Non-ASCII characters are now inserted directly into the composer
|
||||
// rather than buffered by paste burst. The Enter suppression window
|
||||
// kept the newline from submitting prematurely.
|
||||
assert_eq!(app.input, pasted);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ impl PasteBurst {
|
||||
CharDecision::RetainFirstChar
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
|
||||
self.note_plain_char(now);
|
||||
|
||||
@@ -94,7 +95,7 @@ impl PasteBurst {
|
||||
None
|
||||
}
|
||||
|
||||
fn note_plain_char(&mut self, now: Instant) {
|
||||
pub(crate) fn note_plain_char(&mut self, now: Instant) {
|
||||
match self.last_plain_char_time {
|
||||
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
||||
self.consecutive_plain_char_burst =
|
||||
@@ -176,6 +177,7 @@ impl PasteBurst {
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
|
||||
if self.active || !self.buffer.is_empty() {
|
||||
self.append_char_to_buffer(ch, now);
|
||||
|
||||
@@ -102,6 +102,7 @@ impl ProviderPickerView {
|
||||
ApiProvider::Atlascloud => "ATLASCLOUD_API_KEY",
|
||||
ApiProvider::WanjieArk => "WANJIE_ARK_API_KEY",
|
||||
ApiProvider::Openrouter => "OPENROUTER_API_KEY",
|
||||
ApiProvider::XiaomiMimo => "XIAOMI_MIMO_API_KEY / MIMO_API_KEY",
|
||||
ApiProvider::Novita => "NOVITA_API_KEY",
|
||||
ApiProvider::Fireworks => "FIREWORKS_API_KEY",
|
||||
ApiProvider::Moonshot => "MOONSHOT_API_KEY / KIMI_API_KEY",
|
||||
@@ -473,6 +474,7 @@ mod tests {
|
||||
"AtlasCloud",
|
||||
"Wanjie Ark",
|
||||
"OpenRouter",
|
||||
"Xiaomi MiMo",
|
||||
"Novita AI",
|
||||
"Fireworks AI",
|
||||
"Moonshot/Kimi",
|
||||
|
||||
+142
-11
@@ -770,7 +770,7 @@ fn active_tool_rows(app: &App) -> Vec<SidebarToolRow> {
|
||||
if !stale_running.is_empty() {
|
||||
rows.push(collapsed_stale_running_row(stale_running));
|
||||
}
|
||||
editorial_tool_rows(rows, usize::MAX)
|
||||
editorial_tool_rows(rows, usize::MAX, ToolRowOrder::OldestFirst)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -837,7 +837,7 @@ fn recent_tool_rows(app: &App, limit: usize) -> Vec<SidebarToolRow> {
|
||||
.filter_map(sidebar_tool_row_from_cell)
|
||||
.take(RECENT_TOOL_SCAN_LIMIT)
|
||||
.collect();
|
||||
editorial_tool_rows(rows, limit)
|
||||
editorial_tool_rows(rows, limit, ToolRowOrder::NewestFirst)
|
||||
}
|
||||
|
||||
fn push_tool_rows(
|
||||
@@ -1142,7 +1142,17 @@ fn background_task_duplicates_live_tool(
|
||||
})
|
||||
}
|
||||
|
||||
fn editorial_tool_rows(rows: Vec<SidebarToolRow>, limit: usize) -> Vec<SidebarToolRow> {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ToolRowOrder {
|
||||
OldestFirst,
|
||||
NewestFirst,
|
||||
}
|
||||
|
||||
fn editorial_tool_rows(
|
||||
rows: Vec<SidebarToolRow>,
|
||||
limit: usize,
|
||||
order_mode: ToolRowOrder,
|
||||
) -> Vec<SidebarToolRow> {
|
||||
#[derive(Clone)]
|
||||
struct Candidate {
|
||||
rank: u8,
|
||||
@@ -1155,9 +1165,26 @@ fn editorial_tool_rows(rows: Vec<SidebarToolRow>, limit: usize) -> Vec<SidebarTo
|
||||
let mut ci_poll_groups: Vec<(usize, SidebarToolRow, usize)> = Vec::new();
|
||||
let mut shell_wait_groups: Vec<(usize, SidebarToolRow, usize, String)> = Vec::new();
|
||||
let mut seen_success: Vec<String> = Vec::new();
|
||||
let mut seen_success_tool_names: Vec<String> = Vec::new();
|
||||
let mut seen_failures: Vec<String> = Vec::new();
|
||||
let mut visible_failure_count: usize = 0;
|
||||
const MAX_VISIBLE_FAILURES: usize = 2;
|
||||
|
||||
for (order, mut row) in rows.into_iter().enumerate() {
|
||||
if row.status == ToolStatus::Failed {
|
||||
// Deduplicate failures for the same tool name: keep only the most
|
||||
// recent failure per tool. Fixes #1884 — stale failures from
|
||||
// tools that have since succeeded no longer crowd the sidebar.
|
||||
let fail_key = row.name.trim().to_ascii_lowercase();
|
||||
if order_mode == ToolRowOrder::NewestFirst
|
||||
&& seen_success_tool_names.contains(&fail_key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if seen_failures.contains(&fail_key) {
|
||||
continue;
|
||||
}
|
||||
seen_failures.push(fail_key);
|
||||
row.summary = failure_summary_with_hint(&row.summary);
|
||||
}
|
||||
|
||||
@@ -1213,13 +1240,52 @@ fn editorial_tool_rows(rows: Vec<SidebarToolRow>, limit: usize) -> Vec<SidebarTo
|
||||
}
|
||||
if row.status == ToolStatus::Success {
|
||||
seen_success.push(key);
|
||||
let normalized = row.name.trim().to_ascii_lowercase();
|
||||
if !seen_success_tool_names.contains(&normalized) {
|
||||
seen_success_tool_names.push(normalized.clone());
|
||||
}
|
||||
|
||||
// Active rows are oldest-first, so a success means any candidate
|
||||
// failure for the same tool is stale. Recent history rows are
|
||||
// newest-first; in that path the success is older than any
|
||||
// already-seen failure and must not remove it.
|
||||
if order_mode == ToolRowOrder::OldestFirst {
|
||||
let mut removed_visible_failures = 0usize;
|
||||
let mut removed_any_failure = false;
|
||||
candidates.retain(|c| {
|
||||
let remove = c.row.status == ToolStatus::Failed
|
||||
&& c.row.name.trim().eq_ignore_ascii_case(&normalized);
|
||||
if remove {
|
||||
removed_any_failure = true;
|
||||
if c.rank == 0 {
|
||||
removed_visible_failures += 1;
|
||||
}
|
||||
}
|
||||
!remove
|
||||
});
|
||||
if removed_any_failure {
|
||||
seen_failures.retain(|seen| seen != &normalized);
|
||||
visible_failure_count =
|
||||
visible_failure_count.saturating_sub(removed_visible_failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(Candidate {
|
||||
rank: tool_row_rank(&row),
|
||||
order,
|
||||
row,
|
||||
});
|
||||
// Cap visible failures at MAX_VISIBLE_FAILURES. Excess failures
|
||||
// get demoted to rank 3 so they don't crowd the top of the
|
||||
// sidebar. (#1884)
|
||||
let rank = if row.status == ToolStatus::Failed {
|
||||
if visible_failure_count >= MAX_VISIBLE_FAILURES {
|
||||
3
|
||||
} else {
|
||||
visible_failure_count += 1;
|
||||
0
|
||||
}
|
||||
} else {
|
||||
tool_row_rank(&row)
|
||||
};
|
||||
|
||||
candidates.push(Candidate { rank, order, row });
|
||||
}
|
||||
|
||||
for (order, mut row, count) in ci_poll_groups {
|
||||
@@ -1883,9 +1949,9 @@ mod tests {
|
||||
use super::{
|
||||
ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, AutoSidebarPanel,
|
||||
AutoSidebarState, SidebarAgentRow, SidebarHoverSection, SidebarHoverState,
|
||||
SidebarSubagentSummary, SidebarWorkChecklistItem, SidebarWorkStrategyStep,
|
||||
SidebarWorkSummary, auto_sidebar_panels, subagent_panel_lines, task_panel_lines,
|
||||
work_panel_empty_hint, work_panel_lines,
|
||||
SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep,
|
||||
SidebarWorkSummary, ToolRowOrder, auto_sidebar_panels, editorial_tool_rows,
|
||||
subagent_panel_lines, task_panel_lines, work_panel_empty_hint, work_panel_lines,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::palette::PaletteMode;
|
||||
@@ -1925,6 +1991,15 @@ mod tests {
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn sidebar_tool_row(name: &str, status: ToolStatus) -> SidebarToolRow {
|
||||
SidebarToolRow {
|
||||
name: name.to_string(),
|
||||
status,
|
||||
summary: String::new(),
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lines_to_text(lines: &[Line<'static>]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
@@ -1937,6 +2012,62 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editorial_rows_keep_newer_failure_when_older_success_is_seen_later() {
|
||||
let rows = vec![
|
||||
sidebar_tool_row("gh issue create", ToolStatus::Failed),
|
||||
sidebar_tool_row("gh issue create", ToolStatus::Success),
|
||||
];
|
||||
|
||||
let rendered = editorial_tool_rows(rows, 4, ToolRowOrder::NewestFirst);
|
||||
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|row| row.name == "gh issue create" && row.status == ToolStatus::Failed),
|
||||
"newest-first rows must keep a failure newer than a later-seen success: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editorial_rows_hide_older_failure_after_newer_success() {
|
||||
let rows = vec![
|
||||
sidebar_tool_row("gh issue create", ToolStatus::Success),
|
||||
sidebar_tool_row("gh issue create", ToolStatus::Failed),
|
||||
];
|
||||
|
||||
let rendered = editorial_tool_rows(rows, 4, ToolRowOrder::NewestFirst);
|
||||
|
||||
assert!(
|
||||
!rendered
|
||||
.iter()
|
||||
.any(|row| row.name == "gh issue create" && row.status == ToolStatus::Failed),
|
||||
"newest-first rows should hide stale failures older than success: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editorial_rows_reclaim_failure_slot_after_oldest_first_success() {
|
||||
let rows = vec![
|
||||
sidebar_tool_row("gh issue create", ToolStatus::Failed),
|
||||
sidebar_tool_row("grep_files", ToolStatus::Failed),
|
||||
sidebar_tool_row("gh issue create", ToolStatus::Success),
|
||||
sidebar_tool_row("cargo test", ToolStatus::Failed),
|
||||
];
|
||||
|
||||
let rendered = editorial_tool_rows(rows, 2, ToolRowOrder::OldestFirst);
|
||||
|
||||
assert_eq!(
|
||||
rendered
|
||||
.iter()
|
||||
.filter(|row| row.status == ToolStatus::Failed)
|
||||
.map(|row| row.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["grep_files", "cargo test"],
|
||||
"success should clear its stale failure and free a visible failure slot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sidebar_does_not_reserve_empty_work_when_other_panels_are_active() {
|
||||
let panels = auto_sidebar_panels(AutoSidebarState {
|
||||
|
||||
@@ -317,7 +317,7 @@ mod tests {
|
||||
let mut v = ThemePickerView::new("system".to_string());
|
||||
let action = v.handle_key(key(KeyCode::Down));
|
||||
assert!(matches!(action, ViewAction::Emit(_)));
|
||||
assert_eq!(selected_name(&action), Some(ThemeId::Whale.name()));
|
||||
assert_eq!(selected_name(&action), Some(ThemeId::Terminal.name()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,6 +337,7 @@ mod tests {
|
||||
v.handle_key(key(KeyCode::Down));
|
||||
v.handle_key(key(KeyCode::Down));
|
||||
v.handle_key(key(KeyCode::Down));
|
||||
v.handle_key(key(KeyCode::Down));
|
||||
v.handle_key(key(KeyCode::Down)); // -> CatppuccinMocha
|
||||
let action = v.handle_key(key(KeyCode::Enter));
|
||||
match action {
|
||||
@@ -376,8 +377,8 @@ mod tests {
|
||||
#[test]
|
||||
fn digit_jumps_to_row() {
|
||||
let mut v = ThemePickerView::new("system".to_string());
|
||||
let action = v.handle_key(key(KeyCode::Char('5')));
|
||||
// Row 5 (1-indexed) -> index 4 -> CatppuccinMocha
|
||||
let action = v.handle_key(key(KeyCode::Char('6')));
|
||||
// Row 6 (1-indexed) -> index 5 -> CatppuccinMocha
|
||||
assert_eq!(
|
||||
selected_name(&action),
|
||||
Some(ThemeId::CatppuccinMocha.name())
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! Width or render-option changes still bust the entire cache (correct: wrap
|
||||
//! layout depends on width and which cells are visible at all).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ratatui::{
|
||||
@@ -73,6 +74,10 @@ struct CachedCell {
|
||||
pub struct TranscriptViewCache {
|
||||
width: u16,
|
||||
options: TranscriptRenderOptions,
|
||||
/// Snapshot of folded_thinking indices from the last `ensure` call.
|
||||
/// When this changes, all cells must be re-rendered because the fold
|
||||
/// state affects the rendered output but not the cell revision.
|
||||
folded_cells: HashSet<usize>,
|
||||
/// Per-cell rendered output, indexed by current cell position.
|
||||
/// Length always equals the cell count seen on the last `ensure` call.
|
||||
per_cell: Vec<CachedCell>,
|
||||
@@ -94,6 +99,7 @@ impl TranscriptViewCache {
|
||||
Self {
|
||||
width: 0,
|
||||
options: TranscriptRenderOptions::default(),
|
||||
folded_cells: HashSet::new(),
|
||||
per_cell: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
line_meta: Vec::new(),
|
||||
@@ -122,33 +128,51 @@ impl TranscriptViewCache {
|
||||
width: u16,
|
||||
options: TranscriptRenderOptions,
|
||||
) {
|
||||
self.ensure_split(&[cells], cell_revisions, width, options);
|
||||
self.ensure_split(
|
||||
&[cells],
|
||||
cell_revisions,
|
||||
width,
|
||||
options,
|
||||
&HashSet::new(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ensure cached lines match the provided cell shards (logically
|
||||
/// concatenated) plus per-cell revisions. Avoids the
|
||||
/// `concat-into-Vec<HistoryCell>` clone the caller would otherwise pay
|
||||
/// every frame on long transcripts.
|
||||
///
|
||||
/// `folded_cells` contains original virtual indices of thinking cells
|
||||
/// that should render in their folded (summary) form.
|
||||
///
|
||||
/// `original_index_map` maps filtered (positional) indices to original
|
||||
/// virtual indices. Required when `collapsed_cells` filtering is active
|
||||
/// so that `folded_cells` lookups resolve to the correct original index.
|
||||
pub fn ensure_split(
|
||||
&mut self,
|
||||
cell_shards: &[&[HistoryCell]],
|
||||
cell_revisions: &[u64],
|
||||
width: u16,
|
||||
options: TranscriptRenderOptions,
|
||||
folded_cells: &HashSet<usize>,
|
||||
original_index_map: Option<&[usize]>,
|
||||
) {
|
||||
let total_cells: usize = cell_shards.iter().map(|s| s.len()).sum();
|
||||
|
||||
let layout_changed = self.width != width || self.options != options;
|
||||
if layout_changed {
|
||||
let folded_changed = self.folded_cells != *folded_cells;
|
||||
if layout_changed || folded_changed {
|
||||
self.per_cell.clear();
|
||||
}
|
||||
self.width = width;
|
||||
self.options = options;
|
||||
self.folded_cells = folded_cells.clone();
|
||||
|
||||
// Track whether anything actually changed; if all cells are reused at
|
||||
// the same indices, we can skip the reflatten.
|
||||
let old_len = self.per_cell.len();
|
||||
let mut any_dirty = layout_changed || old_len != total_cells;
|
||||
let mut any_dirty = layout_changed || folded_changed || old_len != total_cells;
|
||||
let mut first_dirty: Option<usize> = if old_len != total_cells {
|
||||
Some(old_len.min(total_cells))
|
||||
} else {
|
||||
@@ -190,7 +214,11 @@ impl TranscriptViewCache {
|
||||
} else {
|
||||
width
|
||||
};
|
||||
let rendered = cell.lines_with_copy_metadata(render_width, options);
|
||||
let original_idx = original_index_map
|
||||
.map(|m| *m.get(idx).unwrap_or(&idx))
|
||||
.unwrap_or(idx);
|
||||
let folded = folded_cells.contains(&original_idx);
|
||||
let rendered = cell.lines_with_copy_metadata_folded(render_width, options, folded);
|
||||
let mut lines = Vec::with_capacity(rendered.len());
|
||||
let mut copy_separators = Vec::with_capacity(rendered.len());
|
||||
let mut copy_prefix_widths = Vec::with_capacity(rendered.len());
|
||||
@@ -554,6 +582,7 @@ fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<S
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::palette;
|
||||
use crate::tui::history::{ExecCell, ExecSource, HistoryCell, ToolCell, ToolStatus};
|
||||
|
||||
fn plain_lines(cache: &TranscriptViewCache) -> Vec<String> {
|
||||
@@ -595,6 +624,20 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_renders_user_cells_with_highlight_background() {
|
||||
let cells = vec![user_cell("# literal user prompt")];
|
||||
let revisions = vec![1u64];
|
||||
|
||||
let mut cache = TranscriptViewCache::new();
|
||||
cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default());
|
||||
|
||||
let lines = cache.lines();
|
||||
assert_eq!(lines[0].style.bg, Some(palette::SURFACE_ELEVATED));
|
||||
assert_eq!(lines[0].width(), 40);
|
||||
assert_eq!(plain_lines(&cache)[0].trim_end(), "▎ # literal user prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_reuses_cells_when_revision_unchanged() {
|
||||
let cells = vec![
|
||||
@@ -1006,4 +1049,116 @@ mod tests {
|
||||
);
|
||||
eprintln!(" ✓ well under 1 MB even for very long sessions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folded_thinking_cache_invalidation() {
|
||||
let long_content = "reasoning line\n".repeat(50);
|
||||
let cells = [HistoryCell::Thinking {
|
||||
content: long_content.clone(),
|
||||
streaming: false,
|
||||
duration_secs: Some(1.5),
|
||||
}];
|
||||
let revisions = [1u64];
|
||||
let options = TranscriptRenderOptions {
|
||||
verbose: true, // expanded by default
|
||||
..TranscriptRenderOptions::default()
|
||||
};
|
||||
let width = 80u16;
|
||||
|
||||
// First render: no folding → full content.
|
||||
let mut cache = TranscriptViewCache::new();
|
||||
cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
|
||||
let full_line_count = cache.total_lines();
|
||||
|
||||
// Second render: fold the thinking cell → should invalidate and
|
||||
// produce fewer lines (collapsed summary).
|
||||
let mut folded = HashSet::new();
|
||||
folded.insert(0usize);
|
||||
cache.ensure_split(&[&cells], &revisions, width, options, &folded, None);
|
||||
let folded_line_count = cache.total_lines();
|
||||
|
||||
assert!(
|
||||
folded_line_count < full_line_count,
|
||||
"folded thinking should render fewer lines: folded={folded_line_count} full={full_line_count}"
|
||||
);
|
||||
|
||||
// Third render: unfold → should restore full content.
|
||||
cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
|
||||
let restored_line_count = cache.total_lines();
|
||||
assert_eq!(
|
||||
restored_line_count, full_line_count,
|
||||
"unfolded thinking should restore full line count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folded_thinking_with_collapsed_cells_uses_original_indices() {
|
||||
// Two thinking cells: cell 0 and cell 1. Cell 0 is collapsed (hidden).
|
||||
// Fold cell 1 (original index 1). With the filtered index map,
|
||||
// the cache should still fold the correct cell.
|
||||
let cells = [
|
||||
HistoryCell::Thinking {
|
||||
content: "first thinking block\n".repeat(20),
|
||||
streaming: false,
|
||||
duration_secs: Some(1.0),
|
||||
},
|
||||
HistoryCell::Thinking {
|
||||
content: "second thinking block\n".repeat(20),
|
||||
streaming: false,
|
||||
duration_secs: Some(2.0),
|
||||
},
|
||||
];
|
||||
let revisions = [1u64, 2u64];
|
||||
let options = TranscriptRenderOptions {
|
||||
verbose: true,
|
||||
..TranscriptRenderOptions::default()
|
||||
};
|
||||
let width = 80u16;
|
||||
|
||||
// No collapsing, no folding — baseline.
|
||||
let mut cache = TranscriptViewCache::new();
|
||||
cache.ensure_split(&[&cells], &revisions, width, options, &HashSet::new(), None);
|
||||
let baseline = cache.total_lines();
|
||||
assert!(baseline > 0, "baseline render should contain visible lines");
|
||||
|
||||
// Collapse cell 0, fold cell 1. The filtered list has only cell 1
|
||||
// at filtered index 0, but it maps to original index 1.
|
||||
let filtered_cells = [cells[1].clone()];
|
||||
let filtered_revs = [2u64];
|
||||
let index_map: Vec<usize> = vec![1]; // filtered 0 → original 1
|
||||
|
||||
let mut folded = HashSet::new();
|
||||
folded.insert(1usize); // fold original index 1
|
||||
|
||||
let mut cache2 = TranscriptViewCache::new();
|
||||
cache2.ensure_split(
|
||||
&[&filtered_cells],
|
||||
&filtered_revs,
|
||||
width,
|
||||
options,
|
||||
&folded,
|
||||
Some(&index_map),
|
||||
);
|
||||
let folded_filtered = cache2.total_lines();
|
||||
|
||||
// Cell 1 was expanded in baseline; now it should be folded.
|
||||
// We can't compare directly to baseline because baseline had both
|
||||
// cells, but folded_filtered should be less than if cell 1 were
|
||||
// expanded in the filtered view.
|
||||
let mut cache3 = TranscriptViewCache::new();
|
||||
cache3.ensure_split(
|
||||
&[&filtered_cells],
|
||||
&filtered_revs,
|
||||
width,
|
||||
options,
|
||||
&HashSet::new(),
|
||||
Some(&index_map),
|
||||
);
|
||||
let expanded_filtered = cache3.total_lines();
|
||||
|
||||
assert!(
|
||||
folded_filtered < expanded_filtered,
|
||||
"folded cell via index map should render fewer lines: folded={folded_filtered} expanded={expanded_filtered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+484
-91
@@ -9,6 +9,13 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
// On Windows the push/pop helpers write the escapes directly; crossterm's
|
||||
// PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are
|
||||
// never referenced, so the imports are gated to avoid -D warnings failures.
|
||||
#[cfg(not(windows))]
|
||||
use crossterm::event::{
|
||||
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
};
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
|
||||
@@ -17,13 +24,6 @@ use crossterm::{
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
// On Windows the push/pop helpers write the escapes directly; crossterm's
|
||||
// PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are
|
||||
// never referenced, so the imports are gated to avoid -D warnings failures.
|
||||
#[cfg(not(windows))]
|
||||
use crossterm::event::{
|
||||
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
layout::{Constraint, Direction, Layout, Rect, Size},
|
||||
@@ -32,6 +32,8 @@ use ratatui::{
|
||||
widgets::Block,
|
||||
};
|
||||
use tracing;
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows::Win32::System::Console::{GetConsoleMode, GetStdHandle, SetConsoleMode};
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
|
||||
@@ -60,7 +62,7 @@ use crate::session_manager::{
|
||||
use crate::task_manager::{
|
||||
NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, TaskSummary,
|
||||
};
|
||||
use crate::tools::spec::RuntimeToolServices;
|
||||
use crate::tools::spec::{RuntimeToolServices, ToolResult};
|
||||
use crate::tools::subagent::SubAgentStatus;
|
||||
use crate::tui::auto_router;
|
||||
use crate::tui::color_compat::ColorCompatBackend;
|
||||
@@ -156,6 +158,27 @@ const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
|
||||
const PERIODIC_FULL_REPAINT_EVERY_N: u64 = 50;
|
||||
const TURN_META_PREFIX: &str = "<turn_meta>";
|
||||
const SESSION_TITLE_MAX_CHARS: usize = 32;
|
||||
const VERSION_HINT_TOAST_TTL_MS: u64 = 12_000;
|
||||
|
||||
const REQUIRED_RELEASE_ASSETS: &[&str] = &[
|
||||
"codewhale-artifacts-sha256.txt",
|
||||
"codewhale-linux-arm64",
|
||||
"codewhale-linux-arm64.tar.gz",
|
||||
"codewhale-linux-x64",
|
||||
"codewhale-linux-x64.tar.gz",
|
||||
"codewhale-macos-arm64",
|
||||
"codewhale-macos-arm64.tar.gz",
|
||||
"codewhale-macos-x64",
|
||||
"codewhale-macos-x64.tar.gz",
|
||||
"codewhale-tui-linux-arm64",
|
||||
"codewhale-tui-linux-x64",
|
||||
"codewhale-tui-macos-arm64",
|
||||
"codewhale-tui-macos-x64",
|
||||
"codewhale-tui-windows-x64.exe",
|
||||
"codewhale-windows-x64.exe",
|
||||
"codewhale-windows-x64-portable.zip",
|
||||
"codewhale-windows-x64.zip",
|
||||
];
|
||||
|
||||
fn is_session_approved_for_tool(app: &App, tool_name: &str, grouping_key: &str) -> bool {
|
||||
app.approval_session_approved.contains(grouping_key)
|
||||
@@ -280,9 +303,19 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
enable_windows_ime_console_mode();
|
||||
|
||||
let mut stdout = io::stdout();
|
||||
if use_alt_screen {
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
// On Windows, stderr cannot be redirected to the log file (no dup2).
|
||||
// Suppress verbose CLI logging once the alt-screen is active so
|
||||
// eprintln! calls from crate::logging don't leak into the TUI buffer.
|
||||
#[cfg(windows)]
|
||||
crate::logging::snapshot_verbose_state();
|
||||
#[cfg(windows)]
|
||||
crate::logging::set_verbose(false);
|
||||
}
|
||||
// Initialize the file-backed TUI log and (on Unix) redirect raw stderr
|
||||
// away from the alt-screen for the lifetime of this guard. Any
|
||||
@@ -525,6 +558,8 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
||||
persistence_actor::init_actor(handle);
|
||||
}
|
||||
|
||||
submit_initial_input_if_ready(&mut app, config, &engine_handle).await?;
|
||||
|
||||
let result = run_event_loop(
|
||||
&mut terminal,
|
||||
&mut app,
|
||||
@@ -554,6 +589,8 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
||||
disable_raw_mode()?;
|
||||
if use_alt_screen {
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
#[cfg(windows)]
|
||||
crate::logging::restore_verbose_state();
|
||||
}
|
||||
if use_mouse_capture {
|
||||
execute!(terminal.backend_mut(), DisableMouseCapture)?;
|
||||
@@ -681,7 +718,11 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
notes_path: config.notes_path(),
|
||||
mcp_config_path: config.mcp_config_path(),
|
||||
skills_dir: app.skills_dir.clone(),
|
||||
instructions: config.instructions_paths(),
|
||||
instructions: config
|
||||
.instructions_paths()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
translation_enabled: app.translation_enabled,
|
||||
show_thinking: app.show_thinking,
|
||||
@@ -707,6 +748,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
app.goal.goal_completed,
|
||||
),
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
allowed_tools: app.active_allowed_tools.clone(),
|
||||
network_policy: config.network.clone().map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
}),
|
||||
@@ -900,8 +942,8 @@ async fn run_event_loop(
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
// Fire-and-forget version check — runs once per session in the
|
||||
// background. On success, `app.version_hint` is set and the footer
|
||||
// renders the update recommendation on the next frame.
|
||||
// background. On success, a short status toast advertises the update
|
||||
// without replacing the user's configured footer/status-line chips.
|
||||
let mut version_check: Option<tokio::task::JoinHandle<Option<String>>> = Some({
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
tokio::spawn(async move {
|
||||
@@ -920,22 +962,7 @@ async fn run_event_loop(
|
||||
.await
|
||||
.ok()?;
|
||||
let json: serde_json::Value = resp.json().await.ok()?;
|
||||
let tag = json["tag_name"].as_str()?;
|
||||
let latest = tag.trim_start_matches('v');
|
||||
// Compare semver so dev builds (e.g. "0.8.46-pre") don't
|
||||
// trigger false hints. Falls back to string compare on
|
||||
// unparseable versions.
|
||||
let newer = match (parse_semver(latest), parse_semver(¤t)) {
|
||||
(Some(l), Some(c)) => l > c,
|
||||
_ => latest != current,
|
||||
};
|
||||
if newer {
|
||||
Some(format!(
|
||||
"v{latest} available — run `codewhale update` and restart"
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
version_hint_from_release_json(&json, ¤t)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -947,7 +974,11 @@ async fn run_event_loop(
|
||||
done = handle.is_finished();
|
||||
}
|
||||
if done && let Ok(Some(hint)) = version_check.take().unwrap().await {
|
||||
app.version_hint = Some(hint);
|
||||
app.push_status_toast(
|
||||
hint,
|
||||
StatusToastLevel::Info,
|
||||
Some(VERSION_HINT_TOAST_TTL_MS),
|
||||
);
|
||||
}
|
||||
|
||||
if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await {
|
||||
@@ -1328,9 +1359,7 @@ async fn run_event_loop(
|
||||
}
|
||||
let tool_content = match &result {
|
||||
Ok(output) => sanitize_stream_chunk(
|
||||
&crate::core::engine::compact_tool_result_for_context(
|
||||
&app.model, &name, output,
|
||||
),
|
||||
&tool_result_content_for_api_message(app, &id, &name, output).await,
|
||||
),
|
||||
Err(err) => sanitize_stream_chunk(&format!("Error: {err}")),
|
||||
};
|
||||
@@ -1416,6 +1445,7 @@ async fn run_event_loop(
|
||||
} => {
|
||||
let was_locally_cancelled = app.suppress_stream_events_until_turn_complete;
|
||||
app.suppress_stream_events_until_turn_complete = false;
|
||||
app.active_allowed_tools = None;
|
||||
if !matches!(status, crate::core::events::TurnOutcomeStatus::Completed)
|
||||
|| draws_since_last_full_repaint >= PERIODIC_FULL_REPAINT_EVERY_N
|
||||
{
|
||||
@@ -1736,10 +1766,13 @@ async fn run_event_loop(
|
||||
description,
|
||||
stability_pct,
|
||||
changed,
|
||||
pinned_combined_hash,
|
||||
..
|
||||
} => {
|
||||
app.prefix_checks_total = app.prefix_checks_total.saturating_add(1);
|
||||
app.prefix_stability_pct = Some(stability_pct);
|
||||
app.last_pinned_prefix_hash =
|
||||
(!pinned_combined_hash.is_empty()).then_some(pinned_combined_hash);
|
||||
if changed {
|
||||
app.prefix_change_count = app.prefix_change_count.saturating_add(1);
|
||||
if !description.is_empty() {
|
||||
@@ -1893,6 +1926,7 @@ async fn run_event_loop(
|
||||
id,
|
||||
tool_name,
|
||||
description,
|
||||
input,
|
||||
approval_key,
|
||||
approval_grouping_key,
|
||||
} => {
|
||||
@@ -1937,19 +1971,10 @@ async fn run_event_loop(
|
||||
app.status_message =
|
||||
Some(format!("Blocked tool '{tool_name}' (approval_mode=never)"));
|
||||
} else {
|
||||
let tool_input = app
|
||||
.pending_tool_uses
|
||||
.iter()
|
||||
.find(|(tool_id, _, _)| tool_id == &id)
|
||||
.map(|(_, _, input)| input.clone())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
let tool_input = input;
|
||||
|
||||
if tool_name == "apply_patch" {
|
||||
maybe_add_patch_preview(app, &tool_input);
|
||||
}
|
||||
|
||||
// Create approval request and show overlay
|
||||
let request = ApprovalRequest::new(
|
||||
push_approval_request_view(
|
||||
app,
|
||||
&id,
|
||||
&tool_name,
|
||||
&description,
|
||||
@@ -1965,8 +1990,18 @@ async fn run_event_loop(
|
||||
"mode": app.mode.label(),
|
||||
}),
|
||||
);
|
||||
app.view_stack
|
||||
.push(ApprovalView::new_for_locale(request, app.ui_locale));
|
||||
if let Some((method, _, _)) =
|
||||
crate::tui::notifications::settings(config)
|
||||
{
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
&format!("Approval needed: {tool_name} - {description}"),
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
);
|
||||
}
|
||||
app.status_message = Some(format!(
|
||||
"Approval required for '{tool_name}': {description}"
|
||||
));
|
||||
@@ -1974,6 +2009,16 @@ async fn run_event_loop(
|
||||
}
|
||||
EngineEvent::UserInputRequired { id, request } => {
|
||||
app.view_stack.push(UserInputView::new(id.clone(), request));
|
||||
if let Some((method, _, _)) = crate::tui::notifications::settings(config) {
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
"Action required: please respond in the terminal",
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
);
|
||||
}
|
||||
app.status_message = Some(
|
||||
"Action required: answer the popup with 1-4, arrows, or Enter"
|
||||
.to_string(),
|
||||
@@ -2029,6 +2074,18 @@ async fn run_event_loop(
|
||||
blocked_write,
|
||||
);
|
||||
app.view_stack.push(ElevationView::new(request));
|
||||
if let Some((method, _, _)) =
|
||||
crate::tui::notifications::settings(config)
|
||||
{
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
&format!("Sandbox: {denial_reason} for '{tool_name}'"),
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
);
|
||||
}
|
||||
app.status_message =
|
||||
Some(format!("Sandbox blocked {tool_name}: {denial_reason}"));
|
||||
}
|
||||
@@ -2447,6 +2504,7 @@ async fn run_event_loop(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
submit_initial_input_if_ready(app, config, &engine_handle).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2878,18 +2936,32 @@ async fn run_event_loop(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Space toggles collapse/expand of the focused thinking block
|
||||
// when the composer is empty (#1972).
|
||||
// Space toggles fold/unfold of the focused thinking block
|
||||
// when the composer is empty. For thinking cells, toggles
|
||||
// between summary and full content; for other cells, toggles
|
||||
// visibility (#1972, #2348).
|
||||
KeyCode::Char(' ')
|
||||
if key.modifiers == KeyModifiers::NONE && app.input.is_empty() =>
|
||||
{
|
||||
if let Some(idx) = detail_target_cell_index(app) {
|
||||
if app.collapsed_cells.contains(&idx) {
|
||||
let is_thinking = app
|
||||
.history
|
||||
.get(idx)
|
||||
.is_some_and(|c| matches!(c, HistoryCell::Thinking { .. }));
|
||||
if is_thinking {
|
||||
if app.folded_thinking.contains(&idx) {
|
||||
app.folded_thinking.remove(&idx);
|
||||
app.status_message = Some("Thinking block expanded".to_string());
|
||||
} else {
|
||||
app.folded_thinking.insert(idx);
|
||||
app.status_message = Some("Thinking block folded".to_string());
|
||||
}
|
||||
} else if app.collapsed_cells.contains(&idx) {
|
||||
app.collapsed_cells.remove(&idx);
|
||||
app.status_message = Some("Thinking block expanded".to_string());
|
||||
app.status_message = Some("Cell expanded".to_string());
|
||||
} else {
|
||||
app.collapsed_cells.insert(idx);
|
||||
app.status_message = Some("Thinking block collapsed".to_string());
|
||||
app.status_message = Some("Cell collapsed".to_string());
|
||||
}
|
||||
app.mark_history_updated();
|
||||
app.needs_redraw = true;
|
||||
@@ -4079,6 +4151,83 @@ fn push_assistant_message(
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_result_content_for_api_message(
|
||||
app: &App,
|
||||
id: &str,
|
||||
name: &str,
|
||||
output: &ToolResult,
|
||||
) -> String {
|
||||
let raw = output.content.trim();
|
||||
if raw.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if raw.chars().count() > crate::tool_output_receipts::RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS {
|
||||
let messages = live_tool_receipt_messages(app, id, raw, output.success);
|
||||
let artifacts = app.session_artifacts.clone();
|
||||
let raw = raw.to_string();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
compact_live_tool_receipt(messages, artifacts, raw)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Some(receipt)) => return receipt,
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
crate::logging::warn(format!("live tool-output receipt compaction failed: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::core::engine::compact_tool_result_for_context(&app.model, name, output)
|
||||
}
|
||||
|
||||
fn live_tool_receipt_messages(app: &App, id: &str, raw: &str, success: bool) -> Vec<Message> {
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let Some(tool_use_msg) = app.api_messages.iter().rev().find(|message| {
|
||||
message.content.iter().any(|block| {
|
||||
matches!(block, ContentBlock::ToolUse { id: tool_use_id, .. } if tool_use_id == id)
|
||||
})
|
||||
}) {
|
||||
messages.push(tool_use_msg.clone());
|
||||
}
|
||||
messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: id.to_string(),
|
||||
content: raw.to_string(),
|
||||
is_error: Some(!success),
|
||||
content_blocks: None,
|
||||
}],
|
||||
});
|
||||
messages
|
||||
}
|
||||
|
||||
fn compact_live_tool_receipt(
|
||||
messages: Vec<Message>,
|
||||
artifacts: Vec<crate::artifacts::ArtifactRecord>,
|
||||
raw: String,
|
||||
) -> Option<String> {
|
||||
let (compacted, _) =
|
||||
crate::tool_output_receipts::compact_messages_for_persistence(&messages, &artifacts);
|
||||
let content = compacted
|
||||
.last()
|
||||
.and_then(|message| message.content.first())
|
||||
.and_then(|block| match block {
|
||||
ContentBlock::ToolResult { content, .. } => Some(content),
|
||||
_ => None,
|
||||
})?;
|
||||
if content != &raw && live_tool_content_is_receipt(content) {
|
||||
Some(content.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn live_tool_content_is_receipt(content: &str) -> bool {
|
||||
content.trim_start().starts_with("[TOOL_OUTPUT_RECEIPT]")
|
||||
}
|
||||
|
||||
fn replace_matching_assistant_text(
|
||||
app: &mut App,
|
||||
original_text: &str,
|
||||
@@ -4107,6 +4256,35 @@ fn build_queued_message(app: &mut App, input: String) -> QueuedMessage {
|
||||
QueuedMessage::new(input, skill_instruction)
|
||||
}
|
||||
|
||||
const INITIAL_PROMPT_DEFERRED_STATUS: &str = "Initial prompt ready; complete setup to send it";
|
||||
|
||||
async fn submit_initial_input_if_ready(
|
||||
app: &mut App,
|
||||
config: &Config,
|
||||
engine_handle: &EngineHandle,
|
||||
) -> Result<()> {
|
||||
if !app.auto_submit_initial_input {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if app.onboarding != OnboardingState::None {
|
||||
if app.status_message.is_none() && !app.input.trim().is_empty() {
|
||||
app.status_message = Some(INITIAL_PROMPT_DEFERRED_STATUS.to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.auto_submit_initial_input = false;
|
||||
if let Some(input) = app.submit_input() {
|
||||
if app.status_message.as_deref() == Some(INITIAL_PROMPT_DEFERRED_STATUS) {
|
||||
app.status_message = None;
|
||||
}
|
||||
let queued = build_queued_message(app, input);
|
||||
dispatch_user_message(app, config, engine_handle, queued).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn queue_current_draft_for_next_turn(app: &mut App) -> bool {
|
||||
let Some(input) = app.submit_input() else {
|
||||
return false;
|
||||
@@ -4295,6 +4473,7 @@ async fn dispatch_user_message(
|
||||
approval_mode: app.approval_mode,
|
||||
translation_enabled: app.translation_enabled,
|
||||
show_thinking: app.show_thinking,
|
||||
allowed_tools: app.active_allowed_tools.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -5193,12 +5372,16 @@ async fn handle_mcp_ui_action(
|
||||
args,
|
||||
} => {
|
||||
changed = true;
|
||||
mcp::add_server_config(&path, name.clone(), Some(command), None, args)
|
||||
mcp::add_server_config(&path, name.clone(), Some(command), None, args, None)
|
||||
.map(|()| message = Some(format!("Added MCP stdio server '{name}'")))
|
||||
}
|
||||
crate::tui::app::McpUiAction::AddHttp { name, url } => {
|
||||
crate::tui::app::McpUiAction::AddHttp {
|
||||
name,
|
||||
url,
|
||||
transport,
|
||||
} => {
|
||||
changed = true;
|
||||
mcp::add_server_config(&path, name.clone(), None, Some(url), Vec::new())
|
||||
mcp::add_server_config(&path, name.clone(), None, Some(url), Vec::new(), transport)
|
||||
.map(|()| message = Some(format!("Added MCP HTTP/SSE server '{name}'")))
|
||||
}
|
||||
crate::tui::app::McpUiAction::Enable { name } => {
|
||||
@@ -5684,7 +5867,6 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
|
||||
let header_height = 1;
|
||||
let footer_height = 1;
|
||||
let body_height = size.height.saturating_sub(header_height + footer_height);
|
||||
let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
|
||||
let mention_menu_entries =
|
||||
crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT);
|
||||
@@ -5692,8 +5874,24 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
|
||||
}
|
||||
let context_usage = context_usage_snapshot(app);
|
||||
|
||||
// Defensive two-pass layout: pin the header to the absolute top row,
|
||||
// then split the remaining body area for chat / preview / composer /
|
||||
// footer. This guarantees the header is never vertically centered
|
||||
// regardless of ratatui Flex defaults or terminal size.
|
||||
// Fixes #1834 — macOS terminal title centering.
|
||||
let (header_area, body_area) = {
|
||||
let split = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.flex(ratatui::layout::Flex::Start)
|
||||
.constraints([Constraint::Length(header_height), Constraint::Min(1)])
|
||||
.split(size);
|
||||
(split[0], split[1])
|
||||
};
|
||||
|
||||
let body_height = body_area.height;
|
||||
let composer_max_height = body_height
|
||||
.saturating_sub(MIN_CHAT_HEIGHT)
|
||||
.saturating_sub(MIN_CHAT_HEIGHT + footer_height)
|
||||
.max(MIN_COMPOSER_HEIGHT);
|
||||
let composer_height = {
|
||||
let composer_widget = ComposerWidget::new(
|
||||
@@ -5712,16 +5910,16 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
let pending_preview = build_pending_input_preview(app);
|
||||
let preview_height = pending_preview.desired_height(size.width);
|
||||
|
||||
let chunks = Layout::default()
|
||||
let body_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.flex(ratatui::layout::Flex::Start)
|
||||
.constraints([
|
||||
Constraint::Length(header_height), // Header
|
||||
Constraint::Min(1), // Chat area
|
||||
Constraint::Length(preview_height), // Pending input preview (0 if empty)
|
||||
Constraint::Length(composer_height), // Composer
|
||||
Constraint::Length(footer_height), // Footer
|
||||
])
|
||||
.split(size);
|
||||
.split(body_area);
|
||||
|
||||
// Render header
|
||||
{
|
||||
@@ -5748,6 +5946,7 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
crate::config::ApiProvider::Atlascloud => Some("Atlas"),
|
||||
crate::config::ApiProvider::WanjieArk => Some("Wanjie"),
|
||||
crate::config::ApiProvider::Openrouter => Some("OR"),
|
||||
crate::config::ApiProvider::XiaomiMimo => Some("MiMo"),
|
||||
crate::config::ApiProvider::Novita => Some("Novita"),
|
||||
crate::config::ApiProvider::Fireworks => Some("Fireworks"),
|
||||
crate::config::ApiProvider::Moonshot => Some("Kimi"),
|
||||
@@ -5781,7 +5980,7 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
));
|
||||
let header_widget = HeaderWidget::new(header_data);
|
||||
let buf = f.buffer_mut();
|
||||
header_widget.render(chunks[0], buf);
|
||||
header_widget.render(header_area, buf);
|
||||
}
|
||||
|
||||
// Render chat + sidebar + optional file-tree pane
|
||||
@@ -5792,19 +5991,19 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
// resize) don't retain stale content from a previous frame.
|
||||
Block::default()
|
||||
.style(Style::default().bg(app.ui_theme.surface_bg))
|
||||
.render(chunks[1], f.buffer_mut());
|
||||
.render(body_chunks[0], f.buffer_mut());
|
||||
|
||||
let mut sidebar_area = None;
|
||||
|
||||
// When the file-tree pane is visible and the terminal is wide
|
||||
// enough, reserve the left ~25% for the file tree.
|
||||
let mut chat_area =
|
||||
if app.file_tree.is_some() && chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
|
||||
if app.file_tree.is_some() && body_chunks[0].width >= SIDEBAR_VISIBLE_MIN_WIDTH {
|
||||
app.file_tree_visible = true;
|
||||
let split = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
|
||||
.split(chunks[1]);
|
||||
.split(body_chunks[0]);
|
||||
let tree_area = split[0];
|
||||
let remaining = split[1];
|
||||
|
||||
@@ -5816,7 +6015,7 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
remaining
|
||||
} else {
|
||||
app.file_tree_visible = false;
|
||||
chunks[1]
|
||||
body_chunks[0]
|
||||
};
|
||||
|
||||
if let Some(sidebar_width) = sidebar_width_for_chat_area(app, chat_area.width) {
|
||||
@@ -5868,7 +6067,7 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
// Render pending-input preview (queued/steered messages, if any).
|
||||
if preview_height > 0 {
|
||||
let buf = f.buffer_mut();
|
||||
pending_preview.render(chunks[2], buf);
|
||||
pending_preview.render(body_chunks[1], buf);
|
||||
}
|
||||
|
||||
// Render composer
|
||||
@@ -5880,12 +6079,12 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
&mention_menu_entries,
|
||||
);
|
||||
let buf = f.buffer_mut();
|
||||
composer_widget.render(chunks[3], buf);
|
||||
composer_widget.cursor_pos(chunks[3])
|
||||
composer_widget.render(body_chunks[2], buf);
|
||||
composer_widget.cursor_pos(body_chunks[2])
|
||||
};
|
||||
app.viewport.last_composer_area = Some(chunks[3]);
|
||||
app.viewport.last_composer_area = Some(body_chunks[2]);
|
||||
{
|
||||
let area = chunks[3];
|
||||
let area = body_chunks[2];
|
||||
let has_panel = app.composer_border && area.height >= 3 && area.width >= 12;
|
||||
let inner = if has_panel {
|
||||
ratatui::widgets::Block::default()
|
||||
@@ -5929,11 +6128,11 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
}
|
||||
|
||||
// Render footer
|
||||
render_footer(f, chunks[4], app);
|
||||
render_footer(f, body_chunks[3], app);
|
||||
// Toast stack overlay (#439): when multiple status toasts are queued,
|
||||
// surface the older ones as a 1-2 line strip above the footer so a
|
||||
// burst of events isn't collapsed to a single visible message.
|
||||
render_toast_stack_overlay(f, size, chunks[3], chunks[4], app);
|
||||
render_toast_stack_overlay(f, size, body_chunks[2], body_chunks[3], app);
|
||||
|
||||
// Decision card overlay (v0.8.43 truth-surface). When a decision card is
|
||||
// active, render it centered on top of the transcript.
|
||||
@@ -6403,6 +6602,23 @@ async fn handle_view_events(
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn push_approval_request_view(
|
||||
app: &mut App,
|
||||
id: &str,
|
||||
tool_name: &str,
|
||||
description: &str,
|
||||
tool_input: &serde_json::Value,
|
||||
approval_key: &str,
|
||||
) {
|
||||
if tool_name == "apply_patch" {
|
||||
maybe_add_patch_preview(app, tool_input);
|
||||
}
|
||||
|
||||
let request = ApprovalRequest::new(id, tool_name, description, tool_input, approval_key);
|
||||
app.view_stack
|
||||
.push(ApprovalView::new_for_locale(request, app.ui_locale));
|
||||
}
|
||||
|
||||
struct ApprovalDecisionEvent {
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
@@ -6648,6 +6864,7 @@ async fn apply_provider_picker_api_key(
|
||||
ApiProvider::Atlascloud => &mut providers.atlascloud,
|
||||
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
|
||||
ApiProvider::Openrouter => &mut providers.openrouter,
|
||||
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
|
||||
ApiProvider::Novita => &mut providers.novita,
|
||||
ApiProvider::Fireworks => &mut providers.fireworks,
|
||||
ApiProvider::Moonshot => &mut providers.moonshot,
|
||||
@@ -6700,6 +6917,7 @@ fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider,
|
||||
ApiProvider::Atlascloud => &mut providers.atlascloud,
|
||||
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
|
||||
ApiProvider::Openrouter => &mut providers.openrouter,
|
||||
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
|
||||
ApiProvider::Novita => &mut providers.novita,
|
||||
ApiProvider::Fireworks => &mut providers.fireworks,
|
||||
ApiProvider::Moonshot => &mut providers.moonshot,
|
||||
@@ -6907,6 +7125,8 @@ fn pause_terminal(
|
||||
disable_raw_mode()?;
|
||||
if use_alt_screen {
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
#[cfg(windows)]
|
||||
crate::logging::restore_verbose_state();
|
||||
}
|
||||
if use_mouse_capture {
|
||||
execute!(terminal.backend_mut(), DisableMouseCapture)?;
|
||||
@@ -6927,6 +7147,10 @@ fn resume_terminal(
|
||||
enable_raw_mode()?;
|
||||
if use_alt_screen {
|
||||
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
|
||||
// Re-entering alt-screen after mode recovery — suppress verbose
|
||||
// CLI logging again so eprintln! doesn't leak into the TUI.
|
||||
#[cfg(windows)]
|
||||
crate::logging::set_verbose(false);
|
||||
}
|
||||
recover_terminal_modes(
|
||||
terminal.backend_mut(),
|
||||
@@ -7042,6 +7266,36 @@ pub fn emergency_restore_terminal() {
|
||||
let _ = execute!(stdout, LeaveAlternateScreen);
|
||||
}
|
||||
|
||||
/// On Windows, ensure the console input handle has `ENABLE_WINDOW_INPUT`
|
||||
/// (0x0008) set. crossterm's `enable_raw_mode()` removes this flag, which
|
||||
/// breaks IME composition (Chinese/Japanese/Korean input methods cannot
|
||||
/// commit characters) on some Windows configurations (e.g. Windows Terminal
|
||||
/// in conhost compatibility mode, or the legacy console with VT input).
|
||||
///
|
||||
/// Best-effort and idempotent. Silently ignored if the console handle or
|
||||
/// mode query fails.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn enable_windows_ime_console_mode() {
|
||||
use windows::Win32::System::Console::CONSOLE_MODE;
|
||||
const ENABLE_WINDOW_INPUT: CONSOLE_MODE = CONSOLE_MODE(0x0008);
|
||||
|
||||
// SAFETY: Win32 console API is safe to call from any thread.
|
||||
// Failures (console handle invalid, mode query fails) are silently
|
||||
// ignored — this is a best-effort IME compatibility tweak.
|
||||
unsafe {
|
||||
let Ok(handle) = GetStdHandle(windows::Win32::System::Console::STD_INPUT_HANDLE) else {
|
||||
return;
|
||||
};
|
||||
let mut mode = CONSOLE_MODE(0);
|
||||
if GetConsoleMode(handle, &mut mode).is_err() {
|
||||
return;
|
||||
}
|
||||
if mode.0 & ENABLE_WINDOW_INPUT.0 == 0 {
|
||||
let _ = SetConsoleMode(handle, mode | ENABLE_WINDOW_INPUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-establish terminal mode flags. Idempotent and best-effort: each
|
||||
/// underlying flag is silently discarded by terminals that don't support
|
||||
/// it, and a single flag's failure doesn't prevent later flags from being
|
||||
@@ -7066,6 +7320,9 @@ fn recover_terminal_modes<W: Write>(
|
||||
use_mouse_capture: bool,
|
||||
use_bracketed_paste: bool,
|
||||
) {
|
||||
#[cfg(target_os = "windows")]
|
||||
enable_windows_ime_console_mode();
|
||||
|
||||
push_keyboard_enhancement_flags(writer);
|
||||
if use_mouse_capture && let Err(err) = execute!(writer, EnableMouseCapture) {
|
||||
tracing::debug!(?err, "EnableMouseCapture ignored");
|
||||
@@ -7591,8 +7848,18 @@ fn activity_detail_text(app: &App, cell_index: usize, width: u16) -> Option<Stri
|
||||
sections.push(status);
|
||||
}
|
||||
|
||||
if let Some((position, total)) = thinking_chunk_position(app, cell_index) {
|
||||
sections.push(format!("Thinking chunk: {position} of {total}"));
|
||||
let activity_indices = activity_indices(app);
|
||||
if let Some(position) = activity_indices.iter().position(|&idx| idx == cell_index) {
|
||||
sections.push(format!(
|
||||
"Activity chunk: {} of {}",
|
||||
position + 1,
|
||||
activity_indices.len()
|
||||
));
|
||||
sections.extend(activity_navigation_lines(app, position, &activity_indices));
|
||||
}
|
||||
|
||||
if let Some(handle) = activity_detail_handle_line(app, cell_index, cell) {
|
||||
sections.push(handle);
|
||||
}
|
||||
|
||||
sections.push(String::new());
|
||||
@@ -7644,6 +7911,22 @@ fn reasoning_timeline_text(app: &App, selected_cell_index: usize) -> Option<Stri
|
||||
));
|
||||
if let Some(position) = selected_position {
|
||||
sections.push(format!("Selected chunk: {position} of {total}"));
|
||||
if position > 1 {
|
||||
let previous_index = thinking_indices[position - 2];
|
||||
let preview = thinking_chunk_preview(app, previous_index);
|
||||
sections.push(format!(
|
||||
"Previous chunk: {} of {total} - {preview}",
|
||||
position - 1
|
||||
));
|
||||
}
|
||||
if position < total {
|
||||
let next_index = thinking_indices[position];
|
||||
let preview = thinking_chunk_preview(app, next_index);
|
||||
sections.push(format!(
|
||||
"Next chunk: {} of {total} - {preview}",
|
||||
position + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
sections.push(String::new());
|
||||
|
||||
@@ -7685,6 +7968,18 @@ fn reasoning_timeline_text(app: &App, selected_cell_index: usize) -> Option<Stri
|
||||
Some(sections.join("\n"))
|
||||
}
|
||||
|
||||
fn thinking_chunk_preview(app: &App, cell_index: usize) -> String {
|
||||
let Some(HistoryCell::Thinking { content, .. }) = app.cell_at_virtual_index(cell_index) else {
|
||||
return "thinking".to_string();
|
||||
};
|
||||
let preview = one_line_summary(content, 64);
|
||||
if preview.is_empty() {
|
||||
"thinking".to_string()
|
||||
} else {
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> String {
|
||||
match cell {
|
||||
HistoryCell::Thinking { .. } => "thinking".to_string(),
|
||||
@@ -7793,28 +8088,70 @@ fn format_activity_duration_ms(ms: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn thinking_chunk_position(app: &App, cell_index: usize) -> Option<(usize, usize)> {
|
||||
if !matches!(
|
||||
app.cell_at_virtual_index(cell_index),
|
||||
Some(HistoryCell::Thinking { .. })
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
fn activity_indices(app: &App) -> Vec<usize> {
|
||||
(0..app.virtual_cell_count())
|
||||
.filter(|&idx| {
|
||||
app.cell_at_virtual_index(idx)
|
||||
.is_some_and(is_meaningful_activity_cell)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
let mut total = 0usize;
|
||||
let mut position = None;
|
||||
for idx in 0..app.virtual_cell_count() {
|
||||
if matches!(
|
||||
app.cell_at_virtual_index(idx),
|
||||
Some(HistoryCell::Thinking { .. })
|
||||
) {
|
||||
total += 1;
|
||||
if idx == cell_index {
|
||||
position = Some(total);
|
||||
}
|
||||
fn activity_navigation_lines(
|
||||
app: &App,
|
||||
position: usize,
|
||||
activity_indices: &[usize],
|
||||
) -> Vec<String> {
|
||||
let total = activity_indices.len();
|
||||
let mut lines = Vec::new();
|
||||
if position > 0 {
|
||||
let previous_idx = activity_indices[position - 1];
|
||||
if let Some(cell) = app.cell_at_virtual_index(previous_idx) {
|
||||
let label = activity_cell_label(app, previous_idx, cell);
|
||||
lines.push(format!(
|
||||
"Previous activity: {} of {total} - {}",
|
||||
position,
|
||||
truncate_line_to_width(&label, 56)
|
||||
));
|
||||
}
|
||||
}
|
||||
position.map(|pos| (pos, total))
|
||||
if position + 1 < total {
|
||||
let next_idx = activity_indices[position + 1];
|
||||
if let Some(cell) = app.cell_at_virtual_index(next_idx) {
|
||||
let label = activity_cell_label(app, next_idx, cell);
|
||||
lines.push(format!(
|
||||
"Next activity: {} of {total} - {}",
|
||||
position + 2,
|
||||
truncate_line_to_width(&label, 56)
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn activity_detail_handle_line(app: &App, cell_index: usize, cell: &HistoryCell) -> Option<String> {
|
||||
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
|
||||
if let Some(artifact) = app
|
||||
.session_artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.tool_call_id == detail.tool_id)
|
||||
{
|
||||
return Some(format!(
|
||||
"Detail handle: {} (retrieve_tool_result ref={}; Alt+V raw details)",
|
||||
artifact.id, artifact.id
|
||||
));
|
||||
}
|
||||
return Some(format!(
|
||||
"Detail handle: tool:{} (Alt+V raw details)",
|
||||
detail.tool_id
|
||||
));
|
||||
}
|
||||
|
||||
match cell {
|
||||
HistoryCell::Tool(_) => Some("Detail handle: Alt+V details".to_string()),
|
||||
HistoryCell::SubAgent(_) => Some("Detail handle: Alt+V details".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_cell_to_text(cell: &HistoryCell, width: u16) -> String {
|
||||
@@ -8093,6 +8430,62 @@ fn extract_reasoning_header(text: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Option<String> {
|
||||
if !release_has_required_assets(json) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tag = json["tag_name"].as_str()?;
|
||||
let latest = tag.trim_start_matches('v');
|
||||
if !is_newer_version(latest, current) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"v{latest} available - run `codewhale update` and restart"
|
||||
))
|
||||
}
|
||||
|
||||
fn release_has_required_assets(json: &serde_json::Value) -> bool {
|
||||
if json
|
||||
.get("draft")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if json
|
||||
.get("prerelease")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
REQUIRED_RELEASE_ASSETS
|
||||
.iter()
|
||||
.all(|required| release_has_uploaded_asset(json, required))
|
||||
}
|
||||
|
||||
fn release_has_uploaded_asset(json: &serde_json::Value, required: &str) -> bool {
|
||||
let Some(assets) = json.get("assets").and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
assets.iter().any(|asset| {
|
||||
asset.get("name").and_then(serde_json::Value::as_str) == Some(required)
|
||||
&& asset.get("state").and_then(serde_json::Value::as_str) == Some("uploaded")
|
||||
})
|
||||
}
|
||||
|
||||
fn is_newer_version(latest: &str, current: &str) -> bool {
|
||||
// Compare semver so dev builds (e.g. "0.8.46-pre") don't trigger false
|
||||
// hints. Falls back to string compare on unparseable versions.
|
||||
match (parse_semver(latest), parse_semver(current)) {
|
||||
(Some(l), Some(c)) => l > c,
|
||||
_ => latest != current,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `major.minor.patch` version string into a comparable tuple.
|
||||
/// Returns `None` on any parse failure (non-semver, dev suffixes, etc.).
|
||||
fn parse_semver(v: &str) -> Option<(u32, u32, u32)> {
|
||||
|
||||
@@ -1359,6 +1359,96 @@ fn create_test_options() -> TuiOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_result_api_content_receipts_large_live_output() {
|
||||
let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
let tmp = TempDir::new().expect("spillover tempdir");
|
||||
let prior = crate::tools::truncate::set_test_spillover_root(Some(
|
||||
tmp.path().join(".deepseek").join("tool_outputs"),
|
||||
));
|
||||
struct Restore(Option<PathBuf>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
crate::tools::truncate::set_test_spillover_root(self.0.take());
|
||||
}
|
||||
}
|
||||
let _restore = Restore(prior);
|
||||
|
||||
let mut app = App::new(create_test_options(), &Config::default());
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "call-live-big".to_string(),
|
||||
name: "exec_shell".to_string(),
|
||||
input: serde_json::json!({"command": "cargo test"}),
|
||||
caller: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let raw = "LIVE_RAW_SENTINEL\n".repeat(900);
|
||||
let output = crate::tools::spec::ToolResult::success(raw.clone());
|
||||
let content =
|
||||
tool_result_content_for_api_message(&app, "call-live-big", "exec_shell", &output).await;
|
||||
|
||||
assert!(content.contains("[TOOL_OUTPUT_RECEIPT]"));
|
||||
assert!(content.contains("tool: exec_shell"));
|
||||
assert!(content.contains("tool_call_id: call-live-big"));
|
||||
assert!(content.contains("detail_handle: sha:"));
|
||||
assert!(content.contains("retrieve: retrieve_tool_result ref=sha:"));
|
||||
assert!(!content.contains(&raw));
|
||||
assert!(
|
||||
content.chars().count()
|
||||
< crate::tool_output_receipts::RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_tool_receipt_messages_clones_only_matching_tool_use() {
|
||||
let mut app = App::new(create_test_options(), &Config::default());
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "call-old".to_string(),
|
||||
name: "exec_shell".to_string(),
|
||||
input: serde_json::json!({"command": "old"}),
|
||||
caller: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "call-old".to_string(),
|
||||
content: "OLD_RAW\n".repeat(2_000),
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "call-new".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: serde_json::json!({"path": "src/main.rs"}),
|
||||
caller: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let messages = live_tool_receipt_messages(&app, "call-new", "NEW_RAW", true);
|
||||
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(
|
||||
&messages[0].content[0],
|
||||
ContentBlock::ToolUse { id, name, .. } if id == "call-new" && name == "read_file"
|
||||
));
|
||||
assert!(matches!(
|
||||
&messages[1].content[0],
|
||||
ContentBlock::ToolResult { tool_use_id, content, .. }
|
||||
if tool_use_id == "call-new" && content == "NEW_RAW"
|
||||
));
|
||||
}
|
||||
|
||||
fn text_message(role: &str, text: &str) -> Message {
|
||||
Message {
|
||||
role: role.to_string(),
|
||||
@@ -2077,6 +2167,8 @@ fn init_git_repo() -> TempDir {
|
||||
"user.name=codewhale Tests",
|
||||
"-c",
|
||||
"user.email=tests@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
@@ -2342,6 +2434,75 @@ fn event_poll_timeout_has_nonzero_floor() {
|
||||
);
|
||||
}
|
||||
|
||||
fn complete_release_json(tag: &str) -> serde_json::Value {
|
||||
let assets = REQUIRED_RELEASE_ASSETS
|
||||
.iter()
|
||||
.map(|name| serde_json::json!({ "name": name, "state": "uploaded" }))
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::json!({
|
||||
"tag_name": tag,
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": assets,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_hint_requires_complete_release_assets() {
|
||||
let complete = complete_release_json("v0.8.47");
|
||||
let hint = version_hint_from_release_json(&complete, "0.8.46").expect("newer complete release");
|
||||
assert!(hint.contains("v0.8.47 available"));
|
||||
|
||||
let mut missing_manifest = complete_release_json("v0.8.47");
|
||||
missing_manifest["assets"] = serde_json::Value::Array(
|
||||
missing_manifest["assets"]
|
||||
.as_array()
|
||||
.expect("assets")
|
||||
.iter()
|
||||
.filter(|asset| {
|
||||
asset.get("name").and_then(serde_json::Value::as_str)
|
||||
!= Some("codewhale-artifacts-sha256.txt")
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
assert!(
|
||||
version_hint_from_release_json(&missing_manifest, "0.8.46").is_none(),
|
||||
"do not advertise a release before checksums are uploaded"
|
||||
);
|
||||
|
||||
let mut pending_asset = complete_release_json("v0.8.47");
|
||||
pending_asset["assets"].as_array_mut().expect("assets")[0]["state"] = serde_json::json!("open");
|
||||
assert!(
|
||||
version_hint_from_release_json(&pending_asset, "0.8.46").is_none(),
|
||||
"do not advertise a release before every asset is uploaded"
|
||||
);
|
||||
|
||||
let mut missing_state = complete_release_json("v0.8.47");
|
||||
missing_state["assets"].as_array_mut().expect("assets")[0]
|
||||
.as_object_mut()
|
||||
.expect("asset object")
|
||||
.remove("state");
|
||||
assert!(
|
||||
version_hint_from_release_json(&missing_state, "0.8.46").is_none(),
|
||||
"do not accept malformed asset state as uploaded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_hint_ignores_draft_prerelease_and_current_versions() {
|
||||
let mut draft = complete_release_json("v0.8.47");
|
||||
draft["draft"] = serde_json::Value::Bool(true);
|
||||
assert!(version_hint_from_release_json(&draft, "0.8.46").is_none());
|
||||
|
||||
let mut prerelease = complete_release_json("v0.8.47");
|
||||
prerelease["prerelease"] = serde_json::Value::Bool(true);
|
||||
assert!(version_hint_from_release_json(&prerelease, "0.8.46").is_none());
|
||||
|
||||
let current = complete_release_json("v0.8.46");
|
||||
assert!(version_hint_from_release_json(¤t, "0.8.46").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn external_url_launcher_does_not_wait_for_browser_process() {
|
||||
@@ -3268,6 +3429,47 @@ async fn dispatch_user_message_records_prompt_for_cancel_restore() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_prompt_waits_for_onboarding_then_dispatches() {
|
||||
let mut app = create_test_app();
|
||||
app.input = "阅读项目 and wait".to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
app.auto_submit_initial_input = true;
|
||||
app.onboarding = OnboardingState::Welcome;
|
||||
let config = Config::default();
|
||||
let mut engine = crate::core::engine::mock_engine_handle();
|
||||
|
||||
submit_initial_input_if_ready(&mut app, &config, &engine.handle)
|
||||
.await
|
||||
.expect("defer");
|
||||
|
||||
assert!(app.auto_submit_initial_input);
|
||||
assert_eq!(app.input, "阅读项目 and wait");
|
||||
assert_eq!(
|
||||
app.status_message.as_deref(),
|
||||
Some(INITIAL_PROMPT_DEFERRED_STATUS)
|
||||
);
|
||||
assert!(engine.rx_op.try_recv().is_err());
|
||||
|
||||
app.onboarding = OnboardingState::None;
|
||||
submit_initial_input_if_ready(&mut app, &config, &engine.handle)
|
||||
.await
|
||||
.expect("submit");
|
||||
|
||||
assert!(!app.auto_submit_initial_input);
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(
|
||||
app.last_submitted_prompt.as_deref(),
|
||||
Some("阅读项目 and wait")
|
||||
);
|
||||
match engine.rx_op.recv().await.expect("send message op") {
|
||||
crate::core::ops::Op::SendMessage { content, .. } => {
|
||||
assert!(content.contains("阅读项目 and wait"));
|
||||
}
|
||||
other => panic!("expected SendMessage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steer_user_message_records_prompt_for_cancel_restore() {
|
||||
let mut app = create_test_app();
|
||||
@@ -3629,6 +3831,8 @@ fn open_tool_details_pager_supports_active_virtual_tool_cell() {
|
||||
&[1],
|
||||
100,
|
||||
app.transcript_render_options(),
|
||||
&app.folded_thinking,
|
||||
None,
|
||||
);
|
||||
app.viewport.last_transcript_top = 0;
|
||||
app.viewport.last_transcript_visible = 4;
|
||||
@@ -5048,6 +5252,10 @@ fn activity_detail_opens_reasoning_timeline_for_selected_thinking() {
|
||||
body.contains("Selected chunk: 1 of 2"),
|
||||
"chunk position missing: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Next chunk: 2 of 2 - second chunk reasoning"),
|
||||
"neighboring chunk missing: {body}"
|
||||
);
|
||||
assert!(body.contains("Thinking chunk 1 of 2 (selected)"), "{body}");
|
||||
assert!(body.contains("Thinking chunk 2 of 2"), "{body}");
|
||||
assert!(body.contains("first chunk reasoning"), "body: {body}");
|
||||
@@ -5057,6 +5265,95 @@ fn activity_detail_opens_reasoning_timeline_for_selected_thinking() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_detail_includes_tool_handle_and_neighbor_context() {
|
||||
let mut app = create_test_app();
|
||||
app.history = vec![
|
||||
HistoryCell::Thinking {
|
||||
content: "checked approach".to_string(),
|
||||
streaming: false,
|
||||
duration_secs: Some(0.6),
|
||||
},
|
||||
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "read_file".to_string(),
|
||||
status: ToolStatus::Success,
|
||||
input_summary: Some("src/main.rs".to_string()),
|
||||
output: Some("bounded preview".to_string()),
|
||||
prompts: None,
|
||||
spillover_path: None,
|
||||
output_summary: None,
|
||||
is_diff: false,
|
||||
})),
|
||||
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "grep_files".to_string(),
|
||||
status: ToolStatus::Success,
|
||||
input_summary: Some("TODO".to_string()),
|
||||
output: Some("grep summary".to_string()),
|
||||
prompts: None,
|
||||
spillover_path: None,
|
||||
output_summary: None,
|
||||
is_diff: false,
|
||||
})),
|
||||
];
|
||||
app.tool_details_by_cell.insert(
|
||||
1,
|
||||
ToolDetailRecord {
|
||||
tool_id: "call-read".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
input: serde_json::json!({"path": "src/main.rs"}),
|
||||
output: Some("full output behind raw details".to_string()),
|
||||
},
|
||||
);
|
||||
app.session_artifacts
|
||||
.push(crate::artifacts::ArtifactRecord {
|
||||
id: "art_call-read".to_string(),
|
||||
kind: crate::artifacts::ArtifactKind::ToolOutput,
|
||||
session_id: "session-activity".to_string(),
|
||||
tool_call_id: "call-read".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
byte_size: 42,
|
||||
preview: "bounded preview".to_string(),
|
||||
storage_path: PathBuf::from("artifacts").join("art_call-read.txt"),
|
||||
});
|
||||
app.resync_history_revisions();
|
||||
let revisions = app.history_revisions.clone();
|
||||
app.viewport.transcript_cache.ensure(
|
||||
&app.history,
|
||||
&revisions,
|
||||
100,
|
||||
app.transcript_render_options(),
|
||||
);
|
||||
let line = first_line_for_cell(&app, 1);
|
||||
let point = TranscriptSelectionPoint {
|
||||
line_index: line,
|
||||
column: 0,
|
||||
};
|
||||
app.viewport.transcript_selection.anchor = Some(point);
|
||||
app.viewport.transcript_selection.head = Some(point);
|
||||
|
||||
assert!(open_activity_detail_pager(&mut app));
|
||||
let body = pop_pager_body(&mut app);
|
||||
|
||||
assert!(body.contains("Activity: read_file"), "{body}");
|
||||
assert!(body.contains("Activity chunk: 2 of 3"), "{body}");
|
||||
assert!(
|
||||
body.contains("Previous activity: 1 of 3 - thinking"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("Next activity: 3 of 3 - tool grep_files"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains("Detail handle: art_call-read"), "{body}");
|
||||
assert!(
|
||||
body.contains("retrieve_tool_result ref=art_call-read"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains("Alt+V"), "{body}");
|
||||
assert!(body.contains("raw details"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_detail_fallback_prefers_live_activity_context() {
|
||||
let mut app = create_test_app();
|
||||
@@ -5115,6 +5412,11 @@ fn activity_detail_fallback_uses_recent_meaningful_activity_without_full_tool_du
|
||||
body.contains("Alt+V for details"),
|
||||
"activity detail should stay bounded and point to Alt+V for raw detail: {body}"
|
||||
);
|
||||
assert!(body.contains("Detail handle: Alt+V details"), "{body}");
|
||||
assert!(
|
||||
!body.contains("Detail handle: Alt+V raw details"),
|
||||
"fallback tool details should not be labeled raw: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("line 10"),
|
||||
"middle of large raw output should not be dumped into Activity Detail: {body}"
|
||||
@@ -5202,6 +5504,49 @@ fn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_prompt_uses_event_input_after_message_complete_drain() {
|
||||
let mut app = create_test_app();
|
||||
app.pending_tool_uses.push((
|
||||
"tool-1".to_string(),
|
||||
"exec_shell".to_string(),
|
||||
serde_json::json!({"command": "stale value from drained list"}),
|
||||
));
|
||||
|
||||
// Mirror the old race: MessageComplete drains pending tool uses before
|
||||
// ApprovalRequired is handled. The approval modal must still show the
|
||||
// non-empty input carried directly on the ApprovalRequired event.
|
||||
app.pending_tool_uses.clear();
|
||||
|
||||
let event_input = serde_json::json!({
|
||||
"command": "cargo test -p codewhale-tui approval",
|
||||
"workdir": "/repo",
|
||||
});
|
||||
push_approval_request_view(
|
||||
&mut app,
|
||||
"tool-1",
|
||||
"exec_shell",
|
||||
"Run cargo tests",
|
||||
&event_input,
|
||||
"approval-key",
|
||||
);
|
||||
|
||||
let mut view = app.view_stack.pop().expect("approval view");
|
||||
let approval = view
|
||||
.as_any_mut()
|
||||
.downcast_mut::<ApprovalView>()
|
||||
.expect("approval view");
|
||||
let action = approval.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE));
|
||||
let ViewAction::Emit(ViewEvent::OpenTextPager { content, .. }) = action else {
|
||||
panic!("expected approval params pager");
|
||||
};
|
||||
|
||||
assert!(content.contains("cargo test -p codewhale-tui approval"));
|
||||
assert!(content.contains("/repo"));
|
||||
assert!(!content.contains("stale value from drained list"));
|
||||
assert_ne!(content.trim(), "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_thinking_block_appends_new_entry_in_same_active_cell() {
|
||||
// Real V4 turns can emit Thinking → Tool → Thinking → Tool before any
|
||||
@@ -5694,6 +6039,10 @@ fn default_footer_keeps_prefix_stability_opt_in() {
|
||||
items.contains(&crate::config::StatusItem::Cache),
|
||||
"default footer should still include provider-reported cache hit rate"
|
||||
);
|
||||
assert!(
|
||||
items.contains(&crate::config::StatusItem::GitBranch),
|
||||
"default footer should surface the current workspace branch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5777,11 +6126,37 @@ fn render_footer_from_git_branch_item_renders_workspace_branch() {
|
||||
|
||||
let mut app = create_test_app();
|
||||
app.workspace = repo.path().to_path_buf();
|
||||
crate::tui::workspace_context::refresh_if_needed(&mut app, Instant::now(), true);
|
||||
|
||||
let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);
|
||||
assert_eq!(spans_text(&props.cache), "feature/statusline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_footer_renders_workspace_branch_when_available() {
|
||||
let repo = init_git_repo();
|
||||
let checkout = Command::new("git")
|
||||
.args(["checkout", "-b", "feature/default-branch-chip"])
|
||||
.current_dir(repo.path())
|
||||
.output()
|
||||
.expect("git checkout should run");
|
||||
assert!(
|
||||
checkout.status.success(),
|
||||
"git checkout failed: {}",
|
||||
String::from_utf8_lossy(&checkout.stderr)
|
||||
);
|
||||
|
||||
let mut app = create_test_app();
|
||||
app.workspace = repo.path().to_path_buf();
|
||||
crate::tui::workspace_context::refresh_if_needed(&mut app, Instant::now(), true);
|
||||
|
||||
let props = render_footer_from(&app, &crate::config::StatusItem::default_footer(), None);
|
||||
assert!(
|
||||
spans_text(&props.cache).contains("feature/default-branch-chip"),
|
||||
"default footer should include the current git branch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for issue #244: visible session spend must not decrease.
|
||||
/// Sub-agent token usage events arrive out of order and may be reconciled
|
||||
/// later (cache adjustments, provisional → final swap). The displayed total
|
||||
|
||||
@@ -575,6 +575,7 @@ pub struct ConfigView {
|
||||
filter: String,
|
||||
status: Option<String>,
|
||||
locale: Locale,
|
||||
effective_cost_currency: String,
|
||||
last_visible_rows: Cell<usize>,
|
||||
last_row_hitboxes: RefCell<Vec<(u16, usize)>>,
|
||||
}
|
||||
@@ -819,6 +820,7 @@ impl ConfigView {
|
||||
filter: String::new(),
|
||||
status: None,
|
||||
locale: app.ui_locale,
|
||||
effective_cost_currency: cost_currency_config_value(app),
|
||||
last_visible_rows: Cell::new(0),
|
||||
last_row_hitboxes: RefCell::new(Vec::new()),
|
||||
}
|
||||
@@ -841,7 +843,7 @@ impl ConfigView {
|
||||
|
||||
let section = row.section.label().to_lowercase();
|
||||
let key = row.key.to_lowercase();
|
||||
let value = row.value.to_lowercase();
|
||||
let value = self.row_display_value(row).to_lowercase();
|
||||
let scope = row.scope.label().to_lowercase();
|
||||
|
||||
filter.split_whitespace().all(|term| {
|
||||
@@ -1120,6 +1122,27 @@ impl ConfigView {
|
||||
|
||||
self.update_filter(|filter| filter.clear());
|
||||
}
|
||||
|
||||
fn row_display_value(&self, row: &ConfigRow) -> String {
|
||||
if row.key == "cost_currency" && row.scope == ConfigScope::Saved {
|
||||
let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value);
|
||||
let effective_cost_currency =
|
||||
crate::pricing::CostCurrency::from_setting(&self.effective_cost_currency);
|
||||
if saved_cost_currency != effective_cost_currency {
|
||||
return format!("{} (effective {})", row.value, self.effective_cost_currency);
|
||||
}
|
||||
}
|
||||
|
||||
row.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_currency_config_value(app: &App) -> String {
|
||||
match app.cost_currency {
|
||||
crate::pricing::CostCurrency::Usd => "usd",
|
||||
crate::pricing::CostCurrency::Cny => "cny",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn config_hint_for_key(key: &str) -> &'static str {
|
||||
@@ -1137,6 +1160,8 @@ fn config_hint_for_key(key: &str) -> &'static str {
|
||||
"theme" => "system | dark | light | grayscale",
|
||||
"locale" => "auto | en | ja | zh-Hans | pt-BR",
|
||||
"background_color" => "#RRGGBB | default",
|
||||
"base_url" => "save user config; e.g. https://api.deepseek.com/beta or https://gateway/v1",
|
||||
"cost_currency" => "usd | cny",
|
||||
"default_mode" => "agent | plan | yolo",
|
||||
"sidebar_width" => "10..=50",
|
||||
"sidebar_focus" => "auto | work | tasks | agents | context | hidden",
|
||||
@@ -1444,7 +1469,10 @@ impl ModalView for ConfigView {
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let value = truncate_view_text(&row.value, CONFIG_VALUE_COLUMN_WIDTH);
|
||||
let value = truncate_view_text(
|
||||
&self.row_display_value(row),
|
||||
CONFIG_VALUE_COLUMN_WIDTH,
|
||||
);
|
||||
let mut line = Line::from(format!(
|
||||
" {:<key_width$} {:<value_width$} {}",
|
||||
row.key,
|
||||
@@ -2023,8 +2051,52 @@ mod tests {
|
||||
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||||
};
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::MutexGuard;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct ConfigSettingsEnvGuard {
|
||||
_tmp: TempDir,
|
||||
previous_config_path: Option<OsString>,
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl ConfigSettingsEnvGuard {
|
||||
fn new(settings_toml: &str) -> Self {
|
||||
let lock = crate::test_support::lock_test_env();
|
||||
let tmp = TempDir::new().expect("settings tempdir");
|
||||
let config_path = tmp.path().join(".deepseek").join("config.toml");
|
||||
let settings_path = config_path
|
||||
.parent()
|
||||
.expect("settings parent")
|
||||
.join("settings.toml");
|
||||
std::fs::create_dir_all(config_path.parent().expect("config parent"))
|
||||
.expect("config dir");
|
||||
std::fs::write(&settings_path, settings_toml).expect("settings file");
|
||||
let previous_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
unsafe {
|
||||
std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
|
||||
}
|
||||
Self {
|
||||
_tmp: tmp,
|
||||
previous_config_path,
|
||||
_lock: lock,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConfigSettingsEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
match self.previous_config_path.take() {
|
||||
Some(previous) => std::env::set_var("DEEPSEEK_CONFIG_PATH", previous),
|
||||
None => std::env::remove_var("DEEPSEEK_CONFIG_PATH"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
@@ -2051,6 +2123,26 @@ mod tests {
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn cost_currency_row_for_settings(
|
||||
settings_toml: &str,
|
||||
) -> (String, String, crate::pricing::CostCurrency, Locale) {
|
||||
let _guard = ConfigSettingsEnvGuard::new(settings_toml);
|
||||
let app = create_test_app();
|
||||
let view = ConfigView::new_for_app(&app);
|
||||
let row = view
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.key == "cost_currency")
|
||||
.expect("cost_currency row");
|
||||
|
||||
(
|
||||
row.value.clone(),
|
||||
view.row_display_value(row),
|
||||
app.cost_currency,
|
||||
app.ui_locale,
|
||||
)
|
||||
}
|
||||
|
||||
fn type_filter(view: &mut ConfigView, text: &str) {
|
||||
for ch in text.chars() {
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
@@ -2231,6 +2323,62 @@ mod tests {
|
||||
assert_eq!(row.value, "https://ui-config-view.local/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() {
|
||||
let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n");
|
||||
let app = create_test_app();
|
||||
assert_eq!(app.ui_locale, Locale::ZhHans);
|
||||
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
|
||||
|
||||
let view = ConfigView::new_for_app(&app);
|
||||
let row = view
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.key == "cost_currency")
|
||||
.expect("cost_currency row");
|
||||
|
||||
assert_eq!(row.value, "usd");
|
||||
assert_eq!(view.row_display_value(row), "usd (effective cny)");
|
||||
assert_eq!(Settings::load().expect("settings").cost_currency, "usd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_view_cost_currency_aliases_matching_effective_currency_are_silent() {
|
||||
for alias in ["rmb", "yuan", "¥"] {
|
||||
let (saved_value, display_value, effective_currency, locale) =
|
||||
cost_currency_row_for_settings(&format!(
|
||||
"locale = \"zh-Hans\"\ncost_currency = \"{alias}\"\n"
|
||||
));
|
||||
|
||||
assert_eq!(locale, Locale::ZhHans);
|
||||
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(saved_value, alias);
|
||||
assert_eq!(display_value, alias);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_view_cost_currency_matching_cny_setting_is_silent() {
|
||||
let (saved_value, display_value, effective_currency, locale) =
|
||||
cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n");
|
||||
|
||||
assert_eq!(locale, Locale::ZhHans);
|
||||
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(saved_value, "cny");
|
||||
assert_eq!(display_value, "cny");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_view_cost_currency_non_zh_hans_locale_uses_saved_currency() {
|
||||
let (saved_value, display_value, effective_currency, locale) =
|
||||
cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n");
|
||||
|
||||
assert_eq!(locale, Locale::En);
|
||||
assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny);
|
||||
assert_eq!(saved_value, "cny");
|
||||
assert_eq!(display_value, "cny");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_view_exposes_all_available_saved_settings() {
|
||||
let app = create_test_app();
|
||||
|
||||
@@ -19,8 +19,12 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::config::StatusItem;
|
||||
use crate::localization::truncate_to_width;
|
||||
use crate::palette;
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const STATUS_PICKER_SELECTION_BG: ratatui::style::Color = ratatui::style::Color::Rgb(54, 72, 104);
|
||||
|
||||
/// Picker state. We hold both the user's working selection AND the original
|
||||
/// snapshot so Esc can perfectly revert the live preview.
|
||||
@@ -62,16 +66,21 @@ impl StatusPickerView {
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
if self.rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.cursor == 0 {
|
||||
self.cursor = self.rows.len() - 1;
|
||||
} else {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let max = self.rows.len().saturating_sub(1);
|
||||
if self.cursor < max {
|
||||
self.cursor += 1;
|
||||
if self.rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.cursor = (self.cursor + 1) % self.rows.len();
|
||||
}
|
||||
|
||||
fn toggle_current(&mut self) {
|
||||
@@ -201,7 +210,16 @@ impl ModalView for StatusPickerView {
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (idx, item) in self.rows.iter().enumerate() {
|
||||
let visible_rows = inner.height.saturating_sub(2) as usize;
|
||||
let row_start = visible_row_start(self.rows.len(), self.cursor, visible_rows);
|
||||
|
||||
for (idx, item) in self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(row_start)
|
||||
.take(visible_rows)
|
||||
{
|
||||
let checked = *self.selected.get(idx).unwrap_or(&false);
|
||||
let is_cursor = idx == self.cursor;
|
||||
let mark = if checked { "[✓]" } else { "[ ]" };
|
||||
@@ -225,20 +243,50 @@ impl ModalView for StatusPickerView {
|
||||
};
|
||||
let pointer = if is_cursor { "▸" } else { " " };
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {pointer} "), row_style),
|
||||
Span::styled(mark.to_string(), row_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(item.label().to_string(), row_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("({})", item.hint()), hint_style),
|
||||
]));
|
||||
if is_cursor {
|
||||
let selected_style = Style::default()
|
||||
.fg(palette::SELECTION_TEXT)
|
||||
.bg(STATUS_PICKER_SELECTION_BG)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let line = status_row_text(pointer, mark, item, inner.width as usize);
|
||||
lines.push(Line::from(Span::styled(line, selected_style)));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {pointer} "), row_style),
|
||||
Span::styled(mark.to_string(), row_style),
|
||||
Span::styled(" ", row_style),
|
||||
Span::styled(item.label().to_string(), row_style),
|
||||
Span::styled(" ", row_style),
|
||||
Span::styled(format!("({})", item.hint()), hint_style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_row_start(total_rows: usize, cursor: usize, visible_rows: usize) -> usize {
|
||||
if total_rows == 0 || visible_rows == 0 || total_rows <= visible_rows {
|
||||
return 0;
|
||||
}
|
||||
let max_start = total_rows - visible_rows;
|
||||
cursor
|
||||
.saturating_add(1)
|
||||
.saturating_sub(visible_rows)
|
||||
.min(max_start)
|
||||
}
|
||||
|
||||
fn status_row_text(pointer: &str, mark: &str, item: &StatusItem, width: usize) -> String {
|
||||
let text = format!(" {pointer} {mark} {} ({})", item.label(), item.hint());
|
||||
let mut text = truncate_to_width(&text, width);
|
||||
let current_width = text.width();
|
||||
if current_width < width {
|
||||
text.push_str(&" ".repeat(width - current_width));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -317,18 +365,32 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_cursor_within_bounds() {
|
||||
fn arrow_keys_wrap_cursor_at_edges() {
|
||||
let active = StatusItem::default_footer();
|
||||
let mut view = StatusPickerView::new(&active);
|
||||
assert_eq!(view.cursor, 0);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(view.cursor, StatusItem::all().len() - 1);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
assert_eq!(view.cursor, 0);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
assert_eq!(view.cursor, 1);
|
||||
view.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
assert_eq!(view.cursor, 0);
|
||||
// Move past the bottom shouldn't wrap.
|
||||
for _ in 0..StatusItem::all().len() + 5 {
|
||||
view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
}
|
||||
assert_eq!(view.cursor, StatusItem::all().len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_row_start_keeps_cursor_in_view() {
|
||||
assert_eq!(visible_row_start(14, 0, 8), 0);
|
||||
assert_eq!(visible_row_start(14, 7, 8), 0);
|
||||
assert_eq!(visible_row_start(14, 8, 8), 1);
|
||||
assert_eq!(visible_row_start(14, 13, 8), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_row_text_fills_available_width() {
|
||||
let text = status_row_text("▸", "[ ]", &StatusItem::LastToolElapsed, 40);
|
||||
assert_eq!(text.width(), 40);
|
||||
assert!(text.starts_with(" ▸ [ ] Last tool elapsed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +573,17 @@ impl Renderable for FooterWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the whole footer row first so stale transcript glyphs from
|
||||
// the previous frame cannot survive in cells this frame's spans do not
|
||||
// touch (#2244).
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
buf[(x, y)]
|
||||
.set_symbol(" ")
|
||||
.set_style(Style::default().bg(self.props.footer_bg));
|
||||
}
|
||||
}
|
||||
|
||||
let preview_left_spans = self.left_spans(available_width);
|
||||
let preview_left_width = span_width(&preview_left_spans);
|
||||
let right_budget = available_width
|
||||
@@ -652,6 +663,8 @@ mod tests {
|
||||
use crate::palette;
|
||||
use crate::tui::app::{App, AppMode, TuiOptions};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
};
|
||||
@@ -1377,4 +1390,36 @@ mod tests {
|
||||
assert!(!rendered.contains("agent"));
|
||||
assert!(!rendered.contains("deepseek-v4-flash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_clears_stale_cells_across_entire_footer_row() {
|
||||
let app = make_app();
|
||||
let widget = FooterWidget::new(idle_props_for(&app));
|
||||
let area = Rect::new(0, 0, 48, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
for x in area.x..area.x.saturating_add(area.width) {
|
||||
buf[(x, area.y)]
|
||||
.set_symbol("X")
|
||||
.set_style(Style::default().fg(Color::Red).bg(Color::Blue));
|
||||
}
|
||||
|
||||
widget.render(area, &mut buf);
|
||||
|
||||
let rendered: String = (area.x..area.x.saturating_add(area.width))
|
||||
.map(|x| buf[(x, area.y)].symbol())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!rendered.contains('X'),
|
||||
"footer render must clear stale row content before painting: {rendered:?}"
|
||||
);
|
||||
for x in area.x..area.x.saturating_add(area.width) {
|
||||
assert_eq!(
|
||||
buf[(x, area.y)].bg,
|
||||
app.ui_theme.footer_bg,
|
||||
"footer background should cover the full row"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,8 @@ impl ChatWidget {
|
||||
&cell_revisions,
|
||||
content_area.width.max(1),
|
||||
render_options,
|
||||
&app.folded_thinking,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
// Slow path: clone non-collapsed cells into filtered vecs so
|
||||
@@ -203,6 +205,8 @@ impl ChatWidget {
|
||||
&filtered_revs,
|
||||
content_area.width.max(1),
|
||||
render_options,
|
||||
&app.folded_thinking,
|
||||
Some(&app.collapsed_cell_map),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2080,14 +2084,26 @@ pub(crate) fn slash_completion_hints(
|
||||
let mut entries: Vec<SlashMenuEntry> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let prefix_lower = prefix.to_ascii_lowercase();
|
||||
let user_commands = if completing_skill_arg.is_none() {
|
||||
commands::user_commands::load_user_commands(workspace)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// ── Phase 1: prefix (starts_with) matches ─────────────────────────
|
||||
// Highest priority — preserves existing exact-prefix completion.
|
||||
if completing_skill_arg.is_none() {
|
||||
for name in commands::all_command_names_matching(prefix, workspace) {
|
||||
for name in all_command_names_matching_loaded(prefix, &user_commands) {
|
||||
seen.insert(name.clone());
|
||||
let command_key = name.trim_start_matches('/');
|
||||
push_command_entry(&mut entries, &name, command_key, &prefix_lower, locale);
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
&name,
|
||||
command_key,
|
||||
&prefix_lower,
|
||||
locale,
|
||||
&user_commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2106,7 +2122,14 @@ pub(crate) fn slash_completion_hints(
|
||||
.any(|a| a.to_ascii_lowercase().contains(&prefix_lower));
|
||||
if cmd_lower.contains(&prefix_lower) || alias_match {
|
||||
seen.insert(name.clone());
|
||||
push_command_entry(&mut entries, &name, cmd.name, &prefix_lower, locale);
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
&name,
|
||||
cmd.name,
|
||||
&prefix_lower,
|
||||
locale,
|
||||
&user_commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2126,7 +2149,14 @@ pub(crate) fn slash_completion_hints(
|
||||
.any(|a| fuzzy_chars_in_order(&prefix_lower, &a.to_ascii_lowercase()));
|
||||
if fuzzy_chars_in_order(&prefix_lower, &cmd_lower) || alias_match {
|
||||
seen.insert(name.clone());
|
||||
push_command_entry(&mut entries, &name, cmd.name, &prefix_lower, locale);
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
&name,
|
||||
cmd.name,
|
||||
&prefix_lower,
|
||||
locale,
|
||||
&user_commands,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2219,6 +2249,31 @@ pub(crate) fn slash_completion_hints(
|
||||
entries.into_iter().take(limit).collect()
|
||||
}
|
||||
|
||||
fn all_command_names_matching_loaded(
|
||||
prefix: &str,
|
||||
user_commands: &[(String, String)],
|
||||
) -> Vec<String> {
|
||||
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
|
||||
let mut result: Vec<String> = commands::COMMANDS
|
||||
.iter()
|
||||
.filter(|cmd| {
|
||||
cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix))
|
||||
})
|
||||
.map(|cmd| format!("/{}", cmd.name))
|
||||
.collect();
|
||||
|
||||
result.extend(
|
||||
user_commands
|
||||
.iter()
|
||||
.filter(|(name, _)| name.starts_with(&prefix))
|
||||
.map(|(name, _)| format!("/{name}")),
|
||||
);
|
||||
|
||||
result.sort();
|
||||
result.dedup();
|
||||
result
|
||||
}
|
||||
|
||||
/// Push a built-in command entry to the slash menu, resolving description
|
||||
/// and alias hints.
|
||||
fn push_command_entry(
|
||||
@@ -2227,6 +2282,7 @@ fn push_command_entry(
|
||||
command_key: &str,
|
||||
prefix_lower: &str,
|
||||
locale: crate::localization::Locale,
|
||||
user_commands: &[(String, String)],
|
||||
) {
|
||||
let (description, alias_hint) = if let Some(info) = commands::get_command_info(command_key) {
|
||||
let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) {
|
||||
@@ -2256,7 +2312,25 @@ fn push_command_entry(
|
||||
};
|
||||
(desc, hint)
|
||||
} else {
|
||||
(String::from("User-defined command"), None)
|
||||
let mut description = String::from("User-defined command");
|
||||
let mut argument_hint = None;
|
||||
if let Some((_, content)) = user_commands.iter().find(|(key, _)| key == command_key) {
|
||||
let (metadata, _) = commands::user_commands::parse_frontmatter(content);
|
||||
for (key, value) in metadata {
|
||||
match key.as_str() {
|
||||
"description" => description = value,
|
||||
"argument-hint" => argument_hint = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(hint) = argument_hint {
|
||||
if !hint.trim().is_empty() {
|
||||
description.push_str(" ");
|
||||
description.push_str(hint.trim());
|
||||
}
|
||||
}
|
||||
(description, None)
|
||||
};
|
||||
entries.push(SlashMenuEntry {
|
||||
name: name.to_string(),
|
||||
@@ -2501,7 +2575,8 @@ mod tests {
|
||||
SlashMenuEntry, apply_selection_to_line, build_empty_state_lines, composer_height,
|
||||
composer_max_height, composer_min_input_rows, composer_top_padding, compute_takeover_area,
|
||||
cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines,
|
||||
should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text,
|
||||
push_command_entry, should_render_empty_state, slash_completion_hints, wrap_input_lines,
|
||||
wrap_text,
|
||||
};
|
||||
use crate::config::{ApiProvider, Config};
|
||||
use crate::localization::Locale;
|
||||
@@ -2761,6 +2836,80 @@ mod tests {
|
||||
assert!(!hints.iter().any(|hint| hint.name == "/codewhale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_use_user_command_frontmatter_description() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".deepseek").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("git-scan.md"),
|
||||
"---\ndescription: Scan nested git repositories\n---\nscan",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let hints = slash_completion_hints(
|
||||
"/git",
|
||||
128,
|
||||
&[],
|
||||
Locale::En,
|
||||
Some(tmp.path()),
|
||||
ApiProvider::Deepseek,
|
||||
);
|
||||
let entry = hints
|
||||
.iter()
|
||||
.find(|hint| hint.name == "/git-scan")
|
||||
.expect("custom command should be present");
|
||||
assert_eq!(entry.description, "Scan nested git repositories");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_use_user_command_argument_hint() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let commands_dir = tmp.path().join(".deepseek").join("commands");
|
||||
std::fs::create_dir_all(&commands_dir).unwrap();
|
||||
std::fs::write(
|
||||
commands_dir.join("deploy.md"),
|
||||
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let hints = slash_completion_hints(
|
||||
"/deploy",
|
||||
128,
|
||||
&[],
|
||||
Locale::En,
|
||||
Some(tmp.path()),
|
||||
ApiProvider::Deepseek,
|
||||
);
|
||||
let entry = hints
|
||||
.iter()
|
||||
.find(|hint| hint.name == "/deploy")
|
||||
.expect("custom command should be present");
|
||||
assert_eq!(entry.description, "Deploy target <env>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_regression_push_command_entry_uses_preloaded_user_command_frontmatter() {
|
||||
let user_commands = vec![(
|
||||
"deploy".to_string(),
|
||||
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy".to_string(),
|
||||
)];
|
||||
let mut entries = Vec::new();
|
||||
|
||||
push_command_entry(
|
||||
&mut entries,
|
||||
"/deploy",
|
||||
"deploy",
|
||||
"deploy",
|
||||
Locale::En,
|
||||
&user_commands,
|
||||
);
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].name, "/deploy");
|
||||
assert_eq!(entries[0].description, "Deploy target <env>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_completion_hints_hide_skills_from_top_level_menu() {
|
||||
let cached_skills = vec![
|
||||
|
||||
@@ -107,6 +107,11 @@ fn collect(workspace: &Path) -> Option<String> {
|
||||
Some(format!("{branch} | {status}"))
|
||||
}
|
||||
|
||||
pub(crate) fn branch_from_context(context: &str) -> Option<&str> {
|
||||
let (branch, _) = context.rsplit_once(" | ")?;
|
||||
(!branch.is_empty()).then_some(branch)
|
||||
}
|
||||
|
||||
pub(super) fn branch(workspace: &Path) -> Option<String> {
|
||||
let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||
let branch = branch.trim().to_string();
|
||||
|
||||
+101
-19
@@ -13,6 +13,8 @@ use crate::tools::spec::{
|
||||
ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
|
||||
};
|
||||
|
||||
const DEFAULT_VISION_MAX_OUTPUT_TOKENS: u32 = 4096;
|
||||
|
||||
pub struct ImageAnalyzeTool {
|
||||
config: VisionModelConfig,
|
||||
client: reqwest::Client,
|
||||
@@ -67,6 +69,59 @@ impl ImageAnalyzeTool {
|
||||
fn api_key(&self) -> String {
|
||||
self.config.api_key.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_xiaomi_mimo_model(model: &str) -> bool {
|
||||
let normalized = model.trim().to_ascii_lowercase();
|
||||
let normalized = normalized.strip_prefix("xiaomi/").unwrap_or(&normalized);
|
||||
normalized.starts_with("mimo-")
|
||||
}
|
||||
|
||||
fn uses_max_completion_tokens(config: &VisionModelConfig) -> bool {
|
||||
if Self::is_xiaomi_mimo_model(&config.model) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let base_url = config.base_url.as_deref().unwrap_or_default();
|
||||
let Ok(url) = reqwest::Url::parse(base_url) else {
|
||||
return false;
|
||||
};
|
||||
let Some(domain) = url.domain() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
domain.eq_ignore_ascii_case("xiaomimimo.com")
|
||||
|| domain.to_ascii_lowercase().ends_with(".xiaomimimo.com")
|
||||
}
|
||||
|
||||
fn request_payload(&self, prompt: &str, image_data: &str, mime_type: &str) -> Value {
|
||||
let mut payload = json!({
|
||||
"model": self.config.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": format!("data:{};base64,{}", mime_type, image_data)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"temperature": 0.7
|
||||
});
|
||||
|
||||
let token_limit_field = if Self::uses_max_completion_tokens(&self.config) {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
payload[token_limit_field] = json!(DEFAULT_VISION_MAX_OUTPUT_TOKENS);
|
||||
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -122,25 +177,7 @@ impl ToolSpec for ImageAnalyzeTool {
|
||||
let resolved_path = context.workspace.join(image_path_buf);
|
||||
let (image_data, mime_type) = Self::read_image_file(&resolved_path).await?;
|
||||
|
||||
let payload = json!({
|
||||
"model": self.config.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": format!("data:{};base64,{}", mime_type, image_data)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7
|
||||
});
|
||||
let payload = self.request_payload(prompt, &image_data, &mime_type);
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url());
|
||||
let api_key = self.api_key();
|
||||
@@ -262,6 +299,51 @@ mod tests {
|
||||
assert!(err.to_string().contains("Unsupported image format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_vision_payload_uses_max_tokens() {
|
||||
let tool = ImageAnalyzeTool::new(fake_config());
|
||||
|
||||
let payload = tool.request_payload("describe", "abc123", "image/png");
|
||||
|
||||
assert_eq!(
|
||||
payload.get("max_tokens").and_then(Value::as_u64),
|
||||
Some(u64::from(DEFAULT_VISION_MAX_OUTPUT_TOKENS))
|
||||
);
|
||||
assert!(payload.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_vision_payload_uses_max_completion_tokens() {
|
||||
let mut config = fake_config();
|
||||
config.model = "mimo-v2.5".to_string();
|
||||
config.base_url = Some("https://api.xiaomimimo.com/v1".to_string());
|
||||
let tool = ImageAnalyzeTool::new(config);
|
||||
|
||||
let payload = tool.request_payload("describe", "abc123", "image/png");
|
||||
|
||||
assert_eq!(
|
||||
payload.get("max_completion_tokens").and_then(Value::as_u64),
|
||||
Some(u64::from(DEFAULT_VISION_MAX_OUTPUT_TOKENS))
|
||||
);
|
||||
assert!(payload.get("max_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiaomi_mimo_vision_payload_uses_max_completion_tokens_with_custom_proxy() {
|
||||
let mut config = fake_config();
|
||||
config.model = "mimo-v2.5".to_string();
|
||||
config.base_url = Some("https://vision-proxy.example.invalid/v1".to_string());
|
||||
let tool = ImageAnalyzeTool::new(config);
|
||||
|
||||
let payload = tool.request_payload("describe", "abc123", "image/png");
|
||||
|
||||
assert_eq!(
|
||||
payload.get("max_completion_tokens").and_then(Value::as_u64),
|
||||
Some(u64::from(DEFAULT_VISION_MAX_OUTPUT_TOKENS))
|
||||
);
|
||||
assert!(payload.get("max_tokens").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_rejects_absolute_path() {
|
||||
// Trust-boundary pin: image_path must stay inside the workspace
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! - pinned message indices that compaction should preserve
|
||||
|
||||
use crate::models::{ContentBlock, Message};
|
||||
use crate::workspace_discovery::{
|
||||
DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery, should_skip_unignored_discovery_entry,
|
||||
};
|
||||
use ignore::WalkBuilder;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -269,32 +272,6 @@ const COMPLETIONS_WALK_DEPTH: usize = 6;
|
||||
/// above the actual entry count and the cap is a no-op.
|
||||
const FILE_INDEX_MAX_ENTRIES: usize = 50_000;
|
||||
|
||||
/// Directories that must remain discoverable for `@`-mention completion and
|
||||
/// fuzzy file resolution even when excluded by `.gitignore`. AI-tool
|
||||
/// convention directories (`.deepseek/`, `.cursor/`, `.claude/`, `.agents/`)
|
||||
/// are routinely gitignored, but users need to `@`-mention files inside them.
|
||||
const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
|
||||
|
||||
/// Subdirectories under `DISCOVERY_ALWAYS_DIRS` that must NOT be indexed
|
||||
/// even when the parent dir is walked with gitignore disabled. These are
|
||||
/// large, machine-generated, or sensitive paths that would blow up the
|
||||
/// walker (e.g. `.deepseek/snapshots/` — the snapshot side repo that
|
||||
/// #1112 caps at 500 MB; indexing it would trigger the same OOM/hang
|
||||
/// the cap was built to prevent).
|
||||
const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] = &[".deepseek/snapshots"];
|
||||
|
||||
/// Check whether a path resolved against `walk_root` falls inside any
|
||||
/// `DISCOVERY_EXCLUDED_SUBDIRS` entry. Used to keep the snapshot side
|
||||
/// repo (`.deepseek/snapshots/`) out of the completion/index walk.
|
||||
fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
|
||||
for excluded in DISCOVERY_EXCLUDED_SUBDIRS {
|
||||
if path.starts_with(walk_root.join(excluded)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Configure a `WalkBuilder` for workspace discovery: hidden files, no
|
||||
/// symlink following, depth-limited, custom `.deepseekignore` honored,
|
||||
/// and gitignore overrides for AI-tool dot-directories so `@`-completion
|
||||
@@ -494,7 +471,10 @@ fn local_reference_paths(root: &Path, limit: usize) -> Vec<PathBuf> {
|
||||
.git_global(false)
|
||||
.git_exclude(false);
|
||||
let _ = builder.add_custom_ignore_filename(".deepseekignore");
|
||||
builder.filter_entry(|entry| !should_skip_local_reference_dir(entry.path()));
|
||||
let root_for_filter = root.to_path_buf();
|
||||
builder.filter_entry(move |entry| {
|
||||
!should_skip_unignored_discovery_entry(&root_for_filter, entry.path())
|
||||
});
|
||||
|
||||
for entry in builder.build().flatten() {
|
||||
if out.len() >= limit {
|
||||
@@ -514,25 +494,6 @@ fn local_reference_paths(root: &Path, limit: usize) -> Vec<PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
fn should_skip_local_reference_dir(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
name,
|
||||
".git"
|
||||
| "target"
|
||||
| "node_modules"
|
||||
| ".venv"
|
||||
| "venv"
|
||||
| "env"
|
||||
| "dist"
|
||||
| "build"
|
||||
| "__pycache__"
|
||||
| ".ruff_cache"
|
||||
)
|
||||
}
|
||||
|
||||
impl Clone for Workspace {
|
||||
fn clone(&self) -> Self {
|
||||
// Don't carry the cached file_index — clones get a fresh OnceLock so
|
||||
@@ -1523,6 +1484,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_completions_skip_hidden_worktrees_and_build_bulk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join(".gitignore"), ".worktrees/\n.generated/\n").unwrap();
|
||||
|
||||
std::fs::create_dir_all(root.join(".worktrees/release/src")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".worktrees/release/src/worktree-only.rs"),
|
||||
"fn main() {}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(root.join(".worktrees/release/target/debug")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".worktrees/release/target/debug/generated.o"),
|
||||
"object",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".claude/worktrees/agent/src/agent-only.md"),
|
||||
"agent note",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
|
||||
std::fs::write(root.join(".claude/commands/keep.md"), "command").unwrap();
|
||||
|
||||
std::fs::create_dir_all(root.join(".generated/specs")).unwrap();
|
||||
std::fs::write(root.join(".generated/specs/device-layout.md"), "layout").unwrap();
|
||||
|
||||
let ws = Workspace::with_cwd(root.to_path_buf(), Some(root.to_path_buf()));
|
||||
|
||||
let worktree_entries = ws.completions(".worktrees", 32);
|
||||
assert!(
|
||||
worktree_entries
|
||||
.iter()
|
||||
.all(|entry| !entry.starts_with(".worktrees/")),
|
||||
"hidden release worktrees must stay out of completions: {worktree_entries:?}",
|
||||
);
|
||||
|
||||
let claude_worktree_entries = ws.completions(".claude/worktrees", 32);
|
||||
assert!(
|
||||
claude_worktree_entries
|
||||
.iter()
|
||||
.all(|entry| !entry.starts_with(".claude/worktrees/")),
|
||||
".claude/worktrees must stay out of completions: {claude_worktree_entries:?}",
|
||||
);
|
||||
|
||||
let generated_entries = ws.completions(".generated/specs", 32);
|
||||
assert!(
|
||||
generated_entries
|
||||
.iter()
|
||||
.any(|entry| entry == ".generated/specs/device-layout.md"),
|
||||
"explicit user-generated hidden folders should still complete: {generated_entries:?}",
|
||||
);
|
||||
|
||||
let command_entries = ws.completions(".claude/commands", 32);
|
||||
assert!(
|
||||
command_entries
|
||||
.iter()
|
||||
.any(|entry| entry == ".claude/commands/keep.md"),
|
||||
"normal .claude command files should still complete: {command_entries:?}",
|
||||
);
|
||||
|
||||
assert!(
|
||||
ws.resolve("worktree-only.rs").is_err(),
|
||||
"fuzzy resolution must not index files from hidden release worktrees"
|
||||
);
|
||||
assert!(
|
||||
ws.resolve("agent-only.md").is_err(),
|
||||
"fuzzy resolution must not index files from .claude/worktrees"
|
||||
);
|
||||
assert!(ws.resolve("keep.md").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_index_resolves_hidden_and_ignored_files_except_deepseekignored() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Shared workspace discovery filters for UI path pickers and mentions.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Directories that must remain discoverable for `@`-mention completion and
|
||||
/// fuzzy file resolution even when excluded by `.gitignore`.
|
||||
pub(crate) const DISCOVERY_ALWAYS_DIRS: &[&str] = &[".deepseek", ".cursor", ".claude", ".agents"];
|
||||
|
||||
/// Root-relative directories that are too large or generated to discover
|
||||
/// with gitignore disabled. Exact user-specified paths may still resolve.
|
||||
const DISCOVERY_EXCLUDED_SUBDIRS: &[&str] =
|
||||
&[".deepseek/snapshots", ".worktrees", ".claude/worktrees"];
|
||||
|
||||
/// Directory basenames that should not be traversed by fallback discovery
|
||||
/// walks that deliberately disable gitignore.
|
||||
const DISCOVERY_EXCLUDED_DIR_NAMES: &[&str] = &[
|
||||
".git",
|
||||
"target",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".turbo",
|
||||
"coverage",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
];
|
||||
|
||||
/// Check whether `path` is under a root-relative excluded discovery subtree.
|
||||
pub(crate) fn path_is_excluded_from_discovery(walk_root: &Path, path: &Path) -> bool {
|
||||
DISCOVERY_EXCLUDED_SUBDIRS
|
||||
.iter()
|
||||
.any(|excluded| path.starts_with(walk_root.join(excluded)))
|
||||
}
|
||||
|
||||
/// Filter for walks that turn off gitignore to surface explicit hidden paths.
|
||||
pub(crate) fn should_skip_unignored_discovery_entry(walk_root: &Path, path: &Path) -> bool {
|
||||
if path == walk_root {
|
||||
return false;
|
||||
}
|
||||
|
||||
if path_is_excluded_from_discovery(walk_root, path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| DISCOVERY_EXCLUDED_DIR_NAMES.contains(&name))
|
||||
}
|
||||
@@ -4,6 +4,8 @@ use std::fs;
|
||||
|
||||
#[path = "../src/eval.rs"]
|
||||
mod eval;
|
||||
#[path = "../src/shell_dispatcher.rs"]
|
||||
mod shell_dispatcher;
|
||||
|
||||
use eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
use futures_util::StreamExt;
|
||||
|
||||
#[path = "../src/models.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod models;
|
||||
|
||||
#[path = "support/llm_client.rs"]
|
||||
mod llm_client;
|
||||
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::llm_client::mock::{MockLlmClient, canned};
|
||||
use crate::models::{ContentBlock, Message, MessageRequest};
|
||||
|
||||
fn user_message(text: &str) -> Message {
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_thinking_tool_call(
|
||||
thinking: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
input: serde_json::Value,
|
||||
) -> Message {
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: thinking.to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
input,
|
||||
caller: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_result_message(tool_use_id: &str, content: &str) -> Message {
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
content: content.to_string(),
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_request(messages: Vec<Message>) -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages,
|
||||
max_tokens: 4096,
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reasoning_content_is_replayed_after_thinking_tool_call() {
|
||||
let mock = MockLlmClient::new(vec![]);
|
||||
|
||||
mock.push_turn(vec![
|
||||
canned::message_start("r1"),
|
||||
canned::thinking_delta(0, "I should inspect /tmp before answering."),
|
||||
canned::tool_use_block_start(1, "call_a", "list_dir"),
|
||||
canned::tool_input_delta(1, r#"{"path":"/tmp"}"#),
|
||||
canned::block_stop(1),
|
||||
canned::message_delta("tool_use", None),
|
||||
canned::message_stop(),
|
||||
]);
|
||||
|
||||
mock.push_factory(|request| {
|
||||
let assistant = request
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|message| message.role == "assistant")
|
||||
.expect("follow-up request must include the prior assistant tool-call turn");
|
||||
|
||||
assert!(
|
||||
assistant
|
||||
.content
|
||||
.iter()
|
||||
.any(|block| matches!(block, ContentBlock::Thinking { .. })),
|
||||
"DeepSeek V4 follow-up requests must replay reasoning_content on the assistant tool-call turn"
|
||||
);
|
||||
|
||||
canned::simple_text_turn("I see the /tmp entries.")
|
||||
});
|
||||
|
||||
let mut first = mock
|
||||
.create_message_stream(make_request(vec![user_message("list /tmp")]))
|
||||
.await
|
||||
.expect("first stream opens");
|
||||
while first.next().await.is_some() {}
|
||||
|
||||
let mut second = mock
|
||||
.create_message_stream(make_request(vec![
|
||||
user_message("list /tmp"),
|
||||
assistant_thinking_tool_call(
|
||||
"I should inspect /tmp before answering.",
|
||||
"call_a",
|
||||
"list_dir",
|
||||
serde_json::json!({ "path": "/tmp" }),
|
||||
),
|
||||
tool_result_message("call_a", "/tmp/file1\n/tmp/file2"),
|
||||
]))
|
||||
.await
|
||||
.expect("second stream opens");
|
||||
while second.next().await.is_some() {}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ drives turns through Chat Completions.
|
||||
- `mod.rs` - `LspManager` — lazy per-language transport pool + config
|
||||
- `client.rs` - `StdioLspTransport` — JSON-RPC over stdio with `didOpen`/`didChange`/`publishDiagnostics`
|
||||
- `diagnostics.rs` - Diagnostic types, severity, and HTML-block renderer
|
||||
- `registry.rs` - Language detection and default server map (rust-analyzer, pyright, gopls, clangd, typescript-language-server)
|
||||
- `registry.rs` - Language detection and default server map (rust-analyzer, pyright, gopls, clangd, typescript-language-server, jdtls, vue-language-server)
|
||||
- Wired into the engine via `core/engine/lsp_hooks.rs` — called after every successful edit
|
||||
|
||||
### Security
|
||||
|
||||
+29
-6
@@ -63,8 +63,8 @@ provider's keyring entry.
|
||||
|
||||
For hosted, generic OpenAI-compatible, or self-hosted providers, set
|
||||
`provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`,
|
||||
`"openrouter"`, `"novita"`, `"fireworks"`, `"moonshot"`, `"sglang"`,
|
||||
`"vllm"`, or `"ollama"` or pass `codewhale --provider <name>`.
|
||||
`"openrouter"`, `"xiaomi-mimo"`, `"novita"`, `"fireworks"`, `"moonshot"`,
|
||||
`"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider <name>`.
|
||||
For the provider-by-provider registry, including auth variables, default base
|
||||
URLs, model IDs, and capability metadata, see [PROVIDERS.md](PROVIDERS.md).
|
||||
The facade saves provider credentials to the shared user config and forwards
|
||||
@@ -73,6 +73,7 @@ the resolved key, base URL, provider, and model to the TUI process. Use
|
||||
`codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` or
|
||||
`codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"` or
|
||||
`codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"` or
|
||||
`codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_MIMO_API_KEY"` or
|
||||
`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"`
|
||||
to save provider keys through the facade. The generic `openai` provider defaults
|
||||
to `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to
|
||||
@@ -129,6 +130,25 @@ environment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated
|
||||
and `Content-Type` are managed by the client and are not overridden by this
|
||||
setting.
|
||||
|
||||
### Vision Model
|
||||
|
||||
CodeWhale's chat provider and `image_analyze` tool are configured separately.
|
||||
The main chat path remains the selected text/tool provider; image analysis runs
|
||||
through `[vision_model]` when the `vision_model` feature is enabled.
|
||||
|
||||
Xiaomi's current image-understanding docs include `mimo-v2.5` for image input.
|
||||
To use MiMo for `image_analyze`, configure the vision model explicitly:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
vision_model = true
|
||||
|
||||
[vision_model]
|
||||
model = "mimo-v2.5"
|
||||
api_key = "YOUR_XIAOMI_MIMO_API_KEY"
|
||||
base_url = "https://api.xiaomimimo.com/v1"
|
||||
```
|
||||
|
||||
To bootstrap MCP and skills directories at their resolved paths, run `codewhale-tui setup`.
|
||||
To only scaffold MCP, run `codewhale-tui mcp init`.
|
||||
|
||||
@@ -207,7 +227,7 @@ aliases. When both forms are set the `CODEWHALE_*` value wins; the
|
||||
`DEEPSEEK_*` form is kept for older shells:
|
||||
|
||||
- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —
|
||||
`deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|moonshot|sglang|vllm|ollama`
|
||||
`deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|xiaomi-mimo|novita|fireworks|moonshot|sglang|vllm|ollama`
|
||||
- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider
|
||||
- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider
|
||||
|
||||
@@ -232,6 +252,9 @@ Remaining variables:
|
||||
- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`
|
||||
- `OPENROUTER_API_KEY`
|
||||
- `OPENROUTER_BASE_URL`
|
||||
- `XIAOMI_MIMO_API_KEY` or `MIMO_API_KEY`
|
||||
- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`
|
||||
- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`
|
||||
- `NOVITA_API_KEY`
|
||||
- `NOVITA_BASE_URL`
|
||||
- `FIREWORKS_API_KEY`
|
||||
@@ -441,10 +464,10 @@ If you are upgrading from older releases:
|
||||
|
||||
### Core keys (used by the TUI/engine)
|
||||
|
||||
- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `openrouter` targets `https://openrouter.ai/api/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`.
|
||||
- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`.
|
||||
- `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it.
|
||||
- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.
|
||||
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias.
|
||||
- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.
|
||||
- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias.
|
||||
- `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.
|
||||
- `allow_shell` (bool, optional): defaults to `true` (sandboxed).
|
||||
- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.
|
||||
|
||||
@@ -93,6 +93,26 @@ Do not bake API keys, SSH private keys, or other secrets into custom images.
|
||||
Pass API keys at runtime and mount any SSH material deliberately, preferably
|
||||
read-only and only for projects that need it.
|
||||
|
||||
### Compose toolbox template
|
||||
|
||||
If you prefer a repeatable `docker compose` entry point, use
|
||||
[`docs/examples/compose.toolbox.yml`](examples/compose.toolbox.yml). It builds
|
||||
the toolbox image from [`docs/examples/Dockerfile.toolbox`](examples/Dockerfile.toolbox)
|
||||
and keeps the project state volume explicit:
|
||||
|
||||
```bash
|
||||
CODEWHALE_IMAGE=ghcr.io/hmbown/codewhale:vX.Y.Z \
|
||||
CODEWHALE_TOOLBOX_IMAGE=codewhale-toolbox:my-project \
|
||||
CODEWHALE_HOME_VOLUME=codewhale-my-project-home \
|
||||
CODEWHALE_WORKSPACE="$PWD" \
|
||||
docker compose -f docs/examples/compose.toolbox.yml run --rm codewhale
|
||||
```
|
||||
|
||||
Use a different `CODEWHALE_TOOLBOX_IMAGE` and `CODEWHALE_HOME_VOLUME` for each
|
||||
project that needs an independent toolchain or independent `.deepseek` state.
|
||||
The Compose file also shows opt-in, read-only mounts for SSH material and local
|
||||
CA certificates; keep those commented out unless the project needs them.
|
||||
|
||||
## Multiple independent projects
|
||||
|
||||
Use one named state volume per project so sessions, config, skills, memory, and
|
||||
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
# CodeWhale User Guide
|
||||
|
||||
This guide is for your first hour with CodeWhale. It explains the main
|
||||
workflow, the important safety controls, and where to go next when you need a
|
||||
complete reference.
|
||||
|
||||
CodeWhale has deeper reference documents for installation, configuration,
|
||||
providers, modes, keybindings, tools, and operations. Use this page as a guided
|
||||
walkthrough, then follow the "Next" links when you need every option.
|
||||
|
||||
## 1. Welcome to CodeWhale
|
||||
|
||||
CodeWhale is a terminal coding agent. You run it from a workspace, give it a
|
||||
task, and it can use structured tools to inspect files, run commands, edit
|
||||
code, and report back with evidence.
|
||||
|
||||
The important difference from a normal chat model is that CodeWhale is built
|
||||
around a harness:
|
||||
|
||||
- It keeps the active workspace and session visible.
|
||||
- It routes each turn through explicit modes and approval rules.
|
||||
- It shows tool calls in the transcript instead of hiding the work.
|
||||
- It can preserve sessions, fork conversations, and continue later.
|
||||
- It can run sub-agents for focused background work.
|
||||
|
||||
You can use CodeWhale for small questions:
|
||||
|
||||
```text
|
||||
Explain the authentication flow in this repository.
|
||||
```
|
||||
|
||||
You can also use it for multi-step work:
|
||||
|
||||
```text
|
||||
Find the failing validation path, propose a fix, and wait for my approval
|
||||
before editing files.
|
||||
```
|
||||
|
||||
For a new repository, start conservatively. Ask CodeWhale to explore and plan
|
||||
before asking it to change files. That gives you a reviewable path and makes it
|
||||
easier to catch wrong assumptions early.
|
||||
|
||||
Next: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and
|
||||
runtime model.
|
||||
|
||||
## 2. First Launch
|
||||
|
||||
Install CodeWhale with the path that fits your machine. Each supported install
|
||||
path provides both the `codewhale` dispatcher and the `codewhale-tui` runtime.
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install -g codewhale
|
||||
|
||||
# Cargo
|
||||
cargo install codewhale-cli --locked
|
||||
cargo install codewhale-tui --locked
|
||||
|
||||
# Homebrew
|
||||
# The tap/formula name is legacy; it installs codewhale and codewhale-tui.
|
||||
brew tap Hmbown/deepseek-tui
|
||||
brew install deepseek-tui
|
||||
```
|
||||
|
||||
Docker is also available when you want an isolated runtime:
|
||||
|
||||
```bash
|
||||
docker volume create codewhale-home
|
||||
docker run --rm -it \
|
||||
-e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \
|
||||
-v codewhale-home:/home/codewhale/.codewhale \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
ghcr.io/hmbown/codewhale:latest
|
||||
```
|
||||
|
||||
Launch CodeWhale from the repository or directory you want it to work in:
|
||||
|
||||
```bash
|
||||
codewhale
|
||||
```
|
||||
|
||||
On first launch, CodeWhale needs an API key for the active provider. DeepSeek is
|
||||
the default provider. The most direct setup path is:
|
||||
|
||||
```bash
|
||||
codewhale auth set --provider deepseek
|
||||
```
|
||||
|
||||
You can also provide a key through the environment:
|
||||
|
||||
```bash
|
||||
export DEEPSEEK_API_KEY="your-key"
|
||||
codewhale
|
||||
```
|
||||
|
||||
New CodeWhale config is stored under `~/.codewhale/config.toml`. Legacy
|
||||
`~/.deepseek/config.toml` files are still supported for users migrating from
|
||||
the old name.
|
||||
|
||||
After setup, run a doctor check:
|
||||
|
||||
```bash
|
||||
codewhale doctor
|
||||
```
|
||||
|
||||
Use the JSON form when you need a machine-readable report for an issue:
|
||||
|
||||
```bash
|
||||
codewhale doctor --json
|
||||
```
|
||||
|
||||
If the doctor command reports that a rejected key came from the environment,
|
||||
remove or replace that environment variable before testing saved config again.
|
||||
|
||||
Next: [INSTALL.md](INSTALL.md) covers platform-specific install paths,
|
||||
[CONFIGURATION.md](CONFIGURATION.md) covers config resolution, and
|
||||
[PROVIDERS.md](PROVIDERS.md) covers provider IDs and credentials.
|
||||
|
||||
## 3. Your First Task
|
||||
|
||||
Start with a read-only task in a real workspace:
|
||||
|
||||
```text
|
||||
Map the repository structure and tell me where the CLI entrypoint lives.
|
||||
```
|
||||
|
||||
Then ask for a focused plan:
|
||||
|
||||
```text
|
||||
I want to add a small validation for empty config values. Inspect the relevant
|
||||
code and propose the smallest safe change before editing anything.
|
||||
```
|
||||
|
||||
When you are ready for edits, be specific about the acceptance criteria:
|
||||
|
||||
```text
|
||||
Implement the validation you proposed. Keep the change scoped to config
|
||||
parsing, add or update the narrowest test, and run the relevant check.
|
||||
```
|
||||
|
||||
Good first prompts include four details:
|
||||
|
||||
- The outcome you want.
|
||||
- The files, feature, or behavior you care about.
|
||||
- What is out of scope.
|
||||
- What verification should count as done.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
Fix the broken provider error message in the config loader. Do not change the
|
||||
provider registry. Add a regression test and run only the config crate tests.
|
||||
```
|
||||
|
||||
If you are not sure where the bug is, say that:
|
||||
|
||||
```text
|
||||
Investigate why `codewhale doctor` reports the wrong provider. Do not edit
|
||||
files yet. Return the likely cause, evidence, and a proposed patch plan.
|
||||
```
|
||||
|
||||
CodeWhale works best when you let investigation and implementation happen in
|
||||
separate steps for unfamiliar code. For small, well-understood changes, a
|
||||
single implementation request is fine.
|
||||
|
||||
Next: [MODES.md](MODES.md) explains when to use Plan, Agent, and YOLO.
|
||||
|
||||
## 4. Understanding the Interface
|
||||
|
||||
The interactive TUI has a few stable regions:
|
||||
|
||||
- Header: current session, active model, mode, and high-level status.
|
||||
- Transcript: the conversation, tool calls, command output summaries, and
|
||||
model responses.
|
||||
- Composer: where you type prompts, slash commands, and file mentions.
|
||||
- Sidebar: contextual panels for work state, tasks, agents, or related
|
||||
session information.
|
||||
- Status and footer areas: live activity, queued follow-ups, and short command
|
||||
hints.
|
||||
|
||||
The transcript is the audit trail. When CodeWhale reads files, runs commands,
|
||||
or edits code, the action appears there. If a command fails, use the visible
|
||||
failure output as part of your next instruction instead of starting over.
|
||||
|
||||
The composer accepts normal prompts and slash commands. Type `/` to discover
|
||||
available commands. Use file mentions when you want the model to focus on a
|
||||
specific file or directory instead of searching broadly.
|
||||
|
||||
The sidebar is useful when a turn spans multiple steps. It can keep goals,
|
||||
agent state, and contextual information visible while the transcript continues
|
||||
to grow.
|
||||
|
||||
Keyboard shortcuts vary by context, terminal, and platform. This guide avoids
|
||||
duplicating the full shortcut catalog so it does not drift from the TUI.
|
||||
|
||||
Next: [KEYBINDINGS.md](KEYBINDINGS.md) is the complete shortcut reference.
|
||||
|
||||
## 5. Modes
|
||||
|
||||
CodeWhale has three visible TUI modes:
|
||||
|
||||
| Mode | Use it for | Default posture |
|
||||
| --- | --- | --- |
|
||||
| Plan | Exploration, design, and review before changes | Read-only investigation |
|
||||
| Agent | Normal multi-step coding work | Tool use with approval gates |
|
||||
| YOLO | Trusted repos where you want automatic execution | Auto-approval and trust |
|
||||
|
||||
Switch modes from the TUI with the mode picker:
|
||||
|
||||
```text
|
||||
/mode
|
||||
```
|
||||
|
||||
Or switch directly:
|
||||
|
||||
```text
|
||||
/mode plan
|
||||
/mode agent
|
||||
/mode yolo
|
||||
```
|
||||
|
||||
Plan mode is the safest place to start in an unfamiliar repository. It is for
|
||||
inspection and decision-making, not file edits.
|
||||
|
||||
Agent mode is the default for most contribution work. It lets CodeWhale read,
|
||||
run checks, and edit files while keeping risky actions behind approval gates.
|
||||
|
||||
YOLO mode is for trusted workspaces where you intentionally want the model to
|
||||
act without stopping for approvals. Do not use it in a repository you do not
|
||||
trust.
|
||||
|
||||
Modes are separate from model routing. `Tab` cycles visible modes when the
|
||||
composer is idle, while `/model auto` controls model and thinking selection for
|
||||
turns.
|
||||
|
||||
You can also change approval behavior from `/config` by editing the approval
|
||||
mode. Use this only when you understand how it changes tool execution.
|
||||
|
||||
Next: [MODES.md](MODES.md) has the full mode, approval, and trust-mode
|
||||
reference.
|
||||
|
||||
## 6. Slash Commands
|
||||
|
||||
Slash commands are typed into the composer. They are useful when you want to
|
||||
change CodeWhale state directly instead of asking the model in natural
|
||||
language.
|
||||
|
||||
Common commands for first-time users:
|
||||
|
||||
| Command | Use |
|
||||
| --- | --- |
|
||||
| `/mode` | Open the mode picker or switch with `/mode agent` |
|
||||
| `/model` | Select a model or use `/model auto` |
|
||||
| `/models` | Fetch or list models from the active endpoint |
|
||||
| `/provider` | Pick the active API provider |
|
||||
| `/config` | Edit runtime and provider settings |
|
||||
| `/settings` | Inspect persistent UI preferences |
|
||||
| `/compact` | Summarize long context to recover token budget |
|
||||
| `/review` | Ask for a structured review workflow |
|
||||
| `/memory` | Inspect or manage memory when enabled |
|
||||
| `/mcp` | Configure or inspect MCP server integration |
|
||||
|
||||
Use `/provider` when you want to switch away from the default DeepSeek route.
|
||||
Provider IDs, environment variables, model defaults, and capability notes are
|
||||
kept in the provider registry document.
|
||||
|
||||
Use `/model auto` when you want CodeWhale to choose the model and thinking
|
||||
level per turn. Use a fixed model when you need repeatable benchmarking or a
|
||||
strict cost profile.
|
||||
|
||||
Use `/compact` when a session gets long and the model starts carrying too much
|
||||
history. Compaction trades raw transcript detail for a concise working summary.
|
||||
|
||||
This guide intentionally does not list every command. The command surface
|
||||
changes more often than the onboarding flow, and the TUI command palette is the
|
||||
source of truth while you are inside a session.
|
||||
|
||||
Next: [CONFIGURATION.md](CONFIGURATION.md) covers runtime settings and
|
||||
[MCP.md](MCP.md) covers Model Context Protocol integration.
|
||||
|
||||
## 7. Working with Tools
|
||||
|
||||
CodeWhale tools are structured actions. Instead of only producing prose, the
|
||||
model can call tools to inspect and change the workspace.
|
||||
|
||||
Examples of tool-backed work include:
|
||||
|
||||
- Reading a file before explaining it.
|
||||
- Searching for call sites before proposing a refactor.
|
||||
- Running a focused test command.
|
||||
- Applying a small patch.
|
||||
- Opening a sub-agent for parallel investigation.
|
||||
|
||||
Tool use is governed by mode, approvals, and sandbox policy. The exact behavior
|
||||
depends on the current mode and config, but the basic rule is simple: start in
|
||||
Plan for read-only exploration, use Agent for normal changes, and reserve YOLO
|
||||
for trusted automation.
|
||||
|
||||
The workspace boundary matters. CodeWhale is expected to work in the directory
|
||||
you launched it from or the workspace you configured. Be explicit when a task
|
||||
should stay inside a repo:
|
||||
|
||||
```text
|
||||
Only inspect and edit files under this repository. Do not touch parent
|
||||
directories or global config.
|
||||
```
|
||||
|
||||
When a command needs network, writes outside the workspace, or a risky shell
|
||||
operation, expect an approval prompt unless you have configured more permissive
|
||||
behavior.
|
||||
|
||||
Good tool instructions are concrete:
|
||||
|
||||
```text
|
||||
Run the narrowest test that covers this parser change. If it fails, report the
|
||||
failure and stop before broadening the test scope.
|
||||
```
|
||||
|
||||
Avoid asking for broad cleanup during a focused fix. Smaller tool scopes make
|
||||
the transcript easier to review and the final diff easier to merge.
|
||||
|
||||
Next: [TOOL_SURFACE.md](TOOL_SURFACE.md) lists the tool surface and
|
||||
[SANDBOX.md](SANDBOX.md) explains sandbox behavior.
|
||||
|
||||
## 8. Sub-agents and Parallel Work
|
||||
|
||||
Sub-agents are background child agents. The parent session gives a child a
|
||||
focused task, receives an agent id, and can continue working while the child
|
||||
runs.
|
||||
|
||||
The main orchestration tools are:
|
||||
|
||||
- `agent_open`: start a child with a task and role.
|
||||
- `agent_eval`: wait for and collect the child result.
|
||||
- `agent_close`: cancel a running child.
|
||||
|
||||
You normally do not need to call these tools directly. Ask for parallel work in
|
||||
plain language:
|
||||
|
||||
```text
|
||||
Open one read-only explorer for the config crate and another for the TUI
|
||||
provider picker. Have both return file references and risks before we plan the
|
||||
fix.
|
||||
```
|
||||
|
||||
Useful roles include:
|
||||
|
||||
| Role | Good for |
|
||||
| --- | --- |
|
||||
| `general` | Multi-step tasks; the default when no role is specified |
|
||||
| `explore` | Read-only code mapping |
|
||||
| `plan` | Design and migration planning |
|
||||
| `review` | Bug-focused review of an existing change |
|
||||
| `implementer` | A tightly specified edit |
|
||||
| `verifier` | Running checks and reporting pass/fail evidence |
|
||||
|
||||
Sub-agents are most useful when work can be separated cleanly. Do not use them
|
||||
for tiny edits, and do not ask multiple agents to write the same files at the
|
||||
same time.
|
||||
|
||||
Next: [SUBAGENTS.md](SUBAGENTS.md) covers roles, lifecycle, concurrency, and
|
||||
output contracts.
|
||||
|
||||
## 9. Skills
|
||||
|
||||
Skills are reusable instruction packs. A skill is usually a `SKILL.md` file
|
||||
that teaches CodeWhale how to perform a recurring workflow, use a tool family,
|
||||
or follow a project convention.
|
||||
|
||||
Use skills when a task has a repeatable process:
|
||||
|
||||
- Reviewing a specific kind of PR.
|
||||
- Working with a document or spreadsheet format.
|
||||
- Following a team release checklist.
|
||||
- Using a project-specific memory or wiki workflow.
|
||||
|
||||
Inside the TUI, `/skill` activates a skill when one is available, and `/skills`
|
||||
lists installed skills. The command palette can also surface skill entries
|
||||
alongside normal slash commands.
|
||||
|
||||
Good skills are narrow. They should tell the model what workflow to follow,
|
||||
what evidence to collect, and what to avoid. They should not hide credentials
|
||||
or replace normal repository documentation.
|
||||
|
||||
If a repository has its own instructions, treat them as part of the active
|
||||
work. Read the local guidance before editing, and keep any contribution within
|
||||
the repository's conventions.
|
||||
|
||||
Next: see the "Publishing Your Own Skill" section in [README.md](../README.md)
|
||||
and configuration details in [CONFIGURATION.md](CONFIGURATION.md).
|
||||
|
||||
## 10. Getting Help
|
||||
|
||||
Start with doctor output:
|
||||
|
||||
```bash
|
||||
codewhale doctor
|
||||
```
|
||||
|
||||
Use JSON when filing a detailed issue:
|
||||
|
||||
```bash
|
||||
codewhale doctor --json
|
||||
```
|
||||
|
||||
For authentication problems, check which source is winning: saved config,
|
||||
keyring, environment, or an explicit launch flag. A stale `DEEPSEEK_API_KEY`
|
||||
environment variable can override what you expected to use.
|
||||
|
||||
For provider problems, confirm the active provider and model:
|
||||
|
||||
```text
|
||||
/provider
|
||||
/model
|
||||
```
|
||||
|
||||
For long or confusing sessions, use `/compact` to reduce context pressure, or
|
||||
start a fresh session in the same workspace and summarize what you need.
|
||||
|
||||
When reporting an issue, include:
|
||||
|
||||
- CodeWhale version.
|
||||
- Install method.
|
||||
- Operating system and terminal.
|
||||
- Provider and model.
|
||||
- The exact command or prompt.
|
||||
- Relevant doctor output.
|
||||
- Whether the problem happens in a fresh workspace.
|
||||
|
||||
Do not paste API keys, private source code, or secrets into a public issue.
|
||||
|
||||
Next: [OPERATIONS_RUNBOOK.md](OPERATIONS_RUNBOOK.md) has operational triage and
|
||||
recovery steps.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Is CodeWhale only for DeepSeek?
|
||||
|
||||
DeepSeek is the default and first-class route, but CodeWhale also supports
|
||||
other hosted and local OpenAI-compatible providers. Use `/provider` or
|
||||
`codewhale --provider <id>` to choose a provider. Keep the provider registry
|
||||
open when configuring a non-default route.
|
||||
|
||||
### Which mode should I use first?
|
||||
|
||||
Use Plan for unfamiliar code, Agent for normal implementation, and YOLO only
|
||||
for trusted repositories where automatic execution is acceptable.
|
||||
|
||||
### Why does CodeWhale ask before running commands?
|
||||
|
||||
Approvals are part of the safety model. Shell commands, paid tools, writes, and
|
||||
actions outside the expected workspace can have side effects. Approval prompts
|
||||
let you keep control while still letting the model do useful work.
|
||||
|
||||
### Where is my config stored?
|
||||
|
||||
New CodeWhale config uses `~/.codewhale/config.toml`. Legacy
|
||||
`~/.deepseek/config.toml` remains supported for compatibility. Project overlays
|
||||
can also affect behavior when a workspace config exists.
|
||||
|
||||
### How do I keep costs predictable?
|
||||
|
||||
Use `/model auto` for routing, choose a fixed model when you need a strict
|
||||
profile, and compact long sessions. For larger tasks, ask CodeWhale to plan
|
||||
before implementing so you do not spend tokens on the wrong path.
|
||||
|
||||
### How do I continue previous work?
|
||||
|
||||
CodeWhale saves sessions. Use the session picker or resume/continue CLI paths
|
||||
documented in the README and modes guide. For a risky experiment, fork the
|
||||
session before changing direction.
|
||||
|
||||
### What should I do when the model gets confused?
|
||||
|
||||
Stop and restate the goal, constraints, and current evidence. If the transcript
|
||||
is long, use `/compact` or start a fresh session with a short handoff. If the
|
||||
problem is operational, run `codewhale doctor` and inspect the reported config
|
||||
and provider state.
|
||||
|
||||
### Should I put project rules in prompts or files?
|
||||
|
||||
Use repository files for durable project rules and prompts for turn-specific
|
||||
intent. If a workflow repeats across projects, consider turning it into a
|
||||
skill.
|
||||
|
||||
### Can CodeWhale edit files outside the current repository?
|
||||
|
||||
That depends on workspace boundaries, sandbox settings, trust mode, and
|
||||
approval policy. For contribution work, keep instructions scoped to the current
|
||||
repository unless you intentionally need something else.
|
||||
|
||||
### Where should I go after this guide?
|
||||
|
||||
Read the focused reference for the thing you are changing. For most users, the
|
||||
next pages are install, configuration, providers, modes, keybindings, tools,
|
||||
and sub-agents.
|
||||
|
||||
Next: [INSTALL.md](INSTALL.md), [CONFIGURATION.md](CONFIGURATION.md),
|
||||
[PROVIDERS.md](PROVIDERS.md), [MODES.md](MODES.md), and
|
||||
[TOOL_SURFACE.md](TOOL_SURFACE.md).
|
||||
@@ -479,6 +479,9 @@ Cargo mirror setup in [Section 4](#4-install-via-cargo-any-tier-1-rust-target).
|
||||
assets. On networks where GitHub is blocked or unreliable, use the CNB source
|
||||
mirror instead and install both binaries from the release tag:
|
||||
|
||||
To check the latest release without downloading or replacing binaries, run
|
||||
`codewhale update --check`.
|
||||
|
||||
```bash
|
||||
cargo install --git https://cnb.cool/codewhale.net/codewhale --tag vX.Y.Z codewhale-cli --locked --force
|
||||
cargo install --git https://cnb.cool/codewhale.net/codewhale --tag vX.Y.Z codewhale-tui --locked --force
|
||||
|
||||
@@ -23,6 +23,8 @@ These locales are supported by `locale` in `settings.toml` and by `LANG` / `LC_A
|
||||
| `ja` | Japanese | Jpan | LTR | `en` | v0.7.6 must-have | Core TUI chrome | Covers composer placeholder/history search, help chrome, and `/config` chrome. |
|
||||
| `zh-Hans` | Chinese Simplified | Hans | LTR | `en` | v0.7.6 must-have | Core TUI chrome | `zh`, `zh-CN`, and `zh-Hans` resolve here. Traditional Chinese is not shipped. |
|
||||
| `pt-BR` | Portuguese (Brazil) | Latin | LTR | `en` | v0.7.6 must-have | Core TUI chrome | `pt` and `pt-PT` currently fall back to Brazilian Portuguese; European Portuguese is not separately shipped. |
|
||||
| `es-419` | Spanish (Latin America) | Latin | LTR | `en` | v0.7.6 must-have | Core TUI chrome | `es` and regional variants resolve here. |
|
||||
| `vi` | Vietnamese | Latin | LTR | `en` | v0.7.6 must-have | Core TUI chrome | Fully translated UI chrome, automated width tested. |
|
||||
|
||||
Selection:
|
||||
|
||||
@@ -31,6 +33,8 @@ locale = "auto" # default; checks LC_ALL, LC_MESSAGES, then LANG
|
||||
locale = "ja"
|
||||
locale = "zh-Hans"
|
||||
locale = "pt-BR"
|
||||
locale = "es-419"
|
||||
locale = "vi"
|
||||
```
|
||||
|
||||
Fallback:
|
||||
@@ -52,12 +56,10 @@ These are not claimed as shipped translations in v0.7.6 unless a later change ad
|
||||
| `hi` | Hindi | Deva | LTR | Follow-up | Planned | `en` | Automated renderer sample only; native review preferred before shipping | Combining marks, cursor width, truncation |
|
||||
| `bn` | Bengali | Beng | LTR | Follow-up | Planned | `en` | Matrix only; native review required before shipping | Combining marks, line wrapping |
|
||||
| `id` | Indonesian | Latin | LTR | Follow-up | Planned | `en` | Matrix only; automated narrow-width snapshots and reviewer pass required | Longer labels than English |
|
||||
| `vi` | Vietnamese | Latin | LTR | Follow-up | Planned | `en` | Matrix only; automated width snapshots and reviewer pass required | Diacritics and wrapped labels |
|
||||
| `sw` | Swahili | Latin | LTR | Follow-up | Planned | `en` | Matrix only; native or fluent review required before shipping | Translation quality, longer command descriptions |
|
||||
| `ha` | Hausa | Latin | LTR | Follow-up | Planned | `en` | Matrix only; native or fluent review required before shipping | Diacritics and terminology |
|
||||
| `yo` | Yoruba | Latin | LTR | Follow-up | Planned | `en` | Matrix only; native or fluent review required before shipping | Tone marks and terminology |
|
||||
| `fil` | Filipino/Tagalog | Latin | LTR | Follow-up | Planned | `en` | Matrix only; source strings required before shipping | Terminology consistency |
|
||||
| `es-419` | Spanish (Latin America) | Latin | LTR | Follow-up | Planned | `en` | Matrix only; reviewer pass required before shipping | Regional terminology |
|
||||
| `fr` | French | Latin | LTR | Follow-up | Planned | `en` | Matrix only; reviewer pass required before shipping | African locale terminology varies |
|
||||
|
||||
## Message Coverage
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode agent`,
|
||||
`/mode plan`, `/mode yolo`, `/mode 1`, `/mode 2`, or `/mode 3`.
|
||||
|
||||
- **Plan**: design-first prompting. Read-only investigation tools stay available; shell and patch execution stay off. Use this when you want to think out loud and produce a plan to hand to a human (yourself later, or a reviewer).
|
||||
- **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt).
|
||||
- **Agent**: multi-step tool use. Shell execution (`exec_shell`, `task_shell_start`, `task_shell_wait`) requires `allow_shell = true` in config; approval prompts gate each call. File writes are allowed without a prompt.
|
||||
- **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos.
|
||||
|
||||
All action-capable modes have access to persistent RLM sessions through `rlm_open`, `rlm_eval`, `rlm_configure`, and `rlm_close`. Inside an RLM Python REPL, `sub_query_batch` fans out 1-16 cheap parallel child calls pinned to `deepseek-v4-flash`. The model reaches for it when work is too large or repetitive for the parent transcript.
|
||||
|
||||
+84
-9
@@ -6,11 +6,11 @@ limited to provider IDs, config keys, auth paths, base URLs, model resolution,
|
||||
and capability metadata that the code already knows about.
|
||||
|
||||
DeepSeek remains the first-class default provider. NVIDIA NIM, OpenRouter,
|
||||
Novita, Fireworks, generic OpenAI-compatible endpoints, self-hosted runtimes,
|
||||
and Moonshot/Kimi are additive routes for running the same terminal harness
|
||||
against other hosted or local model endpoints. Hugging Face Inference Providers
|
||||
are a planned additive open-model routing layer; they are not a native provider
|
||||
in this checkout yet.
|
||||
Xiaomi MiMo, Novita, Fireworks, generic OpenAI-compatible endpoints,
|
||||
self-hosted runtimes, and Moonshot/Kimi are additive routes for running the
|
||||
same terminal harness against other hosted or local model endpoints. Hugging
|
||||
Face Inference Providers are a planned additive open-model routing layer; they
|
||||
are not a native provider in this checkout yet.
|
||||
|
||||
Sources to keep in sync:
|
||||
|
||||
@@ -21,13 +21,17 @@ Sources to keep in sync:
|
||||
`codewhale model list` and `codewhale model resolve`.
|
||||
- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config
|
||||
examples and environment variable reference.
|
||||
- `scripts/check-provider-registry.py` - drift check for canonical provider
|
||||
IDs, live TUI provider IDs, TOML table names, static registry rows, and
|
||||
documented defaults.
|
||||
|
||||
## Provider Selection
|
||||
|
||||
The canonical provider IDs are:
|
||||
|
||||
`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`,
|
||||
`novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, and `ollama`.
|
||||
`xiaomi-mimo`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, and
|
||||
`ollama`.
|
||||
|
||||
Use any of these surfaces to select a provider:
|
||||
|
||||
@@ -61,6 +65,48 @@ Non-local `http://` base URLs are rejected unless
|
||||
`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for
|
||||
self-hosted runtimes.
|
||||
|
||||
## Custom DeepSeek-Compatible Endpoints
|
||||
|
||||
Most custom DeepSeek-compatible deployments can use an existing provider ID.
|
||||
Do not create `[providers.deepseek_custom]`; the provider table names are fixed.
|
||||
Instead, choose the closest shipped route and override its endpoint/model:
|
||||
|
||||
- DeepSeek-compatible hosted API: keep `provider = "deepseek"` and set
|
||||
`[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch
|
||||
with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.
|
||||
- Generic OpenAI-compatible gateway: use `provider = "openai"` with
|
||||
`[providers.openai].base_url` plus `[providers.openai].model`, or launch with
|
||||
`OPENAI_BASE_URL` and `OPENAI_MODEL`.
|
||||
- Local OpenAI-compatible runtimes: use `provider = "vllm"`, `"sglang"`, or
|
||||
`"ollama"` with the matching provider-specific base URL/model values.
|
||||
|
||||
Example user config for a DeepSeek-compatible host:
|
||||
|
||||
```toml
|
||||
provider = "deepseek"
|
||||
|
||||
[providers.deepseek]
|
||||
api_key = "YOUR_API_KEY"
|
||||
base_url = "https://your-provider.example/v1"
|
||||
model = "deepseek-ai/DeepSeek-V4-Pro"
|
||||
```
|
||||
|
||||
Example user config for a generic gateway:
|
||||
|
||||
```toml
|
||||
provider = "openai"
|
||||
|
||||
[providers.openai]
|
||||
api_key = "YOUR_GATEWAY_API_KEY"
|
||||
base_url = "https://gateway.example/v1"
|
||||
model = "your-deepseek-compatible-model"
|
||||
```
|
||||
|
||||
Keep `provider`, `api_key`, and `base_url` in user config or process
|
||||
environment. Project-local config overlays intentionally cannot set those keys,
|
||||
so a repository cannot silently redirect prompts or credentials to another
|
||||
endpoint.
|
||||
|
||||
## Shipped Providers
|
||||
|
||||
| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |
|
||||
@@ -71,6 +117,7 @@ self-hosted runtimes.
|
||||
| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default config model `deepseek-ai/deepseek-v4-flash` | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path. The static `ModelRegistry` does not currently list AtlasCloud rows. |
|
||||
| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |
|
||||
| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |
|
||||
| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | `mimo-v2.5-pro`, `mimo-v2.5` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. |
|
||||
| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |
|
||||
| `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. |
|
||||
| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi CLI OAuth credentials when present. |
|
||||
@@ -78,6 +125,15 @@ self-hosted runtimes.
|
||||
| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |
|
||||
| `ollama` | `[providers.ollama]` | Optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | `deepseek-coder:1.3b`; provider-hinted custom tags pass through | Self-hosted Ollama OpenAI-compatible route. Localhost deployments commonly omit auth. `OLLAMA_MODEL` is accepted. |
|
||||
|
||||
### Xiaomi MiMo Notes
|
||||
|
||||
`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding
|
||||
work, while the static registry also exposes `mimo-v2.5`. Xiaomi's current
|
||||
[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)
|
||||
includes `mimo-v2.5` for image input. CodeWhale exposes image analysis through the
|
||||
separate `[vision_model]` / `image_analyze` path; set that model to
|
||||
`mimo-v2.5` when using MiMo for vision.
|
||||
|
||||
## Static Model Registry
|
||||
|
||||
`codewhale model list` and `codewhale model resolve` use the static registry in
|
||||
@@ -92,6 +148,7 @@ endpoint when the endpoint supports model listing.
|
||||
| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash` | yes | yes |
|
||||
| `wanjie-ark` | `deepseek-reasoner` | yes | yes |
|
||||
| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |
|
||||
| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5` | yes | yes |
|
||||
| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |
|
||||
| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |
|
||||
| `moonshot` | `kimi-k2.6` | yes | yes |
|
||||
@@ -119,6 +176,7 @@ All shipped providers use the Chat Completions request payload mode today.
|
||||
| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |
|
||||
| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |
|
||||
| OpenRouter, Novita, Fireworks, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |
|
||||
| Xiaomi MiMo models | 1,000,000 | 128,000 | yes | no | not documented in code |
|
||||
| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | 4,096 | yes | no | not documented in code |
|
||||
| Generic `openai`, AtlasCloud, and Moonshot/Kimi | 128,000 | 4,096 | no in doctor capability metadata | no | not documented in code |
|
||||
| Ollama | 8,192 | 4,096 | no | no | not documented in code |
|
||||
@@ -133,6 +191,26 @@ DeepSeek compatibility aliases `deepseek-chat` and `deepseek-reasoner` map to
|
||||
`deepseek-v4-flash` capability metadata and are scheduled to retire on
|
||||
2026-07-24 at 2026-07-24T15:59:00Z.
|
||||
|
||||
## Drift Check
|
||||
|
||||
Run this before changing provider IDs, provider TOML tables, static model
|
||||
registry rows, or provider default strings:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-provider-registry.py
|
||||
```
|
||||
|
||||
The check fails when:
|
||||
|
||||
- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.
|
||||
- `crates/tui/src/config.rs` `ApiProvider::as_str()` diverges from
|
||||
`ProviderKind::as_str()` except for the explicit `deepseek-cn` legacy alias.
|
||||
- The shipped-provider table omits or adds a `[providers.*]` TOML table.
|
||||
- The static model registry table drifts from providers used by
|
||||
`crates/agent/src/lib.rs`.
|
||||
- A provider default model or base URL constant in `crates/tui/src/config.rs`
|
||||
is no longer mentioned here.
|
||||
|
||||
## Planned, Not Shipped Yet
|
||||
|
||||
These items belong to the v0.8.47 provider-abstraction milestone or related
|
||||
@@ -149,9 +227,6 @@ provider docs work, but they are not native shipped behavior in this checkout:
|
||||
- Hugging Face model passport metadata in the picker, including license, base
|
||||
model, context length, chat template, tool-call support, reasoning support,
|
||||
and gated/private status.
|
||||
- A generated drift-check script that fails when this file diverges from the
|
||||
provider registry. Until that exists, update this file with a source read of
|
||||
the files listed at the top.
|
||||
|
||||
Until native Hugging Face support lands, users can only reach an explicitly
|
||||
configured Hugging Face-compatible OpenAI route through the generic `openai`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user