mirror of
https://github.com/openai/codex.git
synced 2026-05-04 21:32:21 +03:00
feat(tui): add /title terminal title configuration (#12334)
## Problem When multiple Codex sessions are open at once, terminal tabs and windows are hard to distinguish from each other. The existing status line only helps once the TUI is already focused, so it does not solve the "which tab is this?" problem. This PR adds a first-class `/title` command so the terminal window or tab title can carry a short, configurable summary of the current session. ## Screenshot <img width="849" height="320" alt="image" src="https://github.com/user-attachments/assets/8b112927-7890-45ed-bb1e-adf2f584663d" /> ## Mental model `/statusline` and `/title` are separate status surfaces with different constraints. The status line is an in-app footer that can be denser and more detailed. The terminal title is external terminal metadata, so it needs short, stable segments that still make multiple sessions easy to tell apart. The `/title` configuration is an ordered list of compact items. By default it renders `spinner,project`, so active sessions show lightweight progress first while idle sessions still stay easy to disambiguate. Each configured item is omitted when its value is not currently available rather than forcing a placeholder. ## Non-goals This does not merge `/title` into `/statusline`, and it does not add an arbitrary free-form title string. The feature is intentionally limited to a small set of structured items so the title stays short and reviewable. This also does not attempt to restore whatever title the terminal or shell had before Codex started. When Codex clears the title, it clears the title Codex last wrote. ## Tradeoffs A separate `/title` command adds some conceptual overlap with `/statusline`, but it keeps title-specific constraints explicit instead of forcing the status line model to cover two different surfaces. Title refresh can happen frequently, so the implementation now shares parsing and git-branch orchestration between the status line and title paths, and caches the derived project-root name by cwd. That keeps the hot path cheap without introducing background polling. ## Architecture The TUI gets a new `/title` slash command and a dedicated picker UI for selecting and ordering terminal-title items. The chosen ids are persisted in `tui.terminal_title`, with `spinner` and `project` as the default when the config is unset. `status` remains available as a separate text item, so configurations like `spinner,status` render compact progress like `⠋ Working`. `ChatWidget` now refreshes both status surfaces through a shared `refresh_status_surfaces()` path. That shared path parses configured items once, warns on invalid ids once, synchronizes shared cached state such as git-branch lookup, then renders the footer status line and terminal title from the same snapshot. Low-level OSC title writes live in `codex-rs/tui/src/terminal_title.rs`, which owns the terminal write path and last-mile sanitization before emitting OSC 0. ## Security Terminal-title text is treated as untrusted display content before Codex emits it. The write path strips control characters, removes invisible and bidi formatting characters that can make the title visually misleading, normalizes whitespace, and caps the emitted length. References used while implementing this: - [xterm control sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) - [WezTerm escape sequences](https://wezterm.org/escape-sequences.html) - [CWE-150: Improper Neutralization of Escape, Meta, or Control Sequences](https://cwe.mitre.org/data/definitions/150.html) - [CERT VU#999008 (Trojan Source)](https://kb.cert.org/vuls/id/999008) - [Trojan Source disclosure site](https://trojansource.codes/) - [Unicode Bidirectional Algorithm (UAX #9)](https://www.unicode.org/reports/tr9/) - [Unicode Security Considerations (UTR #36)](https://www.unicode.org/reports/tr36/) ## Observability Unknown configured title item ids are warned about once instead of repeatedly spamming the transcript. Live preview applies immediately while the `/title` picker is open, and cancel rolls the in-memory title selection back to the pre-picker value. If terminal title writes fail, the TUI emits debug logs around set and clear attempts. The rendered status label intentionally collapses richer internal states into compact title text such as `Starting...`, `Ready`, `Thinking...`, `Working...`, `Waiting...`, and `Undoing...` when `status` is configured. ## Tests Ran: - `just fmt` - `cargo test -p codex-tui` At the moment, the red Windows `rust-ci` failures are due to existing `codex-core` `apply_patch_cli` stack-overflow tests that also reproduce on `main`. The `/title`-specific `codex-tui` suite is green.
This commit is contained in:
committed by
GitHub
parent
fe287ac467
commit
60cd0cf75e
@@ -49,6 +49,7 @@ mod request_user_input;
|
||||
mod status_line_setup;
|
||||
pub(crate) use app_link_view::AppLinkElicitationTarget;
|
||||
pub(crate) use app_link_view::AppLinkSuggestionType;
|
||||
mod title_setup;
|
||||
pub(crate) use app_link_view::AppLinkView;
|
||||
pub(crate) use app_link_view::AppLinkViewParams;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
@@ -100,6 +101,8 @@ pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
pub(crate) use status_line_setup::StatusLineItem;
|
||||
pub(crate) use status_line_setup::StatusLinePreviewData;
|
||||
pub(crate) use status_line_setup::StatusLineSetupView;
|
||||
pub(crate) use title_setup::TerminalTitleItem;
|
||||
pub(crate) use title_setup::TerminalTitleSetupView;
|
||||
mod paste_burst;
|
||||
mod pending_input_preview;
|
||||
mod pending_thread_approvals;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/title_setup.rs
|
||||
expression: "render_lines(&view, 84)"
|
||||
---
|
||||
|
||||
Configure Terminal Title
|
||||
Select which items to display in the terminal title.
|
||||
|
||||
Type to search
|
||||
>
|
||||
› [x] project Project name (falls back to current directory name)
|
||||
[x] spinner Animated task spinner (omitted while idle or when animations…
|
||||
[x] status Compact session status text (Ready, Working, Thinking)
|
||||
[x] thread Current thread title (omitted until available)
|
||||
[ ] app-name Codex app name
|
||||
[ ] git-branch Current Git branch (omitted when unavailable)
|
||||
[ ] model Current model name
|
||||
[ ] task-progress Latest task progress from update_plan (omitted until availab…
|
||||
|
||||
my-project ⠋ Working | Investigate flaky test
|
||||
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
|
||||
298
codex-rs/tui/src/bottom_pane/title_setup.rs
Normal file
298
codex-rs/tui/src/bottom_pane/title_setup.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! Terminal title configuration view for customizing the terminal window/tab title.
|
||||
//!
|
||||
//! This module provides an interactive picker for selecting which items appear
|
||||
//! in the terminal title. Users can:
|
||||
//!
|
||||
//! - Select items
|
||||
//! - Reorder items
|
||||
//! - Preview the rendered title
|
||||
|
||||
use itertools::Itertools;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::Display;
|
||||
use strum_macros::EnumIter;
|
||||
use strum_macros::EnumString;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
|
||||
use crate::bottom_pane::multi_select_picker::MultiSelectItem;
|
||||
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
/// Available items that can be displayed in the terminal title.
|
||||
#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Hash)]
|
||||
#[strum(serialize_all = "kebab_case")]
|
||||
pub(crate) enum TerminalTitleItem {
|
||||
/// Codex app name.
|
||||
AppName,
|
||||
/// Project root name, or a compact cwd fallback.
|
||||
Project,
|
||||
/// Animated task spinner while active.
|
||||
Spinner,
|
||||
/// Compact runtime status text.
|
||||
Status,
|
||||
/// Current thread title (if available).
|
||||
Thread,
|
||||
/// Current git branch (if available).
|
||||
GitBranch,
|
||||
/// Current model name.
|
||||
Model,
|
||||
/// Latest checklist task progress from `update_plan` (if available).
|
||||
TaskProgress,
|
||||
}
|
||||
|
||||
impl TerminalTitleItem {
|
||||
pub(crate) fn description(self) -> &'static str {
|
||||
match self {
|
||||
TerminalTitleItem::AppName => "Codex app name",
|
||||
TerminalTitleItem::Project => "Project name (falls back to current directory name)",
|
||||
TerminalTitleItem::Spinner => {
|
||||
"Animated task spinner (omitted while idle or when animations are off)"
|
||||
}
|
||||
TerminalTitleItem::Status => "Compact session status text (Ready, Working, Thinking)",
|
||||
TerminalTitleItem::Thread => "Current thread title (omitted until available)",
|
||||
TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",
|
||||
TerminalTitleItem::Model => "Current model name",
|
||||
TerminalTitleItem::TaskProgress => {
|
||||
"Latest task progress from update_plan (omitted until available)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Example text used when previewing the title picker.
|
||||
///
|
||||
/// These are illustrative sample values, not live data from the current
|
||||
/// session.
|
||||
pub(crate) fn preview_example(self) -> &'static str {
|
||||
match self {
|
||||
TerminalTitleItem::AppName => "codex",
|
||||
TerminalTitleItem::Project => "my-project",
|
||||
TerminalTitleItem::Spinner => "⠋",
|
||||
TerminalTitleItem::Status => "Working",
|
||||
TerminalTitleItem::Thread => "Investigate flaky test",
|
||||
TerminalTitleItem::GitBranch => "feat/awesome-feature",
|
||||
TerminalTitleItem::Model => "gpt-5.2-codex",
|
||||
TerminalTitleItem::TaskProgress => "Tasks 2/5",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn separator_from_previous(self, previous: Option<Self>) -> &'static str {
|
||||
match previous {
|
||||
None => "",
|
||||
Some(previous)
|
||||
if previous == TerminalTitleItem::Spinner || self == TerminalTitleItem::Spinner =>
|
||||
{
|
||||
" "
|
||||
}
|
||||
Some(_) => " | ",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>
|
||||
where
|
||||
T: AsRef<str>,
|
||||
{
|
||||
// Treat parsing as all-or-nothing so preview/confirm callbacks never emit
|
||||
// a partially interpreted ordering. Invalid ids are ignored when building
|
||||
// the picker, but once the user is interacting with the picker we only want
|
||||
// to persist or preview a fully valid selection.
|
||||
ids.map(|id| id.as_ref().parse::<TerminalTitleItem>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Interactive view for configuring terminal-title items.
|
||||
pub(crate) struct TerminalTitleSetupView {
|
||||
picker: MultiSelectPicker,
|
||||
}
|
||||
|
||||
impl TerminalTitleSetupView {
|
||||
/// Creates the terminal-title picker, preserving the configured item order first.
|
||||
///
|
||||
/// Unknown configured ids are skipped here instead of surfaced inline. The
|
||||
/// main TUI still warns about them when rendering the actual title, but the
|
||||
/// picker itself only exposes the selectable items it can meaningfully
|
||||
/// preview and persist.
|
||||
pub(crate) fn new(title_items: Option<&[String]>, app_event_tx: AppEventSender) -> Self {
|
||||
let selected_items = title_items
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|id| id.parse::<TerminalTitleItem>().ok())
|
||||
.unique()
|
||||
.collect_vec();
|
||||
let selected_set = selected_items
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let items = selected_items
|
||||
.into_iter()
|
||||
.map(|item| Self::title_select_item(item, /*enabled*/ true))
|
||||
.chain(
|
||||
TerminalTitleItem::iter()
|
||||
.filter(|item| !selected_set.contains(item))
|
||||
.map(|item| Self::title_select_item(item, /*enabled*/ false)),
|
||||
)
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
picker: MultiSelectPicker::builder(
|
||||
"Configure Terminal Title".to_string(),
|
||||
Some("Select which items to display in the terminal title.".to_string()),
|
||||
app_event_tx,
|
||||
)
|
||||
.instructions(vec![
|
||||
"Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel."
|
||||
.into(),
|
||||
])
|
||||
.items(items)
|
||||
.enable_ordering()
|
||||
.on_preview(|items| {
|
||||
let items = parse_terminal_title_items(
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| item.enabled)
|
||||
.map(|item| item.id.as_str()),
|
||||
)?;
|
||||
let mut preview = String::new();
|
||||
let mut previous = None;
|
||||
for item in items.iter().copied() {
|
||||
preview.push_str(item.separator_from_previous(previous));
|
||||
preview.push_str(item.preview_example());
|
||||
previous = Some(item);
|
||||
}
|
||||
if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Line::from(preview))
|
||||
}
|
||||
})
|
||||
.on_change(|items, app_event| {
|
||||
let Some(items) = parse_terminal_title_items(
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| item.enabled)
|
||||
.map(|item| item.id.as_str()),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
app_event.send(AppEvent::TerminalTitleSetupPreview { items });
|
||||
})
|
||||
.on_confirm(|ids, app_event| {
|
||||
let Some(items) = parse_terminal_title_items(ids.iter().map(String::as_str)) else {
|
||||
return;
|
||||
};
|
||||
app_event.send(AppEvent::TerminalTitleSetup { items });
|
||||
})
|
||||
.on_cancel(|app_event| {
|
||||
app_event.send(AppEvent::TerminalTitleSetupCancelled);
|
||||
})
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
fn title_select_item(item: TerminalTitleItem, enabled: bool) -> MultiSelectItem {
|
||||
MultiSelectItem {
|
||||
id: item.to_string(),
|
||||
name: item.to_string(),
|
||||
description: Some(item.description().to_string()),
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for TerminalTitleSetupView {
|
||||
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
|
||||
self.picker.handle_key_event(key_event);
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.picker.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.picker.close();
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for TerminalTitleSetupView {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.picker.render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.picker.desired_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn render_lines(view: &TerminalTitleSetupView, width: u16) -> String {
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let lines: Vec<String> = (0..area.height)
|
||||
.map(|row| {
|
||||
let mut line = String::new();
|
||||
for col in 0..area.width {
|
||||
let symbol = buf[(area.x + col, area.y + row)].symbol();
|
||||
if symbol.is_empty() {
|
||||
line.push(' ');
|
||||
} else {
|
||||
line.push_str(symbol);
|
||||
}
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_title_setup_popup() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let selected = [
|
||||
"project".to_string(),
|
||||
"spinner".to_string(),
|
||||
"status".to_string(),
|
||||
"thread".to_string(),
|
||||
];
|
||||
let view = TerminalTitleSetupView::new(Some(&selected), tx);
|
||||
assert_snapshot!("terminal_title_setup_basic", render_lines(&view, 84));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_terminal_title_items_preserves_order() {
|
||||
let items =
|
||||
parse_terminal_title_items(["project", "spinner", "status", "thread"].into_iter());
|
||||
assert_eq!(
|
||||
items,
|
||||
Some(vec![
|
||||
TerminalTitleItem::Project,
|
||||
TerminalTitleItem::Spinner,
|
||||
TerminalTitleItem::Status,
|
||||
TerminalTitleItem::Thread,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_terminal_title_items_rejects_invalid_ids() {
|
||||
let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter());
|
||||
assert_eq!(items, None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user