mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Adjust terminal title spinner formatting and clearing
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -121,7 +121,7 @@ const TITLE_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "
|
||||
/// 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(32);
|
||||
const TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppExitInfo {
|
||||
@@ -730,7 +730,10 @@ fn decorate_title_context(
|
||||
}
|
||||
|
||||
let frame = TITLE_SPINNER_FRAMES[tick as usize % TITLE_SPINNER_FRAMES.len()];
|
||||
Some(frame.to_string())
|
||||
match context {
|
||||
Some(context) => Some(format!("{frame} - {context}")),
|
||||
None => Some(frame.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_title_context(
|
||||
@@ -3812,12 +3815,13 @@ mod tests {
|
||||
fn decorate_title_context_adds_spinner_while_running() {
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Working".to_string()), true, 0),
|
||||
Some("⠋".to_string())
|
||||
Some("⠋ - Working".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
decorate_title_context(Some("Working".to_string()), true, 9),
|
||||
Some("⠏".to_string())
|
||||
Some("⠏ - Working".to_string())
|
||||
);
|
||||
assert_eq!(decorate_title_context(None, true, 0), Some("⠋".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -161,7 +161,7 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
SetTitle(String),
|
||||
SetTitle(Option<String>),
|
||||
|
||||
/// Apply rollback semantics to local transcript cells.
|
||||
///
|
||||
|
||||
@@ -3943,14 +3943,11 @@ impl ChatWidget {
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
SlashCommand::Title if !trimmed.is_empty() => {
|
||||
let Some(name) = codex_core::util::normalize_thread_name(trimmed) else {
|
||||
self.add_error_message("Title cannot be empty.".to_string());
|
||||
return;
|
||||
};
|
||||
let cell = Self::title_confirmation_cell(&name);
|
||||
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(name);
|
||||
self.set_title_override(title);
|
||||
self.request_redraw();
|
||||
}
|
||||
SlashCommand::Plan if !trimmed.is_empty() => {
|
||||
@@ -4051,18 +4048,13 @@ impl ChatWidget {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
"Set title".to_string(),
|
||||
"Type a title and press Enter".to_string(),
|
||||
"Type a title and press Enter. Leave blank to clear it.".to_string(),
|
||||
None,
|
||||
Box::new(move |name: String| {
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&name) else {
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event("Title cannot be empty.".to_string()),
|
||||
)));
|
||||
return;
|
||||
};
|
||||
let cell = Self::title_confirmation_cell(&name);
|
||||
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(name));
|
||||
tx.send(AppEvent::SetTitle(title));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -7345,10 +7337,16 @@ impl ChatWidget {
|
||||
PlainHistoryCell::new(vec![line.into()])
|
||||
}
|
||||
|
||||
fn title_confirmation_cell(name: &str) -> PlainHistoryCell {
|
||||
PlainHistoryCell::new(vec![
|
||||
vec!["• ".into(), "Title set to ".into(), name.to_string().cyan()].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) {
|
||||
@@ -8033,8 +8031,8 @@ impl ChatWidget {
|
||||
self.title_override.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_title_override(&mut self, title: String) {
|
||||
self.title_override = Some(title);
|
||||
pub(crate) fn set_title_override(&mut self, title: Option<String>) {
|
||||
self.title_override = title;
|
||||
}
|
||||
|
||||
pub(crate) fn is_task_running(&self) -> bool {
|
||||
|
||||
@@ -1772,6 +1772,24 @@ async fn title_command_sets_manual_title_without_renaming_thread() {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -61,6 +61,13 @@ pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME
|
||||
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| {
|
||||
@@ -73,6 +80,7 @@ fn format_terminal_title(context: Option<&str>) -> 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(),
|
||||
}
|
||||
@@ -608,6 +616,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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!(
|
||||
|
||||
Reference in New Issue
Block a user