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:
Hunter Bown
2026-05-25 18:45:36 -05:00
committed by GitHub
parent c7bd7f161e
commit 228372935e
44 changed files with 1879 additions and 998 deletions
+68 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+11 -10
View File
@@ -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)
---
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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(),
+14 -9
View File
@@ -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
View File
@@ -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");
}
}
+15
View File
@@ -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())
}
+7 -7
View File
@@ -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
View File
@@ -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())
+2 -1
View File
@@ -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
View File
@@ -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 {
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+19
View File
@@ -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)]
+4 -4
View File
@@ -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;
}
}
}
+8
View File
@@ -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(),
-106
View File
@@ -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();
+106 -10
View File
@@ -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");
+324
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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,
-26
View File
@@ -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(
+16 -46
View File
@@ -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 {
+2 -2
View File
@@ -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(),
]
);
-1
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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());
+1
View File
@@ -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,
-22
View File
@@ -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"));
-127
View File
@@ -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);
}
}
+25 -25
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+3 -3
View File
@@ -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>.
</>
),