Skip to content

Commit 4e35b94

Browse files
committed
feat(tui): show both timestamp columns with responsive hiding
Display both "Created at" and "Updated at" columns in the resume picker, improving visibility of session history. When terminal width is limited, intelligently hide the inactive timestamp column (whichever doesn't match the current sort key) to preserve space for the conversation preview. Changes: - Add Created at column alongside Updated at - Implement ColumnVisibility logic to show/hide columns based on width - Update column labels from "Creation"/"Last updated" to "Created at"/"Updated at" - Add format_created_label() helper function - Add column_visibility_hides_extra_date_column_when_narrow() test - Update snapshots to reflect new column layout The picker now shows maximum information when space allows, while gracefully degrading to a single timestamp column on narrow terminals.
1 parent f6f4342 commit 4e35b94

5 files changed

+197
-34
lines changed

codex-rs/tui/src/resume_picker.rs

Lines changed: 175 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,8 @@ async fn run_session_picker(
239239
/// Returns the human-readable column header for the given sort key.
240240
fn sort_key_label(sort_key: ThreadSortKey) -> &'static str {
241241
match sort_key {
242-
ThreadSortKey::CreatedAt => "Creation",
243-
ThreadSortKey::UpdatedAt => "Last updated",
242+
ThreadSortKey::CreatedAt => "Created at",
243+
ThreadSortKey::UpdatedAt => "Updated at",
244244
}
245245
}
246246

@@ -928,7 +928,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
928928
let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all);
929929

930930
// Column headers and list
931-
render_column_headers(frame, columns, &metrics);
931+
render_column_headers(frame, columns, &metrics, state.sort_key);
932932
render_list(frame, list, state, &metrics);
933933

