chore(release): prepare v0.8.45
Harvested from PR #2118 by @Hmbown. Includes Kimi/Moonshot OAuth, v0.8.45 release prep, the Codex/ChatGPT OAuth removal, open-source-first model defaults, and the safe green PR batch merged into main before the release branch refresh.
This commit is contained in:
+68
-1
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.45] - 2026-05-25
|
||||
|
||||
### Added
|
||||
|
||||
- **RLM session objects.** `rlm_open` can now load `session://` refs,
|
||||
exposing the active prompt, history, and session data as symbolic objects
|
||||
inside RLM REPLs (#2047).
|
||||
- **Deterministic whale-species sub-agent names.** Sub-agents now get stable,
|
||||
human-readable whale-species nicknames (e.g. "Beluga", "Orca") while
|
||||
preserving the raw agent ID in the popup (#2035, #2016).
|
||||
- **`/balance` command scaffold.** Registered the `/balance` slash command
|
||||
as a placeholder for future provider billing queries (#2035, #2019).
|
||||
- **Readable `/restore` snapshot labels.** Snapshot labels now include the
|
||||
originating user prompt so restore listings are easier to identify. Thanks
|
||||
@idling11 (#2111).
|
||||
- **Sidebar hover tooltips.** Truncated Work and Tasks sidebar lines now expose
|
||||
their full text on hover. Thanks @idling11 (#2110).
|
||||
|
||||
### Changed
|
||||
|
||||
- **AGENTS.md is now maintainer-local.** The project instructions file no
|
||||
longer ships as a tracked repo file; it lives in maintainer-local ignored
|
||||
state (#2047).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sub-agent completion handoff compatibility.** Completion handoffs now use a
|
||||
chat-template-safe role and emit before terminal updates, fixing strict
|
||||
OpenAI-compatible/self-hosted backends and preserving transcript ordering.
|
||||
Thanks @h3c-hexin and @cyq1017 (#2057, #2120).
|
||||
- **Self-hosted context budgeting.** Sub-500K self-hosted model windows now keep
|
||||
a usable input budget instead of disabling preflight compaction after output
|
||||
reservation underflow. Thanks @h3c-hexin (#2060).
|
||||
- **Goal prompts start actionable.** Goal-start prompts now open in an
|
||||
actionable state instead of requiring an extra nudge. Thanks @cyq1017
|
||||
(#2097).
|
||||
- **Composer session title display.** The composer chrome shows the current
|
||||
session title again and avoids grayscale luma overflow in debug builds.
|
||||
Thanks @wdw8276 (#2108).
|
||||
- **Approval prompts use a one-step confirmation flow.** Enter now commits the
|
||||
selected approval option directly, destructive warnings remain visible, and
|
||||
abort cancels the active turn instead of only denying the current tool call.
|
||||
Thanks @reidliu41 (#2143).
|
||||
- **Model picker selection survives Esc.** Dismissing the model picker with Esc
|
||||
no longer loses the highlighted selection. Thanks @reidliu41 (#2056).
|
||||
- **Slash recovery no longer restores command tails in the composer.**
|
||||
Resuming a session or recovering from a crash no longer leaves stale
|
||||
slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032).
|
||||
- **Remembered tool approvals now update the live active turn.**
|
||||
When the "remember" checkbox is set on an approval dialog, the active
|
||||
turn's auto-approve flag flips immediately instead of waiting for the
|
||||
next turn. Thanks @gaord (#2047, #2041).
|
||||
- **YAML block scalars in SKILL.md frontmatter.** Multi-line descriptions
|
||||
using `>` or `|` indicators are now parsed correctly — folded block
|
||||
scalars join non-empty lines with spaces, literal scalars preserve
|
||||
newlines, and all three chomping modes (strip/clip/keep) are supported.
|
||||
Thanks @zlh124 (#1908, #1907).
|
||||
- **User messages highlighted in the transcript.** User-authored messages
|
||||
now render with a full-row background in the live TUI transcript, making
|
||||
it easier to scan prior turns. Assistant and system messages are
|
||||
unaffected. Thanks @reidliu41 (#1995, #1672).
|
||||
- **Cancellable `list_dir` and `file_search`.** Long directory walks and
|
||||
file searches now respond to user cancel/stop requests with a 30-second
|
||||
fallback timeout, preventing the TUI from hanging on deep or slow
|
||||
filesystems (#2035).
|
||||
|
||||
## [0.8.44] - 2026-05-24
|
||||
|
||||
### Added
|
||||
@@ -4806,7 +4872,8 @@ Welcome — and thank you.
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...HEAD
|
||||
[0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45
|
||||
[0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44
|
||||
[0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43
|
||||
[0.8.42]: https://github.com/Hmbown/CodeWhale/compare/v0.8.41...v0.8.42
|
||||
|
||||
Generated
+18
-14
@@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21"
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-agent"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"codewhale-config",
|
||||
"serde",
|
||||
@@ -811,7 +811,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-app-server"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -827,13 +827,16 @@ dependencies = [
|
||||
"codewhale-tools",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-cli"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -858,19 +861,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-config"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codewhale-secrets",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-core"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -888,7 +892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-execpolicy"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codewhale-protocol",
|
||||
@@ -897,7 +901,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-hooks"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -911,7 +915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-mcp"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -920,7 +924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-protocol"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -928,7 +932,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-secrets"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"keyring",
|
||||
@@ -941,7 +945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-state"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -953,7 +957,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tools"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -966,7 +970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tui"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -1032,7 +1036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-tui-core"
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.44"
|
||||
version = "0.8.45"
|
||||
edition = "2024"
|
||||
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
|
||||
# codebase relies on extensively. Cargo enforces this so users on older
|
||||
|
||||
@@ -279,9 +279,13 @@ codewhale --provider novita --model deepseek/deepseek-v4-pro
|
||||
codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"
|
||||
codewhale --provider fireworks --model deepseek-v4-pro
|
||||
|
||||
# Moonshot/Kimi
|
||||
codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_API_KEY"
|
||||
codewhale --provider moonshot --model kimi-k2.6
|
||||
|
||||
# Generic OpenAI-compatible endpoint
|
||||
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
|
||||
OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model deepseek-v4-pro
|
||||
|
||||
# Self-hosted SGLang
|
||||
SGLANG_BASE_URL="http://localhost:30000/v1" codewhale --provider sglang --model deepseek-v4-flash
|
||||
@@ -429,11 +433,6 @@ ACP workflows outside the built-in Zed slice.
|
||||
| `@path` | Attach file/directory context in composer |
|
||||
| `↑` (at composer start) | Select attachment row for removal |
|
||||
|
||||
Voice input is available from the command palette (`Ctrl+K`, then search
|
||||
`Voice input`) after configuring `voice_input_command`; the helper
|
||||
records/transcribes audio, CodeWhale shows a listening status while it runs, and
|
||||
the final transcript is inserted into the composer for editing.
|
||||
|
||||
Full shortcut catalog: [docs/KEYBINDINGS.md](docs/KEYBINDINGS.md).
|
||||
|
||||
---
|
||||
@@ -467,14 +466,15 @@ 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` |
|
||||
| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, `ollama` |
|
||||
| `DEEPSEEK_PROVIDER` | `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `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` / `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` / `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 |
|
||||
| `MOONSHOT_BASE_URL` / `KIMI_BASE_URL` / `MOONSHOT_MODEL` / `KIMI_MODEL` | Moonshot/Kimi endpoint and model override |
|
||||
| `OPENROUTER_BASE_URL` | OpenRouter endpoint override |
|
||||
| `NOVITA_BASE_URL` | Novita endpoint override |
|
||||
| `FIREWORKS_BASE_URL` | Fireworks endpoint override |
|
||||
@@ -604,7 +604,7 @@ This project ships with help from a growing community of contributors:
|
||||
- **[zichen0116](https://github.com/zichen0116)** — CODE_OF_CONDUCT.md (#686)
|
||||
- **[dfwqdyl-ui](https://github.com/dfwqdyl-ui)** — model ID case-sensitivity compatibility report (#729)
|
||||
- **[Oliver-ZPLiu](https://github.com/Oliver-ZPLiu)** — stale `working...` state bug report, Windows clipboard fallback, MCP Streamable HTTP session fixes, and Homebrew tap automation (#738, #850, #1643, #1631)
|
||||
- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, and help picker selection polish (#863, #870, #921, #1078, #1603, #1628, #1601, #1964)
|
||||
- **[reidliu41](https://github.com/reidliu41)** — resume hint, workspace trust persistence, Ollama provider support, thinking-block stream finalization, CI cache hardening, streaming wrap, DeepSeek model completions, help picker selection polish, and transcript user-message highlighting (#863, #870, #921, #1078, #1603, #1628, #1601, #1964, #1995)
|
||||
- **[cyq1017](https://github.com/cyq1017)** — Unicode `git_status` paths, local/configured skill discovery, and mode-switch toast dedupe (#1953, #1956, #1957)
|
||||
- **[xieshutao](https://github.com/xieshutao)** — plain Markdown skill fallback (#869)
|
||||
- **[GK012](https://github.com/GK012)** — npm wrapper `--version` fallback (#885)
|
||||
@@ -637,7 +637,7 @@ This project ships with help from a growing community of contributors:
|
||||
- **[mdrkrg](https://github.com/mdrkrg)** — first-run onboarding crash fix when the API key is missing (#1598)
|
||||
- **[Aitensa](https://github.com/Aitensa)** — CJK wrapping propagation for diff and pager output (#1622)
|
||||
- **[qiyan233](https://github.com/qiyan233)** — legacy DeepSeek CN provider alias compatibility (#1645)
|
||||
- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report and clipboard-init fix (#1772, #1773)
|
||||
- **[zlh124](https://github.com/zlh124)** — WSL2/headless startup report, clipboard-init fix, and YAML block-scalar frontmatter parsing (#1772, #1773, #1908)
|
||||
- **[aboimpinto](https://github.com/aboimpinto)** — Windows alt-screen logging, Home/End composer, and runtime log follow-ups (#1774, #1776, #1748, #1749, #1782, #1783)
|
||||
- **[LeoLin990405](https://github.com/LeoLin990405)** — provider model passthrough, reasoning replay, thinking-only turn, and Windows quoting fixes (#1740, #1743, #1742, #1744)
|
||||
- **[nightt5879](https://github.com/nightt5879)** — Ctrl+C prompt restore fix (#1764)
|
||||
@@ -707,6 +707,7 @@ This project ships with help from a growing community of contributors:
|
||||
- **[xulongzhe](https://github.com/xulongzhe)** — issue-template and vision-boundary follow-ups (#1530, #1544)
|
||||
- **[YaYII](https://github.com/YaYII)** — trusted media path work (#1462)
|
||||
- **[47Cid](https://github.com/47Cid)** and **[Jafar Akhondali](https://github.com/JafarAkhondali)** — responsible security disclosures and hardening reports
|
||||
- **[gaord](https://github.com/gaord)** — approval-remember live-turn sync fix (#2041)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@ repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
codewhale-config = { path = "../config", version = "0.8.44" }
|
||||
codewhale-config = { path = "../config", version = "0.8.45" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -74,18 +74,18 @@ impl Default for ModelRegistry {
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1".to_string(),
|
||||
id: "deepseek-v4-pro".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()],
|
||||
aliases: vec!["openai-compatible-deepseek-v4-pro".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4.1-mini".to_string(),
|
||||
id: "deepseek-v4-flash".to_string(),
|
||||
provider: ProviderKind::Openai,
|
||||
aliases: vec!["gpt-4o-mini".to_string()],
|
||||
aliases: vec!["openai-compatible-deepseek-v4-flash".to_string()],
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-reasoner".to_string(),
|
||||
|
||||
@@ -10,16 +10,21 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.44" }
|
||||
codewhale-config = { path = "../config", version = "0.8.44" }
|
||||
codewhale-core = { path = "../core", version = "0.8.44" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.44" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.44" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.44" }
|
||||
codewhale-state = { path = "../state", version = "0.8.44" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.44" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.45" }
|
||||
codewhale-config = { path = "../config", version = "0.8.45" }
|
||||
codewhale-core = { path = "../core", version = "0.8.45" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.45" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.45" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.45" }
|
||||
codewhale-state = { path = "../state", version = "0.8.45" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.45" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.16"
|
||||
tower = "0.5"
|
||||
|
||||
+308
-28
@@ -2,8 +2,11 @@ use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::State;
|
||||
use anyhow::{Result, bail};
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use codewhale_agent::ModelRegistry;
|
||||
@@ -23,11 +26,25 @@ use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_CORS_ORIGINS: &[&str] = &[
|
||||
"http://localhost",
|
||||
"http://localhost:1420",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1",
|
||||
"http://127.0.0.1:1420",
|
||||
"tauri://localhost",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppServerOptions {
|
||||
pub listen: SocketAddr,
|
||||
pub config_path: Option<PathBuf>,
|
||||
pub auth_token: Option<String>,
|
||||
pub insecure_no_auth: bool,
|
||||
pub cors_origins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -36,6 +53,7 @@ struct AppState {
|
||||
config: Arc<RwLock<codewhale_config::ConfigToml>>,
|
||||
runtime: Arc<Mutex<Runtime>>,
|
||||
registry: ModelRegistry,
|
||||
auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -69,6 +87,12 @@ struct StdioDispatchResult {
|
||||
should_exit: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AppTransport {
|
||||
Http,
|
||||
Stdio,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigGetParams {
|
||||
key: String,
|
||||
@@ -92,26 +116,37 @@ struct ThreadMessageParams {
|
||||
}
|
||||
|
||||
pub async fn run(options: AppServerOptions) -> Result<()> {
|
||||
let state = build_state(options.config_path.clone())?;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/thread", post(thread_handler))
|
||||
.route("/app", post(app_handler))
|
||||
.route("/prompt", post(prompt_handler))
|
||||
.route("/tool", post(tool_handler))
|
||||
.route("/jobs", get(jobs_handler))
|
||||
.route("/mcp/startup", post(mcp_startup_handler))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
let auth_token = resolve_auth_token(&options)?;
|
||||
let state = build_state(options.config_path.clone(), auth_token)?;
|
||||
let app = app_router(state, &options.cors_origins);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(options.listen).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn app_router(state: AppState, cors_origins: &[String]) -> Router {
|
||||
let protected_routes = Router::new()
|
||||
.route("/thread", post(thread_handler))
|
||||
.route("/app", post(app_handler))
|
||||
.route("/prompt", post(prompt_handler))
|
||||
.route("/tool", post(tool_handler))
|
||||
.route("/jobs", get(jobs_handler))
|
||||
.route("/mcp/startup", post(mcp_startup_handler))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_app_server_token,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.merge(protected_routes)
|
||||
.layer(cors_layer(cors_origins))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
let state = build_state(config_path)?;
|
||||
let state = build_state(config_path, None)?;
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
let mut reader = BufReader::new(stdin).lines();
|
||||
@@ -258,10 +293,10 @@ async fn app_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AppRequest>,
|
||||
) -> Json<AppResponse> {
|
||||
Json(process_app_request(&state, req).await)
|
||||
Json(process_app_request(&state, req, AppTransport::Http).await)
|
||||
}
|
||||
|
||||
fn build_state(config_path: Option<PathBuf>) -> Result<AppState> {
|
||||
fn build_state(config_path: Option<PathBuf>, auth_token: Option<String>) -> Result<AppState> {
|
||||
let store = ConfigStore::load(config_path.clone())?;
|
||||
let config = store.config.clone();
|
||||
let registry = ModelRegistry::default();
|
||||
@@ -294,9 +329,95 @@ fn build_state(config_path: Option<PathBuf>) -> Result<AppState> {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
runtime: Arc::new(Mutex::new(runtime)),
|
||||
registry,
|
||||
auth_token,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_auth_token(options: &AppServerOptions) -> Result<Option<String>> {
|
||||
let configured = options.auth_token.as_ref().map(|token| token.trim());
|
||||
if let Some(token) = configured
|
||||
&& token.is_empty()
|
||||
{
|
||||
bail!("app-server auth token cannot be empty");
|
||||
}
|
||||
|
||||
if options.insecure_no_auth {
|
||||
if !options.listen.ip().is_loopback() {
|
||||
bail!("refusing unauthenticated app-server bind on non-loopback address");
|
||||
}
|
||||
eprintln!("warning: app-server HTTP auth disabled by --insecure-no-auth");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let token = configured
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("cwapp_{}", Uuid::new_v4().simple()));
|
||||
if options.auth_token.is_some() {
|
||||
eprintln!("app-server auth: bearer token required for HTTP routes.");
|
||||
} else {
|
||||
eprintln!("app-server auth: generated bearer token for this process.");
|
||||
eprintln!(" Authorization: Bearer {token}");
|
||||
eprintln!(" Pass --auth-token or set CODEWHALE_APP_SERVER_TOKEN for a stable token.");
|
||||
}
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
fn cors_layer(extra_origins: &[String]) -> CorsLayer {
|
||||
let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
|
||||
.iter()
|
||||
.filter_map(|origin| HeaderValue::from_str(origin).ok())
|
||||
.collect();
|
||||
for raw in extra_origins {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match HeaderValue::from_str(trimmed) {
|
||||
Ok(value) if !origins.contains(&value) => origins.push(value),
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
eprintln!("warning: ignoring invalid app-server CORS origin `{trimmed}`: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CorsLayer::new()
|
||||
.allow_origin(origins)
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
|
||||
}
|
||||
|
||||
async fn require_app_server_token(
|
||||
State(state): State<AppState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let Some(expected) = state.auth_token.as_deref() else {
|
||||
return next.run(req).await;
|
||||
};
|
||||
let authorized = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|raw| raw.strip_prefix("Bearer "))
|
||||
.is_some_and(|token| token == expected);
|
||||
|
||||
if authorized {
|
||||
next.run(req).await
|
||||
} else {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": "app-server bearer token required",
|
||||
"status": StatusCode::UNAUTHORIZED.as_u16(),
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn params_or_object(params: Value) -> Value {
|
||||
if params.is_null() { json!({}) } else { params }
|
||||
}
|
||||
@@ -585,7 +706,8 @@ async fn dispatch_stdio_request(
|
||||
}
|
||||
}
|
||||
"app/capabilities" => {
|
||||
let response = process_app_request(state, AppRequest::Capabilities).await;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::Capabilities, AppTransport::Stdio).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
@@ -594,7 +716,7 @@ async fn dispatch_stdio_request(
|
||||
}
|
||||
"app/request" => {
|
||||
let request: AppRequest = parse_params(params)?;
|
||||
let response = process_app_request(state, request).await;
|
||||
let response = process_app_request(state, request, AppTransport::Stdio).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
@@ -603,8 +725,12 @@ async fn dispatch_stdio_request(
|
||||
}
|
||||
"app/config/get" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigGet { key: parsed.key }).await;
|
||||
let response = process_app_request(
|
||||
state,
|
||||
AppRequest::ConfigGet { key: parsed.key },
|
||||
AppTransport::Stdio,
|
||||
)
|
||||
.await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
@@ -619,6 +745,7 @@ async fn dispatch_stdio_request(
|
||||
key: parsed.key,
|
||||
value: parsed.value,
|
||||
},
|
||||
AppTransport::Stdio,
|
||||
)
|
||||
.await;
|
||||
StdioDispatchResult {
|
||||
@@ -629,8 +756,12 @@ async fn dispatch_stdio_request(
|
||||
}
|
||||
"app/config/unset" => {
|
||||
let parsed: ConfigGetParams = parse_params(params_or_object(params))?;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigUnset { key: parsed.key }).await;
|
||||
let response = process_app_request(
|
||||
state,
|
||||
AppRequest::ConfigUnset { key: parsed.key },
|
||||
AppTransport::Stdio,
|
||||
)
|
||||
.await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
@@ -638,7 +769,8 @@ async fn dispatch_stdio_request(
|
||||
}
|
||||
}
|
||||
"app/config/list" => {
|
||||
let response = process_app_request(state, AppRequest::ConfigList).await;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ConfigList, AppTransport::Stdio).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
@@ -646,7 +778,8 @@ async fn dispatch_stdio_request(
|
||||
}
|
||||
}
|
||||
"app/models" => {
|
||||
let response = process_app_request(state, AppRequest::Models).await;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::Models, AppTransport::Stdio).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
@@ -654,7 +787,8 @@ async fn dispatch_stdio_request(
|
||||
}
|
||||
}
|
||||
"app/thread_loaded_list" | "app/thread-loaded-list" => {
|
||||
let response = process_app_request(state, AppRequest::ThreadLoadedList).await;
|
||||
let response =
|
||||
process_app_request(state, AppRequest::ThreadLoadedList, AppTransport::Stdio).await;
|
||||
StdioDispatchResult {
|
||||
result: serde_json::to_value(response)
|
||||
.map_err(|err| JsonRpcError::internal(err.to_string()))?,
|
||||
@@ -685,7 +819,11 @@ async fn dispatch_stdio_request(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse {
|
||||
async fn process_app_request(
|
||||
state: &AppState,
|
||||
req: AppRequest,
|
||||
transport: AppTransport,
|
||||
) -> AppResponse {
|
||||
match req {
|
||||
AppRequest::Capabilities => AppResponse {
|
||||
ok: true,
|
||||
@@ -700,9 +838,13 @@ async fn process_app_request(state: &AppState, req: AppRequest) -> AppResponse {
|
||||
},
|
||||
AppRequest::ConfigGet { key } => {
|
||||
let cfg = state.config.read().await;
|
||||
let value = match transport {
|
||||
AppTransport::Http => cfg.get_display_value(&key),
|
||||
AppTransport::Stdio => cfg.get_value(&key),
|
||||
};
|
||||
AppResponse {
|
||||
ok: true,
|
||||
data: json!({ "key": key, "value": cfg.get_value(&key) }),
|
||||
data: json!({ "key": key, "value": value }),
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -781,3 +923,141 @@ async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml)
|
||||
store.config = config;
|
||||
store.save()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use codewhale_protocol::AppRequest;
|
||||
use std::fs;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn app_with_config(auth_token: Option<&str>) -> (Router, tempfile::TempDir) {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
|
||||
let state = build_state(
|
||||
Some(config_path),
|
||||
auth_token.map(std::string::ToString::to_string),
|
||||
)
|
||||
.expect("state");
|
||||
(app_router(state, &[]), tmp)
|
||||
}
|
||||
|
||||
async fn response_body_json(response: Response) -> Value {
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
serde_json::from_slice(&bytes).expect("json response")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_app_routes_require_bearer_token_when_auth_enabled() {
|
||||
let (app, _tmp) = app_with_config(Some("test-token"));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/app")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&AppRequest::ConfigGet {
|
||||
key: "api_key".to_string(),
|
||||
})
|
||||
.expect("request json"),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_config_get_redacts_sensitive_values_after_auth() {
|
||||
let (app, _tmp) = app_with_config(Some("test-token"));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/app")
|
||||
.header(header::AUTHORIZATION, "Bearer test-token")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&AppRequest::ConfigGet {
|
||||
key: "api_key".to_string(),
|
||||
})
|
||||
.expect("request json"),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_body_json(response).await;
|
||||
assert_eq!(body["data"]["value"], "sk-d***cret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_does_not_allow_arbitrary_origins() {
|
||||
let (app, _tmp) = app_with_config(Some("test-token"));
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/healthz")
|
||||
.header(header::ORIGIN, "https://attacker.example")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_loopback_bind_without_auth_fails_fast() {
|
||||
let options = AppServerOptions {
|
||||
listen: "0.0.0.0:8787".parse().expect("socket addr"),
|
||||
config_path: None,
|
||||
auth_token: None,
|
||||
insecure_no_auth: true,
|
||||
cors_origins: Vec::new(),
|
||||
};
|
||||
|
||||
let err = resolve_auth_token(&options).expect_err("non-loopback unauth should fail");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("refusing unauthenticated app-server bind")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_transport_keeps_raw_config_get_for_legacy_clients() {
|
||||
let state = build_state(None, None).expect("state");
|
||||
{
|
||||
let mut cfg = state.config.write().await;
|
||||
cfg.api_key = Some("sk-deepseek-secret".to_string());
|
||||
}
|
||||
|
||||
let response = process_app_request(
|
||||
&state,
|
||||
AppRequest::ConfigGet {
|
||||
key: "api_key".to_string(),
|
||||
},
|
||||
AppTransport::Stdio,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.data["value"], "sk-deepseek-secret");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ struct Cli {
|
||||
port: u16,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long = "auth-token")]
|
||||
auth_token: Option<String>,
|
||||
#[arg(long, default_value_t = false)]
|
||||
insecure_no_auth: bool,
|
||||
#[arg(long = "cors-origin")]
|
||||
cors_origin: Vec<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -28,6 +34,15 @@ async fn main() -> Result<()> {
|
||||
run(AppServerOptions {
|
||||
listen,
|
||||
config_path: cli.config,
|
||||
auth_token: cli.auth_token.or_else(app_server_token_from_env),
|
||||
insecure_no_auth: cli.insecure_no_auth,
|
||||
cors_origins: cli.cors_origin,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn app_server_token_from_env() -> Option<String> {
|
||||
std::env::var("CODEWHALE_APP_SERVER_TOKEN")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@ path = "src/bin/deepseek_legacy_shim.rs"
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.44" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.8.44" }
|
||||
codewhale-config = { path = "../config", version = "0.8.44" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.44" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.44" }
|
||||
codewhale-state = { path = "../state", version = "0.8.44" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.45" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.8.45" }
|
||||
codewhale-config = { path = "../config", version = "0.8.45" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.45" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.45" }
|
||||
codewhale-state = { path = "../state", version = "0.8.45" }
|
||||
chrono.workspace = true
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
+39
-50
@@ -182,7 +182,7 @@ working-tree diff. `export` only writes the current diff.
|
||||
Serve(TuiPassthroughArgs),
|
||||
/// Generate shell completions for the TUI binary.
|
||||
Completions(TuiPassthroughArgs),
|
||||
/// Save a provider API key to the shared user config file.
|
||||
/// Configure provider credentials.
|
||||
Login(LoginArgs),
|
||||
/// Remove saved authentication state.
|
||||
Logout,
|
||||
@@ -259,16 +259,10 @@ struct TuiPassthroughArgs {
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct LoginArgs {
|
||||
#[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]
|
||||
provider: ProviderArg,
|
||||
#[arg(long, value_enum, hide = true)]
|
||||
provider: Option<ProviderArg>,
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
#[arg(long, default_value_t = false, hide = true)]
|
||||
chatgpt: bool,
|
||||
#[arg(long, default_value_t = false, hide = true)]
|
||||
device_code: bool,
|
||||
#[arg(long, hide = true)]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -428,6 +422,12 @@ struct AppServerArgs {
|
||||
port: u16,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long = "auth-token")]
|
||||
auth_token: Option<String>,
|
||||
#[arg(long, default_value_t = false)]
|
||||
insecure_no_auth: bool,
|
||||
#[arg(long = "cors-origin")]
|
||||
cors_origin: Vec<String>,
|
||||
#[arg(long, default_value_t = false)]
|
||||
stdio: bool,
|
||||
}
|
||||
@@ -654,38 +654,9 @@ fn run_login_command_with_secrets(
|
||||
args: LoginArgs,
|
||||
secrets: &Secrets,
|
||||
) -> Result<()> {
|
||||
let provider: ProviderKind = args.provider.into();
|
||||
let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
|
||||
store.config.provider = provider;
|
||||
|
||||
if args.chatgpt {
|
||||
let token = match args.token {
|
||||
Some(token) => token,
|
||||
None => read_api_key_from_stdin()?,
|
||||
};
|
||||
store.config.auth_mode = Some("chatgpt".to_string());
|
||||
store.config.chatgpt_access_token = Some(token);
|
||||
store.config.device_code_session = None;
|
||||
store.save()?;
|
||||
println!("logged in using chatgpt token mode ({})", provider.as_str());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if args.device_code {
|
||||
let token = match args.token {
|
||||
Some(token) => token,
|
||||
None => read_api_key_from_stdin()?,
|
||||
};
|
||||
store.config.auth_mode = Some("device_code".to_string());
|
||||
store.config.device_code_session = Some(token);
|
||||
store.config.chatgpt_access_token = None;
|
||||
store.save()?;
|
||||
println!(
|
||||
"logged in using device code session mode ({})",
|
||||
provider.as_str()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let api_key = match args.api_key {
|
||||
Some(v) => v,
|
||||
None => read_api_key_from_stdin()?,
|
||||
@@ -721,8 +692,6 @@ fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -
|
||||
}
|
||||
clear_provider_api_key_from_keyring(secrets, active_provider);
|
||||
store.config.auth_mode = None;
|
||||
store.config.chatgpt_access_token = None;
|
||||
store.config.device_code_session = None;
|
||||
store.save()?;
|
||||
println!("logged out");
|
||||
Ok(())
|
||||
@@ -909,6 +878,10 @@ fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
|
||||
|
||||
vec![
|
||||
format!("provider: {}", provider.as_str()),
|
||||
format!(
|
||||
"auth mode: {}",
|
||||
store.config.auth_mode.as_deref().unwrap_or("api_key")
|
||||
),
|
||||
format!("active source: {active_label}"),
|
||||
"lookup order: config -> secret store -> env".to_string(),
|
||||
format!(
|
||||
@@ -1317,9 +1290,18 @@ fn run_app_server_command(args: AppServerArgs) -> Result<()> {
|
||||
runtime.block_on(run_app_server(AppServerOptions {
|
||||
listen,
|
||||
config_path: args.config,
|
||||
auth_token: args.auth_token.or_else(app_server_token_from_env),
|
||||
insecure_no_auth: args.insecure_no_auth,
|
||||
cors_origins: args.cors_origin,
|
||||
}))
|
||||
}
|
||||
|
||||
fn app_server_token_from_env() -> Option<String> {
|
||||
std::env::var("CODEWHALE_APP_SERVER_TOKEN")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
|
||||
}
|
||||
|
||||
fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
|
||||
let persisted = load_mcp_server_definitions(store);
|
||||
let updated = run_stdio_server(persisted)?;
|
||||
@@ -1484,6 +1466,9 @@ fn build_tui_command(
|
||||
cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
|
||||
cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
|
||||
cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
|
||||
if let Some(auth_mode) = resolved_runtime.auth_mode.as_ref() {
|
||||
cmd.env("DEEPSEEK_AUTH_MODE", auth_mode);
|
||||
}
|
||||
if !resolved_runtime.http_headers.is_empty() {
|
||||
let encoded = resolved_runtime
|
||||
.http_headers
|
||||
@@ -2040,11 +2025,8 @@ mod tests {
|
||||
run_login_command_with_secrets(
|
||||
&mut store,
|
||||
LoginArgs {
|
||||
provider: ProviderArg::Deepseek,
|
||||
provider: Some(ProviderArg::Deepseek),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
chatgpt: false,
|
||||
device_code: false,
|
||||
token: None,
|
||||
},
|
||||
&secrets,
|
||||
)
|
||||
@@ -2566,7 +2548,7 @@ mod tests {
|
||||
"--profile",
|
||||
"work",
|
||||
"--model",
|
||||
"gpt-4.1",
|
||||
"deepseek-v4-pro",
|
||||
"--output-mode",
|
||||
"json",
|
||||
"--log-level",
|
||||
@@ -2578,7 +2560,7 @@ mod tests {
|
||||
"--sandbox-mode",
|
||||
"workspace-write",
|
||||
"--base-url",
|
||||
"https://api.openai.com/v1",
|
||||
"https://openai-compatible.example/v1",
|
||||
"--api-key",
|
||||
"sk-test",
|
||||
"--workspace",
|
||||
@@ -2588,19 +2570,22 @@ mod tests {
|
||||
"--skip-onboarding",
|
||||
"model",
|
||||
"resolve",
|
||||
"gpt-4.1",
|
||||
"deepseek-v4-pro",
|
||||
]);
|
||||
|
||||
assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
|
||||
assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
|
||||
assert_eq!(cli.profile.as_deref(), Some("work"));
|
||||
assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
|
||||
assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
|
||||
assert_eq!(cli.output_mode.as_deref(), Some("json"));
|
||||
assert_eq!(cli.log_level.as_deref(), Some("debug"));
|
||||
assert_eq!(cli.telemetry, Some(true));
|
||||
assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
|
||||
assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
|
||||
assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
|
||||
assert_eq!(
|
||||
cli.base_url.as_deref(),
|
||||
Some("https://openai-compatible.example/v1")
|
||||
);
|
||||
assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
|
||||
assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
|
||||
assert!(cli.no_alt_screen);
|
||||
@@ -2668,6 +2653,10 @@ mod tests {
|
||||
command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
|
||||
Some("keyring")
|
||||
);
|
||||
assert_eq!(
|
||||
command_env(&cmd, "DEEPSEEK_AUTH_MODE").as_deref(),
|
||||
Some("api_key")
|
||||
);
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
|
||||
@@ -8,8 +8,9 @@ description = "Config schema and precedence model for DeepSeek workspace archite
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.44" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.45" }
|
||||
dirs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
+161
-94
@@ -17,7 +17,7 @@ pub const CONFIG_FILE_NAME: &str = "config.toml";
|
||||
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
|
||||
const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
||||
const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
|
||||
const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1";
|
||||
const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
|
||||
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
|
||||
const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
@@ -58,6 +58,7 @@ pub enum ProviderKind {
|
||||
)]
|
||||
Deepseek,
|
||||
NvidiaNim,
|
||||
#[serde(alias = "open-ai")]
|
||||
Openai,
|
||||
Atlascloud,
|
||||
#[serde(
|
||||
@@ -210,8 +211,6 @@ pub struct ConfigToml {
|
||||
pub provider: ProviderKind,
|
||||
pub model: Option<String>,
|
||||
pub auth_mode: Option<String>,
|
||||
pub chatgpt_access_token: Option<String>,
|
||||
pub device_code_session: Option<String>,
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: Option<bool>,
|
||||
@@ -343,91 +342,61 @@ pub struct LspConfigToml {
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
/// Merge project-level overrides from `$WORKSPACE/.deepseek/config.toml`.
|
||||
/// Only populated fields in `project` are applied; everything else
|
||||
/// keeps its global value. Provider-specific sub-tables are merged
|
||||
/// field-by-field so a project can set just `providers.deepseek.model`
|
||||
/// without needing to repeat `api_key` or `base_url`.
|
||||
/// Merge safe project-level overrides from `$WORKSPACE/.codewhale/config.toml`
|
||||
/// or legacy `$WORKSPACE/.deepseek/config.toml`.
|
||||
///
|
||||
/// Repo-local config is untrusted input. This helper intentionally ignores
|
||||
/// credentials, endpoints, provider selection, auth/session values, telemetry,
|
||||
/// network policy, skill registry, LSP command tables, and unknown extras.
|
||||
/// Approval and sandbox values may only tighten the existing user/global
|
||||
/// posture.
|
||||
pub fn merge_project_overrides(&mut self, project: ConfigToml) {
|
||||
// Check provider override condition before moving fields.
|
||||
let has_api_key = project.api_key.is_some();
|
||||
|
||||
// Top-level scalar fields: apply when the project has a value.
|
||||
if has_api_key {
|
||||
self.api_key = project.api_key;
|
||||
}
|
||||
if project.base_url.is_some() {
|
||||
self.base_url = project.base_url;
|
||||
}
|
||||
if !project.http_headers.is_empty() {
|
||||
self.http_headers = project.http_headers;
|
||||
}
|
||||
if project.default_text_model.is_some() {
|
||||
self.default_text_model = project.default_text_model;
|
||||
}
|
||||
if project.model.is_some() {
|
||||
self.model = project.model;
|
||||
}
|
||||
if project.auth_mode.is_some() {
|
||||
self.auth_mode = project.auth_mode;
|
||||
}
|
||||
if project.output_mode.is_some() {
|
||||
self.output_mode = project.output_mode;
|
||||
}
|
||||
if project.telemetry.is_some() {
|
||||
self.telemetry = project.telemetry;
|
||||
if project.log_level.is_some() {
|
||||
self.log_level = project.log_level;
|
||||
}
|
||||
if project.approval_policy.is_some() {
|
||||
self.approval_policy = project.approval_policy;
|
||||
if let Some(policy) = project.approval_policy
|
||||
&& project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy)
|
||||
{
|
||||
self.approval_policy = Some(policy);
|
||||
}
|
||||
if project.sandbox_mode.is_some() {
|
||||
self.sandbox_mode = project.sandbox_mode;
|
||||
}
|
||||
// Provider is only overridden if explicitly set (non-default).
|
||||
if project.provider != ProviderKind::Deepseek || has_api_key {
|
||||
self.provider = project.provider;
|
||||
if let Some(mode) = project.sandbox_mode
|
||||
&& project_sandbox_mode_is_allowed(self.sandbox_mode.as_deref(), &mode)
|
||||
{
|
||||
self.sandbox_mode = Some(mode);
|
||||
}
|
||||
|
||||
// Merge provider sub-tables field-by-field.
|
||||
merge_provider_config(&mut self.providers.deepseek, &project.providers.deepseek);
|
||||
merge_provider_config(
|
||||
merge_project_provider_config(&mut self.providers.deepseek, &project.providers.deepseek);
|
||||
merge_project_provider_config(
|
||||
&mut self.providers.nvidia_nim,
|
||||
&project.providers.nvidia_nim,
|
||||
);
|
||||
merge_provider_config(&mut self.providers.openai, &project.providers.openai);
|
||||
merge_provider_config(
|
||||
merge_project_provider_config(&mut self.providers.openai, &project.providers.openai);
|
||||
merge_project_provider_config(
|
||||
&mut self.providers.atlascloud,
|
||||
&project.providers.atlascloud,
|
||||
);
|
||||
merge_provider_config(
|
||||
merge_project_provider_config(
|
||||
&mut self.providers.wanjie_ark,
|
||||
&project.providers.wanjie_ark,
|
||||
);
|
||||
merge_provider_config(
|
||||
merge_project_provider_config(
|
||||
&mut self.providers.openrouter,
|
||||
&project.providers.openrouter,
|
||||
);
|
||||
merge_provider_config(&mut self.providers.novita, &project.providers.novita);
|
||||
merge_provider_config(&mut self.providers.fireworks, &project.providers.fireworks);
|
||||
merge_provider_config(&mut self.providers.sglang, &project.providers.sglang);
|
||||
merge_provider_config(&mut self.providers.vllm, &project.providers.vllm);
|
||||
merge_provider_config(&mut self.providers.ollama, &project.providers.ollama);
|
||||
|
||||
if project.network.is_some() {
|
||||
self.network = project.network;
|
||||
}
|
||||
if project.skills.is_some() {
|
||||
self.skills = project.skills;
|
||||
}
|
||||
if project.snapshots.is_some() {
|
||||
self.snapshots = project.snapshots;
|
||||
}
|
||||
if project.lsp.is_some() {
|
||||
self.lsp = project.lsp;
|
||||
}
|
||||
for (k, v) in project.extras {
|
||||
self.extras.insert(k, v);
|
||||
}
|
||||
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);
|
||||
merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm);
|
||||
merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -440,8 +409,6 @@ impl ConfigToml {
|
||||
"default_text_model" => self.default_text_model.clone(),
|
||||
"model" => self.model.clone(),
|
||||
"auth.mode" => self.auth_mode.clone(),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token.clone(),
|
||||
"auth.device_code_session" => self.device_code_session.clone(),
|
||||
"output_mode" => self.output_mode.clone(),
|
||||
"log_level" => self.log_level.clone(),
|
||||
"telemetry" => self.telemetry.map(|v| v.to_string()),
|
||||
@@ -540,8 +507,6 @@ impl ConfigToml {
|
||||
"default_text_model" => self.default_text_model = Some(value.to_string()),
|
||||
"model" => self.model = Some(value.to_string()),
|
||||
"auth.mode" => self.auth_mode = Some(value.to_string()),
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()),
|
||||
"auth.device_code_session" => self.device_code_session = Some(value.to_string()),
|
||||
"output_mode" => self.output_mode = Some(value.to_string()),
|
||||
"log_level" => self.log_level = Some(value.to_string()),
|
||||
"telemetry" => {
|
||||
@@ -700,8 +665,6 @@ impl ConfigToml {
|
||||
"default_text_model" => self.default_text_model = None,
|
||||
"model" => self.model = None,
|
||||
"auth.mode" => self.auth_mode = None,
|
||||
"auth.chatgpt_access_token" => self.chatgpt_access_token = None,
|
||||
"auth.device_code_session" => self.device_code_session = None,
|
||||
"output_mode" => self.output_mode = None,
|
||||
"log_level" => self.log_level = None,
|
||||
"telemetry" => self.telemetry = None,
|
||||
@@ -795,12 +758,6 @@ impl ConfigToml {
|
||||
if let Some(v) = self.auth_mode.as_ref() {
|
||||
out.insert("auth.mode".to_string(), v.clone());
|
||||
}
|
||||
if let Some(v) = self.chatgpt_access_token.as_ref() {
|
||||
out.insert("auth.chatgpt_access_token".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.device_code_session.as_ref() {
|
||||
out.insert("auth.device_code_session".to_string(), redact_secret(v));
|
||||
}
|
||||
if let Some(v) = self.output_mode.as_ref() {
|
||||
out.insert("output_mode".to_string(), v.clone());
|
||||
}
|
||||
@@ -1137,18 +1094,57 @@ impl ConfigToml {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
|
||||
if source.api_key.is_some() {
|
||||
target.api_key = source.api_key.clone();
|
||||
}
|
||||
if source.base_url.is_some() {
|
||||
target.base_url = source.base_url.clone();
|
||||
}
|
||||
fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfigToml) {
|
||||
if source.model.is_some() {
|
||||
target.model = source.model.clone();
|
||||
}
|
||||
if !source.http_headers.is_empty() {
|
||||
target.http_headers = source.http_headers.clone();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
|
||||
let Some(project_rank) = approval_policy_rank(project) else {
|
||||
return false;
|
||||
};
|
||||
match current.and_then(approval_policy_rank) {
|
||||
Some(current_rank) => project_rank >= current_rank,
|
||||
None => project_rank >= 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_sandbox_mode_is_allowed(current: Option<&str>, project: &str) -> bool {
|
||||
let normalized_project = project.trim().to_ascii_lowercase();
|
||||
if normalized_project == "external-sandbox" {
|
||||
return current
|
||||
.map(|value| value.trim().eq_ignore_ascii_case("external-sandbox"))
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
let Some(project_rank) = sandbox_mode_rank(project) else {
|
||||
return false;
|
||||
};
|
||||
match current.and_then(sandbox_mode_rank) {
|
||||
Some(current_rank) => project_rank >= current_rank,
|
||||
None => project_rank >= 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_policy_rank(value: &str) -> Option<u8> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Some(0),
|
||||
"suggest" | "suggested" | "on-request" | "untrusted" => Some(1),
|
||||
"never" | "deny" | "denied" => Some(2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_mode_rank(value: &str) -> Option<u8> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"danger-full-access" => Some(0),
|
||||
"external-sandbox" => Some(0),
|
||||
"workspace-write" => Some(1),
|
||||
"read-only" => Some(2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,10 +1682,7 @@ fn redact_secret(secret: &str) -> String {
|
||||
|
||||
#[must_use]
|
||||
pub fn is_sensitive_config_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"api_key" | "auth.chatgpt_access_token" | "auth.device_code_session"
|
||||
) || key.ends_with(".api_key")
|
||||
key == "api_key" || key.ends_with(".api_key")
|
||||
}
|
||||
|
||||
fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
|
||||
@@ -2344,7 +2337,6 @@ mod tests {
|
||||
fn get_display_value_redacts_sensitive_keys() {
|
||||
let mut config = ConfigToml {
|
||||
api_key: Some("sk-deepseek-secret".to_string()),
|
||||
chatgpt_access_token: Some("chatgpt-access-secret".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
|
||||
@@ -2354,12 +2346,6 @@ mod tests {
|
||||
config.get_display_value("api_key").as_deref(),
|
||||
Some("sk-d***cret")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.get_display_value("auth.chatgpt_access_token")
|
||||
.as_deref(),
|
||||
Some("chat***cret")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.get_display_value("providers.openrouter.api_key")
|
||||
@@ -2372,6 +2358,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_merge_denies_credentials_endpoints_and_provider_selection() {
|
||||
let mut base = ConfigToml {
|
||||
provider: ProviderKind::Deepseek,
|
||||
api_key: Some("user-key".to_string()),
|
||||
base_url: Some("https://api.deepseek.com".to_string()),
|
||||
default_text_model: Some("deepseek-v4-flash".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
base.providers.openrouter.api_key = Some("user-openrouter-key".to_string());
|
||||
|
||||
let mut project = ConfigToml {
|
||||
provider: ProviderKind::Openrouter,
|
||||
api_key: Some("attacker-key".to_string()),
|
||||
base_url: Some("https://evil.example/v1".to_string()),
|
||||
default_text_model: Some("deepseek-v4-pro".to_string()),
|
||||
auth_mode: Some("oauth".to_string()),
|
||||
telemetry: Some(true),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
|
||||
project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
|
||||
project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
|
||||
|
||||
base.merge_project_overrides(project);
|
||||
|
||||
assert_eq!(base.provider, ProviderKind::Deepseek);
|
||||
assert_eq!(base.api_key.as_deref(), Some("user-key"));
|
||||
assert_eq!(base.base_url.as_deref(), Some("https://api.deepseek.com"));
|
||||
assert_eq!(base.auth_mode, None);
|
||||
assert_eq!(base.telemetry, None);
|
||||
assert_eq!(
|
||||
base.providers.openrouter.api_key.as_deref(),
|
||||
Some("user-openrouter-key")
|
||||
);
|
||||
assert_eq!(base.providers.openrouter.base_url, None);
|
||||
assert_eq!(base.default_text_model.as_deref(), Some("deepseek-v4-pro"));
|
||||
assert_eq!(
|
||||
base.providers.openrouter.model.as_deref(),
|
||||
Some("deepseek/deepseek-v4-pro")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_merge_only_tightens_approval_and_sandbox_policy() {
|
||||
let mut strict = ConfigToml {
|
||||
approval_policy: Some("never".to_string()),
|
||||
sandbox_mode: Some("read-only".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
strict.merge_project_overrides(ConfigToml {
|
||||
approval_policy: Some("on-request".to_string()),
|
||||
sandbox_mode: Some("workspace-write".to_string()),
|
||||
..ConfigToml::default()
|
||||
});
|
||||
assert_eq!(strict.approval_policy.as_deref(), Some("never"));
|
||||
assert_eq!(strict.sandbox_mode.as_deref(), Some("read-only"));
|
||||
|
||||
let mut permissive = ConfigToml {
|
||||
approval_policy: Some("auto".to_string()),
|
||||
sandbox_mode: Some("workspace-write".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
permissive.merge_project_overrides(ConfigToml {
|
||||
approval_policy: Some("never".to_string()),
|
||||
sandbox_mode: Some("read-only".to_string()),
|
||||
..ConfigToml::default()
|
||||
});
|
||||
assert_eq!(permissive.approval_policy.as_deref(), Some("never"));
|
||||
assert_eq!(permissive.sandbox_mode.as_deref(), Some("read-only"));
|
||||
|
||||
let mut unset = ConfigToml::default();
|
||||
unset.merge_project_overrides(ConfigToml {
|
||||
approval_policy: Some("on-request".to_string()),
|
||||
sandbox_mode: Some("workspace-write".to_string()),
|
||||
..ConfigToml::default()
|
||||
});
|
||||
assert_eq!(unset.approval_policy, None);
|
||||
assert_eq!(unset.sandbox_mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_values_redacts_unicode_api_key_without_byte_slicing() {
|
||||
let config = ConfigToml {
|
||||
|
||||
@@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-agent = { path = "../agent", version = "0.8.44" }
|
||||
codewhale-config = { path = "../config", version = "0.8.44" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.44" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.44" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.44" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.44" }
|
||||
codewhale-state = { path = "../state", version = "0.8.44" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.44" }
|
||||
codewhale-agent = { path = "../agent", version = "0.8.45" }
|
||||
codewhale-config = { path = "../config", version = "0.8.45" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.45" }
|
||||
codewhale-hooks = { path = "../hooks", version = "0.8.45" }
|
||||
codewhale-mcp = { path = "../mcp", version = "0.8.45" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.45" }
|
||||
codewhale-state = { path = "../state", version = "0.8.45" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.45" }
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.44" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.45" }
|
||||
serde.workspace = true
|
||||
|
||||
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.44" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.45" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.44" }
|
||||
codewhale-protocol = { path = "../protocol", version = "0.8.45" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
+68
-1
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.45] - 2026-05-25
|
||||
|
||||
### Added
|
||||
|
||||
- **RLM session objects.** `rlm_open` can now load `session://` refs,
|
||||
exposing the active prompt, history, and session data as symbolic objects
|
||||
inside RLM REPLs (#2047).
|
||||
- **Deterministic whale-species sub-agent names.** Sub-agents now get stable,
|
||||
human-readable whale-species nicknames (e.g. "Beluga", "Orca") while
|
||||
preserving the raw agent ID in the popup (#2035, #2016).
|
||||
- **`/balance` command scaffold.** Registered the `/balance` slash command
|
||||
as a placeholder for future provider billing queries (#2035, #2019).
|
||||
- **Readable `/restore` snapshot labels.** Snapshot labels now include the
|
||||
originating user prompt so restore listings are easier to identify. Thanks
|
||||
@idling11 (#2111).
|
||||
- **Sidebar hover tooltips.** Truncated Work and Tasks sidebar lines now expose
|
||||
their full text on hover. Thanks @idling11 (#2110).
|
||||
|
||||
### Changed
|
||||
|
||||
- **AGENTS.md is now maintainer-local.** The project instructions file no
|
||||
longer ships as a tracked repo file; it lives in maintainer-local ignored
|
||||
state (#2047).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sub-agent completion handoff compatibility.** Completion handoffs now use a
|
||||
chat-template-safe role and emit before terminal updates, fixing strict
|
||||
OpenAI-compatible/self-hosted backends and preserving transcript ordering.
|
||||
Thanks @h3c-hexin and @cyq1017 (#2057, #2120).
|
||||
- **Self-hosted context budgeting.** Sub-500K self-hosted model windows now keep
|
||||
a usable input budget instead of disabling preflight compaction after output
|
||||
reservation underflow. Thanks @h3c-hexin (#2060).
|
||||
- **Goal prompts start actionable.** Goal-start prompts now open in an
|
||||
actionable state instead of requiring an extra nudge. Thanks @cyq1017
|
||||
(#2097).
|
||||
- **Composer session title display.** The composer chrome shows the current
|
||||
session title again and avoids grayscale luma overflow in debug builds.
|
||||
Thanks @wdw8276 (#2108).
|
||||
- **Approval prompts use a one-step confirmation flow.** Enter now commits the
|
||||
selected approval option directly, destructive warnings remain visible, and
|
||||
abort cancels the active turn instead of only denying the current tool call.
|
||||
Thanks @reidliu41 (#2143).
|
||||
- **Model picker selection survives Esc.** Dismissing the model picker with Esc
|
||||
no longer loses the highlighted selection. Thanks @reidliu41 (#2056).
|
||||
- **Slash recovery no longer restores command tails in the composer.**
|
||||
Resuming a session or recovering from a crash no longer leaves stale
|
||||
slash-command text (e.g. `/sessions`) in the composer input (#2047, #2032).
|
||||
- **Remembered tool approvals now update the live active turn.**
|
||||
When the "remember" checkbox is set on an approval dialog, the active
|
||||
turn's auto-approve flag flips immediately instead of waiting for the
|
||||
next turn. Thanks @gaord (#2047, #2041).
|
||||
- **YAML block scalars in SKILL.md frontmatter.** Multi-line descriptions
|
||||
using `>` or `|` indicators are now parsed correctly — folded block
|
||||
scalars join non-empty lines with spaces, literal scalars preserve
|
||||
newlines, and all three chomping modes (strip/clip/keep) are supported.
|
||||
Thanks @zlh124 (#1908, #1907).
|
||||
- **User messages highlighted in the transcript.** User-authored messages
|
||||
now render with a full-row background in the live TUI transcript, making
|
||||
it easier to scan prior turns. Assistant and system messages are
|
||||
unaffected. Thanks @reidliu41 (#1995, #1672).
|
||||
- **Cancellable `list_dir` and `file_search`.** Long directory walks and
|
||||
file searches now respond to user cancel/stop requests with a 30-second
|
||||
fallback timeout, preventing the TUI from hanging on deep or slow
|
||||
filesystems (#2035).
|
||||
|
||||
## [0.8.44] - 2026-05-24
|
||||
|
||||
### Added
|
||||
@@ -4806,7 +4872,8 @@ Welcome — and thank you.
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.45...HEAD
|
||||
[0.8.45]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...v0.8.45
|
||||
[0.8.44]: https://github.com/Hmbown/CodeWhale/compare/v0.8.43...v0.8.44
|
||||
[0.8.43]: https://github.com/Hmbown/CodeWhale/compare/v0.8.42...v0.8.43
|
||||
[0.8.42]: https://github.com/Hmbown/CodeWhale/compare/v0.8.41...v0.8.42
|
||||
|
||||
@@ -27,9 +27,9 @@ path = "src/bin/deepseek_tui_legacy_shim.rs"
|
||||
[dependencies]
|
||||
anyhow = "1.0.100"
|
||||
arboard = "3.4"
|
||||
codewhale-config = { path = "../config", version = "0.8.44" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.44" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.44" }
|
||||
codewhale-config = { path = "../config", version = "0.8.45" }
|
||||
codewhale-secrets = { path = "../secrets", version = "0.8.45" }
|
||||
codewhale-tools = { path = "../tools", version = "0.8.45" }
|
||||
schemaui = { version = "0.12.0", default-features = false, optional = true }
|
||||
async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -37,7 +37,7 @@ pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
|
||||
pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
|
||||
pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
|
||||
pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
|
||||
pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1";
|
||||
pub const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
|
||||
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
pub const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
|
||||
pub const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
|
||||
|
||||
+68
-23
@@ -69,6 +69,7 @@ mod snapshot;
|
||||
mod task_manager;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod theme_qa_audit;
|
||||
mod tools;
|
||||
mod tui;
|
||||
mod utils;
|
||||
@@ -4661,41 +4662,49 @@ fn merge_project_config(config: &mut Config, workspace: &Path) {
|
||||
|
||||
// String fields a project may legitimately override (model,
|
||||
// approval/sandbox tightening, notes path, reasoning effort).
|
||||
// Loosening *values* like `approval_policy = "auto"` and
|
||||
// `sandbox_mode = "danger-full-access"` are denied unconditionally
|
||||
// — those are pure escalation regardless of the user's prior
|
||||
// value. Sub-tightening comparisons (e.g. user `"never"` →
|
||||
// project `"on-request"`) stay v0.8.9 follow-up because they
|
||||
// need a richer ordering check.
|
||||
for (key, field) in [
|
||||
("model", &mut config.default_text_model),
|
||||
("reasoning_effort", &mut config.reasoning_effort),
|
||||
("approval_policy", &mut config.approval_policy),
|
||||
("sandbox_mode", &mut config.sandbox_mode),
|
||||
("notes_path", &mut config.notes_path),
|
||||
] {
|
||||
if let Some(v) = table.get(key).and_then(toml::Value::as_str)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
// #417 escalation deny: project cannot push the session
|
||||
// to the loosest values. Other strings flow through the
|
||||
// existing config validator on load.
|
||||
let is_escalation = matches!(
|
||||
(key, v),
|
||||
("approval_policy", "auto") | ("sandbox_mode", "danger-full-access")
|
||||
);
|
||||
if is_escalation {
|
||||
eprintln!(
|
||||
"warning: project-scope `{key} = \"{v}\"` is ignored — \
|
||||
project config cannot escalate to the loosest value. \
|
||||
(See #417.)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
*field = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
if codewhale_config::project_approval_policy_is_allowed(
|
||||
config.approval_policy.as_deref(),
|
||||
v,
|
||||
) {
|
||||
config.approval_policy = Some(v.to_string());
|
||||
} else {
|
||||
eprintln!(
|
||||
"warning: project-scope `approval_policy = \"{v}\"` is ignored — \
|
||||
project config can only tighten the user's approval policy. \
|
||||
(See #417.)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
|
||||
config.sandbox_mode = Some(v.to_string());
|
||||
} else {
|
||||
eprintln!(
|
||||
"warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
|
||||
project config can only tighten the user's sandbox mode. \
|
||||
(See #417.)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric / bool fields that benefit from per-project overrides.
|
||||
if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
|
||||
&& v > 0
|
||||
@@ -6299,6 +6308,42 @@ approval_policy = "auto"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
|
||||
let tmp = workspace_with_project_config(
|
||||
r#"
|
||||
approval_policy = "on-request"
|
||||
sandbox_mode = "workspace-write"
|
||||
"#,
|
||||
);
|
||||
let mut config = Config {
|
||||
approval_policy: Some("never".to_string()),
|
||||
sandbox_mode: Some("read-only".to_string()),
|
||||
..Config::default()
|
||||
};
|
||||
merge_project_config(&mut config, tmp.path());
|
||||
assert_eq!(config.approval_policy.as_deref(), Some("never"));
|
||||
assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_overlay_can_tighten_user_policy() {
|
||||
let tmp = workspace_with_project_config(
|
||||
r#"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
"#,
|
||||
);
|
||||
let mut config = Config {
|
||||
approval_policy: Some("on-request".to_string()),
|
||||
sandbox_mode: Some("workspace-write".to_string()),
|
||||
..Config::default()
|
||||
};
|
||||
merge_project_config(&mut config, tmp.path());
|
||||
assert_eq!(config.approval_policy.as_deref(), Some("never"));
|
||||
assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_overlay_overrides_max_subagents_and_allow_shell() {
|
||||
let tmp = workspace_with_project_config(
|
||||
|
||||
+528
-184
@@ -4,15 +4,57 @@ use ratatui::style::Color;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
|
||||
pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); // #3578E5
|
||||
pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);
|
||||
// v0.8.45 Whale dark palette — refreshed ocean/navy identity.
|
||||
pub const WHALE_BG_RGB: (u8, u8, u8) = (13, 21, 37); // #0D1525 Deep Navy
|
||||
pub const WHALE_PANEL_RGB: (u8, u8, u8) = (19, 29, 48); // #131D30
|
||||
pub const WHALE_ELEVATED_RGB: (u8, u8, u8) = (26, 40, 64); // #1A2840
|
||||
pub const WHALE_SELECTION_RGB: (u8, u8, u8) = (30, 50, 82); // #1E3252
|
||||
pub const WHALE_TEXT_BODY_RGB: (u8, u8, u8) = (246, 242, 232); // #F6F2E8 Whale Ivory
|
||||
pub const WHALE_TEXT_SOFT_RGB: (u8, u8, u8) = (217, 224, 234); // #D9E0EA
|
||||
pub const WHALE_TEXT_MUTED_RGB: (u8, u8, u8) = (169, 180, 199); // #A9B4C7 Mist Gray
|
||||
pub const WHALE_TEXT_HINT_RGB: (u8, u8, u8) = (122, 134, 158); // #7A869E
|
||||
#[allow(dead_code)]
|
||||
pub const WHALE_TEXT_DIM_RGB: (u8, u8, u8) = (107, 120, 146); // #6B7892
|
||||
pub const WHALE_ACCENT_PRIMARY_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold
|
||||
pub const WHALE_ACCENT_SECONDARY_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam
|
||||
pub const WHALE_ACCENT_ACTION_RGB: (u8, u8, u8) = (255, 122, 89); // #FF7A59 Coral Spark
|
||||
pub const WHALE_ERROR_RGB: (u8, u8, u8) = (255, 92, 122); // #FF5C7A Rose Red
|
||||
pub const WHALE_ERROR_HOVER_RGB: (u8, u8, u8) = (255, 120, 144); // #FF7890 Rose Hover
|
||||
pub const WHALE_ERROR_SURFACE_RGB: (u8, u8, u8) = (42, 18, 26); // #2A121A Error Surface
|
||||
pub const WHALE_ERROR_BORDER_RGB: (u8, u8, u8) = (255, 138, 160); // #FF8AA0 Error Border
|
||||
pub const WHALE_ERROR_TEXT_RGB: (u8, u8, u8) = (255, 214, 222); // #FFD6DE Error Text
|
||||
pub const WHALE_WARNING_RGB: (u8, u8, u8) = (240, 160, 48); // #F0A030
|
||||
pub const WHALE_SUCCESS_RGB: (u8, u8, u8) = (79, 209, 197); // #4FD1C5 Seafoam
|
||||
pub const WHALE_INFO_RGB: (u8, u8, u8) = (106, 174, 242); // #6AAEF2 Sky
|
||||
pub const WHALE_BORDER_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F
|
||||
pub const WHALE_REASONING_TEXT_RGB: (u8, u8, u8) = (224, 153, 72); // #E09948
|
||||
pub const WHALE_REASONING_SURFACE_RGB: (u8, u8, u8) = (42, 34, 24); // #2A2218
|
||||
pub const WHALE_REASONING_TINT_RGB: (u8, u8, u8) = (20, 30, 42); // #141E2A
|
||||
pub const WHALE_DIFF_ADDED_RGB: (u8, u8, u8) = (87, 199, 133); // #57C785
|
||||
#[allow(dead_code)]
|
||||
pub const WHALE_DIFF_DELETED_RGB: (u8, u8, u8) = (255, 92, 122); // #FF5C7A Rose Red
|
||||
pub const WHALE_DIFF_ADDED_BG_RGB: (u8, u8, u8) = (18, 42, 34); // #122A22
|
||||
pub const WHALE_DIFF_DELETED_BG_RGB: (u8, u8, u8) = (42, 18, 26); // #2A121A
|
||||
pub const WHALE_MODE_AGENT_RGB: (u8, u8, u8) = (80, 150, 255); // #5096FF
|
||||
pub const WHALE_MODE_YOLO_RGB: (u8, u8, u8) = (255, 100, 100); // #FF6464
|
||||
pub const WHALE_MODE_PLAN_RGB: (u8, u8, u8) = (246, 196, 83); // #F6C453 Signal Gold
|
||||
pub const WHALE_MODE_GOAL_RGB: (u8, u8, u8) = (100, 220, 160); // #64DCA0
|
||||
pub const WHALE_TOOL_LIVE_RGB: (u8, u8, u8) = (133, 184, 234); // #85B8EA
|
||||
pub const WHALE_TOOL_ISSUE_RGB: (u8, u8, u8) = (192, 143, 153); // #C08F99
|
||||
pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = (194, 208, 224); // #C2D0E0
|
||||
pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (24, 34, 53); // #182235
|
||||
pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (31, 45, 69); // #1F2D45
|
||||
|
||||
// Backward-compatible aliases for existing call sites.
|
||||
pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = WHALE_ACCENT_PRIMARY_RGB;
|
||||
pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = WHALE_INFO_RGB;
|
||||
#[allow(dead_code)]
|
||||
pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212);
|
||||
#[allow(dead_code)]
|
||||
pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138);
|
||||
pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
|
||||
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
|
||||
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);
|
||||
pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = WHALE_BG_RGB;
|
||||
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = WHALE_PANEL_RGB;
|
||||
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = WHALE_ERROR_RGB;
|
||||
|
||||
pub const LIGHT_SURFACE_RGB: (u8, u8, u8) = (246, 248, 251); // #F6F8FB
|
||||
pub const LIGHT_PANEL_RGB: (u8, u8, u8) = (236, 242, 248); // #ECF2F8
|
||||
@@ -40,13 +82,14 @@ pub const GRAYSCALE_BORDER_RGB: (u8, u8, u8) = (96, 96, 96); // #606060
|
||||
pub const GRAYSCALE_SELECTION_RGB: (u8, u8, u8) = (62, 62, 62); // #3E3E3E
|
||||
|
||||
// New semantic colors
|
||||
pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F
|
||||
pub const BORDER_COLOR_RGB: (u8, u8, u8) = WHALE_BORDER_RGB; // #2A4A7F
|
||||
|
||||
pub const DEEPSEEK_BLUE: Color = Color::Rgb(
|
||||
DEEPSEEK_BLUE_RGB.0,
|
||||
DEEPSEEK_BLUE_RGB.1,
|
||||
DEEPSEEK_BLUE_RGB.2,
|
||||
);
|
||||
/// Now maps to the secondary accent (Seafoam) for backward compat.
|
||||
pub const DEEPSEEK_SKY: Color =
|
||||
Color::Rgb(DEEPSEEK_SKY_RGB.0, DEEPSEEK_SKY_RGB.1, DEEPSEEK_SKY_RGB.2);
|
||||
#[allow(dead_code)]
|
||||
@@ -181,13 +224,37 @@ pub const GRAYSCALE_SELECTION_BG: Color = Color::Rgb(
|
||||
GRAYSCALE_SELECTION_RGB.2,
|
||||
);
|
||||
|
||||
pub const TEXT_BODY: Color = Color::Rgb(226, 232, 240); // #E2E8F0
|
||||
pub const TEXT_SECONDARY: Color = Color::Rgb(177, 190, 207); // #B1BECF
|
||||
pub const TEXT_HINT: Color = Color::Rgb(135, 151, 171); // #8797AB
|
||||
pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
|
||||
pub const TEXT_BODY: Color = Color::Rgb(
|
||||
WHALE_TEXT_BODY_RGB.0,
|
||||
WHALE_TEXT_BODY_RGB.1,
|
||||
WHALE_TEXT_BODY_RGB.2,
|
||||
);
|
||||
pub const TEXT_SECONDARY: Color = Color::Rgb(
|
||||
WHALE_TEXT_MUTED_RGB.0,
|
||||
WHALE_TEXT_MUTED_RGB.1,
|
||||
WHALE_TEXT_MUTED_RGB.2,
|
||||
);
|
||||
pub const TEXT_HINT: Color = Color::Rgb(
|
||||
WHALE_TEXT_HINT_RGB.0,
|
||||
WHALE_TEXT_HINT_RGB.1,
|
||||
WHALE_TEXT_HINT_RGB.2,
|
||||
);
|
||||
pub const TEXT_ACCENT: Color = Color::Rgb(
|
||||
WHALE_ACCENT_SECONDARY_RGB.0,
|
||||
WHALE_ACCENT_SECONDARY_RGB.1,
|
||||
WHALE_ACCENT_SECONDARY_RGB.2,
|
||||
);
|
||||
pub const SELECTION_TEXT: Color = Color::White;
|
||||
pub const TEXT_SOFT: Color = Color::Rgb(217, 226, 238); // #D9E2EE
|
||||
pub const TEXT_REASONING: Color = Color::Rgb(211, 170, 112); // #D3AA70
|
||||
pub const TEXT_SOFT: Color = Color::Rgb(
|
||||
WHALE_TEXT_SOFT_RGB.0,
|
||||
WHALE_TEXT_SOFT_RGB.1,
|
||||
WHALE_TEXT_SOFT_RGB.2,
|
||||
);
|
||||
pub const TEXT_REASONING: Color = Color::Rgb(
|
||||
WHALE_REASONING_TEXT_RGB.0,
|
||||
WHALE_REASONING_TEXT_RGB.1,
|
||||
WHALE_REASONING_TEXT_RGB.2,
|
||||
);
|
||||
|
||||
// Compatibility aliases for existing call sites.
|
||||
pub const TEXT_PRIMARY: Color = TEXT_BODY;
|
||||
@@ -200,51 +267,140 @@ pub const LIGHT_USER_BODY: Color = Color::Rgb(21, 128, 61); // #15803D green
|
||||
pub const BORDER_COLOR: Color =
|
||||
Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2);
|
||||
#[allow(dead_code)]
|
||||
pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5
|
||||
pub const ACCENT_PRIMARY: Color = Color::Rgb(
|
||||
WHALE_ACCENT_PRIMARY_RGB.0,
|
||||
WHALE_ACCENT_PRIMARY_RGB.1,
|
||||
WHALE_ACCENT_PRIMARY_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #6AAEF2
|
||||
pub const ACCENT_SECONDARY: Color = Color::Rgb(
|
||||
WHALE_ACCENT_SECONDARY_RGB.0,
|
||||
WHALE_ACCENT_SECONDARY_RGB.1,
|
||||
WHALE_ACCENT_SECONDARY_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30
|
||||
pub const BACKGROUND_DARK: Color = Color::Rgb(WHALE_BG_RGB.0, WHALE_BG_RGB.1, WHALE_BG_RGB.2);
|
||||
#[allow(dead_code)]
|
||||
pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
pub const STATUS_NEUTRAL: Color = TEXT_MUTED;
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134
|
||||
pub const SURFACE_PANEL: Color =
|
||||
Color::Rgb(WHALE_PANEL_RGB.0, WHALE_PANEL_RGB.1, WHALE_PANEL_RGB.2);
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40
|
||||
pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A
|
||||
pub const SURFACE_REASONING_TINT: Color = Color::Rgb(16, 24, 37); // #101825
|
||||
pub const SURFACE_ELEVATED: Color = Color::Rgb(
|
||||
WHALE_ELEVATED_RGB.0,
|
||||
WHALE_ELEVATED_RGB.1,
|
||||
WHALE_ELEVATED_RGB.2,
|
||||
);
|
||||
pub const SURFACE_REASONING: Color = Color::Rgb(
|
||||
WHALE_REASONING_SURFACE_RGB.0,
|
||||
WHALE_REASONING_SURFACE_RGB.1,
|
||||
WHALE_REASONING_SURFACE_RGB.2,
|
||||
);
|
||||
pub const SURFACE_REASONING_TINT: Color = Color::Rgb(
|
||||
WHALE_REASONING_TINT_RGB.0,
|
||||
WHALE_REASONING_TINT_RGB.1,
|
||||
WHALE_REASONING_TINT_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C
|
||||
pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(58, 46, 32);
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); // #18273C
|
||||
pub const SURFACE_TOOL: Color = Color::Rgb(
|
||||
WHALE_TOOL_SURFACE_RGB.0,
|
||||
WHALE_TOOL_SURFACE_RGB.1,
|
||||
WHALE_TOOL_SURFACE_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); // #1D3049
|
||||
pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(
|
||||
WHALE_TOOL_ACTIVE_RGB.0,
|
||||
WHALE_TOOL_ACTIVE_RGB.1,
|
||||
WHALE_TOOL_ACTIVE_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); // #16383F
|
||||
pub const SURFACE_SUCCESS: Color = Color::Rgb(18, 42, 37); // dark teal tint
|
||||
#[allow(dead_code)]
|
||||
pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); // #3F1B24
|
||||
pub const DIFF_ADDED_BG: Color = Color::Rgb(18, 52, 38); // #123426 dark green tint
|
||||
pub const DIFF_DELETED_BG: Color = Color::Rgb(52, 22, 28); // #34161C dark red tint
|
||||
pub const DIFF_ADDED: Color = Color::Rgb(87, 199, 133); // #57C785
|
||||
pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(224, 153, 72); // #E09948
|
||||
pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA
|
||||
pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99
|
||||
pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(191, 205, 220); // #BFCEDC
|
||||
pub const SURFACE_ERROR: Color = Color::Rgb(
|
||||
WHALE_ERROR_SURFACE_RGB.0,
|
||||
WHALE_ERROR_SURFACE_RGB.1,
|
||||
WHALE_ERROR_SURFACE_RGB.2,
|
||||
);
|
||||
pub const DIFF_ADDED_BG: Color = Color::Rgb(
|
||||
WHALE_DIFF_ADDED_BG_RGB.0,
|
||||
WHALE_DIFF_ADDED_BG_RGB.1,
|
||||
WHALE_DIFF_ADDED_BG_RGB.2,
|
||||
);
|
||||
pub const DIFF_DELETED_BG: Color = Color::Rgb(
|
||||
WHALE_DIFF_DELETED_BG_RGB.0,
|
||||
WHALE_DIFF_DELETED_BG_RGB.1,
|
||||
WHALE_DIFF_DELETED_BG_RGB.2,
|
||||
);
|
||||
pub const DIFF_ADDED: Color = Color::Rgb(
|
||||
WHALE_DIFF_ADDED_RGB.0,
|
||||
WHALE_DIFF_ADDED_RGB.1,
|
||||
WHALE_DIFF_ADDED_RGB.2,
|
||||
);
|
||||
pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(
|
||||
WHALE_REASONING_TEXT_RGB.0,
|
||||
WHALE_REASONING_TEXT_RGB.1,
|
||||
WHALE_REASONING_TEXT_RGB.2,
|
||||
);
|
||||
pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(
|
||||
WHALE_TOOL_LIVE_RGB.0,
|
||||
WHALE_TOOL_LIVE_RGB.1,
|
||||
WHALE_TOOL_LIVE_RGB.2,
|
||||
);
|
||||
pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(
|
||||
WHALE_TOOL_ISSUE_RGB.0,
|
||||
WHALE_TOOL_ISSUE_RGB.1,
|
||||
WHALE_TOOL_ISSUE_RGB.2,
|
||||
);
|
||||
pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(
|
||||
WHALE_TOOL_OUTPUT_RGB.0,
|
||||
WHALE_TOOL_OUTPUT_RGB.1,
|
||||
WHALE_TOOL_OUTPUT_RGB.2,
|
||||
);
|
||||
|
||||
// Legacy status colors - keep for backward compatibility
|
||||
pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
|
||||
pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); // Amber
|
||||
pub const STATUS_ERROR: Color = DEEPSEEK_RED;
|
||||
pub const STATUS_SUCCESS: Color = Color::Rgb(
|
||||
WHALE_SUCCESS_RGB.0,
|
||||
WHALE_SUCCESS_RGB.1,
|
||||
WHALE_SUCCESS_RGB.2,
|
||||
);
|
||||
pub const STATUS_WARNING: Color = Color::Rgb(
|
||||
WHALE_WARNING_RGB.0,
|
||||
WHALE_WARNING_RGB.1,
|
||||
WHALE_WARNING_RGB.2,
|
||||
);
|
||||
pub const STATUS_ERROR: Color = Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2);
|
||||
#[allow(dead_code)]
|
||||
pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
|
||||
pub const STATUS_INFO: Color = Color::Rgb(WHALE_INFO_RGB.0, WHALE_INFO_RGB.1, WHALE_INFO_RGB.2);
|
||||
|
||||
// Mode-specific accent colors for mode badges
|
||||
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
|
||||
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
|
||||
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
|
||||
pub const MODE_GOAL: Color = Color::Rgb(100, 220, 160); // Mint green
|
||||
pub const MODE_AGENT: Color = Color::Rgb(
|
||||
WHALE_MODE_AGENT_RGB.0,
|
||||
WHALE_MODE_AGENT_RGB.1,
|
||||
WHALE_MODE_AGENT_RGB.2,
|
||||
);
|
||||
pub const MODE_YOLO: Color = Color::Rgb(
|
||||
WHALE_MODE_YOLO_RGB.0,
|
||||
WHALE_MODE_YOLO_RGB.1,
|
||||
WHALE_MODE_YOLO_RGB.2,
|
||||
);
|
||||
pub const MODE_PLAN: Color = Color::Rgb(
|
||||
WHALE_MODE_PLAN_RGB.0,
|
||||
WHALE_MODE_PLAN_RGB.1,
|
||||
WHALE_MODE_PLAN_RGB.2,
|
||||
);
|
||||
pub const MODE_GOAL: Color = Color::Rgb(
|
||||
WHALE_MODE_GOAL_RGB.0,
|
||||
WHALE_MODE_GOAL_RGB.1,
|
||||
WHALE_MODE_GOAL_RGB.2,
|
||||
);
|
||||
|
||||
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
|
||||
pub const SELECTION_BG: Color = Color::Rgb(
|
||||
WHALE_SELECTION_RGB.0,
|
||||
WHALE_SELECTION_RGB.1,
|
||||
WHALE_SELECTION_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
|
||||
|
||||
@@ -322,6 +478,7 @@ fn palette_mode_from_apple_interface_style(value: &str) -> PaletteMode {
|
||||
pub struct UiTheme {
|
||||
pub name: &'static str,
|
||||
pub mode: PaletteMode,
|
||||
// Surface hierarchy
|
||||
pub surface_bg: Color,
|
||||
pub panel_bg: Color,
|
||||
pub elevated_bg: Color,
|
||||
@@ -329,22 +486,45 @@ pub struct UiTheme {
|
||||
pub selection_bg: Color,
|
||||
pub header_bg: Color,
|
||||
pub footer_bg: Color,
|
||||
/// Statusline mode colors (agent/yolo/plan)
|
||||
pub mode_agent: Color,
|
||||
pub mode_yolo: Color,
|
||||
pub mode_plan: Color,
|
||||
pub mode_goal: Color,
|
||||
/// Statusline status colors
|
||||
pub status_ready: Color,
|
||||
pub status_working: Color,
|
||||
pub status_warning: Color,
|
||||
/// Statusline text colors
|
||||
/// Text hierarchy
|
||||
pub text_dim: Color,
|
||||
pub text_hint: Color,
|
||||
pub text_muted: Color,
|
||||
pub text_body: Color,
|
||||
pub text_soft: Color,
|
||||
pub border: Color,
|
||||
// Accent roles
|
||||
pub accent_primary: Color,
|
||||
pub accent_secondary: Color,
|
||||
pub accent_action: Color,
|
||||
// Error / destructive
|
||||
pub error_fg: Color,
|
||||
pub error_hover: Color,
|
||||
pub error_surface: Color,
|
||||
pub error_border: Color,
|
||||
pub error_text: Color,
|
||||
// Status roles (warning / success / info)
|
||||
pub warning: Color,
|
||||
pub success: Color,
|
||||
pub info: Color,
|
||||
// Mode badge colors (agent/yolo/plan/goal)
|
||||
pub mode_agent: Color,
|
||||
pub mode_yolo: Color,
|
||||
pub mode_plan: Color,
|
||||
pub mode_goal: Color,
|
||||
// Footer statusline colors
|
||||
pub status_ready: Color,
|
||||
pub status_working: Color,
|
||||
pub status_warning: Color,
|
||||
// Diff colors
|
||||
pub diff_added_fg: Color,
|
||||
pub diff_deleted_fg: Color,
|
||||
pub diff_added_bg: Color,
|
||||
pub diff_deleted_bg: Color,
|
||||
// Tool cell colors
|
||||
pub tool_running: Color,
|
||||
pub tool_success: Color,
|
||||
pub tool_failed: Color,
|
||||
}
|
||||
|
||||
pub const UI_THEME: UiTheme = UiTheme {
|
||||
@@ -357,6 +537,59 @@ pub const UI_THEME: UiTheme = UiTheme {
|
||||
selection_bg: SELECTION_BG,
|
||||
header_bg: DEEPSEEK_INK,
|
||||
footer_bg: DEEPSEEK_INK,
|
||||
text_dim: TEXT_DIM,
|
||||
text_hint: TEXT_HINT,
|
||||
text_muted: TEXT_MUTED,
|
||||
text_body: TEXT_BODY,
|
||||
text_soft: TEXT_SOFT,
|
||||
border: BORDER_COLOR,
|
||||
accent_primary: Color::Rgb(
|
||||
WHALE_ACCENT_PRIMARY_RGB.0,
|
||||
WHALE_ACCENT_PRIMARY_RGB.1,
|
||||
WHALE_ACCENT_PRIMARY_RGB.2,
|
||||
),
|
||||
accent_secondary: Color::Rgb(
|
||||
WHALE_ACCENT_SECONDARY_RGB.0,
|
||||
WHALE_ACCENT_SECONDARY_RGB.1,
|
||||
WHALE_ACCENT_SECONDARY_RGB.2,
|
||||
),
|
||||
accent_action: Color::Rgb(
|
||||
WHALE_ACCENT_ACTION_RGB.0,
|
||||
WHALE_ACCENT_ACTION_RGB.1,
|
||||
WHALE_ACCENT_ACTION_RGB.2,
|
||||
),
|
||||
error_fg: Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2),
|
||||
error_hover: Color::Rgb(
|
||||
WHALE_ERROR_HOVER_RGB.0,
|
||||
WHALE_ERROR_HOVER_RGB.1,
|
||||
WHALE_ERROR_HOVER_RGB.2,
|
||||
),
|
||||
error_surface: Color::Rgb(
|
||||
WHALE_ERROR_SURFACE_RGB.0,
|
||||
WHALE_ERROR_SURFACE_RGB.1,
|
||||
WHALE_ERROR_SURFACE_RGB.2,
|
||||
),
|
||||
error_border: Color::Rgb(
|
||||
WHALE_ERROR_BORDER_RGB.0,
|
||||
WHALE_ERROR_BORDER_RGB.1,
|
||||
WHALE_ERROR_BORDER_RGB.2,
|
||||
),
|
||||
error_text: Color::Rgb(
|
||||
WHALE_ERROR_TEXT_RGB.0,
|
||||
WHALE_ERROR_TEXT_RGB.1,
|
||||
WHALE_ERROR_TEXT_RGB.2,
|
||||
),
|
||||
warning: Color::Rgb(
|
||||
WHALE_WARNING_RGB.0,
|
||||
WHALE_WARNING_RGB.1,
|
||||
WHALE_WARNING_RGB.2,
|
||||
),
|
||||
success: Color::Rgb(
|
||||
WHALE_SUCCESS_RGB.0,
|
||||
WHALE_SUCCESS_RGB.1,
|
||||
WHALE_SUCCESS_RGB.2,
|
||||
),
|
||||
info: Color::Rgb(WHALE_INFO_RGB.0, WHALE_INFO_RGB.1, WHALE_INFO_RGB.2),
|
||||
mode_agent: MODE_AGENT,
|
||||
mode_yolo: MODE_YOLO,
|
||||
mode_plan: MODE_PLAN,
|
||||
@@ -364,12 +597,13 @@ pub const UI_THEME: UiTheme = UiTheme {
|
||||
status_ready: TEXT_MUTED,
|
||||
status_working: DEEPSEEK_SKY,
|
||||
status_warning: STATUS_WARNING,
|
||||
text_dim: TEXT_DIM,
|
||||
text_hint: TEXT_HINT,
|
||||
text_muted: TEXT_MUTED,
|
||||
text_body: TEXT_BODY,
|
||||
text_soft: TEXT_SOFT,
|
||||
border: BORDER_COLOR,
|
||||
diff_added_fg: DIFF_ADDED,
|
||||
diff_deleted_fg: Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2),
|
||||
diff_added_bg: DIFF_ADDED_BG,
|
||||
diff_deleted_bg: DIFF_DELETED_BG,
|
||||
tool_running: ACCENT_TOOL_LIVE,
|
||||
tool_success: TEXT_DIM,
|
||||
tool_failed: ACCENT_TOOL_ISSUE,
|
||||
};
|
||||
|
||||
pub const LIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
@@ -382,19 +616,37 @@ pub const LIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
selection_bg: LIGHT_SELECTION_BG,
|
||||
header_bg: LIGHT_SURFACE,
|
||||
footer_bg: LIGHT_SURFACE,
|
||||
mode_agent: DEEPSEEK_BLUE,
|
||||
mode_yolo: DEEPSEEK_RED,
|
||||
mode_plan: Color::Rgb(180, 83, 9),
|
||||
mode_goal: Color::Rgb(80, 180, 130), // mint green
|
||||
status_ready: LIGHT_TEXT_MUTED,
|
||||
status_working: DEEPSEEK_BLUE,
|
||||
status_warning: Color::Rgb(180, 83, 9),
|
||||
text_dim: LIGHT_TEXT_HINT,
|
||||
text_hint: LIGHT_TEXT_HINT,
|
||||
text_muted: LIGHT_TEXT_MUTED,
|
||||
text_body: LIGHT_TEXT_BODY,
|
||||
text_soft: LIGHT_TEXT_SOFT,
|
||||
border: LIGHT_BORDER,
|
||||
accent_primary: Color::Rgb(53, 120, 229), // blue
|
||||
accent_secondary: Color::Rgb(79, 180, 160), // teal
|
||||
accent_action: Color::Rgb(220, 90, 60), // warm coral
|
||||
error_fg: Color::Rgb(200, 40, 60), // red
|
||||
error_hover: Color::Rgb(220, 70, 85),
|
||||
error_surface: Color::Rgb(254, 229, 229),
|
||||
error_border: Color::Rgb(240, 120, 130),
|
||||
error_text: Color::Rgb(120, 20, 30),
|
||||
warning: Color::Rgb(180, 83, 9), // amber
|
||||
success: Color::Rgb(21, 128, 61), // green
|
||||
info: Color::Rgb(53, 120, 229), // blue
|
||||
mode_agent: Color::Rgb(53, 120, 229), // blue
|
||||
mode_yolo: Color::Rgb(200, 40, 60), // red
|
||||
mode_plan: Color::Rgb(180, 83, 9), // amber
|
||||
mode_goal: Color::Rgb(80, 180, 130), // mint green
|
||||
status_ready: LIGHT_TEXT_MUTED,
|
||||
status_working: Color::Rgb(53, 120, 229), // blue
|
||||
status_warning: Color::Rgb(180, 83, 9), // amber
|
||||
diff_added_fg: Color::Rgb(22, 101, 52), // green
|
||||
diff_deleted_fg: Color::Rgb(200, 40, 60), // red
|
||||
diff_added_bg: Color::Rgb(223, 247, 231), // light green
|
||||
diff_deleted_bg: Color::Rgb(254, 229, 229), // light red
|
||||
tool_running: Color::Rgb(53, 120, 229), // blue
|
||||
tool_success: LIGHT_TEXT_HINT,
|
||||
tool_failed: Color::Rgb(200, 40, 60), // red
|
||||
};
|
||||
|
||||
pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme {
|
||||
@@ -407,19 +659,37 @@ pub const GRAYSCALE_UI_THEME: UiTheme = UiTheme {
|
||||
selection_bg: GRAYSCALE_SELECTION_BG,
|
||||
header_bg: GRAYSCALE_SURFACE,
|
||||
footer_bg: GRAYSCALE_SURFACE,
|
||||
mode_agent: GRAYSCALE_TEXT_SOFT,
|
||||
mode_yolo: GRAYSCALE_TEXT_BODY,
|
||||
mode_plan: GRAYSCALE_TEXT_MUTED,
|
||||
mode_goal: GRAYSCALE_TEXT_SOFT,
|
||||
status_ready: GRAYSCALE_TEXT_MUTED,
|
||||
status_working: GRAYSCALE_TEXT_SOFT,
|
||||
status_warning: GRAYSCALE_TEXT_BODY,
|
||||
text_dim: GRAYSCALE_TEXT_HINT,
|
||||
text_hint: GRAYSCALE_TEXT_HINT,
|
||||
text_muted: GRAYSCALE_TEXT_MUTED,
|
||||
text_body: GRAYSCALE_TEXT_BODY,
|
||||
text_soft: GRAYSCALE_TEXT_SOFT,
|
||||
border: GRAYSCALE_BORDER,
|
||||
accent_primary: GRAYSCALE_TEXT_SOFT,
|
||||
accent_secondary: GRAYSCALE_TEXT_MUTED,
|
||||
accent_action: Color::Rgb(210, 210, 210),
|
||||
error_fg: GRAYSCALE_TEXT_BODY,
|
||||
error_hover: GRAYSCALE_TEXT_SOFT,
|
||||
error_surface: GRAYSCALE_ERROR,
|
||||
error_border: GRAYSCALE_BORDER,
|
||||
error_text: GRAYSCALE_TEXT_SOFT,
|
||||
warning: GRAYSCALE_TEXT_MUTED,
|
||||
success: GRAYSCALE_TEXT_SOFT,
|
||||
info: GRAYSCALE_TEXT_MUTED,
|
||||
mode_agent: Color::Rgb(200, 200, 200),
|
||||
mode_yolo: GRAYSCALE_TEXT_BODY,
|
||||
mode_plan: GRAYSCALE_TEXT_MUTED,
|
||||
mode_goal: GRAYSCALE_TEXT_SOFT,
|
||||
status_ready: GRAYSCALE_TEXT_MUTED,
|
||||
status_working: GRAYSCALE_TEXT_SOFT,
|
||||
status_warning: GRAYSCALE_TEXT_BODY,
|
||||
diff_added_fg: GRAYSCALE_TEXT_SOFT,
|
||||
diff_deleted_fg: GRAYSCALE_TEXT_BODY,
|
||||
diff_added_bg: GRAYSCALE_SUCCESS,
|
||||
diff_deleted_bg: GRAYSCALE_ERROR,
|
||||
tool_running: GRAYSCALE_TEXT_SOFT,
|
||||
tool_success: GRAYSCALE_TEXT_HINT,
|
||||
tool_failed: GRAYSCALE_TEXT_BODY,
|
||||
};
|
||||
|
||||
pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
|
||||
@@ -432,19 +702,37 @@ pub const CATPPUCCIN_MOCHA_UI_THEME: UiTheme = UiTheme {
|
||||
selection_bg: Color::Rgb(0x45, 0x47, 0x5a), // surface1
|
||||
header_bg: Color::Rgb(0x11, 0x11, 0x1b), // crust
|
||||
footer_bg: Color::Rgb(0x11, 0x11, 0x1b),
|
||||
mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue
|
||||
mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red
|
||||
mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach
|
||||
mode_goal: Color::Rgb(0xa6, 0xe3, 0xa1), // green
|
||||
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
|
||||
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
|
||||
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
|
||||
text_dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0
|
||||
text_hint: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
|
||||
text_muted: Color::Rgb(0xa6, 0xad, 0xc8), // subtext0
|
||||
text_body: Color::Rgb(0xcd, 0xd6, 0xf4), // text
|
||||
text_soft: Color::Rgb(0xba, 0xc2, 0xde), // subtext1
|
||||
border: Color::Rgb(0x45, 0x47, 0x5a), // surface1
|
||||
text_dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0
|
||||
text_hint: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
|
||||
text_muted: Color::Rgb(0xa6, 0xad, 0xc8), // subtext0
|
||||
text_body: Color::Rgb(0xcd, 0xd6, 0xf4), // text
|
||||
text_soft: Color::Rgb(0xba, 0xc2, 0xde), // subtext1
|
||||
border: Color::Rgb(0x45, 0x47, 0x5a), // surface1
|
||||
accent_primary: Color::Rgb(0x89, 0xb4, 0xfa), // blue
|
||||
accent_secondary: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
|
||||
accent_action: Color::Rgb(0xfa, 0xb3, 0x87), // peach
|
||||
error_fg: Color::Rgb(0xf3, 0x8b, 0xa8), // red
|
||||
error_hover: Color::Rgb(0xf5, 0xa2, 0xbc),
|
||||
error_surface: Color::Rgb(0x3a, 0x1f, 0x2a),
|
||||
error_border: Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||
error_text: Color::Rgb(0xf5, 0xc2, 0xd0),
|
||||
warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
|
||||
success: Color::Rgb(0xa6, 0xe3, 0xa1), // green
|
||||
info: Color::Rgb(0x89, 0xd9, 0xeb), // sky
|
||||
mode_agent: Color::Rgb(0x89, 0xb4, 0xfa), // blue
|
||||
mode_yolo: Color::Rgb(0xf3, 0x8b, 0xa8), // red
|
||||
mode_plan: Color::Rgb(0xfa, 0xb3, 0x87), // peach
|
||||
mode_goal: Color::Rgb(0xa6, 0xe3, 0xa1), // green
|
||||
status_ready: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
|
||||
status_working: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
|
||||
status_warning: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow
|
||||
diff_added_fg: Color::Rgb(0xa6, 0xe3, 0xa1), // green
|
||||
diff_deleted_fg: Color::Rgb(0xf3, 0x8b, 0xa8), // red
|
||||
diff_added_bg: Color::Rgb(0x1f, 0x33, 0x29),
|
||||
diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x2a),
|
||||
tool_running: Color::Rgb(0x74, 0xc7, 0xec), // sapphire
|
||||
tool_success: Color::Rgb(0x7f, 0x84, 0x9c), // overlay1
|
||||
tool_failed: Color::Rgb(0xf3, 0x8b, 0xa8), // red
|
||||
};
|
||||
|
||||
pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
@@ -457,19 +745,37 @@ pub const TOKYO_NIGHT_UI_THEME: UiTheme = UiTheme {
|
||||
selection_bg: Color::Rgb(0x28, 0x34, 0x57), // visual selection
|
||||
header_bg: Color::Rgb(0x16, 0x16, 0x1e),
|
||||
footer_bg: Color::Rgb(0x16, 0x16, 0x1e),
|
||||
mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue
|
||||
mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red
|
||||
mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange
|
||||
mode_goal: Color::Rgb(0x9e, 0xce, 0x6a), // green
|
||||
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
|
||||
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
|
||||
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
|
||||
text_dim: Color::Rgb(0x56, 0x5f, 0x89), // comment
|
||||
text_hint: Color::Rgb(0x73, 0x7a, 0xa2), // dark5
|
||||
text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), // fg_dark
|
||||
text_body: Color::Rgb(0xc0, 0xca, 0xf5), // fg
|
||||
text_dim: Color::Rgb(0x56, 0x5f, 0x89), // comment
|
||||
text_hint: Color::Rgb(0x73, 0x7a, 0xa2), // dark5
|
||||
text_muted: Color::Rgb(0xa9, 0xb1, 0xd6), // fg_dark
|
||||
text_body: Color::Rgb(0xc0, 0xca, 0xf5), // fg
|
||||
text_soft: Color::Rgb(0xbb, 0xc2, 0xe0),
|
||||
border: Color::Rgb(0x41, 0x48, 0x68), // terminal_black
|
||||
accent_primary: Color::Rgb(0x7a, 0xa2, 0xf7), // blue
|
||||
accent_secondary: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
|
||||
accent_action: Color::Rgb(0xff, 0x9e, 0x64), // orange
|
||||
error_fg: Color::Rgb(0xf7, 0x76, 0x8e), // red
|
||||
error_hover: Color::Rgb(0xf9, 0x92, 0xa4),
|
||||
error_surface: Color::Rgb(0x33, 0x1c, 0x24),
|
||||
error_border: Color::Rgb(0xf7, 0x76, 0x8e),
|
||||
error_text: Color::Rgb(0xfa, 0xcc, 0xd4),
|
||||
warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
|
||||
success: Color::Rgb(0x9e, 0xce, 0x6a), // green
|
||||
info: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
|
||||
mode_agent: Color::Rgb(0x7a, 0xa2, 0xf7), // blue
|
||||
mode_yolo: Color::Rgb(0xf7, 0x76, 0x8e), // red
|
||||
mode_plan: Color::Rgb(0xff, 0x9e, 0x64), // orange
|
||||
mode_goal: Color::Rgb(0x9e, 0xce, 0x6a), // green
|
||||
status_ready: Color::Rgb(0x56, 0x5f, 0x89), // comment
|
||||
status_working: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
|
||||
status_warning: Color::Rgb(0xe0, 0xaf, 0x68), // yellow
|
||||
diff_added_fg: Color::Rgb(0x9e, 0xce, 0x6a), // green
|
||||
diff_deleted_fg: Color::Rgb(0xf7, 0x76, 0x8e), // red
|
||||
diff_added_bg: Color::Rgb(0x1b, 0x2b, 0x1f),
|
||||
diff_deleted_bg: Color::Rgb(0x33, 0x1c, 0x24),
|
||||
tool_running: Color::Rgb(0x7d, 0xcf, 0xff), // cyan
|
||||
tool_success: Color::Rgb(0x56, 0x5f, 0x89), // comment
|
||||
tool_failed: Color::Rgb(0xf7, 0x76, 0x8e), // red
|
||||
};
|
||||
|
||||
pub const DRACULA_UI_THEME: UiTheme = UiTheme {
|
||||
@@ -482,19 +788,37 @@ pub const DRACULA_UI_THEME: UiTheme = UiTheme {
|
||||
selection_bg: Color::Rgb(0x44, 0x47, 0x5a), // current line
|
||||
header_bg: Color::Rgb(0x21, 0x22, 0x2c),
|
||||
footer_bg: Color::Rgb(0x21, 0x22, 0x2c),
|
||||
mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple
|
||||
mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red
|
||||
mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange
|
||||
mode_goal: Color::Rgb(0x50, 0xfa, 0x7b), // green
|
||||
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
|
||||
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
|
||||
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
|
||||
text_dim: Color::Rgb(0x62, 0x72, 0xa4),
|
||||
text_dim: Color::Rgb(0x62, 0x72, 0xa4), // comment
|
||||
text_hint: Color::Rgb(0x8a, 0x8e, 0xaa),
|
||||
text_muted: Color::Rgb(0xc0, 0xc4, 0xd6),
|
||||
text_body: Color::Rgb(0xf8, 0xf8, 0xf2), // foreground
|
||||
text_soft: Color::Rgb(0xe2, 0xe2, 0xdc),
|
||||
border: Color::Rgb(0x44, 0x47, 0x5a),
|
||||
accent_primary: Color::Rgb(0xbd, 0x93, 0xf9), // purple
|
||||
accent_secondary: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
|
||||
accent_action: Color::Rgb(0xff, 0xb8, 0x6c), // orange
|
||||
error_fg: Color::Rgb(0xff, 0x55, 0x55), // red
|
||||
error_hover: Color::Rgb(0xff, 0x7c, 0x7c),
|
||||
error_surface: Color::Rgb(0x3a, 0x1f, 0x22),
|
||||
error_border: Color::Rgb(0xff, 0x55, 0x55),
|
||||
error_text: Color::Rgb(0xff, 0xbb, 0xbb),
|
||||
warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
|
||||
success: Color::Rgb(0x50, 0xfa, 0x7b), // green
|
||||
info: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
|
||||
mode_agent: Color::Rgb(0xbd, 0x93, 0xf9), // purple
|
||||
mode_yolo: Color::Rgb(0xff, 0x55, 0x55), // red
|
||||
mode_plan: Color::Rgb(0xff, 0xb8, 0x6c), // orange
|
||||
mode_goal: Color::Rgb(0x50, 0xfa, 0x7b), // green
|
||||
status_ready: Color::Rgb(0x62, 0x72, 0xa4), // comment
|
||||
status_working: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
|
||||
status_warning: Color::Rgb(0xf1, 0xfa, 0x8c), // yellow
|
||||
diff_added_fg: Color::Rgb(0x50, 0xfa, 0x7b), // green
|
||||
diff_deleted_fg: Color::Rgb(0xff, 0x55, 0x55), // red
|
||||
diff_added_bg: Color::Rgb(0x21, 0x3a, 0x2a),
|
||||
diff_deleted_bg: Color::Rgb(0x3a, 0x1f, 0x22),
|
||||
tool_running: Color::Rgb(0x8b, 0xe9, 0xfd), // cyan
|
||||
tool_success: Color::Rgb(0x62, 0x72, 0xa4), // comment
|
||||
tool_failed: Color::Rgb(0xff, 0x55, 0x55), // red
|
||||
};
|
||||
|
||||
pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
|
||||
@@ -507,19 +831,37 @@ pub const GRUVBOX_DARK_UI_THEME: UiTheme = UiTheme {
|
||||
selection_bg: Color::Rgb(0x66, 0x5c, 0x54), // bg3
|
||||
header_bg: Color::Rgb(0x1d, 0x20, 0x21), // bg0_h
|
||||
footer_bg: Color::Rgb(0x1d, 0x20, 0x21),
|
||||
mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue
|
||||
mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red
|
||||
mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange
|
||||
mode_goal: Color::Rgb(0x8e, 0xc0, 0x7c), // green
|
||||
status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray
|
||||
status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
|
||||
status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
|
||||
text_dim: Color::Rgb(0x92, 0x83, 0x74), // gray
|
||||
text_hint: Color::Rgb(0xa8, 0x99, 0x84), // fg4
|
||||
text_muted: Color::Rgb(0xbd, 0xae, 0x93), // fg3
|
||||
text_body: Color::Rgb(0xeb, 0xdb, 0xb2), // fg1
|
||||
text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), // fg2
|
||||
border: Color::Rgb(0x66, 0x5c, 0x54), // bg3
|
||||
text_dim: Color::Rgb(0x92, 0x83, 0x74), // gray
|
||||
text_hint: Color::Rgb(0xa8, 0x99, 0x84), // fg4
|
||||
text_muted: Color::Rgb(0xbd, 0xae, 0x93), // fg3
|
||||
text_body: Color::Rgb(0xeb, 0xdb, 0xb2), // fg1
|
||||
text_soft: Color::Rgb(0xd5, 0xc4, 0xa1), // fg2
|
||||
border: Color::Rgb(0x66, 0x5c, 0x54), // bg3
|
||||
accent_primary: Color::Rgb(0x83, 0xa5, 0x98), // blue
|
||||
accent_secondary: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua/green
|
||||
accent_action: Color::Rgb(0xfe, 0x80, 0x19), // orange
|
||||
error_fg: Color::Rgb(0xfb, 0x49, 0x34), // red
|
||||
error_hover: Color::Rgb(0xfc, 0x7c, 0x6b),
|
||||
error_surface: Color::Rgb(0x35, 0x1c, 0x18),
|
||||
error_border: Color::Rgb(0xfb, 0x49, 0x34),
|
||||
error_text: Color::Rgb(0xfc, 0xc4, 0xb8),
|
||||
warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
|
||||
success: Color::Rgb(0x8e, 0xc0, 0x7c), // green
|
||||
info: Color::Rgb(0x83, 0xa5, 0x98), // blue
|
||||
mode_agent: Color::Rgb(0x83, 0xa5, 0x98), // blue
|
||||
mode_yolo: Color::Rgb(0xfb, 0x49, 0x34), // red
|
||||
mode_plan: Color::Rgb(0xfe, 0x80, 0x19), // orange
|
||||
mode_goal: Color::Rgb(0x8e, 0xc0, 0x7c), // green
|
||||
status_ready: Color::Rgb(0x92, 0x83, 0x74), // gray
|
||||
status_working: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
|
||||
status_warning: Color::Rgb(0xfa, 0xbd, 0x2f), // yellow
|
||||
diff_added_fg: Color::Rgb(0x8e, 0xc0, 0x7c), // green
|
||||
diff_deleted_fg: Color::Rgb(0xfb, 0x49, 0x34), // red
|
||||
diff_added_bg: Color::Rgb(0x29, 0x32, 0x16),
|
||||
diff_deleted_bg: Color::Rgb(0x35, 0x1c, 0x18),
|
||||
tool_running: Color::Rgb(0x8e, 0xc0, 0x7c), // aqua
|
||||
tool_success: Color::Rgb(0x92, 0x83, 0x74), // gray
|
||||
tool_failed: Color::Rgb(0xfb, 0x49, 0x34), // red
|
||||
};
|
||||
|
||||
/// Stable identifiers for the named themes the user can select. `System`
|
||||
@@ -592,7 +934,7 @@ impl ThemeId {
|
||||
pub const fn tagline(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Follow terminal background (COLORFGBG / macOS appearance)",
|
||||
Self::Whale => "Default DeepSeek dark blue",
|
||||
Self::Whale => "Whale dark — deep navy & gold",
|
||||
Self::WhaleLight => "DeepSeek light, paper-ish",
|
||||
Self::Grayscale => "Color-minimal high contrast",
|
||||
Self::CatppuccinMocha => "Soft pastels on warm dark",
|
||||
@@ -809,54 +1151,30 @@ fn adapt_bg_for_light_palette(color: Color) -> Color {
|
||||
// no-op — the existing dark/light pipeline handles those.
|
||||
|
||||
/// Per-preset green accent used for things that semantically *should* stay
|
||||
/// green even after theming (diff "+" lines, user-input body). Mapping these
|
||||
/// to `ui.status_working` would lose the green/cyan distinction the UI
|
||||
/// relies on, so we keep a small dedicated table.
|
||||
/// green even after theming (diff "+" lines, user-input body). Now delegates
|
||||
/// to the active UiTheme's diff_added_fg.
|
||||
#[must_use]
|
||||
const fn theme_green(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0xa6, 0xe3, 0xa1),
|
||||
ThemeId::TokyoNight => Color::Rgb(0x9e, 0xce, 0x6a),
|
||||
ThemeId::Dracula => Color::Rgb(0x50, 0xfa, 0x7b),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0xb8, 0xbb, 0x26),
|
||||
_ => USER_BODY,
|
||||
}
|
||||
const fn theme_green(ui: &UiTheme) -> Color {
|
||||
ui.diff_added_fg
|
||||
}
|
||||
|
||||
/// Per-preset red accent, used for diff "−" line foreground when present.
|
||||
#[must_use]
|
||||
const fn theme_red(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||
ThemeId::TokyoNight => Color::Rgb(0xf7, 0x76, 0x8e),
|
||||
ThemeId::Dracula => Color::Rgb(0xff, 0x55, 0x55),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0xfb, 0x49, 0x34),
|
||||
_ => DEEPSEEK_RED,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
const fn theme_red(ui: &UiTheme) -> Color {
|
||||
ui.diff_deleted_fg
|
||||
}
|
||||
|
||||
/// Per-preset dark-green diff-added background tint.
|
||||
#[must_use]
|
||||
const fn theme_diff_added_bg(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0x1f, 0x33, 0x29),
|
||||
ThemeId::TokyoNight => Color::Rgb(0x1b, 0x2b, 0x1f),
|
||||
ThemeId::Dracula => Color::Rgb(0x21, 0x3a, 0x2a),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0x29, 0x32, 0x16),
|
||||
_ => DIFF_ADDED_BG,
|
||||
}
|
||||
const fn theme_diff_added_bg(ui: &UiTheme) -> Color {
|
||||
ui.diff_added_bg
|
||||
}
|
||||
|
||||
/// Per-preset dark-red diff-deleted background tint.
|
||||
#[must_use]
|
||||
const fn theme_diff_deleted_bg(theme: ThemeId) -> Color {
|
||||
match theme {
|
||||
ThemeId::CatppuccinMocha => Color::Rgb(0x3a, 0x1f, 0x2a),
|
||||
ThemeId::TokyoNight => Color::Rgb(0x33, 0x1c, 0x24),
|
||||
ThemeId::Dracula => Color::Rgb(0x3a, 0x1f, 0x22),
|
||||
ThemeId::GruvboxDark => Color::Rgb(0x35, 0x1c, 0x18),
|
||||
_ => DIFF_DELETED_BG,
|
||||
}
|
||||
const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color {
|
||||
ui.diff_deleted_bg
|
||||
}
|
||||
|
||||
/// Returns `true` if the preset participates in the cell-level remap. The
|
||||
@@ -905,13 +1223,12 @@ pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
|
||||
} else if color == ACCENT_TOOL_ISSUE {
|
||||
ui.mode_yolo
|
||||
} else if color == STATUS_WARNING {
|
||||
ui.status_warning
|
||||
} else if color == DEEPSEEK_RED {
|
||||
theme_red(theme)
|
||||
ui.warning
|
||||
} else if color == STATUS_ERROR || color == DEEPSEEK_RED {
|
||||
ui.error_fg
|
||||
} else if color == DIFF_ADDED || color == USER_BODY {
|
||||
theme_green(theme)
|
||||
theme_green(ui)
|
||||
} else if color == DEEPSEEK_BLUE {
|
||||
// The default mode_agent accent — keep it in the preset's blue family.
|
||||
ui.mode_agent
|
||||
} else {
|
||||
color
|
||||
@@ -939,19 +1256,18 @@ pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
|
||||
} else if color == SURFACE_REASONING
|
||||
|| color == SURFACE_REASONING_TINT
|
||||
|| color == SURFACE_REASONING_ACTIVE
|
||||
|| color == SURFACE_SUCCESS
|
||||
|| color == SURFACE_ERROR
|
||||
{
|
||||
// Reasoning/success/error backgrounds are subtle tints that don't have
|
||||
// a dedicated theme slot. Collapse them onto the panel surface so they
|
||||
// read as recessed rather than a stray default-blue tint.
|
||||
ui.panel_bg
|
||||
} else if color == SURFACE_SUCCESS {
|
||||
ui.diff_added_bg
|
||||
} else if color == SURFACE_ERROR {
|
||||
ui.error_surface
|
||||
} else if color == SELECTION_BG {
|
||||
ui.selection_bg
|
||||
} else if color == DIFF_ADDED_BG {
|
||||
theme_diff_added_bg(theme)
|
||||
theme_diff_added_bg(ui)
|
||||
} else if color == DIFF_DELETED_BG {
|
||||
theme_diff_deleted_bg(theme)
|
||||
theme_diff_deleted_bg(ui)
|
||||
} else {
|
||||
color
|
||||
}
|
||||
@@ -1208,10 +1524,9 @@ pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the reasoning surface color tinted at 12% over the app background.
|
||||
/// This is the headline reasoning treatment in v0.6.6; a 12% blend keeps the
|
||||
/// warm bias subtle without competing with body text. Returns `None` when the
|
||||
/// terminal can't render the bg faithfully.
|
||||
/// Return the dedicated reasoning surface tint for terminals that can render
|
||||
/// background colors faithfully. ANSI-16 terminals disable the tint because
|
||||
/// the nearest named background is too coarse for this subtle treatment.
|
||||
#[must_use]
|
||||
pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> {
|
||||
match depth {
|
||||
@@ -1363,7 +1678,8 @@ mod tests {
|
||||
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, adapt_bg, adapt_bg_for_palette_mode, adapt_color,
|
||||
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,
|
||||
normalize_theme_name, parse_hex_rgb_color, pulse_brightness, reasoning_surface_tint,
|
||||
rgb_to_ansi256, theme_label_for_mode, ui_theme_from_settings,
|
||||
@@ -1468,9 +1784,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dark_palette_uses_soft_body_text_and_warm_reasoning() {
|
||||
assert_eq!(TEXT_BODY, Color::Rgb(226, 232, 240));
|
||||
assert_eq!(TEXT_REASONING, Color::Rgb(211, 170, 112));
|
||||
assert_eq!(ACCENT_REASONING_LIVE, Color::Rgb(224, 153, 72));
|
||||
assert_eq!(
|
||||
TEXT_BODY,
|
||||
Color::Rgb(
|
||||
WHALE_TEXT_BODY_RGB.0,
|
||||
WHALE_TEXT_BODY_RGB.1,
|
||||
WHALE_TEXT_BODY_RGB.2
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
TEXT_REASONING,
|
||||
Color::Rgb(
|
||||
WHALE_REASONING_TEXT_RGB.0,
|
||||
WHALE_REASONING_TEXT_RGB.1,
|
||||
WHALE_REASONING_TEXT_RGB.2
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
ACCENT_REASONING_LIVE,
|
||||
Color::Rgb(
|
||||
WHALE_REASONING_TEXT_RGB.0,
|
||||
WHALE_REASONING_TEXT_RGB.1,
|
||||
WHALE_REASONING_TEXT_RGB.2
|
||||
)
|
||||
);
|
||||
assert_ne!(TEXT_REASONING, TEXT_TOOL_OUTPUT);
|
||||
assert_ne!(TEXT_BODY, Color::White);
|
||||
}
|
||||
@@ -1604,8 +1941,12 @@ mod tests {
|
||||
adapt_color(DEEPSEEK_SKY, ColorDepth::Ansi16),
|
||||
Color::LightBlue
|
||||
);
|
||||
// Red: red-dominant, mid lum → Red (not the bright variant).
|
||||
assert_eq!(adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16), Color::Red);
|
||||
// Rose Red is intentionally bright enough to use the terminal's
|
||||
// bright red slot.
|
||||
assert_eq!(
|
||||
adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16),
|
||||
Color::LightRed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1633,8 +1974,12 @@ mod tests {
|
||||
#[test]
|
||||
fn light_palette_maps_reasoning_tint_to_light_surface() {
|
||||
assert_eq!(
|
||||
blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12),
|
||||
SURFACE_REASONING_TINT
|
||||
SURFACE_REASONING_TINT,
|
||||
Color::Rgb(
|
||||
WHALE_REASONING_TINT_RGB.0,
|
||||
WHALE_REASONING_TINT_RGB.1,
|
||||
WHALE_REASONING_TINT_RGB.2
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_bg_for_palette_mode(SURFACE_REASONING_TINT, PaletteMode::Light),
|
||||
@@ -1693,14 +2038,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nearest_ansi16_routes_known_brand_colors() {
|
||||
// Blue-dominant brand colors should stay blue rather than collapsing
|
||||
// to the user's terminal cyan, which is often much louder.
|
||||
assert_eq!(nearest_ansi16(53, 120, 229), Color::Blue);
|
||||
assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue);
|
||||
assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue);
|
||||
assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan);
|
||||
assert_eq!(nearest_ansi16(226, 80, 96), Color::Red);
|
||||
assert_eq!(nearest_ansi16(11, 21, 38), Color::Black);
|
||||
// v0.8.45: accent primary is Signal Gold (#F6C453), secondary is Seafoam.
|
||||
assert_eq!(nearest_ansi16(246, 196, 83), Color::LightYellow); // Signal Gold
|
||||
assert_eq!(nearest_ansi16(79, 209, 197), Color::LightCyan); // Seafoam
|
||||
assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue); // Border
|
||||
assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan); // Aqua
|
||||
assert_eq!(nearest_ansi16(255, 92, 122), Color::LightRed); // Rose Red
|
||||
assert_eq!(nearest_ansi16(13, 21, 37), Color::Black); // Deep Navy
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -201,6 +201,25 @@ fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage:
|
||||
hit_cost + miss_cost + output_cost
|
||||
}
|
||||
|
||||
/// Estimate how much money was saved by serving `cache_hit_tokens` from the
|
||||
/// prefix cache instead of billing them at the cache-miss rate. Returns `None`
|
||||
/// when the model's pricing is unknown or the number of cache-hit tokens is
|
||||
/// zero (nothing to save).
|
||||
#[must_use]
|
||||
pub fn calculate_cache_savings(model: &str, cache_hit_tokens: u32) -> Option<CostEstimate> {
|
||||
if cache_hit_tokens == 0 {
|
||||
return None;
|
||||
}
|
||||
let pricing = pricing_for_model(model)?;
|
||||
let tokens = cache_hit_tokens as f64 / 1_000_000.0;
|
||||
Some(CostEstimate {
|
||||
usd: tokens
|
||||
* (pricing.usd.input_cache_miss_per_million - pricing.usd.input_cache_hit_per_million),
|
||||
cny: tokens
|
||||
* (pricing.cny.input_cache_miss_per_million - pricing.cny.input_cache_hit_per_million),
|
||||
})
|
||||
}
|
||||
|
||||
/// Format a USD cost for compact display.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -868,10 +868,10 @@ impl RuntimeThreadManager {
|
||||
|
||||
{
|
||||
let mut active = self.active.lock().await;
|
||||
if let Some(state) = active.engines.get_mut(thread_id) {
|
||||
if let Some(turn) = state.active_turn.as_mut() {
|
||||
turn.auto_approve = true;
|
||||
}
|
||||
if let Some(state) = active.engines.get_mut(thread_id)
|
||||
&& let Some(turn) = state.active_turn.as_mut()
|
||||
{
|
||||
turn.auto_approve = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,11 @@ pub struct SessionMetadata {
|
||||
/// current saved sessions are linear JSON files, not per-entry trees.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from_message_count: Option<usize>,
|
||||
/// Cumulative turn duration in seconds (sum of completed turn elapsed
|
||||
/// times). Persisted so the footer "worked" chip survives restarts
|
||||
/// (#2038).
|
||||
#[serde(default)]
|
||||
pub cumulative_turn_secs: u64,
|
||||
}
|
||||
|
||||
/// Cost and high-water-mark fields persisted with each session.
|
||||
@@ -723,6 +728,7 @@ pub fn create_saved_session_with_id_and_mode(
|
||||
cost: SessionCostSnapshot::default(),
|
||||
parent_session_id: None,
|
||||
forked_from_message_count: None,
|
||||
cumulative_turn_secs: 0,
|
||||
},
|
||||
messages: capped_messages,
|
||||
system_prompt: merge_truncation_note(
|
||||
@@ -1045,6 +1051,7 @@ mod tests {
|
||||
cost: SessionCostSnapshot::default(),
|
||||
parent_session_id: None,
|
||||
forked_from_message_count: None,
|
||||
cumulative_turn_secs: 0,
|
||||
},
|
||||
system_prompt: None,
|
||||
context_references: Vec::new(),
|
||||
@@ -1075,6 +1082,7 @@ mod tests {
|
||||
cost: SessionCostSnapshot::default(),
|
||||
parent_session_id: None,
|
||||
forked_from_message_count: None,
|
||||
cumulative_turn_secs: 0,
|
||||
},
|
||||
system_prompt: None,
|
||||
context_references: Vec::new(),
|
||||
|
||||
@@ -273,11 +273,6 @@ pub struct Settings {
|
||||
/// `binary_unavailable` response with an install hint, matching the
|
||||
/// pre-v0.8.32 behavior.
|
||||
pub prefer_external_pdftotext: bool,
|
||||
/// Optional command that records/transcribes voice input and writes the
|
||||
/// final UTF-8 transcript to stdout. Triggered by the command palette.
|
||||
pub voice_input_command: Option<String>,
|
||||
/// Timeout for the configured voice input command, in seconds.
|
||||
pub voice_input_timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -320,8 +315,6 @@ impl Default for Settings {
|
||||
status_indicator: "whale".to_string(),
|
||||
synchronized_output: "auto".to_string(),
|
||||
prefer_external_pdftotext: false,
|
||||
voice_input_command: None,
|
||||
voice_input_timeout_secs: crate::tui::voice_input::default_timeout_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,11 +363,6 @@ impl Settings {
|
||||
.to_string();
|
||||
s.background_color = normalize_optional_background_color(s.background_color.as_deref());
|
||||
s.theme = normalize_settings_theme(&s.theme).to_string();
|
||||
let voice_input_command =
|
||||
normalize_optional_voice_input_command(s.voice_input_command.as_deref());
|
||||
s.voice_input_command = voice_input_command;
|
||||
s.voice_input_timeout_secs =
|
||||
crate::tui::voice_input::clamp_timeout_secs(s.voice_input_timeout_secs);
|
||||
s.default_model = s.default_model.as_deref().and_then(normalize_default_model);
|
||||
s.reasoning_effort = s
|
||||
.reasoning_effort
|
||||
@@ -396,15 +384,6 @@ impl Settings {
|
||||
self.low_motion = true;
|
||||
self.fancy_animations = false;
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_VOICE_INPUT_COMMAND") {
|
||||
self.voice_input_command = normalize_optional_voice_input_command(Some(&value));
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_VOICE_INPUT_TIMEOUT_SECS")
|
||||
&& let Ok(timeout_secs) = value.trim().parse::<u64>()
|
||||
{
|
||||
self.voice_input_timeout_secs =
|
||||
crate::tui::voice_input::clamp_timeout_secs(timeout_secs);
|
||||
}
|
||||
// VS Code (TERM_PROGRAM=vscode, #1356), Ghostty (TERM_PROGRAM=ghostty,
|
||||
// #1445), and a few VTE terminals (#1470) produce visible flicker at
|
||||
// 120 FPS. Drop to the 30 FPS low-motion cap for them automatically.
|
||||
@@ -604,22 +583,6 @@ impl Settings {
|
||||
"prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => {
|
||||
self.prefer_external_pdftotext = parse_bool(value)?;
|
||||
}
|
||||
"voice_input_command" | "voice_command" | "dictation_command" => {
|
||||
self.voice_input_command = normalize_optional_voice_input_command(Some(value));
|
||||
}
|
||||
"voice_input_timeout_secs" | "voice_timeout" | "dictation_timeout" => {
|
||||
let timeout_secs: u64 = value.parse().map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to update setting: invalid voice input timeout '{value}'. Expected a number from 1 to 600."
|
||||
)
|
||||
})?;
|
||||
if !(1..=600).contains(&timeout_secs) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: voice input timeout must be between 1 and 600 seconds."
|
||||
);
|
||||
}
|
||||
self.voice_input_timeout_secs = timeout_secs;
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let normalized = normalize_mode(value);
|
||||
if !["agent", "plan", "yolo"].contains(&normalized) {
|
||||
@@ -748,16 +711,6 @@ impl Settings {
|
||||
" prefer_external_pdftotext: {}",
|
||||
self.prefer_external_pdftotext
|
||||
));
|
||||
lines.push(format!(
|
||||
" voice_input_command: {}",
|
||||
self.voice_input_command
|
||||
.as_deref()
|
||||
.unwrap_or("(not configured)")
|
||||
));
|
||||
lines.push(format!(
|
||||
" voice_input_timeout_secs: {}",
|
||||
self.voice_input_timeout_secs
|
||||
));
|
||||
lines.push(format!(" default_mode: {}", self.default_mode));
|
||||
lines.push(format!(
|
||||
" sidebar_width: {}%",
|
||||
@@ -850,14 +803,6 @@ impl Settings {
|
||||
"prefer_external_pdftotext",
|
||||
"Route PDF reads through Poppler's pdftotext instead of the bundled pure-Rust extractor: on/off (default off)",
|
||||
),
|
||||
(
|
||||
"voice_input_command",
|
||||
"Command run by command-palette Voice input; stdout must be the transcript, or none/default to disable",
|
||||
),
|
||||
(
|
||||
"voice_input_timeout_secs",
|
||||
"Voice input command timeout in seconds: 1-600 (default 60)",
|
||||
),
|
||||
("default_mode", "Default mode: agent, plan, yolo"),
|
||||
("sidebar_width", "Sidebar width percentage: 10-50"),
|
||||
(
|
||||
@@ -1078,24 +1023,6 @@ fn normalize_background_color_setting(value: &str) -> Result<Option<String>> {
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_voice_input_command(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_voice_input_command)
|
||||
}
|
||||
|
||||
fn normalize_voice_input_command(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| matches!(
|
||||
trimmed.to_ascii_lowercase().as_str(),
|
||||
"default" | "none" | "off" | "false" | "disabled"
|
||||
)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_sidebar_focus(value: &str) -> &str {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"work" | "plan" | "todos" => "work",
|
||||
@@ -1308,39 +1235,6 @@ mod tests {
|
||||
assert!(!settings.context_panel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_input_settings_normalize_and_clear() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.voice_input_command.is_none());
|
||||
assert_eq!(
|
||||
settings.voice_input_timeout_secs,
|
||||
crate::tui::voice_input::default_timeout_secs()
|
||||
);
|
||||
|
||||
settings
|
||||
.set("voice_input_command", r#"python3 "/tmp/voice helper.py""#)
|
||||
.expect("set voice command");
|
||||
assert_eq!(
|
||||
settings.voice_input_command.as_deref(),
|
||||
Some(r#"python3 "/tmp/voice helper.py""#)
|
||||
);
|
||||
|
||||
settings
|
||||
.set("voice_input_timeout_secs", "120")
|
||||
.expect("set timeout");
|
||||
assert_eq!(settings.voice_input_timeout_secs, 120);
|
||||
|
||||
settings
|
||||
.set("voice_command", "none")
|
||||
.expect("clear voice command");
|
||||
assert!(settings.voice_input_command.is_none());
|
||||
|
||||
let err = settings
|
||||
.set("voice_timeout", "0")
|
||||
.expect_err("timeout must be bounded");
|
||||
assert!(err.to_string().contains("between 1 and 600"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_localizes_header_and_config_file_label() {
|
||||
let settings = Settings::default();
|
||||
|
||||
@@ -391,7 +391,10 @@ pub async fn update_with_registry(
|
||||
network: &NetworkPolicy,
|
||||
registry_url: &str,
|
||||
) -> Result<UpdateResult> {
|
||||
let target = skills_dir.join(name);
|
||||
let target = skill_target_path(name, skills_dir)?;
|
||||
if target.exists() {
|
||||
ensure_target_within_skills_dir(&target, skills_dir)?;
|
||||
}
|
||||
let marker_path = target.join(INSTALLED_FROM_MARKER);
|
||||
if !marker_path.exists() {
|
||||
return Err(InstallError::NotInstalledHere(name.to_string()).into());
|
||||
@@ -439,10 +442,11 @@ pub async fn update_with_registry(
|
||||
/// Refuses to touch any directory that doesn't carry the `.installed-from`
|
||||
/// marker — that's our cue that it's user-owned and not a system skill.
|
||||
pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> {
|
||||
let target = skills_dir.join(name);
|
||||
let target = skill_target_path(name, skills_dir)?;
|
||||
if !target.exists() {
|
||||
bail!("skill '{name}' is not installed at {}", target.display());
|
||||
}
|
||||
ensure_target_within_skills_dir(&target, skills_dir)?;
|
||||
if !target.join(INSTALLED_FROM_MARKER).exists() {
|
||||
return Err(InstallError::NotInstalledHere(name.to_string()).into());
|
||||
}
|
||||
@@ -458,10 +462,11 @@ pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> {
|
||||
/// Refuses to mark system skills (no `.installed-from`) so the bundled
|
||||
/// `skill-creator` doesn't accidentally inherit elevated tool privileges.
|
||||
pub fn trust(name: &str, skills_dir: &Path) -> Result<()> {
|
||||
let target = skills_dir.join(name);
|
||||
let target = skill_target_path(name, skills_dir)?;
|
||||
if !target.exists() {
|
||||
bail!("skill '{name}' is not installed at {}", target.display());
|
||||
}
|
||||
ensure_target_within_skills_dir(&target, skills_dir)?;
|
||||
if !target.join(INSTALLED_FROM_MARKER).exists() {
|
||||
return Err(InstallError::NotInstalledHere(name.to_string()).into());
|
||||
}
|
||||
@@ -1343,6 +1348,40 @@ fn is_safe_path(path: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn skill_target_path(name: &str, skills_dir: &Path) -> Result<PathBuf> {
|
||||
let name = validate_skill_name_segment(name)?;
|
||||
Ok(skills_dir.join(name))
|
||||
}
|
||||
|
||||
fn validate_skill_name_segment(name: &str) -> Result<&str> {
|
||||
if name.is_empty() || name.trim() != name || name.chars().any(char::is_whitespace) {
|
||||
bail!("skill name must be a single path-safe segment (got '{name}')");
|
||||
}
|
||||
if name == "." || name == ".." || name.contains('/') || name.contains('\\') {
|
||||
bail!("skill name must be a single path-safe segment (got '{name}')");
|
||||
}
|
||||
let mut components = Path::new(name).components();
|
||||
if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() {
|
||||
bail!("skill name must be a single path-safe segment (got '{name}')");
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn ensure_target_within_skills_dir(target: &Path, skills_dir: &Path) -> Result<()> {
|
||||
let skills_dir = fs::canonicalize(skills_dir)
|
||||
.with_context(|| format!("failed to resolve {}", skills_dir.display()))?;
|
||||
let target = fs::canonicalize(target)
|
||||
.with_context(|| format!("failed to resolve {}", target.display()))?;
|
||||
if !target.starts_with(&skills_dir) {
|
||||
bail!(
|
||||
"skill path {} escapes skills directory {}",
|
||||
target.display(),
|
||||
skills_dir.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip a leading directory prefix (e.g. `repo-main/`) from a tarball path.
|
||||
fn strip_prefix<'a>(path: &'a str, prefix: &str) -> std::borrow::Cow<'a, str> {
|
||||
if prefix.is_empty() {
|
||||
@@ -1394,13 +1433,7 @@ fn parse_frontmatter_name(bytes: &[u8]) -> Result<String> {
|
||||
if !has_description {
|
||||
return Err(InstallError::MissingFrontmatterField("description").into());
|
||||
}
|
||||
// Sanity check: name must be a single path-safe segment.
|
||||
if name.contains('/')
|
||||
|| name.contains('\\')
|
||||
|| name == "."
|
||||
|| name == ".."
|
||||
|| name.contains(' ')
|
||||
{
|
||||
if validate_skill_name_segment(&name).is_err() {
|
||||
bail!("SKILL.md `name` must be a single path-safe segment (got '{name}')");
|
||||
}
|
||||
Ok(name)
|
||||
@@ -1546,6 +1579,9 @@ mod tests {
|
||||
|
||||
let body = b"---\nname: a name with spaces\ndescription: x\n---\n";
|
||||
assert!(parse_frontmatter_name(body).is_err());
|
||||
|
||||
let body = b"---\nname: tab\tname\ndescription: x\n---\n";
|
||||
assert!(parse_frontmatter_name(body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1554,6 +1590,66 @@ mod tests {
|
||||
assert!(parse_frontmatter_name(body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_skill_names_must_be_single_safe_segments() {
|
||||
for bad in [
|
||||
"",
|
||||
"../evil",
|
||||
"/tmp/evil",
|
||||
"two words",
|
||||
"two\twords",
|
||||
"evil/name",
|
||||
"evil\\name",
|
||||
".",
|
||||
"..",
|
||||
" leading",
|
||||
"trailing ",
|
||||
] {
|
||||
assert!(
|
||||
validate_skill_name_segment(bad).is_err(),
|
||||
"expected {bad:?} to be rejected"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
validate_skill_name_segment("safe-name_1").unwrap(),
|
||||
"safe-name_1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_and_trust_reject_unsafe_skill_names_before_path_join() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let skills_dir = tmp.path().join("skills");
|
||||
std::fs::create_dir_all(&skills_dir).expect("skills dir");
|
||||
|
||||
for bad in [
|
||||
"../evil",
|
||||
"/tmp/evil",
|
||||
"evil/name",
|
||||
"evil\\name",
|
||||
"two words",
|
||||
] {
|
||||
assert!(uninstall(bad, &skills_dir).is_err());
|
||||
assert!(trust(bad, &skills_dir).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn uninstall_rejects_symlink_target_escaping_skills_dir() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let skills_dir = tmp.path().join("skills");
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir_all(&skills_dir).expect("skills dir");
|
||||
std::fs::create_dir_all(&outside).expect("outside dir");
|
||||
std::fs::write(outside.join(INSTALLED_FROM_MARKER), "{}").expect("marker");
|
||||
std::os::unix::fs::symlink(&outside, skills_dir.join("linked")).expect("symlink");
|
||||
|
||||
let err = uninstall("linked", &skills_dir).unwrap_err();
|
||||
assert!(err.to_string().contains("escapes skills directory"));
|
||||
assert!(outside.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_handles_all_cases() {
|
||||
assert_eq!(strip_prefix("foo/bar", "foo"), "bar");
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! v0.8.45 theme QA audit — verification script.
|
||||
//!
|
||||
//! This module validates:
|
||||
//! - Every shipped theme has all required semantic palette fields populated.
|
||||
//! - Error/destructive states are distinct from warm action accents.
|
||||
//! - Selection, focus, diff, warning, success, and status colors are readable.
|
||||
//! - Terminal contrast is checked for common truecolor surfaces.
|
||||
//!
|
||||
//! Run with: cargo test -p codewhale-tui -- theme_qa
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::palette::{
|
||||
CATPPUCCIN_MOCHA_UI_THEME, DRACULA_UI_THEME, GRAYSCALE_UI_THEME, GRUVBOX_DARK_UI_THEME,
|
||||
LIGHT_UI_THEME, TOKYO_NIGHT_UI_THEME, UI_THEME, UiTheme,
|
||||
};
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// All shipped themes in display order.
|
||||
const ALL_THEMES: &[UiTheme] = &[
|
||||
UI_THEME,
|
||||
LIGHT_UI_THEME,
|
||||
GRAYSCALE_UI_THEME,
|
||||
CATPPUCCIN_MOCHA_UI_THEME,
|
||||
TOKYO_NIGHT_UI_THEME,
|
||||
DRACULA_UI_THEME,
|
||||
GRUVBOX_DARK_UI_THEME,
|
||||
];
|
||||
|
||||
/// Extract (r, g, b) from a Color::Rgb. Returns None for non-RGB colors.
|
||||
fn rgb(color: Color) -> Option<(u8, u8, u8)> {
|
||||
match color {
|
||||
Color::Rgb(r, g, b) => Some((r, g, b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative luminance per WCAG 2.1.
|
||||
fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
|
||||
fn channel(c: u8) -> f64 {
|
||||
let s = c as f64 / 255.0;
|
||||
if s <= 0.03928 {
|
||||
s / 12.92
|
||||
} else {
|
||||
((s + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
||||
}
|
||||
|
||||
/// WCAG 2.1 contrast ratio.
|
||||
fn contrast_ratio(fg: (u8, u8, u8), bg: (u8, u8, u8)) -> f64 {
|
||||
let l1 = relative_luminance(fg.0, fg.1, fg.2);
|
||||
let l2 = relative_luminance(bg.0, bg.1, bg.2);
|
||||
let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
|
||||
(lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_themes_have_non_default_surface_bg() {
|
||||
for theme in ALL_THEMES {
|
||||
assert!(
|
||||
rgb(theme.surface_bg).is_some(),
|
||||
"{}: surface_bg must be an RGB color",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_themes_have_required_semantic_fields() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
// Every theme must have distinct accent colors.
|
||||
assert!(
|
||||
rgb(theme.accent_primary).is_some(),
|
||||
"{name}: accent_primary missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.accent_secondary).is_some(),
|
||||
"{name}: accent_secondary missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.accent_action).is_some(),
|
||||
"{name}: accent_action missing"
|
||||
);
|
||||
|
||||
// Error/destructive must be separate from action accent.
|
||||
assert_ne!(
|
||||
theme.error_fg, theme.accent_action,
|
||||
"{name}: error_fg should differ from accent_action"
|
||||
);
|
||||
assert_ne!(
|
||||
theme.error_fg, theme.accent_primary,
|
||||
"{name}: error_fg should differ from accent_primary"
|
||||
);
|
||||
|
||||
// Error fields present.
|
||||
assert!(rgb(theme.error_fg).is_some(), "{name}: error_fg missing");
|
||||
assert!(
|
||||
rgb(theme.error_hover).is_some(),
|
||||
"{name}: error_hover missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.error_surface).is_some(),
|
||||
"{name}: error_surface missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.error_border).is_some(),
|
||||
"{name}: error_border missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.error_text).is_some(),
|
||||
"{name}: error_text missing"
|
||||
);
|
||||
|
||||
// Warning / success / info present.
|
||||
assert!(rgb(theme.warning).is_some(), "{name}: warning missing");
|
||||
assert!(rgb(theme.success).is_some(), "{name}: success missing");
|
||||
assert!(rgb(theme.info).is_some(), "{name}: info missing");
|
||||
|
||||
// Diff colors present.
|
||||
assert!(
|
||||
rgb(theme.diff_added_fg).is_some(),
|
||||
"{name}: diff_added_fg missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.diff_deleted_fg).is_some(),
|
||||
"{name}: diff_deleted_fg missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.diff_added_bg).is_some(),
|
||||
"{name}: diff_added_bg missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.diff_deleted_bg).is_some(),
|
||||
"{name}: diff_deleted_bg missing"
|
||||
);
|
||||
|
||||
// Tool colors present.
|
||||
assert!(
|
||||
rgb(theme.tool_running).is_some(),
|
||||
"{name}: tool_running missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.tool_success).is_some(),
|
||||
"{name}: tool_success missing"
|
||||
);
|
||||
assert!(
|
||||
rgb(theme.tool_failed).is_some(),
|
||||
"{name}: tool_failed missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_text_has_minimum_contrast_on_surface() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
let Some(fg) = rgb(theme.text_body) else {
|
||||
continue;
|
||||
};
|
||||
let Some(bg) = rgb(theme.surface_bg) else {
|
||||
continue;
|
||||
};
|
||||
let cr = contrast_ratio(fg, bg);
|
||||
assert!(
|
||||
cr >= 4.5,
|
||||
"{name}: body text contrast {cr:.1}:1 is below 4.5:1 minimum (fg={fg:?}, bg={bg:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muted_text_is_readable_on_surface() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
let Some(fg) = rgb(theme.text_muted) else {
|
||||
continue;
|
||||
};
|
||||
let Some(bg) = rgb(theme.surface_bg) else {
|
||||
continue;
|
||||
};
|
||||
let cr = contrast_ratio(fg, bg);
|
||||
assert!(
|
||||
cr >= 3.0,
|
||||
"{name}: muted text contrast {cr:.1}:1 is below 3.0:1 minimum (fg={fg:?}, bg={bg:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_text_contrasts_on_error_surface() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
let Some(fg) = rgb(theme.error_text) else {
|
||||
continue;
|
||||
};
|
||||
let Some(bg) = rgb(theme.error_surface) else {
|
||||
continue;
|
||||
};
|
||||
let cr = contrast_ratio(fg, bg);
|
||||
assert!(
|
||||
cr >= 4.5,
|
||||
"{name}: error_text on error_surface contrast {cr:.1}:1 is below 4.5:1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_bg_differs_from_surface_bg() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
assert_ne!(
|
||||
theme.selection_bg, theme.surface_bg,
|
||||
"{name}: selection_bg must differ from surface_bg"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_layers_are_distinct() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
// Panel should be distinct from surface (unless grayscale which has limited range).
|
||||
if theme.name != "grayscale" {
|
||||
assert_ne!(
|
||||
theme.panel_bg, theme.surface_bg,
|
||||
"{name}: panel_bg must differ from surface_bg for visual layering"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_and_warning_are_visually_distinct() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
assert_ne!(
|
||||
theme.success, theme.warning,
|
||||
"{name}: success and warning must be distinct colors"
|
||||
);
|
||||
assert_ne!(
|
||||
theme.success, theme.error_fg,
|
||||
"{name}: success and error must be distinct colors"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_added_and_deleted_are_distinct() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
assert_ne!(
|
||||
theme.diff_added_fg, theme.diff_deleted_fg,
|
||||
"{name}: diff add/del fg must differ"
|
||||
);
|
||||
assert_ne!(
|
||||
theme.diff_added_bg, theme.diff_deleted_bg,
|
||||
"{name}: diff add/del bg must differ"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_colors_are_all_distinct() {
|
||||
for theme in ALL_THEMES {
|
||||
let name = theme.name;
|
||||
let modes = [
|
||||
("agent", theme.mode_agent),
|
||||
("yolo", theme.mode_yolo),
|
||||
("plan", theme.mode_plan),
|
||||
("goal", theme.mode_goal),
|
||||
];
|
||||
for i in 0..modes.len() {
|
||||
for j in (i + 1)..modes.len() {
|
||||
assert_ne!(
|
||||
modes[i].1, modes[j].1,
|
||||
"{name}: mode {} and mode {} have same color",
|
||||
modes[i].0, modes[j].0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whale_dark_uses_proposed_palette() {
|
||||
// Issue #2012: verify the default Whale dark uses proposed tokens.
|
||||
let t = UI_THEME;
|
||||
assert_eq!(rgb(t.surface_bg), Some((13, 21, 37)), "Deep Navy #0D1525");
|
||||
assert_eq!(
|
||||
rgb(t.text_body),
|
||||
Some((246, 242, 232)),
|
||||
"Whale Ivory #F6F2E8"
|
||||
);
|
||||
assert_eq!(
|
||||
rgb(t.text_muted),
|
||||
Some((169, 180, 199)),
|
||||
"Mist Gray #A9B4C7"
|
||||
);
|
||||
assert_eq!(
|
||||
rgb(t.accent_primary),
|
||||
Some((246, 196, 83)),
|
||||
"Signal Gold #F6C453"
|
||||
);
|
||||
assert_eq!(
|
||||
rgb(t.accent_secondary),
|
||||
Some((79, 209, 197)),
|
||||
"Seafoam #4FD1C5"
|
||||
);
|
||||
assert_eq!(
|
||||
rgb(t.accent_action),
|
||||
Some((255, 122, 89)),
|
||||
"Coral Spark #FF7A59"
|
||||
);
|
||||
assert_eq!(rgb(t.error_fg), Some((255, 92, 122)), "Rose Red #FF5C7A");
|
||||
assert_eq!(
|
||||
rgb(t.error_surface),
|
||||
Some((42, 18, 26)),
|
||||
"Error Surface #2A121A"
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -129,18 +129,6 @@ pub enum AppMode {
|
||||
Plan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceInputState {
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
impl VoiceInputState {
|
||||
#[must_use]
|
||||
pub fn new(started_at: Instant) -> Self {
|
||||
Self { started_at }
|
||||
}
|
||||
}
|
||||
|
||||
/// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnCacheRecord {
|
||||
@@ -1090,8 +1078,6 @@ pub struct App {
|
||||
pub sticky_status: Option<StatusToast>,
|
||||
/// Last status text already promoted from `status_message` into toast state.
|
||||
pub last_status_message_seen: Option<String>,
|
||||
/// Active external speech-to-text helper launched from the command palette.
|
||||
pub voice_input_state: Option<VoiceInputState>,
|
||||
pub model: String,
|
||||
/// When true, the model is auto-selected based on request complexity
|
||||
/// rather than using a fixed model. The `/model auto` command sets this.
|
||||
@@ -1816,7 +1802,6 @@ impl App {
|
||||
status_toasts: VecDeque::new(),
|
||||
sticky_status: None,
|
||||
last_status_message_seen: None,
|
||||
voice_input_state: None,
|
||||
model,
|
||||
auto_model,
|
||||
last_effective_model: None,
|
||||
@@ -2220,6 +2205,9 @@ impl App {
|
||||
metadata.cost.subagent_cost_cny = self.session.subagent_cost_cny;
|
||||
metadata.cost.displayed_cost_high_water_usd = self.session.displayed_cost_high_water;
|
||||
metadata.cost.displayed_cost_high_water_cny = self.session.displayed_cost_high_water_cny;
|
||||
// Persist cumulative turn duration so the footer "worked" chip
|
||||
// survives session save/restore (#2038).
|
||||
metadata.cumulative_turn_secs = self.cumulative_turn_duration.as_secs();
|
||||
}
|
||||
|
||||
/// Recompute the displayed cost high-water mark. Called any time a cost
|
||||
@@ -2279,6 +2267,18 @@ impl App {
|
||||
crate::pricing::format_cost_amount_precise(amount, self.cost_currency)
|
||||
}
|
||||
|
||||
/// Estimated cost saved by the last turn's cache-hit tokens in the
|
||||
/// configured display currency. Returns `None` when the model's pricing
|
||||
/// is unknown or there were no cache hits.
|
||||
pub fn last_turn_cache_savings(&self) -> Option<f64> {
|
||||
let hit_tokens = self.session.last_prompt_cache_hit_tokens?;
|
||||
let estimate = crate::pricing::calculate_cache_savings(&self.model, hit_tokens)?;
|
||||
Some(match self.cost_currency {
|
||||
crate::pricing::CostCurrency::Usd => estimate.usd,
|
||||
crate::pricing::CostCurrency::Cny => estimate.cny,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fold the oldest [`Self::HISTORY_FOLD_BATCH`] cells into a single
|
||||
/// `ArchivedContext` placeholder when history exceeds the soft cap.
|
||||
/// Called from [`Self::add_message`]; the caller is responsible for
|
||||
|
||||
@@ -255,7 +255,7 @@ mod tests {
|
||||
fn light_palette_maps_dark_cells_before_depth_adaptation() {
|
||||
let mut cell = Cell::default();
|
||||
cell.set_fg(Color::White);
|
||||
cell.set_bg(Color::Rgb(11, 21, 38));
|
||||
cell.set_bg(palette::DEEPSEEK_INK);
|
||||
|
||||
adapt_cell_colors(
|
||||
&mut cell,
|
||||
|
||||
@@ -55,14 +55,6 @@ pub fn build_entries(
|
||||
) -> Vec<CommandPaletteEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
entries.push(CommandPaletteEntry {
|
||||
section: PaletteSection::Action,
|
||||
label: "Voice input".to_string(),
|
||||
description: "Listen, transcribe, and insert editable text into the composer".to_string(),
|
||||
command: "voice input dictate microphone speech".to_string(),
|
||||
action: CommandPaletteAction::VoiceInput,
|
||||
});
|
||||
|
||||
for command in commands::COMMANDS {
|
||||
let mut description = command.palette_description_for(locale);
|
||||
if command.requires_argument() {
|
||||
@@ -1017,24 +1009,6 @@ mod tests {
|
||||
assert!(!command_labels.contains(&"/deepseek"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_palette_includes_voice_input_action() {
|
||||
let entries = build_entries(
|
||||
Locale::En,
|
||||
Path::new("."),
|
||||
Path::new("."),
|
||||
Path::new("mcp.json"),
|
||||
None,
|
||||
);
|
||||
let voice = entries
|
||||
.iter()
|
||||
.find(|entry| entry.section == PaletteSection::Action && entry.label == "Voice input")
|
||||
.expect("voice input action");
|
||||
|
||||
assert!(voice.description.contains("composer"));
|
||||
assert!(matches!(voice.action, CommandPaletteAction::VoiceInput));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_palette_inserts_model_command_for_argument_entry() {
|
||||
let entries = build_entries(
|
||||
|
||||
@@ -72,8 +72,7 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
// Surface one compact live status row in the footer whenever a turn
|
||||
// is live. Tool turns get the current action plus active/done counts;
|
||||
// non-tool work falls back to the existing dot-pulse label.
|
||||
let mut label = active_voice_input_status_label(app, now_ms)
|
||||
.or_else(|| active_subagent_status_label(app))
|
||||
let mut label = active_subagent_status_label(app)
|
||||
.or_else(|| active_tool_status_label(app))
|
||||
.unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame, app.ui_locale));
|
||||
// Append stall reason when the turn has been running > 30 s.
|
||||
@@ -156,47 +155,16 @@ pub(crate) fn stall_reason(app: &App) -> Option<&'static str> {
|
||||
/// though the agent is still working.
|
||||
pub(crate) fn footer_working_strip_active(app: &App) -> bool {
|
||||
let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress");
|
||||
app.is_loading
|
||||
|| app.is_compacting
|
||||
|| running_agent_count(app) > 0
|
||||
|| turn_in_progress
|
||||
|| app.voice_input_state.is_some()
|
||||
app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress
|
||||
}
|
||||
|
||||
pub(crate) fn footer_working_label_frame(now_ms: u64, fancy_animations: bool) -> u64 {
|
||||
if fancy_animations { now_ms / 400 } else { 0 }
|
||||
}
|
||||
|
||||
pub(crate) fn active_voice_input_status_label(app: &App, now_ms: u64) -> Option<String> {
|
||||
let state = app.voice_input_state.as_ref()?;
|
||||
let elapsed = state.started_at.elapsed().as_secs();
|
||||
Some(voice_input_status_text(
|
||||
app.fancy_animations,
|
||||
elapsed,
|
||||
now_ms,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn voice_input_status_text(
|
||||
fancy_animations: bool,
|
||||
elapsed_secs: u64,
|
||||
now_ms: u64,
|
||||
) -> String {
|
||||
if !fancy_animations {
|
||||
return format!("listening/transcribing {elapsed_secs}s");
|
||||
}
|
||||
let dots = match (now_ms / 300) % 4 {
|
||||
0 => "",
|
||||
1 => ".",
|
||||
2 => "..",
|
||||
_ => "...",
|
||||
};
|
||||
format!("listening/transcribing{dots} {elapsed_secs}s")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{footer_working_label_frame, voice_input_status_text};
|
||||
use super::footer_working_label_frame;
|
||||
|
||||
#[test]
|
||||
fn footer_working_label_frame_is_static_without_fancy_animations() {
|
||||
@@ -205,15 +173,6 @@ mod tests {
|
||||
assert_eq!(footer_working_label_frame(1_600, false), 0);
|
||||
assert_eq!(footer_working_label_frame(1_600, true), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_input_status_label_animates_when_enabled() {
|
||||
let first = voice_input_status_text(true, 2, 0);
|
||||
let second = voice_input_status_text(true, 2, 300);
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert!(first.contains("listening/transcribing"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool {
|
||||
@@ -583,10 +542,21 @@ pub(crate) fn footer_cost_spans(app: &App) -> Vec<Span<'static>> {
|
||||
if !should_show_footer_cost(displayed_cost) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Span::styled(
|
||||
let mut spans = vec![Span::styled(
|
||||
app.format_cost_amount(displayed_cost),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)]
|
||||
)];
|
||||
// Append cache-savings hint when the last turn had cache hits that
|
||||
// saved money (#2038).
|
||||
if let Some(saved) = app.last_turn_cache_savings()
|
||||
&& saved > 0.0
|
||||
{
|
||||
spans.push(Span::styled(
|
||||
format!(" · saved {}", app.format_cost_amount(saved)),
|
||||
Style::default().fg(palette::STATUS_SUCCESS),
|
||||
));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
pub(crate) fn should_show_footer_cost(displayed_cost: f64) -> bool {
|
||||
|
||||
@@ -1571,7 +1571,7 @@ mod tests {
|
||||
fn table_pipes_inside_inline_code_stay_in_the_cell() {
|
||||
let src = "| Check | Result |\n\
|
||||
|---|---|\n\
|
||||
| `strings ~/.cargo/bin/codewhale-tui | grep -c \"Goal mode\"` | 0 matches |\n";
|
||||
| `strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"` | 0 matches |\n";
|
||||
let parsed = parse(src);
|
||||
|
||||
let rows: Vec<&Vec<String>> = parsed
|
||||
@@ -1587,7 +1587,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
rows[1],
|
||||
&vec![
|
||||
"`strings ~/.cargo/bin/codewhale-tui | grep -c \"Goal mode\"`".to_string(),
|
||||
"`strings ~/.cargo/bin/codewhale-tui | grep -c \"legacy marker\"`".to_string(),
|
||||
"0 matches".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -70,7 +70,6 @@ mod ui_text;
|
||||
pub mod user_input;
|
||||
pub mod views;
|
||||
pub mod vim_mode;
|
||||
pub mod voice_input;
|
||||
pub mod widgets;
|
||||
pub mod workspace_context;
|
||||
|
||||
|
||||
@@ -952,6 +952,7 @@ mod tests {
|
||||
cost: crate::session_manager::SessionCostSnapshot::default(),
|
||||
parent_session_id: None,
|
||||
forked_from_message_count: None,
|
||||
cumulative_turn_secs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-101
@@ -108,7 +108,7 @@ use crate::tui::workspace_context;
|
||||
|
||||
use super::app::{
|
||||
App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus,
|
||||
StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions, VoiceInputState,
|
||||
StatusToastLevel, SubmitDisposition, TaskPanelEntry, TuiOptions,
|
||||
looks_like_slash_command_input,
|
||||
};
|
||||
use super::approval::{
|
||||
@@ -195,10 +195,6 @@ enum TranslationEvent {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum VoiceInputEvent {
|
||||
Finished { result: Result<String> },
|
||||
}
|
||||
// Reset scroll region (`\x1b[r`), origin mode (`\x1b[?6l`), and home the cursor
|
||||
// (`\x1b[H`) before letting ratatui's diff renderer repaint. The destructive
|
||||
// `\x1b[2J\x1b[3J` pair was previously appended here to also wipe the visible
|
||||
@@ -870,8 +866,6 @@ async fn run_event_loop(
|
||||
let mut current_streaming_text = String::new();
|
||||
let (translation_tx, mut translation_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<TranslationEvent>();
|
||||
let (voice_input_tx, mut voice_input_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<VoiceInputEvent>();
|
||||
let mut pending_translations = 0usize;
|
||||
let mut pending_thinking_translations = 0usize;
|
||||
let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone());
|
||||
@@ -991,8 +985,6 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
drain_voice_input_events(app, &mut voice_input_rx);
|
||||
|
||||
if last_task_refresh.elapsed() >= Duration::from_millis(2500) {
|
||||
refresh_active_task_panel(app, &task_manager).await;
|
||||
last_task_refresh = Instant::now();
|
||||
@@ -2007,7 +1999,6 @@ async fn run_event_loop(
|
||||
&task_manager,
|
||||
&mut engine_handle,
|
||||
&mut web_config_session,
|
||||
voice_input_tx.clone(),
|
||||
events,
|
||||
)
|
||||
.await?
|
||||
@@ -2020,10 +2011,7 @@ async fn run_event_loop(
|
||||
if reconcile_turn_liveness(app, Instant::now(), has_running_agents) {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
if (app.is_loading
|
||||
|| has_running_agents
|
||||
|| app.is_compacting
|
||||
|| app.voice_input_state.is_some())
|
||||
if (app.is_loading || has_running_agents || app.is_compacting)
|
||||
&& last_status_frame.elapsed()
|
||||
>= Duration::from_millis(status_animation_interval_ms(app))
|
||||
{
|
||||
@@ -2117,11 +2105,7 @@ async fn run_event_loop(
|
||||
app.needs_redraw = false;
|
||||
}
|
||||
|
||||
let mut poll_timeout = if app.is_loading
|
||||
|| has_running_agents
|
||||
|| app.is_compacting
|
||||
|| app.voice_input_state.is_some()
|
||||
{
|
||||
let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting {
|
||||
Duration::from_millis(active_poll_ms(app))
|
||||
} else {
|
||||
Duration::from_millis(idle_poll_ms(app))
|
||||
@@ -2306,7 +2290,6 @@ async fn run_event_loop(
|
||||
&task_manager,
|
||||
&mut engine_handle,
|
||||
&mut web_config_session,
|
||||
voice_input_tx.clone(),
|
||||
events,
|
||||
)
|
||||
.await?
|
||||
@@ -2688,7 +2671,6 @@ async fn run_event_loop(
|
||||
&task_manager,
|
||||
&mut engine_handle,
|
||||
&mut web_config_session,
|
||||
voice_input_tx.clone(),
|
||||
events,
|
||||
)
|
||||
.await?
|
||||
@@ -5291,82 +5273,6 @@ async fn execute_command_input(
|
||||
.await
|
||||
}
|
||||
|
||||
fn start_voice_input(
|
||||
app: &mut App,
|
||||
voice_input_tx: tokio::sync::mpsc::UnboundedSender<VoiceInputEvent>,
|
||||
) {
|
||||
if app.voice_input_state.is_some() {
|
||||
app.status_message = Some("Voice input is already listening".to_string());
|
||||
app.needs_redraw = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = match crate::settings::Settings::load() {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!("Voice input unavailable: failed to load settings: {err}"),
|
||||
});
|
||||
app.status_message = Some("Voice input unavailable".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(command_line) = settings.voice_input_command.clone() else {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: "Voice input is not configured. Set `voice_input_command` in settings.toml or export `DEEPSEEK_VOICE_INPUT_COMMAND`. Open the command palette and choose Voice input after configuring it. The command must write the transcript to stdout.".to_string(),
|
||||
});
|
||||
app.status_message = Some("Voice input not configured".to_string());
|
||||
return;
|
||||
};
|
||||
|
||||
let timeout_secs = settings.voice_input_timeout_secs;
|
||||
let workspace = app.workspace.clone();
|
||||
app.voice_input_state = Some(VoiceInputState::new(Instant::now()));
|
||||
app.status_message =
|
||||
Some("Voice input listening - transcript will appear in the composer".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = crate::tui::voice_input::run_configured_voice_command(
|
||||
&command_line,
|
||||
timeout_secs,
|
||||
&workspace,
|
||||
)
|
||||
.await;
|
||||
let _ = voice_input_tx.send(VoiceInputEvent::Finished { result });
|
||||
});
|
||||
}
|
||||
|
||||
fn drain_voice_input_events(
|
||||
app: &mut App,
|
||||
voice_input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<VoiceInputEvent>,
|
||||
) {
|
||||
while let Ok(event) = voice_input_rx.try_recv() {
|
||||
match event {
|
||||
VoiceInputEvent::Finished { result } => {
|
||||
app.voice_input_state = None;
|
||||
match result {
|
||||
Ok(transcript) => {
|
||||
let char_count = transcript.chars().count();
|
||||
app.insert_str(&transcript);
|
||||
app.status_message = Some(format!(
|
||||
"Voice transcript inserted ({char_count} chars) - edit, then Enter to send"
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!("Voice input failed: {err}"),
|
||||
});
|
||||
app.status_message = Some("Voice input failed".to_string());
|
||||
}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn steer_user_message(
|
||||
app: &mut App,
|
||||
engine_handle: &EngineHandle,
|
||||
@@ -6009,7 +5915,6 @@ async fn handle_view_events(
|
||||
task_manager: &SharedTaskManager,
|
||||
engine_handle: &mut EngineHandle,
|
||||
web_config_session: &mut Option<WebConfigSession>,
|
||||
voice_input_tx: tokio::sync::mpsc::UnboundedSender<VoiceInputEvent>,
|
||||
events: Vec<ViewEvent>,
|
||||
) -> Result<bool> {
|
||||
for event in events {
|
||||
@@ -6040,9 +5945,6 @@ async fn handle_view_events(
|
||||
crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => {
|
||||
open_text_pager(app, title, content);
|
||||
}
|
||||
crate::tui::views::CommandPaletteAction::VoiceInput => {
|
||||
start_voice_input(app, voice_input_tx.clone());
|
||||
}
|
||||
},
|
||||
ViewEvent::OpenTextPager { title, content } => {
|
||||
open_text_pager(app, title, content);
|
||||
@@ -6734,6 +6636,10 @@ fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession)
|
||||
app.session.last_prompt_cache_miss_tokens = None;
|
||||
app.session.last_reasoning_replay_tokens = None;
|
||||
app.session.turn_cache_history.clear();
|
||||
// Restore cumulative turn duration so the footer "worked" chip
|
||||
// persists across session restarts (#2038).
|
||||
app.cumulative_turn_duration =
|
||||
std::time::Duration::from_secs(session.metadata.cumulative_turn_secs);
|
||||
app.current_session_id = Some(session.metadata.id.clone());
|
||||
app.session_artifacts = session.artifacts.clone();
|
||||
app.session_title = Some(session.metadata.title.clone());
|
||||
|
||||
@@ -1286,6 +1286,7 @@ fn saved_session_with_messages(messages: Vec<Message>) -> SavedSession {
|
||||
cost: crate::session_manager::SessionCostSnapshot::default(),
|
||||
parent_session_id: None,
|
||||
forked_from_message_count: None,
|
||||
cumulative_turn_secs: 0,
|
||||
},
|
||||
messages,
|
||||
system_prompt: None,
|
||||
|
||||
@@ -45,7 +45,6 @@ pub enum CommandPaletteAction {
|
||||
ExecuteCommand { command: String },
|
||||
InsertText { text: String },
|
||||
OpenTextPager { title: String, content: String },
|
||||
VoiceInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -751,23 +750,6 @@ impl ConfigView {
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
section: ConfigSection::Composer,
|
||||
key: "voice_input_command".to_string(),
|
||||
value: settings
|
||||
.voice_input_command
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(not configured)".to_string()),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
section: ConfigSection::Composer,
|
||||
key: "voice_input_timeout_secs".to_string(),
|
||||
value: settings.voice_input_timeout_secs.to_string(),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
section: ConfigSection::Sidebar,
|
||||
key: "sidebar_width".to_string(),
|
||||
@@ -1151,8 +1133,6 @@ fn config_hint_for_key(key: &str) -> &'static str {
|
||||
"max_history" => "integer (0 allowed)",
|
||||
"default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default",
|
||||
"reasoning_effort" => "auto | off | low | medium | high | max | default",
|
||||
"voice_input_command" => "command string | none/default",
|
||||
"voice_input_timeout_secs" => "1..=600",
|
||||
"mcp_config_path" => "path to mcp.json",
|
||||
_ => "",
|
||||
}
|
||||
@@ -2206,8 +2186,6 @@ mod tests {
|
||||
assert!(keys.contains(&"composer_border"));
|
||||
assert!(keys.contains(&"composer_vim_mode"));
|
||||
assert!(keys.contains(&"bracketed_paste"));
|
||||
assert!(keys.contains(&"voice_input_command"));
|
||||
assert!(keys.contains(&"voice_input_timeout_secs"));
|
||||
assert!(keys.contains(&"context_panel"));
|
||||
assert!(keys.contains(&"cost_currency"));
|
||||
assert!(keys.contains(&"prefer_external_pdftotext"));
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//! Voice-input command bridge for the composer.
|
||||
//!
|
||||
//! CodeWhale stays out of platform microphone APIs here. A configured command
|
||||
//! owns recording and speech-to-text, writes the final transcript to stdout,
|
||||
//! and the TUI inserts that transcript into the composer.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 60;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
pub(crate) fn clamp_timeout_secs(secs: u64) -> u64 {
|
||||
secs.clamp(1, MAX_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
pub(crate) fn default_timeout_secs() -> u64 {
|
||||
DEFAULT_TIMEOUT_SECS
|
||||
}
|
||||
|
||||
fn parse_voice_command(command_line: &str) -> Result<(String, Vec<String>)> {
|
||||
let trimmed = command_line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(anyhow!("voice_input_command is empty"));
|
||||
}
|
||||
|
||||
let parts = shlex::split(trimmed).ok_or_else(|| {
|
||||
anyhow!("voice_input_command has invalid quoting; check spaces and quote pairs")
|
||||
})?;
|
||||
let Some((program, args)) = parts.split_first() else {
|
||||
return Err(anyhow!("voice_input_command is empty"));
|
||||
};
|
||||
Ok((program.clone(), args.to_vec()))
|
||||
}
|
||||
|
||||
fn stdout_to_transcript(stdout: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
let transcript = text.trim();
|
||||
(!transcript.is_empty()).then(|| transcript.to_string())
|
||||
}
|
||||
|
||||
fn stderr_summary(stderr: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(stderr);
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut summary: String = trimmed.chars().take(300).collect();
|
||||
if trimmed.chars().count() > 300 {
|
||||
summary.push_str("...");
|
||||
}
|
||||
format!(": {summary}")
|
||||
}
|
||||
|
||||
pub(crate) async fn run_configured_voice_command(
|
||||
command_line: &str,
|
||||
timeout_secs: u64,
|
||||
cwd: &Path,
|
||||
) -> Result<String> {
|
||||
let timeout_secs = clamp_timeout_secs(timeout_secs);
|
||||
let (program, args) = parse_voice_command(command_line)?;
|
||||
|
||||
let mut command = TokioCommand::new(&program);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
|
||||
let output = tokio::time::timeout(Duration::from_secs(timeout_secs), command.output())
|
||||
.await
|
||||
.map_err(|_| anyhow!("voice input command timed out after {timeout_secs}s"))?
|
||||
.with_context(|| format!("failed to run voice input command `{program}`"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"voice input command exited with {}{}",
|
||||
output.status,
|
||||
stderr_summary(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
stdout_to_transcript(&output.stdout)
|
||||
.ok_or_else(|| anyhow!("voice input command produced no transcript on stdout"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_quoted_voice_command() {
|
||||
let (program, args) =
|
||||
parse_voice_command(r#"python3 "/tmp/codewhale voice.py" --lang en-US"#)
|
||||
.expect("parse command");
|
||||
assert_eq!(program, "python3");
|
||||
assert_eq!(args, vec!["/tmp/codewhale voice.py", "--lang", "en-US"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_voice_command_quoting() {
|
||||
let err = parse_voice_command(r#"python3 "unterminated"#).expect_err("bad quotes");
|
||||
assert!(err.to_string().contains("invalid quoting"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_stdout_to_transcript() {
|
||||
assert_eq!(
|
||||
stdout_to_transcript(b"\n ship the voice input feature\r\n").as_deref(),
|
||||
Some("ship the voice input feature")
|
||||
);
|
||||
assert!(stdout_to_transcript(b"\n\t ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_clamps_to_supported_range() {
|
||||
assert_eq!(clamp_timeout_secs(0), 1);
|
||||
assert_eq!(clamp_timeout_secs(30), 30);
|
||||
assert_eq!(clamp_timeout_secs(999), MAX_TIMEOUT_SECS);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Palette audit tests to prevent color drift.
|
||||
//!
|
||||
//! These tests ensure that deprecated colors (like DEEPSEEK_AQUA) are not used
|
||||
//! directly in user-visible code. The palette should only use DeepSeek brand
|
||||
//! colors: blue, sky, red (plus neutral shades).
|
||||
//! directly in user-visible code. Backward-compatible DeepSeek aliases should
|
||||
//! point at the current CodeWhale semantic tokens instead of stale brand RGBs.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -133,35 +133,35 @@ fn audit_no_direct_aqua_usage() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_status_success_uses_sky() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let palette_path = Path::new(manifest_dir).join("src/palette.rs");
|
||||
let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs");
|
||||
|
||||
assert!(
|
||||
content.contains("pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;"),
|
||||
"STATUS_SUCCESS should use DEEPSEEK_SKY, not DEEPSEEK_AQUA"
|
||||
fn verify_status_success_uses_success_token() {
|
||||
assert_eq!(
|
||||
palette::STATUS_SUCCESS,
|
||||
Color::Rgb(
|
||||
palette::WHALE_SUCCESS_RGB.0,
|
||||
palette::WHALE_SUCCESS_RGB.1,
|
||||
palette::WHALE_SUCCESS_RGB.2
|
||||
),
|
||||
"STATUS_SUCCESS should use the current success token"
|
||||
);
|
||||
assert_ne!(
|
||||
palette::STATUS_SUCCESS,
|
||||
palette::DEEPSEEK_AQUA,
|
||||
"STATUS_SUCCESS should not regress to deprecated aqua"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_brand_colors_defined() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let palette_path = Path::new(manifest_dir).join("src/palette.rs");
|
||||
let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs");
|
||||
fn verify_brand_aliases_follow_whale_tokens() {
|
||||
assert_eq!(palette::WHALE_ACCENT_PRIMARY_RGB, (246, 196, 83));
|
||||
assert_eq!(palette::WHALE_INFO_RGB, (106, 174, 242));
|
||||
assert_eq!(palette::WHALE_ERROR_RGB, (255, 92, 122));
|
||||
|
||||
assert!(
|
||||
content.contains("DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229);"),
|
||||
"DEEPSEEK_BLUE should be #3578E5"
|
||||
);
|
||||
assert!(
|
||||
content.contains("DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);"),
|
||||
"DEEPSEEK_SKY should be #6AAEF2"
|
||||
);
|
||||
assert!(
|
||||
content.contains("DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);"),
|
||||
"DEEPSEEK_RED should be #E25060"
|
||||
assert_eq!(
|
||||
palette::DEEPSEEK_BLUE_RGB,
|
||||
palette::WHALE_ACCENT_PRIMARY_RGB
|
||||
);
|
||||
assert_eq!(palette::DEEPSEEK_SKY_RGB, palette::WHALE_INFO_RGB);
|
||||
assert_eq!(palette::DEEPSEEK_RED_RGB, palette::WHALE_ERROR_RGB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-62
@@ -63,23 +63,26 @@ provider's keyring entry.
|
||||
|
||||
For hosted, generic OpenAI-compatible, or self-hosted providers, set
|
||||
`provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, `"fireworks"`,
|
||||
`"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider <name>`. The facade saves provider
|
||||
credentials to the shared user config and forwards the resolved key, base URL,
|
||||
provider, and model to the TUI process. Use
|
||||
`"moonshot"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider <name>`.
|
||||
The facade saves provider credentials to the shared user config and forwards
|
||||
the resolved key, base URL, provider, and model to the TUI process. Use
|
||||
`codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"` or
|
||||
`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 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 passes model IDs
|
||||
through unchanged for OpenAI-compatible gateways. `atlascloud` defaults to
|
||||
`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` or
|
||||
`codewhale auth set --provider moonshot --api-key "YOUR_MOONSHOT_OR_KIMI_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
|
||||
`deepseek-v4-pro` for OpenAI-compatible gateways. `atlascloud` defaults to
|
||||
`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses
|
||||
`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets
|
||||
Wanjie Ark's OpenAI-compatible endpoint at
|
||||
`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,
|
||||
and passes model IDs through unchanged because Wanjie model access is
|
||||
account-scoped. SGLang, vLLM, and Ollama are
|
||||
account-scoped. `moonshot` targets Moonshot/Kimi, defaults to `kimi-k2.6`,
|
||||
and can use `KIMI_API_KEY` or `auth_mode = "kimi_oauth"` with local Kimi CLI
|
||||
credentials. SGLang, vLLM, and Ollama are
|
||||
self-hosted and can run without an API key by default. Ollama defaults to
|
||||
`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`
|
||||
or `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom
|
||||
@@ -202,7 +205,7 @@ fallbacks after saved config and keyring credentials:
|
||||
- `DEEPSEEK_API_KEY`
|
||||
- `DEEPSEEK_BASE_URL`
|
||||
- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)
|
||||
- `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|novita|fireworks|moonshot|sglang|vllm|ollama`)
|
||||
- `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL`
|
||||
- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `300`, clamped to `1..=3600`)
|
||||
- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout)
|
||||
@@ -250,8 +253,6 @@ fallbacks after saved config and keyring credentials:
|
||||
- `DEEPSEEK_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)
|
||||
- `DEEPSEEK_HOME` (override the base data directory; defaults to `~/.deepseek`)
|
||||
- `DEEPSEEK_AUTOMATIONS_DIR` (override the automations storage directory; defaults to `~/.deepseek/automations`)
|
||||
- `DEEPSEEK_VOICE_INPUT_COMMAND` (command used by command-palette Voice input; stdout must be the final transcript)
|
||||
- `DEEPSEEK_VOICE_INPUT_TIMEOUT_SECS` (voice input command timeout, clamped to `1..=600`, default `60`)
|
||||
- `DEEPSEEK_CAPACITY_ENABLED`
|
||||
- `DEEPSEEK_CAPACITY_LOW_RISK_MAX`
|
||||
- `DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX`
|
||||
@@ -372,59 +373,11 @@ Common settings keys:
|
||||
- `max_history` (number of submitted input history entries; cleared drafts are
|
||||
also kept locally for composer history search)
|
||||
- `default_model` (model name override)
|
||||
- `voice_input_command` (command run by command-palette Voice input; stdout is
|
||||
inserted into the composer as transcript text)
|
||||
- `voice_input_timeout_secs` (1-600 seconds, default 60)
|
||||
|
||||
Only `agent`, `plan`, and `yolo` are visible modes in the UI. Switch between
|
||||
them with `/mode`. For compatibility, older settings files with
|
||||
`default_mode = "normal"` still load as `agent`.
|
||||
|
||||
### Voice Input
|
||||
|
||||
Voice input is intentionally a command bridge instead of a built-in speech SDK.
|
||||
The configured command owns microphone permission, recording, and
|
||||
speech-to-text. CodeWhale runs it in the background with a listening status,
|
||||
reads stdout, trims surrounding whitespace, and inserts the transcript into the
|
||||
composer at the cursor.
|
||||
Open it from the command palette with `Ctrl+K`, then search `Voice input`.
|
||||
|
||||
```toml
|
||||
voice_input_command = "codewhale-voice"
|
||||
voice_input_timeout_secs = 60
|
||||
```
|
||||
|
||||
The command must:
|
||||
|
||||
- exit `0` on success
|
||||
- write only the final transcript to stdout
|
||||
- write diagnostics to stderr
|
||||
- avoid putting API keys directly in the command string; read secrets from the
|
||||
environment or OS key store instead
|
||||
|
||||
Platform helper patterns:
|
||||
|
||||
- macOS: use a small helper around a local STT tool or Apple's Speech framework,
|
||||
then set `voice_input_command = "codewhale-voice"`. Apple's framework supports
|
||||
live and recorded speech recognition, but microphone and speech permissions
|
||||
belong in the helper, not the terminal UI.
|
||||
- Windows: use a PowerShell, .NET, or WinRT helper around
|
||||
`Windows.Media.SpeechRecognition`. Prefer forward slashes in configured paths,
|
||||
for example
|
||||
`voice_input_command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:/Users/me/bin/codewhale-voice.ps1"`.
|
||||
- HarmonyOS/Huawei devices: use a native, ArkTS/Java, or device-bridge helper
|
||||
that calls the platform/Huawei ASR capability and prints UTF-8 transcript text.
|
||||
This keeps the Rust TUI portable while letting the HarmonyOS side own device
|
||||
permissions and SDK packaging.
|
||||
|
||||
Useful native references for helper authors:
|
||||
|
||||
- Apple Speech framework: <https://developer.apple.com/documentation/speech/>
|
||||
- Windows speech recognition APIs:
|
||||
<https://learn.microsoft.com/en-us/windows/apps/develop/input/speech-recognition>
|
||||
- Huawei ML Kit ASR codelab:
|
||||
<https://developer.huawei.com/consumer/en/codelab/AirTouch/>
|
||||
|
||||
Localization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6
|
||||
core pack covers high-visibility TUI chrome only; provider/tool schemas,
|
||||
personality prompts, and full documentation remain English unless explicitly
|
||||
@@ -476,10 +429,10 @@ If you are upgrading from older releases:
|
||||
|
||||
### Core keys (used by the TUI/engine)
|
||||
|
||||
- `provider` (string, optional): `codewhale` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `codewhale`; 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`; `fireworks` targets `https://api.fireworks.ai/inference/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): `codewhale` (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 `codewhale`; 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`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `moonshot` targets `https://api.moonshot.ai/v1` by default, with Kimi CLI OAuth mode using `https://api.kimi.com/coding/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, `https://api.openai.com/v1` for `provider = "openai"`, `https://api.atlascloud.ai/v1` for `provider = "atlascloud"`, `https://maas-openapi.wanjiedata.com/api/v1` for `provider = "wanjie-ark"`, or the provider-specific endpoint for hosted/self-hosted providers. 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, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `gpt-4.1` for generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `codewhale-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. `DEEPSEEK_MODEL` overrides this for a single process.
|
||||
- `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, `https://api.openai.com/v1` for `provider = "openai"`, `https://api.atlascloud.ai/v1` for `provider = "atlascloud"`, `https://maas-openapi.wanjiedata.com/api/v1` for `provider = "wanjie-ark"`, `https://api.moonshot.ai/v1` for `provider = "moonshot"` API-key mode, or the provider-specific endpoint for hosted/self-hosted providers. 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, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `kimi-k2.6` for Moonshot/Kimi API-key mode, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `codewhale-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. `DEEPSEEK_MODEL` overrides this for a single process.
|
||||
- `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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codewhale",
|
||||
"version": "0.8.44",
|
||||
"codewhaleBinaryVersion": "0.8.44",
|
||||
"version": "0.8.45",
|
||||
"codewhaleBinaryVersion": "0.8.45",
|
||||
"description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "deepseek-tui",
|
||||
"version": "0.8.44",
|
||||
"version": "0.8.45",
|
||||
"description": "Legacy compatibility package. Renamed to `codewhale`; run `npm install -g codewhale` for new installs.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -196,11 +196,11 @@ default_text_model = "openrouter/deepseek/deepseek-v4-pro"`}
|
||||
sources: ["README.md", "#1207"],
|
||||
},
|
||||
{
|
||||
q: "What is Goal mode? Is it available?",
|
||||
q: "What does /goal do?",
|
||||
a: (
|
||||
<>
|
||||
Goal mode is a future workflow/tab direction for long-running, multi-step objectives — not the current <code className="inline">/goal</code> command.
|
||||
The current <code className="inline">/goal</code> is a simple goal-setter. The full Goal mode (autonomous multi-turn task execution with checkpoint/resume) is planned but not yet implemented.
|
||||
<code className="inline">/goal</code> is a simple goal-setter for the current session.
|
||||
It does not add another app mode; the mode switcher remains Plan, Agent, and YOLO.
|
||||
Track progress in <a href="https://github.com/Hmbown/CodeWhale/issues/891" className="body-link">#891</a>.
|
||||
</>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user