Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Ibrahim
e6e74b2c08 Set TUI terminal title from one-shot summary 2026-01-07 00:40:24 -08:00
5 changed files with 134 additions and 0 deletions

View File

@@ -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<String> {
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::<Vec<_>>().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<ConversationManager>,
mut config: Config,
model: String,
request: String,
) -> Option<String> {
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<SkillErrorInfo> {
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)

View File

@@ -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<dyn HistoryCell>),
StartCommitAnimation,

View File

@@ -368,6 +368,7 @@ pub(crate) struct ChatWidget {
// Current session rollout path (if known)
current_rollout_path: Option<PathBuf>,
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 {

View File

@@ -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)
}

View File

@@ -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)
}