mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
Show action required in terminal title (#18372)
Implements #18162 This updates the TUI terminal title to show an explicit action-required state when Codex is blocked on user approval or input. The terminal title now uses the activity title item to cover both active work and blocked-on-user states, while still accepting the legacy spinner config value. Changes - Rename the terminal title item from `spinner` to `activity` while preserving legacy config compatibility - Show `[ ! ] Action Required `while approval or input overlays are active, with a blinking `[ . ]` alternate state - Suppress the normal working spinner while Codex is blocked on user action - Add targeted coverage for action-required title behavior and legacy title-item parsing Testing - Trigger an approval or input modal and confirm the tab title alternates between `[ ! ] Action Required` and `[ . ] Action Required` - Disable the activity title item and confirm the action-required title does not appear - Resolve the prompt and confirm the title returns to the normal spinning/idel state https://github.com/user-attachments/assets/e9ecc530-a6be-4fd7-b9a6-d550a790eb2c
This commit is contained in:
1 parent
e903d000b0
commit
e64c765673
20 files changed
+450
-68
No files matched your search
+1
-1
@@ -13,7 +13,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
|
||||
[ ] app-name Codex app name
|
||||
[ ] project-name Project name (falls back to current directory name)
|
||||
[ ] current-dir Current working directory
|
||||
[ ] spinner Animated task spinner (omitted while idle or when animations are off)
|
||||
[ ] activity Spinner while working, action-required message while blocked.
|
||||
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
|
||||
|
||||
thread title | feat/awesome-feature | Tasks 0/0
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
|
||||
[x] task-progress Latest task progress from update_plan (omitted until available)
|
||||
[ ] app-name Codex app name
|
||||
[ ] current-dir Current working directory
|
||||
[ ] spinner Animated task spinner (omitted while idle or when animations are off)
|
||||
[ ] activity Spinner while working, action-required message while blocked.
|
||||
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
|
||||
|
||||
preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
|
||||
[x] task-progress Latest task progress from update_plan (omitted until available)
|
||||
[ ] app-name Codex app name
|
||||
[ ] current-dir Current working directory
|
||||
[ ] spinner Animated task spinner (omitted while idle or when animations are off)
|
||||
[ ] activity Spinner while working, action-required message while blocked.
|
||||
[ ] run-state Compact session run-state text (Ready, Working, Thinking)
|
||||
[ ] git-branch Current Git branch (omitted when unavailable)
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ use super::*;
|
||||
use crate::status::format_tokens_compact;
|
||||
|
||||
/// Items shown in the terminal title when the user has not configured a
|
||||
/// custom selection. Intentionally minimal: spinner + project name.
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project-name"];
|
||||
/// custom selection. Intentionally minimal: activity indicator + project name.
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"];
|
||||
|
||||
/// Braille-pattern dot-spinner frames for the terminal title animation.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
@@ -17,6 +17,13 @@ pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
/// Time between spinner frame advances in the terminal title.
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Time between action-required blink phases in the terminal title.
|
||||
const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Prefix shown in the terminal title when the agent is blocked on user input.
|
||||
const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX: &str = "[ ! ] Action Required";
|
||||
const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Required";
|
||||
|
||||
/// Compact runtime states that can be rendered into the terminal title.
|
||||
///
|
||||
/// This is intentionally smaller than the full status-header vocabulary. The
|
||||
@@ -177,9 +184,11 @@ impl ChatWidget {
|
||||
/// Empty selections clear the managed title. Non-empty selections render the
|
||||
/// current values in configured order, skip unavailable segments, and cache
|
||||
/// the last successfully written title so redundant OSC writes are avoided.
|
||||
/// When the `spinner` item is present in an animated running state, this also
|
||||
/// schedules the next frame so the spinner keeps advancing.
|
||||
/// When the `activity` item is present in an animated running state, this also
|
||||
/// schedules the next frame so the title animation keeps advancing.
|
||||
fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) {
|
||||
self.last_terminal_title_requires_action =
|
||||
self.terminal_title_shows_action_required_with_selections(selections);
|
||||
if selections.terminal_title_items.is_empty() {
|
||||
if let Err(err) = self.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title");
|
||||
@@ -188,28 +197,11 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let mut previous = None;
|
||||
let title = selections
|
||||
.terminal_title_items
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|item| {
|
||||
self.terminal_title_value_for_item(item, now)
|
||||
.map(|value| (item, value))
|
||||
})
|
||||
.fold(String::new(), |mut title, (item, value)| {
|
||||
title.push_str(item.separator_from_previous(previous));
|
||||
title.push_str(&value);
|
||||
previous = Some(item);
|
||||
title
|
||||
});
|
||||
let title = (!title.is_empty()).then_some(title);
|
||||
let should_animate_spinner =
|
||||
self.should_animate_terminal_title_spinner_with_selections(selections);
|
||||
let title = self.terminal_title_text_for_selections(selections, now);
|
||||
let animation_interval = self.terminal_title_animation_interval_with_selections(selections);
|
||||
if self.last_terminal_title == title {
|
||||
if should_animate_spinner {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
|
||||
if let Some(interval) = animation_interval {
|
||||
self.frame_requester.schedule_frame_in(interval);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -234,9 +226,8 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
if should_animate_spinner {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
|
||||
if let Some(interval) = animation_interval {
|
||||
self.frame_requester.schedule_frame_in(interval);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +254,92 @@ impl ChatWidget {
|
||||
self.refresh_terminal_title_from_selections(&selections);
|
||||
}
|
||||
|
||||
fn terminal_title_requires_action(&self) -> bool {
|
||||
self.bottom_pane.terminal_title_requires_action()
|
||||
}
|
||||
|
||||
pub(super) fn terminal_title_shows_action_required(&self) -> bool {
|
||||
self.terminal_title_requires_action() && self.terminal_title_uses_activity()
|
||||
}
|
||||
|
||||
fn terminal_title_text_for_selections(
|
||||
&mut self,
|
||||
selections: &StatusSurfaceSelections,
|
||||
now: Instant,
|
||||
) -> Option<String> {
|
||||
if self.terminal_title_shows_action_required_with_selections(selections) {
|
||||
return Some(self.action_required_terminal_title_text(selections, now));
|
||||
}
|
||||
|
||||
let mut previous = None;
|
||||
let title = selections
|
||||
.terminal_title_items
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|item| {
|
||||
self.terminal_title_value_for_item(item, now)
|
||||
.map(|value| (item, value))
|
||||
})
|
||||
.fold(String::new(), |mut title, (item, value)| {
|
||||
title.push_str(item.separator_from_previous(previous));
|
||||
title.push_str(&value);
|
||||
previous = Some(item);
|
||||
title
|
||||
});
|
||||
(!title.is_empty()).then_some(title)
|
||||
}
|
||||
|
||||
fn action_required_terminal_title_text(
|
||||
&mut self,
|
||||
selections: &StatusSurfaceSelections,
|
||||
now: Instant,
|
||||
) -> String {
|
||||
crate::bottom_pane::build_action_required_title_text(
|
||||
self.action_required_terminal_title_prefix_at(now),
|
||||
selections.terminal_title_items.iter().copied(),
|
||||
&[TerminalTitleItem::Status],
|
||||
|item| self.terminal_title_value_for_item(item, now),
|
||||
)
|
||||
}
|
||||
|
||||
fn action_required_terminal_title_prefix_at(&self, now: Instant) -> &'static str {
|
||||
if !self.config.animations {
|
||||
return TERMINAL_TITLE_ACTION_REQUIRED_PREFIX;
|
||||
}
|
||||
|
||||
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
|
||||
let phase = (elapsed.as_millis() / TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL.as_millis()) % 2;
|
||||
if phase == 0 {
|
||||
TERMINAL_TITLE_ACTION_REQUIRED_PREFIX
|
||||
} else {
|
||||
TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_title_shows_action_required_with_selections(
|
||||
&self,
|
||||
selections: &StatusSurfaceSelections,
|
||||
) -> bool {
|
||||
self.terminal_title_requires_action()
|
||||
&& selections
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::Spinner)
|
||||
}
|
||||
|
||||
fn terminal_title_animation_interval_with_selections(
|
||||
&self,
|
||||
selections: &StatusSurfaceSelections,
|
||||
) -> Option<Duration> {
|
||||
if self.config.animations
|
||||
&& self.terminal_title_shows_action_required_with_selections(selections)
|
||||
{
|
||||
return Some(TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL);
|
||||
}
|
||||
|
||||
self.should_animate_terminal_title_spinner_with_selections(selections)
|
||||
.then_some(TERMINAL_TITLE_SPINNER_INTERVAL)
|
||||
}
|
||||
|
||||
pub(super) fn request_status_line_branch_refresh(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
if !selections.uses_git_branch() {
|
||||
@@ -431,7 +508,7 @@ impl ChatWidget {
|
||||
}
|
||||
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
|
||||
StatusLineItem::GitBranch => self.status_line_branch.clone(),
|
||||
StatusLineItem::Status => Some(self.terminal_title_status_text()),
|
||||
StatusLineItem::Status => Some(self.run_state_status_text()),
|
||||
StatusLineItem::UsedTokens => {
|
||||
let usage = self.status_line_total_usage();
|
||||
let total = usage.tokens_in_context_window();
|
||||
@@ -505,7 +582,7 @@ impl ChatWidget {
|
||||
StatusSurfacePreviewItem::AppName => return Some("codex".to_string()),
|
||||
StatusSurfacePreviewItem::ProjectName => return self.terminal_title_project_name(),
|
||||
StatusSurfacePreviewItem::ProjectRoot => StatusLineItem::ProjectRoot,
|
||||
StatusSurfacePreviewItem::Status => return Some(self.terminal_title_status_text()),
|
||||
StatusSurfacePreviewItem::Status => return Some(self.run_state_status_text()),
|
||||
StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(),
|
||||
StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir,
|
||||
StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle,
|
||||
@@ -526,7 +603,6 @@ impl ChatWidget {
|
||||
};
|
||||
self.status_line_value_for_item(&status_line_item)
|
||||
}
|
||||
|
||||
/// Resolves one configured terminal-title item into a displayable segment.
|
||||
///
|
||||
/// Returning `None` means "omit this segment for now" so callers can keep
|
||||
@@ -544,7 +620,7 @@ impl ChatWidget {
|
||||
/*max_chars*/ 32,
|
||||
)),
|
||||
TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now),
|
||||
TerminalTitleItem::Status => Some(self.terminal_title_status_text()),
|
||||
TerminalTitleItem::Status => Some(self.run_state_status_text()),
|
||||
TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -612,11 +688,11 @@ impl ChatWidget {
|
||||
format!("{} {label}{fast_label}", self.model_display_name())
|
||||
}
|
||||
|
||||
/// Computes the compact runtime status label used by the terminal title.
|
||||
/// Computes the compact runtime status label used by word-based status items.
|
||||
///
|
||||
/// Startup takes precedence over normal task states, and idle state renders
|
||||
/// as `Ready` regardless of the last active status bucket.
|
||||
pub(super) fn terminal_title_status_text(&self) -> String {
|
||||
pub(super) fn run_state_status_text(&self) -> String {
|
||||
if self.mcp_startup_status.is_some() {
|
||||
return "Starting".to_string();
|
||||
}
|
||||
@@ -659,14 +735,19 @@ impl ChatWidget {
|
||||
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
fn terminal_title_uses_spinner(&self) -> bool {
|
||||
self.config
|
||||
.tui_terminal_title
|
||||
.as_ref()
|
||||
.is_none_or(|items| items.iter().any(|item| item == "spinner"))
|
||||
fn terminal_title_uses_activity(&self) -> bool {
|
||||
self.config.tui_terminal_title.as_ref().is_none_or(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item == "activity" || item == "spinner")
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_title_has_active_progress(&self) -> bool {
|
||||
if self.terminal_title_shows_action_required() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.mcp_startup_status.is_some()
|
||||
|| self.bottom_pane.is_task_running()
|
||||
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
|
||||
@@ -674,10 +755,14 @@ impl ChatWidget {
|
||||
|
||||
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
|
||||
self.config.animations
|
||||
&& self.terminal_title_uses_spinner()
|
||||
&& self.terminal_title_uses_activity()
|
||||
&& self.terminal_title_has_active_progress()
|
||||
}
|
||||
|
||||
pub(super) fn should_animate_terminal_title_action_required(&self) -> bool {
|
||||
self.config.animations && self.terminal_title_shows_action_required()
|
||||
}
|
||||
|
||||
fn should_animate_terminal_title_spinner_with_selections(
|
||||
&self,
|
||||
selections: &StatusSurfaceSelections,
|
||||
|
||||
@@ -283,6 +283,7 @@ mod slash_commands;
|
||||
mod status_and_layout;
|
||||
mod status_command_tests;
|
||||
mod status_surface_previews;
|
||||
mod terminal_title;
|
||||
|
||||
pub(crate) use helpers::make_chatwidget_manual_with_sender;
|
||||
pub(crate) use helpers::set_chatgpt_auth;
|
||||
|
||||
@@ -301,6 +301,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
last_terminal_title: None,
|
||||
last_terminal_title_requires_action: false,
|
||||
terminal_title_setup_original_items: None,
|
||||
terminal_title_animation_origin: Instant::now(),
|
||||
status_line_project_root_name_cache: None,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Terminal-title focused tests for live chatwidget status-surface behavior.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_shows_action_required_while_exec_approval_is_pending() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
let request = ExecApprovalRequestEvent {
|
||||
call_id: "call-action-required".into(),
|
||||
approval_id: Some("call-action-required".into()),
|
||||
turn_id: "turn-action-required".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
reason: Some("need confirmation".into()),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-action-required".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
assert_eq!(
|
||||
chat.last_terminal_title,
|
||||
Some("[ ! ] Action Required | project".to_string())
|
||||
);
|
||||
assert!(!chat.should_animate_terminal_title_spinner());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
chat.pre_draw_tick();
|
||||
|
||||
let title = chat
|
||||
.last_terminal_title
|
||||
.as_deref()
|
||||
.expect("terminal title should be restored after approval");
|
||||
assert!(title.contains("project"));
|
||||
assert!(!title.contains("Action Required"));
|
||||
assert!(chat.should_animate_terminal_title_spinner());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_action_required_respects_spinner_setting() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.tui_terminal_title = Some(vec!["project".to_string()]);
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
let request = ExecApprovalRequestEvent {
|
||||
call_id: "call-no-spinner".into(),
|
||||
approval_id: Some("call-no-spinner".into()),
|
||||
turn_id: "turn-no-spinner".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
reason: Some("need confirmation".into()),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-no-spinner".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some("project".to_string()));
|
||||
assert!(!chat.should_animate_terminal_title_action_required());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_action_required_blinks_when_animations_are_enabled() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500);
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
let request = ExecApprovalRequestEvent {
|
||||
call_id: "call-blink".into(),
|
||||
approval_id: Some("call-blink".into()),
|
||||
turn_id: "turn-blink".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
reason: Some("need confirmation".into()),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-blink".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
assert_eq!(
|
||||
chat.last_terminal_title,
|
||||
Some("[ . ] Action Required | project".to_string())
|
||||
);
|
||||
assert!(chat.should_animate_terminal_title_action_required());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_disabled() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.animations = false;
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500);
|
||||
chat.refresh_terminal_title();
|
||||
|
||||
assert_eq!(chat.last_terminal_title, Some("project".to_string()));
|
||||
assert!(!chat.should_animate_terminal_title_spinner());
|
||||
|
||||
let request = ExecApprovalRequestEvent {
|
||||
call_id: "call-no-animations".into(),
|
||||
approval_id: Some("call-no-animations".into()),
|
||||
turn_id: "turn-no-animations".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello".into()],
|
||||
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
||||
reason: Some("need confirmation".into()),
|
||||
network_approval_context: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
proposed_network_policy_amendments: None,
|
||||
additional_permissions: None,
|
||||
available_decisions: None,
|
||||
parsed_cmd: vec![],
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-no-animations".into(),
|
||||
msg: EventMsg::ExecApprovalRequest(request),
|
||||
});
|
||||
|
||||
chat.pre_draw_tick();
|
||||
|
||||
assert_eq!(
|
||||
chat.last_terminal_title,
|
||||
Some("[ ! ] Action Required | project".to_string())
|
||||
);
|
||||
assert!(!chat.should_animate_terminal_title_action_required());
|
||||
}
|
||||
Reference in new issue
Block a user