feat: 0.8.68 cleanup, palette migration, double-enter steering, perf
Cleanup & dead code removal: - Remove reqwest multipart + brotli features (unused, compile-time cost) - Delete deprecated allowed_tools() method (deprecated since v0.6.6) - Delete orphaned smoothness.md (unreferenced) - Remove unused tools_file config field + example - Remove dead removed_messages field from CompactionResult - Remove todo_* alias scaffolding (constants, is_compat_alias, checklist_replacement_tool_name, .new() constructors, alias tests) Palette brand migration (DEEPSEEK_* → WHALE_*): - Add WHALE_INFO/BG/PANEL/ERROR Color constants to tokens.rs - Remove all DEEPSEEK_* palette aliases (RGB + Color) - Migrate ~216 palette::DEEPSEEK_* references across 40+ files - CRITICAL: DEEPSEEK_* env var names in main.rs left untouched Performance: - B8.1: Wrap blocking cmd.output() in spawn_blocking for git_history and review tools (async cascade: resolve_diff_target, gh_pr_diff, resolve_review_source all made async) Double-Enter for Steer: - Add last_enter_instant field to App struct - Add enter_with_double_tap() method (500ms double-tap window) - Change decide_submit_disposition() busy arm to Queue (was Steer) - Wire up in ui.rs submit handler - Update composer hint text to mention double-Enter steering Previous agent's perf work already in tree (verified): - B1.1 mimalloc for CLI, B1.6 pdf-extract feature-gated - B9.1 to_vec, B9.2 compact JSON, B8.2 spawn_blocking in tasks.rs Verified: cargo check, cargo clippy -D warnings, 8/8 double-enter tests
This commit is contained in:
Generated
+1
-54
@@ -61,21 +61,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocative"
|
||||
version = "0.3.4"
|
||||
@@ -570,27 +555,6 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -849,6 +813,7 @@ dependencies = [
|
||||
"codewhale-state",
|
||||
"dirs",
|
||||
"libc",
|
||||
"mimalloc",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
@@ -1140,7 +1105,6 @@ version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
@@ -3273,16 +3237,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -4474,7 +4428,6 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
@@ -6156,12 +6109,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# CodeWhale 0.8.68 Handoff — Finish the Landing
|
||||
|
||||
> **Companion document:** [`opportunities.md`](../../opportunities.md) — the
|
||||
> verified cleanup & optimization catalog. Every item below cross-references
|
||||
> it. Read both together; this file is the *what to do next*, opportunities.md
|
||||
> is the *what's real*.
|
||||
|
||||
## Branch & Location
|
||||
- **Worktree:** `/Users/hunter/Desktop/Harnesses/CW/.cw-worktrees/v0867-pr4047`
|
||||
- **Branch:** `work/v0.9.0-cutover` (7 ahead of origin/main, 0 behind)
|
||||
- **PR:** #4099 open to main
|
||||
- **Remote:** `Hmbown/CodeWhale`
|
||||
|
||||
## Current State (verified)
|
||||
- `cargo check --workspace` ✅ PASSES
|
||||
- `cargo clippy --workspace --all-features -- -D warnings` ✅ PASSES
|
||||
- 16 files changed in working tree (unstaged, not committed yet)
|
||||
|
||||
## ⛔ DO NOT DELETE — Verified Active
|
||||
|
||||
These six modules were flagged as "dead code" by the original scout audit.
|
||||
**They are all actively imported and used.** Previous agents deleted them and
|
||||
caused 19+ compile errors. Do not touch them under any circumstances.
|
||||
|
||||
| Module | Active consumers |
|
||||
|--------|-----------------|
|
||||
| `tui/src/memory.rs` | `prompts.rs`, `engine.rs`, `context_report.rs`, `ui.rs` |
|
||||
| `tui/src/context_budget.rs` | `core/engine/context.rs:9`, `engine/tests.rs` |
|
||||
| `tui/src/model_registry.rs` | `tui/model_picker.rs:737`, `model_profile.rs:149` |
|
||||
| `tui/src/prompt_zones.rs` | `core/session.rs:8`, `core/engine/turn_loop.rs:10` |
|
||||
| `tui/src/tools/remember.rs` | `tools/registry.rs:890`, `tools/mod.rs:41` |
|
||||
| `config/src/route/` (entire dir) | `catalog.rs:38`, `models_dev.rs:20`, `pricing.rs:25` |
|
||||
|
||||
---
|
||||
|
||||
## What's Actually Done (verified in working tree)
|
||||
|
||||
| Item | Status | Evidence |
|
||||
|------|--------|----------|
|
||||
| B1.1 — mimalloc for CLI | ✅ Done | `crates/cli/Cargo.toml:38` + `crates/cli/src/main.rs:1-2` |
|
||||
| B1.6 — pdf-extract feature-gated | ✅ Done | `Cargo.toml:17` (`pdf = ["dep:pdf-extract"]`), `web_run.rs` `#[cfg(feature = "pdf")]` |
|
||||
| B9.1 — `to_vec` in app-server | ✅ Done | `app-server/src/lib.rs:293,309,325,336,1155` |
|
||||
| B9.2 — compact JSON tool output | ✅ Done | `tools/src/lib.rs`, `tool_execution.rs`, `registry.rs` — no `to_string_pretty` in these paths |
|
||||
| B8.2 — spawn_blocking in tasks.rs | ✅ Done | `tasks.rs:769,848,935` |
|
||||
| file.rs perf tweaks | ✅ Done | `tools/file.rs` modified |
|
||||
| Palette migration (partial) | ✅ 3 files | `logging.rs`, `remote_setup/mod.rs`, `palette_audit.rs` — done |
|
||||
| Double-enter tests | ✅ Written | `tui/app/tests.rs` — `enter_with_double_tap()`, `last_enter_instant` |
|
||||
|
||||
## What the PREVIOUS Handoff Claimed Was Done — But ISN'T
|
||||
|
||||
| Claim | Reality |
|
||||
|-------|---------|
|
||||
| B1.4 — reqwest multipart removed | ❌ **Still in Cargo.toml**: `features = [..., "multipart", ...]` |
|
||||
| B1.5 — reqwest brotli removed | ❌ **Still in Cargo.toml**: `features = [..., "brotli"]` |
|
||||
| allowed_tools() deleted | ❌ **Still present** at `subagent/mod.rs:448` |
|
||||
| smoothness.md deleted | ❌ **Still present** (29,613 bytes) |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Work (in priority order, compile after each)
|
||||
|
||||
### 1. Remove reqwest `multipart` + `brotli` features
|
||||
- **File:** `crates/tui/Cargo.toml` — remove `"multipart"` and `"brotli"` from
|
||||
the `reqwest` features array. Zero uses of `reqwest::multipart` exist. `gzip`
|
||||
alone is sufficient.
|
||||
- **Risk:** None. Verify `cargo check` after.
|
||||
|
||||
### 2. Palette brand migration (`DEEPSEEK_*` → `WHALE_*`)
|
||||
- See the 🎨 section in `opportunities.md` for full details.
|
||||
- **⚠️ main.rs is NOT part of this.** Its 98 `DEEPSEEK_*` refs are **environment
|
||||
variable names** (`DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`, etc.). Renaming
|
||||
them breaks every user's config. Leave them alone.
|
||||
- The actual migration targets ~216 `palette::DEEPSEEK_*` color references
|
||||
across 40+ files in `crates/tui/src/`.
|
||||
- **Missing prerequisite:** No `WHALE_INFO`/`WHALE_BG`/`WHALE_PANEL`/`WHALE_ERROR`
|
||||
`Color` constants exist (only `_RGB` tuples). Add them to `tokens.rs` first.
|
||||
- **Plan:** Add Color constants → `sed` replace `_RGB` variants first, then bare
|
||||
names → add `#[deprecated]` to now-unused aliases → compile.
|
||||
|
||||
### 3. Delete `allowed_tools()` method
|
||||
- **File:** `crates/tui/src/tools/subagent/mod.rs:448` — delete the method and
|
||||
its match arms (deprecated since v0.6.6). Keep the struct fields at lines 1261
|
||||
and 1363.
|
||||
|
||||
### 4. Delete `smoothness.md`
|
||||
- **File:** `smoothness.md` (29,613 bytes) — unreferenced doc file.
|
||||
|
||||
### 5. Remove `tools_file` config field
|
||||
- `config.rs:1881` — remove `pub tools_file: Option<String>` field
|
||||
- `config.rs:5513` — remove merge line
|
||||
- `config.example.toml:162` — remove commented `# tools_file` line
|
||||
|
||||
### 6. Remove `removed_messages` dead field
|
||||
- `compaction.rs:918` — remove field with `#[allow(dead_code)]` + `TODO(v0.8.71)`
|
||||
comment. Dead in production.
|
||||
|
||||
### 7. Remove `todo_*` alias scaffolding (v0.9.0 gate)
|
||||
- `tools/todo.rs:177-178` — remove `TODO_ALIAS_FIRST_DEPRECATED_VERSION`,
|
||||
`TODO_ALIAS_REMOVAL_VERSION` constants and `is_compat_alias()` (line 182)
|
||||
- `tools/registry.rs` — remove `todo_*` registrations (keep `checklist_*`)
|
||||
|
||||
### 8. B8.1 — spawn_blocking for blocking cmd.output()
|
||||
- `tools/git_history.rs:485` — wrap `cmd.output()` in `tokio::task::spawn_blocking`
|
||||
- `tools/review.rs:645,672` — same
|
||||
|
||||
### 9. B5.2 — clippy await_holding_lock audit
|
||||
- Run `cargo clippy --workspace --all-features -- -W clippy::await_holding_lock -W clippy::await_holding_refcell_ref`
|
||||
- Fix any warnings (guards held across `.await`).
|
||||
|
||||
### 10. Double-Enter for Steer (feature implementation)
|
||||
Tests exist in `tui/app/tests.rs` but the feature code does not:
|
||||
1. Add `last_enter_instant: Option<Instant>` field to `App` struct in `tui/app.rs`
|
||||
2. Add `enter_with_double_tap(&mut self) -> Option<SubmitDisposition>` method
|
||||
3. In `decide_submit_disposition()`, change busy-waiting arm to return `Queue`
|
||||
instead of `Steer`
|
||||
4. Wire up in `tui/ui.rs` Enter handler
|
||||
5. Update composer hint text in `tui/widgets/mod.rs`
|
||||
|
||||
---
|
||||
|
||||
## Final Steps
|
||||
1. Commit all changes with descriptive message
|
||||
2. Push to `work/v0.9.0-cutover`
|
||||
3. Verify PR #4099 CI passes (fix any failures)
|
||||
4. Run `cargo test --workspace --locked` to verify tests pass
|
||||
5. Build release binary: `cargo build --release -p codewhale-tui`
|
||||
|
||||
## Guidelines
|
||||
- Work file-by-file, compile after each change
|
||||
- NEVER delete a file without first grepping for ALL imports of that module
|
||||
- For dead code removal: prefer adding `#[allow(dead_code)]` over deleting files
|
||||
with active imports
|
||||
- Commit in logical chunks, not one giant commit
|
||||
@@ -158,9 +158,6 @@ memory_path = "~/.codewhale/memory.md"
|
||||
# enabled = true # turn the feature on (default: false)
|
||||
# Override the env-var equivalent: `DEEPSEEK_MEMORY=on`
|
||||
|
||||
# Parsed but currently unused (reserved for future versions):
|
||||
# tools_file = "./tools.json"
|
||||
|
||||
# Xiaomi MiMo speech/TTS defaults. Also configurable with
|
||||
# XIAOMI_MIMO_SPEECH_OUTPUT_DIR / MIMO_SPEECH_OUTPUT_DIR.
|
||||
[speech]
|
||||
|
||||
@@ -290,7 +290,7 @@ pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
None,
|
||||
JsonRpcError::parse_error(format!("invalid json: {err}")),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(&serde_json::to_vec(&response)?).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
@@ -306,7 +306,7 @@ pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
request.id,
|
||||
JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
|
||||
);
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(&serde_json::to_vec(&response)?).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
continue;
|
||||
@@ -322,7 +322,7 @@ pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
{
|
||||
Ok(dispatch) => {
|
||||
let encoded = jsonrpc_result(request.id, dispatch.result);
|
||||
writer.write_all(encoded.to_string().as_bytes()).await?;
|
||||
writer.write_all(&serde_json::to_vec(&encoded)?).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
if dispatch.should_exit {
|
||||
@@ -333,7 +333,7 @@ pub async fn run_stdio(config_path: Option<PathBuf>) -> Result<()> {
|
||||
Err(err) => jsonrpc_error(request.id, err),
|
||||
};
|
||||
|
||||
writer.write_all(response.to_string().as_bytes()).await?;
|
||||
writer.write_all(&serde_json::to_vec(&response)?).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
@@ -1152,7 +1152,7 @@ fn turn_terminal_status(payload: &Value) -> TurnTerminalStatus {
|
||||
}
|
||||
|
||||
async fn emit_stdio_event<W: AsyncWrite + Unpin>(writer: &mut W, event: Value) -> Result<()> {
|
||||
writer.write_all(event.to_string().as_bytes()).await?;
|
||||
writer.write_all(&serde_json::to_vec(&event)?).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -35,6 +35,7 @@ reqwest = { workspace = true, features = ["blocking"] }
|
||||
rustls.workspace = true
|
||||
semver.workspace = true
|
||||
tokio.workspace = true
|
||||
mimalloc.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Reset SIGPIPE to SIG_DFL so piping codewhale output into a command that
|
||||
// exits early (e.g. `codewhale doctor | head`) terminates the process
|
||||
|
||||
@@ -140,7 +140,7 @@ impl ToolResult {
|
||||
/// Create a successful result from JSON.
|
||||
pub fn json<T: Serialize>(value: &T) -> std::result::Result<Self, serde_json::Error> {
|
||||
Ok(Self {
|
||||
content: serde_json::to_string_pretty(value)?,
|
||||
content: serde_json::to_string(value)?,
|
||||
success: true,
|
||||
metadata: None,
|
||||
})
|
||||
|
||||
@@ -8,12 +8,13 @@ description = "Terminal UI for open-source and open-weight coding models"
|
||||
default-run = "codewhale-tui"
|
||||
|
||||
[features]
|
||||
default = ["tui", "json", "toml"]
|
||||
default = ["tui", "json", "toml", "pdf"]
|
||||
tui = ["dep:schemaui", "schemaui/tui", "json", "toml"]
|
||||
web = ["dep:schemaui", "schemaui/web", "json", "toml"]
|
||||
json = ["schemaui/json"]
|
||||
toml = ["schemaui/toml"]
|
||||
long-running-tests = []
|
||||
pdf = ["dep:pdf-extract"]
|
||||
|
||||
[[bin]]
|
||||
name = "codewhale-tui"
|
||||
@@ -44,7 +45,7 @@ futures-util = "0.3.31"
|
||||
oauth2 = "5"
|
||||
ratatui = "0.30"
|
||||
regex = "1.11"
|
||||
reqwest = { workspace = true, features = ["blocking", "stream", "multipart", "form", "http2", "gzip", "brotli"] }
|
||||
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2", "gzip"] }
|
||||
rmcp = { version = "1.7.0", default-features = false, features = ["auth", "client"] }
|
||||
rustls.workspace = true
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
@@ -77,7 +78,7 @@ ignore = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
lru = "0.18"
|
||||
parking_lot = "0.12"
|
||||
pdf-extract = "0.10"
|
||||
pdf-extract = { version = "0.10", optional = true }
|
||||
tar = "0.4"
|
||||
flate2 = "1.1"
|
||||
sha2.workspace = true
|
||||
|
||||
@@ -914,10 +914,6 @@ pub struct CompactionResult {
|
||||
pub messages: Vec<Message>,
|
||||
/// Summary system prompt
|
||||
pub summary_prompt: Option<SystemPrompt>,
|
||||
/// Messages that were removed from the active window
|
||||
// TODO(v0.8.71): kept for replay compatibility; dead in production, see #3490
|
||||
#[allow(dead_code)]
|
||||
pub removed_messages: Vec<Message>,
|
||||
/// Number of retries used before success
|
||||
pub retries_used: u32,
|
||||
}
|
||||
@@ -1008,7 +1004,6 @@ pub async fn compact_messages_safe(
|
||||
return Ok(CompactionResult {
|
||||
messages: sanitize_retained_messages(pruned_messages),
|
||||
summary_prompt: None,
|
||||
removed_messages: Vec::new(),
|
||||
retries_used: 0,
|
||||
});
|
||||
}
|
||||
@@ -1041,7 +1036,6 @@ pub async fn compact_messages_safe(
|
||||
return Ok(CompactionResult {
|
||||
messages: sanitize_retained_messages(msgs),
|
||||
summary_prompt: prompt,
|
||||
removed_messages: Vec::new(),
|
||||
retries_used: attempt,
|
||||
});
|
||||
}
|
||||
@@ -2822,13 +2816,11 @@ mod tests {
|
||||
let result = CompactionResult {
|
||||
messages: vec![],
|
||||
summary_prompt: None,
|
||||
removed_messages: vec![],
|
||||
retries_used: 2,
|
||||
};
|
||||
|
||||
assert_eq!(result.retries_used, 2);
|
||||
assert!(result.messages.is_empty());
|
||||
assert!(result.removed_messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1878,9 +1878,8 @@ pub struct Config {
|
||||
/// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
|
||||
/// Defaults to `"max"` at runtime if unset.
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub tools_file: Option<String>,
|
||||
/// Native tool catalog controls. `tools_file` is the legacy external
|
||||
/// schema path; this table controls built-in tool loading policy.
|
||||
/// Native tool catalog controls. This table controls built-in
|
||||
/// tool loading policy.
|
||||
#[serde(default)]
|
||||
pub tools: Option<ToolsConfig>,
|
||||
pub skills_dir: Option<String>,
|
||||
@@ -5510,7 +5509,6 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
default_text_model: override_cfg.default_text_model.or(base.default_text_model),
|
||||
auth_mode: override_cfg.auth_mode.or(base.auth_mode),
|
||||
reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),
|
||||
tools_file: override_cfg.tools_file.or(base.tools_file),
|
||||
tools: override_cfg.tools.or(base.tools),
|
||||
skills_dir: override_cfg.skills_dir.or(base.skills_dir),
|
||||
mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),
|
||||
|
||||
@@ -172,7 +172,7 @@ impl Engine {
|
||||
.call_tool(name, input)
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
|
||||
let content = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
|
||||
let content = serde_json::to_string(&result).unwrap_or_else(|_| result.to_string());
|
||||
Ok(ToolResult::success(content))
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ impl Theme {
|
||||
section_borders: Borders::ALL,
|
||||
section_border_type: BorderType::Plain,
|
||||
section_border_color: palette::BORDER_COLOR,
|
||||
section_bg: palette::DEEPSEEK_INK,
|
||||
section_bg: palette::WHALE_BG,
|
||||
section_title_color: palette::WHALE_ACCENT_PRIMARY,
|
||||
// Horizontal padding only. `Padding::uniform(1)` ate two rows of
|
||||
// each sidebar panel — for compact terminals where Work/Tasks/Agents
|
||||
@@ -104,7 +104,7 @@ impl Theme {
|
||||
tool_label_color: palette::LIGHT_TEXT_HINT,
|
||||
tool_running_accent: palette::WHALE_ACCENT_PRIMARY,
|
||||
tool_success_accent: palette::LIGHT_TEXT_HINT,
|
||||
tool_failed_accent: palette::DEEPSEEK_RED,
|
||||
tool_failed_accent: palette::WHALE_ERROR,
|
||||
plan_progress_color: palette::WHALE_ACCENT_PRIMARY,
|
||||
plan_summary_color: palette::LIGHT_TEXT_MUTED,
|
||||
plan_explanation_color: palette::LIGHT_TEXT_HINT,
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
let theme = Theme::dark();
|
||||
assert_eq!(theme.variant, Variant::Dark);
|
||||
assert_eq!(theme.section_border_color, palette::BORDER_COLOR);
|
||||
assert_eq!(theme.section_bg, palette::DEEPSEEK_INK);
|
||||
assert_eq!(theme.section_bg, palette::WHALE_BG);
|
||||
assert_eq!(theme.section_title_color, palette::WHALE_ACCENT_PRIMARY);
|
||||
assert_eq!(theme.tool_title_color, palette::TEXT_SOFT);
|
||||
assert_eq!(theme.tool_value_color, palette::TEXT_MUTED);
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn is_verbose() -> bool {
|
||||
/// Emit a verbose info message (no-op when verbosity is disabled).
|
||||
pub fn info(message: impl AsRef<str>) {
|
||||
if is_verbose() {
|
||||
let (r, g, b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (r, g, b) = palette::WHALE_INFO_RGB;
|
||||
eprintln!("{} {}", "info".truecolor(r, g, b).bold(), message.as_ref());
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ pub fn info(message: impl AsRef<str>) {
|
||||
/// Emit a verbose warning message (no-op when verbosity is disabled).
|
||||
pub fn warn(message: impl AsRef<str>) {
|
||||
if is_verbose() {
|
||||
let (r, g, b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (r, g, b) = palette::WHALE_INFO_RGB;
|
||||
eprintln!("{} {}", "warn".truecolor(r, g, b).bold(), message.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
+13
-13
@@ -2276,8 +2276,8 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
use crate::palette;
|
||||
use colored::Colorize;
|
||||
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
|
||||
|
||||
let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
|
||||
let run_mcp = args.mcp || args.all || !any_explicit;
|
||||
@@ -2493,9 +2493,9 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
use crate::palette;
|
||||
use colored::Colorize;
|
||||
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
|
||||
let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
@@ -2704,9 +2704,9 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
use colored::Colorize;
|
||||
|
||||
let (accent_r, accent_g, accent_b) = palette::WHALE_ACCENT_PRIMARY_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
|
||||
let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
@@ -5220,8 +5220,8 @@ fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
|
||||
use session_manager::{SessionManager, format_session_line};
|
||||
|
||||
let (accent_r, accent_g, accent_b) = palette::WHALE_ACCENT_PRIMARY_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
|
||||
|
||||
let manager = SessionManager::default_location()?;
|
||||
|
||||
@@ -5287,9 +5287,9 @@ fn init_project() -> Result<()> {
|
||||
use colored::Colorize;
|
||||
use project_context::create_default_agents_md;
|
||||
|
||||
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (red_r, red_g, red_b) = palette::DEEPSEEK_RED_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
|
||||
let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
|
||||
|
||||
let workspace = std::env::current_dir()?;
|
||||
let agents_path = workspace.join("AGENTS.md");
|
||||
|
||||
@@ -37,7 +37,7 @@ fn adapt_fg_for_light_palette(color: Color) -> Color {
|
||||
LIGHT_TEXT_SOFT
|
||||
} else if color == BORDER_COLOR {
|
||||
LIGHT_BORDER
|
||||
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
|
||||
} else if color == TEXT_ACCENT || color == WHALE_INFO || color == ACCENT_TOOL_LIVE {
|
||||
WHALE_ACCENT_PRIMARY
|
||||
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
|
||||
Color::Rgb(146, 64, 14)
|
||||
@@ -53,9 +53,9 @@ fn adapt_fg_for_light_palette(color: Color) -> Color {
|
||||
}
|
||||
|
||||
fn adapt_bg_for_light_palette(color: Color) -> Color {
|
||||
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
|
||||
if color == WHALE_BG || color == BACKGROUND_DARK {
|
||||
LIGHT_SURFACE
|
||||
} else if color == DEEPSEEK_SLATE
|
||||
} else if color == WHALE_PANEL
|
||||
|| color == COMPOSER_BG
|
||||
|| color == SURFACE_PANEL
|
||||
|| color == SURFACE_TOOL
|
||||
@@ -94,7 +94,7 @@ fn adapt_fg_for_solarized_light_palette(color: Color) -> Color {
|
||||
SOLARIZED_TEXT_SOFT
|
||||
} else if color == BORDER_COLOR {
|
||||
SOLARIZED_BORDER
|
||||
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
|
||||
} else if color == TEXT_ACCENT || color == WHALE_INFO || color == ACCENT_TOOL_LIVE {
|
||||
SOLARIZED_BLUE
|
||||
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
|
||||
SOLARIZED_ORANGE
|
||||
@@ -108,9 +108,9 @@ fn adapt_fg_for_solarized_light_palette(color: Color) -> Color {
|
||||
}
|
||||
|
||||
fn adapt_bg_for_solarized_light_palette(color: Color) -> Color {
|
||||
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
|
||||
if color == WHALE_BG || color == BACKGROUND_DARK {
|
||||
SOLARIZED_SURFACE
|
||||
} else if color == DEEPSEEK_SLATE
|
||||
} else if color == WHALE_PANEL
|
||||
|| color == COMPOSER_BG
|
||||
|| color == SURFACE_PANEL
|
||||
|| color == SURFACE_TOOL
|
||||
@@ -139,7 +139,7 @@ fn adapt_bg_for_solarized_light_palette(color: Color) -> Color {
|
||||
// === Community-theme remap ===
|
||||
//
|
||||
// The vast majority of render sites in this crate reach for `palette::TEXT_*`,
|
||||
// `palette::DEEPSEEK_INK`, `palette::BORDER_COLOR`, etc. directly rather than
|
||||
// `palette::WHALE_BG`, `palette::BORDER_COLOR`, etc. directly rather than
|
||||
// looking up `app.ui_theme`. To make community theme presets (Catppuccin,
|
||||
// Tokyo Night, …) actually move the needle visually we intercept colors at
|
||||
// the backend layer (see `tui::color_compat::ColorCompatBackend`) and remap
|
||||
@@ -220,7 +220,7 @@ pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
|
||||
ui.text_soft
|
||||
} else if color == BORDER_COLOR {
|
||||
ui.border
|
||||
} else if color == TEXT_ACCENT || color == DEEPSEEK_SKY || color == ACCENT_TOOL_LIVE {
|
||||
} else if color == TEXT_ACCENT || color == WHALE_INFO || color == ACCENT_TOOL_LIVE {
|
||||
ui.status_working
|
||||
} else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
|
||||
if theme == ThemeId::Matrix {
|
||||
@@ -232,7 +232,7 @@ pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
|
||||
ui.mode_yolo
|
||||
} else if color == STATUS_WARNING {
|
||||
ui.warning
|
||||
} else if color == STATUS_ERROR || color == DEEPSEEK_RED {
|
||||
} else if color == STATUS_ERROR || color == WHALE_ERROR {
|
||||
ui.error_fg
|
||||
} else if color == DIFF_ADDED || color == USER_BODY {
|
||||
theme_green(ui)
|
||||
@@ -251,9 +251,9 @@ pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
|
||||
return color;
|
||||
}
|
||||
|
||||
if color == DEEPSEEK_INK || color == BACKGROUND_DARK {
|
||||
if color == WHALE_BG || color == BACKGROUND_DARK {
|
||||
ui.surface_bg
|
||||
} else if color == DEEPSEEK_SLATE
|
||||
} else if color == WHALE_PANEL
|
||||
|| color == COMPOSER_BG
|
||||
|| color == SURFACE_PANEL
|
||||
|| color == SURFACE_TOOL
|
||||
@@ -289,7 +289,7 @@ fn adapt_fg_for_grayscale_palette(color: Color) -> Color {
|
||||
|| color == SELECTION_TEXT
|
||||
|| color == LIGHT_TEXT_BODY
|
||||
|| color == Color::White
|
||||
|| color == DEEPSEEK_RED
|
||||
|| color == WHALE_ERROR
|
||||
|| color == STATUS_ERROR
|
||||
|| color == MODE_YOLO
|
||||
{
|
||||
@@ -298,7 +298,7 @@ fn adapt_fg_for_grayscale_palette(color: Color) -> Color {
|
||||
|| color == TEXT_TOOL_OUTPUT
|
||||
|| color == LIGHT_TEXT_SOFT
|
||||
|| color == TEXT_ACCENT
|
||||
|| color == DEEPSEEK_SKY
|
||||
|| color == WHALE_INFO
|
||||
|| color == WHALE_ACCENT_PRIMARY
|
||||
|| color == ACCENT_TOOL_LIVE
|
||||
|| color == STATUS_SUCCESS
|
||||
@@ -353,9 +353,9 @@ fn adapt_bg_for_grayscale_palette(color: Color) -> Color {
|
||||
if color == Color::Reset {
|
||||
return color;
|
||||
}
|
||||
if color == DEEPSEEK_INK || color == BACKGROUND_DARK || color == LIGHT_SURFACE {
|
||||
if color == WHALE_BG || color == BACKGROUND_DARK || color == LIGHT_SURFACE {
|
||||
GRAYSCALE_SURFACE
|
||||
} else if color == DEEPSEEK_SLATE
|
||||
} else if color == WHALE_PANEL
|
||||
|| color == COMPOSER_BG
|
||||
|| color == SURFACE_PANEL
|
||||
|| color == SURFACE_TOOL
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::themes::{
|
||||
theme_label_for_mode, ui_theme_from_settings,
|
||||
};
|
||||
use super::tokens::{
|
||||
ACCENT_REASONING_LIVE, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY, DEEPSEEK_SLATE, DIFF_ADDED,
|
||||
ACCENT_REASONING_LIVE, WHALE_BG, WHALE_ERROR, WHALE_INFO, WHALE_PANEL, DIFF_ADDED,
|
||||
DIFF_ADDED_BG, GRAYSCALE_BORDER, GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING,
|
||||
GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, LIGHT_BORDER,
|
||||
LIGHT_ELEVATED, LIGHT_PANEL, LIGHT_REASONING, LIGHT_SURFACE, LIGHT_TEXT_BODY,
|
||||
@@ -126,7 +126,7 @@ fn terminal_theme_resets_surfaces_and_remaps_direct_palette_constants() {
|
||||
assert_eq!(TERMINAL_UI_THEME.text_body, Color::Reset);
|
||||
|
||||
assert_eq!(
|
||||
adapt_bg_for_theme(DEEPSEEK_INK, ThemeId::Terminal, &TERMINAL_UI_THEME),
|
||||
adapt_bg_for_theme(WHALE_BG, ThemeId::Terminal, &TERMINAL_UI_THEME),
|
||||
Color::Reset
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -225,11 +225,11 @@ fn hex_rgb_color_parser_accepts_hashless_and_normalizes() {
|
||||
#[test]
|
||||
fn light_palette_maps_dark_surfaces_and_text() {
|
||||
assert_eq!(
|
||||
adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Light),
|
||||
adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::Light),
|
||||
LIGHT_SURFACE
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Light),
|
||||
adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::Light),
|
||||
LIGHT_PANEL
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -245,11 +245,11 @@ fn light_palette_maps_dark_surfaces_and_text() {
|
||||
#[test]
|
||||
fn solarized_light_palette_maps_dark_surfaces_and_text_to_solarized_roles() {
|
||||
assert_eq!(
|
||||
adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::SolarizedLight),
|
||||
adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::SolarizedLight),
|
||||
SOLARIZED_SURFACE
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::SolarizedLight),
|
||||
adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::SolarizedLight),
|
||||
SOLARIZED_PANEL
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -265,11 +265,11 @@ fn solarized_light_palette_maps_dark_surfaces_and_text_to_solarized_roles() {
|
||||
#[test]
|
||||
fn grayscale_palette_maps_brand_hues_to_neutral_roles() {
|
||||
assert_eq!(
|
||||
adapt_bg_for_palette_mode(DEEPSEEK_INK, PaletteMode::Grayscale),
|
||||
adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::Grayscale),
|
||||
GRAYSCALE_SURFACE
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_bg_for_palette_mode(DEEPSEEK_SLATE, PaletteMode::Grayscale),
|
||||
adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::Grayscale),
|
||||
GRAYSCALE_PANEL
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -277,11 +277,11 @@ fn grayscale_palette_maps_brand_hues_to_neutral_roles() {
|
||||
GRAYSCALE_REASONING
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_fg_for_palette_mode(DEEPSEEK_SKY, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
|
||||
adapt_fg_for_palette_mode(WHALE_INFO, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
|
||||
GRAYSCALE_TEXT_SOFT
|
||||
);
|
||||
assert_eq!(
|
||||
adapt_fg_for_palette_mode(DEEPSEEK_RED, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
|
||||
adapt_fg_for_palette_mode(WHALE_ERROR, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
|
||||
GRAYSCALE_TEXT_BODY
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -342,13 +342,13 @@ fn adapt_bg_maps_rgb_to_indexed_on_ansi256() {
|
||||
fn adapt_color_drops_to_named_on_ansi16() {
|
||||
// Sky: blue-dominant and bright → LightBlue, not terminal cyan.
|
||||
assert_eq!(
|
||||
adapt_color(DEEPSEEK_SKY, ColorDepth::Ansi16),
|
||||
adapt_color(WHALE_INFO, ColorDepth::Ansi16),
|
||||
Color::LightBlue
|
||||
);
|
||||
// Rose Red is intentionally bright enough to use the terminal's
|
||||
// bright red slot.
|
||||
assert_eq!(
|
||||
adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16),
|
||||
adapt_color(WHALE_ERROR, ColorDepth::Ansi16),
|
||||
Color::LightRed
|
||||
);
|
||||
}
|
||||
@@ -462,5 +462,5 @@ fn color_depth_detect_is_safe_without_env() {
|
||||
// Don't try to pin the result — env may be anything in CI. Just
|
||||
// exercise the path so a panic would surface.
|
||||
let _ = ColorDepth::detect();
|
||||
let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect());
|
||||
let _ = adapt_color(WHALE_BG, ColorDepth::detect());
|
||||
}
|
||||
|
||||
@@ -61,13 +61,13 @@ pub struct UiTheme {
|
||||
pub const UI_THEME: UiTheme = UiTheme {
|
||||
name: "whale",
|
||||
mode: PaletteMode::Dark,
|
||||
surface_bg: DEEPSEEK_INK,
|
||||
panel_bg: DEEPSEEK_SLATE,
|
||||
surface_bg: WHALE_BG,
|
||||
panel_bg: WHALE_PANEL,
|
||||
elevated_bg: SURFACE_ELEVATED,
|
||||
composer_bg: DEEPSEEK_SLATE,
|
||||
composer_bg: WHALE_PANEL,
|
||||
selection_bg: SELECTION_BG,
|
||||
header_bg: DEEPSEEK_INK,
|
||||
footer_bg: DEEPSEEK_INK,
|
||||
header_bg: WHALE_BG,
|
||||
footer_bg: WHALE_BG,
|
||||
text_dim: TEXT_DIM,
|
||||
text_hint: TEXT_HINT,
|
||||
text_muted: TEXT_MUTED,
|
||||
@@ -126,7 +126,7 @@ pub const UI_THEME: UiTheme = UiTheme {
|
||||
mode_plan: MODE_PLAN,
|
||||
mode_goal: MODE_GOAL,
|
||||
status_ready: TEXT_MUTED,
|
||||
status_working: DEEPSEEK_SKY,
|
||||
status_working: WHALE_INFO,
|
||||
status_warning: STATUS_WARNING,
|
||||
diff_added_fg: DIFF_ADDED,
|
||||
diff_deleted_fg: Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2),
|
||||
|
||||
@@ -64,18 +64,6 @@ pub const WHALE_TOOL_OUTPUT_RGB: (u8, u8, u8) = (194, 208, 224); // #C2D0E0
|
||||
pub const WHALE_TOOL_SURFACE_RGB: (u8, u8, u8) = (28, 40, 62); // #1C283E
|
||||
pub const WHALE_TOOL_ACTIVE_RGB: (u8, u8, u8) = (38, 54, 80); // #263650
|
||||
|
||||
// Backward-compatible aliases for existing downstream users.
|
||||
#[allow(dead_code)]
|
||||
#[deprecated(
|
||||
since = "0.8.61",
|
||||
note = "use WHALE_ACCENT_PRIMARY_RGB instead; this alias will be removed after the rename window"
|
||||
)]
|
||||
pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = WHALE_ACCENT_PRIMARY_RGB;
|
||||
pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = WHALE_INFO_RGB;
|
||||
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
|
||||
pub const LIGHT_ELEVATED_RGB: (u8, u8, u8) = (219, 229, 240); // #DBE5F0
|
||||
@@ -213,24 +201,12 @@ pub const WHALE_ACCENT_PRIMARY: Color = Color::Rgb(
|
||||
WHALE_ACCENT_PRIMARY_RGB.1,
|
||||
WHALE_ACCENT_PRIMARY_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
#[deprecated(
|
||||
since = "0.8.61",
|
||||
note = "use WHALE_ACCENT_PRIMARY instead; this alias will be removed after the rename window"
|
||||
)]
|
||||
pub const DEEPSEEK_BLUE: Color = WHALE_ACCENT_PRIMARY;
|
||||
/// 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);
|
||||
pub const DEEPSEEK_INK: Color =
|
||||
Color::Rgb(DEEPSEEK_INK_RGB.0, DEEPSEEK_INK_RGB.1, DEEPSEEK_INK_RGB.2);
|
||||
pub const DEEPSEEK_SLATE: Color = Color::Rgb(
|
||||
DEEPSEEK_SLATE_RGB.0,
|
||||
DEEPSEEK_SLATE_RGB.1,
|
||||
DEEPSEEK_SLATE_RGB.2,
|
||||
);
|
||||
pub const DEEPSEEK_RED: Color =
|
||||
Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2);
|
||||
pub const WHALE_INFO: Color = Color::Rgb(WHALE_INFO_RGB.0, WHALE_INFO_RGB.1, WHALE_INFO_RGB.2);
|
||||
pub const WHALE_BG: Color = Color::Rgb(WHALE_BG_RGB.0, WHALE_BG_RGB.1, WHALE_BG_RGB.2);
|
||||
pub const WHALE_PANEL: Color =
|
||||
Color::Rgb(WHALE_PANEL_RGB.0, WHALE_PANEL_RGB.1, WHALE_PANEL_RGB.2);
|
||||
pub const WHALE_ERROR: Color =
|
||||
Color::Rgb(WHALE_ERROR_RGB.0, WHALE_ERROR_RGB.1, WHALE_ERROR_RGB.2);
|
||||
|
||||
pub const LIGHT_SURFACE: Color = Color::Rgb(
|
||||
LIGHT_SURFACE_RGB.0,
|
||||
@@ -524,4 +500,4 @@ pub const SELECTION_BG: Color = Color::Rgb(
|
||||
WHALE_SELECTION_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
|
||||
pub const COMPOSER_BG: Color = WHALE_PANEL;
|
||||
|
||||
@@ -130,7 +130,7 @@ pub fn run_remote_setup(args: RemoteSetupArgs) -> Result<()> {
|
||||
fn print_header() {
|
||||
use crate::palette;
|
||||
use colored::Colorize;
|
||||
let (r, g, b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (r, g, b) = palette::WHALE_INFO_RGB;
|
||||
println!("{}", "CodeWhale Remote Setup".truecolor(r, g, b).bold());
|
||||
println!("{}", "======================".truecolor(r, g, b));
|
||||
println!("Generate a deploy bundle for a remote CodeWhale agent (cloud + chat bridge).");
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::spec::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
#[cfg(feature = "pdf")]
|
||||
use std::fmt::Display;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -454,10 +455,14 @@ fn read_pdf(path: &Path, pages: Option<&str>) -> Result<ToolResult, ToolError> {
|
||||
if prefer_external {
|
||||
read_pdf_via_pdftotext(path, page_range)
|
||||
} else {
|
||||
read_pdf_via_pdf_extract(path, page_range)
|
||||
#[cfg(feature = "pdf")]
|
||||
{ read_pdf_via_pdf_extract(path, page_range) }
|
||||
#[cfg(not(feature = "pdf"))]
|
||||
{ read_pdf_via_pdftotext(path, page_range) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
fn read_pdf_via_pdf_extract(
|
||||
path: &Path,
|
||||
page_range: Option<(u32, u32)>,
|
||||
|
||||
@@ -112,7 +112,7 @@ impl ToolSpec for GitLogTool {
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command(&git_ctx.working_dir, &args)?;
|
||||
let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(
|
||||
@@ -230,7 +230,7 @@ impl ToolSpec for GitShowTool {
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command(&git_ctx.working_dir, &args)?;
|
||||
let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(ToolResult::error(format!(
|
||||
@@ -360,7 +360,7 @@ impl ToolSpec for GitBlameTool {
|
||||
args.push(pathspec.display().to_string());
|
||||
|
||||
let command_str = format_command(working_dir, &args);
|
||||
let output = run_git_command(working_dir, &args)?;
|
||||
let output = run_git_command_async(working_dir.to_path_buf(), args).await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(ToolResult::error(format!(
|
||||
@@ -491,6 +491,17 @@ fn run_git_command(working_dir: &Path, args: &[String]) -> Result<Output, ToolEr
|
||||
})
|
||||
}
|
||||
|
||||
/// Async wrapper that offloads the blocking `git` invocation onto a
|
||||
/// blocking-capable thread so the tokio worker is not stalled.
|
||||
async fn run_git_command_async(
|
||||
working_dir: PathBuf,
|
||||
args: Vec<String>,
|
||||
) -> Result<Output, ToolError> {
|
||||
tokio::task::spawn_blocking(move || run_git_command(&working_dir, &args))
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))?
|
||||
}
|
||||
|
||||
fn format_command(working_dir: &Path, args: &[String]) -> String {
|
||||
format!(
|
||||
"git -C {} {}",
|
||||
|
||||
@@ -1125,10 +1125,6 @@ impl ToolRegistryBuilder {
|
||||
.with_tool(Arc::new(TodoAddTool::checklist(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoUpdateTool::checklist(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoListTool::checklist(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoWriteTool::new(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoAddTool::new(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoUpdateTool::new(todo_list.clone())))
|
||||
.with_tool(Arc::new(TodoListTool::new(todo_list)))
|
||||
}
|
||||
|
||||
/// Include the plan tool with a shared `PlanState`.
|
||||
@@ -1254,7 +1250,7 @@ impl ToolSpec for McpToolAdapter {
|
||||
.call_tool(&self.name, input)
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?;
|
||||
let content = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
|
||||
let content = serde_json::to_string(&result).unwrap_or_else(|_| result.to_string());
|
||||
Ok(ToolResult::success(content))
|
||||
}
|
||||
}
|
||||
@@ -1346,10 +1342,6 @@ mod tests {
|
||||
.with_todo_tool(crate::tools::todo::new_shared_todo_list())
|
||||
.build(ctx);
|
||||
|
||||
for alias in ["todo_write", "todo_add", "todo_update", "todo_list"] {
|
||||
assert!(registry.contains(alias), "{alias} should remain callable");
|
||||
}
|
||||
|
||||
let api_names = registry
|
||||
.to_api_tools()
|
||||
.into_iter()
|
||||
@@ -1367,12 +1359,6 @@ mod tests {
|
||||
"{canonical} should stay model-visible"
|
||||
);
|
||||
}
|
||||
for alias in ["todo_write", "todo_add", "todo_update", "todo_list"] {
|
||||
assert!(
|
||||
api_names.iter().all(|name| name != alias),
|
||||
"{alias} should be hidden from the model catalog"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -489,7 +489,8 @@ impl ToolSpec for ReviewTool {
|
||||
.clamp(1, MAX_MAX_CHARS);
|
||||
|
||||
let source =
|
||||
resolve_review_source(target, kind.as_deref(), staged, base.as_deref(), context)?;
|
||||
resolve_review_source(target, kind.as_deref(), staged, base.as_deref(), context)
|
||||
.await?;
|
||||
let prompt = build_review_prompt(&source, max_chars);
|
||||
|
||||
let request = MessageRequest {
|
||||
@@ -547,7 +548,7 @@ enum ReviewSource {
|
||||
PullRequest { label: String, diff: String },
|
||||
}
|
||||
|
||||
fn resolve_review_source(
|
||||
async fn resolve_review_source(
|
||||
target: &str,
|
||||
kind: Option<&str>,
|
||||
staged: bool,
|
||||
@@ -557,16 +558,18 @@ fn resolve_review_source(
|
||||
if let Some(kind) = kind {
|
||||
return match kind {
|
||||
"file" => resolve_file_target(target, context),
|
||||
"diff" => resolve_diff_target(context.workspace.as_path(), staged, base).map(|diff| {
|
||||
ReviewSource::Diff {
|
||||
"diff" => {
|
||||
let diff =
|
||||
resolve_diff_target(context.workspace.as_path(), staged, base).await?;
|
||||
Ok(ReviewSource::Diff {
|
||||
label: "git diff".to_string(),
|
||||
diff,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
"pr" | "pull" | "pull_request" => {
|
||||
let pr = parse_pr_url(target)
|
||||
.ok_or_else(|| ToolError::invalid_input("Invalid pull request URL"))?;
|
||||
let diff = gh_pr_diff(&pr, &context.workspace)?;
|
||||
let diff = gh_pr_diff(&pr, &context.workspace).await?;
|
||||
Ok(ReviewSource::PullRequest {
|
||||
label: pr.label(),
|
||||
diff,
|
||||
@@ -579,7 +582,7 @@ fn resolve_review_source(
|
||||
}
|
||||
|
||||
if let Some(pr) = parse_pr_url(target) {
|
||||
let diff = gh_pr_diff(&pr, &context.workspace)?;
|
||||
let diff = gh_pr_diff(&pr, &context.workspace).await?;
|
||||
return Ok(ReviewSource::PullRequest {
|
||||
label: pr.label(),
|
||||
diff,
|
||||
@@ -588,7 +591,8 @@ fn resolve_review_source(
|
||||
|
||||
if let Some(staged_override) = diff_mode_from_target(target) {
|
||||
let staged = staged || staged_override;
|
||||
let diff = resolve_diff_target(context.workspace.as_path(), staged, base)?;
|
||||
let diff =
|
||||
resolve_diff_target(context.workspace.as_path(), staged, base).await?;
|
||||
return Ok(ReviewSource::Diff {
|
||||
label: if staged {
|
||||
"git diff --cached"
|
||||
@@ -622,7 +626,7 @@ fn resolve_file_target(target: &str, context: &ToolContext) -> Result<ReviewSour
|
||||
Ok(ReviewSource::File { display, content })
|
||||
}
|
||||
|
||||
fn resolve_diff_target(
|
||||
async fn resolve_diff_target(
|
||||
workspace: &Path,
|
||||
staged: bool,
|
||||
base: Option<&str>,
|
||||
@@ -641,8 +645,9 @@ fn resolve_diff_target(
|
||||
}
|
||||
cmd.current_dir(workspace);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
let output = tokio::task::spawn_blocking(move || cmd.output())
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("git diff task panicked: {e}")))?
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to run git diff: {e}")))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -658,7 +663,7 @@ fn resolve_diff_target(
|
||||
Ok(diff)
|
||||
}
|
||||
|
||||
fn gh_pr_diff(pr: &PullRequestRef, workspace: &Path) -> Result<String, ToolError> {
|
||||
async fn gh_pr_diff(pr: &PullRequestRef, workspace: &Path) -> Result<String, ToolError> {
|
||||
let Some(mut cmd) = crate::dependencies::Gh::command() else {
|
||||
return Err(ToolError::execution_failed("gh not found"));
|
||||
};
|
||||
@@ -669,9 +674,14 @@ fn gh_pr_diff(pr: &PullRequestRef, workspace: &Path) -> Result<String, ToolError
|
||||
.arg(format!("{}/{}", pr.owner, pr.repo))
|
||||
.current_dir(workspace);
|
||||
|
||||
let output = cmd.output().map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to run gh pr diff (is gh installed?): {e}"))
|
||||
})?;
|
||||
let output = tokio::task::spawn_blocking(move || cmd.output())
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("gh pr diff task panicked: {e}")))?
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to run gh pr diff (is gh installed?): {e}"
|
||||
))
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
|
||||
@@ -434,118 +434,6 @@ impl SubAgentType {
|
||||
};
|
||||
format!("{role_intro}{SUBAGENT_OUTPUT_FORMAT}")
|
||||
}
|
||||
|
||||
/// Get the default allowed tools for this agent type.
|
||||
///
|
||||
/// **Deprecated since v0.6.6.** Default sub-agents now inherit the full
|
||||
/// parent registry; the per-type allowlist is advisory only. Pass an explicit
|
||||
/// `allowed_tools` array for narrow Custom roles instead.
|
||||
#[must_use]
|
||||
#[deprecated(
|
||||
since = "0.6.6",
|
||||
note = "Default sub-agents inherit the full parent registry; pass an explicit allowed_tools list only for narrow Custom roles."
|
||||
)]
|
||||
pub fn allowed_tools(&self) -> Vec<&'static str> {
|
||||
match self {
|
||||
Self::General => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web.run",
|
||||
"web_search",
|
||||
"exec_shell",
|
||||
"exec_shell_wait",
|
||||
"exec_shell_interact",
|
||||
"exec_wait",
|
||||
"exec_interact",
|
||||
"note",
|
||||
"checklist_write",
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
"checklist_list",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
"update_plan",
|
||||
],
|
||||
Self::Explore => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web.run",
|
||||
"web_search",
|
||||
"exec_shell",
|
||||
"exec_shell_wait",
|
||||
"exec_shell_interact",
|
||||
"exec_wait",
|
||||
"exec_interact",
|
||||
],
|
||||
Self::Plan => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web.run",
|
||||
"note",
|
||||
"update_plan",
|
||||
"checklist_write",
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
"checklist_list",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
],
|
||||
Self::Review => vec!["list_dir", "read_file", "grep_files", "file_search", "note"],
|
||||
Self::Implementer => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"exec_shell",
|
||||
"exec_shell_wait",
|
||||
"exec_shell_interact",
|
||||
"exec_wait",
|
||||
"exec_interact",
|
||||
"note",
|
||||
"checklist_write",
|
||||
"checklist_add",
|
||||
"checklist_update",
|
||||
"checklist_list",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
"update_plan",
|
||||
],
|
||||
Self::Verifier => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"exec_shell",
|
||||
"exec_shell_wait",
|
||||
"exec_shell_interact",
|
||||
"exec_wait",
|
||||
"exec_interact",
|
||||
"run_tests",
|
||||
"run_verifiers",
|
||||
"diagnostics",
|
||||
"note",
|
||||
],
|
||||
Self::Custom => vec![], // Must be provided by caller.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a sub-agent execution.
|
||||
|
||||
@@ -890,33 +890,6 @@ fn new_session_tools_use_single_agent_name() {
|
||||
assert_eq!(AgentTool::new(manager, stub_runtime()).name(), "agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implementer_allowed_tools_include_writes() {
|
||||
// Implementer is the write-heavy role; the deprecated
|
||||
// `allowed_tools()` advisory list should reflect that the role
|
||||
// can write/edit/patch even if today's runtime grants full
|
||||
// inheritance.
|
||||
#[allow(deprecated)]
|
||||
let tools = SubAgentType::Implementer.allowed_tools();
|
||||
assert!(tools.contains(&"write_file"));
|
||||
assert!(tools.contains(&"edit_file"));
|
||||
assert!(tools.contains(&"apply_patch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verifier_allowed_tools_include_test_runner_but_no_writes() {
|
||||
// Verifier runs validation; it should not have write tools in
|
||||
// its advisory list. The runtime will still gate writes through
|
||||
// approval, but the advisory list signals intent.
|
||||
#[allow(deprecated)]
|
||||
let tools = SubAgentType::Verifier.allowed_tools();
|
||||
assert!(tools.contains(&"run_tests"));
|
||||
assert!(tools.contains(&"run_verifiers"));
|
||||
assert!(tools.contains(&"diagnostics"));
|
||||
assert!(!tools.contains(&"write_file"));
|
||||
assert!(!tools.contains(&"apply_patch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() {
|
||||
let input = json!({
|
||||
|
||||
@@ -353,7 +353,7 @@ impl ToolSpec for TaskGateRunTool {
|
||||
"failed"
|
||||
};
|
||||
let classification = classify_gate_failure(&gate, status, timed_out, &stderr, &stdout);
|
||||
let log_path = write_runtime_artifact(context, "gate", &full_log)?;
|
||||
let log_path = write_runtime_artifact(context, "gate", &full_log).await?;
|
||||
let gate_record = TaskGateRecord {
|
||||
id: format!("gate_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
gate: gate.clone(),
|
||||
@@ -521,7 +521,7 @@ impl ToolSpec for TaskShellWaitTool {
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_default();
|
||||
let command = optional_str(&input, "command").unwrap_or("(background shell)");
|
||||
let log_path = write_runtime_artifact(context, "background_gate", &result.content)?;
|
||||
let log_path = write_runtime_artifact(context, "background_gate", &result.content).await?;
|
||||
let gate_status = if exit_code == Some(0) {
|
||||
"passed"
|
||||
} else if status == "TimedOut" {
|
||||
@@ -594,21 +594,21 @@ impl ToolSpec for PrAttemptRecordTool {
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let task_id = task_id_from_input_or_context(&input, context)?;
|
||||
let base_sha = git_output(&context.workspace, &["rev-parse", "HEAD"]).ok();
|
||||
let base_sha = git_output(&context.workspace, &["rev-parse", "HEAD"]).await.ok();
|
||||
let head_sha = base_sha.clone();
|
||||
let branch = git_output(&context.workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok();
|
||||
let diff = git_output(&context.workspace, &["diff", "--binary", "--no-color"])?;
|
||||
let branch = git_output(&context.workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).await.ok();
|
||||
let diff = git_output(&context.workspace, &["diff", "--binary", "--no-color"]).await?;
|
||||
if diff.trim().is_empty() {
|
||||
return Ok(ToolResult::error(
|
||||
"No working-tree diff to record as an attempt.",
|
||||
));
|
||||
}
|
||||
let changed_files = git_output(&context.workspace, &["diff", "--name-only"])?
|
||||
let changed_files = git_output(&context.workspace, &["diff", "--name-only"]).await?
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let patch_path = write_task_artifact_for(context, &task_id, "attempt_patch", &diff)?;
|
||||
let patch_path = write_task_artifact_for(context, &task_id, "attempt_patch", &diff).await?;
|
||||
let attempt = TaskAttemptRecord {
|
||||
id: format!("attempt_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
attempt_group_id: optional_str(&input, "attempt_group_id")
|
||||
@@ -818,7 +818,7 @@ fn resolve_cwd(context: &ToolContext, raw: Option<&str>) -> Result<PathBuf, Tool
|
||||
}
|
||||
}
|
||||
|
||||
fn write_runtime_artifact(
|
||||
async fn write_runtime_artifact(
|
||||
context: &ToolContext,
|
||||
label: &str,
|
||||
content: &str,
|
||||
@@ -837,16 +837,22 @@ fn write_runtime_artifact(
|
||||
return Ok(None);
|
||||
};
|
||||
let artifact_dir = data_dir.join("artifacts").join(task_id);
|
||||
std::fs::create_dir_all(&artifact_dir)
|
||||
.map_err(|e| ToolError::execution_failed(format!("create artifact dir: {e}")))?;
|
||||
let filename = format!(
|
||||
"{}_{}.txt",
|
||||
Utc::now().format("%Y%m%dT%H%M%S%.3fZ"),
|
||||
sanitize_filename(label)
|
||||
);
|
||||
let absolute = artifact_dir.join(filename);
|
||||
std::fs::write(&absolute, content)
|
||||
.map_err(|e| ToolError::execution_failed(format!("write artifact: {e}")))?;
|
||||
let content_owned = content.to_owned();
|
||||
let abs = absolute.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::fs::create_dir_all(&artifact_dir)?;
|
||||
std::fs::write(&abs, content_owned)?;
|
||||
Ok::<(), std::io::Error>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("artifact write task panicked: {e}")))?
|
||||
.map_err(|e| ToolError::execution_failed(format!("write artifact: {e}")))?;
|
||||
Ok(Some(
|
||||
absolute
|
||||
.strip_prefix(data_dir)
|
||||
@@ -855,7 +861,7 @@ fn write_runtime_artifact(
|
||||
))
|
||||
}
|
||||
|
||||
fn write_task_artifact_for(
|
||||
async fn write_task_artifact_for(
|
||||
context: &ToolContext,
|
||||
task_id: &str,
|
||||
label: &str,
|
||||
@@ -870,7 +876,7 @@ fn write_task_artifact_for(
|
||||
if context.runtime.active_task_id.as_deref() != Some(task_id) {
|
||||
return Ok(None);
|
||||
}
|
||||
write_runtime_artifact(context, label, content)
|
||||
write_runtime_artifact(context, label, content).await
|
||||
}
|
||||
|
||||
fn artifact_updates(label: &str, path: Option<PathBuf>, summary: &str) -> Value {
|
||||
@@ -923,9 +929,16 @@ fn task_id_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn git_output(workspace: &Path, args: &[&str]) -> Result<String, ToolError> {
|
||||
let out = crate::dependencies::Git::output(args, workspace)
|
||||
.map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
|
||||
async fn git_output(workspace: &Path, args: &[&str]) -> Result<String, ToolError> {
|
||||
let args_owned: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
|
||||
let cwd = workspace.to_path_buf();
|
||||
let out = tokio::task::spawn_blocking(move || {
|
||||
let arg_refs: Vec<&str> = args_owned.iter().map(String::as_str).collect();
|
||||
crate::dependencies::Git::output(&arg_refs, &cwd)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))?
|
||||
.map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?;
|
||||
if !out.status.success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"git {} failed: {}",
|
||||
|
||||
@@ -174,24 +174,9 @@ pub fn new_shared_todo_list() -> SharedTodoList {
|
||||
Arc::new(Mutex::new(TodoList::new()))
|
||||
}
|
||||
|
||||
const TODO_ALIAS_FIRST_DEPRECATED_VERSION: &str = "0.8.53";
|
||||
const TODO_ALIAS_REMOVAL_VERSION: &str = "0.9.0";
|
||||
const CANONICAL_WORK_SURFACE: &str = "checklist";
|
||||
const DURABLE_WORK_OWNER: &str = "fleet_workflow_ledger";
|
||||
|
||||
fn is_compat_alias(tool_name: &str) -> bool {
|
||||
tool_name.starts_with("todo_")
|
||||
}
|
||||
|
||||
fn checklist_replacement_tool_name(tool_name: &str) -> &'static str {
|
||||
match tool_name {
|
||||
"todo_add" | "checklist_add" => "checklist_add",
|
||||
"todo_update" | "checklist_update" => "checklist_update",
|
||||
"todo_list" | "checklist_list" => "checklist_list",
|
||||
_ => "checklist_write",
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for writing and updating the todo list
|
||||
pub struct TodoWriteTool {
|
||||
todo_list: SharedTodoList,
|
||||
@@ -199,13 +184,6 @@ pub struct TodoWriteTool {
|
||||
}
|
||||
|
||||
impl TodoWriteTool {
|
||||
pub fn new(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
tool_name: "todo_write",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checklist(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
@@ -221,13 +199,6 @@ pub struct TodoAddTool {
|
||||
}
|
||||
|
||||
impl TodoAddTool {
|
||||
pub fn new(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
tool_name: "todo_add",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checklist(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
@@ -277,7 +248,7 @@ impl ToolSpec for TodoAddTool {
|
||||
}
|
||||
|
||||
fn model_visible(&self) -> bool {
|
||||
!is_compat_alias(self.tool_name)
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -317,13 +288,6 @@ pub struct TodoUpdateTool {
|
||||
}
|
||||
|
||||
impl TodoUpdateTool {
|
||||
pub fn new(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
tool_name: "todo_update",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checklist(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
@@ -373,7 +337,7 @@ impl ToolSpec for TodoUpdateTool {
|
||||
}
|
||||
|
||||
fn model_visible(&self) -> bool {
|
||||
!is_compat_alias(self.tool_name)
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -417,13 +381,6 @@ pub struct TodoListTool {
|
||||
}
|
||||
|
||||
impl TodoListTool {
|
||||
pub fn new(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
tool_name: "todo_list",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checklist(todo_list: SharedTodoList) -> Self {
|
||||
Self {
|
||||
todo_list,
|
||||
@@ -462,7 +419,7 @@ impl ToolSpec for TodoListTool {
|
||||
}
|
||||
|
||||
fn model_visible(&self) -> bool {
|
||||
!is_compat_alias(self.tool_name)
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -534,7 +491,7 @@ impl ToolSpec for TodoWriteTool {
|
||||
}
|
||||
|
||||
fn model_visible(&self) -> bool {
|
||||
!is_compat_alias(self.tool_name)
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
@@ -582,8 +539,6 @@ impl ToolSpec for TodoWriteTool {
|
||||
}
|
||||
|
||||
fn checklist_metadata(snapshot: &TodoListSnapshot, tool_name: &str) -> serde_json::Value {
|
||||
let canonical_tool = checklist_replacement_tool_name(tool_name);
|
||||
let compat_alias = is_compat_alias(tool_name);
|
||||
let items = snapshot
|
||||
.items
|
||||
.iter()
|
||||
@@ -595,12 +550,12 @@ fn checklist_metadata(snapshot: &TodoListSnapshot, tool_name: &str) -> serde_jso
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut metadata = json!({
|
||||
"canonical_tool": canonical_tool,
|
||||
"compat_alias": compat_alias,
|
||||
json!({
|
||||
"canonical_tool": tool_name,
|
||||
"compat_alias": false,
|
||||
"work_surface": {
|
||||
"canonical": CANONICAL_WORK_SURFACE,
|
||||
"model_visible": !compat_alias,
|
||||
"model_visible": true,
|
||||
"durable_owner": DURABLE_WORK_OWNER,
|
||||
"progress_key": "task_updates.checklist"
|
||||
},
|
||||
@@ -612,22 +567,7 @@ fn checklist_metadata(snapshot: &TodoListSnapshot, tool_name: &str) -> serde_jso
|
||||
"updated_at": null
|
||||
}
|
||||
}
|
||||
});
|
||||
if compat_alias && let Some(obj) = metadata.as_object_mut() {
|
||||
obj.insert(
|
||||
"_deprecation".to_string(),
|
||||
json!({
|
||||
"this_tool": tool_name,
|
||||
"use_instead": canonical_tool,
|
||||
"first_deprecated": TODO_ALIAS_FIRST_DEPRECATED_VERSION,
|
||||
"removed_in": TODO_ALIAS_REMOVAL_VERSION,
|
||||
"message": format!(
|
||||
"Tool '{tool_name}' is a hidden compatibility alias; use '{canonical_tool}' before v{TODO_ALIAS_REMOVAL_VERSION}."
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
metadata
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -673,87 +613,4 @@ mod tests {
|
||||
"wire durable task tools"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn todo_write_remains_compat_alias() {
|
||||
let tool = TodoWriteTool::new(new_shared_todo_list());
|
||||
let context = ToolContext::new(std::env::temp_dir());
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"todos": [
|
||||
{ "content": "legacy caller", "status": "completed" }
|
||||
]
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("todo write succeeds");
|
||||
|
||||
let metadata = result.metadata.expect("metadata");
|
||||
assert_eq!(tool.name(), "todo_write");
|
||||
assert_eq!(metadata["canonical_tool"], "checklist_write");
|
||||
assert_eq!(metadata["compat_alias"], true);
|
||||
assert_eq!(metadata["work_surface"]["canonical"], "checklist");
|
||||
assert_eq!(
|
||||
metadata["work_surface"]["progress_key"],
|
||||
"task_updates.checklist"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata["task_updates"]["checklist"]["items"][0]["content"],
|
||||
"legacy caller"
|
||||
);
|
||||
assert_eq!(metadata["_deprecation"]["this_tool"], "todo_write");
|
||||
assert_eq!(metadata["_deprecation"]["use_instead"], "checklist_write");
|
||||
assert_eq!(metadata["_deprecation"]["removed_in"], "0.9.0");
|
||||
assert!(!tool.model_visible());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn todo_item_aliases_return_replacement_metadata() {
|
||||
let list = new_shared_todo_list();
|
||||
let context = ToolContext::new(std::env::temp_dir());
|
||||
|
||||
let add = TodoAddTool::new(list.clone());
|
||||
let add_result = add
|
||||
.execute(
|
||||
json!({"content": "legacy add", "status": "in_progress"}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("todo add succeeds");
|
||||
let add_metadata = add_result.metadata.expect("add metadata");
|
||||
assert_eq!(add_metadata["canonical_tool"], "checklist_add");
|
||||
assert_eq!(add_metadata["work_surface"]["canonical"], "checklist");
|
||||
assert_eq!(add_metadata["_deprecation"]["use_instead"], "checklist_add");
|
||||
assert!(!add.model_visible());
|
||||
|
||||
let update = TodoUpdateTool::new(list.clone());
|
||||
let update_result = update
|
||||
.execute(json!({"id": 1, "status": "completed"}), &context)
|
||||
.await
|
||||
.expect("todo update succeeds");
|
||||
let update_metadata = update_result.metadata.expect("update metadata");
|
||||
assert_eq!(update_metadata["canonical_tool"], "checklist_update");
|
||||
assert_eq!(update_metadata["work_surface"]["canonical"], "checklist");
|
||||
assert_eq!(
|
||||
update_metadata["_deprecation"]["use_instead"],
|
||||
"checklist_update"
|
||||
);
|
||||
assert!(!update.model_visible());
|
||||
|
||||
let list_tool = TodoListTool::new(list);
|
||||
let list_result = list_tool
|
||||
.execute(json!({}), &context)
|
||||
.await
|
||||
.expect("todo list succeeds");
|
||||
let list_metadata = list_result.metadata.expect("list metadata");
|
||||
assert_eq!(list_metadata["canonical_tool"], "checklist_list");
|
||||
assert_eq!(list_metadata["work_surface"]["canonical"], "checklist");
|
||||
assert_eq!(
|
||||
list_metadata["_deprecation"]["use_instead"],
|
||||
"checklist_list"
|
||||
);
|
||||
assert!(!list_tool.model_visible());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
#[cfg(feature = "pdf")]
|
||||
use std::fmt::Display;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -1159,6 +1160,7 @@ async fn fetch_page(url: &str, timeout_ms: u64) -> Result<WebPage, ToolError> {
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
if is_pdf(&content_type, url) {
|
||||
return parse_pdf_page(url, content_type, &bytes);
|
||||
}
|
||||
@@ -1176,6 +1178,7 @@ async fn fetch_page(url: &str, timeout_ms: u64) -> Result<WebPage, ToolError> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
fn is_pdf(content_type: &Option<String>, url: &str) -> bool {
|
||||
if let Some(ct) = content_type
|
||||
&& ct.to_lowercase().contains("application/pdf")
|
||||
@@ -1185,6 +1188,7 @@ fn is_pdf(content_type: &Option<String>, url: &str) -> bool {
|
||||
url.to_lowercase().ends_with(".pdf")
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
fn parse_pdf_page(
|
||||
url: &str,
|
||||
content_type: Option<String>,
|
||||
@@ -1204,11 +1208,13 @@ fn parse_pdf_page(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
fn pdf_extract_text(bytes: &[u8]) -> Result<String, ToolError> {
|
||||
guard_pdf_extract(|| pdf_extract::extract_text_from_mem(bytes))
|
||||
.map_err(|e| ToolError::execution_failed(format!("PDF extract failed: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
fn guard_pdf_extract<T, E, F>(extract: F) -> Result<T, String>
|
||||
where
|
||||
E: Display,
|
||||
@@ -1224,6 +1230,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(message) = payload.downcast_ref::<&str>() {
|
||||
(*message).to_string()
|
||||
@@ -1234,6 +1241,7 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
fn split_pdf_pages(text: &str) -> Vec<Vec<String>> {
|
||||
let raw_pages: Vec<&str> = text.split('\x0C').collect();
|
||||
raw_pages
|
||||
@@ -1820,6 +1828,7 @@ mod tests {
|
||||
assert_eq!(percent_decode("foo+bar%20baz"), "foo+bar baz");
|
||||
}
|
||||
|
||||
#[cfg(feature = "pdf")]
|
||||
#[test]
|
||||
fn pdf_extract_panic_is_returned_as_tool_error_text() {
|
||||
let err = guard_pdf_extract(|| -> Result<String, &'static str> {
|
||||
|
||||
+39
-16
@@ -1594,6 +1594,9 @@ pub struct App {
|
||||
pub next_history_revision: u64,
|
||||
pub api_messages: Vec<Message>,
|
||||
pub is_loading: bool,
|
||||
/// Timestamp of the most recent Enter while the engine was busy.
|
||||
/// Used by `enter_with_double_tap()` to detect a double-tap within 500 ms.
|
||||
pub last_enter_instant: Option<Instant>,
|
||||
/// Whether the once-per-turn provider-wait incident (#3095) has already
|
||||
/// been logged for the current turn.
|
||||
pub provider_wait_incident_logged: bool,
|
||||
@@ -2682,6 +2685,7 @@ impl App {
|
||||
next_history_revision: 1,
|
||||
api_messages: Vec::new(),
|
||||
is_loading: false,
|
||||
last_enter_instant: None,
|
||||
provider_wait_incident_logged: false,
|
||||
prompt_suggestion: None,
|
||||
prompt_suggestion_gen: std::sync::atomic::AtomicU64::new(0),
|
||||
@@ -5653,17 +5657,14 @@ impl App {
|
||||
|
||||
/// Decide how to route a fresh composer submit.
|
||||
///
|
||||
/// #382 / v0.8.44: when the model is busy but not actively streaming
|
||||
/// (waiting on tool results, sub-agents, or shell commands), Enter tries
|
||||
/// to steer into the current turn. If steering fails, the message queues.
|
||||
/// During active streaming, Enter always queues to avoid interrupting
|
||||
/// in-flight reasoning. Ctrl+Enter forces Steer in all busy states.
|
||||
/// v0.8.68: busy always queues. A double-tap Enter within 500 ms
|
||||
/// triggers Steer via [`enter_with_double_tap`]; Ctrl+Enter forces
|
||||
/// Steer in all busy states.
|
||||
///
|
||||
/// Truth table:
|
||||
/// offline=F, busy=F → Immediate
|
||||
/// offline=F, busy=T+streaming → Queue
|
||||
/// offline=F, busy=T+waiting → Steer (fallback Queue)
|
||||
/// offline=T, busy=* → Queue
|
||||
/// offline=F, busy=F → Immediate
|
||||
/// offline=F, busy=T → Queue (double-tap → Steer)
|
||||
/// offline=T, busy=* → Queue
|
||||
#[must_use]
|
||||
pub fn decide_submit_disposition(&self) -> SubmitDisposition {
|
||||
if self.offline_mode {
|
||||
@@ -5672,16 +5673,38 @@ impl App {
|
||||
if !self.is_loading {
|
||||
return SubmitDisposition::Immediate;
|
||||
}
|
||||
// Busy but not streaming text: model is waiting on tool results or
|
||||
// sub-agents — steer so the new message reaches the engine promptly
|
||||
// instead of sitting in the queue until the current turn finishes.
|
||||
if self.streaming_message_index.is_none() {
|
||||
return SubmitDisposition::Steer;
|
||||
}
|
||||
// Actively streaming: queue to avoid interrupting in-flight reasoning.
|
||||
// Busy: queue the message. Double-tap Enter within 500 ms triggers
|
||||
// Steer via enter_with_double_tap(); see the ui.rs submit handler.
|
||||
SubmitDisposition::Queue
|
||||
}
|
||||
|
||||
/// Process an Enter keypress with double-tap steering detection.
|
||||
///
|
||||
/// When the engine is busy, the first Enter queues the message. A second
|
||||
/// Enter within 500 ms triggers Steer (interrupt the current turn to
|
||||
/// inject the new instruction immediately). When idle, Enter submits
|
||||
/// immediately.
|
||||
#[must_use]
|
||||
pub fn enter_with_double_tap(&mut self) -> Option<SubmitDisposition> {
|
||||
let disposition = self.decide_submit_disposition();
|
||||
match disposition {
|
||||
SubmitDisposition::Queue => {
|
||||
if let Some(instant) = self.last_enter_instant
|
||||
&& instant.elapsed() < Duration::from_millis(500)
|
||||
{
|
||||
self.last_enter_instant = None;
|
||||
return Some(SubmitDisposition::Steer);
|
||||
}
|
||||
self.last_enter_instant = Some(Instant::now());
|
||||
Some(SubmitDisposition::Queue)
|
||||
}
|
||||
other => {
|
||||
self.last_enter_instant = None;
|
||||
Some(other)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the in-flight streaming Assistant cell as interrupted: prepend
|
||||
/// `[interrupted]` to whatever streamed so far (so the user can see what
|
||||
/// was salvaged) and flip `streaming` off so the spinner halts. No-op if
|
||||
|
||||
@@ -2754,14 +2754,14 @@ fn submit_disposition_immediate_when_idle_and_online() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_disposition_steer_when_busy_and_online_not_streaming() {
|
||||
// v0.8.44: Busy + not streaming → Steer (Enter reaches engine during
|
||||
// sub-agent/shell waits instead of silently queueing).
|
||||
fn submit_disposition_queue_when_busy_and_online_not_streaming() {
|
||||
// Double-enter steer: Busy + not streaming now returns Queue; steering
|
||||
// is triggered externally via enter_with_double_tap() or Ctrl+Enter.
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.is_loading = true;
|
||||
app.offline_mode = false;
|
||||
// streaming_message_index is None (default) → tool execution phase
|
||||
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Steer);
|
||||
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2792,6 +2792,49 @@ fn submit_disposition_offline_busy_queues() {
|
||||
assert_eq!(app.decide_submit_disposition(), SubmitDisposition::Queue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_enter_detects_steering() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
// Simulate a busy engine (not streaming) so decide_submit_disposition
|
||||
// returns Queue rather than Immediate.
|
||||
app.is_loading = true;
|
||||
|
||||
// First Enter → Queue (normal queueing)
|
||||
let first = app.enter_with_double_tap();
|
||||
assert_eq!(first, Some(SubmitDisposition::Queue));
|
||||
|
||||
// Second Enter within 500ms → Steer (double-tap detected)
|
||||
let second = app.enter_with_double_tap();
|
||||
assert_eq!(second, Some(SubmitDisposition::Steer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_enter_resets_after_timeout() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.is_loading = true;
|
||||
|
||||
// First Enter → Queue
|
||||
let first = app.enter_with_double_tap();
|
||||
assert_eq!(first, Some(SubmitDisposition::Queue));
|
||||
|
||||
// Simulate timeout by clearing last_enter_instant
|
||||
app.last_enter_instant = None;
|
||||
|
||||
// Next Enter → Queue again (not Steer, because window expired)
|
||||
let second = app.enter_with_double_tap();
|
||||
assert_eq!(second, Some(SubmitDisposition::Queue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_enter_passes_through_when_idle() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
// Engine idle → Immediate (not affected by double-tap)
|
||||
let first = app.enter_with_double_tap();
|
||||
assert_eq!(first, Some(SubmitDisposition::Immediate));
|
||||
let second = app.enter_with_double_tap();
|
||||
assert_eq!(second, Some(SubmitDisposition::Immediate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_pending_steer_arms_resend_flag() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
|
||||
@@ -437,7 +437,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(palette::DEEPSEEK_INK);
|
||||
cell.set_bg(palette::WHALE_BG);
|
||||
|
||||
adapt_cell_colors(
|
||||
&mut cell,
|
||||
@@ -454,8 +454,8 @@ mod tests {
|
||||
#[test]
|
||||
fn grayscale_palette_maps_hued_cells_before_depth_adaptation() {
|
||||
let mut cell = Cell::default();
|
||||
cell.set_fg(palette::DEEPSEEK_SKY);
|
||||
cell.set_bg(palette::DEEPSEEK_INK);
|
||||
cell.set_fg(palette::WHALE_INFO);
|
||||
cell.set_bg(palette::WHALE_BG);
|
||||
|
||||
adapt_cell_colors(
|
||||
&mut cell,
|
||||
@@ -472,11 +472,11 @@ mod tests {
|
||||
#[test]
|
||||
fn community_theme_remap_honors_background_color_override() {
|
||||
// Tokyo Night + a custom black surface: the remap must rewrite
|
||||
// `palette::DEEPSEEK_INK` to the *active* UiTheme's overridden
|
||||
// `palette::WHALE_BG` to the *active* UiTheme's overridden
|
||||
// surface, not to tokyo-night's default surface.
|
||||
let active = palette::TOKYO_NIGHT_UI_THEME.with_background_color(Color::Rgb(0, 0, 0));
|
||||
let mut cell = Cell::default();
|
||||
cell.set_bg(palette::DEEPSEEK_INK);
|
||||
cell.set_bg(palette::WHALE_BG);
|
||||
|
||||
adapt_cell_colors(
|
||||
&mut cell,
|
||||
|
||||
@@ -438,7 +438,7 @@ fn modal_block() -> Block<'static> {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1))
|
||||
}
|
||||
|
||||
@@ -743,7 +743,7 @@ impl CommandPaletteView {
|
||||
Line::from(vec![Span::styled(
|
||||
format!(" {title} ({count}) "),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)])
|
||||
}
|
||||
@@ -1736,7 +1736,7 @@ mod tests {
|
||||
assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ impl ModalView for ContextMenuView {
|
||||
let block = Block::default()
|
||||
.title(self.title.as_str())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::DEEPSEEK_SKY))
|
||||
.border_style(Style::default().fg(palette::WHALE_INFO))
|
||||
.style(Style::default().bg(palette::SURFACE_ELEVATED))
|
||||
.padding(Padding::horizontal(0));
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ fn parse_hunk_header(line: &str) -> Option<(usize, usize)> {
|
||||
|
||||
fn render_header_line(line: &str, width: u16) -> Vec<Line<'static>> {
|
||||
let style = Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
wrap_with_style(line, style, width)
|
||||
}
|
||||
|
||||
@@ -131,12 +131,12 @@ impl ModalView for FeedbackPickerView {
|
||||
.title(Line::from(Span::styled(
|
||||
" Feedback ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -277,7 +277,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
|
||||
@@ -347,7 +347,7 @@ impl ModalView for FilePickerView {
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -366,13 +366,13 @@ impl ModalView for FilePickerView {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
// Query line.
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("> ", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("> ", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::raw(self.query.clone()),
|
||||
Span::styled(
|
||||
" ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_INK)
|
||||
.bg(palette::DEEPSEEK_SKY),
|
||||
.fg(palette::WHALE_BG)
|
||||
.bg(palette::WHALE_INFO),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
@@ -908,7 +908,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
|
||||
@@ -112,7 +112,7 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
}
|
||||
if !label.is_empty() {
|
||||
props.state_label = label;
|
||||
props.state_color = palette::DEEPSEEK_SKY;
|
||||
props.state_color = palette::WHALE_INFO;
|
||||
}
|
||||
if active_subagent_label.is_some() {
|
||||
props.agents.clear();
|
||||
|
||||
@@ -872,7 +872,7 @@ impl ExploringCell {
|
||||
);
|
||||
lines.push(Line::styled(
|
||||
format!(" {dots} {counts}"),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ pub(super) fn render_checklist_change_card(
|
||||
fn checklist_status_marker(status: &str) -> (&'static str, Color) {
|
||||
match status.to_ascii_lowercase().as_str() {
|
||||
"completed" | "done" => ("\u{2611}", palette::STATUS_SUCCESS), // ☑
|
||||
"in_progress" | "inprogress" | "running" => ("\u{25D0}", palette::DEEPSEEK_SKY), // ◐
|
||||
"in_progress" | "inprogress" | "running" => ("\u{25D0}", palette::WHALE_INFO), // ◐
|
||||
"blocked" | "failed" => ("\u{2717}", palette::STATUS_ERROR), // ✗
|
||||
"cancelled" | "canceled" | "skipped" => ("\u{2298}", palette::TEXT_MUTED), // ⊘
|
||||
_ => ("\u{2610}", palette::TEXT_MUTED), // ☐ pending
|
||||
|
||||
@@ -218,9 +218,9 @@ pub(super) fn assistant_label_style_for(streaming: bool, low_motion: bool) -> St
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
palette::pulse_brightness(palette::DEEPSEEK_SKY, now_ms)
|
||||
palette::pulse_brightness(palette::WHALE_INFO, now_ms)
|
||||
} else {
|
||||
palette::DEEPSEEK_SKY
|
||||
palette::WHALE_INFO
|
||||
};
|
||||
Style::default().fg(color)
|
||||
}
|
||||
|
||||
@@ -1146,8 +1146,8 @@ fn assistant_glyph_holds_full_brightness_when_idle() {
|
||||
// source sky — pulse only fires when actively streaming.
|
||||
let idle = assistant_label_style_for(false, false);
|
||||
let low_motion = assistant_label_style_for(true, true);
|
||||
assert_eq!(idle.fg, Some(palette::DEEPSEEK_SKY));
|
||||
assert_eq!(low_motion.fg, Some(palette::DEEPSEEK_SKY));
|
||||
assert_eq!(idle.fg, Some(palette::WHALE_INFO));
|
||||
assert_eq!(low_motion.fg, Some(palette::WHALE_INFO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1161,8 +1161,8 @@ fn assistant_glyph_pulses_when_streaming_and_motion_allowed() {
|
||||
let mut saw_dimmed = false;
|
||||
for _ in 0..50 {
|
||||
if let Some(Color::Rgb(_, _, b)) = assistant_label_style_for(true, false).fg {
|
||||
let Color::Rgb(_, _, src_b) = palette::DEEPSEEK_SKY else {
|
||||
panic!("DEEPSEEK_SKY must be RGB");
|
||||
let Color::Rgb(_, _, src_b) = palette::WHALE_INFO else {
|
||||
panic!("WHALE_INFO must be RGB");
|
||||
};
|
||||
if b < src_b {
|
||||
saw_dimmed = true;
|
||||
|
||||
@@ -535,7 +535,7 @@ fn file_line_style(text: &str) -> Option<Style> {
|
||||
{
|
||||
Some(
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(ratatui::style::Modifier::UNDERLINED),
|
||||
)
|
||||
} else {
|
||||
@@ -548,7 +548,7 @@ fn file_line_style(text: &str) -> Option<Style> {
|
||||
/// Returns the appropriate style for the line based on its prefix:
|
||||
/// - Lines starting with `+` (after trimming) => `palette::DIFF_ADDED` (green)
|
||||
/// - Lines starting with `-` (after trimming) => `palette::STATUS_ERROR` (red)
|
||||
/// - Lines starting with `@@` => `palette::DEEPSEEK_SKY` (cyan/blue)
|
||||
/// - Lines starting with `@@` => `palette::WHALE_INFO` (cyan/blue)
|
||||
/// - All other lines => None (use default style)
|
||||
fn diff_line_style(text: &str) -> Option<Style> {
|
||||
let trimmed = text.trim_start();
|
||||
|
||||
@@ -465,7 +465,7 @@ impl HotbarSetupView {
|
||||
Span::styled(
|
||||
value,
|
||||
Style::default().fg(if self.filter_focused {
|
||||
palette::DEEPSEEK_SKY
|
||||
palette::WHALE_INFO
|
||||
} else {
|
||||
palette::TEXT_PRIMARY
|
||||
}),
|
||||
@@ -530,7 +530,7 @@ impl HotbarSetupView {
|
||||
text,
|
||||
Style::default()
|
||||
.fg(if selected {
|
||||
palette::DEEPSEEK_SKY
|
||||
palette::WHALE_INFO
|
||||
} else {
|
||||
palette::TEXT_PRIMARY
|
||||
})
|
||||
@@ -795,7 +795,7 @@ impl ModalView for HotbarSetupView {
|
||||
.title(" Hotbar setup ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
.style(Style::default().bg(palette::WHALE_BG));
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
|
||||
@@ -1305,7 +1305,7 @@ mod tests {
|
||||
assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
|
||||
@@ -525,7 +525,7 @@ impl ModalView for LiveTranscriptOverlay {
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
@@ -943,7 +943,7 @@ mod tests {
|
||||
assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ pub fn render_parsed_tagged(
|
||||
match &parsed.blocks[i] {
|
||||
Block::Heading { text, .. } => {
|
||||
let style = Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
out.extend(render_wrapped_line_tagged(text, width, style, false, false));
|
||||
}
|
||||
@@ -267,7 +267,7 @@ pub fn render_parsed_tagged(
|
||||
});
|
||||
}
|
||||
Block::ListItem { bullet, text } => {
|
||||
let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY);
|
||||
let bullet_style = Style::default().fg(palette::WHALE_INFO);
|
||||
out.extend(render_list_line_tagged(
|
||||
bullet,
|
||||
text,
|
||||
@@ -278,7 +278,7 @@ pub fn render_parsed_tagged(
|
||||
}
|
||||
Block::Code { line } => {
|
||||
let code_style = Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::ITALIC);
|
||||
out.extend(render_wrapped_line_tagged(
|
||||
line, width, code_style, true, true,
|
||||
|
||||
@@ -390,7 +390,7 @@ impl ModelPickerView {
|
||||
focused: bool,
|
||||
) {
|
||||
let border_style = if focused {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY)
|
||||
Style::default().fg(palette::WHALE_INFO)
|
||||
} else {
|
||||
Style::default().fg(palette::BORDER_COLOR)
|
||||
};
|
||||
@@ -904,12 +904,12 @@ impl ModelPickerView {
|
||||
ModelListView::Catalog => " Model & thinking · all ",
|
||||
},
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
.style(Style::default().bg(palette::WHALE_BG));
|
||||
let inner = outer.inner(popup_area);
|
||||
outer.render(popup_area, buf);
|
||||
|
||||
@@ -2235,7 +2235,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
// No row exceeds the frame width (no horizontal overflow).
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
|
||||
Line::from(Span::styled(
|
||||
app.tr(MessageId::OnboardApiKeyTitle).to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
|
||||
Line::from(Span::styled(
|
||||
app.tr(MessageId::OnboardLanguageTitle).to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
|
||||
@@ -33,7 +33,7 @@ pub const ONBOARDING_PROVIDER_OPTIONS: &[(char, ApiProvider)] = &[
|
||||
];
|
||||
|
||||
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
let block = Block::default().style(Style::default().bg(palette::WHALE_BG));
|
||||
f.render_widget(block, area);
|
||||
|
||||
const TOP_MARGIN: u16 = 2;
|
||||
@@ -66,7 +66,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_SLATE))
|
||||
.style(Style::default().bg(palette::WHALE_PANEL))
|
||||
.padding(Padding::new(2, 2, 1, 1));
|
||||
if !app.onboarding_workspace_trust_gate {
|
||||
let (step, total) = onboarding_step(app);
|
||||
@@ -123,7 +123,7 @@ pub fn tips_lines(app: &App) -> Vec<ratatui::text::Line<'static>> {
|
||||
Line::from(Span::styled(
|
||||
app.tr(MessageId::OnboardTipsTitle).to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
@@ -304,7 +304,7 @@ fn provider_lines(app: &App) -> Vec<ratatui::text::Line<'static>> {
|
||||
Line::from(Span::styled(
|
||||
app.tr(MessageId::OnboardProviderTitle).to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
|
||||
lines.push(Line::from(Span::styled(
|
||||
app.tr(MessageId::OnboardTrustTitle).to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
@@ -388,7 +388,7 @@ impl ModalView for PagerView {
|
||||
.title(self.title.clone())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
@@ -471,7 +471,7 @@ impl ModalView for PagerView {
|
||||
visible_lines.push(Line::from(Span::styled(
|
||||
prompt,
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
} else if !self.search_matches.is_empty() {
|
||||
@@ -1068,7 +1068,7 @@ mod tests {
|
||||
assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ fn modal_block() -> Block<'static> {
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1))
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ impl ModalView for PlanPromptView {
|
||||
let confirm_lines = vec![
|
||||
Line::from(Span::styled(
|
||||
"Exit without implementing?",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
@@ -323,7 +323,7 @@ impl ModalView for PlanPromptView {
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" y — Yes, exit Plan mode",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
" n / Esc — Cancel, go back to plan",
|
||||
@@ -331,10 +331,10 @@ impl ModalView for PlanPromptView {
|
||||
)),
|
||||
];
|
||||
let confirm_footer = Line::from(vec![
|
||||
Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled(" y ", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("n / Esc", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
|
||||
]);
|
||||
let popup_area = centered_rect(66, 34, area);
|
||||
@@ -352,7 +352,7 @@ impl ModalView for PlanPromptView {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Action required",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)]));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Choose what should happen after this plan.",
|
||||
@@ -413,24 +413,24 @@ impl ModalView for PlanPromptView {
|
||||
};
|
||||
footer_spans.push(Span::styled(
|
||||
scroll_text,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
));
|
||||
}
|
||||
if compact_footer {
|
||||
footer_spans.extend([
|
||||
Span::styled("↑↓", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("↑↓", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::raw(" "),
|
||||
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
]);
|
||||
} else {
|
||||
footer_spans.extend([
|
||||
Span::styled("↑/↓", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("↑/↓", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" move ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" choose ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ fn push_plan_snapshot_lines(
|
||||
if !plan.items.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Plan steps:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
for (i, item) in plan.items.iter().enumerate() {
|
||||
let status_mark = match item.status {
|
||||
@@ -556,7 +556,7 @@ fn push_plan_snapshot_lines(
|
||||
} else if show_empty {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Plan steps:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Not provided",
|
||||
@@ -581,7 +581,7 @@ fn push_todo_snapshot_lines(
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("Checklist ({}% complete):", todos.completion_pct),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
for (i, item) in todos.items.iter().enumerate() {
|
||||
let status_mark = match item.status {
|
||||
@@ -628,7 +628,7 @@ fn push_plan_text(
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{label}:"),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
let (value, style) = value.map_or_else(
|
||||
|| {
|
||||
@@ -662,7 +662,7 @@ fn push_plan_list(
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{label}:"),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
if values.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
@@ -844,7 +844,7 @@ mod tests {
|
||||
let blank_interior_y = popup_area.y + 2;
|
||||
let blank = &buf[(blank_interior_x, blank_interior_y)];
|
||||
assert_eq!(blank.symbol(), " ");
|
||||
assert_eq!(blank.bg, palette::DEEPSEEK_INK);
|
||||
assert_eq!(blank.bg, palette::WHALE_BG);
|
||||
|
||||
let mut rendered_popup = String::new();
|
||||
for y in popup_area.y..popup_area.y.saturating_add(popup_area.height) {
|
||||
|
||||
@@ -1431,12 +1431,12 @@ impl ProviderPickerView {
|
||||
.title(Line::from(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
.style(Style::default().bg(palette::WHALE_BG));
|
||||
let inner = outer.inner(area);
|
||||
outer.render(area, buf);
|
||||
|
||||
@@ -1635,12 +1635,12 @@ impl ProviderPickerView {
|
||||
format!(" API key — {} ", row.display_name)
|
||||
},
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
.style(Style::default().bg(palette::WHALE_BG));
|
||||
let inner = outer.inner(area);
|
||||
outer.render(area, buf);
|
||||
|
||||
@@ -1726,12 +1726,12 @@ impl ProviderPickerView {
|
||||
.title(Line::from(Span::styled(
|
||||
" Custom provider ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
.style(Style::default().bg(palette::WHALE_BG));
|
||||
let inner = outer.inner(area);
|
||||
outer.render(area, buf);
|
||||
|
||||
@@ -1806,7 +1806,7 @@ impl ProviderPickerView {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let label_style = if selected {
|
||||
Self::selected_row_style(palette::DEEPSEEK_SKY)
|
||||
Self::selected_row_style(palette::WHALE_INFO)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_MUTED)
|
||||
};
|
||||
@@ -3621,7 +3621,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
// No row exceeds the frame width (no horizontal overflow).
|
||||
|
||||
@@ -32,7 +32,7 @@ fn modal_block(title: &str) -> Block<'static> {
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1))
|
||||
}
|
||||
|
||||
@@ -654,7 +654,7 @@ fn build_list_lines(
|
||||
} else if let Some(status) = status {
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate(status, width),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1545,7 +1545,7 @@ mod tests {
|
||||
assert!(!text.contains('X'), "{w}x{h}: background bleed-through");
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
|
||||
@@ -2849,7 +2849,7 @@ impl ModalView for SetupWizardView {
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_SLATE))
|
||||
.style(Style::default().bg(palette::WHALE_PANEL))
|
||||
.padding(Padding::new(2, 2, 1, 1));
|
||||
let inner = block.inner(popup_area);
|
||||
block.render(popup_area, buf);
|
||||
@@ -2960,7 +2960,7 @@ impl ModalView for SetupWizardView {
|
||||
Line::from(Span::styled(
|
||||
tr(self.locale, spec.title_id()).to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
@@ -5665,7 +5665,7 @@ mod tests {
|
||||
"{w}x{h}: background bleed-through into setup modal"
|
||||
);
|
||||
assert!(
|
||||
[palette::DEEPSEEK_INK, palette::DEEPSEEK_SLATE].contains(&buf[(w / 2, h / 2)].bg),
|
||||
[palette::WHALE_BG, palette::WHALE_PANEL].contains(&buf[(w / 2, h / 2)].bg),
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
|
||||
@@ -9090,7 +9090,7 @@ async fn submit_or_steer_message(
|
||||
engine_handle: &EngineHandle,
|
||||
message: QueuedMessage,
|
||||
) -> Result<()> {
|
||||
match app.decide_submit_disposition() {
|
||||
match app.enter_with_double_tap().unwrap_or(SubmitDisposition::Immediate) {
|
||||
SubmitDisposition::Immediate => {
|
||||
dispatch_user_message(app, config, engine_handle, message).await
|
||||
}
|
||||
@@ -9588,7 +9588,7 @@ fn render(f: &mut Frame, app: &mut App, config: &Config) {
|
||||
.fg(palette::TEXT_MUTED)
|
||||
} else {
|
||||
Style::default()
|
||||
.bg(palette::DEEPSEEK_SLATE)
|
||||
.bg(palette::WHALE_PANEL)
|
||||
.fg(palette::TEXT_MUTED)
|
||||
};
|
||||
|
||||
@@ -11793,7 +11793,7 @@ fn terminal_event_needs_viewport_recapture(evt: &Event) -> bool {
|
||||
|
||||
pub(crate) fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
|
||||
match level {
|
||||
StatusToastLevel::Info => palette::DEEPSEEK_SKY,
|
||||
StatusToastLevel::Info => palette::WHALE_INFO,
|
||||
StatusToastLevel::Success => palette::STATUS_SUCCESS,
|
||||
StatusToastLevel::Warning => palette::STATUS_WARNING,
|
||||
StatusToastLevel::Error => palette::STATUS_ERROR,
|
||||
|
||||
@@ -19,7 +19,7 @@ fn modal_block(title: &str) -> Block<'static> {
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1))
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ impl ModalView for UserInputView {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Action required",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
@@ -417,10 +417,10 @@ impl ModalView for UserInputView {
|
||||
lines.push(Line::from(""));
|
||||
if self.mode == InputMode::OtherInput {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
|
||||
]));
|
||||
} else {
|
||||
@@ -434,34 +434,34 @@ impl ModalView for UserInputView {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
quick_pick_label,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
),
|
||||
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Space", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Space", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" toggle", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" toggle/confirm", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
quick_pick_label,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
),
|
||||
Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Up/Down", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Enter", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
|
||||
Span::styled("Esc", Style::default().fg(palette::WHALE_INFO).bold()),
|
||||
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ impl ModalView for FleetRosterView {
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -219,7 +219,7 @@ impl ModalView for FleetRosterView {
|
||||
let header = vec![
|
||||
Line::from(Span::styled(
|
||||
"The saved party",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"Built-ins < config [fleet.profiles] < project .codewhale/agents. s edits.",
|
||||
@@ -336,7 +336,7 @@ impl FleetRosterView {
|
||||
fn detail_field(lines: &mut Vec<Line<'static>>, label: &str, body: String) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
label.to_string(),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
body,
|
||||
|
||||
@@ -531,7 +531,7 @@ impl ModalView for FleetSetupView {
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -598,7 +598,7 @@ impl FleetSetupView {
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
title,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
subtitle,
|
||||
@@ -624,7 +624,7 @@ impl FleetSetupView {
|
||||
let section = |lines: &mut Vec<Line>, label: &str, body: String| {
|
||||
lines.push(Line::from(Span::styled(
|
||||
label.to_string(),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
body,
|
||||
|
||||
@@ -269,7 +269,7 @@ fn modal_block() -> Block<'static> {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1))
|
||||
}
|
||||
|
||||
@@ -419,7 +419,7 @@ impl ModalView for HelpView {
|
||||
lines.push(Line::from(Span::styled(
|
||||
query_label,
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
|
||||
@@ -876,7 +876,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
|
||||
@@ -87,7 +87,7 @@ pub(crate) fn render_modal_surface(area: Rect, popup_area: Rect, buf: &mut Buffe
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
Block::default()
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.render(popup_area, buf);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ fn render_modal_backdrop(area: Rect, buf: &mut Buffer) {
|
||||
for x in area.left()..area.right() {
|
||||
buf[(x, y)]
|
||||
.set_symbol(" ")
|
||||
.set_style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
.set_style(Style::default().bg(palette::WHALE_BG));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,7 @@ impl ActionHint {
|
||||
Span::styled(
|
||||
format!(" {} ", self.key),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
@@ -1930,8 +1930,8 @@ fn render_config_editor_value_line(
|
||||
));
|
||||
|
||||
let cursor_style = Style::default()
|
||||
.fg(palette::DEEPSEEK_INK)
|
||||
.bg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_BG)
|
||||
.bg(palette::WHALE_INFO)
|
||||
.bold();
|
||||
let selected_style = Style::default()
|
||||
.fg(palette::SELECTION_TEXT)
|
||||
@@ -2099,7 +2099,7 @@ impl ModalView for ConfigView {
|
||||
let base_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = base_block.inner(popup_area);
|
||||
@@ -2118,7 +2118,7 @@ impl ModalView for ConfigView {
|
||||
};
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
edit_title,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)]));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
@@ -2215,7 +2215,7 @@ impl ModalView for ConfigView {
|
||||
ConfigListItem::Section(section) => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", section.label(self.locale)),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
}
|
||||
ConfigListItem::Row(idx) => {
|
||||
@@ -2311,7 +2311,7 @@ impl ModalView for ConfigView {
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -2550,13 +2550,13 @@ impl ModalView for SubAgentsView {
|
||||
("Running", running.len(), palette::STATUS_WARNING),
|
||||
("Completed", completed.len(), palette::STATUS_SUCCESS),
|
||||
("Interrupted", interrupted.len(), palette::STATUS_WARNING),
|
||||
("Failed", failed.len(), palette::DEEPSEEK_RED),
|
||||
("Failed", failed.len(), palette::WHALE_ERROR),
|
||||
("Cancelled", cancelled.len(), palette::TEXT_MUTED),
|
||||
];
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Fleet workers",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
Style::default().fg(palette::WHALE_INFO).bold(),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Sub-agent roles are Fleet worker roles.",
|
||||
@@ -2629,7 +2629,7 @@ impl ModalView for SubAgentsView {
|
||||
append_subagent_group(
|
||||
&mut lines,
|
||||
"Failed",
|
||||
palette::DEEPSEEK_RED.into(),
|
||||
palette::WHALE_ERROR.into(),
|
||||
&failed,
|
||||
content_width,
|
||||
);
|
||||
@@ -2662,13 +2662,13 @@ impl ModalView for SubAgentsView {
|
||||
.title_bottom(
|
||||
Line::from(Span::styled(
|
||||
scroll_indicator,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
))
|
||||
.alignment(Alignment::Right),
|
||||
)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -2748,7 +2748,7 @@ fn append_subagent_group(
|
||||
let detail = truncate_view_text(detail, max_len);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" reason: ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(detail, Style::default().fg(palette::DEEPSEEK_RED)),
|
||||
Span::styled(detail, Style::default().fg(palette::WHALE_ERROR)),
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -2757,7 +2757,7 @@ fn append_subagent_group(
|
||||
let role = truncate_view_text(role, max_len);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" role: ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(role, Style::default().fg(palette::DEEPSEEK_SKY)),
|
||||
Span::styled(role, Style::default().fg(palette::WHALE_INFO)),
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -2776,7 +2776,7 @@ fn append_subagent_group(
|
||||
let branch_detail = truncate_view_text(&branch_detail, max_len);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" git: ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled(branch_detail, Style::default().fg(palette::DEEPSEEK_SKY)),
|
||||
Span::styled(branch_detail, Style::default().fg(palette::WHALE_INFO)),
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -2824,7 +2824,7 @@ fn format_agent_status(
|
||||
use ratatui::style::Style;
|
||||
|
||||
match status {
|
||||
SubAgentStatus::Running => ("running", Style::default().fg(palette::DEEPSEEK_SKY), None),
|
||||
SubAgentStatus::Running => ("running", Style::default().fg(palette::WHALE_INFO), None),
|
||||
SubAgentStatus::Completed => (
|
||||
"completed",
|
||||
Style::default().fg(palette::WHALE_ACCENT_PRIMARY),
|
||||
@@ -2843,7 +2843,7 @@ fn format_agent_status(
|
||||
),
|
||||
SubAgentStatus::Failed(reason) => (
|
||||
"failed",
|
||||
Style::default().fg(palette::DEEPSEEK_RED),
|
||||
Style::default().fg(palette::WHALE_ERROR),
|
||||
Some(reason.as_str()),
|
||||
),
|
||||
}
|
||||
@@ -2936,7 +2936,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
@@ -4059,7 +4059,7 @@ base_url = "https://api.xiaomimimo.com/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
cell.bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"backdrop at ({x},{y}) must be opaque"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,12 +102,12 @@ impl ModalView for ModePickerView {
|
||||
.title(Line::from(Span::styled(
|
||||
" Mode ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -242,7 +242,7 @@ mod tests {
|
||||
let center = &buf[(w / 2, h / 2)];
|
||||
assert_eq!(
|
||||
center.bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
|
||||
|
||||
@@ -184,12 +184,12 @@ impl ModalView for StatusPickerView {
|
||||
.title(Line::from(Span::styled(
|
||||
tr(self.locale, MessageId::StatusPickerTitle),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
@@ -455,7 +455,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(w / 2, h / 2)].bg,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
"{w}x{h}: modal interior must be opaque"
|
||||
);
|
||||
for (y, row) in rows.iter().enumerate() {
|
||||
|
||||
@@ -333,7 +333,7 @@ impl FanoutCard {
|
||||
Span::styled(
|
||||
self.dot_grid(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn footer_agents_chip(running: usize, locale: Locale) -> Vec<Span<'static>>
|
||||
};
|
||||
vec![Span::styled(
|
||||
text,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -661,7 +661,7 @@ impl Renderable for FooterWidget {
|
||||
let spacer_span = match self.props.working_strip_frame {
|
||||
Some(frame) if spacer_width > 0 => Span::styled(
|
||||
footer_working_strip_string(spacer_width, frame),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
),
|
||||
_ => Span::raw(" ".repeat(spacer_width)),
|
||||
};
|
||||
@@ -878,7 +878,7 @@ mod tests {
|
||||
&app,
|
||||
None,
|
||||
"busy",
|
||||
palette::DEEPSEEK_SKY,
|
||||
palette::WHALE_INFO,
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
@@ -887,7 +887,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(props.state_label, "busy");
|
||||
assert_eq!(props.state_color, palette::DEEPSEEK_SKY);
|
||||
assert_eq!(props.state_color, palette::WHALE_INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1225,7 +1225,7 @@ mod tests {
|
||||
// Production state labels are `&'static str`; for tests we leak a
|
||||
// copy to match that lifetime.
|
||||
Box::leak(state.to_string().into_boxed_str()),
|
||||
palette::DEEPSEEK_SKY,
|
||||
palette::WHALE_INFO,
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
@@ -1312,7 +1312,7 @@ mod tests {
|
||||
&app,
|
||||
None,
|
||||
Box::leak(state.to_string().into_boxed_str()),
|
||||
palette::DEEPSEEK_SKY,
|
||||
palette::WHALE_INFO,
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
Vec::<Span<'static>>::new(),
|
||||
|
||||
@@ -241,7 +241,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
} else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
|
||||
palette::STATUS_WARNING
|
||||
} else {
|
||||
palette::DEEPSEEK_SKY
|
||||
palette::WHALE_INFO
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
// the chip visually grouped with `● Live` and the effort label.
|
||||
vec![Span::styled(
|
||||
frame.to_string(),
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
Style::default().fg(palette::WHALE_INFO),
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
vec![Span::styled(
|
||||
trimmed.to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]
|
||||
}
|
||||
@@ -323,7 +323,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
let color = if is_off {
|
||||
palette::TEXT_HINT
|
||||
} else {
|
||||
palette::DEEPSEEK_SKY
|
||||
palette::WHALE_INFO
|
||||
};
|
||||
let body = if !include_prefix {
|
||||
trimmed.to_string()
|
||||
@@ -381,7 +381,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
spans.push(Span::styled(
|
||||
"●",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if show_stream_label {
|
||||
@@ -623,7 +623,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"codewhale-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
),
|
||||
72,
|
||||
);
|
||||
@@ -647,7 +647,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"codewhale-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
),
|
||||
120,
|
||||
);
|
||||
@@ -668,7 +668,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"codewhale-tui",
|
||||
true,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
)
|
||||
.with_usage(1_000, Some(128_000), 0.0, Some(2_000)),
|
||||
12,
|
||||
@@ -692,7 +692,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"workspace",
|
||||
true,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
)
|
||||
.with_usage(42_000, Some(128_000), 0.0, Some(48_000)),
|
||||
72,
|
||||
@@ -706,7 +706,7 @@ mod tests {
|
||||
#[test]
|
||||
fn narrow_header_keeps_context_percent_visible() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(AppMode::Agent, "", "", true, palette::DEEPSEEK_INK).with_usage(
|
||||
HeaderData::new(AppMode::Agent, "", "", true, palette::WHALE_BG).with_usage(
|
||||
0,
|
||||
Some(128_000),
|
||||
0.0,
|
||||
@@ -726,7 +726,7 @@ mod tests {
|
||||
"deepseek-v4-flash",
|
||||
"repo",
|
||||
true,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
)
|
||||
.with_usage(1_000, Some(10_000), 0.0, Some(4_000)),
|
||||
8,
|
||||
@@ -745,7 +745,7 @@ mod tests {
|
||||
"deepseek-v4-flash",
|
||||
"repo",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
),
|
||||
48,
|
||||
);
|
||||
@@ -762,7 +762,7 @@ mod tests {
|
||||
"deepseek-v4-flash",
|
||||
"repo",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
)
|
||||
.with_usage(1_000, Some(128_000), 0.0, Some(320_000)),
|
||||
48,
|
||||
@@ -780,7 +780,7 @@ mod tests {
|
||||
"deepseek-ai/deepseek-v4-flash",
|
||||
"codewhale-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
)
|
||||
.with_provider(Some("NIM")),
|
||||
72,
|
||||
@@ -799,7 +799,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"codewhale-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
),
|
||||
72,
|
||||
);
|
||||
@@ -864,7 +864,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"codewhale-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
)
|
||||
.with_reasoning_effort(Some("max"))
|
||||
.with_status_indicator(Some("🐳")),
|
||||
@@ -895,7 +895,7 @@ mod tests {
|
||||
"deepseek-v4-pro",
|
||||
"codewhale-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
)
|
||||
.with_reasoning_effort(Some("max"))
|
||||
.with_status_indicator(None),
|
||||
|
||||
@@ -711,7 +711,7 @@ impl Renderable for ComposerWidget<'_> {
|
||||
if queue_count > 0 {
|
||||
(
|
||||
Some(format!("↵ send ({queue_count} queued)")),
|
||||
palette::DEEPSEEK_SKY,
|
||||
palette::WHALE_INFO,
|
||||
)
|
||||
} else {
|
||||
(None, palette::TEXT_MUTED)
|
||||
@@ -722,20 +722,20 @@ impl Renderable for ComposerWidget<'_> {
|
||||
(Some("↵ offline queue".to_string()), palette::STATUS_WARNING)
|
||||
} else {
|
||||
let label = if queue_count > 0 {
|
||||
format!("↵ queue ({} waiting)", queue_count.saturating_add(1))
|
||||
format!("↵ queue ({} waiting, double-↵ to steer)", queue_count.saturating_add(1))
|
||||
} else {
|
||||
"↵ queue for next turn".to_string()
|
||||
"↵ queue (double-↵ to steer)".to_string()
|
||||
};
|
||||
(Some(label), palette::TEXT_MUTED)
|
||||
}
|
||||
}
|
||||
// Steer and QueueFollowUp are now only reached via Ctrl+Enter override.
|
||||
// Steer reached via double-tap Enter or Ctrl+Enter override.
|
||||
SubmitDisposition::Steer => (
|
||||
Some("↵ steering (Ctrl+Enter)".to_string()),
|
||||
palette::DEEPSEEK_SKY,
|
||||
Some("↵ steering".to_string()),
|
||||
palette::WHALE_INFO,
|
||||
),
|
||||
SubmitDisposition::QueueFollowUp => (
|
||||
Some("↵ queued (Ctrl+Enter to steer)".to_string()),
|
||||
Some("↵ queued (double-↵ to steer)".to_string()),
|
||||
palette::TEXT_MUTED,
|
||||
),
|
||||
};
|
||||
@@ -1020,7 +1020,7 @@ impl Renderable for ComposerWidget<'_> {
|
||||
|
||||
// Name column
|
||||
let name_style = if entry.is_skill && !is_selected {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY)
|
||||
Style::default().fg(palette::WHALE_INFO)
|
||||
} else {
|
||||
sel_style
|
||||
};
|
||||
@@ -1204,7 +1204,7 @@ impl<'a> ApprovalWidget<'a> {
|
||||
Span::styled(
|
||||
format!(" {} ", stakes_badge_text(stakes, locale)),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_INK)
|
||||
.fg(palette::WHALE_BG)
|
||||
.bg(palette_colors.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
@@ -1212,7 +1212,7 @@ impl<'a> ApprovalWidget<'a> {
|
||||
Span::styled(
|
||||
self.request.tool_name.clone(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
@@ -1407,7 +1407,7 @@ impl Renderable for ApprovalWidget<'_> {
|
||||
let line = Line::from(Span::styled(
|
||||
summary,
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_INK)
|
||||
.fg(palette::WHALE_BG)
|
||||
.bg(palette_colors.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
@@ -1431,7 +1431,7 @@ impl Renderable for ApprovalWidget<'_> {
|
||||
// approval is no longer a full-screen takeover (#3799).
|
||||
Clear.render(region, buf);
|
||||
Block::default()
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.render(region, buf);
|
||||
|
||||
// Top separator rule, risk-tinted, so the prompt reads as a distinct
|
||||
@@ -1648,7 +1648,7 @@ fn paint_left_rail(card: Rect, buf: &mut Buffer, color: Color) {
|
||||
}
|
||||
let cell = &mut buf[(rail_x, y)];
|
||||
cell.set_char('\u{2503}'); // ┃ — heavy bar so the warning reads at a glance
|
||||
cell.set_style(Style::default().fg(color).bg(palette::DEEPSEEK_INK));
|
||||
cell.set_style(Style::default().fg(color).bg(palette::WHALE_BG));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1664,18 +1664,18 @@ fn approval_palette(stakes: crate::tui::approval::ApprovalStakes) -> ApprovalCol
|
||||
match stakes {
|
||||
ApprovalStakes::Routine => ApprovalColors {
|
||||
border: palette::BORDER_COLOR,
|
||||
accent: palette::DEEPSEEK_SKY,
|
||||
shortcut: palette::DEEPSEEK_SKY,
|
||||
accent: palette::WHALE_INFO,
|
||||
shortcut: palette::WHALE_INFO,
|
||||
},
|
||||
// Ordinary state-touching work: a calm ask, not an alarm.
|
||||
ApprovalStakes::Elevated => ApprovalColors {
|
||||
border: palette::BORDER_COLOR,
|
||||
accent: palette::STATUS_WARNING,
|
||||
shortcut: palette::DEEPSEEK_SKY,
|
||||
shortcut: palette::WHALE_INFO,
|
||||
},
|
||||
ApprovalStakes::Critical => ApprovalColors {
|
||||
border: palette::DEEPSEEK_RED,
|
||||
accent: palette::DEEPSEEK_RED,
|
||||
border: palette::WHALE_ERROR,
|
||||
accent: palette::WHALE_ERROR,
|
||||
shortcut: palette::STATUS_WARNING,
|
||||
},
|
||||
}
|
||||
@@ -1724,9 +1724,9 @@ fn category_label_for(category: ToolCategory, locale: Locale) -> (Cow<'static, s
|
||||
ToolCategory::FileWrite => palette::STATUS_WARNING,
|
||||
ToolCategory::Shell => palette::STATUS_ERROR,
|
||||
ToolCategory::Network => palette::STATUS_WARNING,
|
||||
ToolCategory::McpRead => palette::DEEPSEEK_SKY,
|
||||
ToolCategory::McpRead => palette::WHALE_INFO,
|
||||
ToolCategory::McpAction => palette::STATUS_WARNING,
|
||||
ToolCategory::Agent => palette::DEEPSEEK_SKY,
|
||||
ToolCategory::Agent => palette::WHALE_INFO,
|
||||
ToolCategory::Unknown => palette::STATUS_ERROR,
|
||||
};
|
||||
(label, color)
|
||||
@@ -1754,7 +1754,7 @@ fn push_detail_line(lines: &mut Vec<Line<'static>>, label: &str, value: &str) {
|
||||
Span::styled(
|
||||
format!("{label:<7} "),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(value.to_string(), Style::default().fg(palette::TEXT_BODY)),
|
||||
@@ -1829,7 +1829,7 @@ fn push_shell_command_lines(
|
||||
Span::styled(
|
||||
format!("{label}:"),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
@@ -2024,7 +2024,7 @@ impl Renderable for ElevationWidget<'_> {
|
||||
Span::styled(
|
||||
&self.request.tool_name,
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.fg(palette::WHALE_INFO)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
@@ -2146,7 +2146,7 @@ impl Renderable for ElevationWidget<'_> {
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.style(Style::default().bg(palette::WHALE_BG))
|
||||
.padding(Padding::uniform(1));
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
@@ -2357,7 +2357,7 @@ fn truncate_display_width(text: &str, max_width: usize) -> String {
|
||||
fn vim_mode_style(mode: VimMode) -> Style {
|
||||
let color = match mode {
|
||||
VimMode::Normal => palette::TEXT_MUTED,
|
||||
VimMode::Insert => palette::DEEPSEEK_SKY,
|
||||
VimMode::Insert => palette::WHALE_INFO,
|
||||
VimMode::Visual => palette::MODE_PLAN,
|
||||
};
|
||||
Style::default().fg(color).bold()
|
||||
|
||||
@@ -98,12 +98,12 @@ fn verify_brand_aliases_follow_whale_tokens() {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
palette::DEEPSEEK_BLUE_RGB,
|
||||
palette::WHALE_ACCENT_PRIMARY_RGB,
|
||||
palette::WHALE_ACCENT_PRIMARY_RGB
|
||||
);
|
||||
assert_eq!(palette::DEEPSEEK_BLUE, palette::WHALE_ACCENT_PRIMARY);
|
||||
assert_eq!(palette::DEEPSEEK_SKY_RGB, palette::WHALE_INFO_RGB);
|
||||
assert_eq!(palette::DEEPSEEK_RED_RGB, palette::WHALE_ERROR_RGB);
|
||||
assert_eq!(palette::WHALE_ACCENT_PRIMARY, palette::WHALE_ACCENT_PRIMARY);
|
||||
assert_eq!(palette::WHALE_INFO_RGB, palette::WHALE_INFO_RGB);
|
||||
assert_eq!(palette::WHALE_ERROR_RGB, palette::WHALE_ERROR_RGB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -111,33 +111,33 @@ fn contrast_guardrails_for_key_ui_pairs() {
|
||||
let min_readable = 4.5;
|
||||
|
||||
assert_min_contrast(
|
||||
"TEXT_BODY on DEEPSEEK_INK",
|
||||
"TEXT_BODY on WHALE_BG",
|
||||
palette::TEXT_BODY,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
min_readable,
|
||||
);
|
||||
assert_min_contrast(
|
||||
"TEXT_SECONDARY on DEEPSEEK_INK",
|
||||
"TEXT_SECONDARY on WHALE_BG",
|
||||
palette::TEXT_SECONDARY,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
min_readable,
|
||||
);
|
||||
assert_min_contrast(
|
||||
"TEXT_HINT on DEEPSEEK_INK",
|
||||
"TEXT_HINT on WHALE_BG",
|
||||
palette::TEXT_HINT,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
min_readable,
|
||||
);
|
||||
assert_min_contrast(
|
||||
"STATUS_WARNING on DEEPSEEK_INK",
|
||||
"STATUS_WARNING on WHALE_BG",
|
||||
palette::STATUS_WARNING,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
min_readable,
|
||||
);
|
||||
assert_min_contrast(
|
||||
"STATUS_ERROR on DEEPSEEK_INK",
|
||||
"STATUS_ERROR on WHALE_BG",
|
||||
palette::STATUS_ERROR,
|
||||
palette::DEEPSEEK_INK,
|
||||
palette::WHALE_BG,
|
||||
min_readable,
|
||||
);
|
||||
assert_min_contrast(
|
||||
|
||||
-248
@@ -1,248 +0,0 @@
|
||||
# CodeWhale Smoothness Playbook — Fixing the "Pop In / Pop Out & Bad Timing" Feeling
|
||||
|
||||
Audience: the CodeWhale TUI team. Scope: perceived motion quality — how ephemeral UI (overlays, modals, spinners, toasts, streaming text, transcript cells, sidebar panels) appears, disappears, and coordinates in time. This is the choreography layer that the prior `CODEWHALE_PERFORMANCE_MOTION_AUDIT.md` under-covers. It does not re-litigate raw render micro-perf except where perf causes a *visible* flash or stutter.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
**There is exactly one root cause, and it has two faces.**
|
||||
|
||||
CodeWhale has **no animation clock**. Redraw is on-demand: `App::needs_redraw: bool` (`app/…`, flipped in 83 places in `app.rs` alone) is the *only* signal that a frame should be produced, and `frame_rate_limiter.rs` is a pure *throttle* — `clamp_deadline()` (frame_rate_limiter.rs:55–63) only pushes a requested draw *later* so two draws never land closer than `MIN_FRAME_INTERVAL` (8.33ms, frame_rate_limiter.rs:36). Nothing ever ticks the UI *forward* in time on its own. Because state can only advance when an event arrives, **every state change is a single-frame snap**: an overlay is absent on frame N and fully present on frame N+1. There are no in-between frames to ease through, so nothing can fade, slide, or grow. This is *face one*: **no clock → everything snaps.**
|
||||
|
||||
Layered on top is *face two*: **no shared timing scale.** There are 40+ magic durations scattered across ~30 files (spinner 80ms, forced-repaint cadence 80ms, streaming hysteresis 1200ms/300ms/250ms, toast TTLs 4s/5s/15s, receipt 8s, version hint 12s, thinking throttle 100ms) with no central module and no easing vocabulary anywhere in the crate. Even if two surfaces *did* animate, they'd move at unrelated tempos. This is why the UI feels "uncoordinated" even in the places that technically do move (the spinner and the footer working-strip drift against each other).
|
||||
|
||||
Put together: **things pop instead of transition (no clock), and the few things that do move feel unrelated (no scale).** That is the entire complaint.
|
||||
|
||||
The 3–4 highest-leverage moves, in order:
|
||||
|
||||
1. **Build the animation clock.** A single scheduler that, whenever anything is mid-transition, forces a redraw at a steady ~30–60fps and idles otherwise. This is the keystone — nothing else in the "smooth" column is possible without it. (Section 3.)
|
||||
2. **Add a `motion::timing` token module** — `INSTANT/FAST/BASE/SLOW` durations, a 2–3 curve easing set, and the spinner delay/min-visible constants — and route the existing 40+ magic numbers through it. (Section 3.)
|
||||
3. **Ship the quick wins that need no clock first** — spinner show-delay + min-visible-time, unified 80ms spinner cadence, kill the full-terminal-clear on resize/focus, and minimum-dwell on toasts. These remove the *worst* flashes immediately and buy goodwill while the clock lands. (Section 5A.)
|
||||
4. **Give each surface a real enter/exit** — a small, uniform fade+offset on overlays/modals/toasts/panels driven by the clock and the tokens. Symmetry is the point: things should leave the way they arrived. (Section 4, Section 5B.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Why it feels ugly today — the mental model
|
||||
|
||||
CodeWhale's render loop is **event-driven snap rendering**. Walk the loop in `ui.rs`: an input or engine event arrives, a handler mutates state and sets `app.needs_redraw = true`, and the draw gate (`if needs_redraw && draw_wait.is_none()`, ui.rs:~3493–3503) paints once. Between events the loop *sleeps* on the next terminal event. `frame_rate_limiter.rs` caps how *fast* this can happen; nothing makes it happen *on a rhythm*. The consequence is structural: **a transition that should take 150ms cannot exist**, because there is no mechanism to wake the loop 5 times over those 150ms to advance it. Every "before" state is one frame; every "after" state is the next frame.
|
||||
|
||||
Everything the user perceives as "ugly timing" is a symptom of that one fact, expressed on different surfaces:
|
||||
|
||||
- **Boolean overlay toggles.** Overlays are a `ViewStack` of boxed `ModalView`s. `ViewStack::push()` (views/mod.rs:795) synchronously appends the view; the next draw renders it at full size and opacity via `render_modal_surface`/`render_modal_backdrop` (views/mod.rs:64–102), which paint an opaque block in one pass with no alpha/scale parameter. `ViewStack::apply_action()` handling `ViewAction::Close` (views/mod.rs:876–880) calls `pop()` immediately. No modal struct — `ApprovalView` (approval.rs:1329–1354), `CommandPaletteView` (command_palette.rs:54–59), `ModelPickerView` (model_picker.rs:51–76), `ContextMenuView` (context_menu.rs:34–44) — carries an `entering_at`, `exit_progress`, `opened_at`, or `min_visible` field. A grep for `animation_frame` across the whole `tui/` tree returns zero hits. So the command palette, model/provider/session/file pickers, slash menu, feedback picker, pager, backtrack, context inspector, and the **approval modal** (the single most weighty surface, pushed at ui.rs:10410, popped on `EmitAndClose` at approval.rs:1405) all pop and vanish in one frame, backdrop included.
|
||||
|
||||
- **Flashing / stuttering spinners.** `braille_spinner_frame()` (spinner.rs:40–50) derives its frame index purely from `started.elapsed() / 80ms`. It is only ever called *inside a render pass*. So the spinner only advances when *something else* triggers a redraw. If the app is idle for >80ms — network stall, user not typing — `needs_redraw` stays false, the spinner freezes, and when an unrelated event finally fires it jumps several frames at once (visible stutter). Worse, there is **no show-delay and no min-visible-time**: a tool status flips to `Running` and the spinner renders immediately (history.rs:1605–1612, 1710–1735), and flips to `Success` and it vanishes immediately (footer_ui.rs:258–264 gates purely on `is_loading`; sidebar.rs:1680–1686 returns `None` the instant status != running). A sub-100ms op flashes a glyph for one or two frames — reads as a glitch, not "working."
|
||||
|
||||
- **Inconsistent spinner tempos.** The braille spinner runs at 80ms/frame (spinner.rs:15). The footer working-label uses `now_ms / 400` (footer_ui.rs:267–268) — a different cadence entirely. Sidebar task spinners sample `braille_spinner_frame_for_duration_ms(task.duration_ms)` (sidebar.rs:1683) independently. Each surface reads the wall clock on its own with no shared frame counter, so when the footer, an active tool card, and a sidebar task are all spinning at once they visibly beat against each other.
|
||||
|
||||
- **Layout jumps.** New transcript cells materialize at full height in a single frame: `flatten_from()` appends the cell's full line vector at once (transcript.rs:282–299), shoving existing content upward. When sticky-to-bottom is active, `scroll.set(max_scroll)` fires unconditionally every frame (live_transcript.rs:556–558) — the view jerks down with no ease. Thinking blocks toggle collapsed/expanded via a boolean (history/thinking.rs:84), snapping between fixed line limits (4/6/8 lines, history/thinking.rs:20–22) with no height interpolation. On `flush_active_cell` (app.rs:3857–3912) the finalized cells are appended with no height reservation, so the transcript grows under the reader. Sidebar panels appear/disappear by recomputing `Layout::split()` constraints every frame from boolean predicates (`auto_sidebar_panels`, sidebar.rs:219–237; `sidebar_auto_idle`, sidebar.rs:195–201) — width and panel count snap with zero intermediate frames.
|
||||
|
||||
- **Transients with no lifecycle.** `StatusToast` (app.rs:482–487) is `{ text, level, created_at, ttl_ms }` — no phase field. `active_status_toast()` (app.rs:4165–4195) hard-drops a toast the instant `is_expired()` (app.rs:501–504) is true; the footer renders it at full opacity with no fade (footer_ui.rs / widgets/footer.rs:560–572). There is no *minimum* display floor, so an error that clears in <100ms can flash and vanish before the eye registers it. Retry banners (`retry_status.rs:22–49`, footer.rs:582–601) are identical: a bare `Idle→Active→Idle` enum toggle with no phase and no dwell. Receipts (8s, app.rs:3978), version hints (12s, ui.rs:190), and file-mention candidates (4s, file_mention.rs:246) all vanish the same instant their TTL trips.
|
||||
|
||||
- **Scattered magic durations.** The 40+ constants above live in ~30 files with no `motion` module and **no easing functions anywhere in the codebase**. Values span 5ms to 12,000ms — a ~2400× spread — with no tokenized fast/base/slow. Even the two intentionally-decoupled cadences (120fps render cap vs. 80ms spinner) are undocumented as to *why* they differ, so future contributors will keep inventing new magic numbers.
|
||||
|
||||
- **Resize/focus flash.** On resize and on focus-regain the code calls `draw_app_frame_inner(full_repaint = true)`, which unconditionally issues `terminal.clear()` (ui.rs:9547–9550) — an `ESC[2J` that blanks the whole viewport before ratatui repaints. This is a literal black flash, and on a fast drag it fires repeatedly. On top of the flash, the full transcript per-cell cache is cleared synchronously on any width change (transcript.rs:163–167), re-wrapping every cell on the render thread — the sluggish re-flow the user feels while dragging.
|
||||
|
||||
The through-line: **the codebase has all the *state* for these surfaces and none of the *time*.** Add time, and the ugliness has somewhere to go.
|
||||
|
||||
---
|
||||
|
||||
## 3. The foundational fix — an animation clock + a `motion::timing` token module
|
||||
|
||||
Everything in Section 4 depends on this. Build it first.
|
||||
|
||||
### 3.1 The animation clock / scheduler
|
||||
|
||||
**Goal:** while anything is animating, the loop ticks at a steady 30–60fps; when nothing is animating, it idles exactly as it does today (zero cost when the UI is at rest — this must not become a busy-loop).
|
||||
|
||||
Concrete shape:
|
||||
|
||||
```rust
|
||||
// crates/tui/src/tui/motion/clock.rs
|
||||
pub struct AnimationClock {
|
||||
started: Instant, // fixed origin; all progress is measured from here
|
||||
active: Vec<AnimationHandle>, // in-flight transitions
|
||||
}
|
||||
|
||||
impl AnimationClock {
|
||||
pub fn now_ms(&self) -> u128 { self.started.elapsed().as_millis() }
|
||||
pub fn is_animating(&self) -> bool { !self.active.is_empty() }
|
||||
/// Next instant the loop MUST wake to advance an animation, if any.
|
||||
pub fn next_deadline(&self, now: Instant) -> Option<Instant> { /* now + tick */ }
|
||||
pub fn retire_completed(&mut self, now_ms: u128) { /* drop finished */ }
|
||||
}
|
||||
```
|
||||
|
||||
Integration points (all already exist, all one-line hooks):
|
||||
|
||||
- **Wake the loop.** In the `poll_timeout` computation in `ui.rs` (the block near ui.rs:3506–3537 that already shortens the timeout for `is_loading`, `stream_display_clock`, etc.), fold in `min(poll_timeout, clock.next_deadline())`. When any animation is in flight, the loop now wakes at the next frame boundary even with zero user input. This is the whole trick — it converts "sleep until an event" into "sleep until the next animation frame *or* an event, whichever is sooner."
|
||||
- **Advance and request redraw.** After the frame limiter's `mark_emitted`, if `clock.is_animating()` set `needs_redraw = true` for the following frame. When the last animation retires, stop forcing — idle returns.
|
||||
- **Read progress in render.** Give render access to `clock.now_ms()`. Each animated surface computes its own `progress = ((now_ms - started_ms) / duration_ms).clamp(0.0, 1.0)` and passes it through easing. Because every surface samples the *same* `now_ms`, all motion is in lockstep by construction — this alone fixes the spinner-vs-footer beat problem.
|
||||
|
||||
Tick rate: **30–50ms (20–33fps) is enough for terminal motion** and stays comfortably inside the existing 120fps limiter and the 30fps low-motion cap (frame_rate_limiter.rs:36, LOW_MOTION at 33.33ms). Honor `low_motion`: when set, either skip enter/exit easing (snap, but still respect min-visible-time) or run at the 30fps cap. Motion must be a comfort, never a tax.
|
||||
|
||||
Effort: **large**, but it is the *only* large item that unlocks a dozen medium/small ones. Treat it as the platform.
|
||||
|
||||
### 3.2 `motion::timing` — one tokenized scale + one easing set
|
||||
|
||||
Create `crates/tui/src/tui/motion/timing.rs` and make it the single source of truth. Suggested values (tuned for a terminal, where a few discrete frames already read as "smooth"):
|
||||
|
||||
```rust
|
||||
// Durations — the fast/base/slow spine.
|
||||
pub const INSTANT: Duration = Duration::ZERO;
|
||||
pub const FAST: Duration = Duration::from_millis(120); // snappy feedback, context menus
|
||||
pub const BASE: Duration = Duration::from_millis(200); // standard overlay enter
|
||||
pub const SLOW: Duration = Duration::from_millis(320); // deliberate / weighty (approval)
|
||||
|
||||
// Enter/exit — exits are a touch quicker than enters (leave decisively).
|
||||
pub const OVERLAY_ENTER: Duration = BASE; // 200ms
|
||||
pub const OVERLAY_EXIT: Duration = Duration::from_millis(150);
|
||||
|
||||
// Spinner discipline.
|
||||
pub const SPINNER_FRAME: Duration = Duration::from_millis(80); // keep existing cadence
|
||||
pub const SPINNER_SHOW_DELAY: Duration = Duration::from_millis(150); // suppress instant ops
|
||||
pub const SPINNER_MIN_SHOW: Duration = Duration::from_millis(450); // never flicker
|
||||
|
||||
// Transient dwell (min-visible floors + fades).
|
||||
pub const TRANSIENT_MIN_VISIBLE: Duration = Duration::from_millis(350);
|
||||
pub const TOAST_FADE: Duration = FAST; // 120ms in and out
|
||||
```
|
||||
|
||||
```rust
|
||||
// crates/tui/src/tui/motion/easing.rs — the entire easing vocabulary.
|
||||
pub fn linear(t: f32) -> f32 { t }
|
||||
pub fn ease_out_cubic(t: f32) -> f32 { 1.0 - (1.0 - t).powi(3) } // enters: fast then settle
|
||||
pub fn ease_in_cubic(t: f32) -> f32 { t * t * t } // exits: withdraw decisively
|
||||
pub fn step_half(t: f32) -> f32 { if t < 0.5 { 1.0 } else { 0.0 } } // cursor blink
|
||||
```
|
||||
|
||||
Rule of thumb: **enter = `ease_out_cubic` (arrives eagerly, settles gently); exit = `ease_in_cubic` (accelerates away).** Symmetry with a slightly quicker exit is what reads as "elegant" rather than "sluggish."
|
||||
|
||||
Then **route the existing magic numbers through this module.** `BRAILLE_SPINNER_FRAME_MS` (spinner.rs:15) becomes `SPINNER_FRAME`. The toast TTLs in `classify_status_text` (app.rs:4038–4080) become named dwell tokens. The forced-repaint cadence `UI_STATUS_ANIMATION_MS` (ui.rs:184) references `SPINNER_FRAME` and gets a doc comment explaining the intentional 120fps-cap / 80ms-cadence decoupling (this last one is a *trivial* documentation fix worth doing regardless). One module, imported everywhere, kills the "2400× spread of unrelated numbers" problem and gives every future contributor a vocabulary instead of a fresh literal.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-surface transitions
|
||||
|
||||
Once 3.1 and 3.2 land, apply this table uniformly. Durations reference the Section 3.2 tokens. "Enter" and "exit" are the transition; "delay/min" is the discipline that prevents flicker. Everything uses `ease_out_cubic` on enter and `ease_in_cubic` on exit unless noted.
|
||||
|
||||
| Surface | Enter | Exit | Delay / Min-visible | Notes & anchor |
|
||||
|---|---|---|---|---|
|
||||
| **Command palette / model / provider / session / file picker / slash menu / feedback / pager / backtrack / context inspector** | `BASE` 200ms: alpha 0→1 + slide-down 3–5 rows→0 | `OVERLAY_EXIT` 150ms: reverse | Min-visible `TRANSIENT_MIN_VISIBLE` 350ms (guard `pop()` in `apply_action`) | Add `entering_at`/`exit_progress` to each `ModalView`; `push` records enter, `Close` sets exit instead of removing (views/mod.rs:795, 876) |
|
||||
| **Approval modal** | `SLOW` 320ms: alpha 0→1 + slide-up 3 rows, backdrop dim 0→70% in lockstep | `OVERLAY_EXIT` 150ms | Min-visible **400–500ms** (reuse `requested_at`; block pop until elapsed) | Weightiest surface — earn the extra 120ms. Pushed ui.rs:10410, popped approval.rs:1405 |
|
||||
| **Modal backdrop** | Alpha 0→70% over enter, synced to modal | 70%→0 over exit | — | Parameterize `render_modal_backdrop` (views/mod.rs:94–102) with `opacity: f32` |
|
||||
| **Context menu** | `FAST` 120ms: scale 0.9→1.0 centered on cursor + alpha | `FAST` 120ms | — | Lightweight; punchy is correct. Scale origin = `(column, row)` (context_menu.rs:34–44) |
|
||||
| **Running spinners (tool cards, footer strip, sidebar tasks)** | Appear only after `SPINNER_SHOW_DELAY` 150ms | Hold to `SPINNER_MIN_SHOW` 450ms after first frame, then a 200ms completion glyph (✓/•) before hiding | Delay 150ms, min-show 450ms | Wrap in a `SpinnerDisplay` that tracks `first_shown_at`; gate `braille_spinner_frame` calls in history.rs:1710, footer_ui.rs:258, sidebar.rs:1680 |
|
||||
| **Spinner cadence** | — | — | Fixed `SPINNER_FRAME` 80ms sampled from `clock.now_ms()` | Replace independent wall-clock samples (footer_ui.rs:267, sidebar.rs:1683) with the shared clock so all spinners lockstep |
|
||||
| **Streaming reveal** | Paced ~2–4 chars/frame Smooth, ~8–15 CatchUp, `linear` | — | — | Add a per-frame reveal budget in `commit_tick.rs` (currently all-or-one, commit_tick.rs:154–157); optionally a `reveal_cursor`. Requires the clock to tick between SSE chunks |
|
||||
| **Streaming cursor** | — | — | Blink `step_half`, 600ms period, start after 50ms | Time-modulate `REASONING_CURSOR` (history/thinking.rs:174–176) using `clock.now_ms()` — mirror the pattern already in streaming_thinking.rs:105–119 |
|
||||
| **Thinking block collapse/expand** | Height lerp prev→target, `ease_out_cubic` | Same, symmetric | `BASE`–`SLOW` 200–280ms | Interpolate line count in `render_thinking` (history/thinking.rs:79–166) instead of snapping between fixed limits |
|
||||
| **New transcript cell** | Height grow 30%→60%→100% over 3–4 frames, or fade-in over `FAST` | — | — | Stage in `flatten_from` (transcript.rs:282–299); cheapest correct version is a 3-frame height ramp keyed off a per-cell `entered_at` |
|
||||
| **Scroll-to-bottom (sticky)** | Ease scroll last→target `ease_out_cubic` 150–200ms | — | — | Replace unconditional `scroll.set(max_scroll)` (live_transcript.rs:556–558) with an interpolated target |
|
||||
| **Status toasts** | `TOAST_FADE` 120ms fade+slide-up 0.5 row | `TOAST_FADE` 120ms fade+slide-down | Min-visible `TRANSIENT_MIN_VISIBLE` 350ms; optional 80–120ms entry delay for errors so <100ms hiccups never show | Add `phase` + `phase_started_at` to `StatusToast` (app.rs:482–487); `is_expired` becomes `elapsed > ttl + fade` |
|
||||
| **Retry banner** | `FAST` 120ms fade+scale 0.9→1 | `FAST` 120ms | Min-visible **400ms** (so instant-success retries are still seen) | Same phase pattern on `RetryState`/`RetryBanner` (retry_status.rs:22–49) |
|
||||
| **Receipts / version hint / file candidates** | fade-in `FAST` | fade-out `FAST` 150ms | Keep existing TTLs (retokenized) | Add `exit_started` (app.rs:3978, ui.rs:190, file_mention.rs:246); remove only after fade completes |
|
||||
| **Sidebar width (collapse/expand)** | Width lerp current→target `ease_out_cubic` 200ms | Same | — | Animate the `Constraint` percentage instead of flipping `sidebar_auto_idle` (sidebar.rs:195) into an instant `Layout::split` |
|
||||
| **Sidebar panels (todo/tasks/agents/context)** | fade+height-clip in, `BASE` 150ms | 120ms; keep exiting panel alive until fade done | — | Diff `auto_sidebar_panels` (sidebar.rs:219–237) frame-over-frame; record enter/exit per panel |
|
||||
| **Subagent rows** | per-row fade `FAST` with +50ms stagger between rows | 100ms | — | Add `entered_at`/`exited_at` to `SidebarAgentRow` (sidebar.rs:2488–2501); keep evicted rows alive one fade |
|
||||
| **Completed task row (8s TTL)** | — | 150ms fade before drop | Hold ≥400ms after completion before fade starts | `active_tool_row_visibility` (sidebar.rs:1728–1752) currently binary at TTL |
|
||||
| **Resize** | No fade — *eliminate the flash* (Section 5A) | — | Debounce rapid drags 150–200ms into one draw | Replace unconditional `terminal.clear()` (ui.rs:9547–9550) with selective/diff repaint |
|
||||
| **Focus change (region → region)** | Cross-fade focus indicator old→new `FAST` 150ms | — | — | `set_sidebar_focus` currently mutates the enum + `needs_redraw` with no transition |
|
||||
|
||||
---
|
||||
|
||||
## 5. Ranked recommendations
|
||||
|
||||
Two buckets. **Bucket A ships value *before* the clock exists** — do these first; they kill the worst flashes and are all small/medium. **Bucket B is the motion system** — the clock, the tokens, and the per-surface transitions that give the UI its polish.
|
||||
|
||||
### Bucket A — quick wins, no animation clock required
|
||||
|
||||
Ordered by impact-per-effort.
|
||||
|
||||
1. **Eliminate the full-terminal-clear flash on resize & focus.**
|
||||
- *Symptom fixed:* the whole viewport blanks black on every resize and every app-switch-back; repeats on a fast drag.
|
||||
- *Change:* stop calling `terminal.clear()` unconditionally when `full_repaint` is true (ui.rs:9547–9550). On focus-regain, rely on `needs_redraw` + ratatui's diff (a debounce path already exists near ui.rs:3637). On resize, only clear when the viewport *grew*; for shrink/same-size let the diff renderer handle it. Add a 150–200ms resize debounce (ui.rs:3655–3728) so a drag coalesces into one draw.
|
||||
- *Effort:* medium. Highest visible payoff of any single change.
|
||||
|
||||
2. **Spinner show-delay + min-visible-time.**
|
||||
- *Symptom fixed:* sub-100ms ops flash a spinner glyph (looks like a glitch); the strip pops out before the eye registers it.
|
||||
- *Change:* introduce a `SpinnerDisplay { first_shown_at }` wrapper. Don't render the glyph until `SPINNER_SHOW_DELAY` (150ms) has elapsed; once shown, keep it for `SPINNER_MIN_SHOW` (450ms) even if the op finished, then a brief completion marker. Gate the call sites at history.rs:1710, footer_ui.rs:258, sidebar.rs:1680. *No clock needed* — these are wall-clock comparisons in the existing render path.
|
||||
- *Effort:* small–medium.
|
||||
|
||||
3. **Unify spinner cadence.**
|
||||
- *Symptom fixed:* footer working-strip (`now_ms/400`) beats against the 80ms braille spinner and sidebar spinners.
|
||||
- *Change:* pick `SPINNER_FRAME` (80ms) as the one cadence; drive every spinner from a single `app.animation_frame` (a `u64` bumped once per redraw) instead of independent wall-clock reads (footer_ui.rs:267, sidebar.rs:1683). Interim step before the real clock; the clock later subsumes it.
|
||||
- *Effort:* small.
|
||||
|
||||
4. **Minimum-dwell + entry-suppression for toasts and retry banners.**
|
||||
- *Symptom fixed:* a network error that clears in <100ms flashes and vanishes unseen; a fast-success retry banner never registers.
|
||||
- *Change:* track `shown_at` + `min_display_duration` (350–400ms) on `StatusToast` (app.rs:482–487) and `RetryState`; refuse expiry until the floor passes regardless of TTL. For error/warning toasts, add an 80–120ms pending window so ultra-fast recoveries are discarded before they ever show (push logic at app.rs:3922). Also retokenize the TTLs (app.rs:4038–4080) through `motion::timing`.
|
||||
- *Effort:* small.
|
||||
|
||||
5. **Scroll anchoring on resize (no new flash, less disorientation).**
|
||||
- *Symptom fixed:* content appears to "jump" on resize even though scroll position is technically preserved (app.rs:4210–4216) — no visual bridge.
|
||||
- *Change:* keep the anchor line centered (±3 rows) rather than pinned to top, so the reader's eye tracks it across a re-wrap. A brief 200–300ms highlight on the anchored line is a nice-to-have but can wait for the clock.
|
||||
- *Effort:* small.
|
||||
|
||||
6. **Defer the transcript width-cache invalidation by one frame.**
|
||||
- *Symptom fixed:* perceptible stutter while dragging to a new width, as every markdown cell re-wraps synchronously on the render thread.
|
||||
- *Change:* on width change, draw once at the old width, then invalidate `per_cell` (transcript.rs:163–167) on the next loop iteration so the debounce and scroll anchoring coordinate — or move markdown re-wrap off the render thread.
|
||||
- *Effort:* medium.
|
||||
|
||||
7. **Document the 120fps-cap / 80ms-spinner decoupling.**
|
||||
- *Symptom fixed:* future contributors keep inventing magic numbers because the existing ones aren't explained.
|
||||
- *Change:* comment `frame_rate_limiter.rs:36`, `ui.rs:184`, and `spinner.rs:15` to state that the render cap prevents SSE-driven waste while the spinner cadence is the *perceptual* motion rate, and that they are intentionally independent layers.
|
||||
- *Effort:* trivial.
|
||||
|
||||
### Bucket B — the motion system
|
||||
|
||||
8. **Build the `AnimationClock` scheduler (Section 3.1).**
|
||||
- *Symptom fixed:* the root cause — nothing can ease over time, spinners stall when idle, streaming can't reveal between chunks.
|
||||
- *Change:* steady 30–50fps tick while animating, idle otherwise; fold `next_deadline()` into the `poll_timeout` calc (ui.rs:3506–3537); expose `now_ms()` to render. Honor `low_motion`.
|
||||
- *Effort:* large. The keystone.
|
||||
|
||||
9. **Add `motion::timing` + `motion::easing` and route the 40+ magic numbers through them (Section 3.2).**
|
||||
- *Symptom fixed:* "nothing feels coordinated" — one fast/base/slow scale and one easing set replace the 2400× spread.
|
||||
- *Effort:* large (mechanical breadth, low risk). Land alongside #8.
|
||||
|
||||
10. **Overlay & modal enter/exit + backdrop fade.**
|
||||
- *Symptom fixed:* every picker/palette/menu and the approval modal snap in and out; backdrop hard-cuts.
|
||||
- *Change:* `entering_at`/`exit_progress`/`opened_at` on each `ModalView`; `push` schedules enter, `Close` schedules exit and defers `pop()` until the exit completes (views/mod.rs:795, 876); `render_modal_surface`/`render_modal_backdrop` (views/mod.rs:64–102) take alpha/scale/offset. Enforce min-visible in `apply_action`.
|
||||
- *Effort:* medium (per-surface, but one shared pattern).
|
||||
|
||||
11. **Streaming reveal pacing + cursor blink.**
|
||||
- *Symptom fixed:* text arrives in bursty batches; the caret is static.
|
||||
- *Change:* per-frame char budget in `commit_tick.rs` (replace all-or-one at commit_tick.rs:154–157); blink `REASONING_CURSOR` off `clock.now_ms()` (history/thinking.rs:174–176). Requires the clock to tick between SSE chunks.
|
||||
- *Effort:* medium.
|
||||
|
||||
12. **Transcript cell grow-in, sticky-scroll easing, thinking-block height animation.**
|
||||
- *Symptom fixed:* content shoves the transcript, sticky-scroll jerks, folds snap.
|
||||
- *Change:* staged height in `flatten_from` (transcript.rs:282–299); interpolated `scroll.set` target (live_transcript.rs:556–558); height-lerp in `render_thinking` (history/thinking.rs:79–166).
|
||||
- *Effort:* large (transcript is the hottest, most cache-sensitive path — sequence carefully).
|
||||
|
||||
13. **Sidebar width/panel/row transitions + focus cross-fade.**
|
||||
- *Symptom fixed:* sidebar collapse, panel appear/disappear, and subagent rows all pop; focus jumps with no bridge.
|
||||
- *Change:* animate the width `Constraint` (sidebar.rs:195); diff panels/rows frame-over-frame with per-item enter/exit (sidebar.rs:219–237, 2488–2501); fade completed-task rows before drop (sidebar.rs:1728–1752); cross-fade focus indicator.
|
||||
- *Effort:* medium–large.
|
||||
|
||||
---
|
||||
|
||||
## 6. Prioritized checklist
|
||||
|
||||
Do them in this order. A–1 through A–7 are safe to ship independently and immediately; they remove the loudest complaints. B–8 and B–9 are the platform and should land together. B–10 onward are pure payoff once the platform exists.
|
||||
|
||||
- [ ] **A–1. Kill the full-terminal-clear flash on resize + focus; debounce rapid resizes.** *(medium — biggest single visible win)* — ui.rs:9547–9550, ui.rs:3655–3728, ui.rs:3637
|
||||
- [ ] **A–2. Spinner show-delay (150ms) + min-visible-time (450ms) + completion marker.** *(small–medium)* — history.rs:1710, footer_ui.rs:258, sidebar.rs:1680
|
||||
- [ ] **A–3. Unify all spinners to one 80ms cadence via a shared `animation_frame` counter.** *(small)* — footer_ui.rs:267, sidebar.rs:1683, spinner.rs:15
|
||||
- [ ] **A–4. Min-dwell (350–400ms) + fast-error entry-suppression on toasts & retry banners; retokenize TTLs.** *(small)* — app.rs:482–487, 3922, 4038–4080, retry_status.rs:22–49
|
||||
- [ ] **A–5. Center-anchor scroll on resize (±3 rows) so content doesn't appear to jump.** *(small)* — app.rs:4210–4216
|
||||
- [ ] **A–6. Defer transcript width-cache invalidation one frame (or move re-wrap off the render thread).** *(medium)* — transcript.rs:163–167
|
||||
- [ ] **A–7. Document the 120fps-cap / 80ms-spinner decoupling.** *(trivial)* — frame_rate_limiter.rs:36, ui.rs:184, spinner.rs:15
|
||||
- [ ] **B–8. Build `AnimationClock` — steady 30–50fps while animating, idle otherwise; wire into `poll_timeout`; honor low-motion.** *(large — keystone)* — new `motion/clock.rs`, ui.rs:3506–3537
|
||||
- [ ] **B–9. Create `motion::timing` + `motion::easing`; route the 40+ magic durations through them.** *(large, mechanical)* — new `motion/timing.rs`, `motion/easing.rs`
|
||||
- [ ] **B–10. Overlay/modal enter+exit + backdrop fade + enforced min-visible (all pickers, palette, menus, approval).** *(medium)* — views/mod.rs:64–102, 795, 876; approval.rs:1329–1354, 1405
|
||||
- [ ] **B–11. Streaming reveal pacing (per-frame char budget) + cursor blink.** *(medium)* — commit_tick.rs:154–157, history/thinking.rs:174–176
|
||||
- [ ] **B–12. Transcript cell grow-in + sticky-scroll easing + thinking-block height animation.** *(large — hot path, sequence carefully)* — transcript.rs:282–299, live_transcript.rs:556–558, history/thinking.rs:79–166
|
||||
- [ ] **B–13. Sidebar width/panel/row transitions + focus cross-fade.** *(medium–large)* — sidebar.rs:195, 219–237, 1728–1752, 2488–2501
|
||||
|
||||
The test for "done" is simple and human: open a picker, approve a tool, watch a short tool run, resize the window, and let a response stream — and none of it should *pop*. It should arrive, and it should leave the way it arrived.
|
||||
Reference in New Issue
Block a user