From c6f72207ba4cf39c7132765aeabe778591b0a740 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 24 May 2026 19:03:41 -0500 Subject: [PATCH 1/3] feat(agents): deterministic whale-species naming for sub-agents (#2016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the sequential-spawn-index whale-nickname system with a deterministic hash-based naming scheme that maps each agent ID to a stable whale species name. The same agent ID always gets the same friendly name — even across session restarts for persisted agents. - whale_name_for_id(id): hash agent ID → WHALE_NICKNAMES index - assign_unique_whale_name(id, active_names): deterministic with collision avoidance, appends numeric suffix when base name is taken - Expand WHALE_NICKNAMES from 25 to ~45 Cetacea species including baleen whales, toothed whales, and select dolphins (Delphinidae); porpoises excluded as labels that don't carry well - SubAgent::new now accepts a pre-generated id parameter so the spawn method can hash it before construction - SubAgentsView popup now shows friendly nickname next to raw agent ID (dimmed) instead of hiding it - live_subagent_result accepts optional nickname parameter - whale_nickname_for_index kept as legacy public API for test snapshots 137 sub-agent tests pass. Taxonomy source: Society for Marine Mammalogy (2025). --- crates/tui/src/tools/subagent/tests.rs | 2 +- crates/tui/src/tui/views/mod.rs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 7f7464124..bd4a548a9 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2120,4 +2120,4 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { !second.contains("Found three errors."), "sentinel should not duplicate the human summary line" ); -} +} \ No newline at end of file diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index c50c83c0b..07e089665 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1895,9 +1895,15 @@ fn append_subagent_group( lines.push(Line::from(vec![ Span::raw(" "), - Span::styled(display_name, Style::default().fg(palette::TEXT_PRIMARY)), + Span::styled( + display_name, + Style::default().fg(palette::TEXT_PRIMARY), + ), Span::raw(" "), - Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)), + Span::styled( + format!("{id:<11}"), + Style::default().fg(palette::TEXT_DIM), + ), Span::styled( format!("{kind:<9}"), Style::default().fg(palette::TEXT_MUTED), From 5da21f9e9709cb179e3ef1f973f7f6f662b03714 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 24 May 2026 21:09:30 -0500 Subject: [PATCH 2/3] fix(commands): make balance scaffold honest --- crates/tui/src/tools/subagent/tests.rs | 2 +- crates/tui/src/tui/views/mod.rs | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index bd4a548a9..7f7464124 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2120,4 +2120,4 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { !second.contains("Found three errors."), "sentinel should not duplicate the human summary line" ); -} \ No newline at end of file +} diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 07e089665..c50c83c0b 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1895,15 +1895,9 @@ fn append_subagent_group( lines.push(Line::from(vec![ Span::raw(" "), - Span::styled( - display_name, - Style::default().fg(palette::TEXT_PRIMARY), - ), + Span::styled(display_name, Style::default().fg(palette::TEXT_PRIMARY)), Span::raw(" "), - Span::styled( - format!("{id:<11}"), - Style::default().fg(palette::TEXT_DIM), - ), + Span::styled(format!("{id:<11}"), Style::default().fg(palette::TEXT_DIM)), Span::styled( format!("{kind:<9}"), Style::default().fg(palette::TEXT_MUTED), From b9822129968e1e42d97fd817f889a79917ac0cb7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 25 May 2026 04:46:20 -0500 Subject: [PATCH 3/3] chore(release): prepare v0.8.45 --- CHANGELOG.md | 43 ++ Cargo.lock | 32 +- Cargo.toml | 2 +- README.md | 10 +- crates/agent/Cargo.toml | 2 +- crates/agent/src/lib.rs | 119 ++++- crates/app-server/Cargo.toml | 23 +- crates/app-server/src/lib.rs | 328 +++++++++++- crates/app-server/src/main.rs | 15 + crates/cli/Cargo.toml | 14 +- crates/cli/src/lib.rs | 219 +++++++- crates/config/Cargo.toml | 3 +- crates/config/src/lib.rs | 420 ++++++++++++--- crates/core/Cargo.toml | 16 +- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/CHANGELOG.md | 43 ++ crates/tui/Cargo.toml | 6 +- crates/tui/src/config.rs | 33 +- crates/tui/src/main.rs | 91 +++- crates/tui/src/models.rs | 38 ++ crates/tui/src/palette.rs | 710 +++++++++++++++++++------- crates/tui/src/pricing.rs | 19 + crates/tui/src/runtime_threads.rs | 8 +- crates/tui/src/session_manager.rs | 8 + crates/tui/src/settings.rs | 106 ---- crates/tui/src/skills/install.rs | 116 ++++- crates/tui/src/theme_qa_audit.rs | 324 ++++++++++++ crates/tui/src/tui/app.rs | 30 +- crates/tui/src/tui/color_compat.rs | 2 +- crates/tui/src/tui/command_palette.rs | 26 - crates/tui/src/tui/footer_ui.rs | 62 +-- crates/tui/src/tui/markdown_render.rs | 4 +- crates/tui/src/tui/mod.rs | 1 - crates/tui/src/tui/session_picker.rs | 1 + crates/tui/src/tui/ui.rs | 108 +--- crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/views/mod.rs | 22 - crates/tui/src/tui/voice_input.rs | 127 ----- crates/tui/tests/palette_audit.rs | 52 +- docs/CONFIGURATION.md | 50 -- npm/codewhale/package.json | 4 +- npm/deepseek-tui/package.json | 2 +- web/app/[locale]/faq/page.tsx | 6 +- 45 files changed, 2334 insertions(+), 918 deletions(-) create mode 100644 crates/tui/src/theme_qa_audit.rs delete mode 100644 crates/tui/src/tui/voice_input.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fda0e46..542fc9e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ 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). + +### 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 + +- **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 @@ -4807,6 +4849,7 @@ Welcome — and thank you. - Example skills and launch assets [Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...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 diff --git a/Cargo.lock b/Cargo.lock index 2d5bd8e14..b8ce5e457 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index cee78462f..78d560a0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index 58975408d..2a54b9253 100644 --- a/README.md +++ b/README.md @@ -429,11 +429,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). --- @@ -604,7 +599,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 +632,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 +702,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) --- diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index c6d4fd3f9..dc402892f 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -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 diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 928973c07..752dc4729 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -76,14 +76,98 @@ impl Default for ModelRegistry { ModelInfo { id: "gpt-4.1".to_string(), provider: ProviderKind::Openai, - aliases: vec!["gpt4.1".to_string(), "gpt-4o".to_string()], + aliases: vec!["gpt4.1".to_string()], supports_tools: true, - supports_reasoning: true, + supports_reasoning: false, }, ModelInfo { id: "gpt-4.1-mini".to_string(), provider: ProviderKind::Openai, - aliases: vec!["gpt-4o-mini".to_string()], + aliases: vec!["gpt4.1-mini".to_string()], + supports_tools: true, + supports_reasoning: false, + }, + ModelInfo { + id: "gpt-5.5".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5.5".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5.4".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5.4".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5.4-mini".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5.4-mini".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5.4-nano".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5.4-nano".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5.3-codex".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5.3-codex".to_string(), "gpt-5-codex".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5.1".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5.1".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5-mini".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5-mini".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-5-nano".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt5-nano".to_string()], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "gpt-4.1-nano".to_string(), + provider: ProviderKind::Openai, + aliases: vec!["gpt4.1-nano".to_string()], + supports_tools: true, + supports_reasoning: false, + }, + ModelInfo { + id: "gpt-4o".to_string(), + provider: ProviderKind::Openai, + aliases: vec![], + supports_tools: true, + supports_reasoning: false, + }, + ModelInfo { + id: "gpt-4o-mini".to_string(), + provider: ProviderKind::Openai, + aliases: vec![], supports_tools: true, supports_reasoning: false, }, @@ -353,6 +437,35 @@ mod tests { assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro"); } + #[test] + fn openai_default_preserves_stable_runtime_default() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(None, Some(ProviderKind::Openai)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Openai); + assert_eq!(resolved.resolved.id, "gpt-4.1"); + assert!(resolved.resolved.supports_tools); + assert!(!resolved.resolved.supports_reasoning); + } + + #[test] + fn openai_codex_alias_resolves_to_current_codex_model() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("gpt-5-codex"), Some(ProviderKind::Openai)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Openai); + assert_eq!(resolved.resolved.id, "gpt-5.3-codex"); + } + + #[test] + fn openai_gpt_4o_requested_model_is_not_rewritten_to_gpt_4_1() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("gpt-4o"), Some(ProviderKind::Openai)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Openai); + assert_eq!(resolved.resolved.id, "gpt-4o"); + } + #[test] fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() { let registry = ModelRegistry::default(); diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index dc87c8872..d683abf42 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -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" diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index e580ed322..a9fe43996 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -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, + pub auth_token: Option, + pub insecure_no_auth: bool, + pub cors_origins: Vec, } #[derive(Clone)] @@ -36,6 +53,7 @@ struct AppState { config: Arc>, runtime: Arc>, registry: ModelRegistry, + auth_token: Option, } #[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 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 app = Router::new() - .route("/healthz", get(healthz)) + 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)) - .layer(CorsLayer::permissive()) - .with_state(state); + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_app_server_token, + )); - let listener = tokio::net::TcpListener::bind(options.listen).await?; - axum::serve(listener, app).await?; - Ok(()) + 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) -> 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, Json(req): Json, ) -> Json { - Json(process_app_request(&state, req).await) + Json(process_app_request(&state, req, AppTransport::Http).await) } -fn build_state(config_path: Option) -> Result { +fn build_state(config_path: Option, auth_token: Option) -> Result { 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) -> Result { config: Arc::new(RwLock::new(config)), runtime: Arc::new(Mutex::new(runtime)), registry, + auth_token, }) } +fn resolve_auth_token(options: &AppServerOptions) -> Result> { + 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 = 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, + 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"); + } +} diff --git a/crates/app-server/src/main.rs b/crates/app-server/src/main.rs index fef6b65d8..9627746e1 100644 --- a/crates/app-server/src/main.rs +++ b/crates/app-server/src/main.rs @@ -17,6 +17,12 @@ struct Cli { port: u16, #[arg(long)] config: Option, + #[arg(long = "auth-token")] + auth_token: Option, + #[arg(long, default_value_t = false)] + insecure_no_auth: bool, + #[arg(long = "cors-origin")] + cors_origin: Vec, } #[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 { + std::env::var("CODEWHALE_APP_SERVER_TOKEN") + .ok() + .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok()) +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 87ef1e74b..1355ac046 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -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 diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c27d699f7..2390a8924 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -15,6 +15,7 @@ use codewhale_app_server::{ }; use codewhale_config::{ CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource, + codex_auth_file_path, codex_oauth_access_token, }; use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine}; use codewhale_mcp::{McpServerDefinition, run_stdio_server}; @@ -25,6 +26,7 @@ use codewhale_state::{StateStore, ThreadListFilters}; enum ProviderArg { Deepseek, NvidiaNim, + #[value(alias = "codex", alias = "chatgpt", alias = "openai-compatible")] Openai, Atlascloud, WanjieArk, @@ -180,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, @@ -257,10 +259,17 @@ 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, #[arg(long)] api_key: Option, + #[arg( + long = "codex-oauth", + alias = "with-access-token", + help = "Use Codex/ChatGPT OAuth from CODEX_ACCESS_TOKEN or ~/.codex/auth.json", + default_value_t = false + )] + codex_oauth: bool, #[arg(long, default_value_t = false, hide = true)] chatgpt: bool, #[arg(long, default_value_t = false, hide = true)] @@ -426,6 +435,12 @@ struct AppServerArgs { port: u16, #[arg(long)] config: Option, + #[arg(long = "auth-token")] + auth_token: Option, + #[arg(long, default_value_t = false)] + insecure_no_auth: bool, + #[arg(long = "cors-origin")] + cors_origin: Vec, #[arg(long, default_value_t = false)] stdio: bool, } @@ -652,23 +667,56 @@ fn run_login_command_with_secrets( args: LoginArgs, secrets: &Secrets, ) -> Result<()> { - let provider: ProviderKind = args.provider.into(); + let token_mode = args.chatgpt || args.codex_oauth || args.device_code; + let provider: ProviderKind = args + .provider + .unwrap_or(if token_mode { + ProviderArg::Openai + } else { + ProviderArg::Deepseek + }) + .into(); store.config.provider = provider; - if args.chatgpt { + if args.chatgpt || args.codex_oauth { + if provider != ProviderKind::Openai { + bail!("Codex OAuth can only be used with the openai provider"); + } let token = match args.token { - Some(token) => token, - None => read_api_key_from_stdin()?, + Some(token) => Some(token), + None if args.chatgpt && !args.codex_oauth => Some(read_api_key_from_stdin()?), + None => None, }; - store.config.auth_mode = Some("chatgpt".to_string()); - store.config.chatgpt_access_token = Some(token); + let has_dynamic_token = token.is_some() || codex_oauth_access_token().is_some(); + store.config.auth_mode = Some(if args.codex_oauth { + "codex_oauth".to_string() + } else { + "chatgpt".to_string() + }); + store.config.chatgpt_access_token = token; store.config.device_code_session = None; store.save()?; - println!("logged in using chatgpt token mode ({})", provider.as_str()); + let source = if store.config.chatgpt_access_token.is_some() { + "stored access token".to_string() + } else if has_dynamic_token { + "Codex CLI access token".to_string() + } else { + let path = codex_auth_file_path() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "$CODEX_HOME/auth.json".to_string()); + format!("Codex CLI access token (not found yet; run `codex login`, expected {path})") + }; + println!( + "logged in using Codex OAuth token mode ({}) via {source}", + provider.as_str() + ); return Ok(()); } if args.device_code { + if provider != ProviderKind::Openai { + bail!("device-code auth can only be used with the openai provider"); + } let token = match args.token { Some(token) => token, None => read_api_key_from_stdin()?, @@ -873,10 +921,16 @@ fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec { let provider = store.config.provider; let config_key = provider_config_api_key(store, provider); + let oauth_token = provider_oauth_token(store, provider); let keyring_key = provider_keyring_api_key(secrets, provider); let env_key = provider_env_value(provider); - let active_source = if config_key.is_some() { + let active_source = if oauth_token.is_some() { + oauth_token + .as_ref() + .map(|(label, _)| *label) + .unwrap_or("oauth") + } else if config_key.is_some() { "config" } else if keyring_key.is_some() { "secret store" @@ -885,8 +939,10 @@ fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec { } else { "missing" }; - let active_last4 = config_key - .map(last4_label) + let active_last4 = oauth_token + .as_ref() + .map(|(_, token)| last4_label(token)) + .or_else(|| config_key.map(last4_label)) .or_else(|| keyring_key.as_deref().map(last4_label)) .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value))); let active_label = active_last4 @@ -904,8 +960,16 @@ fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec { 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(), + if oauth_token.is_some() { + "lookup order: oauth token -> config -> secret store -> env".to_string() + } else { + "lookup order: config -> secret store -> env".to_string() + }, format!( "config file: {} ({})", store.path().display(), @@ -920,6 +984,55 @@ fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec { ] } +fn provider_oauth_token( + store: &ConfigStore, + provider: ProviderKind, +) -> Option<(&'static str, String)> { + if provider != ProviderKind::Openai { + return None; + } + let auth_mode = store + .config + .auth_mode + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase(); + + if matches!(auth_mode.as_str(), "device-code" | "device_code" | "device") { + return store + .config + .device_code_session + .clone() + .filter(|token| !token.trim().is_empty()) + .map(|token| ("device code session", token)); + } + + if matches!( + auth_mode.as_str(), + "chatgpt" + | "chat-gpt" + | "codex" + | "codex-oauth" + | "codex_oauth" + | "oauth" + | "access-token" + | "access_token" + | "with-access-token" + | "with_access_token" + ) { + return store + .config + .chatgpt_access_token + .clone() + .filter(|token| !token.trim().is_empty()) + .map(|token| ("chatgpt token", token)) + .or_else(|| codex_oauth_access_token().map(|token| ("codex oauth", token))); + } + + None +} + fn source_status(value: Option<&str>, missing_label: &str) -> String { value .map(|v| format!("set, last4: {}", last4_label(v))) @@ -1312,9 +1425,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 { + 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)?; @@ -1479,6 +1601,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 @@ -2035,8 +2160,9 @@ mod tests { run_login_command_with_secrets( &mut store, LoginArgs { - provider: ProviderArg::Deepseek, + provider: Some(ProviderArg::Deepseek), api_key: Some("sk-test".to_string()), + codex_oauth: false, chatgpt: false, device_code: false, token: None, @@ -2061,6 +2187,41 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn codex_oauth_login_defaults_to_openai_without_copying_codex_token() { + let _lock = env_lock(); + let codex_home = tempfile::TempDir::new().expect("tempdir"); + let codex_home_str = codex_home.path().to_string_lossy().into_owned(); + let _codex_home = ScopedEnvVar::set("CODEX_HOME", &codex_home_str); + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "deepseek-cli-codex-oauth-login-test-{}-{nanos}.toml", + std::process::id() + )); + let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); + let secrets = no_keyring_secrets(); + + run_login_command_with_secrets( + &mut store, + LoginArgs { + provider: None, + api_key: None, + codex_oauth: true, + chatgpt: false, + device_code: false, + token: None, + }, + &secrets, + ) + .expect("codex oauth login should write config"); + + assert_eq!(store.config.provider, ProviderKind::Openai); + assert_eq!(store.config.auth_mode.as_deref(), Some("codex_oauth")); + assert_eq!(store.config.chatgpt_access_token, None); + + let _ = std::fs::remove_file(path); + } + #[test] fn parses_auth_subcommand_matrix() { let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]); @@ -2391,6 +2552,30 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn auth_status_reports_codex_oauth_source_with_last4() { + let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "deepseek-cli-auth-codex-oauth-status-test-{}-{nanos}.toml", + std::process::id() + )); + let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); + store.config.provider = ProviderKind::Openai; + store.config.auth_mode = Some("codex_oauth".to_string()); + store.config.chatgpt_access_token = Some("codex-token-7777".to_string()); + let secrets = no_keyring_secrets(); + + let output = auth_status_lines(&store, &secrets).join("\n"); + + assert!(output.contains("provider: openai")); + assert!(output.contains("auth mode: codex_oauth")); + assert!(output.contains("active source: chatgpt token (last4: ...7777)")); + assert!(output.contains("lookup order: oauth token -> config -> secret store -> env")); + assert!(!output.contains("codex-token-7777")); + + let _ = std::fs::remove_file(path); + } + #[test] fn dispatch_keyring_recovery_self_heals_into_config_file() { use codewhale_secrets::{InMemoryKeyringStore, KeyringStore}; @@ -2651,6 +2836,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 = cmd .get_args() .map(|arg| arg.to_string_lossy().into_owned()) diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 2d9ea5228..912d5ed8c 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -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 diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9bfb089e0..c28b87528 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -54,6 +54,13 @@ pub enum ProviderKind { )] Deepseek, NvidiaNim, + #[serde( + alias = "open-ai", + alias = "codex", + alias = "chatgpt", + alias = "openai-compatible", + alias = "openai_compatible" + )] Openai, Atlascloud, #[serde( @@ -97,7 +104,8 @@ impl ProviderKind { "deepseek" | "deep-seek" | "deepseek-cn" | "deepseek_china" | "deepseekcn" | "deepseek-china" => Some(Self::Deepseek), "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim), - "openai" | "open-ai" => Some(Self::Openai), + "openai" | "open-ai" | "codex" | "chatgpt" | "openai-compatible" + | "openai_compatible" => Some(Self::Openai), "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => Some(Self::Atlascloud), "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark" | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk), @@ -331,91 +339,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 project.sandbox_mode.is_some() { - self.sandbox_mode = project.sandbox_mode; + if let Some(policy) = project.approval_policy + && project_approval_policy_is_allowed(self.approval_policy.as_deref(), &policy) + { + self.approval_policy = Some(policy); } - // 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] @@ -1009,8 +987,11 @@ impl ConfigToml { // secrets façade recovers configured secret-store credentials before // falling back to ambient env. let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); + let oauth_token = oauth_token_for_provider(provider, auth_mode.as_deref(), self); let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() { (Some(value), Some(RuntimeApiKeySource::Cli)) + } else if let Some((value, source)) = oauth_token { + (Some(value), Some(source)) } else if let Some(value) = from_file.clone().filter(|v| !v.trim().is_empty()) { (Some(value), Some(RuntimeApiKeySource::ConfigFile)) } else if should_skip_secret_store_for_provider(provider, &base_url, auth_mode.as_deref()) { @@ -1105,18 +1086,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 { + 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 { + 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, } } @@ -1282,6 +1302,115 @@ fn auth_mode_disables_api_key(auth_mode: Option<&str>) -> bool { ) } +fn oauth_token_for_provider( + provider: ProviderKind, + auth_mode: Option<&str>, + config: &ConfigToml, +) -> Option<(String, RuntimeApiKeySource)> { + if provider != ProviderKind::Openai { + return None; + } + + if auth_mode_uses_device_code_session(auth_mode) { + return config + .device_code_session + .clone() + .and_then(non_empty_secret) + .map(|token| (token, RuntimeApiKeySource::DeviceCodeSession)); + } + + if auth_mode_uses_codex_oauth(auth_mode) { + return config + .chatgpt_access_token + .clone() + .and_then(non_empty_secret) + .map(|token| (token, RuntimeApiKeySource::ChatgptToken)) + .or_else(|| { + codex_oauth_access_token().map(|token| (token, RuntimeApiKeySource::CodexOAuth)) + }); + } + + None +} + +fn auth_mode_uses_codex_oauth(auth_mode: Option<&str>) -> bool { + matches!( + normalized_auth_mode(auth_mode).as_deref(), + Some( + "chatgpt" + | "chat-gpt" + | "codex" + | "codex-oauth" + | "codex_oauth" + | "oauth" + | "access-token" + | "access_token" + | "with-access-token" + | "with_access_token" + ) + ) +} + +fn auth_mode_uses_device_code_session(auth_mode: Option<&str>) -> bool { + matches!( + normalized_auth_mode(auth_mode).as_deref(), + Some("device-code" | "device_code" | "device") + ) +} + +fn normalized_auth_mode(auth_mode: Option<&str>) -> Option { + auth_mode + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) +} + +fn non_empty_secret(value: String) -> Option { + (!value.trim().is_empty()).then_some(value) +} + +#[derive(Deserialize)] +struct CodexAuthJson { + tokens: Option, +} + +#[derive(Deserialize)] +struct CodexAuthTokens { + access_token: Option, +} + +/// Return the active Codex CLI OAuth access token, if Codex has one locally. +/// +/// This is only used when the caller explicitly selects a Codex/ChatGPT OAuth +/// auth mode. It never prints or persists the token. +#[must_use] +pub fn codex_oauth_access_token() -> Option { + for key in [ + "CODEX_ACCESS_TOKEN", + "CHATGPT_ACCESS_TOKEN", + "OPENAI_ACCESS_TOKEN", + ] { + if let Ok(value) = std::env::var(key) + && !value.trim().is_empty() + { + return Some(value); + } + } + + let raw = fs::read_to_string(codex_auth_file_path()?).ok()?; + let parsed: CodexAuthJson = serde_json::from_str(&raw).ok()?; + parsed.tokens?.access_token.and_then(non_empty_secret) +} + +/// Path to the Codex CLI auth file used for explicit OAuth import mode. +#[must_use] +pub fn codex_auth_file_path() -> Option { + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".codex"))) + .map(|dir| dir.join("auth.json")) +} + fn base_url_uses_local_host(base_url: &str) -> bool { let Some(host) = base_url_host(base_url) else { return false; @@ -1324,6 +1453,9 @@ pub struct CliRuntimeOverrides { pub enum RuntimeApiKeySource { Cli, ConfigFile, + ChatgptToken, + DeviceCodeSession, + CodexOAuth, Keyring, Env, } @@ -1334,6 +1466,9 @@ impl RuntimeApiKeySource { match self { Self::Cli => "cli", Self::ConfigFile => "config", + Self::ChatgptToken => "chatgpt-token", + Self::DeviceCodeSession => "device-code-session", + Self::CodexOAuth => "codex-oauth", Self::Keyring => "keyring", Self::Env => "env", } @@ -1845,6 +1980,10 @@ mod tests { vllm_base_url: Option, ollama_api_key: Option, ollama_base_url: Option, + codex_access_token: Option, + chatgpt_access_token: Option, + openai_access_token: Option, + codex_home: Option, } impl EnvGuard { @@ -1880,6 +2019,10 @@ mod tests { vllm_base_url: env::var_os("VLLM_BASE_URL"), ollama_api_key: env::var_os("OLLAMA_API_KEY"), ollama_base_url: env::var_os("OLLAMA_BASE_URL"), + codex_access_token: env::var_os("CODEX_ACCESS_TOKEN"), + chatgpt_access_token: env::var_os("CHATGPT_ACCESS_TOKEN"), + openai_access_token: env::var_os("OPENAI_ACCESS_TOKEN"), + codex_home: env::var_os("CODEX_HOME"), }; // Safety: test-only environment mutation guarded by a module mutex. unsafe { @@ -1913,6 +2056,10 @@ mod tests { env::remove_var("VLLM_BASE_URL"); env::remove_var("OLLAMA_API_KEY"); env::remove_var("OLLAMA_BASE_URL"); + env::remove_var("CODEX_ACCESS_TOKEN"); + env::remove_var("CHATGPT_ACCESS_TOKEN"); + env::remove_var("OPENAI_ACCESS_TOKEN"); + env::remove_var("CODEX_HOME"); } guard } @@ -1960,6 +2107,10 @@ mod tests { Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take()); Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); + Self::restore_var("CODEX_ACCESS_TOKEN", self.codex_access_token.take()); + Self::restore_var("CHATGPT_ACCESS_TOKEN", self.chatgpt_access_token.take()); + Self::restore_var("OPENAI_ACCESS_TOKEN", self.openai_access_token.take()); + Self::restore_var("CODEX_HOME", self.codex_home.take()); } } } @@ -2285,6 +2436,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("codex".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 { @@ -2556,6 +2788,56 @@ mod tests { assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama"]); } + #[test] + fn openai_codex_oauth_mode_uses_config_token_before_secret_store() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let store = Arc::new(RecordingSecretsStore::with_value("stale-openai-key")); + let secrets = Secrets::new(store.clone()); + let config = ConfigToml { + provider: ProviderKind::Openai, + auth_mode: Some("codex_oauth".to_string()), + chatgpt_access_token: Some("codex-access-token".to_string()), + ..ConfigToml::default() + }; + + let resolved = + config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert_eq!(resolved.api_key.as_deref(), Some("codex-access-token")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ChatgptToken) + ); + assert!( + store.gets.lock().unwrap().is_empty(), + "configured OAuth token should avoid probing the secret store" + ); + } + + #[test] + fn openai_codex_oauth_mode_can_use_codex_access_token_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { env::set_var("CODEX_ACCESS_TOKEN", "codex-env-token") }; + + let config = ConfigToml { + provider: ProviderKind::Openai, + auth_mode: Some("codex-oauth".to_string()), + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.api_key.as_deref(), Some("codex-env-token")); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::CodexOAuth) + ); + } + #[test] fn loopback_custom_deepseek_base_url_does_not_probe_secret_store_by_default() { let _lock = env_lock(); diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index debdf425c..c9d602f4a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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 diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 669759c49..16b09697d 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -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 diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index a39dc18fd..4f657cd0a 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -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 diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 9059c3444..464ce47e4 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -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 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f6fda0e46..542fc9e25 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,48 @@ 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). + +### 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 + +- **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 @@ -4807,6 +4849,7 @@ Welcome — and thank you. - Example skills and launch assets [Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.44...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 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index f78b57a42..ffcb900e2 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -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" diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b41712557..ce7232930 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -75,6 +75,22 @@ pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[ "deepseek/deepseek-v4-flash", ]; pub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &["deepseek-v4-pro", "deepseek-v4-flash"]; +pub const OPENAI_CHAT_COMPLETIONS_MODELS: &[&str] = &[ + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.3-codex", + "gpt-5.1", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", +]; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] @@ -102,7 +118,8 @@ impl ApiProvider { Some(Self::DeepseekCN) } "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim), - "openai" | "open-ai" => Some(Self::Openai), + "openai" | "open-ai" | "codex" | "chatgpt" | "openai-compatible" + | "openai_compatible" => Some(Self::Openai), "atlascloud" | "atlas-cloud" | "atlas_cloud" | "atlas" => Some(Self::Atlascloud), "wanjie" | "wanjie-ark" | "wanjie_ark" | "ark-wanjie" | "ark_wanjie" | "wanjieark" | "wanjie-maas" | "wanjie_maas" | "wanjiemaas" => Some(Self::WanjieArk), @@ -423,9 +440,8 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL], ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], - ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => { - OFFICIAL_DEEPSEEK_MODELS.to_vec() - } + ApiProvider::Openai => OPENAI_CHAT_COMPLETIONS_MODELS.to_vec(), + ApiProvider::Atlascloud | ApiProvider::Ollama => OFFICIAL_DEEPSEEK_MODELS.to_vec(), } } @@ -4830,6 +4846,15 @@ api_key = "old-openrouter-key" ); } + #[test] + fn model_completion_names_for_openai_include_codex_models() { + let names = model_completion_names_for_provider(ApiProvider::Openai); + + assert!(names.contains(&"gpt-5.3-codex")); + assert!(names.contains(&"gpt-5.5")); + assert!(names.contains(&"gpt-4.1")); + } + #[test] fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() { assert!(normalize_model_name("gpt-4o").is_none()); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 34cb7fce7..bc5ea84a0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -69,6 +69,7 @@ mod snapshot; mod task_manager; #[cfg(test)] mod test_support; +mod theme_qa_audit; mod tools; mod tui; mod utils; @@ -4651,41 +4652,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 @@ -6289,6 +6298,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( diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index a5f52c6d3..5e338124b 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -6,6 +6,9 @@ use serde::{Deserialize, Serialize}; /// newer V4 alias and do not carry an explicit `*k` suffix. pub const LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS: u32 = 128_000; pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000; +pub const OPENAI_GPT_5_4_CONTEXT_WINDOW_TOKENS: u32 = 1_050_000; +pub const OPENAI_GPT_5_CONTEXT_WINDOW_TOKENS: u32 = 400_000; +pub const OPENAI_GPT_4_1_CONTEXT_WINDOW_TOKENS: u32 = 1_047_576; /// Last-resort compaction trigger when [`context_window_for_model`] returns /// `None` (an unrecognised model id). v0.8.11 raised this from `50_000` to /// `102_400` (80% of [`LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS`]) so unknown @@ -226,6 +229,21 @@ pub fn context_window_for_model(model: &str) -> Option { if lower.contains("claude") { return Some(200_000); } + if lower.starts_with("gpt-5.4-mini") || lower.starts_with("gpt-5.4-nano") { + return Some(OPENAI_GPT_5_CONTEXT_WINDOW_TOKENS); + } + if lower.starts_with("gpt-5.5") || lower.starts_with("gpt-5.4") { + return Some(OPENAI_GPT_5_4_CONTEXT_WINDOW_TOKENS); + } + if lower.starts_with("gpt-5") || lower.contains("codex") { + return Some(OPENAI_GPT_5_CONTEXT_WINDOW_TOKENS); + } + if lower.starts_with("gpt-4.1") { + return Some(OPENAI_GPT_4_1_CONTEXT_WINDOW_TOKENS); + } + if lower.starts_with("gpt-4o") { + return Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS); + } None } @@ -424,6 +442,26 @@ mod tests { ); } + #[test] + fn openai_models_map_to_current_context_windows() { + assert_eq!( + context_window_for_model("gpt-5.5"), + Some(OPENAI_GPT_5_4_CONTEXT_WINDOW_TOKENS) + ); + assert_eq!( + context_window_for_model("gpt-5.3-codex"), + Some(OPENAI_GPT_5_CONTEXT_WINDOW_TOKENS) + ); + assert_eq!( + context_window_for_model("gpt-5.4-mini"), + Some(OPENAI_GPT_5_CONTEXT_WINDOW_TOKENS) + ); + assert_eq!( + context_window_for_model("gpt-4.1"), + Some(OPENAI_GPT_4_1_CONTEXT_WINDOW_TOKENS) + ); + } + #[test] fn compaction_threshold_scales_with_context_window() { assert_eq!( diff --git a/crates/tui/src/palette.rs b/crates/tui/src/palette.rs index c99802006..dc41c1b3c 100644 --- a/crates/tui/src/palette.rs +++ b/crates/tui/src/palette.rs @@ -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) + /// 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, - /// Statusline status colors + // Footer statusline colors pub status_ready: Color, pub status_working: Color, pub status_warning: Color, - /// Statusline text colors - pub text_dim: Color, - pub text_hint: Color, - pub text_muted: Color, - pub text_body: Color, - pub text_soft: Color, - pub border: 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 } @@ -1209,10 +1525,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 { match depth { @@ -1364,7 +1679,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, @@ -1469,9 +1785,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); } @@ -1605,8 +1942,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] @@ -1634,8 +1975,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), @@ -1694,14 +2039,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] diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 750f9830b..eb78ed8bd 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -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 { + 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)] diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 1a08473d6..55be26e8a 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -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; } } } diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index c5a691930..93fcb56c6 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -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, + /// 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(), diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index d34010716..252fdc7ef 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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, - /// 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::() - { - 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> { }) } -fn normalize_optional_voice_input_command(value: Option<&str>) -> Option { - value.and_then(normalize_voice_input_command) -} - -fn normalize_voice_input_command(value: &str) -> Option { - 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(); diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index 53e641fb5..aa4550be8 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -391,7 +391,10 @@ pub async fn update_with_registry( network: &NetworkPolicy, registry_url: &str, ) -> Result { - 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 { + 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 { 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"); diff --git a/crates/tui/src/theme_qa_audit.rs b/crates/tui/src/theme_qa_audit.rs new file mode 100644 index 000000000..a19da7e30 --- /dev/null +++ b/crates/tui/src/theme_qa_audit.rs @@ -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" + ); + } +} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 4e5e78c00..66d93cc6f 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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 { @@ -1074,8 +1062,6 @@ pub struct App { pub sticky_status: Option, /// Last status text already promoted from `status_message` into toast state. pub last_status_message_seen: Option, - /// Active external speech-to-text helper launched from the command palette. - pub voice_input_state: Option, 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. @@ -1794,7 +1780,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, @@ -2195,6 +2180,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 @@ -2254,6 +2242,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 { + 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 diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index 68c367f29..73a253aa8 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -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, diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 4af59bcf4..f1e5bb04b 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -55,14 +55,6 @@ pub fn build_entries( ) -> Vec { 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( diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 3b0c3ebd6..0269c8dea 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -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 { - 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> { 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 { diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 0ad254678..e9c92e3a0 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -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> = 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(), ] ); diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index d36b81cdd..34b70ee27 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -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; diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index f6a806393..1cfbad951 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -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, } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 25e1fdb11..6de9123ac 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -105,7 +105,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::{ @@ -192,10 +192,6 @@ enum TranslationEvent { }, } -#[derive(Debug)] -enum VoiceInputEvent { - Finished { result: Result }, -} // 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 @@ -867,8 +863,6 @@ async fn run_event_loop( let mut current_streaming_text = String::new(); let (translation_tx, mut translation_rx) = tokio::sync::mpsc::unbounded_channel::(); - let (voice_input_tx, mut voice_input_rx) = - tokio::sync::mpsc::unbounded_channel::(); let mut pending_translations = 0usize; let mut pending_thinking_translations = 0usize; let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone()); @@ -988,8 +982,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(); @@ -2004,7 +1996,6 @@ async fn run_event_loop( &task_manager, &mut engine_handle, &mut web_config_session, - voice_input_tx.clone(), events, ) .await? @@ -2017,10 +2008,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)) { @@ -2114,11 +2102,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)) @@ -2303,7 +2287,6 @@ async fn run_event_loop( &task_manager, &mut engine_handle, &mut web_config_session, - voice_input_tx.clone(), events, ) .await? @@ -2685,7 +2668,6 @@ async fn run_event_loop( &task_manager, &mut engine_handle, &mut web_config_session, - voice_input_tx.clone(), events, ) .await? @@ -5288,82 +5270,6 @@ async fn execute_command_input( .await } -fn start_voice_input( - app: &mut App, - voice_input_tx: tokio::sync::mpsc::UnboundedSender, -) { - 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, -) { - 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, @@ -5977,7 +5883,6 @@ async fn handle_view_events( task_manager: &SharedTaskManager, engine_handle: &mut EngineHandle, web_config_session: &mut Option, - voice_input_tx: tokio::sync::mpsc::UnboundedSender, events: Vec, ) -> Result { for event in events { @@ -6008,9 +5913,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); @@ -6607,6 +6509,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()); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 6b983961d..2a254c672 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1286,6 +1286,7 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { cost: crate::session_manager::SessionCostSnapshot::default(), parent_session_id: None, forked_from_message_count: None, + cumulative_turn_secs: 0, }, messages, system_prompt: None, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index c50c83c0b..ed2e84f24 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -45,7 +45,6 @@ pub enum CommandPaletteAction { ExecuteCommand { command: String }, InsertText { text: String }, OpenTextPager { title: String, content: String }, - VoiceInput, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -746,23 +745,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(), @@ -1146,8 +1128,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", _ => "", } @@ -2201,8 +2181,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")); diff --git a/crates/tui/src/tui/voice_input.rs b/crates/tui/src/tui/voice_input.rs deleted file mode 100644 index 04f57e8aa..000000000 --- a/crates/tui/src/tui/voice_input.rs +++ /dev/null @@ -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)> { - 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 { - 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 { - 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); - } -} diff --git a/crates/tui/tests/palette_audit.rs b/crates/tui/tests/palette_audit.rs index f8cc28051..e86c207a6 100644 --- a/crates/tui/tests/palette_audit.rs +++ b/crates/tui/tests/palette_audit.rs @@ -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"); - - 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" +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_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] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 858bac7ea..131762657 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -250,8 +250,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 +370,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: -- Windows speech recognition APIs: - -- Huawei ML Kit ASR codelab: - - 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 diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index 3f6c3cb2c..c8a402f80 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -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", diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index bbca3bb64..b99f9a15b 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -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", diff --git a/web/app/[locale]/faq/page.tsx b/web/app/[locale]/faq/page.tsx index 4a3af70cf..f216552a2 100644 --- a/web/app/[locale]/faq/page.tsx +++ b/web/app/[locale]/faq/page.tsx @@ -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 /goal command. - The current /goal is a simple goal-setter. The full Goal mode (autonomous multi-turn task execution with checkpoint/resume) is planned but not yet implemented. + /goal 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 #891. ),