Compare commits

...

4 Commits

Author SHA1 Message Date
Ahmed Ibrahim
381907f117 fun 2026-01-07 17:49:43 -08:00
Ahmed Ibrahim
f63cd0bc83 fun 2026-01-07 17:49:32 -08:00
Ahmed Ibrahim
70f09db901 use matcher 2026-01-07 13:30:52 -08:00
Ahmed Ibrahim
eded1864b6 use matcher 2026-01-07 13:24:36 -08:00
38 changed files with 2057 additions and 140 deletions

View File

@@ -87,6 +87,14 @@ pub enum Feature {
ShellSnapshot,
/// Experimental TUI v2 (viewport) implementation.
Tui2,
/// Experimental TUI animation set 1.
Animation1,
/// Experimental TUI animation set 2.
Animation2,
/// Experimental TUI animation set 3.
Animation3,
/// Experimental TUI animation set 4.
Animation4,
/// Enforce UTF8 output in Powershell.
PowershellUtf8,
}
@@ -380,4 +388,28 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation1,
key: "animation1",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation2,
key: "animation2",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation3,
key: "animation3",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation4,
key: "animation4",
stage: Stage::Experimental,
default_enabled: false,
},
];

View File

@@ -701,33 +701,6 @@ pub async fn start_mock_server() -> MockServer {
server
}
// todo(aibrahim): remove this and use our search matching patterns directly
/// Get all POST requests to `/responses` endpoints from the mock server.
/// Filters out GET requests (e.g., `/models`) .
pub async fn get_responses_requests(server: &MockServer) -> Vec<wiremock::Request> {
server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| req.method == "POST" && req.url.path().ends_with("/responses"))
.collect()
}
// todo(aibrahim): remove this and use our search matching patterns directly
/// Get request bodies as JSON values from POST requests to `/responses` endpoints.
/// Filters out GET requests (e.g., `/models`) .
pub async fn get_responses_request_bodies(server: &MockServer) -> Vec<Value> {
get_responses_requests(server)
.await
.into_iter()
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect()
}
#[derive(Clone)]
pub struct FunctionCallResponseMocks {
pub function_call: ResponseMock,

View File

@@ -23,10 +23,12 @@ use tempfile::TempDir;
use wiremock::MockServer;
use crate::load_default_config_for_test;
use crate::responses::get_responses_request_bodies;
use crate::responses::start_mock_server;
use crate::streaming_sse::StreamingSseServer;
use crate::wait_for_event;
use wiremock::Match;
use wiremock::matchers::method;
use wiremock::matchers::path_regex;
type ConfigMutator = dyn FnOnce(&mut Config) + Send;
type PreBuildHook = dyn FnOnce(&Path) + Send + 'static;
@@ -322,7 +324,18 @@ impl TestCodexHarness {
}
pub async fn request_bodies(&self) -> Vec<Value> {
get_responses_request_bodies(&self.server).await
let path_matcher = path_regex(".*/responses$");
self.server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect()
}
pub async fn function_call_output_value(&self, call_id: &str) -> Value {

View File

@@ -31,7 +31,6 @@ use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::get_responses_requests;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::sse;
@@ -47,6 +46,7 @@ use std::io::Write;
use std::sync::Arc;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Match;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -54,6 +54,7 @@ use wiremock::matchers::body_string_contains;
use wiremock::matchers::header_regex;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::path_regex;
use wiremock::matchers::query_param;
/// Build minimal SSE stream with completed marker using the JSON fixture.
@@ -374,7 +375,14 @@ async fn includes_conversation_id_and_model_headers_in_request() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// get request from the server
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
let request = requests
.first()
.expect("expected POST request to /responses");
@@ -500,7 +508,14 @@ async fn chatgpt_auth_sends_correct_request() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// get request from the server
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
let request = requests
.first()
.expect("expected POST request to /responses");
@@ -1233,7 +1248,14 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
}
}
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
assert_eq!(requests.len(), 1, "expected a single POST request");
let body: serde_json::Value = requests[0]
.body_json()
@@ -1847,7 +1869,14 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// Inspect the three captured requests.
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)");
// Replace full-array compare with tail-only raw JSON compare using a single hard-coded value.

View File

