mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
888c6dd9e7
commit
d909048a85
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -722,6 +722,7 @@ mod tests {
|
||||
vec![ParsedCommand::Unknown { cmd: "ls".into() }],
|
||||
ExecCommandSource::Agent,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
exec_cell.complete_call(
|
||||
"exec-1",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user