62
segments
63
}
64
65
+// ── Tabs ──────────────────────────────────────────────────────────────────────
66
+//
67
+// The top-level tab bar (GitHub Copilot CLI-style). Defined outside `mod tui`
68
+// so the pure cycling/formatting logic is testable on every target; only the
69
+// Unix-only TUI consumes it at runtime, hence the non-Unix dead-code gates
70
+// (same pattern as `permissions::TUI_SESSION`).
71
+
72
+/// The three top-level TUI tabs, cycled with the Tab key.
73
+#[cfg_attr(not(unix), allow(dead_code))]
74
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75
+pub(crate) enum Tab {
76
+ /// The chat itself (default).
77
+ Session,
78
+ /// Saved sessions from the session store.
79
+ History,
80
+ /// siGit Code Cloud status and settings.
81
+ Cloud,
82
+}
83
+
84
+#[cfg_attr(not(unix), allow(dead_code))]
85
+impl Tab {
86
+ pub(crate) const TITLES: [&'static str; 3] = ["Session", "History", "Cloud"];
87
+
88
+ /// Session → History → Cloud → Session.
89
+ pub(crate) fn next(self) -> Self {
90
+ match self {
91
+ Tab::Session => Tab::History,
92
+ Tab::History => Tab::Cloud,
93
+ Tab::Cloud => Tab::Session,
94
+ }
95
+ }
96
+
97
+ /// Position in [`Tab::TITLES`], for the ratatui `Tabs` widget.
98
+ pub(crate) fn index(self) -> usize {
99
+ match self {
100
+ Tab::Session => 0,
101
+ Tab::History => 1,
102
+ Tab::Cloud => 2,
103
+ }
104
+ }
105
+}
106
+
107
+/// Coarse "how long ago" label for the History tab (no date dependency).
108
+#[cfg_attr(not(unix), allow(dead_code))]
109
+pub(crate) fn format_age(age: std::time::Duration) -> String {
110
+ let secs = age.as_secs();
111
+ if secs < 60 {
112
+ format!("{secs}s ago")
113
+ } else if secs < 3_600 {
114
+ format!("{}m ago", secs / 60)
115
+ } else if secs < 86_400 {
116
+ format!("{}h ago", secs / 3_600)
117
+ } else {
118
+ format!("{}d ago", secs / 86_400)
119
+ }
120
+}
121
+
122
+/// One History-tab row: id, age, message count. `age` is `None` when the
123
+/// file's mtime could not be read (or lies in the future).
124
+#[cfg_attr(not(unix), allow(dead_code))]
125
+pub(crate) fn history_row(
126
+ id: &str,
127
+ age: Option<std::time::Duration>,
128
+ message_count: usize,
129
+) -> String {
130
+ let when = age
131
+ .map(format_age)
132
+ .unwrap_or_else(|| "age unknown".to_string());
133
+ format!("{id} · {when} · {message_count} message(s)")
134
+}
135
+
136
// ── Unix-only TUI ─────────────────────────────────────────────────────────────
137
//
138
// macOS + Linux only. Windows uses ACP mode instead.
148
use futures::StreamExt;
149
use onde::inference::{ChatEngine, SamplingConfig};
150
151
+ use super::Tab;
152
use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec};
153
use crate::models::{
154
InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
155
};
156
+ use crate::session_store::SessionEntry;
157
use ratatui::{
158
Frame,
159
layout::{Constraint, Layout, Position},
160
style::{Color, Modifier, Style},
161
text::{Line, Span},
89
- widgets::{Block, Borders, Clear, Paragraph, Wrap},
162
+ widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap},
163
};
164
use tokio::sync::{mpsc, oneshot};
165
use tokio::time::{Duration, Instant, interval};
306
/// The backend serving inference. Swapped in place when the user picks a
307
/// different model or cloud tier via `/models`.
308
backend: Arc<dyn InferenceBackend>,
309
+
310
+ // ── Tab bar state ─────────────────────────────────────────────────────
311
+ /// Which top-level tab is showing. Inference keeps running while the
312
+ /// user is on History/Cloud; updates land in `messages` regardless.
313
+ active_tab: Tab,
314
+
315
+ // History tab: saved sessions from the session store.
316
+ history_sessions: Vec<SessionEntry>,
317
+ history_index: usize,
318
+ /// Session id awaiting the confirming second `d`; any other key clears it.
319
+ history_pending_delete: Option<String>,
320
+ /// One-shot notice shown under the session list (e.g. a failed restore).
321
+ history_notice: Option<String>,
322
+
323
+ // Cloud tab: status text is fetched async when the tab opens and cached.
324
+ /// `None` while a fetch is in flight (renders as "fetching…").
325
+ cloud_lines: Option<Vec<String>>,
326
+ cloud_rx: Option<oneshot::Receiver<Vec<String>>>,
327
}
328
329
const BANNER_ART: &str = "\
405
current_model_name,
406
tool_calling,
407
backend,
408
+ active_tab: Tab::Session,
409
+ history_sessions: Vec::new(),
410
+ history_index: 0,
411
+ history_pending_delete: None,
412
+ history_notice: None,
413
+ cloud_lines: None,
414
+ cloud_rx: None,
415
}
416
}
417
418
+ /// Reload the History tab's session list, keeping the selection in
419
+ /// bounds and dropping any pending delete confirmation.
420
+ fn refresh_history(&mut self) {
421
+ self.history_sessions = crate::session_store::list();
422
+ self.history_index = self
423
+ .history_index
424
+ .min(self.history_sessions.len().saturating_sub(1));
425
+ self.history_pending_delete = None;
426
+ }
427
+
428
+ /// The History entry the cursor is on, if any.
429
+ fn selected_session(&self) -> Option<&SessionEntry> {
430
+ self.history_sessions.get(self.history_index)
431
+ }
432
+
433
fn is_busy(&self) -> bool {
434
self.is_streaming() || self.thinking || self.switching_model
435
}
865
.split(vertical[1])[1]
866
}
867
868
+ // ── Tab bar (Session / History / Cloud) ───────────────────────────────────
869
+
870
+ /// Switch to `tab`, refreshing the data it shows. Entering History rescans
871
+ /// the sessions dir; entering Cloud kicks off the async status fetch.
872
+ fn switch_tab(app: &mut App, tab: Tab, engine: &Arc<ChatEngine>) {
873
+ app.active_tab = tab;
874
+ match tab {
875
+ Tab::Session => {}
876
+ Tab::History => {
877
+ app.refresh_history();
878
+ app.history_notice = None;
879
+ }
880
+ Tab::Cloud => refresh_cloud(app, engine),
881
+ }
882
+ }
883
+
884
+ /// Fetch the Cloud tab's status text on a background task and cache it.
885
+ /// Account status and engine info are async (the account check may hit the
886
+ /// network), so the tab shows "fetching…" until the oneshot resolves in
887
+ /// the event loop.
888
+ fn refresh_cloud(app: &mut App, engine: &Arc<ChatEngine>) {
889
+ let (tx, rx) = oneshot::channel();
890
+ app.cloud_rx = Some(rx);
891
+ app.cloud_lines = None;
892
+
893
+ let engine = Arc::clone(engine);
894
+ let is_remote = app.backend.is_remote();
895
+ let model_name = app.current_model_name.clone();
896
+ tokio::spawn(async move {
897
+ // `status_line` already folds failures into its message, so a dead
898
+ // network degrades to an error string rather than a stuck tab.
899
+ let account = crate::account::status_line().await;
900
+ let info = engine.info().await;
901
+
902
+ let mut lines = Vec::new();
903
+ lines.push(format!("Account: {account}"));
904
+ lines.push(format!(
905
+ "Inference: {}",
906
+ if is_remote {
907
+ "remote (siGit Code Cloud / hosted endpoint)"
908
+ } else {
909
+ "on-device"
910
+ }
911
+ ));
912
+ lines.push(format!("Model: {model_name}"));
913
+ lines.push(format!(
914
+ "Engine: status: {:?} model: {} memory: {} history: {} turns",
915
+ info.status,
916
+ info.model_name.as_deref().unwrap_or("(none)"),
917
+ info.approx_memory.as_deref().unwrap_or("unknown"),
918
+ info.history_length,
919
+ ));
920
+ lines.push(format!(
921
+ "Local inference: {}",
922
+ if crate::settings::local_inference_enabled() {
923
+ "on"
924
+ } else {
925
+ "off"
926
+ }
927
+ ));
928
+ let config_dir = std::env::var("SIGIT_CONFIG_DIR").unwrap_or_else(|_| {
929
+ let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
930
+ format!("{home}/.config/sigit")
931
+ });
932
+ lines.push(format!("Config dir: {config_dir}"));
933
+ lines.push(String::new());
934
+ for perm_line in crate::permissions::describe(crate::permissions::TUI_SESSION).lines() {
935
+ lines.push(perm_line.to_string());
936
+ }
937
+
938
+ let _ = tx.send(lines);
939
+ });
940
+ }
941
+
942
+ /// Keys on the History and Cloud tabs (the Session tab keeps `handle_key`).
943
+ /// Tab/Esc navigation is handled earlier in the event loop; this gets the
944
+ /// rest.
945
+ async fn handle_tab_key(app: &mut App, key: KeyEvent, engine: &Arc<ChatEngine>) {
946
+ let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
947
+ if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) {
948
+ app.quit = true;
949
+ return;
950
+ }
951
+
952
+ match app.active_tab {
953
+ Tab::Session => {}
954
+ Tab::History => match key.code {
955
+ KeyCode::Up => {
956
+ app.history_pending_delete = None;
957
+ app.history_index = app.history_index.saturating_sub(1);
958
+ }
959
+ KeyCode::Down => {
960
+ app.history_pending_delete = None;
961
+ if app.history_index + 1 < app.history_sessions.len() {
962
+ app.history_index += 1;
963
+ }
964
+ }
965
+ KeyCode::Char('r') => {
966
+ app.refresh_history();
967
+ app.history_notice = None;
968
+ }
969
+ KeyCode::Char('d') => {
970
+ let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
971
+ return;
972
+ };
973
+ if app.history_pending_delete.as_deref() == Some(id.as_str()) {
974
+ crate::session_store::delete(&id);
975
+ app.refresh_history();
976
+ app.history_notice = Some(format!("Deleted session '{id}'."));
977
+ } else {
978
+ app.history_pending_delete = Some(id);
979
+ }
980
+ }
981
+ KeyCode::Enter => {
982
+ app.history_pending_delete = None;
983
+ let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
984
+ return;
985
+ };
986
+ match crate::session_store::load(&id) {
987
+ Some(history) if !history.is_empty() => {
988
+ let restored = history.len();
989
+ app.backend.restore_history(history).await;
990
+ app.messages.push(ChatMessage::system(format!(
991
+ "Restored {restored} message(s) from session '{id}'. \
992
+ The model remembers the conversation; the scrollback \
993
+ above does not replay it."
994
+ )));
995
+ app.active_tab = Tab::Session;
996
+ }
997
+ _ => {
998
+ app.history_notice = Some(format!(
999
+ "Could not restore '{id}': the session is empty or unreadable."
1000
+ ));
1001
+ }
1002
+ }
1003
+ }
1004
+ // Any other key cancels a pending delete confirmation.
1005
+ _ => app.history_pending_delete = None,
1006
+ },
1007
+ Tab::Cloud => match key.code {
1008
+ KeyCode::Char('l') => {
1009
+ let enabled = !crate::settings::local_inference_enabled();
1010
+ match crate::settings::set_local_inference(enabled) {
1011
+ Ok(()) => refresh_cloud(app, engine),
1012
+ Err(error) => {
1013
+ app.cloud_lines
1014
+ .get_or_insert_with(Vec::new)
1015
+ .push(format!("error: could not save the setting: {error}"));
1016
+ }
1017
+ }
1018
+ }
1019
+ KeyCode::Char('r') => refresh_cloud(app, engine),
1020
+ _ => {}
1021
+ },
1022
+ }
1023
+ }
1024
+
1025
+ fn render_tab_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1026
+ let tabs = Tabs::new(Tab::TITLES.map(Line::from).to_vec())
1027
+ .select(app.active_tab.index())
1028
+ .style(Style::default().fg(Color::DarkGray))
1029
+ .highlight_style(
1030
+ Style::default()
1031
+ .fg(Color::Black)
1032
+ .bg(Color::Green)
1033
+ .add_modifier(Modifier::BOLD),
1034
+ );
1035
+ frame.render_widget(tabs, area);
1036
+ }
1037
+
1038
+ fn render_history_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1039
+ let block = Block::default()
1040
+ .borders(Borders::ALL)
1041
+ .border_style(Style::default().fg(Color::DarkGray))
1042
+ .title(" saved sessions ");
1043
+ let inner = block.inner(area);
1044
+ frame.render_widget(block, area);
1045
+
1046
+ let mut lines: Vec<Line> = Vec::new();
1047
+
1048
+ if app.history_sessions.is_empty() {
1049
+ lines.push(Line::from(Span::styled(
1050
+ " No saved sessions yet. Sessions are saved after each turn.",
1051
+ Style::default().fg(Color::DarkGray),
1052
+ )));
1053
+ } else {
1054
+ let now = std::time::SystemTime::now();
1055
+ for (index, entry) in app.history_sessions.iter().enumerate() {
1056
+ let selected = index == app.history_index;
1057
+ let marker = if selected { "› " } else { " " };
1058
+ let age = now.duration_since(entry.modified).ok();
1059
+ let row = super::history_row(&entry.id, age, entry.message_count);
1060
+ let style = if selected {
1061
+ Style::default().fg(Color::Black).bg(Color::Green)
1062
+ } else {
1063
+ Style::default().fg(Color::White)
1064
+ };
1065
+ lines.push(Line::from(Span::styled(format!("{marker}{row}"), style)));
1066
+ }
1067
+ }
1068
+
1069
+ if let Some(ref id) = app.history_pending_delete {
1070
+ lines.push(Line::from(""));
1071
+ lines.push(Line::from(Span::styled(
1072
+ format!(" Delete '{id}'? Press d again to confirm — any other key cancels."),
1073
+ Style::default().fg(Color::Yellow),
1074
+ )));
1075
+ } else if let Some(ref notice) = app.history_notice {
1076
+ lines.push(Line::from(""));
1077
+ lines.push(Line::from(Span::styled(
1078
+ format!(" {notice}"),
1079
+ Style::default().fg(Color::Yellow),
1080
+ )));
1081
+ }
1082
+
1083
+ // Keep the selection visible when the list outgrows the pane.
1084
+ let inner_height = inner.height as usize;
1085
+ let scroll = app
1086
+ .history_index
1087
+ .saturating_sub(inner_height.saturating_sub(1)) as u16;
1088
+ frame.render_widget(
1089
+ Paragraph::new(lines)
1090
+ .wrap(Wrap { trim: false })
1091
+ .scroll((scroll, 0)),
1092
+ inner,
1093
+ );
1094
+ }
1095
+
1096
+ fn render_cloud_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1097
+ let block = Block::default()
1098
+ .borders(Borders::ALL)
1099
+ .border_style(Style::default().fg(Color::DarkGray))
1100
+ .title(" siGit Code Cloud ");
1101
+ let inner = block.inner(area);
1102
+ frame.render_widget(block, area);
1103
+
1104
+ let mut lines: Vec<Line> = Vec::new();
1105
+ match app.cloud_lines {
1106
+ None => lines.push(Line::from(Span::styled(
1107
+ " fetching status…",
1108
+ Style::default().fg(Color::DarkGray),
1109
+ ))),
1110
+ Some(ref cloud_lines) => {
1111
+ for text in cloud_lines {
1112
+ lines.push(Line::from(Span::styled(
1113
+ format!(" {text}"),
1114
+ Style::default().fg(Color::White),
1115
+ )));
1116
+ }
1117
+ }
1118
+ }
1119
+ lines.push(Line::from(""));
1120
+ lines.push(Line::from(Span::styled(
1121
+ " Note: toggling Local Inference takes effect for the next model \
1122
+ selection (/models); the running backend is not swapped.",
1123
+ Style::default().fg(Color::DarkGray),
1124
+ )));
1125
+
1126
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
1127
+ }
1128
+
1129
// ── Slash commands ────────────────────────────────────────────────────────
1130
1131
enum SlashCommand {
1216
return;
1217
}
1218
845
- let zones = Layout::vertical([
846
- Constraint::Length(1),
847
- Constraint::Min(1),
848
- Constraint::Length(3),
849
- Constraint::Length(1),
850
- ])
851
- .split(area);
852
-
853
- render_title(frame, app, zones[0]);
854
- render_messages(frame, app, zones[1]);
855
- render_input(frame, app, zones[2]);
856
- render_footer(frame, app, zones[3]);
1219
+ match app.active_tab {
1220
+ Tab::Session => {
1221
+ let zones = Layout::vertical([
1222
+ Constraint::Length(1),
1223
+ Constraint::Length(1),
1224
+ Constraint::Min(1),
1225
+ Constraint::Length(3),
1226
+ Constraint::Length(1),
1227
+ ])
1228
+ .split(area);
1229
+
1230
+ render_tab_bar(frame, app, zones[0]);
1231
+ render_title(frame, app, zones[1]);
1232
+ render_messages(frame, app, zones[2]);
1233
+ render_input(frame, app, zones[3]);
1234
+ render_footer(frame, app, zones[4]);
1235
+ }
1236
+ // No input pane on the non-chat tabs: the Tab key always cycles.
1237
+ Tab::History | Tab::Cloud => {
1238
+ let zones = Layout::vertical([
1239
+ Constraint::Length(1),
1240
+ Constraint::Length(1),
1241
+ Constraint::Min(1),
1242
+ Constraint::Length(1),
1243
+ ])
1244
+ .split(area);
1245
+
1246
+ render_tab_bar(frame, app, zones[0]);
1247
+ render_title(frame, app, zones[1]);
1248
+ if app.active_tab == Tab::History {
1249
+ render_history_tab(frame, app, zones[2]);
1250
+ } else {
1251
+ render_cloud_tab(frame, app, zones[2]);
1252
+ }
1253
+ render_footer(frame, app, zones[3]);
1254
+ }
1255
+ }
1256
1257
if app.show_model_picker {
1258
render_model_picker(frame, app, area);
1562
}
1563
1564
fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1565
+ let key_style = Style::default().fg(Color::Black).bg(Color::Green);
1566
+ let label_style = Style::default().fg(Color::DarkGray);
1567
+
1568
+ if app.active_tab != Tab::Session {
1569
+ let hints: &[(&str, &str)] = match app.active_tab {
1570
+ Tab::History => &[
1571
+ (" ↑/↓ ", " select "),
1572
+ (" Enter ", " resume "),
1573
+ (" d ", " delete "),
1574
+ (" r ", " refresh "),
1575
+ (" Tab ", " next tab "),
1576
+ (" Esc ", " session"),
1577
+ ],
1578
+ _ => &[
1579
+ (" l ", " toggle local inference "),
1580
+ (" r ", " refresh "),
1581
+ (" Tab ", " next tab "),
1582
+ (" Esc ", " session"),
1583
+ ],
1584
+ };
1585
+ let mut spans = Vec::new();
1586
+ for (key, label) in hints {
1587
+ spans.push(Span::styled(key.to_string(), key_style));
1588
+ spans.push(Span::styled(label.to_string(), label_style));
1589
+ }
1590
+ frame.render_widget(Paragraph::new(Line::from(spans)), area);
1591
+ return;
1592
+ }
1593
+
1594
let mut spans = vec![
1167
- Span::styled(
1168
- " Enter ",
1169
- Style::default().fg(Color::Black).bg(Color::Green),
1170
- ),
1171
- Span::styled(" send ", Style::default().fg(Color::DarkGray)),
1595
+ Span::styled(" Enter ", key_style),
1596
+ Span::styled(" send ", label_style),
1597
+ Span::styled(" Tab ", key_style),
1598
+ Span::styled(" tabs ", label_style),
1599
Span::styled(
1600
" /help ",
1601
Style::default().fg(Color::Black).bg(Color::DarkGray),
2501
app.messages.push(ChatMessage::system(format!("error: {msg}")));
2502
}
2503
Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
2504
+ // The y/a/n prompt lives on the Session tab; make
2505
+ // sure the user can see what they're answering.
2506
+ app.active_tab = Tab::Session;
2507
let call = if args.is_empty() {
2508
tool.clone()
2509
} else {
2522
}
2523
}
2524
2525
+ // ── Cloud tab status fetch resolving ─────────────────────────
2526
+ status = async {
2527
+ match app.cloud_rx.as_mut() {
2528
+ Some(rx) => rx.await,
2529
+ None => pending().await,
2530
+ }
2531
+ } => {
2532
+ app.cloud_rx = None;
2533
+ app.cloud_lines = Some(status.unwrap_or_else(|_| {
2534
+ vec!["error: the status fetch task died — press r to retry".to_string()]
2535
+ }));
2536
+ }
2537
+
2538
// ── thinking / switching spinner tick (100ms) ────────────────
2539
_ = async {
2540
if app.thinking || app.switching_model {
2618
continue;
2619
}
2620
2621
+ // ── Tab-bar navigation ────────────────────────────────
2622
+ // Handled before the busy gate so the user can look at
2623
+ // History/Cloud while inference runs (updates keep
2624
+ // landing in the Session tab's message list). The Tab
2625
+ // key only cycles when the input buffer is empty, so
2626
+ // pasted text containing tabs can't fight it; on
2627
+ // non-Session tabs the input is hidden, so it always
2628
+ // cycles there.
2629
+ if key.kind == KeyEventKind::Press && !app.show_model_picker {
2630
+ if key.code == KeyCode::Tab
2631
+ && (app.active_tab != Tab::Session || app.input.is_empty())
2632
+ {
2633
+ let next = app.active_tab.next();
2634
+ switch_tab(&mut app, next, &engine);
2635
+ continue;
2636
+ }
2637
+ if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
2638
+ app.active_tab = Tab::Session;
2639
+ continue;
2640
+ }
2641
+ }
2642
+
2643
// busy — only cancel keys work
2644
if app.is_busy() {
2645
if key.kind == KeyEventKind::Press {
2669
continue;
2670
}
2671
2672
+ // History / Cloud tabs have their own key handling; the
2673
+ // chat input is inactive there.
2674
+ if app.active_tab != Tab::Session {
2675
+ if key.kind == KeyEventKind::Press {
2676
+ handle_tab_key(&mut app, key, &engine).await;
2677
+ }
2678
+ continue;
2679
+ }
2680
+
2681
if let Some(text) = handle_key(&mut app, key) {
2682
if let Some(cmd) = parse_slash(&text) {
2683
exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await;
2764
2765
#[cfg(test)]
2766
mod tests {
2293
- use super::{parse_rich_text_segments, strip_think_blocks};
2767
+ use std::time::Duration;
2768
+
2769
+ use super::{Tab, format_age, history_row, parse_rich_text_segments, strip_think_blocks};
2770
+
2771
+ #[test]
2772
+ fn tab_next_cycles_session_history_cloud() {
2773
+ assert_eq!(Tab::Session.next(), Tab::History);
2774
+ assert_eq!(Tab::History.next(), Tab::Cloud);
2775
+ assert_eq!(Tab::Cloud.next(), Tab::Session);
2776
+ // Three hops return to the start, matching the tab bar's order.
2777
+ assert_eq!(Tab::Session.next().next().next(), Tab::Session);
2778
+ }
2779
+
2780
+ #[test]
2781
+ fn tab_index_matches_titles_order() {
2782
+ assert_eq!(Tab::TITLES[Tab::Session.index()], "Session");
2783
+ assert_eq!(Tab::TITLES[Tab::History.index()], "History");
2784
+ assert_eq!(Tab::TITLES[Tab::Cloud.index()], "Cloud");
2785
+ }
2786
+
2787
+ #[test]
2788
+ fn format_age_picks_the_coarsest_sensible_unit() {
2789
+ assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
2790
+ assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
2791
+ assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
2792
+ assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
2793
+ assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
2794
+ assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
2795
+ assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
2796
+ assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
2797
+ }
2798
+
2799
+ #[test]
2800
+ fn history_row_formats_id_age_and_count() {
2801
+ assert_eq!(
2802
+ history_row("tui", Some(Duration::from_secs(120)), 7),
2803
+ "tui · 2m ago · 7 message(s)"
2804
+ );
2805
+ assert_eq!(
2806
+ history_row("sess-1", None, 0),
2807
+ "sess-1 · age unknown · 0 message(s)"
2808
+ );
2809
+ }
2810
2811
#[test]
2812
fn strip_think_blocks_separates_thinking_and_visible_reply() {