diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 7ef74cfb7a..5cc5afaad6 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -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, + }, ]; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 329d8ec805..512420b31d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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, @@ -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>, } @@ -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()), }); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap index 86e3da4573..ee69faae02 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 27df671e4d..0efecf7d13 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 868319893c..e27cd35e43 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, + spinner_set: SpinnerSet, thread_id: Option, 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, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index c3bdf60bd2..f26744ce1e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -2,6 +2,31 @@ source: tui/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + + + + + + + + + + + + + + + + + + + + + + + + + • I’m 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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index 6d9aa515b1..5aa7eedf16 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap index 588a9503eb..748455c209 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -expression: blob1 +expression: active_blob(&chat) --- -• Exploring +>... Exploring └ List ls -la diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap index 2ce4170929..e9955c6556 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -1,7 +1,7 @@ --- source: tui/src/chatwidget/tests.rs -expression: blob3 +expression: active_blob(&chat) --- -• Exploring +<... Exploring └ List ls -la Read foo.txt diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap index c6866c1b51..291f75df61 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__mcp_startup_header_booting.snap @@ -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 " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index 9fbebfb500..3c4e1c644e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -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 " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cf53b7fac9..75fa6632e7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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, diff --git a/codex-rs/tui/src/exec_cell/mod.rs b/codex-rs/tui/src/exec_cell/mod.rs index 906091113e..58976e12ac 100644 --- a/codex-rs/tui/src/exec_cell/mod.rs +++ b/codex-rs/tui/src/exec_cell/mod.rs @@ -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; diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 76316968c6..e40c242211 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -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, 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 { self.calls.iter() } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 6517bcf470..cb161b9f79 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -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, 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, 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> { let mut out: Vec> = 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); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4e16306d13..a5a7a3b8ee 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -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, result: Option>, 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> = vec![vec!["• ".dim(), header.bold()].into()]; + PlainHistoryCell { lines } +} + pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box { 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(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6f4faaad65..e701284040 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 46aaba8644..602296b1d6 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -753,6 +753,7 @@ mod tests { ExecCommandSource::Agent, None, true, + crate::animations::spinners::SpinnerSet::Default, ); exec_cell.complete_call( "exec-1", diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap index 7360fd138f..ecba7df37e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap @@ -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}) diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap index ba179808bd..905660c7a1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap @@ -2,5 +2,5 @@ source: tui/src/status_indicator_widget.rs expression: terminal.backend() --- -"• Working (0s • esc " +"o... Working (0s • e" " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap index 2b80830f81..a1c2b41b2e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap @@ -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) " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap index 565d5451ff..3982e6730f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap @@ -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 " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index bef0d0328d..6c4b3b6dd6 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -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::(); 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::(); 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::(); 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::(); 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::(); 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);