Added feature switch to disable animations in TUI (#6870)

This PR adds support for a new feature flag `tui.animations`. By
default, the TUI uses animations in its welcome screen, "working"
spinners, and "shimmer" effects. This animations can interfere with
screen readers, so it's good to provide a way to disable them.

This change is inspired by [a
PR](https://github.com/openai/codex/pull/4014) contributed by @Orinks.
That PR has faltered a bit, but I think the core idea is sound. This
version incorporates feedback from @aibrahim-oai. In particular:
1. It uses a feature flag (`tui.animations`) rather than the unqualified
CLI key `no-animations`. Feature flags are the preferred way to expose
boolean switches. They are also exposed via CLI command switches.
2. It includes more complete documentation.
3. It disables a few animations that the other PR omitted.
This commit is contained in:
Eric Traut
2025-11-20 12:40:08 -06:00
committed by GitHub
Unverified
parent 888c6dd9e7
commit d909048a85
15 changed files with 326 additions and 188 deletions
+8
View File
@@ -160,6 +160,9 @@ pub struct Config {
/// and turn completions when not focused.
pub tui_notifications: Notifications,
/// Enable ASCII animations and shimmer effects in the TUI.
pub animations: bool,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
@@ -1253,6 +1256,7 @@ impl Config {
.as_ref()
.map(|t| t.notifications.clone())
.unwrap_or_default(),
animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@@ -3003,6 +3007,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -3075,6 +3080,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
};
@@ -3162,6 +3168,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
};
@@ -3235,6 +3242,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
};
+9
View File
@@ -363,6 +363,15 @@ pub struct Tui {
/// Defaults to `true`.
#[serde(default)]
pub notifications: Notifications,
/// Enable animations (welcome screen, shimmer effects, spinners).
/// Defaults to `true`.
#[serde(default = "default_true")]
pub animations: bool,
}
const fn default_true() -> bool {
true
}
/// Settings for notices we display to users via the tui and app-server clients
+28 -8
View File
@@ -69,6 +69,7 @@ pub(crate) struct BottomPane {
is_task_running: bool,
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
@@ -84,28 +85,38 @@ pub(crate) struct BottomPaneParams {
pub(crate) enhanced_keys_supported: bool,
pub(crate) placeholder_text: String,
pub(crate) disable_paste_burst: bool,
pub(crate) animations_enabled: bool,
}
impl BottomPane {
pub fn new(params: BottomPaneParams) -> Self {
let enhanced_keys_supported = params.enhanced_keys_supported;
let BottomPaneParams {
app_event_tx,
frame_requester,
has_input_focus,
enhanced_keys_supported,
placeholder_text,
disable_paste_burst,
animations_enabled,
} = params;
Self {
composer: ChatComposer::new(
params.has_input_focus,
params.app_event_tx.clone(),
has_input_focus,
app_event_tx.clone(),
enhanced_keys_supported,
params.placeholder_text,
params.disable_paste_burst,
placeholder_text,
disable_paste_burst,
),
view_stack: Vec::new(),
app_event_tx: params.app_event_tx,
frame_requester: params.frame_requester,
has_input_focus: params.has_input_focus,
app_event_tx,
frame_requester,
has_input_focus,
is_task_running: false,
ctrl_c_quit_hint: false,
status: None,
queued_user_messages: QueuedUserMessages::new(),
esc_backtrack_hint: false,
animations_enabled,
context_window_percent: None,
}
}
@@ -294,6 +305,7 @@ impl BottomPane {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
));
}
if let Some(status) = self.status.as_mut() {
@@ -319,6 +331,7 @@ impl BottomPane {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
));
self.request_redraw();
}
@@ -554,6 +567,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
pane.push_approval_request(exec_request());
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
@@ -574,6 +588,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Create an approval modal (active view).
@@ -605,6 +620,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Start a running task so the status indicator is active above the composer.
@@ -670,6 +686,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Begin a task: show initial status.
@@ -695,6 +712,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Activate spinner (status view replaces composer) with no live ring.
@@ -724,6 +742,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
pane.set_task_running(true);
@@ -750,6 +769,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
pane.set_task_running(true);
+10 -1
View File
@@ -962,6 +962,7 @@ impl ChatWidget {
parsed,
source,
None,
self.config.animations,
)));
}
@@ -1071,6 +1072,7 @@ impl ChatWidget {
ev.parsed_cmd,
ev.source,
interaction_input,
self.config.animations,
)));
}
@@ -1083,6 +1085,7 @@ impl ChatWidget {
self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call(
ev.call_id,
ev.invocation,
self.config.animations,
)));
self.request_redraw();
}
@@ -1104,7 +1107,11 @@ impl ChatWidget {
Some(cell) if cell.call_id() == call_id => cell.complete(duration, result),
_ => {
self.flush_active_cell();
let mut cell = history_cell::new_active_mcp_tool_call(call_id, invocation);
let mut cell = history_cell::new_active_mcp_tool_call(
call_id,
invocation,
self.config.animations,
);
let extra_cell = cell.complete(duration, result);
self.active_cell = Some(Box::new(cell));
extra_cell
@@ -1146,6 +1153,7 @@ impl ChatWidget {
enhanced_keys_supported,
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
}),
active_cell: None,
config: config.clone(),
@@ -1220,6 +1228,7 @@ impl ChatWidget {
enhanced_keys_supported,
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
}),
active_cell: None,
config: config.clone(),
+1
View File
@@ -338,6 +338,7 @@ fn make_chatwidget_manual() -> (
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: cfg.animations,
});
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let widget = ChatWidget {
+11 -2
View File
@@ -28,11 +28,15 @@ pub(crate) struct ExecCall {
#[derive(Debug)]
pub(crate) struct ExecCell {
pub(crate) calls: Vec<ExecCall>,
animations_enabled: bool,
}
impl ExecCell {
pub(crate) fn new(call: ExecCall) -> Self {
Self { calls: vec![call] }
pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self {
Self {
calls: vec![call],
animations_enabled,
}
}
pub(crate) fn with_added_call(
@@ -56,6 +60,7 @@ impl ExecCell {
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
animations_enabled: self.animations_enabled,
})
} else {
None
@@ -112,6 +117,10 @@ impl ExecCell {
.and_then(|c| c.start_time)
}
pub(crate) fn animations_enabled(&self) -> bool {
self.animations_enabled
}
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
self.calls.iter()
}
+20 -13
View File
@@ -40,17 +40,21 @@ pub(crate) fn new_active_exec_command(
parsed: Vec<ParsedCommand>,
source: ExecCommandSource,
interaction_input: Option<String>,
animations_enabled: bool,
) -> ExecCell {
ExecCell::new(ExecCall {
call_id,
command,
parsed,
output: None,
source,
start_time: Some(Instant::now()),
duration: None,
interaction_input,
})
ExecCell::new(
ExecCall {
call_id,
command,
parsed,
output: None,
source,
start_time: Some(Instant::now()),
duration: None,
interaction_input,
},
animations_enabled,
)
}
fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String {
@@ -168,7 +172,10 @@ pub(crate) fn output_lines(
}
}
pub(crate) fn spinner(start_time: Option<Instant>) -> Span<'static> {
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)
@@ -239,7 +246,7 @@ impl ExecCell {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time())
spinner(self.active_start_time(), self.animations_enabled())
} else {
"".dim()
},
@@ -347,7 +354,7 @@ impl ExecCell {
let bullet = match success {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(call.start_time),
None => spinner(call.start_time, self.animations_enabled()),
};
let is_interaction = call.is_unified_exec_interaction();
let title = if is_interaction {
+182 -145
View File
@@ -806,16 +806,22 @@ pub(crate) struct McpToolCallCell {
start_time: Instant,
duration: Option<Duration>,
result: Option<Result<mcp_types::CallToolResult, String>>,
animations_enabled: bool,
}
impl McpToolCallCell {
pub(crate) fn new(call_id: String, invocation: McpInvocation) -> Self {
pub(crate) fn new(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
) -> Self {
Self {
call_id,
invocation,
start_time: Instant::now(),
duration: None,
result: None,
animations_enabled,
}
}
@@ -877,7 +883,7 @@ impl HistoryCell for McpToolCallCell {
let bullet = match status {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(Some(self.start_time)),
None => spinner(Some(self.start_time), self.animations_enabled),
};
let header_text = if status.is_some() {
"Called"
@@ -965,8 +971,9 @@ impl HistoryCell for McpToolCallCell {
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
) -> McpToolCallCell {
McpToolCallCell::new(call_id, invocation)
McpToolCallCell::new(call_id, invocation, animations_enabled)
}
pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell {
@@ -1631,7 +1638,7 @@ mod tests {
})),
};
let cell = new_active_mcp_tool_call("call-1".into(), invocation);
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
@@ -1658,7 +1665,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(1420), Ok(result))
.is_none()
@@ -1680,7 +1687,7 @@ mod tests {
})),
};
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation, true);
assert!(
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
.is_none()
@@ -1724,7 +1731,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(640), Ok(result))
.is_none()
@@ -1756,7 +1763,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(1280), Ok(result))
.is_none()
@@ -1795,7 +1802,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(320), Ok(result))
.is_none()
@@ -1853,32 +1860,35 @@ mod tests {
fn coalesces_sequential_reads_within_one_call() {
// Build one exec cell with a Search followed by two Reads
let call_id = "c1".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
ParsedCommand::Read {
name: "status_indicator_widget.rs".into(),
cmd: "cat status_indicator_widget.rs".into(),
path: "status_indicator_widget.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
ParsedCommand::Read {
name: "status_indicator_widget.rs".into(),
cmd: "cat status_indicator_widget.rs".into(),
path: "status_indicator_widget.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Mark call complete so markers are ✓
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -1889,20 +1899,23 @@ mod tests {
#[test]
fn coalesces_reads_across_multiple_calls() {
let mut cell = ExecCell::new(ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
}],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
}],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Call 1: Search only
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
// Call 2: Read A
@@ -1943,32 +1956,35 @@ mod tests {
#[test]
fn coalesced_reads_dedupe_names() {
let mut cell = ExecCell::new(ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
@@ -1980,16 +1996,19 @@ mod tests {
// Create a completed exec cell with a multiline command
let cmd = "set -o pipefail\ncargo test --all-features --quiet".to_string();
let call_id = "c1".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Mark call complete so it renders as "Ran"
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -2003,16 +2022,19 @@ mod tests {
#[test]
fn single_line_command_compact_when_fits() {
let call_id = "c1".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["echo".into(), "ok".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["echo".into(), "ok".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
// Wide enough that it fits inline
let lines = cell.display_lines(80);
@@ -2024,16 +2046,19 @@ mod tests {
fn single_line_command_wraps_with_four_space_continuation() {
let call_id = "c1".to_string();
let long = "a_very_long_token_without_spaces_to_force_wrapping".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(24);
let rendered = render_lines(&lines).join("\n");
@@ -2044,16 +2069,19 @@ mod tests {
fn multiline_command_without_wrap_uses_branch_then_eight_spaces() {
let call_id = "c1".to_string();
let cmd = "echo one\necho two".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
@@ -2065,16 +2093,19 @@ mod tests {
let call_id = "c1".to_string();
let cmd = "first_token_is_long_enough_to_wrap\nsecond_token_is_also_long_enough_to_wrap"
.to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(28);
let rendered = render_lines(&lines).join("\n");
@@ -2086,16 +2117,19 @@ mod tests {
// Build an exec cell with a non-zero exit and 10 lines on stderr to exercise
// the head/tail rendering and gutter prefixes.
let call_id = "c_err".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
let stderr: String = (1..=10)
.map(|n| n.to_string())
.collect::<Vec<_>>()
@@ -2133,16 +2167,19 @@ mod tests {
let call_id = "c_wrap_err".to_string();
let long_cmd =
"echo this_is_a_very_long_single_token_that_will_wrap_across_the_available_width";
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
cell.complete_call(
+10 -4
View File
@@ -155,6 +155,7 @@ pub(crate) struct AuthModeWidget {
pub auth_manager: Arc<AuthManager>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
}
impl AuthModeWidget {
@@ -260,10 +261,14 @@ impl AuthModeWidget {
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let mut spans = vec![" ".into()];
// Schedule a follow-up frame to keep the shimmer animation going.
self.request_frame
.schedule_frame_in(std::time::Duration::from_millis(100));
spans.extend(shimmer_spans("Finish signing in via your browser"));
if self.animations_enabled {
// Schedule a follow-up frame to keep the shimmer animation going.
self.request_frame
.schedule_frame_in(std::time::Duration::from_millis(100));
spans.extend(shimmer_spans("Finish signing in via your browser"));
} else {
spans.push("Finish signing in via your browser".into());
}
let mut lines = vec![spans.into(), "".into()];
let sign_in_state = self.sign_in_state.read().unwrap();
@@ -670,6 +675,7 @@ mod tests {
),
forced_chatgpt_workspace_id: None,
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
animations_enabled: true,
};
(widget, codex_home)
}
@@ -88,6 +88,7 @@ impl OnboardingScreen {
steps.push(Step::Welcome(WelcomeWidget::new(
!matches!(login_status, LoginStatus::NotAuthenticated),
tui.frame_requester(),
config.animations,
)));
if show_login_screen {
let highlighted_mode = match forced_login_method {
@@ -105,6 +106,7 @@ impl OnboardingScreen {
auth_manager,
forced_chatgpt_workspace_id,
forced_login_method,
animations_enabled: config.animations,
}))
}
let is_git_repo = get_git_repo_root(&cwd).is_some();
+16 -6
View File
@@ -25,10 +25,14 @@ const MIN_ANIMATION_WIDTH: u16 = 60;
pub(crate) struct WelcomeWidget {
pub is_logged_in: bool,
animation: AsciiAnimation,
animations_enabled: bool,
}
impl KeyboardHandler for WelcomeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if !self.animations_enabled {
return;
}
if key_event.kind == KeyEventKind::Press
&& key_event.code == KeyCode::Char('.')
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
@@ -40,10 +44,15 @@ impl KeyboardHandler for WelcomeWidget {
}
impl WelcomeWidget {
pub(crate) fn new(is_logged_in: bool, request_frame: FrameRequester) -> Self {
pub(crate) fn new(
is_logged_in: bool,
request_frame: FrameRequester,
animations_enabled: bool,
) -> Self {
Self {
is_logged_in,
animation: AsciiAnimation::new(request_frame),
animations_enabled,
}
}
}
@@ -51,17 +60,17 @@ impl WelcomeWidget {
impl WidgetRef for &WelcomeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
self.animation.schedule_next_frame();
if self.animations_enabled {
self.animation.schedule_next_frame();
}
// Skip the animation entirely when the viewport is too small so we don't clip frames.
let show_animation =
area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH;
let mut lines: Vec<Line> = Vec::new();
if show_animation {
if show_animation && self.animations_enabled {
let frame = self.animation.current_frame();
// let frame_line_count = frame.lines().count();
// lines.reserve(frame_line_count + 2);
lines.extend(frame.lines().map(Into::into));
lines.push("".into());
}
@@ -99,7 +108,7 @@ mod tests {
#[test]
fn welcome_renders_animation_on_first_draw() {
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy());
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
let mut buf = Buffer::empty(area);
(&widget).render(area, &mut buf);
@@ -129,6 +138,7 @@ mod tests {
let mut widget = WelcomeWidget {
is_logged_in: false,
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
animations_enabled: true,
};
let before = widget.animation.current_frame();
+1
View File
@@ -722,6 +722,7 @@ mod tests {
vec![ParsedCommand::Unknown { cmd: "ls".into() }],
ExecCommandSource::Agent,
None,
true,
);
exec_cell.complete_call(
"exec-1",
+17 -6
View File
@@ -30,6 +30,7 @@ pub(crate) struct StatusIndicatorWidget {
is_paused: bool,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
}
// Format elapsed seconds into a compact human-friendly form used by the status line.
@@ -50,7 +51,11 @@ pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
}
impl StatusIndicatorWidget {
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
pub(crate) fn new(
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
) -> Self {
Self {
header: String::from("Working"),
show_interrupt_hint: true,
@@ -60,6 +65,7 @@ impl StatusIndicatorWidget {
app_event_tx,
frame_requester,
animations_enabled,
}
}
@@ -146,9 +152,13 @@ impl Renderable for StatusIndicatorWidget {
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)));
spans.push(spinner(Some(self.last_resume_at), self.animations_enabled));
spans.push(" ".into());
spans.extend(shimmer_spans(&self.header));
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![
@@ -195,7 +205,7 @@ 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());
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
@@ -209,7 +219,7 @@ 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());
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
@@ -223,7 +233,8 @@ 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());
let mut widget =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let baseline = Instant::now();
widget.last_resume_at = baseline;
+5
View File
@@ -893,6 +893,10 @@ notifications = true
# You can optionally filter to specific notification types.
# Available types are "agent-turn-complete" and "approval-requested".
notifications = [ "agent-turn-complete", "approval-requested" ]
# Disable terminal animations (welcome screen, status shimmer, spinner).
# Defaults to true.
animations = false
```
> [!NOTE]
@@ -950,6 +954,7 @@ Valid values:
| `sandbox_workspace_write.exclude_tmpdir_env_var` | boolean | Exclude `$TMPDIR` from writable roots (default: false). |
| `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). |
| `notify` | array<string> | External program for notifications. |
| `tui.animations` | boolean | Enable terminal animations (welcome screen, shimmer, spinner). Defaults to true; set to `false` to disable visual motion. |
| `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. |
| `features.<feature-flag>` | boolean | See [feature flags](#feature-flags) for details |
| `mcp_servers.<id>.command` | string | MCP server launcher command (stdio servers only). |
+6 -3
View File
@@ -140,13 +140,16 @@ file_opener = "vscode"
# Examples: false | ["agent-turn-complete", "approval-requested"]
notifications = false
# Suppress internal reasoning events from output (default: false)
# Enables welcome/status/spinner animations. Default: true
animations = true
# Suppress internal reasoning events from output. Default: false
hide_agent_reasoning = false
# Show raw reasoning content when available (default: false)
# Show raw reasoning content when available. Default: false
show_raw_agent_reasoning = false
# Disable burst-paste detection in the TUI (default: false)
# Disable burst-paste detection in the TUI. Default: false
disable_paste_burst = false
# Track Windows onboarding acknowledgement (Windows only). Default: false