diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d76a5b40392..f5f41286eb2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -42,6 +42,7 @@ use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::user_input::UserInput; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -66,6 +67,8 @@ use tokio::sync::mpsc::unbounded_channel; use crate::history_cell::UpdateAvailableHistoryCell; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; +const TERMINAL_TITLE_INSTRUCTIONS: &str = + "Generate a short title (max 4 words) for the request. Respond with the title only."; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -91,6 +94,72 @@ fn session_summary( }) } +fn normalize_terminal_title(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let first_line = trimmed.lines().next().unwrap_or(trimmed); + let stripped = first_line.trim_matches(|ch| matches!(ch, '"' | '\'' | '`')); + let words: Vec<&str> = stripped.split_whitespace().collect(); + if words.is_empty() { + return None; + } + let title = words.into_iter().take(4).collect::>().join(" "); + let title = title.trim_matches(|ch| matches!(ch, '"' | '\'' | '`')); + if title.is_empty() { + None + } else { + Some(title.to_string()) + } +} + +async fn generate_terminal_title( + server: Arc, + mut config: Config, + model: String, + request: String, +) -> Option { + config.model = Some(model); + config.base_instructions = Some(TERMINAL_TITLE_INSTRUCTIONS.to_string()); + config.user_instructions = None; + config.project_doc_max_bytes = 0; + + let new_conversation = server.new_conversation(config).await.ok()?; + let conversation_id = new_conversation.conversation_id; + let conversation = new_conversation.conversation; + + let prompt = format!( + "Create a concise title (max 4 words) for this request. Respond with only the title.\n\nRequest:\n{request}" + ); + conversation + .submit(Op::UserInput { + items: vec![UserInput::Text { text: prompt }], + final_output_json_schema: None, + }) + .await + .ok()?; + + let mut output = None; + loop { + let event = conversation.next_event().await.ok()?; + match event.msg { + EventMsg::TaskComplete(task_complete) => { + output = task_complete.last_agent_message; + break; + } + EventMsg::TurnAborted(_) => break, + _ => {} + } + } + + let _ = conversation.submit(Op::Shutdown).await; + let _ = server.remove_conversation(&conversation_id).await; + + output.and_then(|title| normalize_terminal_title(&title)) +} + fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { response .skills @@ -711,6 +780,24 @@ impl App { AppEvent::CommitTick => { self.chat_widget.on_commit_tick(); } + AppEvent::GenerateTerminalTitle { request } => { + let server = self.server.clone(); + let config = self.config.clone(); + let model = self.current_model.clone(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + if let Some(title) = + generate_terminal_title(server, config, model, request).await + { + tx.send(AppEvent::SetTerminalTitle(title)); + } + }); + } + AppEvent::SetTerminalTitle(title) => { + if let Err(err) = tui.set_terminal_title(&title) { + tracing::warn!("failed to set terminal title: {err}"); + } + } AppEvent::CodexEvent(event) => { if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1f99e372e97..33f500a1103 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -52,6 +52,14 @@ pub(crate) enum AppEvent { /// Result of computing a `/diff` command. DiffResult(String), + /// Generate a short terminal title from the initial user request. + GenerateTerminalTitle { + request: String, + }, + + /// Set the terminal title to the provided text. + SetTerminalTitle(String), + InsertHistoryCell(Box), StartCommitAnimation, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3535a1d0551..f4ec57f73a6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -368,6 +368,7 @@ pub(crate) struct ChatWidget { // Current session rollout path (if known) current_rollout_path: Option, external_editor_state: ExternalEditorState, + terminal_title_requested: bool, } struct UserMessage { @@ -1478,6 +1479,7 @@ impl ChatWidget { feedback, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, + terminal_title_requested: false, }; widget.prefetch_rate_limits(); @@ -1564,6 +1566,7 @@ impl ChatWidget { feedback, current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, + terminal_title_requested: false, }; widget.prefetch_rate_limits(); @@ -1944,6 +1947,13 @@ impl ChatWidget { items.push(UserInput::LocalImage { path }); } + if !self.terminal_title_requested && !text.is_empty() { + self.terminal_title_requested = true; + self.app_event_tx.send(AppEvent::GenerateTerminalTitle { + request: text.clone(), + }); + } + if let Some(skills) = self.bottom_pane.skills() { let skill_mentions = find_skill_mentions(&text, skills); for skill in skill_mentions { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f7af95b32fa..40a420bd634 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -408,6 +408,7 @@ async fn make_chatwidget_manual( feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, external_editor_state: ExternalEditorState::Closed, + terminal_title_requested: false, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index ff9ce94d24f..ac9fb1f1e13 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -120,6 +120,27 @@ impl Command for DisableAlternateScroll { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SetTerminalTitle(pub String); + +impl Command for SetTerminalTitle { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b]0;{}\x07", self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> Result<()> { + Err(std::io::Error::other( + "tried to execute SetTerminalTitle using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + fn restore_common(should_disable_raw_mode: bool) -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); @@ -285,6 +306,13 @@ impl Tui { self.enhanced_keys_supported } + pub fn set_terminal_title(&mut self, title: &str) -> Result<()> { + execute!( + self.terminal.backend_mut(), + SetTerminalTitle(title.to_string()) + ) + } + pub fn is_alt_screen_active(&self) -> bool { self.alt_screen_active.load(Ordering::Relaxed) }