diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 32223f18ec..9b41aeb902 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_texts; 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,34 @@ 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_texts::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 1f99e372e9..4e44a92f8d 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 ac2d82c360..7bacf01d43 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -79,6 +79,7 @@ pub(crate) struct BottomPane { esc_backtrack_hint: bool, animations_enabled: bool, entertainment_enabled: bool, + entertainment_arcs: Vec>, /// Inline status indicator shown above the composer while a task is running. status: Option, @@ -138,6 +139,7 @@ impl BottomPane { esc_backtrack_hint: false, animations_enabled, entertainment_enabled, + entertainment_arcs: Vec::new(), context_window_percent: None, context_window_used_tokens: None, } @@ -359,6 +361,7 @@ impl BottomPane { self.animations_enabled, self.entertainment_enabled, )); + self.apply_entertainment_arcs(); } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(true); @@ -386,6 +389,18 @@ impl BottomPane { self.animations_enabled, self.entertainment_enabled, )); + self.apply_entertainment_arcs(); + self.request_redraw(); + } + } + + pub(crate) fn update_entertainment_texts(&mut self, texts: Vec) { + if texts.is_empty() { + return; + } + self.entertainment_arcs.push(texts.clone()); + if let Some(status) = self.status.as_mut() { + status.add_entertainment_arc(texts); self.request_redraw(); } } @@ -397,6 +412,15 @@ impl BottomPane { } } + fn apply_entertainment_arcs(&mut self) { + if self.entertainment_arcs.is_empty() { + return; + } + if let Some(status) = self.status.as_mut() { + status.set_entertainment_arcs(self.entertainment_arcs.clone()); + } + } + 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 { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8d5e07b137..33995e301e 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_texts::EntertainmentTextManager; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; @@ -344,6 +345,7 @@ pub(crate) struct ChatWidget { reasoning_header_emitted: bool, // Current status header shown in the status indicator. current_status_header: String, + entertainment: EntertainmentTextManager, // Previous status header to restore after a transient stream retry. retry_status_header: Option, thread_id: Option, @@ -426,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); @@ -554,6 +560,7 @@ 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(); @@ -881,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); @@ -957,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, @@ -1032,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)); } @@ -1042,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(); } @@ -1434,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 { @@ -1448,7 +1460,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - entertainment_enabled: config.features.enabled(Feature::Entertainment), + entertainment_enabled, skills: None, }), active_cell: None, @@ -1479,6 +1491,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), reasoning_header_emitted: false, current_status_header: String::from("Working"), + entertainment: EntertainmentTextManager::new(entertainment_enabled), retry_status_header: None, thread_id: None, queued_user_messages: VecDeque::new(), @@ -1521,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()); @@ -1536,7 +1550,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - entertainment_enabled: config.features.enabled(Feature::Entertainment), + entertainment_enabled, skills: None, }), active_cell: None, @@ -1567,6 +1581,7 @@ impl ChatWidget { full_reasoning_buffer: String::new(), reasoning_header_emitted: false, current_status_header: String::from("Working"), + entertainment: EntertainmentTextManager::new(entertainment_enabled), retry_status_header: None, thread_id: None, queued_user_messages: VecDeque::new(), @@ -1939,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/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1b28288437..b130218878 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_texts::EntertainmentTextManager; 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,7 +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: cfg.features.enabled(Feature::Entertainment), + entertainment_enabled, skills: None, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); @@ -397,6 +399,7 @@ async fn make_chatwidget_manual( full_reasoning_buffer: String::new(), reasoning_header_emitted: false, current_status_header: String::from("Working"), + entertainment: EntertainmentTextManager::new(entertainment_enabled), retry_status_header: None, thread_id: None, frame_requester: FrameRequester::test_dummy(), diff --git a/codex-rs/tui/src/entertainment_prompt.md b/codex-rs/tui/src/entertainment_prompt.md new file mode 100644 index 0000000000..77fb70e79f --- /dev/null +++ b/codex-rs/tui/src/entertainment_prompt.md @@ -0,0 +1,47 @@ +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 10 words +- Read like a status message, not a poem +- Use dry developer humor +- Blend technical language with human emotion +- Be suitable for a spinner, progress bar, or log panel + +The arc must have emotional progression: + +1. Start with 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": [ + "Starting deploy", + "Feeling optimistic", + "Waiting for logs" + ] +} + +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_texts.rs b/codex-rs/tui/src/entertainment_texts.rs new file mode 100644 index 0000000000..6e2cf7fd1e --- /dev/null +++ b/codex-rs/tui/src/entertainment_texts.rs @@ -0,0 +1,186 @@ +use std::collections::VecDeque; +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 ratatui::text::Line; +use serde::Deserialize; +use serde_json::Value; +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!("entertainment_prompt.md"); +const HISTORY_LIMIT: usize = 10; + +#[derive(Debug)] +pub(crate) struct EntertainmentTextManager { + enabled: bool, + history: VecDeque, +} + +#[derive(Debug, Deserialize)] +struct EntertainmentTextOutput { + texts: Vec, +} + +impl EntertainmentTextManager { + 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) + } +} + +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 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 + }) +} + +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/lib.rs b/codex-rs/tui/src/lib.rs index 3fa41399c3..f8c1496e20 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_texts; mod exec_cell; mod exec_command; mod external_editor; @@ -76,6 +77,7 @@ mod status_indicator_widget; mod streaming; mod style; mod terminal_palette; +pub mod test_support; mod text_formatting; mod tooltips; mod tui; diff --git a/codex-rs/tui/src/shimmer_text.rs b/codex-rs/tui/src/shimmer_text.rs index 0d39df80ae..4c6a405165 100644 --- a/codex-rs/tui/src/shimmer_text.rs +++ b/codex-rs/tui/src/shimmer_text.rs @@ -139,6 +139,8 @@ pub(crate) struct ShimmerStep { 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, @@ -153,11 +155,14 @@ impl Default for ShimmerText { 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 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, @@ -165,13 +170,21 @@ impl ShimmerText { } 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]; + 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() { + 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( @@ -179,28 +192,21 @@ impl ShimmerText { Some(self.face_arc_index), FACE_SEQUENCES.len(), ); - if self.definition_item_index >= text_arc.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(), - ); + 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: face.to_string(), - text: text.to_string(), - } + ShimmerStep { face, text } } 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(), - ); + 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), @@ -211,6 +217,19 @@ impl ShimmerText { 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" } @@ -220,6 +239,20 @@ impl ShimmerText { 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; @@ -235,6 +268,13 @@ impl ShimmerText { 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) diff --git a/codex-rs/tui/src/status_indicator_shimmer.rs b/codex-rs/tui/src/status_indicator_shimmer.rs index e6b5a7a3a4..18499f0859 100644 --- a/codex-rs/tui/src/status_indicator_shimmer.rs +++ b/codex-rs/tui/src/status_indicator_shimmer.rs @@ -80,6 +80,46 @@ impl StatusShimmer { } } + 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_TEXT_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_TEXT_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() diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index cbe283849d..1f7581f296 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -93,6 +93,14 @@ impl StatusIndicatorWidget { 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. pub(crate) fn update_details(&mut self, details: Option) { self.details = details diff --git a/codex-rs/tui/src/test_support.rs b/codex-rs/tui/src/test_support.rs new file mode 100644 index 0000000000..097b385f9c --- /dev/null +++ b/codex-rs/tui/src/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/tests/suite/status_indicator.rs b/codex-rs/tui/tests/suite/status_indicator.rs index 62f190d269..9cecac84ea 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"); +}