wip: checkpoint v0.8.67 fallback candidate

This commit is contained in:
Hunter B committed 2026-07-05 13:05:57 -07:00
1 parent b6a53c9891
commit 000c041894
17 files changed
+473 -629

No files matched your search

+6
View File
@@ -132,3 +132,9 @@ notes/
# Agent-local roadmap and scratch (never commit — may hold unreleased plans)
.agents/
scratchpad/
# Exploratory operations spec / prompt — never track (canonical copy lives at CW root)
operations9.md
OPERATIONS_0_9_0.md
operations_0_9_0.md
CODEWHALE_0_9_0_CUTOVER.md
Generated
+19 -1
View File
@@ -1021,6 +1021,7 @@ dependencies = [
"image",
"libc",
"lru 0.18.0",
"mimalloc",
"multimap",
"oauth2",
"objc2",
@@ -3009,6 +3010,15 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
]
[[package]]
name = "libredox"
version = "0.1.12"
@@ -3254,6 +3264,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -5774,7 +5793,6 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
+8 -1
View File
@@ -52,7 +52,7 @@ serde_json = "1.0.149"
semver = "1.0.28"
thiserror = "2.0"
tempfile = "3.27"
tokio = { version = "1.50.0", features = ["full"] }
tokio = { version = "1.50.0", features = ["fs", "io-util", "io-std", "macros", "net", "process", "rt", "rt-multi-thread", "signal", "sync", "time"] }
toml = "1.0.6"
toml_edit = "0.25.12"
sha2 = "0.11"
@@ -61,3 +61,10 @@ tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1.11", features = ["v4"] }
mimalloc = { version = "0.1", default-features = false }
[profile.release]
lto = true
strip = true
panic = "abort"
codegen-units = 1
+1
View File
@@ -80,6 +80,7 @@ tar = "0.4"
flate2 = "1.1"
sha2.workspace = true
rust-i18n = "4.1.0"
mimalloc.workspace = true
[build-dependencies]
codewhale-build-support = { path = "../build-support", version = "0.8.67" }
+20 -10
View File
@@ -189,7 +189,7 @@ const ALLOW_INSECURE_HTTP_ENV: &str = "DEEPSEEK_ALLOW_INSECURE_HTTP";
/// of committing to the full remaining window up front.
const RATE_LIMIT_PAUSE_RECHECK_INTERVAL: Duration = Duration::from_millis(250);
pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 8 * 1024 * 1024; // 8 MB
pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 1024 * 1024; // 1 MB
pub(super) const SSE_BACKPRESSURE_SLEEP_MS: u64 = 10;
pub(super) const SSE_MAX_LINES_PER_CHUNK: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1110,9 +1110,7 @@ impl DeepSeekClient {
});
apply_reasoning_effort(&mut body, Some("off"), self.api_provider);
let response = self
.send_with_retry(|| self.http_client.post(&url).json(&body))
.await?;
let response = self.send_json_with_retry(&url, &body).await?;
let value: serde_json::Value = response.json().await?;
let translated = value["choices"][0]["message"]["content"]
@@ -1331,9 +1329,7 @@ impl DeepSeekClient {
let body = build_speech_synthesis_body(&model, &text, instruction, audio);
let url = api_url(&self.base_url, "chat/completions");
let response = self
.send_with_retry(|| self.http_client.post(&url).json(&body))
.await?;
let response = self.send_json_with_retry(&url, &body).await?;
let status = response.status();
if !status.is_success() {
let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
@@ -1506,6 +1502,22 @@ impl DeepSeekClient {
}
}
}
pub(super) async fn send_json_with_retry(
&self,
url: &str,
body: &serde_json::Value,
) -> Result<reqwest::Response> {
let request_body =
serde_json::to_vec(body).context("Failed to serialize JSON request body")?;
self.send_with_retry(|| {
self.http_client
.post(url)
.header(CONTENT_TYPE, "application/json")
.body(request_body.clone())
})
.await
}
}
/// Translate the structured `LlmError` into both a categorical label
@@ -1991,9 +2003,7 @@ impl DeepSeekClient {
"suffix": suffix,
"max_tokens": max_tokens,
});
let response = self
.send_with_retry(|| self.http_client.post(&url).json(&body))
.await?;
let response = self.send_json_with_retry(&url, &body).await?;
let status = response.status();
if !status.is_success() {
let raw_error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
+3 -8
View File
@@ -224,11 +224,8 @@ impl DeepSeekClient {
self.path_suffix.as_deref(),
);
let open_timeout = stream_open_timeout();
let response = match tokio_timeout(
open_timeout,
self.send_with_retry(|| self.http_client.post(&url).json(&body)),
)
.await
let response = match tokio_timeout(open_timeout, self.send_json_with_retry(&url, &body))
.await
{
Ok(result) => result?,
Err(_elapsed) => {
@@ -350,9 +347,7 @@ impl DeepSeekClient {
"chat/completions",
self.path_suffix.as_deref(),
);
let response = self
.send_with_retry(|| self.http_client.post(&url).json(&body))
.await?;
let response = self.send_json_with_retry(&url, &body).await?;
let status = response.status();
if !status.is_success() {
+3 -1
View File
@@ -91,6 +91,8 @@ impl DeepSeekClient {
// ChatGPT backend additionally requires the account id and the
// experimental Responses beta opt-in.
let account_id = crate::oauth::codex_account_id();
let request_body =
serde_json::to_vec(&body).context("Failed to serialize Responses API request body")?;
let response = self
.send_with_retry(|| {
let mut builder = self
@@ -103,7 +105,7 @@ impl DeepSeekClient {
if let Some(account_id) = &account_id {
builder = builder.header("chatgpt-account-id", account_id);
}
builder.json(&body)
builder.body(request_body.clone())
})
.await
.context("Responses API request failed")?;
+64 -5
View File
@@ -74,6 +74,8 @@ const LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS: usize = 120_000;
const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000;
const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000;
const TOOL_PRUNE_STOP_CHECK_BYTES: usize = 16 * 1024;
const RETAINED_TOOL_RESULT_MAX_CHARS: usize = 64 * 1024;
const RETAINED_THINKING_MAX_CHARS: usize = 16 * 1024;
const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048;
const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
const CACHE_ALIGNED_SUMMARY_CONTEXT_BUDGET_PERCENT: usize = 85;
@@ -852,6 +854,61 @@ where
bytes_saved
}
fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) -> bool {
let char_count = content.chars().count();
if char_count <= max_chars {
return false;
}
let snippet_budget = max_chars.saturating_sub(256).max(1024);
let head_chars = snippet_budget / 2;
let tail_chars_budget = snippet_budget.saturating_sub(head_chars);
let head = truncate_chars(content, head_chars).to_string();
let tail = tail_chars(content, tail_chars_budget);
*content =
format!("[{label} retained-history truncated from {char_count} chars]\n{head}\n…\n{tail}");
true
}
fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> {
for message in &mut messages {
for block in &mut message.content {
match block {
ContentBlock::ToolResult {
content,
content_blocks,
..
} => {
if truncate_retained_block(
"tool result",
content,
RETAINED_TOOL_RESULT_MAX_CHARS,
) {
*content_blocks = None;
}
}
ContentBlock::Thinking {
thinking,
signature,
} => {
// Signed thinking must stay byte-for-byte valid for providers that
// verify replay signatures. Unsigned thinking is local memory pressure
// and can be capped once compaction has summarized the old turn.
if signature.is_none() {
truncate_retained_block(
"thinking block",
thinking,
RETAINED_THINKING_MAX_CHARS,
);
}
}
_ => {}
}
}
}
messages
}
/// Result of a compaction operation with metadata.
#[derive(Debug)]
pub struct CompactionResult {
@@ -951,7 +1008,7 @@ pub async fn compact_messages_safe(
));
if was_over_threshold && now_under_threshold {
return Ok(CompactionResult {
messages: pruned_messages,
messages: sanitize_retained_messages(pruned_messages),
summary_prompt: None,
removed_messages: Vec::new(),
retries_used: 0,
@@ -982,10 +1039,11 @@ pub async fn compact_messages_safe(
.await
{
Ok((msgs, prompt, removed)) => {
drop(removed);
return Ok(CompactionResult {
messages: msgs,
messages: sanitize_retained_messages(msgs),
summary_prompt: prompt,
removed_messages: removed,
removed_messages: Vec::new(),
retries_used: attempt,
});
}
@@ -1081,6 +1139,7 @@ pub async fn compact_messages(
// Extract workflow context (files touched, tasks in progress, etc.)
let workflow_context = extract_workflow_context(&to_summarize, workspace);
drop(to_summarize);
let anchors_section = anchor_summary_section(workspace);
@@ -1118,9 +1177,9 @@ pub async fn compact_messages(
.collect();
Ok((
pinned_messages,
sanitize_retained_messages(pinned_messages),
Some(SystemPrompt::Blocks(vec![summary_block])),
to_summarize,
Vec::new(),
))
}
+9 -3
View File
@@ -7,12 +7,18 @@
//! `config.rs` with a private `use`, so no new external surface is created
//! (#3311).
pub const DEFAULT_MAX_SUBAGENTS: usize = 20;
pub const MAX_SUBAGENTS: usize = 20;
/// Temporary high-throughput default while the shared-context cutover makes
/// agent fanout cheap. This should eventually be governed by API/backpressure
/// budgets rather than memory-driven count throttles.
pub const DEFAULT_MAX_SUBAGENTS: usize = 64;
/// User-configurable ceiling for concurrent sub-agent execution. Keep this
/// above the default so operators can opt into larger API-bound fanout without
/// code changes while the full resource budget gate lands.
pub const MAX_SUBAGENTS: usize = 128;
/// Upper bound for queued + running sub-agent admissions. This is deliberately
/// higher than the instantaneous concurrency cap so Workflow-style fanout can
/// opt into large bounded populations without unbounded queue growth.
pub const MAX_SUBAGENT_ADMISSION: usize = 200;
pub const MAX_SUBAGENT_ADMISSION: usize = 1024;
/// Default per-step DeepSeek API timeout for sub-agent requests, in seconds.
/// Matches the legacy hardcoded value so existing configs keep their old
/// behavior when `[subagents] api_timeout_secs` is unset (#1806, #1808).
+3 -3
View File
@@ -2802,7 +2802,7 @@ impl Engine {
Ok(result) => {
if !result.messages.is_empty() || self.session.messages.is_empty() {
let messages_after = result.messages.len();
self.session.messages = result.messages.into();
self.session.replace_messages(result.messages);
self.merge_compaction_summary(result.summary_prompt);
self.emit_session_updated().await;
let removed = messages_before.saturating_sub(messages_after);
@@ -2898,7 +2898,7 @@ impl Engine {
{
Ok(result) => {
let messages_after = result.messages.len();
self.session.messages = result.messages.into();
self.session.replace_messages(result.messages);
self.emit_session_updated().await;
let summary = format!(
@@ -3024,7 +3024,7 @@ impl Engine {
}
if !compacted_messages.is_empty() || self.session.messages.is_empty() {
self.session.messages = compacted_messages.into();
self.session.replace_messages(compacted_messages);
}
self.merge_compaction_summary(summary_prompt);
+1 -1
View File
@@ -385,7 +385,7 @@ impl Engine {
// Only update if we got valid messages (never corrupt state)
if !result.messages.is_empty() || self.session.messages.is_empty() {
let auto_messages_after = result.messages.len();
self.session.messages = result.messages.into();
self.session.replace_messages(result.messages);
self.merge_compaction_summary(result.summary_prompt);
self.emit_session_updated().await;
let removed = auto_messages_before.saturating_sub(auto_messages_after);
+3
View File
@@ -2,6 +2,9 @@
#![allow(clippy::uninlined_format_args)]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
+85 -7
View File
@@ -14,27 +14,45 @@
//! deterministic for replay.
use std::cell::Cell;
use std::env;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use rquickjs::function::{Async, Func};
use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, CaughtError, Ctx, Promise, Value};
use serde::Deserialize;
use tokio::sync::{oneshot, watch};
use tokio::sync::{OwnedSemaphorePermit, Semaphore, oneshot, watch};
use crate::driver::{ProgressEvent, TaskCompletion, TaskRequest, WorkflowDriver};
use crate::error::WhaleflowJsError;
use crate::schema::{compile_schema, decode_reply};
use crate::{PARALLEL_MAX_ITEMS, WHALEFLOW_LIFETIME_CAP, normalize_profile};
const DEFAULT_VM_MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
const MIN_VM_MEMORY_LIMIT_BYTES: usize = 4 * 1024 * 1024;
const MAX_VM_MEMORY_LIMIT_BYTES: usize = 512 * 1024 * 1024;
const DEFAULT_VM_STACK_BYTES: usize = 1024 * 1024;
const MIN_VM_STACK_BYTES: usize = 128 * 1024;
const MAX_VM_STACK_BYTES: usize = 8 * 1024 * 1024;
const DEFAULT_VM_THREAD_STACK_BYTES: usize = 2 * 1024 * 1024;
const MIN_VM_THREAD_STACK_BYTES: usize = 512 * 1024;
const MAX_VM_THREAD_STACK_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_MAX_CONCURRENT_VMS: usize = 4;
const MAX_CONCURRENT_VMS: usize = 256;
const VM_MEMORY_LIMIT_MB_ENV: &str = "CODEWHALE_WHALEFLOW_JS_MEMORY_LIMIT_MB";
const VM_STACK_KB_ENV: &str = "CODEWHALE_WHALEFLOW_JS_STACK_KB";
const VM_THREAD_STACK_KB_ENV: &str = "CODEWHALE_WHALEFLOW_JS_THREAD_STACK_KB";
const VM_MAX_CONCURRENT_ENV: &str = "CODEWHALE_WHALEFLOW_JS_MAX_CONCURRENT";
/// Resource limits applied to the QuickJS runtime before any script runs.
///
/// There is deliberately no wall-clock timeout here: cancellation (dropping
/// the run future, or the driver's cancel cascade) is the deadline mechanism.
#[derive(Debug, Clone, Copy)]
pub struct VmLimits {
/// QuickJS heap ceiling in bytes (default 64 MiB).
/// QuickJS heap ceiling in bytes (default 32 MiB).
pub memory_limit_bytes: usize,
/// Maximum interpreter stack in bytes (default 1 MiB).
pub max_stack_bytes: usize,
@@ -42,13 +60,63 @@ pub struct VmLimits {
impl Default for VmLimits {
fn default() -> Self {
Self::from_env()
}
}
impl VmLimits {
pub fn from_env() -> Self {
Self {
memory_limit_bytes: 64 * 1024 * 1024,
max_stack_bytes: 1024 * 1024,
memory_limit_bytes: env_usize_bytes(
VM_MEMORY_LIMIT_MB_ENV,
1024 * 1024,
MIN_VM_MEMORY_LIMIT_BYTES,
MAX_VM_MEMORY_LIMIT_BYTES,
DEFAULT_VM_MEMORY_LIMIT_BYTES,
),
max_stack_bytes: env_usize_bytes(
VM_STACK_KB_ENV,
1024,
MIN_VM_STACK_BYTES,
MAX_VM_STACK_BYTES,
DEFAULT_VM_STACK_BYTES,
),
}
}
}
fn env_usize_bytes(name: &str, unit: usize, min: usize, max: usize, default: usize) -> usize {
env::var(name)
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.and_then(|value| value.checked_mul(unit))
.map(|bytes| bytes.clamp(min, max))
.unwrap_or(default)
}
fn max_concurrent_vms() -> usize {
env::var(VM_MAX_CONCURRENT_ENV)
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.map(|value| value.clamp(1, MAX_CONCURRENT_VMS))
.unwrap_or(DEFAULT_MAX_CONCURRENT_VMS)
}
fn vm_thread_stack_bytes() -> usize {
env_usize_bytes(
VM_THREAD_STACK_KB_ENV,
1024,
MIN_VM_THREAD_STACK_BYTES,
MAX_VM_THREAD_STACK_BYTES,
DEFAULT_VM_THREAD_STACK_BYTES,
)
}
fn vm_admission() -> &'static Arc<Semaphore> {
static ADMISSION: OnceLock<Arc<Semaphore>> = OnceLock::new();
ADMISSION.get_or_init(|| Arc::new(Semaphore::new(max_concurrent_vms())))
}
/// Executes WhaleFlow scripts, one isolated QuickJS runtime per run.
///
/// Every [`WhaleflowVm::run_script`] call spins up a fresh interpreter on a
@@ -101,13 +169,20 @@ impl WhaleflowVm {
armed: true,
};
let permit = vm_admission()
.clone()
.acquire_owned()
.await
.map_err(|_| WhaleflowJsError::VmInit("VM admission gate closed".to_string()))?;
let limits = self.limits;
let source = source.to_string();
let thread_driver = driver.clone();
let thread_cancel = cancel.clone();
let spawned = std::thread::Builder::new()
.name("whaleflow-js-vm".to_string())
.stack_size(vm_thread_stack_bytes())
.spawn(move || {
let _permit: OwnedSemaphorePermit = permit;
let outcome = vm_thread_main(
source,
args_json,
@@ -231,9 +306,12 @@ async fn run_in_vm(
.await
.map_err(|err| WhaleflowJsError::VmInit(err.to_string()))?;
context
let result = context
.async_with(async |ctx| run_in_ctx(ctx, source, args_json, driver, cancel).await)
.await
.await;
drop(context);
runtime.run_gc().await;
result
}
async fn run_in_ctx(
-285
View File
@@ -1,285 +0,0 @@
# RFC: Hook Lifecycle Data Flow
**Issue:** #1364
**Status:** Draft
**Date:** 2026-05-28
## 1. Problem
CodeWhale already has lifecycle hooks and MCP support, but the current hook
surface is mostly observer-only. This blocks portable extensions that need to
participate in the agent data flow:
- memory/context injection before a user message reaches the model
- post-turn background analysis that prepares context for the next turn
- sub-agent lifecycle visibility for orchestration and audit extensions
The current `message_submit` event fires before dispatch, but its output is
ignored. `TurnComplete`, `AgentSpawned`, and `AgentComplete` exist internally,
but they are not exposed as configurable hook events.
## 2. PR split
This issue should be implemented as three PRs. Each PR should be independently
reviewable and should leave the hook system in a useful state.
### PR 1: Mutable `message_submit`
Add a structured hook execution path for `message_submit` that can transform or
block the user's submitted text before it is sent to the engine.
Scope:
- keep the existing `[[hooks.hooks]]` config shape
- pass a JSON payload to the hook on stdin
- interpret stdout JSON containing `text` as the replacement user text
- treat exit code `2` as an intentional block
- run multiple submit hooks serially in config order
- keep existing env vars for compatibility
- keep `shell_env` stdout parsing unchanged
Non-goals:
- no tool argument mutation
- no global stdout JSON semantics for all hook events
- no transcript or model response mutation
### PR 2: `turn_end`
Expose the existing turn completion lifecycle as a hook event.
Scope:
- add `HookEvent::TurnEnd` with event name `turn_end`
- fire from the UI's `EngineEvent::TurnComplete` branch after core app state,
usage, cost, notifications, and receipt state have been updated
- pass turn metadata on stdin as JSON
- make failures non-blocking and warn-only
- include a `stop_hook_active` field in the payload, initially `false`, so the
contract can support re-entry protection later
Non-goals:
- no change to turn status
- no blocking of user input
- no transcript mutation from `turn_end`
Implementation note for the v0.9 branch: the narrow #2578 harvest uses the
shared structured observer path introduced for sub-agent lifecycle hooks. It
fires before queued follow-up dispatch, after queue-recovery state is known, so
the payload can report the queued-message count without letting a hook change
what gets sent next. Stdout is ignored for `turn_end`; only `message_submit`
has a stdout mutation contract.
### PR 3: Subagent lifecycle observer hooks
Expose subagent start and completion as observer-only hook events.
Scope:
- add `HookEvent::SubagentSpawn` with event name `subagent_spawn`
- add `HookEvent::SubagentComplete` with event name `subagent_complete`
- fire from the existing `AgentSpawned` and `AgentComplete` UI branches
- pass subagent metadata on stdin as JSON
- make failures non-blocking and warn-only
Non-goals:
- no subagent spawn gating in the first version
- no subagent prompt/result mutation
- no changes to subagent scheduling
## 3. PR 1 detailed plan
### 3.1 Contract
Configuration:
```toml
[[hooks.hooks]]
event = "message_submit"
command = "~/.deepseek/hooks/inject-memory.sh"
timeout_secs = 2
continue_on_error = true
```
Input payload on stdin:
```json
{
"event": "message_submit",
"text": "original user text",
"session_id": "sess_xxxx",
"workspace": "/path/to/workspace",
"mode": "agent",
"model": "deepseek-chat",
"total_tokens": 1234
}
```
Output payload on stdout:
```json
{ "text": "replacement user text" }
```
Rules:
- exit `0` with stdout JSON containing `text: string` replaces the current text
- exit `0` with empty stdout leaves the current text unchanged
- exit `0` with JSON that does not contain `text` leaves the current text
unchanged
- exit `2` blocks submission before the message is appended to history or sent
to the engine
- other non-zero exits follow `continue_on_error`
- `true`: warn, keep the current text, continue later hooks
- `false`: stop later hooks and block submission with an error message
- `background = true` on `message_submit` remains observer-only and cannot
transform or block submission
Multiple hooks:
- hooks run in config order
- each hook receives the latest transformed text
- the final transformed text is the only text used by file mention expansion,
skill wrapping, auto routing, history, and `api_messages`
### 3.2 Implementation steps
1. Add structured submit outcome types in `crates/tui/src/hooks.rs`:
```rust
pub enum MessageSubmitOutcome {
Unchanged,
Replaced(String),
Blocked { reason: String },
}
```
2. Add a stdin-capable sync executor:
```rust
fn execute_sync_with_stdin(
&self,
hook: &Hook,
env_vars: &HashMap<String, String>,
stdin_json: &serde_json::Value,
) -> HookResult
```
This should reuse the existing timeout, working directory, stdout, stderr, and
error handling behavior from `execute_sync`.
3. Add a `message_submit` transform entrypoint:
```rust
pub fn execute_message_submit_transform(
&self,
context: &HookContext,
original_text: &str,
) -> MessageSubmitOutcome
```
This method should:
- filter configured `MessageSubmit` hooks through existing condition matching
- build a JSON payload for each hook using the current text
- run non-background hooks through `execute_sync_with_stdin`
- run background hooks with the existing observer-only path
- parse stdout JSON only for non-background hooks
- return the final text or a block result
4. Apply the transformed message in `dispatch_user_message`:
- run the transform before `last_submitted_prompt`, file mentions, history, and
`api_messages`
- create a local mutable `QueuedMessage` or replacement display text
- if blocked, show a status message or toast and return without dispatch
5. Update `/hooks events`:
- keep `message_submit` listed
- update description to say it can transform or block user text
6. Update user-facing docs:
- document the stdin/stdout contract
- document exit code `2`
- document that `shell_env` still uses `KEY=VALUE` stdout
### 3.3 Test plan
Unit tests in `crates/tui/src/hooks.rs`:
- parses stdout `{"text":"changed"}` as replacement
- empty stdout means unchanged
- JSON without `text` means unchanged
- malformed stdout means unchanged with warning semantics
- exit `2` maps to blocked
- multiple hooks apply transforms in order
- background `message_submit` hook cannot transform
- `continue_on_error = false` blocks on non-zero failure
TUI integration or focused dispatch tests:
- transformed text is written to `api_messages`
- transformed text is written to visible history
- transformed text is used by file mention expansion
- blocked submit does not append user history
- blocked submit does not push an API message
- blocked submit leaves loading state false
Manual smoke test:
1. Add a config hook that prepends `[hooked] ` to every submitted message.
2. Submit `hello`.
3. Verify the transcript and model input use `[hooked] hello`.
4. Replace the hook with one that exits `2`.
5. Submit `hello`.
6. Verify no turn starts and the TUI shows the block reason.
## 4. Shared payload conventions
All new structured hook payloads should include:
- `event`
- `session_id`
- `workspace`
- `mode`
- `model`
Event-specific payloads should add only fields that are stable and useful for
extension authors. Avoid leaking secrets, full tool outputs, or unbounded
transcript content in the first version.
## 5. Compatibility
- Existing hook config remains valid.
- Existing observer-only hooks keep working.
- Existing env vars remain available.
- `shell_env` keeps its existing stdout `KEY=VALUE` contract.
- Structured stdout is interpreted only by `message_submit` in PR 1. Structured
observer hooks such as `turn_end`, `subagent_spawn`, and `subagent_complete`
receive JSON on stdin, but their stdout is ignored by the caller.
## 6. Review checkpoints
PR 1 should be accepted only if:
- submit mutation is covered by tests
- submit blocking is covered by tests
- the unchanged path preserves current behavior
- `shell_env` tests still prove the old stdout contract
- the docs clearly mark `message_submit` as the only mutable hook
PR 2 should be accepted only if:
- `turn_end` fires after `TurnComplete` app state updates
- failure is warn-only
- payload contains status and usage
PR 3 should be accepted only if:
- subagent hooks are observer-only
- failures do not affect subagent lifecycle
- payloads do not include unbounded or secret data
-167
View File
@@ -1,167 +0,0 @@
# RFC: Provider Fallback Chain
**Issue:** #2574
**Reporter:** @hsdbeebou
**Design source:** #2581 by @idling11
**Status:** Draft for the v0.9 provider-routing lane
**Date:** 2026-06-04
## Problem
CodeWhale can store credentials and defaults for several providers, but a
running session uses one active provider route at a time. When that provider
hits a rate limit, temporary outage, or transport failure, the user must notice
the failure, run `/provider`, choose another route, and resubmit the turn.
That manual switch is especially disruptive during long-running agentic work.
A provider fallback chain can keep work moving, but it also changes billing
source, model behavior, tool support, context-window limits, and vendor
expectations. The design must make that switch explicit and capability-aware.
## Principles
- Fallback is opt-in. No provider switch happens unless the user configured a
fallback chain.
- Billing and vendor changes are visible in the transcript and status UI.
- Normal retry policy runs before fallback.
- Fallback is allowed only before assistant content or tool calls have started
streaming for the failing request.
- Fallback candidates must support the request shape for the current turn.
- Authentication, authorization, malformed request, and model-not-found errors
do not silently switch providers by default.
## Proposed Config Shape
Keep the existing root `provider = "..."` setting as the primary route. Add an
ordered fallback list and a small policy section:
```toml
provider = "nvidia-nim"
fallback_providers = ["deepseek", "openrouter"]
[provider_fallback]
enabled = true
reset_on_new_session = true
```
Rules:
- `fallback_providers` is ordered and contains provider IDs already accepted by
the provider parser.
- The primary provider is not repeated in the fallback list.
- Duplicate fallback providers are rejected.
- Missing credentials produce a startup warning and make that fallback entry
inactive until credentials appear.
- If `provider_fallback.enabled` is absent, the presence of a non-empty
`fallback_providers` list enables fallback.
## Fallback Eligibility
| Failure | Fallback by default? | Notes |
| --- | --- | --- |
| HTTP 429 | Yes | Rate limit or quota exhaustion on the active route. |
| HTTP 502, 503, 504 | Yes | Temporary upstream failure after normal retries. |
| Connect timeout / DNS failure | Yes | Transport path failed before content streamed. |
| HTTP 401 / 403 | No | Usually bad credentials or account permissions. |
| HTTP 400 | No | Usually client request shape or model parameter issue. |
| Model not found | No | Avoid silently switching model families unless a future policy explicitly opts in. |
| Stream interrupted after content | No | The transcript may already contain partial assistant content or tool-call deltas. |
The first implementation should classify errors centrally and expose tests for
each case before any fallback execution is wired into the turn loop.
## Capability Gate
Before switching to a fallback provider/model, CodeWhale checks that the
candidate can support the current request shape:
| Requirement | Gate |
| --- | --- |
| Tool calls | Candidate provider/model must support tool calling. |
| Reasoning effort | Candidate must support the requested thinking mode, or the switch is blocked. |
| Context size | Candidate context window must fit the estimated current request. |
| Image inputs | Candidate must support vision if the turn includes images. |
| Provider-specific headers | Candidate request must be rebuilt from that provider's own auth/base-url/header rules. |
If no fallback candidate passes the gate, CodeWhale surfaces the original
provider error with a clear "fallback chain exhausted or incompatible" note.
## Runtime Behavior
1. Build the request for the active provider.
2. Run existing retry policy for that provider.
3. If retries exhaust with a fallback-eligible failure and no assistant content
has streamed, evaluate the next fallback provider.
4. Rebuild the request with the fallback provider's model, base URL, auth, and
provider-specific headers.
5. Add a visible transcript marker and status event before the fallback request
starts.
6. Continue through the chain until a provider succeeds, the chain is
exhausted, or a non-eligible failure occurs.
Suggested transcript marker:
```text
[provider fallback: nvidia-nim -> deepseek, reason: rate_limit]
```
Suggested status text:
```text
NVIDIA NIM unavailable; switched to DeepSeek fallback
```
For multi-request turns, such as tool-call result follow-ups, fallback can be
considered for a later request only if that later request has not started
streaming assistant content yet. The transcript marker must identify that the
turn changed provider between requests.
## UI and Commands
- `/provider` should show the primary route and the current fallback position.
- `/provider reset` should return to the primary provider for future requests in
the current session.
- The footer/statusline should surface the concrete provider/model that actually
handled the latest request.
- Session receipts should record both attempted provider and successful
provider so cost and debugging information stay truthful.
## Implementation Slices
1. Config schema and validation:
- parse `fallback_providers` and `[provider_fallback]`
- validate known providers, duplicates, missing credentials, and primary
self-reference
- document the config surface
2. Error classification:
- define fallback-eligible error kinds
- add unit tests for HTTP and transport failures
3. Request-shape capability gate:
- evaluate tool, thinking, context, and image requirements
- add tests for incompatible fallbacks
4. Fallback execution:
- run retries per provider before moving to the next provider
- rebuild auth/base-url/header state for each candidate
- block fallback after partial streaming
5. UI/receipt integration:
- status event
- transcript marker
- `/provider reset`
- receipt fields for attempted and selected provider
## Non-goals
- No automatic cost optimization or weighted provider selection.
- No silent fallback when authentication or permissions fail.
- No fallback after partial assistant content or tool-call deltas have streamed.
- No provider/model capability downgrades without an explicit future policy.
- No sub-agent-specific fallback policy in the first implementation; sub-agents
inherit the same configured fallback chain unless they are given an explicit
provider/model override.
## Credit
This RFC is based on issue #2574 from @hsdbeebou and PR #2581 from @idling11.
The original PR head currently has no net file changes, so this document
preserves the useful design direction while tightening the v0.9 contract around
truthful provider routing, billing visibility, and capability checks.
-137
View File
@@ -1,137 +0,0 @@
# Orchestration disposition and the Fleet substrate
Status: design note (forward-looking)
Related: constitution `## Orchestration` regulation; EPIC #3154; #3167; #3205
## Why this note exists
A CodeWhale agent, given a build larger than one context could hold,
spontaneously orchestrated it: it read the dependency shape, fanned the
independent work out to sub-agents across isolated git worktrees, kept its
own context for deciding and verifying, refused to trust any worker's
"done" until it checked the return, and kept a living backlog so the loop
never stalled. Nobody hardcoded that behavior. The model reached for the
delegation substrate as a *disposition*.
That is the thesis worth protecting: **metacognition belongs to the
model.** The judgment about when work has outgrown a single hand, how to
slice it, who should do each slice, and how to reassemble the pieces is
exactly the kind of decision we trust the model to make. We do not want to
re-encode it as a state machine.
This note separates the two halves of that observation so neither
swallows the other:
- The **disposition** — the orchestrator stance — is instilled by the
constitution, not by machinery. It is judgment.
- **Fleet** — #3154 / #3167 / #3205 — is the **substrate** the disposition
reaches for. It is mechanism: durable workers, profiled roles,
preconfigured loadouts, receipts.
The constitution already draws this line in Article IV (*Legacy*):
"A principle may name the duty; mechanism carries it." Orchestration is the
principle. Fleet is the mechanism. They meet but do not merge.
## The disposition (the judgment)
The new `## Orchestration` regulation unifies four existing Tier-3
regulations into a single stance rather than adding new rules:
- *Composition Pattern for Multi-Step Work* — read the shape, plan before
you dig.
- *Sub-Agent Strategy* — delegate the doing; brief tightly; keep
architecture, integration, and verification in the parent.
- *Thinking Delegation* — deep reasoning on a sub-problem is a delegation
signal, generalized here to all substantial doing, not only reasoning.
- *Keeping the Plan Honest* — the plan is the orchestrator's instrument; a
stalled parent is the failure the stance exists to prevent.
The regulation deliberately encodes *only judgment*: when to shift from
builder to orchestrator, how to sequence vs. parallelize, how to match a
worker to its work and re-route an unfit one, how to isolate parallel
streams, and the verify-floor that nothing returned is trusted until it is
checked against ground truth (Article II). It carries the small-task
carve-out: you do not orchestrate a one-file change.
Crucially, it names *no* concrete mechanism — no tool names, no role
registry, no slot schema. That is what keeps it a disposition. The same
stance works whether the substrate underneath is short-lived `agent`
fanout today or durable Fleet workers tomorrow.
## The substrate (Fleet)
When an agent orchestrates today, it improvises the loadouts: it picks a
read-only scout for a lookup, a builder for a slice, a review panel for a
design, a verifier for a check — and it re-derives those choices every
turn, in prose, from scratch. Fleet is the layer that lets those choices
be *pre-set* so the agent reaches for a configured role instead of
re-improvising one.
The Fleet design (per the EPIC) keeps three layers distinct:
- **Profile layer (#3167)** — `FleetProfile`, `FleetRole`, `FleetSlot`,
`FleetLoadout`. Roles already include `scout`, `builder`, `reviewer`,
`verifier`, `synthesizer`, **`orchestrator`**, `release`, `security`.
A slot says "when this role appears, use this persona, these tools, this
permission envelope, this loadout — unless overridden."
- **Route layer (#3205)** — Fleet model classes (`strong` / `balanced` /
`fast`) and semantic route roles, resolved through the shared route
resolver. This is "match the worker to the work" made concrete and
provider-agnostic.
- **Execution layer (#3154)** — durable worker lifecycle, leases,
heartbeats, retries, receipts, artifacts, and synthesis hooks. This is
the verify-floor given teeth: a receipt is a checkable record, not a
worker's word.
The mapping from disposition to substrate is close to one-to-one:
- *read the dependency shape; sequence vs. parallelize* → the orchestrator
composes a Fleet run of dependent and independent tasks.
- *match the worker to the work* → a `FleetSlot` binds a role to a tool
profile and a model class (#3167 + #3205).
- *isolate parallel streams; serialize the shared surface* → per-worker
workspace bounds and writable paths in the task spec.
- *never trust a worker's "done"* → Fleet receipts (`pass` / `fail` /
`partial` / `verifier_failed`) instead of a transcript claim.
- *keep the loop alive* → durable supervision, planner wakeups on
barriers, and resume-from-ledger so progress survives a dropped parent.
## What a Fleet profile buys the user
A `FleetProfile` lets a user pre-set the team an agent would otherwise
improvise: a `codex-coder` builder slot on a `balanced`/`strong` loadout,
a `claude-resolver` slot for conflict and merge work, a `design-panel`
reviewer slot that is read-only by default, and a `verifier` slot wired to
a deterministic scorer or a fresh-context verify pass. The disposition
still decides *whether and how* to delegate this turn; the profile decides
*what a delegated worker is made of* so that decision is cheap, repeatable,
and auditable rather than re-narrated every run.
This is the configurability payoff of trusting the model: because the
judgment lives in the model and the loadouts live in config, a user can
tune their team without touching the agent's reasoning, and the agent can
exercise its reasoning without re-litigating provider, model, and
permission policy every turn.
## What this note does not claim
- It does not propose new constitution machinery. The disposition is prose;
the guarantees (ordering, budgets, permission narrowing, receipts) belong
to Fleet mechanism per Article IV.
- It does not duplicate the Fleet schema. #3167 and #3205 own the profile
and loadout shapes; this note only argues *why* they are the right home
for the loadout choices the disposition currently improvises.
- It does not assert Fleet exists today. The disposition stands on its own
on the current `agent` substrate; Fleet is where it lands durably. The
regulation is intentionally substrate-agnostic so it does not rot when the
substrate underneath it changes.
## Open questions
- How does an orchestrator-role worker (#3167) delegate further without
unbounded recursive spawning? Delegation depth is a profile concern, but
the disposition should know the ceiling exists.
- Should the constitution ever *name* Fleet, or stay substrate-agnostic
forever? This note's position: stay agnostic. The moment the constitution
names a mechanism, the mechanism becomes load-bearing in the place we
reserved for judgment.
+248
View File
@@ -0,0 +1,248 @@
# 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.