mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aba7b2913 | ||
|
|
a57fefa8fc | ||
|
|
ed7723607d | ||
|
|
7b3263e9a6 | ||
|
|
112bc1c39f | ||
|
|
b1d46c2320 | ||
|
|
b9e443f0c4 | ||
|
|
f2e64e9fa5 |
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,6 +46,14 @@ pub(crate) enum AppEvent {
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
|
||||
GenerateEntertainmentTexts {
|
||||
prompt: String,
|
||||
},
|
||||
|
||||
EntertainmentTextsGenerated {
|
||||
texts: Vec<String>,
|
||||
},
|
||||
|
||||
/// Result of refreshing rate limits
|
||||
RateLimitSnapshotFetched(RateLimitSnapshot),
|
||||
|
||||
|
||||
@@ -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<StatusIndicatorWidget>,
|
||||
@@ -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<Vec<SkillMetadata>>,
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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<i64>, used_tokens: Option<i64>) {
|
||||
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()),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
thread_id: Option<ThreadId>,
|
||||
@@ -424,6 +428,10 @@ impl ChatWidget {
|
||||
self.set_status(header, None);
|
||||
}
|
||||
|
||||
pub(crate) fn update_entertainment_texts(&mut self, texts: Vec<String>) {
|
||||
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<ratatui::text::Line<'static>> = 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<dyn HistoryCell>) {
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
33
codex-rs/tui/src/entertainment/arc_store.rs
Normal file
33
codex-rs/tui/src/entertainment/arc_store.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct EntertainmentArcStore {
|
||||
enabled: bool,
|
||||
arcs: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl EntertainmentArcStore {
|
||||
pub(crate) fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
arcs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, texts: Vec<String>, 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());
|
||||
}
|
||||
}
|
||||
80
codex-rs/tui/src/entertainment/controller.rs
Normal file
80
codex-rs/tui/src/entertainment/controller.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
111
codex-rs/tui/src/entertainment/generator.rs
Normal file
111
codex-rs/tui/src/entertainment/generator.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_entertainment_texts(
|
||||
server: Arc<ThreadManager>,
|
||||
config: Config,
|
||||
prompt: String,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
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<String> = 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
|
||||
})
|
||||
}
|
||||
11
codex-rs/tui/src/entertainment/mod.rs
Normal file
11
codex-rs/tui/src/entertainment/mod.rs
Normal file
@@ -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;
|
||||
41
codex-rs/tui/src/entertainment/prompt.md
Normal file
41
codex-rs/tui/src/entertainment/prompt.md
Normal file
@@ -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."]
|
||||
291
codex-rs/tui/src/entertainment/shimmer_text.rs
Normal file
291
codex-rs/tui/src/entertainment/shimmer_text.rs
Normal file
@@ -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<Vec<String>>,
|
||||
generated_arcs: Vec<Vec<String>>,
|
||||
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<String>) {
|
||||
if arc.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.generated_arcs.push(arc);
|
||||
self.reset_definition_sequence();
|
||||
}
|
||||
|
||||
pub(crate) fn set_generated_arcs(&mut self, arcs: Vec<Vec<String>>) {
|
||||
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<String>] {
|
||||
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<usize>, 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<Vec<String>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
12
codex-rs/tui/src/entertainment/test_support.rs
Normal file
12
codex-rs/tui/src/entertainment/test_support.rs
Normal file
@@ -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<String> = arc.into_iter().map(|text| text.to_string()).collect();
|
||||
shimmer.set_entertainment_arcs(vec![arc]);
|
||||
shimmer.render_header(now).text
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
173
codex-rs/tui/src/status_indicator_shimmer.rs
Normal file
173
codex-rs/tui/src/status_indicator_shimmer.rs
Normal file
@@ -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<String>,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
pub(crate) struct StatusShimmer {
|
||||
entertainment_enabled: bool,
|
||||
header: String,
|
||||
entertainment: Option<EntertainmentState>,
|
||||
}
|
||||
|
||||
struct EntertainmentState {
|
||||
use_shimmer_text: Cell<bool>,
|
||||
shimmer_text: RefCell<ShimmerText>,
|
||||
shimmer_face_cache: RefCell<String>,
|
||||
shimmer_text_cache: RefCell<String>,
|
||||
last_shimmer_update: Cell<Instant>,
|
||||
shimmer_interval: Cell<Duration>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<Vec<String>>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
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<String>) {
|
||||
self.shimmer.add_entertainment_arc(arc);
|
||||
}
|
||||
|
||||
pub(crate) fn set_entertainment_arcs(&mut self, arcs: Vec<Vec<String>>) {
|
||||
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::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let w =
|
||||
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, 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::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let w =
|
||||
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, 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::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
|
||||
let mut w =
|
||||
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false, 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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let mut w =
|
||||
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false);
|
||||
w.update_details(Some("abcd abcd abcd abcd".to_string()));
|
||||
|
||||
let lines = w.wrapped_details_lines(6);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user