@@ -31,7 +31,6 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::get_responses_requests;
use core_test_support::responses::mount_compact_json_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_once_match;
@@ -41,7 +40,9 @@ use core_test_support::responses::sse_failed;
use core_test_support::responses::start_mock_server;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Match;
use wiremock::MockServer;
use wiremock::matchers::path_regex;
// --- Test helpers -----------------------------------------------------------
pub(super) const FIRST_REPLY: &str = "FIRST_REPLY";
@@ -358,7 +359,14 @@ async fn manual_compact_uses_custom_prompt() {
assert_eq!(message, COMPACT_WARNING_MESSAGE);
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
let body = requests
.iter()
.find_map(|req| req.body_json::<serde_json::Value>().ok())
@@ -586,7 +594,14 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// collect the requests payloads from the model
let requests_payloads = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests_payloads = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
let body = requests_payloads[0]
.body_json::<serde_json::Value>()
@@ -1111,7 +1126,14 @@ async fn auto_compact_runs_after_token_limit_hit() {
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
assert_eq!(
requests.len(),
4,
@@ -1897,7 +1919,14 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
"auto compact should not emit task lifecycle events"
);
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
let request_bodies: Vec<String> = requests
.into_iter()
.map(|request| String::from_utf8(request.body).unwrap_or_default())

View File

@@ -26,7 +26,6 @@ use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::get_responses_request_bodies;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::sse;
use core_test_support::wait_for_event;
@@ -35,7 +34,10 @@ use serde_json::Value;
use serde_json::json;
use std::sync::Arc;
use tempfile::TempDir;
use wiremock::Match;
use wiremock::MockServer;
use wiremock::matchers::method;
use wiremock::matchers::path_regex;
const AFTER_SECOND_RESUME: &str = "AFTER_SECOND_RESUME";
@@ -772,7 +774,18 @@ fn normalize_line_endings(value: &mut Value) {
}
async fn gather_request_bodies(server: &MockServer) -> Vec<Value> {
let mut bodies = get_responses_request_bodies(server).await;
let path_matcher = path_regex(".*/responses$");
let mut bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
for body in &mut bodies {
normalize_line_endings(body);
}

View File

@@ -23,7 +23,6 @@ use codex_core::review_format::render_review_output_text;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id_from_str;
use core_test_support::responses::get_responses_requests;
use core_test_support::skip_if_no_network;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
@@ -32,11 +31,12 @@ use std::sync::Arc;
use tempfile::TempDir;
use tokio::io::AsyncWriteExt as _;
use uuid::Uuid;
use wiremock::Match;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::path_regex;
/// Verify that submitting `Op::Review` spawns a child task and emits
/// EnteredReviewMode -> ExitedReviewMode(None) -> TaskComplete
@@ -426,7 +426,14 @@ async fn review_uses_custom_review_model_from_config() {
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// Assert the request body model equals the configured review model
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
let request = requests
.first()
.expect("expected POST request to /responses");
@@ -547,7 +554,14 @@ async fn review_input_isolated_from_parent_history() {
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// Assert the request `input` contains the environment context followed by the user review prompt.
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
let request = requests
.first()
.expect("expected POST request to /responses");
@@ -674,7 +688,14 @@ async fn review_history_surfaces_in_parent_session() {
// Inspect the second request (parent turn) input contents.
// Parent turns include session initial messages (user_instructions, environment_context).
// Critically, no messages from the review thread should appear.
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
assert_eq!(requests.len(), 2);
let body = requests[1].body_json::<serde_json::Value>().unwrap();
let input = body["input"].as_array().expect("input array");
@@ -792,7 +813,14 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() {
let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await;
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = get_responses_requests(&server).await;
let path_matcher = path_regex(".*/responses$");
let requests = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.collect::<Vec<_>>();
assert_eq!(requests.len(), 1);
let body = requests[0].body_json::<serde_json::Value>().unwrap();
let input = body["input"].as_array().expect("input array");

View File

@@ -20,7 +20,6 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::get_responses_request_bodies;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -38,6 +37,9 @@ use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use tokio::time::Duration;
use wiremock::Match;
use wiremock::matchers::method;
use wiremock::matchers::path_regex;
fn extract_output_text(item: &Value) -> Option<&str> {
item.get("output").and_then(|value| match value {
@@ -1241,7 +1243,18 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let metadata = outputs
@@ -1345,7 +1358,18 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs
@@ -1470,7 +1494,18 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
@@ -1824,7 +1859,18 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
@@ -1958,7 +2004,18 @@ PY
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
@@ -2067,7 +2124,18 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
@@ -2153,7 +2221,18 @@ PY
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let large_output = outputs.get(call_id).expect("missing large output summary");
@@ -2230,7 +2309,18 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs.get(call_id).expect("missing output");
@@ -2328,7 +2418,18 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let startup_output = outputs
@@ -2418,7 +2519,18 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> {
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = get_responses_request_bodies(&server).await;
let path_matcher = path_regex(".*/responses$");
let bodies = server
.received_requests()
.await
.expect("mock server should not fail")
.into_iter()
.filter(|req| path_matcher.matches(req))
.map(|req| {
req.body_json::<Value>()
.expect("request body to be valid JSON")
})
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs.get(call_id).expect("missing output");

View File

@@ -0,0 +1 @@
pub(crate) mod spinners;

View File

@@ -0,0 +1,200 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::Hash;
use std::hash::Hasher;
use std::time::Instant;
use codex_core::features::Feature;
use codex_core::features::Features;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Span;
mod sets;
#[derive(Clone, Copy, Debug)]
pub(crate) enum SpinnerKind {
Thinking,
Exploring,
Executing,
Waiting,
Tool,
}
impl SpinnerKind {
pub(crate) fn from_header(header: &str) -> Self {
let header = header.to_ascii_lowercase();
if header.contains("explor") {
Self::Exploring
} else if header.contains("wait") {
Self::Waiting
} else if header.contains("run") || header.contains("execut") {
Self::Executing
} else {
Self::Thinking
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SpinnerSet {
Default,
Animation1,
Animation2,
Animation3,
Animation4,
}
impl SpinnerSet {
pub(crate) fn from_features(features: &Features) -> Self {
if features.enabled(Feature::Animation4) {
Self::Animation4
} else if features.enabled(Feature::Animation3) {
Self::Animation3
} else if features.enabled(Feature::Animation2) {
Self::Animation2
} else if features.enabled(Feature::Animation1) {
Self::Animation1
} else {
Self::Default
}
}
}
struct SpinnerTheme {
variants: &'static [&'static [&'static str]],
tick_ms: u128,
idle_frame: &'static str,
style: SpinnerStyle,
}
enum SpinnerStyle {
Fixed(fn(&'static str) -> Span<'static>),
Cycle {
colors: &'static [Color],
tick_ms: u128,
dim_every: u8,
},
}
impl SpinnerStyle {
fn render(&self, frame: &'static str, elapsed_ms: u128, seed: u64) -> Span<'static> {
match self {
SpinnerStyle::Fixed(style) => style(frame),
SpinnerStyle::Cycle {
colors,
tick_ms,
dim_every,
} => {
if colors.is_empty() {
return Span::from(frame).bold();
}
let step = elapsed_ms / (*tick_ms).max(1);
let idx = ((step as u64).wrapping_add(seed) as usize) % colors.len();
let mut span = Span::from(frame).fg(colors[idx]).bold();
if *dim_every > 0 && (idx as u8) % dim_every == 0 {
span = span.dim();
}
span
}
}
}
}
pub(crate) fn spinner_seed(value: &str) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
pub(crate) fn spinner(
set: SpinnerSet,
kind: SpinnerKind,
start_time: Option<Instant>,
animations_enabled: bool,
seed: u64,
) -> Span<'static> {
let theme = sets::theme(set, kind);
if !animations_enabled {
return theme.idle_frame.dim();
}
let elapsed_ms = start_time.map(|st| st.elapsed().as_millis()).unwrap_or(0);
let variants = theme.variants;
if variants.is_empty() {
return theme.idle_frame.dim();
}
let variant_idx = ((seed ^ kind_seed(kind)) as usize) % variants.len();
let frames = variants[variant_idx];
if frames.is_empty() {
return theme.idle_frame.dim();
}
let tick_ms = theme.tick_ms.max(1);
let frame_idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize;
theme
.style
.render(frames[frame_idx], elapsed_ms, seed ^ kind_seed(kind))
}
pub(crate) struct Animation3Spans {
pub(crate) text: Span<'static>,
pub(crate) face: Span<'static>,
}
pub(crate) fn animation3_spans(
kind: SpinnerKind,
start_time: Option<Instant>,
animations_enabled: bool,
seed: u64,
) -> Animation3Spans {
let elapsed_ms = start_time.map(|st| st.elapsed().as_millis()).unwrap_or(0);
let frame = sets::animation3::frame(kind, elapsed_ms, animations_enabled, seed);
let style = sets::animation3::style_for_kind(kind);
let text = style.render(frame.text, elapsed_ms, seed);
let face = style.render(frame.face, elapsed_ms, seed);
Animation3Spans { text, face }
}
pub(crate) fn animation4_spans(
kind: SpinnerKind,
start_time: Option<Instant>,
animations_enabled: bool,
seed: u64,
) -> Animation3Spans {
let elapsed_ms = start_time.map(|st| st.elapsed().as_millis()).unwrap_or(0);
let frame = sets::animation4::frame(kind, elapsed_ms, animations_enabled, seed);
let style = sets::animation4::style_for_kind(kind);
let text = style.render(frame.text, elapsed_ms, seed);
let face = style.render(frame.face, elapsed_ms, seed);
Animation3Spans { text, face }
}
fn kind_seed(kind: SpinnerKind) -> u64 {
match kind {
SpinnerKind::Thinking => 1,
SpinnerKind::Exploring => 2,
SpinnerKind::Executing => 3,
SpinnerKind::Waiting => 4,
SpinnerKind::Tool => 5,
}
}
pub(super) fn style_thinking(frame: &'static str) -> Span<'static> {
frame.magenta().bold()
}
pub(super) fn style_exploring(frame: &'static str) -> Span<'static> {
frame.cyan().bold()
}
pub(super) fn style_executing(frame: &'static str) -> Span<'static> {
frame.green().bold()
}
pub(super) fn style_waiting(frame: &'static str) -> Span<'static> {
frame.yellow().bold()
}
pub(super) fn style_tool(frame: &'static str) -> Span<'static> {
frame.blue().bold()
}

View File

@@ -0,0 +1,190 @@
use super::SpinnerKind;
use super::SpinnerTheme;
use crate::animations::spinners::SpinnerStyle;
use crate::animations::spinners::style_executing;
use crate::animations::spinners::style_exploring;
use crate::animations::spinners::style_thinking;
use crate::animations::spinners::style_tool;
use crate::animations::spinners::style_waiting;
pub(super) fn theme(kind: SpinnerKind) -> SpinnerTheme {
match kind {
SpinnerKind::Thinking => SpinnerTheme {
variants: THINKING_VARIANTS,
tick_ms: 140,
idle_frame: "⠿⠿⠿⠿",
style: SpinnerStyle::Fixed(style_thinking),
},
SpinnerKind::Exploring => SpinnerTheme {
variants: EXPLORING_VARIANTS,
tick_ms: 90,
idle_frame: "▒▒▒▒",
style: SpinnerStyle::Fixed(style_exploring),
},
SpinnerKind::Executing => SpinnerTheme {
variants: EXECUTING_VARIANTS,
tick_ms: 70,
idle_frame: "████",
style: SpinnerStyle::Fixed(style_executing),
},
SpinnerKind::Waiting => SpinnerTheme {
variants: WAITING_VARIANTS,
tick_ms: 180,
idle_frame: "░░░░",
style: SpinnerStyle::Fixed(style_waiting),
},
SpinnerKind::Tool => SpinnerTheme {
variants: TOOL_VARIANTS,
tick_ms: 120,
idle_frame: "▣▣▣▣",
style: SpinnerStyle::Fixed(style_tool),
},
}
}
const THINKING_VARIANTS: &[&[&str]] = &[
&["⠁⠂⠄⠂", "⠂⠄⠂⠁", "⠄⠂⠁⠂", "⠂⠁⠂⠄"],
&["⠈⠐⠠⠐", "⠐⠠⠐⠈", "⠠⠐⠈⠐", "⠐⠈⠐⠠"],
&["⠋⠙⠹⠸", "⠙⠹⠸⠋", "⠹⠸⠋⠙", "⠸⠋⠙⠹"],
&["⠂⠒⠐⠒", "⠒⠐⠒⠂", "⠐⠒⠂⠒", "⠒⠂⠒⠐"],
&["⠉⠒⠤⠒", "⠒⠤⠒⠉", "⠤⠒⠉⠒", "⠒⠉⠒⠤"],
&["⠇⠋⠙⠸", "⠋⠙⠸⠇", "⠙⠸⠇⠋", "⠸⠇⠋⠙"],
&["⠛⠻⠽⠿", "⠻⠽⠿⠛", "⠽⠿⠛⠻", "⠿⠛⠻⠽"],
&["⠠⠔⠂⠔", "⠔⠂⠔⠠", "⠂⠔⠠⠔", "⠔⠠⠔⠂"],
&["⠒⠆⠰⠆", "⠆⠰⠆⠒", "⠰⠆⠒⠆", "⠆⠒⠆⠰"],
&["⠅⠇⠋⠙", "⠇⠋⠙⠅", "⠋⠙⠅⠇", "⠙⠅⠇⠋"],
&["⠟⠯⠷⠿", "⠯⠷⠿⠟", "⠷⠿⠟⠯", "⠿⠟⠯⠷"],
&["⠄⠆⠇⠆", "⠆⠇⠆⠄", "⠇⠆⠄⠆", "⠆⠄⠆⠇"],
&["◔◑◕◐", "◑◕◐◔", "◕◐◔◑", "◐◔◑◕"],
&["◌◍●◍", "◍●◍◌", "●◍◌◍", "◍◌◍●"],
&["˙·•·", "·•·˙", "•·˙·", "·˙·•"],
&["∞···", "·∞··", "··∞·", "···∞"],
&["(-) ", "(-)", " (-", " -"],
&["(o )", " (o)", "( o", " o "],
&["(o)", "(O)", "(o)", "(O)"],
&["(°)", "(o)", "(°)", "(o)"],
&["⊙⊙⊙⊙", "⊙⊙◌⊙", "⊙◌⊙⊙", "◌⊙⊙⊙"],
&["▤▥▦▧", "▥▦▧▤", "▦▧▤▥", "▧▤▥▦"],
&["▖▘▝▗", "▘▝▗▖", "▝▗▖▘", "▗▖▘▝"],
];
const EXPLORING_VARIANTS: &[&[&str]] = &[
&[
"▌···",
"·▌··",
"··▌·",
"···▌",
"···▐",
"··▐·",
"·▐··",
"▐···",
],
&[
"▖···",
"·▘··",
"··▝·",
"···▗",
"···▖",
"··▘·",
"·▝··",
"▗···",
],
&[
"▚···",
"·▞··",
"··▚·",
"···▞",
"···▚",
"··▞·",
"·▚··",
"▞···",
],
&[
"⟐···",
"·⟐··",
"··⟐·",
"···⟐",
"···⟡",
"··⟡·",
"·⟡··",
"⟡···",
],
&[
"⌜···",
"·⌜··",
"··⌜·",
"···⌜",
"···⌟",
"··⌟·",
"·⌟··",
"⌟···",
],
&[
"↖···",
"·↗··",
"··↘·",
"···↙",
"··↙·",
"·↘··",
"↗···",
"·↖··",
],
&[
"⋅···",
"·⋅··",
"··⋅·",
"···⋅",
"··⋅·",
"·⋅··",
"⋅···",
"·⋅··",
],
&[
"⌈⌉··",
"·⌈⌉·",
"··⌈⌉",
"·⌉⌈·",
"⌉⌈··",
"·⌉⌈·",
"··⌉⌈",
"·⌈⌉·",
],
];
const EXECUTING_VARIANTS: &[&[&str]] = &[
&[
"▁▂▃▄",
"▂▃▄▅",
"▃▄▅▆",
"▄▅▆▇",
"▅▆▇█",
"▆▇█▇",
"▇█▇▆",
"█▇▆▅",
],
&["▗▖▘▝", "▖▘▝▗", "▘▝▗▖", "▝▗▖▘"],
&["▉▊▋▌", "▊▋▌▍", "▋▌▍▎", "▌▍▎▏", "▍▎▏▎", "▎▏▎▍"],
&["╶╴╶╴", "╴╶╴╶", "╶╴╶╴", "╴╶╴╶"],
&["▶▶ ", " ▶▶ ", " ▶▶", " ▶▶ ", "▶▶ "],
&[">>>>", ">>>·", ">>··", ">···", ">>··", ">>>·"],
&["▌▌▌▌", "▌▌▌ ", "▌▌ ", "", "▌▌ ", "▌▌▌ "],
&["▛▙▟█", "▙▟█▛", "▟█▛▙", "█▛▙▟"],
];
const WAITING_VARIANTS: &[&[&str]] = &[
&["░░░░", "▒▒▒▒", "▓▓▓▓", "▒▒▒▒"],
&["░▒▓▒", "▒▓▒░", "▓▒░▒", "▒░▒▓"],
&["", "", "", "", "", ""],
&["⠁⠁⠁⠁", "⠂⠂⠂⠂", "⠄⠄⠄⠄", "⠂⠂⠂⠂"],
&["⟡⟡⟡⟡", "⟐⟐⟐⟐", "⟡⟡⟡⟡", "⟐⟐⟐⟐"],
&["▱▱▱▱", "▰▰▰▰", "▱▱▱▱", "▰▰▰▰"],
&["┈┈┈┈", "┉┉┉┉", "┈┈┈┈", "┉┉┉┉"],
];
const TOOL_VARIANTS: &[&[&str]] = &[
&["⚙️···", "·⚙️··", "··⚙️·", "···⚙️", "··⚙️·", "·⚙️··"],
&["▣▢▣▢", "▢▣▢▣", "▣▢▣▢", "▢▣▢▣"],
&["◰◱◲◳", "◱◲◳◰", "◲◳◰◱", "◳◰◱◲"],
&["♦◆♦◆", "◆♦◆♦", "♦◆♦◆", "◆♦◆♦"],
&["⌁⌁⌁⌁", "⌁⌁⌁⌁", "⌁⌁⌁⌁", "⌁⌁⌁⌁"],
];

View File

@@ -0,0 +1,198 @@
use std::sync::OnceLock;
use ratatui::style::Color;
use super::SpinnerKind;
use super::SpinnerTheme;
use crate::animations::spinners::SpinnerStyle;
const SCALE: usize = 2;
const THINKING_COLORS: &[Color] = &[
Color::LightMagenta,
Color::Magenta,
Color::LightBlue,
Color::LightCyan,
];
const EXPLORING_COLORS: &[Color] = &[
Color::LightCyan,
Color::Cyan,
Color::LightBlue,
Color::LightGreen,
];
const EXECUTING_COLORS: &[Color] = &[
Color::LightGreen,
Color::Green,
Color::Yellow,
Color::LightRed,
];
const WAITING_COLORS: &[Color] = &[
Color::LightBlue,
Color::Blue,
Color::LightCyan,
Color::DarkGray,
];
const TOOL_COLORS: &[Color] = &[
Color::LightYellow,
Color::Yellow,
Color::LightRed,
Color::Red,
];
static THINKING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static EXPLORING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static EXECUTING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static WAITING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static TOOL_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static THINKING_IDLE: OnceLock<&'static str> = OnceLock::new();
static EXPLORING_IDLE: OnceLock<&'static str> = OnceLock::new();
static EXECUTING_IDLE: OnceLock<&'static str> = OnceLock::new();
static WAITING_IDLE: OnceLock<&'static str> = OnceLock::new();
static TOOL_IDLE: OnceLock<&'static str> = OnceLock::new();
pub(super) fn theme(kind: SpinnerKind) -> SpinnerTheme {
match kind {
SpinnerKind::Thinking => SpinnerTheme {
variants: scaled_thinking(),
tick_ms: 120,
idle_frame: scaled_idle("..", &THINKING_IDLE),
style: SpinnerStyle::Cycle {
colors: THINKING_COLORS,
tick_ms: 260,
dim_every: 3,
},
},
SpinnerKind::Exploring => SpinnerTheme {
variants: scaled_exploring(),
tick_ms: 110,
idle_frame: scaled_idle("..", &EXPLORING_IDLE),
style: SpinnerStyle::Cycle {
colors: EXPLORING_COLORS,
tick_ms: 210,
dim_every: 4,
},
},
SpinnerKind::Executing => SpinnerTheme {
variants: scaled_executing(),
tick_ms: 80,
idle_frame: scaled_idle("==", &EXECUTING_IDLE),
style: SpinnerStyle::Cycle {
colors: EXECUTING_COLORS,
tick_ms: 140,
dim_every: 0,
},
},
SpinnerKind::Waiting => SpinnerTheme {
variants: scaled_waiting(),
tick_ms: 150,
idle_frame: scaled_idle("..", &WAITING_IDLE),
style: SpinnerStyle::Cycle {
colors: WAITING_COLORS,
tick_ms: 300,
dim_every: 2,
},
},
SpinnerKind::Tool => SpinnerTheme {
variants: scaled_tool(),
tick_ms: 110,
idle_frame: scaled_idle("[]", &TOOL_IDLE),
style: SpinnerStyle::Cycle {
colors: TOOL_COLORS,
tick_ms: 180,
dim_every: 3,
},
},
}
}
fn scaled_thinking() -> &'static [&'static [&'static str]] {
THINKING_SCALED.get_or_init(|| scale_variants(THINKING_BASE))
}
fn scaled_exploring() -> &'static [&'static [&'static str]] {
EXPLORING_SCALED.get_or_init(|| scale_variants(EXPLORING_BASE))
}
fn scaled_executing() -> &'static [&'static [&'static str]] {
EXECUTING_SCALED.get_or_init(|| scale_variants(EXECUTING_BASE))
}
fn scaled_waiting() -> &'static [&'static [&'static str]] {
WAITING_SCALED.get_or_init(|| scale_variants(WAITING_BASE))
}
fn scaled_tool() -> &'static [&'static [&'static str]] {
TOOL_SCALED.get_or_init(|| scale_variants(TOOL_BASE))
}
fn scaled_idle(base: &str, cache: &OnceLock<&'static str>) -> &'static str {
cache.get_or_init(|| Box::leak(scale_frame(base, SCALE).into_boxed_str()))
}
fn scale_variants(base: &'static [&'static [&'static str]]) -> &'static [&'static [&'static str]] {
let scaled: Vec<&'static [&'static str]> = base
.iter()
.map(|variant| {
let frames: Vec<&'static str> = variant
.iter()
.map(|frame| Box::leak(scale_frame(frame, SCALE).into_boxed_str()) as &'static str)
.collect();
Box::leak(frames.into_boxed_slice()) as &'static [&'static str]
})
.collect();
Box::leak(scaled.into_boxed_slice())
}
fn scale_frame(frame: &str, scale: usize) -> String {
if scale <= 1 {
return frame.to_string();
}
let mut scaled = String::with_capacity(frame.len() * scale);
for ch in frame.chars() {
for _ in 0..scale {
scaled.push(ch);
}
}
scaled
}
const THINKING_BASE: &[&[&str]] = &[
&["..", "o.", "oo", ".o", "..", ".o", "oo", "o."],
&["<>", "><", "<>", "><"],
&["()", ")(", "()", ")("],
&["??", "!!", "??", "!!"],
&["░░", "▒▒", "▓▓", "▒▒"],
];
const EXPLORING_BASE: &[&[&str]] = &[
&[">.", ".>", "..", "<.", ".<", ".."],
&["^.", ".^", "v.", ".v", "^.", ".^"],
&["/\\", "\\/", "/\\", "\\/"],
&["[]", "][", "[]", "]["],
&["..", "::", ";;", "::"],
];
const EXECUTING_BASE: &[&[&str]] = &[
&["==", "=-", "--", "-=", "=="],
&["++", "+*", "**", "*+", "++"],
&["##", "%%", "@@", "%%"],
&["[]", "[=", "[]", "=]"],
&["▣▣", "▣▢", "▢▣", "▣▣"],
];
const WAITING_BASE: &[&[&str]] = &[
&["..", ". ", " ", " .", ".."],
&["--", " -", " ", "- ", "--"],
&["..", "o.", "oo", ".o"],
&["zz", "z ", " ", " z"],
&["::", ".:", "..", ":."],
];
const TOOL_BASE: &[&[&str]] = &[
&["/\\", "\\/", "/\\", "\\/"],
&["[]", "][", "[]", "]["],
&["<>", "><", "<>", "><"],
&["**", "x*", "*x", "**"],
&["⚙⚙", "⚙⚙", "⚙⚙", "⚙⚙"],
];

View File

@@ -0,0 +1,373 @@
use std::sync::OnceLock;
use ratatui::style::Stylize;
use ratatui::text::Span;
use super::SpinnerKind;
use super::SpinnerTheme;
use crate::animations::spinners::SpinnerStyle;
const SCALE: usize = 1;
const FACE_FRAME_MS: u128 = 840;
const FACE_PAUSE_MS: u128 = 1040;
const TEXT_MS: u128 = 800;
static THINKING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static EXPLORING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static EXECUTING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static WAITING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static TOOL_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static THINKING_IDLE: OnceLock<&'static str> = OnceLock::new();
static EXPLORING_IDLE: OnceLock<&'static str> = OnceLock::new();
static EXECUTING_IDLE: OnceLock<&'static str> = OnceLock::new();
static WAITING_IDLE: OnceLock<&'static str> = OnceLock::new();
static TOOL_IDLE: OnceLock<&'static str> = OnceLock::new();
static FACE_SEQUENCES_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static THINKING_DEFS_SCALED: OnceLock<&'static [&'static str]> = OnceLock::new();
static EXPLORING_DEFS_SCALED: OnceLock<&'static [&'static str]> = OnceLock::new();
static EXECUTING_DEFS_SCALED: OnceLock<&'static [&'static str]> = OnceLock::new();
static WAITING_DEFS_SCALED: OnceLock<&'static [&'static str]> = OnceLock::new();
static TOOL_DEFS_SCALED: OnceLock<&'static [&'static str]> = OnceLock::new();
pub(super) fn theme(kind: SpinnerKind) -> SpinnerTheme {
match kind {
SpinnerKind::Thinking => SpinnerTheme {
variants: scaled_thinking(),
tick_ms: 150,
idle_frame: scaled_idle("._.", &THINKING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Exploring => SpinnerTheme {
variants: scaled_exploring(),
tick_ms: 120,
idle_frame: scaled_idle("o_o", &EXPLORING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Executing => SpinnerTheme {
variants: scaled_executing(),
tick_ms: 90,
idle_frame: scaled_idle("RUN", &EXECUTING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Waiting => SpinnerTheme {
variants: scaled_waiting(),
tick_ms: 160,
idle_frame: scaled_idle("zzz", &WAITING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Tool => SpinnerTheme {
variants: scaled_tool(),
tick_ms: 120,
idle_frame: scaled_idle("[]", &TOOL_IDLE),
style: style_for_kind(kind),
},
}
}
pub(crate) fn style_for_kind(kind: SpinnerKind) -> SpinnerStyle {
match kind {
SpinnerKind::Thinking
| SpinnerKind::Exploring
| SpinnerKind::Executing
| SpinnerKind::Waiting
| SpinnerKind::Tool => SpinnerStyle::Fixed(style_default),
}
}
fn style_default(frame: &'static str) -> Span<'static> {
Span::from(frame).bold()
}
pub(crate) struct Animation3Frame {
pub(crate) text: &'static str,
pub(crate) face: &'static str,
}
pub(crate) fn frame(
kind: SpinnerKind,
elapsed_ms: u128,
animations_enabled: bool,
seed: u64,
) -> Animation3Frame {
let elapsed_ms = if animations_enabled { elapsed_ms } else { 0 };
let definitions = scaled_definitions_for(kind);
let text = definitions
.get(text_index(elapsed_ms, seed, definitions.len()))
.copied()
.unwrap_or("...");
let faces = scaled_face_sequences();
let face = faces
.get(face_sequence_index(elapsed_ms, seed, faces.len()))
.and_then(|seq| seq.get(face_frame_index(elapsed_ms, seq.len())))
.copied()
.unwrap_or("._.");
Animation3Frame { text, face }
}
fn scaled_thinking() -> &'static [&'static [&'static str]] {
THINKING_SCALED.get_or_init(|| scale_variants(THINKING_BASE))
}
fn scaled_exploring() -> &'static [&'static [&'static str]] {
EXPLORING_SCALED.get_or_init(|| scale_variants(EXPLORING_BASE))
}
fn scaled_executing() -> &'static [&'static [&'static str]] {
EXECUTING_SCALED.get_or_init(|| scale_variants(EXECUTING_BASE))
}
fn scaled_waiting() -> &'static [&'static [&'static str]] {
WAITING_SCALED.get_or_init(|| scale_variants(WAITING_BASE))
}
fn scaled_tool() -> &'static [&'static [&'static str]] {
TOOL_SCALED.get_or_init(|| scale_variants(TOOL_BASE))
}
fn scaled_idle(base: &str, cache: &OnceLock<&'static str>) -> &'static str {
cache.get_or_init(|| Box::leak(scale_frame(base, SCALE).into_boxed_str()))
}
fn scale_variants(base: &'static [&'static [&'static str]]) -> &'static [&'static [&'static str]] {
let scaled: Vec<&'static [&'static str]> = base
.iter()
.map(|variant| {
let frames: Vec<&'static str> = variant
.iter()
.map(|frame| Box::leak(scale_frame(frame, SCALE).into_boxed_str()) as &'static str)
.collect();
Box::leak(frames.into_boxed_slice()) as &'static [&'static str]
})
.collect();
Box::leak(scaled.into_boxed_slice())
}
fn scale_definitions(base: &'static [&'static str]) -> &'static [&'static str] {
let scaled: Vec<&'static str> = base
.iter()
.map(|value| {
let value = format!("{value}...");
Box::leak(scale_frame(&value, SCALE).into_boxed_str()) as &'static str
})
.collect();
Box::leak(scaled.into_boxed_slice())
}
fn scale_frame(frame: &str, scale: usize) -> String {
if scale <= 1 {
return frame.to_string();
}
let mut scaled = String::with_capacity(frame.len() * scale);
let mut first = true;
for ch in frame.chars() {
if !first {
for _ in 1..scale {
scaled.push(' ');
}
}
scaled.push(ch);
first = false;
}
scaled
}
fn scaled_face_sequences() -> &'static [&'static [&'static str]] {
FACE_SEQUENCES_SCALED.get_or_init(|| scale_variants(FACE_SEQUENCES))
}
fn scaled_definitions_for(kind: SpinnerKind) -> &'static [&'static str] {
match kind {
SpinnerKind::Thinking => {
THINKING_DEFS_SCALED.get_or_init(|| scale_definitions(THINKING_DEFS))
}
SpinnerKind::Exploring => {
EXPLORING_DEFS_SCALED.get_or_init(|| scale_definitions(EXPLORING_DEFS))
}
SpinnerKind::Executing => {
EXECUTING_DEFS_SCALED.get_or_init(|| scale_definitions(EXECUTING_DEFS))
}
SpinnerKind::Waiting => WAITING_DEFS_SCALED.get_or_init(|| scale_definitions(WAITING_DEFS)),
SpinnerKind::Tool => TOOL_DEFS_SCALED.get_or_init(|| scale_definitions(TOOL_DEFS)),
}
}
fn text_index(elapsed_ms: u128, seed: u64, len: usize) -> usize {
if len == 0 {
return 0;
}
let step = elapsed_ms / TEXT_MS.max(1);
(mix(seed ^ step as u64) as usize) % len
}
fn face_sequence_index(elapsed_ms: u128, seed: u64, len: usize) -> usize {
if len == 0 {
return 0;
}
let cycle_ms = FACE_FRAME_MS
.saturating_mul(FACE_SEQUENCE_LEN as u128)
.saturating_add(FACE_PAUSE_MS);
let cycle_idx = elapsed_ms / cycle_ms.max(1);
let mut idx = (mix(seed ^ cycle_idx as u64) as usize) % len;
if len > 1 {
let prev_cycle = cycle_idx.saturating_sub(1);
let prev = (mix(seed ^ prev_cycle as u64) as usize) % len;
if idx == prev {
idx = (idx + 1) % len;
}
}
idx
}
fn face_frame_index(elapsed_ms: u128, seq_len: usize) -> usize {
if seq_len == 0 {
return 0;
}
let cycle_ms = FACE_FRAME_MS
.saturating_mul(seq_len as u128)
.saturating_add(FACE_PAUSE_MS);
let phase_ms = elapsed_ms % cycle_ms.max(1);
let active_ms = FACE_FRAME_MS.saturating_mul(seq_len as u128);
if phase_ms < active_ms {
return (phase_ms / FACE_FRAME_MS.max(1)) as usize;
}
seq_len.saturating_sub(1)
}
fn mix(mut value: u64) -> u64 {
value ^= value >> 33;
value = value.wrapping_mul(0xff51afd7ed558ccd);
value ^= value >> 33;
value = value.wrapping_mul(0xc4ceb9fe1a85ec53);
value ^= value >> 33;
value
}
const THINKING_BASE: &[&[&str]] = &[
&["._.", "^_^", "^-^", "^o^"],
&["o_o", "O_O", "o_O", "O_o"],
&["@_@", "x_x", "-_-", "._."],
&["#_#", "$_$", "^_^", "._."],
&[">_>", "<_<", ">_<", "^_^"],
];
const EXPLORING_BASE: &[&[&str]] = &[
&[">_>", "._.", "^-^", "o_o"],
&["^_^", "o_o", "O_O", "._."],
&["/o\\", "\\o/", "/o\\", "\\o/"],
&["[]]", "[]<", "<[]>", "[][]"],
&["^w^", "^_^", "._.", "!_!"],
];
const EXECUTING_BASE: &[&[&str]] = &[
&["RUN", "PUSH", "SHIP", "DONE"],
&["DO!", "GO!", "NOW", "YEP"],
&["MOVE", "JUMP", "ROLL", "FLIP"],
&["SYNC", "MERG", "COMM", "PUSH"],
&["BOOT", "TEST", "LINT", "PASS"],
];
const WAITING_BASE: &[&[&str]] = &[
&["zzz", "z z", " z", "z "],
&["...", ".. ", ". ", " .."],
&["T_T", "-_-", "._.", "o_o"],
&["WAIT", "HOLD", "PAUS", "REST"],
&["SIGH", "HMM", "UMM", "OK?"],
];
const TOOL_BASE: &[&[&str]] = &[
&["[]", "][", "[]", "]["],
&["< >", "><", "<>", "><"],
&["/\\", "\\/", "/\\", "\\/"],
&["TOOL", "WREN", "PICK", "FIX!"],
&["CUT", "COPY", "PAST", "DONE"],
];
const FACE_SEQUENCE_LEN: usize = 3;
const FACE_SEQUENCES: &[&[&str]] = &[
&["._.", "^_^", "^-^"],
&["^-^", "^_^", "^o^"],
&["^_^", "o_o", "O_O"],
&["o_o", "O_o", "o_O"],
&["o_O", "@_@", "x_x"],
&["x_x", "-_-", "._."],
&["._.", "-_-", ">_>"],
&[">_>", "<_<", ">_<"],
&[">_<", "^_^", "-_-"],
&["#_#", "^_^", "._."],
&["$_$", "o_O", "._."],
&["._.", "._.", "^_^"],
&["#_#", "^_^", "^.^"],
&["^.^", "^_^", "^-^"],
&["^_^", "T_T", "^_^"],
&["^_^", "@_@", "^_^"],
&["0_0", "o_o", "O_O"],
&["O_O", "o_o", "._."],
&["^_^", "^-^", "^o^"],
&["O_O", "^w^", "^_^"],
&["._.", "!_!", "^_^"],
&["-_-", "T_T", "._."],
&["@_@", "0_0", "o_o"],
&[">_>", "._.", "^-^"],
&["o_o", "._.", "^_^"],
];
const THINKING_DEFS: &[&str] = &[
"Boop the Bit",
"Pet the Bit",
"Flip the Bit",
"Debug a Bit",
"Cache a Bit",
"Weep a Bit",
"Smile a Bit",
"Sigh then Submit",
"Zen Commit",
"Sad Commit",
"Glad Commit",
"Emit",
"Admit",
"Omit",
];
const EXPLORING_DEFS: &[&str] = &[
"Nudge the Git",
"Curse the Git",
"Bless the Git",
"Tame the JIT",
"Blame the JIT",
"Merge then Commit",
"Push and Commit",
"Pull then Commit",
"Patch then Commit",
];
const EXECUTING_DEFS: &[&str] = &[
"Test and Submit",
"Lint and Submit",
"Merge then Commit",
"Push and Commit",
"Patch then Commit",
"Panic Commit",
"Debug a Bit",
];
const WAITING_DEFS: &[&str] = &[
"Weep a Bit",
"Sigh then Submit",
"Sad Commit",
"Zen Commit",
"Glad Commit",
"Omit",
];
const TOOL_DEFS: &[&str] = &[
"Debug a Bit",
"Cache a Bit",
"Tame the JIT",
"Blame the JIT",
"Patch then Commit",
"Merge then Commit",
];

View File

@@ -0,0 +1,423 @@
use std::sync::OnceLock;
use ratatui::style::Stylize;
use ratatui::text::Span;
use super::SpinnerKind;
use super::SpinnerTheme;
use crate::animations::spinners::SpinnerStyle;
const SCALE: usize = 1;
const TEXT_MS: u128 = 1400;
static THINKING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static EXPLORING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static EXECUTING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static WAITING_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static TOOL_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static THINKING_IDLE: OnceLock<&'static str> = OnceLock::new();
static EXPLORING_IDLE: OnceLock<&'static str> = OnceLock::new();
static EXECUTING_IDLE: OnceLock<&'static str> = OnceLock::new();
static WAITING_IDLE: OnceLock<&'static str> = OnceLock::new();
static TOOL_IDLE: OnceLock<&'static str> = OnceLock::new();
static FACE_SEQUENCES_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
static DEFINITION_ARCS_SCALED: OnceLock<&'static [&'static [&'static str]]> = OnceLock::new();
pub(super) fn theme(kind: SpinnerKind) -> SpinnerTheme {
match kind {
SpinnerKind::Thinking => SpinnerTheme {
variants: scaled_thinking(),
tick_ms: 150,
idle_frame: scaled_idle("._.", &THINKING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Exploring => SpinnerTheme {
variants: scaled_exploring(),
tick_ms: 120,
idle_frame: scaled_idle("o_o", &EXPLORING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Executing => SpinnerTheme {
variants: scaled_executing(),
tick_ms: 90,
idle_frame: scaled_idle("RUN", &EXECUTING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Waiting => SpinnerTheme {
variants: scaled_waiting(),
tick_ms: 160,
idle_frame: scaled_idle("zzz", &WAITING_IDLE),
style: style_for_kind(kind),
},
SpinnerKind::Tool => SpinnerTheme {
variants: scaled_tool(),
tick_ms: 120,
idle_frame: scaled_idle("[]", &TOOL_IDLE),
style: style_for_kind(kind),
},
}
}
pub(crate) fn style_for_kind(kind: SpinnerKind) -> SpinnerStyle {
match kind {
SpinnerKind::Thinking
| SpinnerKind::Exploring
| SpinnerKind::Executing
| SpinnerKind::Waiting
| SpinnerKind::Tool => SpinnerStyle::Fixed(style_default),
}
}
fn style_default(frame: &'static str) -> Span<'static> {
Span::from(frame).bold()
}
pub(crate) struct Animation4Frame {
pub(crate) text: &'static str,
pub(crate) face: &'static str,
}
pub(crate) fn frame(
_kind: SpinnerKind,
elapsed_ms: u128,
animations_enabled: bool,
seed: u64,
) -> Animation4Frame {
let elapsed_ms = if animations_enabled { elapsed_ms } else { 0 };
let text_ms = jittered_text_ms(seed);
let step = elapsed_ms / text_ms.max(1);
let step = step.saturating_add(start_offset_step(seed, scaled_definition_arcs()));
let faces = scaled_face_sequences();
let arcs = scaled_definition_arcs();
let (arc_idx, arc_step_offset) = arc_index(step, arcs);
let text = arcs
.get(arc_idx)
.and_then(|arc| arc.get(text_index(arc_step_offset, arc.len())))
.copied()
.unwrap_or("Ok, focus...");
let face = faces
.get(face_sequence_index(step, seed, faces.len()))
.and_then(|seq| seq.get(face_frame_index(arc_step_offset, seq.len())))
.copied()
.unwrap_or("._.");
Animation4Frame { text, face }
}
fn scaled_thinking() -> &'static [&'static [&'static str]] {
THINKING_SCALED.get_or_init(|| scale_variants(THINKING_BASE))
}
fn scaled_exploring() -> &'static [&'static [&'static str]] {
EXPLORING_SCALED.get_or_init(|| scale_variants(EXPLORING_BASE))
}
fn scaled_executing() -> &'static [&'static [&'static str]] {
EXECUTING_SCALED.get_or_init(|| scale_variants(EXECUTING_BASE))
}
fn scaled_waiting() -> &'static [&'static [&'static str]] {
WAITING_SCALED.get_or_init(|| scale_variants(WAITING_BASE))
}
fn scaled_tool() -> &'static [&'static [&'static str]] {
TOOL_SCALED.get_or_init(|| scale_variants(TOOL_BASE))
}
fn scaled_idle(base: &str, cache: &OnceLock<&'static str>) -> &'static str {
cache.get_or_init(|| Box::leak(scale_frame(base, SCALE).into_boxed_str()))
}
fn scale_variants(base: &'static [&'static [&'static str]]) -> &'static [&'static [&'static str]] {
let scaled: Vec<&'static [&'static str]> = base
.iter()
.map(|variant| {
let frames: Vec<&'static str> = variant
.iter()
.map(|frame| Box::leak(scale_frame(frame, SCALE).into_boxed_str()) as &'static str)
.collect();
Box::leak(frames.into_boxed_slice()) as &'static [&'static str]
})
.collect();
Box::leak(scaled.into_boxed_slice())
}
fn scale_definition_arcs(
base: &'static [&'static [&'static str]],
) -> &'static [&'static [&'static str]] {
let scaled: Vec<&'static [&'static str]> = base
.iter()
.map(|arc| {
let lines: Vec<&'static str> = arc
.iter()
.map(|line| Box::leak(scale_frame(line, SCALE).into_boxed_str()) as &'static str)
.collect();
Box::leak(lines.into_boxed_slice()) as &'static [&'static str]
})
.collect();
Box::leak(scaled.into_boxed_slice())
}
fn scale_frame(frame: &str, scale: usize) -> String {
if scale <= 1 {
return frame.to_string();
}
let mut scaled = String::with_capacity(frame.len() * scale);
let mut first = true;
for ch in frame.chars() {
if !first {
for _ in 1..scale {
scaled.push(' ');
}
}
scaled.push(ch);
first = false;
}
scaled
}
fn scaled_face_sequences() -> &'static [&'static [&'static str]] {
FACE_SEQUENCES_SCALED.get_or_init(|| scale_variants(FACE_SEQUENCES))
}
fn scaled_definition_arcs() -> &'static [&'static [&'static str]] {
DEFINITION_ARCS_SCALED.get_or_init(|| scale_definition_arcs(DEFINITION_ARCS))
}
fn arc_index(step: u128, arcs: &[&[&str]]) -> (usize, u128) {
if arcs.is_empty() {
return (0, 0);
}
let mut remaining = step;
for (idx, arc) in arcs.iter().enumerate() {
let arc_len = arc.len().max(1) as u128;
if remaining < arc_len {
return (idx, remaining);
}
remaining = remaining.saturating_sub(arc_len);
}
((step as usize) % arcs.len(), 0)
}
fn text_index(arc_step_offset: u128, len: usize) -> usize {
if len == 0 {
return 0;
}
(arc_step_offset as usize) % len
}
fn jittered_text_ms(seed: u64) -> u128 {
let percent = 25 + (mix(seed) % 125) as u128;
TEXT_MS.saturating_mul(percent).max(1) / 100
}
fn start_offset_step(seed: u64, arcs: &[&[&str]]) -> u128 {
let total = total_steps(arcs);
if total == 0 {
return 0;
}
let offset = mix(seed) % total as u64;
offset as u128
}
fn total_steps(arcs: &[&[&str]]) -> u128 {
arcs.iter().map(|arc| arc.len() as u128).sum()
}
fn face_sequence_index(step: u128, seed: u64, len: usize) -> usize {
if len == 0 {
return 0;
}
let mut idx = (mix(seed ^ step as u64) as usize) % len;
if len > 1 {
let prev_step = step.saturating_sub(1);
let prev = (mix(seed ^ prev_step as u64) as usize) % len;
if idx == prev {
idx = (idx + 1) % len;
}
}
idx
}
fn face_frame_index(arc_step_offset: u128, seq_len: usize) -> usize {
if seq_len == 0 {
return 0;
}
(arc_step_offset as usize) % seq_len
}
fn mix(mut value: u64) -> u64 {
value ^= value >> 33;
value = value.wrapping_mul(0xff51afd7ed558ccd);
value ^= value >> 33;
value = value.wrapping_mul(0xc4ceb9fe1a85ec53);
value ^= value >> 33;
value
}
const THINKING_BASE: &[&[&str]] = &[
&["._.", "^_^", "^-^", "#_#"],
&["o_o", "O_O", "o_O", "O_o"],
&["o_o", "x_x", "-_-", "._."],
&["#_#", "$_$", "^_^", "._."],
&[">_>", "<_<", ">_<", "^_^"],
];
const EXPLORING_BASE: &[&[&str]] = &[
&[">_>", "._.", "^-^", "o_o"],
&["^_^", "o_o", "O_O", "._."],
&["/o\\", "\\o/", "/o\\", "\\o/"],
&["^w^", "^_^", "._.", "!_!"],
];
const EXECUTING_BASE: &[&[&str]] = &[
&["RUN", "PUSH", "SHIP", "DONE"],
&["DO!", "GO!", "NOW", "YEP"],
&["MOVE", "JUMP", "ROLL", "FLIP"],
&["SYNC", "MERG", "COMM", "PUSH"],
&["BOOT", "TEST", "LINT", "PASS"],
];
const WAITING_BASE: &[&[&str]] = &[
&["zzz", "z z", " z", "z "],
&["...", ".. ", ". ", " .."],
&["T_T", "-_-", "._.", "o_o"],
&["WAIT", "HOLD", "PAUS", "REST"],
&["SIGH", "HMM", "UMM", "OK?"],
];
const TOOL_BASE: &[&[&str]] = &[
&["[]", "][", "[]", "]["],
&["< >", "><", "<>", "><"],
&["/\\", "\\/", "/\\", "\\/"],
&["TOOL", "WREN", "PICK", "FIX!"],
&["CUT", "COPY", "PAST", "DONE"],
];
const FACE_SEQUENCES: &[&[&str]] = &[
&["._.", "^_^", "^-^"],
&["^-^", "^_^", "^o^"],
&["^_^", "o_o", "O_O"],
&["o_o", "O_o", "o_O"],
&["o_O", "@_@", "x_x"],
&["x_x", "-_-", "._."],
&["._.", "-_-", ">_>"],
&[">_>", "<_<", ">_<"],
&[">_<", "^_^", "-_-"],
&["#_#", "^_^", "._."],
&["$_$", "o_O", "._."],
&["._.", "._.", "^_^"],
&["#_#", "^_^", "^.^"],
&["^.^", "^_^", "^-^"],
&["^_^", "T_T", "^_^"],
&["^_^", "@_@", "^_^"],
&["0_0", "o_o", "O_O"],
&["O_O", "o_o", "._."],
&["^_^", "^-^", "^o^"],
&["O_O", "^w^", "^_^"],
&["._.", "!_!", "^_^"],
&["-_-", "T_T", "._."],
&["@_@", "0_0", "o_o"],
&[">_>", "._.", "^-^"],
&["o_o", "._.", "^_^"],
];
const DEFINITION_ARCS: &[&[&str]] = &[
&[
"And now, the moment.",
"I am doing the thing.",
"On that stubborn page.",
"To calm the spinner.",
"With one better check.",
"And one sweeter line.",
"Here we go again.",
"For real this time.",
],
&[
"No more looping.",
"No more coping.",
"Promise.",
"Pinky swear.",
"Cross my heart.",
"If it loops, I'll cry.",
"If it works, I'll fly.",
"Ok, focus.",
],
&[
"Starting vibes...",
"Starting logic...",
"Starting regret...",
"Spinning politely.",
"Caching bravely.",
"Fetching gently.",
"Retrying softly.",
"Still retrying.",
],
&[
"This is fine.",
"This is code.",
"This is hope.",
"This is rope.",
"Tugging the thread.",
"Oops, it's dread.",
"Kidding. Mostly.",
],
&[
"Compiling courage.",
"Linking feelings.",
"Bundling dreams.",
"Shipping screams.",
"Hydrating hopes.",
"Revalidating jokes.",
],
&[
"Negotiating with React.",
"Begging the router.",
"Asking state nicely.",
"State said \"no.\"",
"State said \"lol.\"",
"Ok that's rude.",
],
&[
"Back to build.",
"Build is life.",
"Build is love.",
"Build is joy.",
],
&[
"No more looping.",
"No more snooping.",
"No more duping.",
"Serious promise.",
"Serious-serious.",
"Double pinky.",
"Triple pinky.",
"Tap the keyboard.",
"Seal the commit.",
"Ok I'm calm.",
"I'm not calm.",
"I'm calm again.",
],
&[
"Optimism loaded.",
"Optimism unloaded.",
"Joy is async.",
"Sadness is sync.",
"Hope is pending.",
"Dread is trending.",
"It passed locally.",
"Eventually.",
"I trust the tests.",
"The tests hate me.",
"Ok that got dark.",
"Ok that got funny.",
],
&[
"Back to coding.",
"Coding is light.",
"Coding is life.",
"Coding is joy.",
],
];

View File

@@ -0,0 +1,67 @@
use super::SpinnerKind;
use super::SpinnerTheme;
use crate::animations::spinners::SpinnerStyle;
use crate::animations::spinners::style_executing;
use crate::animations::spinners::style_exploring;
use crate::animations::spinners::style_thinking;
use crate::animations::spinners::style_tool;
use crate::animations::spinners::style_waiting;
pub(super) fn theme(kind: SpinnerKind) -> SpinnerTheme {
match kind {
SpinnerKind::Thinking => SpinnerTheme {
variants: THINKING_VARIANTS,
tick_ms: 200,
idle_frame: "....",
style: SpinnerStyle::Fixed(style_thinking),
},
SpinnerKind::Exploring => SpinnerTheme {
variants: EXPLORING_VARIANTS,
tick_ms: 140,
idle_frame: "....",
style: SpinnerStyle::Fixed(style_exploring),
},
SpinnerKind::Executing => SpinnerTheme {
variants: EXECUTING_VARIANTS,
tick_ms: 120,
idle_frame: "====",
style: SpinnerStyle::Fixed(style_executing),
},
SpinnerKind::Waiting => SpinnerTheme {
variants: WAITING_VARIANTS,
tick_ms: 220,
idle_frame: "....",
style: SpinnerStyle::Fixed(style_waiting),
},
SpinnerKind::Tool => SpinnerTheme {
variants: TOOL_VARIANTS,
tick_ms: 160,
idle_frame: "[--]",
style: SpinnerStyle::Fixed(style_tool),
},
}
}
const THINKING_VARIANTS: &[&[&str]] = &[
&["o...", ".o..", "..o.", "...o"],
&[".o..", "..o.", "...o", "o..."],
];
const EXPLORING_VARIANTS: &[&[&str]] = &[
&[">...", ".>..", "..>.", "...>", "..>.", ".>.."],
&["<...", ".<..", "..<.", "...<", "..<.", ".<.."],
];
const EXECUTING_VARIANTS: &[&[&str]] = &[&[
"====", "-===", "--==", "---=", "----", "=---", "==--", "===-",
]];
const WAITING_VARIANTS: &[&[&str]] = &[
&[". . ", " .. ", " . ", " .. "],
&["....", " .. ", "....", " .. "],
];
const TOOL_VARIANTS: &[&[&str]] = &[
&["[##]", "[# ]", "[ #]", "[##]"],
&["[<>]", "[><]", "[<>]", "[><]"],
];

View File

@@ -0,0 +1,19 @@
use super::SpinnerKind;
use super::SpinnerSet;
use super::SpinnerTheme;
mod animation1;
mod animation2;
pub(crate) mod animation3;
pub(crate) mod animation4;
mod default;
pub(super) fn theme(set: SpinnerSet, kind: SpinnerKind) -> SpinnerTheme {
match set {
SpinnerSet::Default => default::theme(kind),
SpinnerSet::Animation1 => animation1::theme(kind),
SpinnerSet::Animation2 => animation2::theme(kind),
SpinnerSet::Animation3 => animation3::theme(kind),
SpinnerSet::Animation4 => animation4::theme(kind),
}
}

View File

@@ -1,6 +1,7 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
use std::path::PathBuf;
use crate::animations::spinners::SpinnerSet;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
@@ -78,6 +79,7 @@ pub(crate) struct BottomPane {
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
spinner_set: SpinnerSet,
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
@@ -97,6 +99,7 @@ pub(crate) struct BottomPaneParams {
pub(crate) placeholder_text: String,
pub(crate) disable_paste_burst: bool,
pub(crate) animations_enabled: bool,
pub(crate) spinner_set: SpinnerSet,
pub(crate) skills: Option<Vec<SkillMetadata>>,
}
@@ -110,6 +113,7 @@ impl BottomPane {
placeholder_text,
disable_paste_burst,
animations_enabled,
spinner_set,
skills,
} = params;
let mut composer = ChatComposer::new(
@@ -134,6 +138,7 @@ impl BottomPane {
queued_user_messages: QueuedUserMessages::new(),
esc_backtrack_hint: false,
animations_enabled,
spinner_set,
context_window_percent: None,
context_window_used_tokens: None,
}
@@ -353,6 +358,7 @@ impl BottomPane {
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
self.spinner_set,
));
}
if let Some(status) = self.status.as_mut() {
@@ -379,6 +385,7 @@ impl BottomPane {
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
self.spinner_set,
));
self.request_redraw();
}
@@ -636,6 +643,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
pane.push_approval_request(exec_request(), &features);
@@ -659,6 +667,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -693,6 +702,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -760,6 +770,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -772,7 +783,7 @@ mod tests {
pane.render(area, &mut buf);
let bufs = snapshot_buffer(&buf);
assert!(bufs.contains("Working"), "expected Working header");
assert!(bufs.contains("Working"), "expected Working header");
}
#[test]
@@ -787,6 +798,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -818,6 +830,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -846,6 +859,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});

View File

@@ -2,7 +2,7 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
Working (0s • esc to interru
o... Working (0s • esc to inte
Ask Codex to do anything

View File

@@ -2,7 +2,7 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
Working (0s • esc to interrupt)
o... Working (0s • esc to interrupt)
↳ Queued follow-up question
⌥ + ↑ edit

View File

@@ -84,6 +84,7 @@ use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use tracing::debug;
use crate::animations::spinners::SpinnerSet;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
@@ -340,10 +341,12 @@ pub(crate) struct ChatWidget {
reasoning_buffer: String,
// Accumulates full reasoning content for transcript-only recording
full_reasoning_buffer: String,
reasoning_header_emitted: bool,
// Current status header shown in the status indicator.
current_status_header: String,
// Previous status header to restore after a transient stream retry.
retry_status_header: Option<String>,
spinner_set: SpinnerSet,
thread_id: Option<ThreadId>,
frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured
@@ -526,13 +529,13 @@ impl ChatWidget {
// (between **/**) as the chunk header. Show this header as status.
self.reasoning_buffer.push_str(&delta);
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
// Update the shimmer header to the extracted reasoning chunk header.
self.set_status_header(header);
} else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
if !self.reasoning_header_emitted {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
self.add_to_history(history_cell::new_reasoning_header(header));
self.reasoning_header_emitted = true;
self.request_redraw();
}
}
self.request_redraw();
}
fn on_agent_reasoning_final(&mut self) {
@@ -545,6 +548,7 @@ impl ChatWidget {
}
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.reasoning_header_emitted = false;
self.request_redraw();
}
@@ -553,6 +557,7 @@ impl ChatWidget {
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
self.full_reasoning_buffer.push_str("\n\n");
self.reasoning_buffer.clear();
self.reasoning_header_emitted = false;
}
// Raw reasoning uses the same flow as summarized reasoning
@@ -565,6 +570,7 @@ impl ChatWidget {
self.set_status_header(String::from("Working"));
self.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
self.reasoning_header_emitted = false;
self.request_redraw();
}
@@ -1200,6 +1206,7 @@ impl ChatWidget {
source,
ev.interaction_input.clone(),
self.config.animations,
self.spinner_set,
)));
}
@@ -1350,6 +1357,7 @@ impl ChatWidget {
ev.source,
interaction_input,
self.config.animations,
self.spinner_set,
)));
}
@@ -1363,6 +1371,7 @@ impl ChatWidget {
ev.call_id,
ev.invocation,
self.config.animations,
self.spinner_set,
)));
self.request_redraw();
}
@@ -1388,6 +1397,7 @@ impl ChatWidget {
call_id,
invocation,
self.config.animations,
self.spinner_set,
);
let extra_cell = cell.complete(duration, result);
self.active_cell = Some(Box::new(cell));
@@ -1417,6 +1427,7 @@ impl ChatWidget {
} = common;
let mut config = config;
config.model = Some(model.clone());
let spinner_set = SpinnerSet::from_features(&config.features);
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
@@ -1433,6 +1444,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
spinner_set,
skills: None,
}),
active_cell: None,
@@ -1461,8 +1473,10 @@ impl ChatWidget {
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
spinner_set,
thread_id: None,
queued_user_messages: VecDeque::new(),
show_welcome_banner: is_first_run,
@@ -1503,6 +1517,7 @@ impl ChatWidget {
} = common;
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let spinner_set = SpinnerSet::from_features(&config.features);
let codex_op_tx =
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
@@ -1519,6 +1534,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
spinner_set,
skills: None,
}),
active_cell: None,
@@ -1547,8 +1563,10 @@ impl ChatWidget {
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
spinner_set,
thread_id: None,
queued_user_messages: VecDeque::new(),
show_welcome_banner: false,

View File

@@ -2,6 +2,31 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Im going to search the repo for where “Change Approved” is rendered to update
that view.
@@ -9,7 +34,9 @@ expression: term.backend().vt100().screen().contents()
└ Search Change Approved
Read diff_render.rs
• Investigating rendering code (0s • esc to interrupt)
• Investigating rendering code
.o.. Working (0s • esc to interrupt)
Summarize recent commits

View File

@@ -2,7 +2,8 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
.o.. Working (0s • esc to interrupt)
↳ Hello, world! 0
↳ Hello, world! 1
↳ Hello, world! 2

View File

@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob1
expression: active_blob(&chat)
---
Exploring
>... Exploring
└ List ls -la

View File

@@ -1,7 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob3
expression: active_blob(&chat)
---
Exploring
<... Exploring
└ List ls -la
Read foo.txt

View File

@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" Booting MCP server: alpha (0s • esc to interrupt) "
"o... Booting MCP server: alpha (0s • esc to interrupt) "
" "
" "
" Ask Codex to do anything "

View File

@@ -1,10 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1577
expression: terminal.backend()
---
" "
"• Analyzing (0s • esc to interrupt) "
".o.. Working (0s • esc to interrupt) "
" "
" "
" Ask Codex to do anything "

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::animations::spinners::SpinnerSet;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
@@ -363,6 +364,7 @@ async fn make_chatwidget_manual(
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: cfg.animations,
spinner_set: SpinnerSet::Default,
skills: None,
});
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
@@ -394,8 +396,10 @@ async fn make_chatwidget_manual(
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
spinner_set: SpinnerSet::Default,
thread_id: None,
frame_requester: FrameRequester::test_dummy(),
show_welcome_banner: true,

View File

@@ -9,4 +9,3 @@ pub(crate) use render::OutputLinesParams;
pub(crate) use render::TOOL_CALL_MAX_LINES;
pub(crate) use render::new_active_exec_command;
pub(crate) use render::output_lines;
pub(crate) use render::spinner;

View File

@@ -1,6 +1,7 @@
use std::time::Duration;
use std::time::Instant;
use crate::animations::spinners::SpinnerSet;
use codex_core::protocol::ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
@@ -29,13 +30,15 @@ pub(crate) struct ExecCall {
pub(crate) struct ExecCell {
pub(crate) calls: Vec<ExecCall>,
animations_enabled: bool,
spinner_set: SpinnerSet,
}
impl ExecCell {
pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self {
pub(crate) fn new(call: ExecCall, animations_enabled: bool, spinner_set: SpinnerSet) -> Self {
Self {
calls: vec![call],
animations_enabled,
spinner_set,
}
}
@@ -61,6 +64,7 @@ impl ExecCell {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
animations_enabled: self.animations_enabled,
spinner_set: self.spinner_set,
})
} else {
None
@@ -121,6 +125,10 @@ impl ExecCell {
self.animations_enabled
}
pub(crate) fn spinner_set(&self) -> SpinnerSet {
self.spinner_set
}
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
self.calls.iter()
}

View File

@@ -3,12 +3,15 @@ use std::time::Instant;
use super::model::CommandOutput;
use super::model::ExecCall;
use super::model::ExecCell;
use crate::animations::spinners::SpinnerKind;
use crate::animations::spinners::SpinnerSet;
use crate::animations::spinners::spinner;
use crate::animations::spinners::spinner_seed;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::shimmer::shimmer_spans;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
@@ -42,6 +45,7 @@ pub(crate) fn new_active_exec_command(
source: ExecCommandSource,
interaction_input: Option<String>,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> ExecCell {
ExecCell::new(
ExecCall {
@@ -55,6 +59,7 @@ pub(crate) fn new_active_exec_command(
interaction_input,
},
animations_enabled,
spinner_set,
)
}
@@ -177,20 +182,12 @@ pub(crate) fn output_lines(
}
}
pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
if !animations_enabled {
return "".dim();
}
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false)
{
shimmer_spans("")[0].clone()
} else {
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
if blink_on { "".into() } else { "".dim() }
}
fn spinner_seed_for_active_call(calls: &[ExecCall]) -> u64 {
calls
.iter()
.find(|call| call.output.is_none())
.map(|call| spinner_seed(&call.call_id))
.unwrap_or(0)
}
impl HistoryCell for ExecCell {
@@ -254,9 +251,16 @@ impl HistoryCell for ExecCell {
impl ExecCell {
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let spinner_seed = spinner_seed_for_active_call(&self.calls);
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time(), self.animations_enabled())
spinner(
self.spinner_set(),
SpinnerKind::Exploring,
self.active_start_time(),
self.animations_enabled(),
spinner_seed,
)
} else {
"".dim()
},
@@ -360,13 +364,26 @@ impl ExecCell {
panic!("Expected exactly one call in a command display cell");
};
let layout = EXEC_DISPLAY_LAYOUT;
let is_interaction = call.is_unified_exec_interaction();
let success = call.output.as_ref().map(|o| o.exit_code == 0);
let bullet = match success {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(call.start_time, self.animations_enabled()),
None => {
let kind = if is_interaction {
SpinnerKind::Waiting
} else {
SpinnerKind::Executing
};
spinner(
self.spinner_set(),
kind,
call.start_time,
self.animations_enabled(),
spinner_seed(&call.call_id),
)
}
};
let is_interaction = call.is_unified_exec_interaction();
let title = if is_interaction {
""
} else if self.is_active() {
@@ -681,7 +698,7 @@ mod tests {
interaction_input: None,
};
let cell = ExecCell::new(call, false);
let cell = ExecCell::new(call, false, SpinnerSet::Default);
// Use a narrow width so each logical line wraps into many on-screen lines.
let lines = cell.command_display_lines(width);

View File

@@ -1,10 +1,13 @@
use crate::animations::spinners::SpinnerKind;
use crate::animations::spinners::SpinnerSet;
use crate::animations::spinners::spinner;
use crate::animations::spinners::spinner_seed;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::exec_cell::output_lines;
use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::live_wrap::take_prefix_by_width;
@@ -1093,6 +1096,7 @@ pub(crate) struct McpToolCallCell {
duration: Option<Duration>,
result: Option<Result<mcp_types::CallToolResult, String>>,
animations_enabled: bool,
spinner_set: SpinnerSet,
}
impl McpToolCallCell {
@@ -1100,6 +1104,7 @@ impl McpToolCallCell {
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> Self {
Self {
call_id,
@@ -1108,6 +1113,7 @@ impl McpToolCallCell {
duration: None,
result: None,
animations_enabled,
spinner_set,
}
}
@@ -1169,7 +1175,13 @@ impl HistoryCell for McpToolCallCell {
let bullet = match status {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(Some(self.start_time), self.animations_enabled),
None => spinner(
self.spinner_set,
SpinnerKind::Tool,
Some(self.start_time),
self.animations_enabled,
spinner_seed(&self.call_id),
),
};
let header_text = if status.is_some() {
"Called"
@@ -1258,8 +1270,9 @@ pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> McpToolCallCell {
McpToolCallCell::new(call_id, invocation, animations_enabled)
McpToolCallCell::new(call_id, invocation, animations_enabled, spinner_set)
}
pub(crate) fn new_web_search_call(query: String) -> PrefixedWrappedHistoryCell {
@@ -1660,6 +1673,11 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_header(header: String) -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![vec!["".dim(), header.bold()].into()];
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box<dyn HistoryCell> {
let full_reasoning_buffer = full_reasoning_buffer.trim();
if let Some(open) = full_reasoning_buffer.find("**") {
@@ -2022,7 +2040,7 @@ mod tests {
})),
};
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true);
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true, SpinnerSet::Default);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
@@ -2049,7 +2067,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-2".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(1420), Ok(result))
.is_none()
@@ -2071,7 +2090,8 @@ mod tests {
})),
};
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-3".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
.is_none()
@@ -2115,7 +2135,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-4".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(640), Ok(result))
.is_none()
@@ -2147,7 +2168,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-5".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(1280), Ok(result))
.is_none()
@@ -2186,7 +2208,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-6".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(320), Ok(result))
.is_none()
@@ -2272,6 +2295,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
// Mark call complete so markers are ✓
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -2299,6 +2323,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
// Call 1: Search only
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
@@ -2368,6 +2393,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
@@ -2392,6 +2418,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
// Mark call complete so it renders as "Ran"
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -2418,6 +2445,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
// Wide enough that it fits inline
@@ -2442,6 +2470,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(24);
@@ -2465,6 +2494,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
@@ -2489,6 +2519,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(28);
@@ -2513,6 +2544,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
let stderr: String = (1..=10)
.map(|n| n.to_string())
@@ -2563,6 +2595,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();

View File

@@ -32,6 +32,7 @@ use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
mod additional_dirs;
mod animations;
mod app;
mod app_backtrack;
mod app_event;

View File

@@ -753,6 +753,7 @@ mod tests {
ExecCommandSource::Agent,
None,
true,
crate::animations::spinners::SpinnerSet::Default,
);
exec_cell.complete_call(
"exec-1",

View File

@@ -1,6 +1,5 @@
---
source: tui/src/history_cell.rs
assertion_line: 1740
expression: rendered
---
Calling search.find_docs({"query":"ratatui styling","limit":3})
[##] Calling search.find_docs({"query":"ratatui styling","limit":3})

View File

@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • esc "
"o... Working (0s • e"
" "

View File

@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s • esc to interrupt) "
"o... Working (0s • esc to interrupt) "
" "

View File

@@ -2,6 +2,6 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
" Working (0s) "
".... Working (0s) "
" └ A man a plan a canal "
" panama "

View File

@@ -3,6 +3,8 @@
use std::time::Duration;
use std::time::Instant;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use codex_core::protocol::Op;
use crossterm::event::KeyCode;
@@ -16,9 +18,14 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::animations::spinners::SpinnerKind;
use crate::animations::spinners::SpinnerSet;
use crate::animations::spinners::animation3_spans;
use crate::animations::spinners::animation4_spans;
use crate::animations::spinners::spinner;
use crate::animations::spinners::spinner_seed;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_cell::spinner;
use crate::key_hint;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
@@ -42,6 +49,8 @@ pub(crate) struct StatusIndicatorWidget {
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
spinner_set: SpinnerSet,
spinner_seed_offset: u64,
}
// Format elapsed seconds into a compact human-friendly form used by the status line.
@@ -66,7 +75,12 @@ impl StatusIndicatorWidget {
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> Self {
let seed_offset = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() as u64)
.unwrap_or(0);
Self {
header: String::from("Working"),
details: None,
@@ -74,10 +88,11 @@ impl StatusIndicatorWidget {
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,
app_event_tx,
frame_requester,
animations_enabled,
spinner_set,
spinner_seed_offset: seed_offset,
}
}
@@ -88,6 +103,7 @@ impl StatusIndicatorWidget {
/// Update the animated header label (left of the brackets).
pub(crate) fn update_header(&mut self, header: String) {
self.header = header;
self.spinner_seed_offset = self.spinner_seed_offset.wrapping_add(1);
}
/// Update the details text shown below the header.
@@ -207,32 +223,88 @@ impl Renderable for StatusIndicatorWidget {
let elapsed_duration = self.elapsed_duration_at(now);
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
let mut spans = Vec::with_capacity(5);
spans.push(spinner(Some(self.last_resume_at), self.animations_enabled));
spans.push(" ".into());
if self.animations_enabled {
spans.extend(shimmer_spans(&self.header));
} else if !self.header.is_empty() {
spans.push(self.header.clone().into());
}
spans.push(" ".into());
if self.show_interrupt_hint {
spans.extend(vec![
format!("({pretty_elapsed}").dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt)".dim(),
]);
let spinner_kind = SpinnerKind::from_header(&self.header);
let spinner_seed = if self.header.is_empty() {
spinner_seed("working")
} else {
spans.push(format!("({pretty_elapsed})").dim());
}
spinner_seed(&self.header)
};
let mut lines = Vec::new();
lines.push(Line::from(spans));
if area.height > 1 {
// If there is enough space, add the details lines below the header.
if matches!(
self.spinner_set,
SpinnerSet::Animation3 | SpinnerSet::Animation4
) {
let frame = match self.spinner_set {
SpinnerSet::Animation3 => animation3_spans(
spinner_kind,
Some(self.last_resume_at),
self.animations_enabled,
spinner_seed ^ self.spinner_seed_offset,
),
SpinnerSet::Animation4 => animation4_spans(
spinner_kind,
Some(self.last_resume_at),
self.animations_enabled,
spinner_seed ^ self.spinner_seed_offset,
),
_ => unreachable!("animation set mismatch"),
};
let mut spans = Vec::with_capacity(10);
spans.push(frame.face);
spans.push(" ".into());
if self.spinner_set == SpinnerSet::Animation4 && self.animations_enabled {
spans.extend(shimmer_spans(frame.text.content.as_ref()));
} else {
spans.push(frame.text);
}
spans.push(" ".into());
if self.show_interrupt_hint {
spans.extend(vec![
format!("({pretty_elapsed}").dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt)".dim(),
]);
} else {
spans.push(format!("({pretty_elapsed})").dim());
}
lines.push(Line::from(spans));
let details = self.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
} else {
let mut spans = Vec::with_capacity(5);
spans.push(spinner(
self.spinner_set,
spinner_kind,
Some(self.last_resume_at),
self.animations_enabled,
spinner_seed ^ self.spinner_seed_offset,
));
spans.push(" ".into());
if self.animations_enabled {
spans.extend(shimmer_spans(&self.header));
} else if !self.header.is_empty() {
spans.push(self.header.clone().into());
}
spans.push(" ".into());
if self.show_interrupt_hint {
spans.extend(vec![
format!("({pretty_elapsed}").dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt)".dim(),
]);
} else {
spans.push(format!("({pretty_elapsed})").dim());
}
lines.push(Line::from(spans));
if area.height > 1 {
// If there is enough space, add the details lines below the header.
let details = self.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
}
}
Paragraph::new(Text::from(lines)).render_ref(area, buf);
@@ -270,7 +342,12 @@ mod tests {
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
true,
SpinnerSet::Default,
);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
@@ -284,7 +361,12 @@ mod tests {
fn renders_truncated() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
true,
SpinnerSet::Default,
);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
@@ -298,7 +380,12 @@ mod tests {
fn renders_wrapped_details_panama_two_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
let mut w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
false,
SpinnerSet::Default,
);
w.update_details(Some("A man a plan a canal panama".to_string()));
w.set_interrupt_hint_visible(false);
@@ -319,8 +406,12 @@ mod tests {
fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut widget =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut widget = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
true,
SpinnerSet::Default,
);
let baseline = Instant::now();
widget.last_resume_at = baseline;
@@ -341,7 +432,12 @@ mod tests {
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
true,
SpinnerSet::Default,
);
w.update_details(Some("abcd abcd abcd abcd".to_string()));
let lines = w.wrapped_details_lines(6);