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:
Yaroslav Volovich
2026-03-19 19:26:36 +00:00
committed by GitHub
parent fe287ac467
commit 60cd0cf75e
17 changed files with 1867 additions and 294 deletions

View File

@@ -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;

View File

@@ -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.

View 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);
}
}