/statusline & /title - Shared preview values (#18435)

This PR makes the `/statusline` and `/title` setup UIs share one
preview-value source instead of each surface using its own examples.
Both pickers now render consistent live values when available, and
stable placeholders when they are not. It also resolves live preview
values at the shared preview-item layer, so `/title` preview can use
real runtime values for title-specific cases like status text, task
progress, and project-name fallback behavior.

- Adds a shared preview data model for status surfaces
- Maps status-line items and terminal-title items onto that shared
preview list
- Feeds both setup views from the same chatwidget-derived preview data,
with terminal-title-specific formatting applied before `/title` preview
renders
- Keeps project-root preview aligned with status-line behavior while
project in /title keeps its title fallback/truncation behavior
- Adds snapshot coverage for live-only, hardcoded-only, and mixed cases

Test Steps
- Open Codex TUI and launch `/statusline`.
- Toggle and reorder items, then verify the preview uses current session
values when possible, and placeholder values for missing values (ex: no
thread ID).
- Open `/title` and verify it shows the same normalized values,
including live status/task-progress values when available.
This commit is contained in:
canvrno-oai
2026-04-20 17:46:11 -07:00
committed by GitHub
Unverified
parent ca3246f77a
commit 9a2b34213b
19 changed files with 723 additions and 284 deletions
+5 -1
View File
@@ -49,6 +49,7 @@ mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
mod status_surface_preview;
mod title_setup;
pub(crate) use app_link_view::AppLinkElicitationTarget;
pub(crate) use app_link_view::AppLinkSuggestionType;
@@ -107,10 +108,13 @@ pub(crate) use feedback_view::feedback_upload_consent_params;
pub(crate) use skills_toggle_view::SkillsToggleItem;
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 status_surface_preview::StatusSurfacePreviewData;
pub(crate) use status_surface_preview::StatusSurfacePreviewItem;
pub(crate) use title_setup::TerminalTitleItem;
pub(crate) use title_setup::TerminalTitleSetupView;
#[cfg(test)]
pub(crate) use title_setup::preview_line_for_title_items;
mod paste_burst;
mod pending_input_preview;
mod pending_thread_approvals;
@@ -1,21 +0,0 @@
---
source: tui/src/bottom_pane/status_line_setup.rs
expression: "render_lines(&view, 72)"
---
Configure Status Line
Select which items to display in the status line.
Type to search
>
[x] model-name Current model name
[x] current-dir Current working directory
[x] git-branch Current Git branch (omitted when unavaila…
[ ] model-with-reasoning Current model name with reasoning level
[ ] project-root Project root directory (omitted when unav…
[ ] context-remaining Percentage of context window remaining (o…
[ ] context-used Percentage of context window used (omitte…
[ ] five-hour-limit Remaining usage on 5-hour usage limit (om…
gpt-5-codex · ~/codex-rs · jif/statusline-preview
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc
@@ -1,21 +0,0 @@
---
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.
+31 -166
View File
@@ -19,8 +19,6 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::collections::BTreeMap;
use std::collections::HashSet;
use strum::IntoEnumIterator;
use strum_macros::Display;
@@ -33,6 +31,8 @@ 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::bottom_pane::status_surface_preview::StatusSurfacePreviewData;
use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem;
use crate::render::renderable::Renderable;
/// Available items that can be displayed in the status line.
@@ -138,36 +138,26 @@ impl StatusLineItem {
StatusLineItem::ThreadTitle => "Current thread title (omitted unless changed by user)",
}
}
}
/// Runtime values used to preview the current status-line selection.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct StatusLinePreviewData {
values: BTreeMap<StatusLineItem, String>,
}
impl StatusLinePreviewData {
pub(crate) fn from_iter<I>(values: I) -> Self
where
I: IntoIterator<Item = (StatusLineItem, String)>,
{
Self {
values: values.into_iter().collect(),
}
}
fn line_for_items(&self, items: &[MultiSelectItem]) -> Option<Line<'static>> {
let preview = items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
.filter_map(|item| self.values.get(&item).cloned())
.collect::<Vec<_>>()
.join(" · ");
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
pub(crate) fn preview_item(self) -> StatusSurfacePreviewItem {
match self {
StatusLineItem::ModelName => StatusSurfacePreviewItem::Model,
StatusLineItem::ModelWithReasoning => StatusSurfacePreviewItem::ModelWithReasoning,
StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir,
StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot,
StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch,
StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining,
StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed,
StatusLineItem::FiveHourLimit => StatusSurfacePreviewItem::FiveHourLimit,
StatusLineItem::WeeklyLimit => StatusSurfacePreviewItem::WeeklyLimit,
StatusLineItem::CodexVersion => StatusSurfacePreviewItem::CodexVersion,
StatusLineItem::ContextWindowSize => StatusSurfacePreviewItem::ContextWindowSize,
StatusLineItem::UsedTokens => StatusSurfacePreviewItem::UsedTokens,
StatusLineItem::TotalInputTokens => StatusSurfacePreviewItem::TotalInputTokens,
StatusLineItem::TotalOutputTokens => StatusSurfacePreviewItem::TotalOutputTokens,
StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId,
StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode,
StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle,
}
}
}
@@ -197,7 +187,7 @@ impl StatusLineSetupView {
/// enabled. Remaining items are appended and marked as disabled.
pub(crate) fn new(
status_line_items: Option<&[String]>,
preview_data: StatusLinePreviewData,
preview_data: StatusSurfacePreviewData,
app_event_tx: AppEventSender,
) -> Self {
let mut used_ids = HashSet::new();
@@ -236,7 +226,15 @@ impl StatusLineSetupView {
])
.items(items)
.enable_ordering()
.on_preview(move |items| preview_data.line_for_items(items))
.on_preview(move |items| {
preview_data.line_for_items(
items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
.map(StatusLineItem::preview_item),
)
})
.on_confirm(|ids, app_event| {
let items = ids
.iter()
@@ -291,14 +289,7 @@ impl Renderable for StatusLineSetupView {
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event_sender::AppEventSender;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
use crate::app_event::AppEvent;
#[test]
fn context_used_accepts_context_usage_legacy_id() {
@@ -324,130 +315,4 @@ mod tests {
"context-remaining"
);
}
#[test]
fn preview_uses_runtime_values() {
let preview_data = StatusLinePreviewData::from_iter([
(StatusLineItem::ModelName, "gpt-5".to_string()),
(StatusLineItem::CurrentDir, "/repo".to_string()),
]);
let items = vec![
MultiSelectItem {
id: StatusLineItem::ModelName.to_string(),
name: String::new(),
description: None,
enabled: true,
},
MultiSelectItem {
id: StatusLineItem::CurrentDir.to_string(),
name: String::new(),
description: None,
enabled: true,
},
];
assert_eq!(
preview_data.line_for_items(&items),
Some(Line::from("gpt-5 · /repo"))
);
}
#[test]
fn preview_omits_items_without_runtime_values() {
let preview_data =
StatusLinePreviewData::from_iter([(StatusLineItem::ModelName, "gpt-5".to_string())]);
let items = vec![
MultiSelectItem {
id: StatusLineItem::ModelName.to_string(),
name: String::new(),
description: None,
enabled: true,
},
MultiSelectItem {
id: StatusLineItem::GitBranch.to_string(),
name: String::new(),
description: None,
enabled: true,
},
];
assert_eq!(
preview_data.line_for_items(&items),
Some(Line::from("gpt-5"))
);
}
#[test]
fn preview_includes_thread_title() {
let preview_data = StatusLinePreviewData::from_iter([
(StatusLineItem::ModelName, "gpt-5".to_string()),
(StatusLineItem::ThreadTitle, "Roadmap cleanup".to_string()),
]);
let items = vec![
MultiSelectItem {
id: StatusLineItem::ModelName.to_string(),
name: String::new(),
description: None,
enabled: true,
},
MultiSelectItem {
id: StatusLineItem::ThreadTitle.to_string(),
name: String::new(),
description: None,
enabled: true,
},
];
assert_eq!(
preview_data.line_for_items(&items),
Some(Line::from("gpt-5 · Roadmap cleanup"))
);
}
#[test]
fn setup_view_snapshot_uses_runtime_preview_values() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let view = StatusLineSetupView::new(
Some(&[
StatusLineItem::ModelName.to_string(),
StatusLineItem::CurrentDir.to_string(),
StatusLineItem::GitBranch.to_string(),
]),
StatusLinePreviewData::from_iter([
(StatusLineItem::ModelName, "gpt-5-codex".to_string()),
(StatusLineItem::CurrentDir, "~/codex-rs".to_string()),
(
StatusLineItem::GitBranch,
"jif/statusline-preview".to_string(),
),
(StatusLineItem::WeeklyLimit, "weekly 82%".to_string()),
]),
AppEventSender::new(tx_raw),
);
assert_snapshot!(render_lines(&view, /*width*/ 72));
}
fn render_lines(view: &StatusLineSetupView, 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);
(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::<Vec<_>>()
.join("\n")
}
}
@@ -0,0 +1,173 @@
use std::collections::BTreeMap;
use ratatui::text::Line;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub(crate) enum StatusSurfacePreviewItem {
AppName,
ProjectName,
ProjectRoot,
CurrentDir,
Status,
ThreadTitle,
GitBranch,
ContextRemaining,
ContextUsed,
FiveHourLimit,
WeeklyLimit,
CodexVersion,
ContextWindowSize,
UsedTokens,
TotalInputTokens,
TotalOutputTokens,
SessionId,
FastMode,
Model,
ModelWithReasoning,
TaskProgress,
}
impl StatusSurfacePreviewItem {
fn placeholder(self) -> &'static str {
match self {
StatusSurfacePreviewItem::AppName => "codex",
StatusSurfacePreviewItem::ProjectName => "my-project",
StatusSurfacePreviewItem::ProjectRoot => "my-project",
StatusSurfacePreviewItem::CurrentDir => "~/my-project/subdir",
StatusSurfacePreviewItem::Status => "Working",
StatusSurfacePreviewItem::ThreadTitle => "thread title",
StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature",
StatusSurfacePreviewItem::ContextRemaining => "Context 0% left",
StatusSurfacePreviewItem::ContextUsed => "Context 0% used",
StatusSurfacePreviewItem::FiveHourLimit => "5h 0%",
StatusSurfacePreviewItem::WeeklyLimit => "weekly 0%",
StatusSurfacePreviewItem::CodexVersion => "0.0.0",
StatusSurfacePreviewItem::ContextWindowSize => "0 window",
StatusSurfacePreviewItem::UsedTokens => "0 used",
StatusSurfacePreviewItem::TotalInputTokens => "0 in",
StatusSurfacePreviewItem::TotalOutputTokens => "0 out",
StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4",
StatusSurfacePreviewItem::FastMode => "Fast on",
StatusSurfacePreviewItem::Model => "gpt-5.2-codex",
StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium",
StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0",
}
}
pub(crate) fn iter() -> impl Iterator<Item = Self> {
[
Self::AppName,
Self::ProjectName,
Self::ProjectRoot,
Self::CurrentDir,
Self::Status,
Self::ThreadTitle,
Self::GitBranch,
Self::ContextRemaining,
Self::ContextUsed,
Self::FiveHourLimit,
Self::WeeklyLimit,
Self::CodexVersion,
Self::ContextWindowSize,
Self::UsedTokens,
Self::TotalInputTokens,
Self::TotalOutputTokens,
Self::SessionId,
Self::FastMode,
Self::Model,
Self::ModelWithReasoning,
Self::TaskProgress,
]
.into_iter()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct PreviewValue {
text: String,
is_placeholder: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct StatusSurfacePreviewData {
values: BTreeMap<StatusSurfacePreviewItem, PreviewValue>,
}
impl Default for StatusSurfacePreviewData {
fn default() -> Self {
let mut data = Self {
values: BTreeMap::new(),
};
for item in StatusSurfacePreviewItem::iter() {
data.set_placeholder(item, item.placeholder());
}
data
}
}
impl StatusSurfacePreviewData {
pub(crate) fn from_iter<I, V>(values: I) -> Self
where
I: IntoIterator<Item = (StatusSurfacePreviewItem, V)>,
V: Into<String>,
{
let mut data = Self::default();
for (item, value) in values {
data.set_live(item, value);
}
data
}
pub(crate) fn set_live<V>(&mut self, item: StatusSurfacePreviewItem, value: V)
where
V: Into<String>,
{
self.values.insert(
item,
PreviewValue {
text: value.into(),
is_placeholder: false,
},
);
}
pub(crate) fn set_placeholder<V>(&mut self, item: StatusSurfacePreviewItem, value: V)
where
V: Into<String>,
{
if self
.values
.get(&item)
.is_some_and(|value| !value.is_placeholder)
{
return;
}
self.values.insert(
item,
PreviewValue {
text: value.into(),
is_placeholder: true,
},
);
}
pub(crate) fn value_for(&self, item: StatusSurfacePreviewItem) -> Option<&str> {
self.values.get(&item).map(|value| value.text.as_str())
}
pub(crate) fn line_for_items<I>(&self, items: I) -> Option<Line<'static>>
where
I: IntoIterator<Item = StatusSurfacePreviewItem>,
{
let preview = items
.into_iter()
.filter_map(|item| self.value_for(item))
.collect::<Vec<_>>()
.join(" · ");
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
}
}
+51 -69
View File
@@ -22,6 +22,8 @@ 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::bottom_pane::status_surface_preview::StatusSurfacePreviewData;
use crate::bottom_pane::status_surface_preview::StatusSurfacePreviewItem;
use crate::render::renderable::Renderable;
/// Available items that can be displayed in the terminal title.
@@ -68,20 +70,16 @@ impl TerminalTitleItem {
}
}
/// 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 {
pub(crate) fn preview_item(self) -> Option<StatusSurfacePreviewItem> {
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",
TerminalTitleItem::AppName => Some(StatusSurfacePreviewItem::AppName),
TerminalTitleItem::Project => Some(StatusSurfacePreviewItem::ProjectName),
TerminalTitleItem::Spinner => None,
TerminalTitleItem::Status => Some(StatusSurfacePreviewItem::Status),
TerminalTitleItem::Thread => Some(StatusSurfacePreviewItem::ThreadTitle),
TerminalTitleItem::GitBranch => Some(StatusSurfacePreviewItem::GitBranch),
TerminalTitleItem::Model => Some(StatusSurfacePreviewItem::Model),
TerminalTitleItem::TaskProgress => Some(StatusSurfacePreviewItem::TaskProgress),
}
}
@@ -103,6 +101,39 @@ impl TerminalTitleItem {
}
}
pub(crate) fn preview_line_for_title_items(
items: &[TerminalTitleItem],
preview_data: &StatusSurfacePreviewData,
) -> Option<Line<'static>> {
let mut previous = None;
let preview = items
.iter()
.copied()
.fold(String::new(), |mut preview, item| {
if item == TerminalTitleItem::Spinner {
preview.push_str(item.separator_from_previous(previous));
preview.push('⠋');
previous = Some(item);
return preview;
}
let Some(value) = item
.preview_item()
.and_then(|preview_item| preview_data.value_for(preview_item))
else {
return preview;
};
preview.push_str(item.separator_from_previous(previous));
preview.push_str(value);
previous = Some(item);
preview
});
if preview.is_empty() {
None
} else {
Some(Line::from(preview))
}
}
fn parse_terminal_title_items<T>(ids: impl Iterator<Item = T>) -> Option<Vec<TerminalTitleItem>>
where
T: AsRef<str>,
@@ -128,7 +159,11 @@ impl TerminalTitleSetupView {
/// 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 {
pub(crate) fn new(
title_items: Option<&[String]>,
preview_data: StatusSurfacePreviewData,
app_event_tx: AppEventSender,
) -> Self {
let selected_items = title_items
.into_iter()
.flatten()
@@ -161,25 +196,14 @@ impl TerminalTitleSetupView {
])
.items(items)
.enable_ordering()
.on_preview(|items| {
.on_preview(move |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))
}
preview_line_for_title_items(&items, &preview_data)
})
.on_change(|items, app_event| {
let Some(items) = parse_terminal_title_items(
@@ -243,49 +267,7 @@ impl Renderable for TerminalTitleSetupView {
#[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, /*width*/ 84)
);
}
#[test]
fn parse_terminal_title_items_preserves_order() {
+32 -5
View File
@@ -52,8 +52,9 @@ use crate::app_server_session::ThreadSessionState;
#[cfg(not(target_os = "linux"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLinePreviewData;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::StatusSurfacePreviewData;
use crate::bottom_pane::StatusSurfacePreviewItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME;
@@ -7595,10 +7596,7 @@ impl ChatWidget {
let configured_status_line_items = self.configured_status_line_items();
let view = StatusLineSetupView::new(
Some(configured_status_line_items.as_slice()),
StatusLinePreviewData::from_iter(StatusLineItem::iter().filter_map(|item| {
self.status_line_value_for_item(&item)
.map(|value| (item, value))
})),
self.status_surface_preview_data(),
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
@@ -7609,11 +7607,40 @@ impl ChatWidget {
self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone());
let view = TerminalTitleSetupView::new(
Some(configured_terminal_title_items.as_slice()),
self.terminal_title_preview_data(),
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
}
fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData {
StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| {
self.status_surface_preview_value_for_item(item)
.map(|value| (item, value))
}))
}
fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData {
let mut preview_data = self.status_surface_preview_data();
let now = Instant::now();
for item in [
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::GitBranch,
TerminalTitleItem::Model,
TerminalTitleItem::TaskProgress,
] {
let Some(preview_item) = item.preview_item() else {
continue;
};
let Some(value) = self.terminal_title_value_for_item(item, now) else {
continue;
};
preview_data.set_live(preview_item, value);
}
preview_data
}
fn open_theme_picker(&mut self) {
let codex_home = crate::legacy_core::config::find_codex_home().ok();
let terminal_width = self
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: status_line_popup_snapshot(&mut chat)
---
Configure Status Line
Select which items to display in the status line.
Type to search
>
[x] project-root Project root directory (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted unless changed by user)
[ ] model-name Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
[ ] context-remaining Percentage of context window remaining (omitted when unknown)
[ ] context-used Percentage of context window used (omitted when unknown)
my-project · feat/awesome-feature · thread title
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: status_line_popup_snapshot(&mut chat)
---
Configure Status Line
Select which items to display in the status line.
Type to search
>
[x] project-root Project root directory (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted unless changed by user)
[ ] model-name Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
[ ] context-remaining Percentage of context window remaining (omitted when unknown)
[ ] context-used Percentage of context window used (omitted when unknown)
preview-live-root · feature/live-preview-branch · Live preview thread
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: status_line_popup_snapshot(&mut chat)
---
Configure Status Line
Select which items to display in the status line.
Type to search
>
[x] project-root Project root directory (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted unless changed by user)
[ ] model-name Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
[ ] context-remaining Percentage of context window remaining (omitted when unknown)
[ ] context-used Percentage of context window used (omitted when unknown)
my-project · feature/mixed-preview · Mixed preview thread
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: snapshot
---
status line: my-project · feat/awesome-feature · thread title
terminal title: thread title | feat/awesome-feature | Tasks 0/0
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: snapshot
---
status line: preview-live-root · feature/live-preview-branch · Live preview thread
terminal title: preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: snapshot
---
status line: my-project · feature/mixed-preview · Mixed preview thread
terminal title: project | Mixed preview thread | Tasks 0/0
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: terminal_title_popup_snapshot(&mut chat)
---
Configure Terminal Title
Select which items to display in the terminal title.
Type to search
>
[x] thread Current thread title (omitted until available)
[x] git-branch Current Git branch (omitted when unavailable)
[x] task-progress Latest task progress from update_plan (omitted until available)
[ ] app-name Codex app name
[ ] project Project name (falls back to current directory name)
[ ] spinner Animated task spinner (omitted while idle or when animations are off)
[ ] status Compact session status text (Ready, Working, Thinking)
[ ] model Current model name
thread title | feat/awesome-feature | Tasks 0/0
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: terminal_title_popup_snapshot(&mut chat)
---
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] thread Current thread title (omitted until available)
[x] git-branch Current Git branch (omitted when unavailable)
[x] task-progress Latest task progress from update_plan (omitted until available)
[ ] app-name Codex app name
[ ] spinner Animated task spinner (omitted while idle or when animations are off)
[ ] status Compact session status text (Ready, Working, Thinking)
[ ] model Current model name
preview-live-root | Live preview thread | feature/live-preview-branch | Tasks 2/5
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: terminal_title_popup_snapshot(&mut chat)
---
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] thread Current thread title (omitted until available)
[x] task-progress Latest task progress from update_plan (omitted until available)
[ ] app-name Codex app name
[ ] spinner Animated task spinner (omitted while idle or when animations are off)
[ ] status Compact session status text (Ready, Working, Thinking)
[ ] git-branch Current Git branch (omitted when unavailable)
[ ] model Current model name
project | Mixed preview thread | Tasks 0/0
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.
+31 -1
View File
@@ -505,11 +505,41 @@ impl ChatWidget {
}
}
pub(super) fn status_surface_preview_value_for_item(
&mut self,
item: StatusSurfacePreviewItem,
) -> Option<String> {
let status_line_item = match item {
StatusSurfacePreviewItem::AppName => return Some("codex".to_string()),
StatusSurfacePreviewItem::ProjectName => return self.terminal_title_project_name(),
StatusSurfacePreviewItem::ProjectRoot => StatusLineItem::ProjectRoot,
StatusSurfacePreviewItem::Status => return Some(self.terminal_title_status_text()),
StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(),
StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir,
StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle,
StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch,
StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining,
StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed,
StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit,
StatusSurfacePreviewItem::WeeklyLimit => StatusLineItem::WeeklyLimit,
StatusSurfacePreviewItem::CodexVersion => StatusLineItem::CodexVersion,
StatusSurfacePreviewItem::ContextWindowSize => StatusLineItem::ContextWindowSize,
StatusSurfacePreviewItem::UsedTokens => StatusLineItem::UsedTokens,
StatusSurfacePreviewItem::TotalInputTokens => StatusLineItem::TotalInputTokens,
StatusSurfacePreviewItem::TotalOutputTokens => StatusLineItem::TotalOutputTokens,
StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId,
StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode,
StatusSurfacePreviewItem::Model => StatusLineItem::ModelName,
StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning,
};
self.status_line_value_for_item(&status_line_item)
}
/// Resolves one configured terminal-title item into a displayable segment.
///
/// Returning `None` means "omit this segment for now" so callers can keep
/// the configured order while hiding values that are not yet available.
fn terminal_title_value_for_item(
pub(super) fn terminal_title_value_for_item(
&mut self,
item: TerminalTitleItem,
now: Instant,
+1
View File
@@ -276,6 +276,7 @@ mod side;
mod slash_commands;
mod status_and_layout;
mod status_command_tests;
mod status_surface_previews;
pub(crate) use helpers::make_chatwidget_manual_with_sender;
pub(crate) use helpers::set_chatgpt_auth;
@@ -0,0 +1,261 @@
use super::*;
use crate::bottom_pane::preview_line_for_title_items;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
fn line_text(line: Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect()
}
fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String {
let preview_data = chat.status_surface_preview_data();
let preview = preview_data
.line_for_items(items.iter().cloned().map(StatusLineItem::preview_item))
.expect("status preview line");
line_text(preview)
}
fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> String {
let preview_data = chat.terminal_title_preview_data();
let preview =
preview_line_for_title_items(items, &preview_data).expect("terminal title preview line");
line_text(preview)
}
fn combined_preview_snapshot(
chat: &mut ChatWidget,
status_items: &[StatusLineItem],
title_items: &[TerminalTitleItem],
) -> String {
normalize_snapshot_paths(format!(
"status line: {}\nterminal title: {}",
status_preview_line(chat, status_items),
title_preview_line(chat, title_items),
))
}
fn status_line_popup_snapshot(chat: &mut ChatWidget) -> String {
chat.open_status_line_setup();
normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup(
chat, /*width*/ 100,
)))
}
fn terminal_title_popup_snapshot(chat: &mut ChatWidget) -> String {
chat.open_terminal_title_setup();
normalize_snapshot_paths(strip_osc8_for_snapshot(&render_bottom_popup(
chat, /*width*/ 100,
)))
}
fn cache_project_root(chat: &mut ChatWidget, root_name: &str) {
chat.status_line_project_root_name_cache = Some(CachedProjectRootName {
cwd: chat.config.cwd.to_path_buf(),
root_name: Some(root_name.to_string()),
});
}
#[tokio::test]
async fn status_surface_preview_lines_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.last_plan_progress = Some((2, 5));
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::GitBranch,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_live_only", snapshot);
}
#[tokio::test]
async fn status_line_setup_popup_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.config.tui_status_line = Some(vec![
"project-root".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_live_only",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_surface_preview_lines_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Thread,
TerminalTitleItem::GitBranch,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_hardcoded_only", snapshot);
}
#[tokio::test]
async fn status_line_setup_popup_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec![
"project-root".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_hardcoded_only",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn status_surface_preview_lines_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
let snapshot = combined_preview_snapshot(
&mut chat,
&[
StatusLineItem::ProjectRoot,
StatusLineItem::GitBranch,
StatusLineItem::ThreadTitle,
],
&[
TerminalTitleItem::Project,
TerminalTitleItem::Thread,
TerminalTitleItem::TaskProgress,
],
);
assert_chatwidget_snapshot!("status_surface_previews_mixed", snapshot);
}
#[tokio::test]
async fn status_line_setup_popup_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_branch = Some("feature/mixed-preview".to_string());
chat.thread_name = Some("Mixed preview thread".to_string());
chat.config.tui_status_line = Some(vec![
"project-root".to_string(),
"git-branch".to_string(),
"thread-title".to_string(),
]);
assert_chatwidget_snapshot!(
"status_line_setup_popup_mixed",
status_line_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_live_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
cache_project_root(&mut chat, "preview-live-root");
chat.status_line_branch = Some("feature/live-preview-branch".to_string());
chat.thread_name = Some("Live preview thread".to_string());
chat.last_plan_progress = Some((2, 5));
chat.config.tui_terminal_title = Some(vec![
"project".to_string(),
"thread".to_string(),
"git-branch".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_live_only",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_hardcoded_only_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_terminal_title = Some(vec![
"thread".to_string(),
"git-branch".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_hardcoded_only",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn terminal_title_setup_popup_mixed_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_name = Some("Mixed preview thread".to_string());
chat.config.tui_terminal_title = Some(vec![
"project".to_string(),
"thread".to_string(),
"task-progress".to_string(),
]);
assert_chatwidget_snapshot!(
"terminal_title_setup_popup_mixed",
terminal_title_popup_snapshot(&mut chat)
);
}
#[tokio::test]
async fn missing_project_root_uses_different_status_and_title_preview_sources() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let status_preview = status_preview_line(&mut chat, &[StatusLineItem::ProjectRoot]);
let title_preview = title_preview_line(&mut chat, &[TerminalTitleItem::Project]);
assert_eq!(status_preview, "my-project");
assert_eq!(title_preview, "project");
}
#[tokio::test]
async fn terminal_title_preview_uses_title_truncation_for_live_values() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let long_thread = "This thread title is intentionally much longer than forty-eight characters";
let long_branch = "feature/this-branch-name-is-intentionally-longer-than-thirty-two";
chat.thread_name = Some(long_thread.to_string());
chat.status_line_branch = Some(long_branch.to_string());
let preview = title_preview_line(
&mut chat,
&[TerminalTitleItem::Thread, TerminalTitleItem::GitBranch],
);
let truncated_thread =
ChatWidget::truncate_terminal_title_part(long_thread.to_string(), /*max_chars*/ 48);
let truncated_branch =
ChatWidget::truncate_terminal_title_part(long_branch.to_string(), /*max_chars*/ 32);
assert_eq!(preview, format!("{truncated_thread} | {truncated_branch}"));
}