@setoelkahfi / sigit / commits / 71e2f71

Add tabbed TUI: session, history, and cloud tabs

The interactive TUI gets a tab bar in the Copilot CLI style, cycled with the Tab key (only when the input is empty) and left with Esc. The Session tab is the chat, unchanged. The History tab lists the saved sessions from the session store with age and message count: Enter restores one into the live conversation and returns to the chat, d pressed twice deletes (keyed by session id so a refresh cannot redirect the confirmation), r refreshes. The Cloud tab shows the signed-in account, on-device or remote inference, the current model and engine state, the permission policy, and the config dir; l toggles the Local Inference setting live, with a note that it takes effect at the next model selection. The cloud status fetch runs as a spawned task wired into the select loop, since it can hit the network and must not freeze rendering. Tab navigation works while inference runs, and a permission prompt arriving on another tab switches back to Session so the y/a/n prompt is never invisible. Pure helpers (tab cycling, age and row formatting, session listing) live outside the unix-only module so all four CI targets compile and test them.

paydii committed Jul 5, 2026 at 01:31 UTC 71e2f71e203f498b3d7653a2377f3f9522d7d5c2
2 files changed +629 -19
src/chat.rs
+535 -19
@@ -62,6 +62,77 @@ pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
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.
@@ -77,16 +148,18 @@ mod tui {
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};
@@ -233,6 +306,24 @@ mod tui {
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 = "\
@@ -314,9 +405,31 @@ mod tui {
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 }
@@ -752,6 +865,267 @@ mod tui {
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 {
@@ -842,18 +1216,43 @@ mod tui {
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);
@@ -1163,12 +1562,40 @@ mod tui {
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),
@@ -2074,6 +2501,9 @@ mod tui {
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 {
@@ -2092,6 +2522,19 @@ mod tui {
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 {
@@ -2175,6 +2618,28 @@ mod tui {
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 {
@@ -2204,6 +2669,15 @@ mod tui {
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;
@@ -2290,7 +2764,49 @@ pub use tui::run_with;
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() {
src/session_store.rs
+94
@@ -104,6 +104,60 @@ pub fn delete(session_id: &str) {
104 }
105 }
106
107 +/// One saved session as seen on disk.
108 +///
109 +/// Cross-platform like the rest of the store, though today only the Unix-only
110 +/// TUI (`chat.rs` History tab) consumes it — hence the non-Unix dead-code gate,
111 +/// mirroring `permissions::TUI_SESSION`.
112 +#[cfg_attr(not(unix), allow(dead_code))]
113 +#[derive(Debug, Clone, PartialEq, Eq)]
114 +pub struct SessionEntry {
115 + /// The sanitized id (the file stem), which `load`/`delete` accept as-is.
116 + pub id: String,
117 + /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable.
118 + pub modified: std::time::SystemTime,
119 + /// Number of history messages (non-empty lines) in the file.
120 + pub message_count: usize,
121 +}
122 +
123 +/// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
124 +/// stray junk) are skipped. A missing or unreadable sessions dir yields an
125 +/// empty list.
126 +#[cfg_attr(not(unix), allow(dead_code))]
127 +pub fn list() -> Vec<SessionEntry> {
128 + let Some(dir) = sessions_dir() else {
129 + return Vec::new();
130 + };
131 + let Ok(entries) = std::fs::read_dir(&dir) else {
132 + return Vec::new();
133 + };
134 + let mut sessions: Vec<SessionEntry> = entries
135 + .flatten()
136 + .filter_map(|entry| {
137 + let path = entry.path();
138 + if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
139 + return None;
140 + }
141 + let id = path.file_stem()?.to_str()?.to_string();
142 + // Cheap line count: no JSON parsing, just non-empty lines.
143 + let contents = std::fs::read_to_string(&path).ok()?;
144 + let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
145 + let modified = entry
146 + .metadata()
147 + .ok()
148 + .and_then(|m| m.modified().ok())
149 + .unwrap_or(std::time::UNIX_EPOCH);
150 + Some(SessionEntry {
151 + id,
152 + modified,
153 + message_count,
154 + })
155 + })
156 + .collect();
157 + sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id)));
158 + sessions
159 +}
160 +
161 #[cfg(test)]
162 mod tests {
163 use super::*;
@@ -164,4 +218,44 @@ mod tests {
218 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
219 let _ = std::fs::remove_dir_all(&dir);
220 }
221 +
222 + // Same single-test-per-env-var pattern as `save_load_delete_round_trip`:
223 + // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold.
224 + #[test]
225 + fn list_returns_sessions_newest_first_and_skips_junk() {
226 + let _guard = crate::ENV_TEST_LOCK
227 + .lock()
228 + .unwrap_or_else(|poisoned| poisoned.into_inner());
229 + let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id()));
230 + let _ = std::fs::remove_dir_all(&dir);
231 + // SAFETY: serialized by ENV_TEST_LOCK; restored below.
232 + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
233 +
234 + // No sessions dir at all → empty list, not an error.
235 + assert!(list().is_empty());
236 +
237 + let msg = |text: &str| serde_json::json!({ "role": "user", "content": text });
238 + save("older", &[msg("a"), msg("b"), msg("c")]).unwrap();
239 + // Distinct mtimes so the newest-first order is deterministic.
240 + std::thread::sleep(std::time::Duration::from_millis(50));
241 + save("newer", &[msg("x")]).unwrap();
242 +
243 + // Junk the lister must skip: wrong extension, a stray temp file, and a
244 + // subdirectory.
245 + let sessions = dir.join("sessions");
246 + std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap();
247 + std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap();
248 + std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap();
249 +
250 + let listed = list();
251 + assert_eq!(listed.len(), 2);
252 + assert_eq!(listed[0].id, "newer");
253 + assert_eq!(listed[0].message_count, 1);
254 + assert_eq!(listed[1].id, "older");
255 + assert_eq!(listed[1].message_count, 3);
256 + assert!(listed[0].modified >= listed[1].modified);
257 +
258 + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
259 + let _ = std::fs::remove_dir_all(&dir);
260 + }
261 }