Compare commits

..

4 Commits

Author SHA1 Message Date
Omer Strulovich
7269a2ef40 Adjust terminal title spinner formatting and clearing
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 16:30:44 -05:00
Omer Strulovich
95ca63a688 Load terminal title from thread name automatically
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 15:50:50 -05:00
Omer Strulovich
67b7db7684 Add terminal title spinner while running
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 15:50:43 -05:00
Omer Strulovich
d1e20cdcac Add /title terminal title override
Co-authored-by: Codex <noreply@openai.com>
2026-03-04 15:50:23 -05:00
3 changed files with 116 additions and 7 deletions

View File

@@ -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,12 +720,44 @@ fn normalize_harness_overrides_for_cwd(
Ok(overrides)
}
fn normalize_title_context(title_override: Option<String>) -> Option<String> {
title_override
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)
.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 {
@@ -1755,8 +1789,18 @@ impl App {
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
};
let title_context = normalize_title_context(app.chat_widget.title_override());
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")]
@@ -1856,8 +1900,18 @@ impl App {
AppRunControl::Continue
}
};
let title_context = normalize_title_context(app.chat_widget.title_override());
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,
@@ -3750,13 +3804,47 @@ mod tests {
}
#[test]
fn normalize_title_context_uses_manual_title_when_present() {
fn decorate_title_context_leaves_idle_titles_plain() {
assert_eq!(
normalize_title_context(Some("Named thread".to_string())),
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 =

View File

@@ -8035,6 +8035,10 @@ impl ChatWidget {
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

View File

@@ -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!(