Merge 4565f6dbb6 into sapling-pr-archive-rhan-oai

This commit is contained in:
rhan-oai
2026-04-20 17:52:17 -07:00
committed by GitHub
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;
@@ -7501,10 +7502,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));
@@ -7515,11 +7513,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}"));
}