Skip to content

Commit 2202675

Browse files
authored
Normalize /statusline & /title items (#18886)
This change aligns the `/statusline` and `/title` UIs around the same normalized item model so both surfaces use consistent ids, labels, and preview semantics. It keeps the shared preview work from #18435 , tightens the remaining mismatches by standardizing item naming, expands title/status item coverage where appropriate, and makes `/title` preview use the same title-specific formatting path as the real rendered terminal title. - Normalizes persisted item ids and keeps legacy aliases for compatibility - Aligns `status-line` and `terminal-title` items with the shared preview model - Routes `terminal-title` preview through title-specific formatting and truncation - Updates the affected status/title setup snapshots Added to `/statusline`: - status - task-progress Normalized in `/statusline`: - model-name -> model - project-root -> project-name Added to `/title`: - current-dir - context-remaining - context-used - five-hour-limit - weekly-limit - codex-version - used-tokens - total-input-tokens - total-output-tokens - session-id - fast-mode - model-with-reasoning Normalized in `/title`: - project -> project-name - thread -> thread-title - model-name -> model
1 parent ef00014 commit 2202675

9 files changed

Lines changed: 570 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
source: tui/src/bottom_pane/status_line_setup.rs
3+
expression: "render_lines(&view, 72)"
4+
---
5+
6+
Configure Status Line
7+
Select which items to display in the status line.
8+
9+
Type to search
10+
>
11+
› [x] model Current model name
12+
[x] current-dir Current working directory
13+
[x] git-branch Current Git branch (omitted when unavaila
14+
[ ] model-with-reasoning Current model name with reasoning level
15+
[ ] project-name Project name (omitted when unavailable)
16+
[ ] run-state Compact session run-state text (Ready, Wo
17+
[ ] context-remaining Percentage of context window remaining (o
18+
[ ] context-used Percentage of context window used (omitte
19+
20+
gpt-5-codex · ~/codex-rs · jif/statusline-preview
21+
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
source: tui/src/bottom_pane/title_setup.rs
3+
expression: "render_lines(&view, 84)"
4+
---
5+
6+
Configure Terminal Title
7+
Select which items to display in the terminal title.
8+
9+
Type to search
10+
>
11+
› [x] project-name Project name (falls back to current directory name)
12+
[x] spinner Animated task spinner (omitted while idle or when animat
13+
[x] run-state Compact session run-state text (Ready, Working, Thinking)
14+
[x] thread-title Current thread title (omitted when unavailable)
15+
[ ] app-name Codex app name
16+
[ ] current-dir Current working directory
17+
[ ] git-branch Current Git branch (omitted when unavailable)
18+
[ ] context-remaining Percentage of context window remaining (omitted when unk
19+
20+
my-projectWorking | thread title
21+
Use ↑↓ to navigate, ←→ to move, space to select, enter to confirm, esc to cancel.

codex-rs/tui/src/bottom_pane/status_line_setup.rs

Lines changed: 247 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ use crate::render::renderable::Renderable;
4949
#[strum(serialize_all = "kebab_case")]
5050
pub(crate) enum StatusLineItem {
5151
/// The current model name.
52+
#[strum(to_string = "model", serialize = "model-name")]
5253
ModelName,
5354

5455
/// Model name with reasoning level suffix.
@@ -58,11 +59,20 @@ pub(crate) enum StatusLineItem {
5859
CurrentDir,
5960

6061
/// Project root directory (if detected).
62+
#[strum(
63+
to_string = "project-name",
64+
serialize = "project",
65+
serialize = "project-root"
66+
)]
6167
ProjectRoot,
6268

6369
/// Current git branch name (if in a repository).
6470
GitBranch,
6571

72+
/// Compact runtime run-state text.
73+
#[strum(to_string = "run-state", serialize = "status")]
74+
Status,
75+
6676
/// Percentage of context window remaining.
6777
ContextRemaining,
6878

@@ -101,6 +111,9 @@ pub(crate) enum StatusLineItem {
101111

102112
/// Current thread title (if set by user).
103113
ThreadTitle,
114+
115+
/// Latest checklist task progress from `update_plan` (if available).
116+
TaskProgress,
104117
}
105118

106119
impl StatusLineItem {
@@ -110,8 +123,9 @@ impl StatusLineItem {
110123
StatusLineItem::ModelName => "Current model name",
111124
StatusLineItem::ModelWithReasoning => "Current model name with reasoning level",
112125
StatusLineItem::CurrentDir => "Current working directory",
113-
StatusLineItem::ProjectRoot => "Project root directory (omitted when unavailable)",
126+
StatusLineItem::ProjectRoot => "Project name (omitted when unavailable)",
114127
StatusLineItem::GitBranch => "Current Git branch (omitted when unavailable)",
128+
StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)",
115129
StatusLineItem::ContextRemaining => {
116130
"Percentage of context window remaining (omitted when unknown)"
117131
}
@@ -135,7 +149,10 @@ impl StatusLineItem {
135149
"Current session identifier (omitted until session starts)"
136150
}
137151
StatusLineItem::FastMode => "Whether Fast mode is currently active",
138-
StatusLineItem::ThreadTitle => "Current thread title (omitted unless changed by user)",
152+
StatusLineItem::ThreadTitle => "Current thread title (omitted when unavailable)",
153+
StatusLineItem::TaskProgress => {
154+
"Latest task progress from update_plan (omitted until available)"
155+
}
139156
}
140157
}
141158

@@ -146,6 +163,7 @@ impl StatusLineItem {
146163
StatusLineItem::CurrentDir => StatusSurfacePreviewItem::CurrentDir,
147164
StatusLineItem::ProjectRoot => StatusSurfacePreviewItem::ProjectRoot,
148165
StatusLineItem::GitBranch => StatusSurfacePreviewItem::GitBranch,
166+
StatusLineItem::Status => StatusSurfacePreviewItem::Status,
149167
StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining,
150168
StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed,
151169
StatusLineItem::FiveHourLimit => StatusSurfacePreviewItem::FiveHourLimit,
@@ -158,6 +176,7 @@ impl StatusLineItem {
158176
StatusLineItem::SessionId => StatusSurfacePreviewItem::SessionId,
159177
StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode,
160178
StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle,
179+
StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress,
161180
}
162181
}
163182
}
@@ -289,7 +308,15 @@ impl Renderable for StatusLineSetupView {
289308
#[cfg(test)]
290309
mod tests {
291310
use super::*;
311+
use crate::app_event_sender::AppEventSender;
312+
use insta::assert_snapshot;
292313
use pretty_assertions::assert_eq;
314+
use ratatui::buffer::Buffer;
315+
use ratatui::layout::Rect;
316+
use ratatui::text::Line;
317+
use tokio::sync::mpsc::unbounded_channel;
318+
319+
use crate::app_event::AppEvent;
293320

294321
#[test]
295322
fn context_used_accepts_context_usage_legacy_id() {
@@ -315,4 +342,222 @@ mod tests {
315342
"context-remaining"
316343
);
317344
}
345+
#[test]
346+
fn project_name_is_canonical_and_accepts_legacy_ids() {
347+
assert_eq!(StatusLineItem::ProjectRoot.to_string(), "project-name");
348+
assert_eq!(
349+
"project-name".parse::<StatusLineItem>(),
350+
Ok(StatusLineItem::ProjectRoot)
351+
);
352+
assert_eq!(
353+
"project".parse::<StatusLineItem>(),
354+
Ok(StatusLineItem::ProjectRoot)
355+
);
356+
assert_eq!(
357+
"project-root".parse::<StatusLineItem>(),
358+
Ok(StatusLineItem::ProjectRoot)
359+
);
360+
}
361+
362+
#[test]
363+
fn model_is_canonical_and_accepts_model_name_legacy_id() {
364+
assert_eq!(StatusLineItem::ModelName.to_string(), "model");
365+
assert_eq!(
366+
"model".parse::<StatusLineItem>(),
367+
Ok(StatusLineItem::ModelName)
368+
);
369+
assert_eq!(
370+
"model-name".parse::<StatusLineItem>(),
371+
Ok(StatusLineItem::ModelName)
372+
);
373+
}
374+
375+
#[test]
376+
fn run_state_is_canonical_and_accepts_status_legacy_id() {
377+
assert_eq!(StatusLineItem::Status.to_string(), "run-state");
378+
assert_eq!(
379+
"run-state".parse::<StatusLineItem>(),
380+
Ok(StatusLineItem::Status)
381+
);
382+
assert_eq!(
383+
"status".parse::<StatusLineItem>(),
384+
Ok(StatusLineItem::Status)
385+
);
386+
}
387+
388+
#[test]
389+
fn parse_status_line_items_accepts_title_only_variants() {
390+
let items = ["run-state", "task-progress"]
391+
.into_iter()
392+
.map(str::parse::<StatusLineItem>)
393+
.collect::<Result<Vec<_>, _>>();
394+
assert_eq!(
395+
items,
396+
Ok(vec![StatusLineItem::Status, StatusLineItem::TaskProgress,])
397+
);
398+
}
399+
400+
#[test]
401+
fn preview_uses_runtime_values() {
402+
let preview_data = StatusSurfacePreviewData::from_iter([
403+
(
404+
StatusLineItem::ModelName.preview_item(),
405+
"gpt-5".to_string(),
406+
),
407+
(
408+
StatusLineItem::CurrentDir.preview_item(),
409+
"/repo".to_string(),
410+
),
411+
]);
412+
let items = [
413+
MultiSelectItem {
414+
id: StatusLineItem::ModelName.to_string(),
415+
name: String::new(),
416+
description: None,
417+
enabled: true,
418+
},
419+
MultiSelectItem {
420+
id: StatusLineItem::CurrentDir.to_string(),
421+
name: String::new(),
422+
description: None,
423+
enabled: true,
424+
},
425+
];
426+
427+
assert_eq!(
428+
preview_data.line_for_items(
429+
items
430+
.iter()
431+
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
432+
.map(StatusLineItem::preview_item),
433+
),
434+
Some(Line::from("gpt-5 · /repo"))
435+
);
436+
}
437+
438+
#[test]
439+
fn preview_uses_placeholders_when_runtime_values_are_missing() {
440+
let preview_data = StatusSurfacePreviewData::from_iter([(
441+
StatusSurfacePreviewItem::Model,
442+
"gpt-5".to_string(),
443+
)]);
444+
let items = [
445+
MultiSelectItem {
446+
id: StatusLineItem::ModelName.to_string(),
447+
name: String::new(),
448+
description: None,
449+
enabled: true,
450+
},
451+
MultiSelectItem {
452+
id: StatusLineItem::GitBranch.to_string(),
453+
name: String::new(),
454+
description: None,
455+
enabled: true,
456+
},
457+
];
458+
459+
assert_eq!(
460+
preview_data.line_for_items(
461+
items
462+
.iter()
463+
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
464+
.map(StatusLineItem::preview_item),
465+
),
466+
Some(Line::from("gpt-5 · feat/awesome-feature"))
467+
);
468+
}
469+
470+
#[test]
471+
fn preview_includes_thread_title() {
472+
let preview_data = StatusSurfacePreviewData::from_iter([
473+
(
474+
StatusLineItem::ModelName.preview_item(),
475+
"gpt-5".to_string(),
476+
),
477+
(
478+
StatusLineItem::ThreadTitle.preview_item(),
479+
"Roadmap cleanup".to_string(),
480+
),
481+
]);
482+
let items = [
483+
MultiSelectItem {
484+
id: StatusLineItem::ModelName.to_string(),
485+
name: String::new(),
486+
description: None,
487+
enabled: true,
488+
},
489+
MultiSelectItem {
490+
id: StatusLineItem::ThreadTitle.to_string(),
491+
name: String::new(),
492+
description: None,
493+
enabled: true,
494+
},
495+
];
496+
497+
assert_eq!(
498+
preview_data.line_for_items(
499+
items
500+
.iter()
501+
.filter_map(|item| item.id.parse::<StatusLineItem>().ok())
502+
.map(StatusLineItem::preview_item),
503+
),
504+
Some(Line::from("gpt-5 · Roadmap cleanup"))
505+
);
506+
}
507+
508+
#[test]
509+
fn setup_view_snapshot_uses_runtime_preview_values() {
510+
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
511+
let view = StatusLineSetupView::new(
512+
Some(&[
513+
StatusLineItem::ModelName.to_string(),
514+
StatusLineItem::CurrentDir.to_string(),
515+
StatusLineItem::GitBranch.to_string(),
516+
]),
517+
StatusSurfacePreviewData::from_iter([
518+
(
519+
StatusLineItem::ModelName.preview_item(),
520+
"gpt-5-codex".to_string(),
521+
),
522+
(
523+
StatusLineItem::CurrentDir.preview_item(),
524+
"~/codex-rs".to_string(),
525+
),
526+
(
527+
StatusLineItem::GitBranch.preview_item(),
528+
"jif/statusline-preview".to_string(),
529+
),
530+
(
531+
StatusLineItem::WeeklyLimit.preview_item(),
532+
"weekly 82%".to_string(),
533+
),
534+
]),
535+
AppEventSender::new(tx_raw),
536+
);
537+
538+
assert_snapshot!(render_lines(&view, /*width*/ 72));
539+
}
540+
541+
fn render_lines(view: &StatusLineSetupView, width: u16) -> String {
542+
let height = view.desired_height(width);
543+
let area = Rect::new(0, 0, width, height);
544+
let mut buf = Buffer::empty(area);
545+
view.render(area, &mut buf);
546+
547+
(0..area.height)
548+
.map(|row| {
549+
let mut line = String::new();
550+
for col in 0..area.width {
551+
let symbol = buf[(area.x + col, area.y + row)].symbol();
552+
if symbol.is_empty() {
553+
line.push(' ');
554+
} else {
555+
line.push_str(symbol);
556+
}
557+
}
558+
line
559+
})
560+
.collect::<Vec<_>>()
561+
.join("\n")
562+
}
318563
}

0 commit comments

Comments
 (0)