diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 3248c7377c..287640de58 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -17,6 +17,8 @@ path = "src/lib.rs" vt100-tests = [] # Gate verbose debug logging inside the TUI implementation. debug-logs = [] +# Enable entertainment-only shimmer behavior in the status indicator. +entertainment = [] [lints] workspace = true diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 868319893c..45dcafd853 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -340,6 +340,8 @@ pub(crate) struct ChatWidget { reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, + // Tracks whether the current reasoning section header has been emitted to history. + 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. @@ -523,18 +525,28 @@ impl ChatWidget { fn on_agent_reasoning_delta(&mut self, delta: String) { // For reasoning deltas, do not stream to history. Accumulate the // current reasoning block and extract the first bold element - // (between **/**) as the chunk header. Show this header as status. + // (between **/**) as the chunk header. Emit this header as a history entry. 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.emit_reasoning_header(header); + self.reasoning_header_emitted = true; + } } self.request_redraw(); } + fn emit_reasoning_header(&mut self, header: String) { + let mut rendered: Vec> = Vec::new(); + append_markdown(&format!("**{header}**"), None, &mut rendered); + if rendered.is_empty() { + return; + } + let cell = AgentMessageCell::new(rendered, true); + self.add_boxed_history(Box::new(cell)); + } + fn on_agent_reasoning_final(&mut self) { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); @@ -545,6 +557,7 @@ impl ChatWidget { } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.reasoning_header_emitted = false; self.request_redraw(); } @@ -553,6 +566,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 +579,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(); } @@ -1461,6 +1476,7 @@ 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, thread_id: None, @@ -1547,6 +1563,7 @@ 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, thread_id: None, 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..257a4e07db 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 + +• 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..14b71a1494 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,6 +2,7 @@ source: tui/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + • Working (0s • esc to interrupt) ↳ Hello, world! 0 ↳ Hello, world! 1 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..fa4959d0bd 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) " +"• 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..735a07ac73 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -394,6 +394,7 @@ 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, thread_id: None, @@ -2713,12 +2714,6 @@ async fn ui_snapshots_small_heights_task_running() { model_context_window: None, }), }); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Thinking**".into(), - }), - }); for h in [1u16, 2, 3] { let name = format!("chat_small_running_h{h}"); let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); @@ -2744,13 +2739,6 @@ async fn status_widget_and_approval_modal_snapshot() { model_context_window: None, }), }); - // Provide a deterministic header for the status line. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); // Now show an approval modal (e.g. exec approval). let ev = ExecApprovalRequestEvent { @@ -2796,13 +2784,6 @@ async fn status_widget_active_snapshot() { model_context_window: None, }), }); - // Provide a deterministic header via a bold reasoning chunk. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); // Render and snapshot. let height = chat.desired_height(80); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) @@ -3343,7 +3324,7 @@ async fn stream_error_updates_status_indicator() { .bottom_pane .status_widget() .expect("status indicator should be visible"); - assert_eq!(status.header(), msg); + assert_eq!(status.header(), msg.to_string()); assert_eq!(status.details(), Some(details)); } @@ -3396,7 +3377,7 @@ async fn stream_recovery_restores_previous_status_header() { .bottom_pane .status_widget() .expect("status indicator should be visible"); - assert_eq!(status.header(), "Working"); + assert_eq!(status.header(), "Working".to_string()); assert_eq!(status.details(), None); assert!(chat.retry_status_header.is_none()); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6f4faaad65..82ee764044 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -68,8 +68,11 @@ mod resume_picker; mod selection_list; mod session_log; mod shimmer; +#[cfg(feature = "entertainment")] +mod shimmer_text; mod slash_command; mod status; +mod status_indicator_shimmer; mod status_indicator_widget; mod streaming; mod style; diff --git a/codex-rs/tui/src/shimmer_text.rs b/codex-rs/tui/src/shimmer_text.rs new file mode 100644 index 0000000000..1bc839d20c --- /dev/null +++ b/codex-rs/tui/src/shimmer_text.rs @@ -0,0 +1,251 @@ +use rand::Rng; +use rand::SeedableRng; +use rand::rngs::StdRng; +use std::time::Duration; + +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.", + ], +]; + +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", "._.", "^_^"], +]; + +#[derive(Debug, Clone)] +pub(crate) struct ShimmerStep { + pub(crate) face: String, + pub(crate) text: String, +} + +#[derive(Debug)] +pub(crate) struct ShimmerText { + definition_arc_index: usize, + definition_item_index: usize, + face_arc_index: usize, + face_item_index: usize, + rng: StdRng, +} + +impl Default for ShimmerText { + fn default() -> Self { + Self::new() + } +} + +impl ShimmerText { + pub(crate) fn new() -> Self { + let mut rng = Self::seeded_rng(); + let definition_arc_index = Self::pick_arc(&mut rng, None, DEFINITION_ARCS.len()); + let face_arc_index = Self::pick_arc(&mut rng, None, FACE_SEQUENCES.len()); + Self { + definition_arc_index, + definition_item_index: 0, + face_arc_index, + face_item_index: 0, + rng, + } + } + + pub(crate) fn get_next(&mut self) -> ShimmerStep { + let text_arc = DEFINITION_ARCS[self.definition_arc_index]; + let face_arc = FACE_SEQUENCES[self.face_arc_index]; + let text = text_arc[self.definition_item_index]; + let face = face_arc[self.face_item_index]; + + self.face_item_index += 1; + if self.face_item_index >= face_arc.len() { + self.face_item_index = 0; + self.definition_item_index += 1; + self.face_arc_index = Self::pick_arc( + &mut self.rng, + Some(self.face_arc_index), + FACE_SEQUENCES.len(), + ); + if self.definition_item_index >= text_arc.len() { + self.definition_item_index = 0; + self.definition_arc_index = Self::pick_arc( + &mut self.rng, + Some(self.definition_arc_index), + DEFINITION_ARCS.len(), + ); + } + } + + ShimmerStep { + face: face.to_string(), + text: text.to_string(), + } + } + + pub(crate) fn reset_and_get_next(&mut self) -> ShimmerStep { + self.definition_arc_index = Self::pick_arc( + &mut self.rng, + Some(self.definition_arc_index), + DEFINITION_ARCS.len(), + ); + self.face_arc_index = Self::pick_arc( + &mut self.rng, + Some(self.face_arc_index), + FACE_SEQUENCES.len(), + ); + self.definition_item_index = 0; + self.face_item_index = 0; + self.get_next() + } + + pub(crate) fn is_default_label(&self, text: &str) -> bool { + text == "Working" + } + + pub(crate) fn next_interval(&mut self, base: Duration) -> Duration { + let multiplier = self.rng.random_range(0.5..=1.25); + Duration::from_secs_f64(base.as_secs_f64() * multiplier) + } + + fn pick_arc(rng: &mut StdRng, current: Option, count: usize) -> usize { + if count <= 1 { + return 0; + } + if let Some(current) = current { + loop { + let next = rng.random_range(0..count); + if next != current { + return next; + } + } + } + rng.random_range(0..count) + } + + #[cfg(test)] + fn seeded_rng() -> StdRng { + StdRng::seed_from_u64(1) + } + + #[cfg(not(test))] + fn seeded_rng() -> StdRng { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + StdRng::seed_from_u64(nanos as u64) + } +} diff --git a/codex-rs/tui/src/status_indicator_shimmer.rs b/codex-rs/tui/src/status_indicator_shimmer.rs new file mode 100644 index 0000000000..9464c2c7e0 --- /dev/null +++ b/codex-rs/tui/src/status_indicator_shimmer.rs @@ -0,0 +1,139 @@ +#[cfg(feature = "entertainment")] +use std::time::Duration; +use std::time::Instant; + +#[cfg(feature = "entertainment")] +use std::cell::Cell; +#[cfg(feature = "entertainment")] +use std::cell::RefCell; + +#[cfg(feature = "entertainment")] +use crate::shimmer_text::ShimmerText; + +#[cfg(feature = "entertainment")] +const SHIMMER_TEXT_INTERVAL: Duration = Duration::from_secs(1); + +pub(crate) struct RenderHeader { + pub(crate) face: Option, + pub(crate) text: String, +} + +#[cfg(feature = "entertainment")] +pub(crate) struct StatusShimmer { + header: String, + use_shimmer_text: bool, + shimmer_text: RefCell, + shimmer_face_cache: RefCell, + shimmer_text_cache: RefCell, + last_shimmer_update: Cell, + shimmer_interval: Cell, +} + +#[cfg(feature = "entertainment")] +impl StatusShimmer { + pub(crate) fn new(now: Instant) -> Self { + let mut shimmer_text = ShimmerText::new(); + let shimmer_step = shimmer_text.get_next(); + let shimmer_interval = shimmer_text.next_interval(SHIMMER_TEXT_INTERVAL); + Self { + header: shimmer_step.text.clone(), + use_shimmer_text: true, + shimmer_text: RefCell::new(shimmer_text), + shimmer_face_cache: RefCell::new(shimmer_step.face), + shimmer_text_cache: RefCell::new(shimmer_step.text), + last_shimmer_update: Cell::new(now), + shimmer_interval: Cell::new(shimmer_interval), + } + } + + pub(crate) fn update_header(&mut self, header: String) { + let was_shimmer = self.use_shimmer_text; + let use_shimmer = self.shimmer_text.borrow().is_default_label(&header); + self.use_shimmer_text = use_shimmer; + if use_shimmer { + self.header = header.clone(); + if !was_shimmer { + let next = self.shimmer_text.borrow_mut().reset_and_get_next(); + self.set_shimmer_step(next); + let next_interval = self + .shimmer_text + .borrow_mut() + .next_interval(SHIMMER_TEXT_INTERVAL); + self.shimmer_interval.set(next_interval); + } + self.last_shimmer_update.set(Instant::now()); + } else { + self.header = header; + } + } + + #[cfg(test)] + pub(crate) fn header_for_test(&self) -> String { + if self.use_shimmer_text { + self.shimmer_text_cache.borrow().clone() + } else { + self.header.clone() + } + } + + pub(crate) fn render_header(&self, now: Instant) -> RenderHeader { + if !self.use_shimmer_text { + return RenderHeader { + face: Some(self.shimmer_face_cache.borrow().clone()), + text: self.header.clone(), + }; + } + + let elapsed = now.saturating_duration_since(self.last_shimmer_update.get()); + if elapsed >= self.shimmer_interval.get() { + let next = self.shimmer_text.borrow_mut().get_next(); + self.set_shimmer_step(next); + self.last_shimmer_update.set(now); + let next_interval = self + .shimmer_text + .borrow_mut() + .next_interval(SHIMMER_TEXT_INTERVAL); + self.shimmer_interval.set(next_interval); + } + + RenderHeader { + face: Some(self.shimmer_face_cache.borrow().clone()), + text: self.shimmer_text_cache.borrow().clone(), + } + } + + fn set_shimmer_step(&self, step: crate::shimmer_text::ShimmerStep) { + *self.shimmer_face_cache.borrow_mut() = step.face; + *self.shimmer_text_cache.borrow_mut() = step.text; + } +} + +#[cfg(not(feature = "entertainment"))] +pub(crate) struct StatusShimmer { + header: String, +} + +#[cfg(not(feature = "entertainment"))] +impl StatusShimmer { + pub(crate) fn new(_now: Instant) -> Self { + Self { + header: String::from("Working"), + } + } + + pub(crate) fn update_header(&mut self, header: String) { + self.header = header; + } + + #[cfg(test)] + pub(crate) fn header_for_test(&self) -> String { + self.header.clone() + } + + pub(crate) fn render_header(&self, _now: Instant) -> RenderHeader { + RenderHeader { + face: None, + text: self.header.clone(), + } + } +} diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index bef0d0328d..63548690e7 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -22,6 +22,8 @@ use crate::exec_cell::spinner; use crate::key_hint; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; +use crate::status_indicator_shimmer::RenderHeader; +use crate::status_indicator_shimmer::StatusShimmer; use crate::text_formatting::capitalize_first; use crate::tui::FrameRequester; use crate::wrapping::RtOptions; @@ -31,8 +33,7 @@ const DETAILS_MAX_LINES: usize = 3; const DETAILS_PREFIX: &str = " └ "; pub(crate) struct StatusIndicatorWidget { - /// Animated header text (defaults to "Working"). - header: String, + shimmer: StatusShimmer, details: Option, show_interrupt_hint: bool, @@ -67,12 +68,13 @@ impl StatusIndicatorWidget { frame_requester: FrameRequester, animations_enabled: bool, ) -> Self { + let now = Instant::now(); Self { - header: String::from("Working"), + shimmer: StatusShimmer::new(now), details: None, show_interrupt_hint: true, elapsed_running: Duration::ZERO, - last_resume_at: Instant::now(), + last_resume_at: now, is_paused: false, app_event_tx, @@ -87,7 +89,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.shimmer.update_header(header); } /// Update the details text shown below the header. @@ -98,8 +100,8 @@ impl StatusIndicatorWidget { } #[cfg(test)] - pub(crate) fn header(&self) -> &str { - &self.header + pub(crate) fn header(&self) -> String { + self.shimmer.header_for_test() } #[cfg(test)] @@ -188,6 +190,10 @@ impl StatusIndicatorWidget { out } + + fn shimmer_header(&self, now: Instant) -> RenderHeader { + self.shimmer.render_header(now) + } } impl Renderable for StatusIndicatorWidget { @@ -206,14 +212,18 @@ impl Renderable for StatusIndicatorWidget { let now = Instant::now(); let elapsed_duration = self.elapsed_duration_at(now); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); - + let header = self.shimmer_header(now); let mut spans = Vec::with_capacity(5); - spans.push(spinner(Some(self.last_resume_at), self.animations_enabled)); + if let Some(face) = header.face { + spans.push(face.into()); + } else { + 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.extend(shimmer_spans(&header.text)); + } else if !header.text.is_empty() { + spans.push(header.text.into()); } spans.push(" ".into()); if self.show_interrupt_hint {