mirror of
https://github.com/openai/codex.git
synced 2026-03-04 21:53:21 +00:00
Compare commits
4 Commits
pr13053
...
codex-cli-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7269a2ef40 | ||
|
|
95ca63a688 | ||
|
|
67b7db7684 | ||
|
|
d1e20cdcac |
@@ -115,11 +115,13 @@ use self::pending_interactive_replay::PendingInteractiveReplayState;
|
||||
|
||||
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
|
||||
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
|
||||
const TITLE_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
/// Baseline cadence for periodic stream commit animation ticks.
|
||||
///
|
||||
/// Smooth-mode streaming drains one line per tick, so this interval controls
|
||||
/// perceived typing speed for non-backlogged output.
|
||||
const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL;
|
||||
const TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppExitInfo {
|
||||
@@ -718,6 +720,46 @@ fn normalize_harness_overrides_for_cwd(
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
fn decorate_title_context(
|
||||
context: Option<String>,
|
||||
task_running: bool,
|
||||
tick: u128,
|
||||
) -> Option<String> {
|
||||
if !task_running {
|
||||
return context;
|
||||
}
|
||||
|
||||
let frame = TITLE_SPINNER_FRAMES[tick as usize % TITLE_SPINNER_FRAMES.len()];
|
||||
match context {
|
||||
Some(context) => Some(format!("{frame} - {context}")),
|
||||
None => Some(frame.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_title_context(
|
||||
title_override: Option<String>,
|
||||
thread_name: Option<String>,
|
||||
task_running: bool,
|
||||
tick: u128,
|
||||
) -> Option<String> {
|
||||
let context = title_override
|
||||
.or(thread_name)
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
decorate_title_context(context, task_running, tick)
|
||||
}
|
||||
|
||||
fn title_animation_tick() -> u128 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
/ TITLE_SPINNER_INTERVAL.as_millis()
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn chatwidget_init_for_forked_or_resumed_thread(
|
||||
&self,
|
||||
@@ -1747,6 +1789,18 @@ impl App {
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
};
|
||||
let task_running = app.chat_widget.is_task_running();
|
||||
let title_context = compute_title_context(
|
||||
app.chat_widget.title_override(),
|
||||
app.chat_widget.thread_name(),
|
||||
task_running,
|
||||
title_animation_tick(),
|
||||
);
|
||||
tui.set_title_context(title_context.as_deref())?;
|
||||
if task_running {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
|
||||
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1785,6 +1839,7 @@ impl App {
|
||||
)
|
||||
.await?;
|
||||
if let AppRunControl::Exit(exit_reason) = control {
|
||||
tui.set_title_context(None)?;
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: app.token_usage(),
|
||||
thread_id: app.chat_widget.thread_id(),
|
||||
@@ -1845,6 +1900,18 @@ impl App {
|
||||
AppRunControl::Continue
|
||||
}
|
||||
};
|
||||
let task_running = app.chat_widget.is_task_running();
|
||||
let title_context = compute_title_context(
|
||||
app.chat_widget.title_override(),
|
||||
app.chat_widget.thread_name(),
|
||||
task_running,
|
||||
title_animation_tick(),
|
||||
);
|
||||
tui.set_title_context(title_context.as_deref())?;
|
||||
if task_running {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
if App::should_stop_waiting_for_initial_session(
|
||||
waiting_for_initial_session_configured,
|
||||
app.primary_thread_id,
|
||||
@@ -1856,6 +1923,7 @@ impl App {
|
||||
AppRunControl::Exit(reason) => break reason,
|
||||
}
|
||||
};
|
||||
tui.set_title_context(None)?;
|
||||
tui.terminal.clear()?;
|
||||
Ok(AppExitInfo {
|
||||
token_usage: app.token_usage(),
|
||||
@@ -2124,6 +2192,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SetTitle(title) => {
|
||||
self.chat_widget.set_title_override(title);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||
if self.apply_non_pending_thread_rollback(num_turns) {
|
||||
tui.frame_requester().schedule_frame();
|
||||
@@ -3731,6 +3803,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorate_title_context_leaves_idle_titles_plain() {
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Named thread".to_string()), false, 3),
|
||||
Some("Named thread".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorate_title_context_adds_spinner_while_running() {
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Working".to_string()), true, 0),
|
||||
Some("⠋ - Working".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Working".to_string()), true, 9),
|
||||
Some("⠏ - Working".to_string())
|
||||
);
|
||||
assert_eq!(decorate_title_context(None, true, 0), Some("⠋".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_context_uses_thread_name_when_idle() {
|
||||
assert_eq!(
|
||||
compute_title_context(None, Some("named thread".to_string()), false, 0),
|
||||
Some("named thread".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_context_prefers_manual_title_when_idle() {
|
||||
assert_eq!(
|
||||
compute_title_context(
|
||||
Some("manual title".to_string()),
|
||||
Some("named thread".to_string()),
|
||||
false,
|
||||
0,
|
||||
),
|
||||
Some("manual title".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
|
||||
let mut wait_for_initial_session =
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
SetTitle(Option<String>),
|
||||
|
||||
/// Apply rollback semantics to local transcript cells.
|
||||
///
|
||||
|
||||
@@ -17,7 +17,11 @@ use std::collections::HashSet;
|
||||
// Hide alias commands in the default popup list so each unique action appears once.
|
||||
// `quit` is an alias of `exit`, so we skip `quit` here.
|
||||
// `approvals` is an alias of `permissions`.
|
||||
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
|
||||
const ALIAS_COMMANDS: &[SlashCommand] = &[
|
||||
SlashCommand::Quit,
|
||||
SlashCommand::Approvals,
|
||||
SlashCommand::Title,
|
||||
];
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -477,6 +481,18 @@ mod tests {
|
||||
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_hidden_in_empty_filter_but_shown_for_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
let items = popup.filtered_items();
|
||||
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Title)));
|
||||
|
||||
popup.on_composer_text_change("/ti".to_string());
|
||||
let items = popup.filtered_items();
|
||||
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Title)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_command_hidden_when_collaboration_modes_disabled() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
|
||||
@@ -82,6 +82,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_command_resolves_for_dispatch() {
|
||||
assert_eq!(
|
||||
find_builtin_command("title", all_enabled_flags()),
|
||||
Some(SlashCommand::Title)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_command_is_hidden_when_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
|
||||
@@ -607,6 +607,7 @@ pub(crate) struct ChatWidget {
|
||||
pending_status_indicator_restore: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
title_override: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
@@ -2913,6 +2914,7 @@ impl ChatWidget {
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_message_edit_binding,
|
||||
@@ -3093,6 +3095,7 @@ impl ChatWidget {
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
saw_plan_update_this_turn: false,
|
||||
saw_plan_item_this_turn: false,
|
||||
@@ -3262,6 +3265,7 @@ impl ChatWidget {
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
queued_message_edit_binding,
|
||||
@@ -3607,6 +3611,9 @@ impl ChatWidget {
|
||||
self.otel_manager.counter("codex.thread.rename", 1, &[]);
|
||||
self.show_rename_prompt();
|
||||
}
|
||||
SlashCommand::Title => {
|
||||
self.show_title_prompt();
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
}
|
||||
@@ -3936,6 +3943,13 @@ impl ChatWidget {
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
SlashCommand::Title => {
|
||||
let title = codex_core::util::normalize_thread_name(trimmed);
|
||||
let cell = Self::title_confirmation_cell(title.as_deref());
|
||||
self.add_boxed_history(Box::new(cell));
|
||||
self.set_title_override(title);
|
||||
self.request_redraw();
|
||||
}
|
||||
SlashCommand::Plan if !trimmed.is_empty() => {
|
||||
self.dispatch_command(cmd);
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
@@ -4030,6 +4044,23 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn show_title_prompt(&mut self) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
"Set title".to_string(),
|
||||
"Type a title and press Enter. Leave blank to clear it.".to_string(),
|
||||
None,
|
||||
Box::new(move |name: String| {
|
||||
let title = codex_core::util::normalize_thread_name(&name);
|
||||
let cell = Self::title_confirmation_cell(title.as_deref());
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||
tx.send(AppEvent::SetTitle(title));
|
||||
}),
|
||||
);
|
||||
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
@@ -7306,6 +7337,18 @@ impl ChatWidget {
|
||||
PlainHistoryCell::new(vec![line.into()])
|
||||
}
|
||||
|
||||
fn title_confirmation_cell(title: Option<&str>) -> PlainHistoryCell {
|
||||
let line = match title {
|
||||
Some(title) => vec![
|
||||
"• ".into(),
|
||||
"Title set to ".into(),
|
||||
title.to_string().cyan(),
|
||||
],
|
||||
None => vec!["• ".into(), "Title cleared".into()],
|
||||
};
|
||||
PlainHistoryCell::new(vec![line.into()])
|
||||
}
|
||||
|
||||
pub(crate) fn add_mcp_output(&mut self) {
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
|
||||
self.config.codex_home.clone(),
|
||||
@@ -7984,6 +8027,18 @@ impl ChatWidget {
|
||||
self.thread_name.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn title_override(&self) -> Option<String> {
|
||||
self.title_override.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_title_override(&mut self, title: Option<String>) {
|
||||
self.title_override = title;
|
||||
}
|
||||
|
||||
pub(crate) fn is_task_running(&self) -> bool {
|
||||
self.bottom_pane.is_task_running()
|
||||
}
|
||||
|
||||
/// Returns the current thread's precomputed rollout path.
|
||||
///
|
||||
/// For fresh non-ephemeral threads this path may exist before the file is
|
||||
|
||||
@@ -1715,6 +1715,7 @@ async fn make_chatwidget_manual(
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
title_override: None,
|
||||
forked_from: None,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
@@ -1754,6 +1755,41 @@ async fn make_chatwidget_manual(
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn title_command_sets_manual_title_without_renaming_thread() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Title, "manual title".to_string(), Vec::new());
|
||||
|
||||
assert_eq!(chat.title_override(), Some("manual title".to_string()));
|
||||
assert_eq!(chat.thread_name(), None);
|
||||
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(op, Op::SetThreadName { .. }),
|
||||
"unexpected rename op: {op:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_title_command_clears_manual_title() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_title_override(Some("manual title".to_string()));
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Title, String::new(), Vec::new());
|
||||
|
||||
assert_eq!(chat.title_override(), None);
|
||||
assert_eq!(chat.thread_name(), None);
|
||||
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(op, Op::SetThreadName { .. }),
|
||||
"unexpected rename op: {op:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper
|
||||
// filters until we see a submission op.
|
||||
fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub enum SlashCommand {
|
||||
Skills,
|
||||
Review,
|
||||
Rename,
|
||||
Title,
|
||||
New,
|
||||
Resume,
|
||||
Fork,
|
||||
@@ -72,6 +73,7 @@ impl SlashCommand {
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Rename => "rename the current thread",
|
||||
SlashCommand::Title => "set the terminal title",
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Clear => "clear the terminal and start a new chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
@@ -124,6 +126,7 @@ impl SlashCommand {
|
||||
self,
|
||||
SlashCommand::Review
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Title
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::SandboxReadRoot
|
||||
@@ -156,6 +159,7 @@ impl SlashCommand {
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Title
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Status
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::future::Future;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::Write;
|
||||
use std::io::stdin;
|
||||
use std::io::stdout;
|
||||
use std::panic;
|
||||
@@ -58,6 +59,47 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME
|
||||
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
||||
const DEFAULT_TERMINAL_TITLE: &str = "Codex";
|
||||
|
||||
fn has_spinner_prefix(context: &str) -> bool {
|
||||
matches!(
|
||||
context.chars().next(),
|
||||
Some('⠋' | '⠙' | '⠹' | '⠸' | '⠼' | '⠴' | '⠦' | '⠧' | '⠇' | '⠏')
|
||||
)
|
||||
}
|
||||
|
||||
fn format_terminal_title(context: Option<&str>) -> String {
|
||||
let context = context
|
||||
.map(|text| {
|
||||
text.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|text| !text.is_empty());
|
||||
|
||||
match context {
|
||||
Some(context) if has_spinner_prefix(&context) => format!("{DEFAULT_TERMINAL_TITLE} {context}"),
|
||||
Some(context) => format!("{DEFAULT_TERMINAL_TITLE} - {context}"),
|
||||
None => DEFAULT_TERMINAL_TITLE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_terminal_title(
|
||||
writer: &mut impl Write,
|
||||
current_title: &mut Option<String>,
|
||||
context: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let title = format_terminal_title(context);
|
||||
if current_title.as_ref() == Some(&title) {
|
||||
return Ok(());
|
||||
}
|
||||
write!(writer, "\x1b]0;{title}\x07")?;
|
||||
writer.flush()?;
|
||||
*current_title = Some(title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_modes() -> Result<()> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
@@ -255,6 +297,7 @@ pub struct Tui {
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
|
||||
alt_screen_enabled: bool,
|
||||
current_title: Option<String>,
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
@@ -283,6 +326,7 @@ impl Tui {
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
||||
alt_screen_enabled: true,
|
||||
current_title: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +339,12 @@ impl Tui {
|
||||
self.notification_backend = Some(detect_backend(method));
|
||||
}
|
||||
|
||||
pub fn set_title_context(&mut self, context: Option<&str>) -> Result<()> {
|
||||
let current_title = &mut self.current_title;
|
||||
let backend = self.terminal.backend_mut();
|
||||
write_terminal_title(backend, current_title, context)
|
||||
}
|
||||
|
||||
pub fn frame_requester(&self) -> FrameRequester {
|
||||
self.frame_requester.clone()
|
||||
}
|
||||
@@ -544,3 +594,56 @@ impl Tui {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::DEFAULT_TERMINAL_TITLE;
|
||||
use super::format_terminal_title;
|
||||
use super::write_terminal_title;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn terminal_title_defaults_to_codex() {
|
||||
assert_eq!(format_terminal_title(None), DEFAULT_TERMINAL_TITLE);
|
||||
assert_eq!(format_terminal_title(Some(" ")), DEFAULT_TERMINAL_TITLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_includes_thread_name() {
|
||||
assert_eq!(
|
||||
format_terminal_title(Some("fix title syncing")),
|
||||
"Codex - fix title syncing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_places_spinner_after_codex() {
|
||||
assert_eq!(format_terminal_title(Some("⠋")), "Codex ⠋");
|
||||
assert_eq!(
|
||||
format_terminal_title(Some("⠋ - fix title syncing")),
|
||||
"Codex ⠋ - fix title syncing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_strips_control_characters() {
|
||||
assert_eq!(
|
||||
format_terminal_title(Some("hello\x1b\t\n\r\u{7}world")),
|
||||
"Codex - helloworld"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_title_write_is_deduplicated() {
|
||||
let mut output = Vec::new();
|
||||
let mut current_title = None;
|
||||
|
||||
write_terminal_title(&mut output, &mut current_title, Some("plan"))
|
||||
.expect("first title write should succeed");
|
||||
write_terminal_title(&mut output, &mut current_title, Some("plan"))
|
||||
.expect("duplicate title write should succeed");
|
||||
|
||||
assert_eq!(output, b"\x1b]0;Codex - plan\x07");
|
||||
assert_eq!(current_title, Some("Codex - plan".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user