This commit is contained in:
Ahmed Ibrahim
2026-01-07 17:49:32 -08:00
parent 70f09db901
commit f63cd0bc83
23 changed files with 331 additions and 82 deletions
+32
View File
@@ -87,6 +87,14 @@ pub enum Feature {
ShellSnapshot,
/// Experimental TUI v2 (viewport) implementation.
Tui2,
/// Experimental TUI animation set 1.
Animation1,
/// Experimental TUI animation set 2.
Animation2,
/// Experimental TUI animation set 3.
Animation3,
/// Experimental TUI animation set 4.
Animation4,
/// Enforce UTF8 output in Powershell.
PowershellUtf8,
}
@@ -380,4 +388,28 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation1,
key: "animation1",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation2,
key: "animation2",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation3,
key: "animation3",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Animation4,
key: "animation4",
stage: Stage::Experimental,
default_enabled: false,
},
];
+15 -1
View File
@@ -1,6 +1,7 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
use std::path::PathBuf;
use crate::animations::spinners::SpinnerSet;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
@@ -78,6 +79,7 @@ pub(crate) struct BottomPane {
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
spinner_set: SpinnerSet,
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
@@ -97,6 +99,7 @@ pub(crate) struct BottomPaneParams {
pub(crate) placeholder_text: String,
pub(crate) disable_paste_burst: bool,
pub(crate) animations_enabled: bool,
pub(crate) spinner_set: SpinnerSet,
pub(crate) skills: Option<Vec<SkillMetadata>>,
}
@@ -110,6 +113,7 @@ impl BottomPane {
placeholder_text,
disable_paste_burst,
animations_enabled,
spinner_set,
skills,
} = params;
let mut composer = ChatComposer::new(
@@ -134,6 +138,7 @@ impl BottomPane {
queued_user_messages: QueuedUserMessages::new(),
esc_backtrack_hint: false,
animations_enabled,
spinner_set,
context_window_percent: None,
context_window_used_tokens: None,
}
@@ -353,6 +358,7 @@ impl BottomPane {
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
self.spinner_set,
));
}
if let Some(status) = self.status.as_mut() {
@@ -379,6 +385,7 @@ impl BottomPane {
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
self.spinner_set,
));
self.request_redraw();
}
@@ -636,6 +643,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
pane.push_approval_request(exec_request(), &features);
@@ -659,6 +667,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -693,6 +702,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -760,6 +770,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -772,7 +783,7 @@ mod tests {
pane.render(area, &mut buf);
let bufs = snapshot_buffer(&buf);
assert!(bufs.contains("• Working"), "expected Working header");
assert!(bufs.contains("Working"), "expected Working header");
}
#[test]
@@ -787,6 +798,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -818,6 +830,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -846,6 +859,7 @@ mod tests {
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
spinner_set: SpinnerSet::Default,
skills: Some(Vec::new()),
});
@@ -2,7 +2,7 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interru
o... Working (0s • esc to inte
› Ask Codex to do anything
@@ -2,7 +2,7 @@
source: tui/src/bottom_pane/mod.rs
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interrupt)
o... Working (0s • esc to interrupt)
↳ Queued follow-up question
⌥ + ↑ edit
+24 -6
View File
@@ -84,6 +84,7 @@ use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinHandle;
use tracing::debug;
use crate::animations::spinners::SpinnerSet;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
@@ -340,10 +341,12 @@ pub(crate) struct ChatWidget {
reasoning_buffer: String,
// Accumulates full reasoning content for transcript-only recording
full_reasoning_buffer: String,
reasoning_header_emitted: bool,
// Current status header shown in the status indicator.
current_status_header: String,
// Previous status header to restore after a transient stream retry.
retry_status_header: Option<String>,
spinner_set: SpinnerSet,
thread_id: Option<ThreadId>,
frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured
@@ -526,13 +529,13 @@ impl ChatWidget {
// (between **/**) as the chunk header. Show this header as status.
self.reasoning_buffer.push_str(&delta);
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
// Update the shimmer header to the extracted reasoning chunk header.
self.set_status_header(header);
} else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
if !self.reasoning_header_emitted {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
self.add_to_history(history_cell::new_reasoning_header(header));
self.reasoning_header_emitted = true;
self.request_redraw();
}
}
self.request_redraw();
}
fn on_agent_reasoning_final(&mut self) {
@@ -545,6 +548,7 @@ impl ChatWidget {
}
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.reasoning_header_emitted = false;
self.request_redraw();
}
@@ -553,6 +557,7 @@ impl ChatWidget {
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
self.full_reasoning_buffer.push_str("\n\n");
self.reasoning_buffer.clear();
self.reasoning_header_emitted = false;
}
// Raw reasoning uses the same flow as summarized reasoning
@@ -565,6 +570,7 @@ impl ChatWidget {
self.set_status_header(String::from("Working"));
self.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
self.reasoning_header_emitted = false;
self.request_redraw();
}
@@ -1200,6 +1206,7 @@ impl ChatWidget {
source,
ev.interaction_input.clone(),
self.config.animations,
self.spinner_set,
)));
}
@@ -1350,6 +1357,7 @@ impl ChatWidget {
ev.source,
interaction_input,
self.config.animations,
self.spinner_set,
)));
}
@@ -1363,6 +1371,7 @@ impl ChatWidget {
ev.call_id,
ev.invocation,
self.config.animations,
self.spinner_set,
)));
self.request_redraw();
}
@@ -1388,6 +1397,7 @@ impl ChatWidget {
call_id,
invocation,
self.config.animations,
self.spinner_set,
);
let extra_cell = cell.complete(duration, result);
self.active_cell = Some(Box::new(cell));
@@ -1417,6 +1427,7 @@ impl ChatWidget {
} = common;
let mut config = config;
config.model = Some(model.clone());
let spinner_set = SpinnerSet::from_features(&config.features);
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
@@ -1433,6 +1444,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
spinner_set,
skills: None,
}),
active_cell: None,
@@ -1461,8 +1473,10 @@ impl ChatWidget {
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
spinner_set,
thread_id: None,
queued_user_messages: VecDeque::new(),
show_welcome_banner: is_first_run,
@@ -1503,6 +1517,7 @@ impl ChatWidget {
} = common;
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let spinner_set = SpinnerSet::from_features(&config.features);
let codex_op_tx =
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
@@ -1519,6 +1534,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
spinner_set,
skills: None,
}),
active_cell: None,
@@ -1547,8 +1563,10 @@ impl ChatWidget {
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
spinner_set,
thread_id: None,
queued_user_messages: VecDeque::new(),
show_welcome_banner: false,
@@ -2,6 +2,31 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• I’m going to search the repo for where “Change Approved” is rendered to update
that view.
@@ -9,7 +34,9 @@ expression: term.backend().vt100().screen().contents()
└ Search Change Approved
Read diff_render.rs
• Investigating rendering code (0s • esc to interrupt)
• Investigating rendering code
.o.. Working (0s • esc to interrupt)
› Summarize recent commits
@@ -2,7 +2,8 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
.o.. Working (0s • esc to interrupt)
↳ Hello, world! 0
↳ Hello, world! 1
↳ Hello, world! 2
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob1
expression: active_blob(&chat)
---
• Exploring
>... Exploring
└ List ls -la
@@ -1,7 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob3
expression: active_blob(&chat)
---
• Exploring
<... Exploring
└ List ls -la
Read foo.txt
@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"• Booting MCP server: alpha (0s • esc to interrupt) "
"o... Booting MCP server: alpha (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
@@ -1,10 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1577
expression: terminal.backend()
---
" "
"• Analyzing (0s • esc to interrupt) "
".o.. Working (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
+4
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::animations::spinners::SpinnerSet;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
@@ -363,6 +364,7 @@ async fn make_chatwidget_manual(
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: cfg.animations,
spinner_set: SpinnerSet::Default,
skills: None,
});
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
@@ -394,8 +396,10 @@ async fn make_chatwidget_manual(
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
reasoning_header_emitted: false,
current_status_header: String::from("Working"),
retry_status_header: None,
spinner_set: SpinnerSet::Default,
thread_id: None,
frame_requester: FrameRequester::test_dummy(),
show_welcome_banner: true,
-1
View File
@@ -9,4 +9,3 @@ pub(crate) use render::OutputLinesParams;
pub(crate) use render::TOOL_CALL_MAX_LINES;
pub(crate) use render::new_active_exec_command;
pub(crate) use render::output_lines;
pub(crate) use render::spinner;
+9 -1
View File
@@ -1,6 +1,7 @@
use std::time::Duration;
use std::time::Instant;
use crate::animations::spinners::SpinnerSet;
use codex_core::protocol::ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
@@ -29,13 +30,15 @@ pub(crate) struct ExecCall {
pub(crate) struct ExecCell {
pub(crate) calls: Vec<ExecCall>,
animations_enabled: bool,
spinner_set: SpinnerSet,
}
impl ExecCell {
pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self {
pub(crate) fn new(call: ExecCall, animations_enabled: bool, spinner_set: SpinnerSet) -> Self {
Self {
calls: vec![call],
animations_enabled,
spinner_set,
}
}
@@ -61,6 +64,7 @@ impl ExecCell {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
animations_enabled: self.animations_enabled,
spinner_set: self.spinner_set,
})
} else {
None
@@ -121,6 +125,10 @@ impl ExecCell {
self.animations_enabled
}
pub(crate) fn spinner_set(&self) -> SpinnerSet {
self.spinner_set
}
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
self.calls.iter()
}
+36 -19
View File
@@ -3,12 +3,15 @@ use std::time::Instant;
use super::model::CommandOutput;
use super::model::ExecCall;
use super::model::ExecCell;
use crate::animations::spinners::SpinnerKind;
use crate::animations::spinners::SpinnerSet;
use crate::animations::spinners::spinner;
use crate::animations::spinners::spinner_seed;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::shimmer::shimmer_spans;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
@@ -42,6 +45,7 @@ pub(crate) fn new_active_exec_command(
source: ExecCommandSource,
interaction_input: Option<String>,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> ExecCell {
ExecCell::new(
ExecCall {
@@ -55,6 +59,7 @@ pub(crate) fn new_active_exec_command(
interaction_input,
},
animations_enabled,
spinner_set,
)
}
@@ -177,20 +182,12 @@ pub(crate) fn output_lines(
}
}
pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
if !animations_enabled {
return "•".dim();
}
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false)
{
shimmer_spans("•")[0].clone()
} else {
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
if blink_on { "•".into() } else { "◦".dim() }
}
fn spinner_seed_for_active_call(calls: &[ExecCall]) -> u64 {
calls
.iter()
.find(|call| call.output.is_none())
.map(|call| spinner_seed(&call.call_id))
.unwrap_or(0)
}
impl HistoryCell for ExecCell {
@@ -254,9 +251,16 @@ impl HistoryCell for ExecCell {
impl ExecCell {
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let spinner_seed = spinner_seed_for_active_call(&self.calls);
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time(), self.animations_enabled())
spinner(
self.spinner_set(),
SpinnerKind::Exploring,
self.active_start_time(),
self.animations_enabled(),
spinner_seed,
)
} else {
"•".dim()
},
@@ -360,13 +364,26 @@ impl ExecCell {
panic!("Expected exactly one call in a command display cell");
};
let layout = EXEC_DISPLAY_LAYOUT;
let is_interaction = call.is_unified_exec_interaction();
let success = call.output.as_ref().map(|o| o.exit_code == 0);
let bullet = match success {
Some(true) => "•".green().bold(),
Some(false) => "•".red().bold(),
None => spinner(call.start_time, self.animations_enabled()),
None => {
let kind = if is_interaction {
SpinnerKind::Waiting
} else {
SpinnerKind::Executing
};
spinner(
self.spinner_set(),
kind,
call.start_time,
self.animations_enabled(),
spinner_seed(&call.call_id),
)
}
};
let is_interaction = call.is_unified_exec_interaction();
let title = if is_interaction {
""
} else if self.is_active() {
@@ -681,7 +698,7 @@ mod tests {
interaction_input: None,
};
let cell = ExecCell::new(call, false);
let cell = ExecCell::new(call, false, SpinnerSet::Default);
// Use a narrow width so each logical line wraps into many on-screen lines.
let lines = cell.command_display_lines(width);
+42 -9
View File
@@ -1,10 +1,13 @@
use crate::animations::spinners::SpinnerKind;
use crate::animations::spinners::SpinnerSet;
use crate::animations::spinners::spinner;
use crate::animations::spinners::spinner_seed;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::exec_cell::output_lines;
use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::live_wrap::take_prefix_by_width;
@@ -1093,6 +1096,7 @@ pub(crate) struct McpToolCallCell {
duration: Option<Duration>,
result: Option<Result<mcp_types::CallToolResult, String>>,
animations_enabled: bool,
spinner_set: SpinnerSet,
}
impl McpToolCallCell {
@@ -1100,6 +1104,7 @@ impl McpToolCallCell {
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> Self {
Self {
call_id,
@@ -1108,6 +1113,7 @@ impl McpToolCallCell {
duration: None,
result: None,
animations_enabled,
spinner_set,
}
}
@@ -1169,7 +1175,13 @@ impl HistoryCell for McpToolCallCell {
let bullet = match status {
Some(true) => "•".green().bold(),
Some(false) => "•".red().bold(),
None => spinner(Some(self.start_time), self.animations_enabled),
None => spinner(
self.spinner_set,
SpinnerKind::Tool,
Some(self.start_time),
self.animations_enabled,
spinner_seed(&self.call_id),
),
};
let header_text = if status.is_some() {
"Called"
@@ -1258,8 +1270,9 @@ pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> McpToolCallCell {
McpToolCallCell::new(call_id, invocation, animations_enabled)
McpToolCallCell::new(call_id, invocation, animations_enabled, spinner_set)
}
pub(crate) fn new_web_search_call(query: String) -> PrefixedWrappedHistoryCell {
@@ -1660,6 +1673,11 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_header(header: String) -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![vec!["• ".dim(), header.bold()].into()];
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box<dyn HistoryCell> {
let full_reasoning_buffer = full_reasoning_buffer.trim();
if let Some(open) = full_reasoning_buffer.find("**") {
@@ -2022,7 +2040,7 @@ mod tests {
})),
};
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true);
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true, SpinnerSet::Default);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
@@ -2049,7 +2067,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-2".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(1420), Ok(result))
.is_none()
@@ -2071,7 +2090,8 @@ mod tests {
})),
};
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-3".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
.is_none()
@@ -2115,7 +2135,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-4".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(640), Ok(result))
.is_none()
@@ -2147,7 +2168,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-5".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(1280), Ok(result))
.is_none()
@@ -2186,7 +2208,8 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
let mut cell =
new_active_mcp_tool_call("call-6".into(), invocation, true, SpinnerSet::Default);
assert!(
cell.complete(Duration::from_millis(320), Ok(result))
.is_none()
@@ -2272,6 +2295,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
// Mark call complete so markers are ✓
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -2299,6 +2323,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
// Call 1: Search only
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
@@ -2368,6 +2393,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
@@ -2392,6 +2418,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
// Mark call complete so it renders as "Ran"
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -2418,6 +2445,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
// Wide enough that it fits inline
@@ -2442,6 +2470,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(24);
@@ -2465,6 +2494,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
@@ -2489,6 +2519,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(28);
@@ -2513,6 +2544,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
let stderr: String = (1..=10)
.map(|n| n.to_string())
@@ -2563,6 +2595,7 @@ mod tests {
interaction_input: None,
},
true,
SpinnerSet::Default,
);
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
+1
View File
@@ -32,6 +32,7 @@ use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
mod additional_dirs;
mod animations;
mod app;
mod app_backtrack;
mod app_event;
+1
View File
@@ -753,6 +753,7 @@ mod tests {
ExecCommandSource::Agent,
None,
true,
crate::animations::spinners::SpinnerSet::Default,
);
exec_cell.complete_call(
"exec-1",
@@ -1,6 +1,5 @@
---
source: tui/src/history_cell.rs
assertion_line: 1740
expression: rendered
---
• Calling search.find_docs({"query":"ratatui styling","limit":3})
[##] Calling search.find_docs({"query":"ratatui styling","limit":3})
@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
"• Working (0s • esc "
"o... Working (0s • e"
" "
@@ -2,5 +2,5 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
"• Working (0s • esc to interrupt) "
"o... Working (0s • esc to interrupt) "
" "
@@ -2,6 +2,6 @@
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
"• Working (0s) "
".... Working (0s) "
" └ A man a plan a canal "
" panama "
+125 -29
View File
@@ -3,6 +3,8 @@
use std::time::Duration;
use std::time::Instant;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use codex_core::protocol::Op;
use crossterm::event::KeyCode;
@@ -16,9 +18,14 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::animations::spinners::SpinnerKind;
use crate::animations::spinners::SpinnerSet;
use crate::animations::spinners::animation3_spans;
use crate::animations::spinners::animation4_spans;
use crate::animations::spinners::spinner;
use crate::animations::spinners::spinner_seed;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_cell::spinner;
use crate::key_hint;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
@@ -42,6 +49,8 @@ pub(crate) struct StatusIndicatorWidget {
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
spinner_set: SpinnerSet,
spinner_seed_offset: u64,
}
// Format elapsed seconds into a compact human-friendly form used by the status line.
@@ -66,7 +75,12 @@ impl StatusIndicatorWidget {
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
spinner_set: SpinnerSet,
) -> Self {
let seed_offset = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() as u64)
.unwrap_or(0);
Self {
header: String::from("Working"),
details: None,
@@ -74,10 +88,11 @@ impl StatusIndicatorWidget {
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,
app_event_tx,
frame_requester,
animations_enabled,
spinner_set,
spinner_seed_offset: seed_offset,
}
}
@@ -88,6 +103,7 @@ impl StatusIndicatorWidget {
/// Update the animated header label (left of the brackets).
pub(crate) fn update_header(&mut self, header: String) {
self.header = header;
self.spinner_seed_offset = self.spinner_seed_offset.wrapping_add(1);
}
/// Update the details text shown below the header.
@@ -207,32 +223,88 @@ impl Renderable for StatusIndicatorWidget {
let elapsed_duration = self.elapsed_duration_at(now);
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
let mut spans = Vec::with_capacity(5);
spans.push(spinner(Some(self.last_resume_at), self.animations_enabled));
spans.push(" ".into());
if self.animations_enabled {
spans.extend(shimmer_spans(&self.header));
} else if !self.header.is_empty() {
spans.push(self.header.clone().into());
}
spans.push(" ".into());
if self.show_interrupt_hint {
spans.extend(vec![
format!("({pretty_elapsed} • ").dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt)".dim(),
]);
let spinner_kind = SpinnerKind::from_header(&self.header);
let spinner_seed = if self.header.is_empty() {
spinner_seed("working")
} else {
spans.push(format!("({pretty_elapsed})").dim());
}
spinner_seed(&self.header)
};
let mut lines = Vec::new();
lines.push(Line::from(spans));
if area.height > 1 {
// If there is enough space, add the details lines below the header.
if matches!(
self.spinner_set,
SpinnerSet::Animation3 | SpinnerSet::Animation4
) {
let frame = match self.spinner_set {
SpinnerSet::Animation3 => animation3_spans(
spinner_kind,
Some(self.last_resume_at),
self.animations_enabled,
spinner_seed ^ self.spinner_seed_offset,
),
SpinnerSet::Animation4 => animation4_spans(
spinner_kind,
Some(self.last_resume_at),
self.animations_enabled,
spinner_seed ^ self.spinner_seed_offset,
),
_ => unreachable!("animation set mismatch"),
};
let mut spans = Vec::with_capacity(10);
spans.push(frame.face);
spans.push(" ".into());
if self.spinner_set == SpinnerSet::Animation4 && self.animations_enabled {
spans.extend(shimmer_spans(frame.text.content.as_ref()));
} else {
spans.push(frame.text);
}
spans.push(" ".into());
if self.show_interrupt_hint {
spans.extend(vec![
format!("({pretty_elapsed} • ").dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt)".dim(),
]);
} else {
spans.push(format!("({pretty_elapsed})").dim());
}
lines.push(Line::from(spans));
let details = self.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
} else {
let mut spans = Vec::with_capacity(5);
spans.push(spinner(
self.spinner_set,
spinner_kind,
Some(self.last_resume_at),
self.animations_enabled,
spinner_seed ^ self.spinner_seed_offset,
));
spans.push(" ".into());
if self.animations_enabled {
spans.extend(shimmer_spans(&self.header));
} else if !self.header.is_empty() {
spans.push(self.header.clone().into());
}
spans.push(" ".into());
if self.show_interrupt_hint {
spans.extend(vec![
format!("({pretty_elapsed} • ").dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt)".dim(),
]);
} else {
spans.push(format!("({pretty_elapsed})").dim());
}
lines.push(Line::from(spans));
if area.height > 1 {
// If there is enough space, add the details lines below the header.
let details = self.wrapped_details_lines(area.width);
let max_details = usize::from(area.height.saturating_sub(1));
lines.extend(details.into_iter().take(max_details));
}
}
Paragraph::new(Text::from(lines)).render_ref(area, buf);
@@ -270,7 +342,12 @@ mod tests {
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<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,
SpinnerSet::Default,
);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
@@ -284,7 +361,12 @@ mod tests {
fn renders_truncated() {
let (tx_raw, _rx) = unbounded_channel::<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,
SpinnerSet::Default,
);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
@@ -298,7 +380,12 @@ mod tests {
fn renders_wrapped_details_panama_two_lines() {
let (tx_raw, _rx) = unbounded_channel::<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,
SpinnerSet::Default,
);
w.update_details(Some("A man a plan a canal panama".to_string()));
w.set_interrupt_hint_visible(false);
@@ -319,8 +406,12 @@ mod tests {
fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut widget =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut widget = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
true,
SpinnerSet::Default,
);
let baseline = Instant::now();
widget.last_resume_at = baseline;
@@ -341,7 +432,12 @@ mod tests {
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<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,
SpinnerSet::Default,
);
w.update_details(Some("abcd abcd abcd abcd".to_string()));
let lines = w.wrapped_details_lines(6);