934934
// Hint line
@@ -979,24 +979,31 @@ fn render_list(
979979
let labels = &metrics.labels;
980980
let mut y = area.y;
981981

982+
let visibility = column_visibility(area.width, metrics, state.sort_key);
983+
let max_created_width = metrics.max_created_width;
982984
let max_updated_width = metrics.max_updated_width;
983985
let max_branch_width = metrics.max_branch_width;
984986
let max_cwd_width = metrics.max_cwd_width;
985987

986-
for (idx, (row, (updated_label, branch_label, cwd_label))) in rows[start..end]
988+
for (idx, (row, (created_label, updated_label, branch_label, cwd_label))) in rows[start..end]
987989
.iter()
988990
.zip(labels[start..end].iter())
989991
.enumerate()
990992
{
991993
let is_sel = start + idx == state.selected;
992994
let marker = if is_sel { "> ".bold() } else { " ".into() };
993995
let marker_width = 2usize;
994-
let updated_span = if max_updated_width == 0 {
995-
None
996+
let created_span = if visibility.show_created {
997+
Some(Span::from(format!("{created_label:<max_created_width$}")).dim())
996998
} else {
999+
None
1000+
};
1001+
let updated_span = if visibility.show_updated {
9971002
Some(Span::from(format!("{updated_label:<max_updated_width$}")).dim())
1003+
} else {
1004+
None
9981005
};
999-
let branch_span = if max_branch_width == 0 {
1006+
let branch_span = if !visibility.show_branch {
10001007
None
10011008
} else if branch_label.is_empty() {
10021009
Some(
@@ -1010,7 +1017,7 @@ fn render_list(
10101017
} else {
10111018
Some(Span::from(format!("{branch_label:<max_branch_width$}")).cyan())
10121019
};
1013-
let cwd_span = if max_cwd_width == 0 {
1020+
let cwd_span = if !visibility.show_cwd {
10141021
None
10151022
} else if cwd_label.is_empty() {
10161023
Some(
@@ -1027,21 +1034,31 @@ fn render_list(
10271034

10281035
let mut preview_width = area.width as usize;
10291036
preview_width = preview_width.saturating_sub(marker_width);
1030-
if max_updated_width > 0 {
1037+
if visibility.show_created {
1038+
preview_width = preview_width.saturating_sub(max_created_width + 2);
1039+
}
1040+
if visibility.show_updated {
10311041
preview_width = preview_width.saturating_sub(max_updated_width + 2);
10321042
}
1033-
if max_branch_width > 0 {
1043+
if visibility.show_branch {
10341044
preview_width = preview_width.saturating_sub(max_branch_width + 2);
10351045
}
1036-
if max_cwd_width > 0 {
1046+
if visibility.show_cwd {
10371047
preview_width = preview_width.saturating_sub(max_cwd_width + 2);
10381048
}
1039-
let add_leading_gap = max_updated_width == 0 && max_branch_width == 0 && max_cwd_width == 0;
1049+
let add_leading_gap = !visibility.show_created
1050+
&& !visibility.show_updated
1051+
&& !visibility.show_branch
1052+
&& !visibility.show_cwd;
10401053
if add_leading_gap {
10411054
preview_width = preview_width.saturating_sub(2);
10421055
}
10431056
let preview = truncate_text(row.display_preview(), preview_width);
10441057
let mut spans: Vec<Span> = vec![marker];
1058+
if let Some(created) = created_span {
1059+
spans.push(created);
1060+
spans.push(" ".into());
1061+
}
10451062
if let Some(updated) = updated_span {
10461063
spans.push(updated);
10471064
spans.push(" ".into());
@@ -1143,26 +1160,45 @@ fn format_updated_label(row: &Row) -> String {
11431160
}
11441161
}
11451162

1163+
fn format_created_label(row: &Row) -> String {
1164+
match (row.created_at, row.updated_at) {
1165+
(Some(created), _) => human_time_ago(created),
1166+
(None, Some(updated)) => human_time_ago(updated),
1167+
(None, None) => "-".to_string(),
1168+
}
1169+
}
1170+
11461171
fn render_column_headers(
11471172
frame: &mut crate::custom_terminal::Frame,
11481173
area: Rect,
11491174
metrics: &ColumnMetrics,
1175+
sort_key: ThreadSortKey,
11501176
) {
11511177
if area.height == 0 {
11521178
return;
11531179
}
11541180

11551181
let mut spans: Vec<Span> = vec![" ".into()];
1156-
if metrics.max_updated_width > 0 {
1182+
let visibility = column_visibility(area.width, metrics, sort_key);
1183+
if visibility.show_created {
11571184
let label = format!(
11581185
"{text:<width$}",
1159-
text = "Updated",
1186+
text = "Created at",
1187+
width = metrics.max_created_width
1188+
);
1189+
spans.push(Span::from(label).bold());
1190+
spans.push(" ".into());
1191+
}
1192+
if visibility.show_updated {
1193+
let label = format!(
1194+
"{text:<width$}",
1195+
text = "Updated at",
11601196
width = metrics.max_updated_width
11611197
);
11621198
spans.push(Span::from(label).bold());
11631199
spans.push(" ".into());
11641200
}
1165-
if metrics.max_branch_width > 0 {
1201+
if visibility.show_branch {
11661202
let label = format!(
11671203
"{text:<width$}",
11681204
text = "Branch",
@@ -1171,7 +1207,7 @@ fn render_column_headers(
11711207
spans.push(Span::from(label).bold());
11721208
spans.push(" ".into());
11731209
}
1174-
if metrics.max_cwd_width > 0 {
1210+
if visibility.show_cwd {
11751211
let label = format!(
11761212
"{text:<width$}",
11771213
text = "CWD",
@@ -1189,11 +1225,25 @@ fn render_column_headers(
11891225
/// Widths are measured in Unicode display width (not byte length) so columns
11901226
/// align correctly when labels contain non-ASCII characters.
11911227
struct ColumnMetrics {
1228+
max_created_width: usize,
11921229
max_updated_width: usize,
11931230
max_branch_width: usize,
11941231
max_cwd_width: usize,
1195-
/// (updated_label, branch_label, cwd_label) per row.
1196-
labels: Vec<(String, String, String)>,
1232+
/// (created_label, updated_label, branch_label, cwd_label) per row.
1233+
labels: Vec<(String, String, String, String)>,
1234+
}
1235+
1236+
/// Determines which columns to render given available terminal width.
1237+
///
1238+
/// When the terminal is narrow, only one timestamp column is shown (whichever
1239+
/// matches the current sort key). Branch and CWD are hidden if their max
1240+
/// widths are zero (no data to show).
1241+
#[derive(Debug, PartialEq, Eq)]
1242+
struct ColumnVisibility {
1243+
show_created: bool,
1244+
show_updated: bool,
1245+
show_branch: bool,
1246+
show_cwd: bool,
11971247
}
11981248

11991249
fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
@@ -1216,8 +1266,9 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
12161266
format!("…{tail}")
12171267
}
12181268

1219-
let mut labels: Vec<(String, String, String)> = Vec::with_capacity(rows.len());
1220-
let mut max_updated_width = UnicodeWidthStr::width("Updated");
1269+
let mut labels: Vec<(String, String, String, String)> = Vec::with_capacity(rows.len());
1270+
let mut max_created_width = UnicodeWidthStr::width("Created at");
1271+
let mut max_updated_width = UnicodeWidthStr::width("Updated at");
12211272
let mut max_branch_width = UnicodeWidthStr::width("Branch");
12221273
let mut max_cwd_width = if include_cwd {
12231274
UnicodeWidthStr::width("CWD")
@@ -1226,6 +1277,7 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
12261277
};
12271278

12281279
for row in rows {
1280+
let created = format_created_label(row);
12291281
let updated = format_updated_label(row);
12301282
let branch_raw = row.git_branch.clone().unwrap_or_default();
12311283
let branch = right_elide(&branch_raw, 24);
@@ -1239,20 +1291,74 @@ fn calculate_column_metrics(rows: &[Row], include_cwd: bool) -> ColumnMetrics {
12391291
} else {
12401292
String::new()
12411293
};
1294+
max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str()));
12421295
max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str()));
12431296
max_branch_width = max_branch_width.max(UnicodeWidthStr::width(branch.as_str()));
12441297
max_cwd_width = max_cwd_width.max(UnicodeWidthStr::width(cwd.as_str()));
1245-
labels.push((updated, branch, cwd));
1298+
labels.push((created, updated, branch, cwd));
12461299
}
12471300

12481301
ColumnMetrics {
1302+
max_created_width,
12491303
max_updated_width,
12501304
max_branch_width,
12511305
max_cwd_width,
12521306
labels,
12531307
}
12541308
}
12551309

1310+
/// Computes which columns fit in the available width.
1311+
///
1312+
/// The algorithm reserves at least `MIN_PREVIEW_WIDTH` characters for the
1313+
/// conversation preview. If both timestamp columns don't fit, only the one
1314+
/// matching the current sort key is shown.
1315+
fn column_visibility(
1316+
area_width: u16,
1317+
metrics: &ColumnMetrics,
1318+
sort_key: ThreadSortKey,
1319+
) -> ColumnVisibility {
1320+
const MIN_PREVIEW_WIDTH: usize = 10;
1321+
1322+
let show_branch = metrics.max_branch_width > 0;
1323+
let show_cwd = metrics.max_cwd_width > 0;
1324+
1325+
// Calculate remaining width after all optional columns.
1326+
let mut preview_width = area_width as usize;
1327+
preview_width = preview_width.saturating_sub(2); // marker
1328+
if metrics.max_created_width > 0 {
1329+
preview_width = preview_width.saturating_sub(metrics.max_created_width + 2);
1330+
}
1331+
if metrics.max_updated_width > 0 {
1332+
preview_width = preview_width.saturating_sub(metrics.max_updated_width + 2);
1333+
}
1334+
if show_branch {
1335+
preview_width = preview_width.saturating_sub(metrics.max_branch_width + 2);
1336+
}
1337+
if show_cwd {
1338+
preview_width = preview_width.saturating_sub(metrics.max_cwd_width + 2);
1339+
}
1340+
1341+
// If preview would be too narrow, hide the non-active timestamp column.
1342+
let show_both = preview_width >= MIN_PREVIEW_WIDTH;
1343+
let show_created = if show_both {
1344+
metrics.max_created_width > 0
1345+
} else {
1346+
sort_key == ThreadSortKey::CreatedAt
1347+
};
1348+
let show_updated = if show_both {
1349+
metrics.max_updated_width > 0
1350+
} else {
1351+
sort_key == ThreadSortKey::UpdatedAt
1352+
};
1353+
1354+
ColumnVisibility {
1355+
show_created,
1356+
show_updated,
1357+
show_branch,
1358+
show_cwd,
1359+
}
1360+
}
1361+
12561362
#[cfg(test)]
12571363
mod tests {
12581364
use super::*;
@@ -1586,7 +1692,7 @@ mod tests {
15861692
let area = frame.area();
15871693
let segments =
15881694
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
1589-
render_column_headers(&mut frame, segments[0], &metrics);
1695+
render_column_headers(&mut frame, segments[0], &metrics, state.sort_key);
15901696
render_list(&mut frame, segments[1], &state, &metrics);
15911697
}
15921698
terminal.flush().expect("flush");
@@ -1734,14 +1840,14 @@ mod tests {
17341840
" ".into(),
17351841
"Sort:".dim(),
17361842
" ".into(),
1737-
"Creation".magenta(),
1843+
"Created at".magenta(),
17381844
]),
17391845
header,
17401846
);
17411847

17421848
frame.render_widget_ref(Line::from("Type to search".dim()), search);
17431849

1744-
render_column_headers(&mut frame, columns, &metrics);
1850+
render_column_headers(&mut frame, columns, &metrics, state.sort_key);
17451851
render_list(&mut frame, list, &state, &metrics);
17461852

17471853
let hint_line: Line = vec![
@@ -1855,7 +1961,7 @@ mod tests {
18551961
let area = frame.area();
18561962
let segments =
18571963
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
1858-
render_column_headers(&mut frame, segments[0], &metrics);
1964+
render_column_headers(&mut frame, segments[0], &metrics, state.sort_key);
18591965
render_list(&mut frame, segments[1], &state, &metrics);
18601966
}
18611967
terminal.flush().expect("flush");
@@ -1965,6 +2071,50 @@ mod tests {
19652071
assert!(guard[0].search_token.is_none());
19662072
}
19672073

2074+
#[test]
2075+
fn column_visibility_hides_extra_date_column_when_narrow() {
2076+
let metrics = ColumnMetrics {
2077+
max_created_width: 8,
2078+
max_updated_width: 12,
2079+
max_branch_width: 0,
2080+
max_cwd_width: 0,
2081+
labels: Vec::new(),
2082+
};
2083+
2084+
let created = column_visibility(30, &metrics, ThreadSortKey::CreatedAt);
2085+
assert_eq!(
2086+
created,
2087+
ColumnVisibility {
2088+
show_created: true,
2089+
show_updated: false,
2090+
show_branch: false,
2091+
show_cwd: false,
2092+
}
2093+
);
2094+
2095+
let updated = column_visibility(30, &metrics, ThreadSortKey::UpdatedAt);
2096+
assert_eq!(
2097+
updated,
2098+
ColumnVisibility {
2099+
show_created: false,
2100+
show_updated: true,
2101+
show_branch: false,
2102+
show_cwd: false,
2103+
}
2104+
);
2105+
2106+
let wide = column_visibility(40, &metrics, ThreadSortKey::CreatedAt);
2107+
assert_eq!(
2108+
wide,
2109+
ColumnVisibility {
2110+
show_created: true,
2111+
show_updated: true,
2112+
show_branch: false,
2113+
show_cwd: false,
2114+
}
2115+
);
2116+
}
2117+
19682118
#[tokio::test]
19692119
async fn toggle_sort_key_reloads_with_new_sort() {
19702120
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = Arc::new(Mutex::new(Vec::new()));

codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ expression: snapshot
44
---
55
Resume a previous session Sort: Creation
66
Type to search
7-
Updated Branch CWD Conversation
7+
Created at Updated at Branch CWD Conversation
88
No sessions yet
99

1010

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
source: tui/src/resume_picker.rs
3+
assertion_line: 1872
4+
expression: snapshot
5+
---
6+
Resume a previous session Sort: Created at
7+
Type to search
8+
Created at Updated at Branch CWD Conversation
9+
No sessions yet
10+
11+
12+
13+
14+
enter to resume esc to start new ctrl + c to quit tab to toggle sort

0 commit comments

Comments
 (0)