diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 7ef74cfb7aa..d18a3dfa578 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -87,6 +87,8 @@ pub enum Feature { ShellSnapshot, /// Experimental TUI v2 (viewport) implementation. Tui2, + /// Experimental entertainment mode for the status shimmer. + Entertainment, /// Enforce UTF8 output in Powershell. PowershellUtf8, } @@ -380,4 +382,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::Entertainment, + key: "entertainment", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 32223f18ec3..dde05d3bd6c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,6 +5,7 @@ use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; use crate::diff_render::DiffSummary; +use crate::entertainment; use crate::exec_command::strip_bash_lc_and_escape; use crate::external_editor; use crate::file_search::FileSearchManager; @@ -61,6 +62,7 @@ use std::thread; use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; +use tracing::info; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; @@ -735,6 +737,33 @@ impl App { )); tui.frame_requester().schedule_frame(); } + AppEvent::GenerateEntertainmentTexts { prompt } => { + info!( + prompt_len = prompt.len(), + "received request to generate entertainment texts" + ); + let app_event_tx = self.app_event_tx.clone(); + let server = Arc::clone(&self.server); + let config = self.config.clone(); + tokio::spawn(async move { + match entertainment::generate_entertainment_texts(server, config, prompt).await + { + Ok(texts) => { + info!( + texts_len = texts.len(), + "entertainment text generation completed" + ); + app_event_tx.send(AppEvent::EntertainmentTextsGenerated { texts }); + } + Err(err) => { + tracing::warn!("entertainment text generation failed: {err}"); + } + } + }); + } + AppEvent::EntertainmentTextsGenerated { texts } => { + self.chat_widget.update_entertainment_texts(texts); + } AppEvent::StartFileSearch(query) => { if !query.is_empty() { self.file_search.on_user_query(query); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1f99e372e97..4e44a92f8de 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -46,6 +46,14 @@ pub(crate) enum AppEvent { matches: Vec, }, + GenerateEntertainmentTexts { + prompt: String, + }, + + EntertainmentTextsGenerated { + texts: Vec, + }, + /// Result of refreshing rate limits RateLimitSnapshotFetched(RateLimitSnapshot), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 329d8ec805c..a300167e7f4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::queued_user_messages::QueuedUserMessages; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; +use crate::entertainment::EntertainmentArcStore; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; @@ -78,6 +79,8 @@ pub(crate) struct BottomPane { ctrl_c_quit_hint: bool, esc_backtrack_hint: bool, animations_enabled: bool, + entertainment_enabled: bool, + entertainment_arcs: EntertainmentArcStore, /// Inline status indicator shown above the composer while a task is running. status: Option, @@ -97,6 +100,7 @@ pub(crate) struct BottomPaneParams { pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, + pub(crate) entertainment_enabled: bool, pub(crate) skills: Option>, } @@ -110,6 +114,7 @@ impl BottomPane { placeholder_text, disable_paste_burst, animations_enabled, + entertainment_enabled, skills, } = params; let mut composer = ChatComposer::new( @@ -134,6 +139,8 @@ impl BottomPane { queued_user_messages: QueuedUserMessages::new(), esc_backtrack_hint: false, animations_enabled, + entertainment_enabled, + entertainment_arcs: EntertainmentArcStore::new(entertainment_enabled), context_window_percent: None, context_window_used_tokens: None, } @@ -353,7 +360,9 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.entertainment_enabled, )); + self.apply_entertainment_arcs(); } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(true); @@ -379,7 +388,16 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.entertainment_enabled, )); + self.apply_entertainment_arcs(); + self.request_redraw(); + } + } + + pub(crate) fn update_entertainment_texts(&mut self, texts: Vec) { + self.entertainment_arcs.push(texts, self.status.as_mut()); + if self.status.is_some() { self.request_redraw(); } } @@ -391,6 +409,12 @@ impl BottomPane { } } + fn apply_entertainment_arcs(&mut self) { + if let Some(status) = self.status.as_mut() { + self.entertainment_arcs.apply_to(status); + } + } + pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens { @@ -636,6 +660,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); pane.push_approval_request(exec_request(), &features); @@ -659,6 +684,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -693,6 +719,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -760,6 +787,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -787,6 +815,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -818,6 +847,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -846,6 +876,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 868319893cf..beba2a96b58 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -100,6 +100,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::diff_render::display_path_for; +use crate::entertainment::EntertainmentController; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; @@ -340,8 +341,11 @@ 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, + entertainment: EntertainmentController, // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, @@ -424,6 +428,10 @@ impl ChatWidget { self.set_status(header, None); } + pub(crate) fn update_entertainment_texts(&mut self, texts: Vec) { + self.bottom_pane.update_entertainment_texts(texts); + } + fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.retry_status_header.take() { self.set_status_header(header); @@ -523,18 +531,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); @@ -542,9 +560,11 @@ impl ChatWidget { let cell = history_cell::new_reasoning_summary_block(self.full_reasoning_buffer.clone()); self.add_boxed_history(cell); + self.entertainment.request_generation(&self.app_event_tx); } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.reasoning_header_emitted = false; self.request_redraw(); } @@ -553,6 +573,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 +586,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(); } @@ -866,6 +888,7 @@ impl ChatWidget { } fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { + self.entertainment.request_generation(&self.app_event_tx); self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { self.track_unified_exec_process_begin(&ev); @@ -942,6 +965,7 @@ impl ChatWidget { } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { + self.entertainment.request_generation(&self.app_event_tx); self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, @@ -1017,6 +1041,7 @@ impl ChatWidget { } fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { + self.entertainment.request_generation(&self.app_event_tx); let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); } @@ -1027,6 +1052,7 @@ impl ChatWidget { } fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { + self.entertainment.request_generation(&self.app_event_tx); self.flush_answer_stream_with_separator(); } @@ -1419,6 +1445,7 @@ impl ChatWidget { config.model = Some(model.clone()); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + let entertainment_enabled = config.features.enabled(Feature::Entertainment); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); let mut widget = Self { @@ -1433,6 +1460,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + entertainment_enabled, skills: None, }), active_cell: None, @@ -1461,7 +1489,9 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + reasoning_header_emitted: false, current_status_header: String::from("Working"), + entertainment: EntertainmentController::new(entertainment_enabled), retry_status_header: None, thread_id: None, queued_user_messages: VecDeque::new(), @@ -1504,6 +1534,7 @@ impl ChatWidget { let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + let entertainment_enabled = config.features.enabled(Feature::Entertainment); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); @@ -1519,6 +1550,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + entertainment_enabled, skills: None, }), active_cell: None, @@ -1547,7 +1579,9 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + reasoning_header_emitted: false, current_status_header: String::from("Working"), + entertainment: EntertainmentController::new(entertainment_enabled), retry_status_header: None, thread_id: None, queued_user_messages: VecDeque::new(), @@ -1920,6 +1954,7 @@ impl ChatWidget { } fn add_boxed_history(&mut self, cell: Box) { + self.entertainment.record_history_cell(cell.as_ref()); if !cell.display_lines(u16::MAX).is_empty() { // Only break exec grouping if the cell renders visible lines. self.flush_active_cell(); 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 c3bdf60bd2c..257a4e07db0 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 6d9aa515b1a..14b71a1494d 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 9fbebfb500f..fa4959d0bd3 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 cf53b7fac96..14328441f62 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::entertainment::EntertainmentController; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -355,6 +356,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } + let entertainment_enabled = cfg.features.enabled(Feature::Entertainment); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -363,6 +365,7 @@ async fn make_chatwidget_manual( placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: cfg.animations, + entertainment_enabled, skills: None, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); @@ -394,7 +397,9 @@ 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"), + entertainment: EntertainmentController::new(entertainment_enabled), retry_status_header: None, thread_id: None, frame_requester: FrameRequester::test_dummy(), @@ -2713,12 +2718,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 +2743,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 +2788,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 +3328,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 +3381,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/entertainment/arc_store.rs b/codex-rs/tui/src/entertainment/arc_store.rs new file mode 100644 index 00000000000..4083c61e62f --- /dev/null +++ b/codex-rs/tui/src/entertainment/arc_store.rs @@ -0,0 +1,33 @@ +use crate::status_indicator_widget::StatusIndicatorWidget; + +#[derive(Debug)] +pub(crate) struct EntertainmentArcStore { + enabled: bool, + arcs: Vec>, +} + +impl EntertainmentArcStore { + pub(crate) fn new(enabled: bool) -> Self { + Self { + enabled, + arcs: Vec::new(), + } + } + + pub(crate) fn push(&mut self, texts: Vec, status: Option<&mut StatusIndicatorWidget>) { + if !self.enabled || texts.is_empty() { + return; + } + self.arcs.push(texts.clone()); + if let Some(status) = status { + status.add_entertainment_arc(texts); + } + } + + pub(crate) fn apply_to(&self, status: &mut StatusIndicatorWidget) { + if !self.enabled || self.arcs.is_empty() { + return; + } + status.set_entertainment_arcs(self.arcs.clone()); + } +} diff --git a/codex-rs/tui/src/entertainment/controller.rs b/codex-rs/tui/src/entertainment/controller.rs new file mode 100644 index 00000000000..e7536ecd12a --- /dev/null +++ b/codex-rs/tui/src/entertainment/controller.rs @@ -0,0 +1,80 @@ +use std::collections::VecDeque; + +use ratatui::text::Line; +use tracing::info; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::history_cell::HistoryCell; + +const PROMPT_TEMPLATE: &str = include_str!("prompt.md"); +const HISTORY_LIMIT: usize = 10; + +#[derive(Debug)] +pub(crate) struct EntertainmentController { + enabled: bool, + history: VecDeque, +} + +impl EntertainmentController { + pub(crate) fn new(enabled: bool) -> Self { + Self { + enabled, + history: VecDeque::new(), + } + } + + pub(crate) fn record_history_cell(&mut self, cell: &dyn HistoryCell) { + let lines = cell.transcript_lines(u16::MAX); + let text = render_lines(&lines).join("\n"); + let text = text.trim(); + if text.is_empty() { + return; + } + self.history.push_back(text.to_string()); + while self.history.len() > HISTORY_LIMIT { + self.history.pop_front(); + } + } + + pub(crate) fn request_generation(&self, app_event_tx: &AppEventSender) { + if !self.enabled { + return; + } + let prompt = self.build_prompt(); + info!( + history_len = self.history.len(), + prompt_len = prompt.len(), + "requesting entertainment text generation" + ); + app_event_tx.send(AppEvent::GenerateEntertainmentTexts { prompt }); + } + + fn build_prompt(&self) -> String { + let history = if self.history.is_empty() { + "- (no recent history)".to_string() + } else { + let mut out = String::new(); + for entry in &self.history { + out.push_str("- "); + out.push_str(entry); + out.push('\n'); + } + out.trim_end().to_string() + }; + + PROMPT_TEMPLATE.replace("{{INSERT_CONTEXT_HERE}}", &history) + } +} + +fn render_lines(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() +} diff --git a/codex-rs/tui/src/entertainment/generator.rs b/codex-rs/tui/src/entertainment/generator.rs new file mode 100644 index 00000000000..0cbf7c0f299 --- /dev/null +++ b/codex-rs/tui/src/entertainment/generator.rs @@ -0,0 +1,111 @@ +use std::sync::Arc; + +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; +use serde::Deserialize; +use serde_json::Value; +use tracing::info; + +#[derive(Debug, Deserialize)] +struct EntertainmentTextOutput { + texts: Vec, +} + +pub(crate) async fn generate_entertainment_texts( + server: Arc, + config: Config, + prompt: String, +) -> anyhow::Result> { + info!( + prompt_len = prompt.len(), + "starting entertainment text generation thread" + ); + let mut config = config; + config.model = Some("gpt-5-nano".to_string()); + config.model_reasoning_effort = None; + let new_thread = server.start_thread(config).await?; + let schema = entertainment_output_schema(); + let input = vec![UserInput::Text { text: prompt }]; + new_thread + .thread + .submit(Op::UserInput { + items: input, + final_output_json_schema: Some(schema), + }) + .await?; + + let mut output = String::new(); + while let Ok(event) = new_thread.thread.next_event().await { + match event.msg { + EventMsg::AgentMessage(msg) => { + output.push_str(&msg.message); + break; + } + EventMsg::Error(err) => { + return Err(anyhow::anyhow!(err.message)); + } + EventMsg::TaskComplete(task) => { + if output.trim().is_empty() { + if let Some(message) = task.last_agent_message { + output = message; + } + } + break; + } + _ => {} + } + } + + let _ = new_thread.thread.submit(Op::Shutdown).await; + + if output.trim().is_empty() { + return Err(anyhow::anyhow!( + "entertainment generation returned empty output" + )); + } + + let parsed: EntertainmentTextOutput = serde_json::from_str(output.trim())?; + let mut texts: Vec = parsed + .texts + .into_iter() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) + .collect(); + + info!( + texts_len = texts.len(), + "parsed entertainment text generation output" + ); + + if !(5..=7).contains(&texts.len()) { + return Err(anyhow::anyhow!( + "expected 5-7 entertainment texts, got {}", + texts.len() + )); + } + + for text in &mut texts { + *text = text.trim().to_string(); + } + + Ok(texts) +} + +fn entertainment_output_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "texts": { + "type": "array", + "minItems": 5, + "maxItems": 7, + "items": { "type": "string" } + } + }, + "required": ["texts"], + "additionalProperties": false + }) +} diff --git a/codex-rs/tui/src/entertainment/mod.rs b/codex-rs/tui/src/entertainment/mod.rs new file mode 100644 index 00000000000..46d85960fbc --- /dev/null +++ b/codex-rs/tui/src/entertainment/mod.rs @@ -0,0 +1,11 @@ +pub(crate) mod arc_store; +pub(crate) mod controller; +pub(crate) mod generator; +pub(crate) mod shimmer_text; +pub mod test_support; + +pub(crate) use arc_store::EntertainmentArcStore; +pub(crate) use controller::EntertainmentController; +pub(crate) use generator::generate_entertainment_texts; +pub(crate) use shimmer_text::ShimmerStep; +pub(crate) use shimmer_text::ShimmerText; diff --git a/codex-rs/tui/src/entertainment/prompt.md b/codex-rs/tui/src/entertainment/prompt.md new file mode 100644 index 00000000000..cc9cb64a3c2 --- /dev/null +++ b/codex-rs/tui/src/entertainment/prompt.md @@ -0,0 +1,41 @@ +You are a witty, emotionally-aware software engineer writing micro-poems for a loading, retry, build, or deployment UI. + +Generate one status arc: an ordered list of 5-7 short lines that will be displayed one at a time while a system is working, retrying, compiling, or rebuilding. + +Each line must: + +- Be under 5 words +- Use dry developer humor +- Blend very little technical language with human emotion + +The arc can have mix of emotional progression: + +1. confidence or optimism +2. Shift into uncertainty or effort +3. Dip into self-aware humor or mild dread +4. End with calm, hope, or ironic acceptance +5. give a general ironic comment on the whole arc until now + +Use simple present tense and minimal punctuation. + +Context for this arc: +{{INSERT_CONTEXT_HERE}} + +Return only JSON with this shape: + +{ + "texts": ["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."] +} + +Examples + +- ["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."] diff --git a/codex-rs/tui/src/entertainment/shimmer_text.rs b/codex-rs/tui/src/entertainment/shimmer_text.rs new file mode 100644 index 00000000000..4c6a4051656 --- /dev/null +++ b/codex-rs/tui/src/entertainment/shimmer_text.rs @@ -0,0 +1,291 @@ +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, + default_definition_arcs: Vec>, + generated_arcs: Vec>, + 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 default_definition_arcs = Self::default_definition_arcs(); + let definition_arc_index = Self::pick_arc(&mut rng, None, default_definition_arcs.len()); + let face_arc_index = Self::pick_arc(&mut rng, None, FACE_SEQUENCES.len()); + Self { + definition_arc_index, + definition_item_index: 0, + default_definition_arcs, + generated_arcs: Vec::new(), + face_arc_index, + face_item_index: 0, + rng, + } + } + + pub(crate) fn get_next(&mut self) -> ShimmerStep { + let (text, text_arc_len) = { + let arcs = self.active_definition_arcs(); + let text_arc = &arcs[self.definition_arc_index]; + ( + text_arc[self.definition_item_index].to_string(), + text_arc.len(), + ) + }; + let (face, face_arc_len) = { + let face_arc = FACE_SEQUENCES[self.face_arc_index]; + (face_arc[self.face_item_index].to_string(), face_arc.len()) + }; + + 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; + let arcs_len = self.active_definition_arcs().len(); + self.definition_arc_index = + Self::pick_arc(&mut self.rng, Some(self.definition_arc_index), arcs_len); + } + } + + ShimmerStep { face, text } + } + + pub(crate) fn reset_and_get_next(&mut self) -> ShimmerStep { + let arcs_len = self.active_definition_arcs().len(); + self.definition_arc_index = + Self::pick_arc(&mut self.rng, Some(self.definition_arc_index), 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 add_generated_arc(&mut self, arc: Vec) { + if arc.is_empty() { + return; + } + self.generated_arcs.push(arc); + self.reset_definition_sequence(); + } + + pub(crate) fn set_generated_arcs(&mut self, arcs: Vec>) { + self.generated_arcs = arcs.into_iter().filter(|arc| !arc.is_empty()).collect(); + self.reset_definition_sequence(); + } + + 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.4..=1.0); + Duration::from_secs_f64(base.as_secs_f64() * multiplier) + } + + fn active_definition_arcs(&self) -> &[Vec] { + if self.generated_arcs.is_empty() { + &self.default_definition_arcs + } else { + &self.generated_arcs + } + } + + fn reset_definition_sequence(&mut self) { + let arcs_len = self.active_definition_arcs().len(); + self.definition_arc_index = Self::pick_arc(&mut self.rng, None, arcs_len); + self.definition_item_index = 0; + } + + 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) + } + + fn default_definition_arcs() -> Vec> { + DEFINITION_ARCS + .iter() + .map(|arc| arc.iter().map(|text| (*text).to_string()).collect()) + .collect() + } + + #[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/entertainment/test_support.rs b/codex-rs/tui/src/entertainment/test_support.rs new file mode 100644 index 00000000000..097b385f9c2 --- /dev/null +++ b/codex-rs/tui/src/entertainment/test_support.rs @@ -0,0 +1,12 @@ +use std::time::Instant; + +use crate::status_indicator_shimmer::StatusShimmer; + +#[doc(hidden)] +pub fn entertainment_header_from_arc(arc: Vec<&str>) -> String { + let now = Instant::now(); + let mut shimmer = StatusShimmer::new(now, true); + let arc: Vec = arc.into_iter().map(|text| text.to_string()).collect(); + shimmer.set_entertainment_arcs(vec![arc]); + shimmer.render_header(now).text +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6f4faaad659..8277923d0ff 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -44,6 +44,7 @@ mod clipboard_paste; mod color; pub mod custom_terminal; mod diff_render; +mod entertainment; mod exec_cell; mod exec_command; mod external_editor; @@ -70,10 +71,12 @@ mod session_log; mod shimmer; mod slash_command; mod status; +mod status_indicator_shimmer; mod status_indicator_widget; mod streaming; mod style; mod terminal_palette; +pub use entertainment::test_support; mod text_formatting; mod tooltips; mod tui; 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 00000000000..0f7246b813c --- /dev/null +++ b/codex-rs/tui/src/status_indicator_shimmer.rs @@ -0,0 +1,173 @@ +use std::cell::Cell; +use std::cell::RefCell; +use std::time::Duration; +use std::time::Instant; + +use crate::entertainment::ShimmerText; + +const SHIMMER_FACE_INTERVAL: Duration = Duration::from_secs(2); + +pub(crate) struct RenderHeader { + pub(crate) face: Option, + pub(crate) text: String, +} + +pub(crate) struct StatusShimmer { + entertainment_enabled: bool, + header: String, + entertainment: Option, +} + +struct EntertainmentState { + use_shimmer_text: Cell, + shimmer_text: RefCell, + shimmer_face_cache: RefCell, + shimmer_text_cache: RefCell, + last_shimmer_update: Cell, + shimmer_interval: Cell, +} + +impl StatusShimmer { + pub(crate) fn new(now: Instant, entertainment_enabled: bool) -> Self { + if entertainment_enabled { + let mut shimmer_text = ShimmerText::new(); + let shimmer_step = shimmer_text.get_next(); + let shimmer_interval = shimmer_text.next_interval(SHIMMER_FACE_INTERVAL); + let entertainment = EntertainmentState { + use_shimmer_text: Cell::new(true), + shimmer_text: RefCell::new(shimmer_text), + shimmer_face_cache: RefCell::new(shimmer_step.face), + shimmer_text_cache: RefCell::new(shimmer_step.text.clone()), + last_shimmer_update: Cell::new(now), + shimmer_interval: Cell::new(shimmer_interval), + }; + Self { + entertainment_enabled, + header: shimmer_step.text, + entertainment: Some(entertainment), + } + } else { + Self { + entertainment_enabled, + header: String::from("Working"), + entertainment: None, + } + } + } + + pub(crate) fn update_header(&mut self, header: String) { + self.header = header; + if !self.entertainment_enabled { + return; + } + let Some(state) = self.entertainment.as_ref() else { + return; + }; + let was_shimmer = state.use_shimmer_text.get(); + let use_shimmer = state.shimmer_text.borrow().is_default_label(&self.header); + state.use_shimmer_text.set(use_shimmer); + if use_shimmer { + if !was_shimmer { + let next = state.shimmer_text.borrow_mut().reset_and_get_next(); + self.set_shimmer_step(state, next); + let next_interval = state + .shimmer_text + .borrow_mut() + .next_interval(SHIMMER_FACE_INTERVAL); + state.shimmer_interval.set(next_interval); + } + state.last_shimmer_update.set(Instant::now()); + } + } + + pub(crate) fn add_entertainment_arc(&mut self, arc: Vec) { + if !self.entertainment_enabled { + return; + } + let Some(state) = self.entertainment.as_ref() else { + return; + }; + state.shimmer_text.borrow_mut().add_generated_arc(arc); + if state.use_shimmer_text.get() { + let next = state.shimmer_text.borrow_mut().reset_and_get_next(); + self.set_shimmer_step(state, next); + let next_interval = state + .shimmer_text + .borrow_mut() + .next_interval(SHIMMER_FACE_INTERVAL); + state.shimmer_interval.set(next_interval); + state.last_shimmer_update.set(Instant::now()); + } + } + + pub(crate) fn set_entertainment_arcs(&mut self, arcs: Vec>) { + if !self.entertainment_enabled { + return; + } + let Some(state) = self.entertainment.as_ref() else { + return; + }; + state.shimmer_text.borrow_mut().set_generated_arcs(arcs); + if state.use_shimmer_text.get() { + let next = state.shimmer_text.borrow_mut().reset_and_get_next(); + self.set_shimmer_step(state, next); + let next_interval = state + .shimmer_text + .borrow_mut() + .next_interval(SHIMMER_FACE_INTERVAL); + state.shimmer_interval.set(next_interval); + state.last_shimmer_update.set(Instant::now()); + } + } + + #[cfg(test)] + pub(crate) fn header_for_test(&self) -> String { + if let Some(state) = self.entertainment.as_ref() + && state.use_shimmer_text.get() + { + return state.shimmer_text_cache.borrow().clone(); + } + self.header.clone() + } + + pub(crate) fn render_header(&self, now: Instant) -> RenderHeader { + let Some(state) = self.entertainment.as_ref() else { + return RenderHeader { + face: None, + text: self.header.clone(), + }; + }; + if !state.use_shimmer_text.get() { + return RenderHeader { + face: Some(state.shimmer_face_cache.borrow().clone()), + text: self.header.clone(), + }; + } + + let elapsed = now.saturating_duration_since(state.last_shimmer_update.get()); + if elapsed >= state.shimmer_interval.get() { + let next = state.shimmer_text.borrow_mut().get_next(); + self.set_shimmer_step(state, next); + state.last_shimmer_update.set(now); + let next_interval = state + .shimmer_text + .borrow_mut() + .next_interval(SHIMMER_FACE_INTERVAL); + state.shimmer_interval.set(next_interval); + } + + RenderHeader { + face: Some(state.shimmer_face_cache.borrow().clone()), + text: state.shimmer_text_cache.borrow().clone(), + } + } + + fn set_shimmer_step( + &self, + state: &EntertainmentState, + step: crate::entertainment::ShimmerStep, + ) { + *state.shimmer_face_cache.borrow_mut() = step.face; + *state.shimmer_text_cache.borrow_mut() = step.text; + } +} diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index bef0d0328db..1f7581f2961 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, @@ -66,13 +67,15 @@ impl StatusIndicatorWidget { app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, + entertainment_enabled: bool, ) -> Self { + let now = Instant::now(); Self { - header: String::from("Working"), + shimmer: StatusShimmer::new(now, entertainment_enabled), 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 +90,15 @@ 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); + } + + pub(crate) fn add_entertainment_arc(&mut self, arc: Vec) { + self.shimmer.add_entertainment_arc(arc); + } + + pub(crate) fn set_entertainment_arcs(&mut self, arcs: Vec>) { + self.shimmer.set_entertainment_arcs(arcs); } /// Update the details text shown below the header. @@ -98,8 +109,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 +199,10 @@ impl StatusIndicatorWidget { out } + + fn shimmer_header(&self, now: Instant) -> RenderHeader { + self.shimmer.render_header(now) + } } impl Renderable for StatusIndicatorWidget { @@ -206,14 +221,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 { @@ -270,7 +289,8 @@ 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, false); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); @@ -284,7 +304,8 @@ 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, false); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); @@ -298,7 +319,8 @@ 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, false); w.update_details(Some("A man a plan a canal panama".to_string())); w.set_interrupt_hint_visible(false); @@ -320,7 +342,7 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut widget = - StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false); let baseline = Instant::now(); widget.last_resume_at = baseline; @@ -341,7 +363,8 @@ 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, false); w.update_details(Some("abcd abcd abcd abcd".to_string())); let lines = w.wrapped_details_lines(6); diff --git a/codex-rs/tui/tests/suite/status_indicator.rs b/codex-rs/tui/tests/suite/status_indicator.rs index 62f190d269f..9cecac84ea8 100644 --- a/codex-rs/tui/tests/suite/status_indicator.rs +++ b/codex-rs/tui/tests/suite/status_indicator.rs @@ -5,6 +5,8 @@ //! relies on. use codex_ansi_escape::ansi_escape_line; +use codex_tui::test_support::entertainment_header_from_arc; +use pretty_assertions::assert_eq; #[test] fn ansi_escape_line_strips_escape_sequences() { @@ -22,3 +24,16 @@ fn ansi_escape_line_strips_escape_sequences() { assert_eq!(combined, "RED"); } + +#[test] +fn entertainment_arc_replaces_default_header() { + let header = entertainment_header_from_arc(vec![ + "Starting deploy", + "Feeling optimistic", + "Waiting for logs", + "Still waiting", + "Ok still waiting", + ]); + + assert_eq!(header, "Starting deploy"); +}