@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
index d7b301b..0be391a 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -62,6 +62,77 @@ pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> { segments } +// ── Tabs ────────────────────────────────────────────────────────────────────── +// +// The top-level tab bar (GitHub Copilot CLI-style). Defined outside `mod tui` +// so the pure cycling/formatting logic is testable on every target; only the +// Unix-only TUI consumes it at runtime, hence the non-Unix dead-code gates +// (same pattern as `permissions::TUI_SESSION`). + +/// The three top-level TUI tabs, cycled with the Tab key. +#[cfg_attr(not(unix), allow(dead_code))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Tab { + /// The chat itself (default). + Session, + /// Saved sessions from the session store. + History, + /// siGit Code Cloud status and settings. + Cloud, +} + +#[cfg_attr(not(unix), allow(dead_code))] +impl Tab { + pub(crate) const TITLES: [&'static str; 3] = ["Session", "History", "Cloud"]; + + /// Session → History → Cloud → Session. + pub(crate) fn next(self) -> Self { + match self { + Tab::Session => Tab::History, + Tab::History => Tab::Cloud, + Tab::Cloud => Tab::Session, + } + } + + /// Position in [`Tab::TITLES`], for the ratatui `Tabs` widget. + pub(crate) fn index(self) -> usize { + match self { + Tab::Session => 0, + Tab::History => 1, + Tab::Cloud => 2, + } + } +} + +/// Coarse "how long ago" label for the History tab (no date dependency). +#[cfg_attr(not(unix), allow(dead_code))] +pub(crate) fn format_age(age: std::time::Duration) -> String { + let secs = age.as_secs(); + if secs < 60 { + format!("{secs}s ago") + } else if secs < 3_600 { + format!("{}m ago", secs / 60) + } else if secs < 86_400 { + format!("{}h ago", secs / 3_600) + } else { + format!("{}d ago", secs / 86_400) + } +} + +/// One History-tab row: id, age, message count. `age` is `None` when the +/// file's mtime could not be read (or lies in the future). +#[cfg_attr(not(unix), allow(dead_code))] +pub(crate) fn history_row( + id: &str, + age: Option<std::time::Duration>, + message_count: usize, +) -> String { + let when = age + .map(format_age) + .unwrap_or_else(|| "age unknown".to_string()); + format!("{id} · {when} · {message_count} message(s)") +} + // ── Unix-only TUI ───────────────────────────────────────────────────────────── // // macOS + Linux only. Windows uses ACP mode instead. @@ -77,16 +148,18 @@ mod tui { use futures::StreamExt; use onde::inference::{ChatEngine, SamplingConfig}; + use super::Tab; use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec}; use crate::models::{ InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items, }; + use crate::session_store::SessionEntry; use ratatui::{ Frame, layout::{Constraint, Layout, Position}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap}, }; use tokio::sync::{mpsc, oneshot}; use tokio::time::{Duration, Instant, interval}; @@ -233,6 +306,24 @@ mod tui { /// The backend serving inference. Swapped in place when the user picks a /// different model or cloud tier via `/models`. backend: Arc<dyn InferenceBackend>, + + // ── Tab bar state ───────────────────────────────────────────────────── + /// Which top-level tab is showing. Inference keeps running while the + /// user is on History/Cloud; updates land in `messages` regardless. + active_tab: Tab, + + // History tab: saved sessions from the session store. + history_sessions: Vec<SessionEntry>, + history_index: usize, + /// Session id awaiting the confirming second `d`; any other key clears it. + history_pending_delete: Option<String>, + /// One-shot notice shown under the session list (e.g. a failed restore). + history_notice: Option<String>, + + // Cloud tab: status text is fetched async when the tab opens and cached. + /// `None` while a fetch is in flight (renders as "fetching…"). + cloud_lines: Option<Vec<String>>, + cloud_rx: Option<oneshot::Receiver<Vec<String>>>, } const BANNER_ART: &str = "\ @@ -314,9 +405,31 @@ mod tui { current_model_name, tool_calling, backend, + active_tab: Tab::Session, + history_sessions: Vec::new(), + history_index: 0, + history_pending_delete: None, + history_notice: None, + cloud_lines: None, + cloud_rx: None, } } + /// Reload the History tab's session list, keeping the selection in + /// bounds and dropping any pending delete confirmation. + fn refresh_history(&mut self) { + self.history_sessions = crate::session_store::list(); + self.history_index = self + .history_index + .min(self.history_sessions.len().saturating_sub(1)); + self.history_pending_delete = None; + } + + /// The History entry the cursor is on, if any. + fn selected_session(&self) -> Option<&SessionEntry> { + self.history_sessions.get(self.history_index) + } + fn is_busy(&self) -> bool { self.is_streaming() || self.thinking || self.switching_model } @@ -752,6 +865,267 @@ mod tui { .split(vertical[1])[1] } + // ── Tab bar (Session / History / Cloud) ─────────────────────────────────── + + /// Switch to `tab`, refreshing the data it shows. Entering History rescans + /// the sessions dir; entering Cloud kicks off the async status fetch. + fn switch_tab(app: &mut App, tab: Tab, engine: &Arc<ChatEngine>) { + app.active_tab = tab; + match tab { + Tab::Session => {} + Tab::History => { + app.refresh_history(); + app.history_notice = None; + } + Tab::Cloud => refresh_cloud(app, engine), + } + } + + /// Fetch the Cloud tab's status text on a background task and cache it. + /// Account status and engine info are async (the account check may hit the + /// network), so the tab shows "fetching…" until the oneshot resolves in + /// the event loop. + fn refresh_cloud(app: &mut App, engine: &Arc<ChatEngine>) { + let (tx, rx) = oneshot::channel(); + app.cloud_rx = Some(rx); + app.cloud_lines = None; + + let engine = Arc::clone(engine); + let is_remote = app.backend.is_remote(); + let model_name = app.current_model_name.clone(); + tokio::spawn(async move { + // `status_line` already folds failures into its message, so a dead + // network degrades to an error string rather than a stuck tab. + let account = crate::account::status_line().await; + let info = engine.info().await; + + let mut lines = Vec::new(); + lines.push(format!("Account: {account}")); + lines.push(format!( + "Inference: {}", + if is_remote { + "remote (siGit Code Cloud / hosted endpoint)" + } else { + "on-device" + } + )); + lines.push(format!("Model: {model_name}")); + lines.push(format!( + "Engine: status: {:?} model: {} memory: {} history: {} turns", + info.status, + info.model_name.as_deref().unwrap_or("(none)"), + info.approx_memory.as_deref().unwrap_or("unknown"), + info.history_length, + )); + lines.push(format!( + "Local inference: {}", + if crate::settings::local_inference_enabled() { + "on" + } else { + "off" + } + )); + let config_dir = std::env::var("SIGIT_CONFIG_DIR").unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string()); + format!("{home}/.config/sigit") + }); + lines.push(format!("Config dir: {config_dir}")); + lines.push(String::new()); + for perm_line in crate::permissions::describe(crate::permissions::TUI_SESSION).lines() { + lines.push(perm_line.to_string()); + } + + let _ = tx.send(lines); + }); + } + + /// Keys on the History and Cloud tabs (the Session tab keeps `handle_key`). + /// Tab/Esc navigation is handled earlier in the event loop; this gets the + /// rest. + async fn handle_tab_key(app: &mut App, key: KeyEvent, engine: &Arc<ChatEngine>) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) { + app.quit = true; + return; + } + + match app.active_tab { + Tab::Session => {} + Tab::History => match key.code { + KeyCode::Up => { + app.history_pending_delete = None; + app.history_index = app.history_index.saturating_sub(1); + } + KeyCode::Down => { + app.history_pending_delete = None; + if app.history_index + 1 < app.history_sessions.len() { + app.history_index += 1; + } + } + KeyCode::Char('r') => { + app.refresh_history(); + app.history_notice = None; + } + KeyCode::Char('d') => { + let Some(id) = app.selected_session().map(|e| e.id.clone()) else { + return; + }; + if app.history_pending_delete.as_deref() == Some(id.as_str()) { + crate::session_store::delete(&id); + app.refresh_history(); + app.history_notice = Some(format!("Deleted session '{id}'.")); + } else { + app.history_pending_delete = Some(id); + } + } + KeyCode::Enter => { + app.history_pending_delete = None; + let Some(id) = app.selected_session().map(|e| e.id.clone()) else { + return; + }; + match crate::session_store::load(&id) { + Some(history) if !history.is_empty() => { + let restored = history.len(); + app.backend.restore_history(history).await; + app.messages.push(ChatMessage::system(format!( + "Restored {restored} message(s) from session '{id}'. \ + The model remembers the conversation; the scrollback \ + above does not replay it." + ))); + app.active_tab = Tab::Session; + } + _ => { + app.history_notice = Some(format!( + "Could not restore '{id}': the session is empty or unreadable." + )); + } + } + } + // Any other key cancels a pending delete confirmation. + _ => app.history_pending_delete = None, + }, + Tab::Cloud => match key.code { + KeyCode::Char('l') => { + let enabled = !crate::settings::local_inference_enabled(); + match crate::settings::set_local_inference(enabled) { + Ok(()) => refresh_cloud(app, engine), + Err(error) => { + app.cloud_lines + .get_or_insert_with(Vec::new) + .push(format!("error: could not save the setting: {error}")); + } + } + } + KeyCode::Char('r') => refresh_cloud(app, engine), + _ => {} + }, + } + } + + fn render_tab_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let tabs = Tabs::new(Tab::TITLES.map(Line::from).to_vec()) + .select(app.active_tab.index()) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style( + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(tabs, area); + } + + fn render_history_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" saved sessions "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines: Vec<Line> = Vec::new(); + + if app.history_sessions.is_empty() { + lines.push(Line::from(Span::styled( + " No saved sessions yet. Sessions are saved after each turn.", + Style::default().fg(Color::DarkGray), + ))); + } else { + let now = std::time::SystemTime::now(); + for (index, entry) in app.history_sessions.iter().enumerate() { + let selected = index == app.history_index; + let marker = if selected { "› " } else { " " }; + let age = now.duration_since(entry.modified).ok(); + let row = super::history_row(&entry.id, age, entry.message_count); + let style = if selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default().fg(Color::White) + }; + lines.push(Line::from(Span::styled(format!("{marker}{row}"), style))); + } + } + + if let Some(ref id) = app.history_pending_delete { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" Delete '{id}'? Press d again to confirm — any other key cancels."), + Style::default().fg(Color::Yellow), + ))); + } else if let Some(ref notice) = app.history_notice { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {notice}"), + Style::default().fg(Color::Yellow), + ))); + } + + // Keep the selection visible when the list outgrows the pane. + let inner_height = inner.height as usize; + let scroll = app + .history_index + .saturating_sub(inner_height.saturating_sub(1)) as u16; + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)), + inner, + ); + } + + fn render_cloud_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" siGit Code Cloud "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines: Vec<Line> = Vec::new(); + match app.cloud_lines { + None => lines.push(Line::from(Span::styled( + " fetching status…", + Style::default().fg(Color::DarkGray), + ))), + Some(ref cloud_lines) => { + for text in cloud_lines { + lines.push(Line::from(Span::styled( + format!(" {text}"), + Style::default().fg(Color::White), + ))); + } + } + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Note: toggling Local Inference takes effect for the next model \ + selection (/models); the running backend is not swapped.", + Style::default().fg(Color::DarkGray), + ))); + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + // ── Slash commands ──────────────────────────────────────────────────────── enum SlashCommand { @@ -842,18 +1216,43 @@ mod tui { return; } - let zones = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(3), - Constraint::Length(1), - ]) - .split(area); - - render_title(frame, app, zones[0]); - render_messages(frame, app, zones[1]); - render_input(frame, app, zones[2]); - render_footer(frame, app, zones[3]); + match app.active_tab { + Tab::Session => { + let zones = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + render_tab_bar(frame, app, zones[0]); + render_title(frame, app, zones[1]); + render_messages(frame, app, zones[2]); + render_input(frame, app, zones[3]); + render_footer(frame, app, zones[4]); + } + // No input pane on the non-chat tabs: the Tab key always cycles. + Tab::History | Tab::Cloud => { + let zones = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + + render_tab_bar(frame, app, zones[0]); + render_title(frame, app, zones[1]); + if app.active_tab == Tab::History { + render_history_tab(frame, app, zones[2]); + } else { + render_cloud_tab(frame, app, zones[2]); + } + render_footer(frame, app, zones[3]); + } + } if app.show_model_picker { render_model_picker(frame, app, area); @@ -1163,12 +1562,40 @@ mod tui { } fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let key_style = Style::default().fg(Color::Black).bg(Color::Green); + let label_style = Style::default().fg(Color::DarkGray); + + if app.active_tab != Tab::Session { + let hints: &[(&str, &str)] = match app.active_tab { + Tab::History => &[ + (" ↑/↓ ", " select "), + (" Enter ", " resume "), + (" d ", " delete "), + (" r ", " refresh "), + (" Tab ", " next tab "), + (" Esc ", " session"), + ], + _ => &[ + (" l ", " toggle local inference "), + (" r ", " refresh "), + (" Tab ", " next tab "), + (" Esc ", " session"), + ], + }; + let mut spans = Vec::new(); + for (key, label) in hints { + spans.push(Span::styled(key.to_string(), key_style)); + spans.push(Span::styled(label.to_string(), label_style)); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); + return; + } + let mut spans = vec![ - Span::styled( - " Enter ", - Style::default().fg(Color::Black).bg(Color::Green), - ), - Span::styled(" send ", Style::default().fg(Color::DarkGray)), + Span::styled(" Enter ", key_style), + Span::styled(" send ", label_style), + Span::styled(" Tab ", key_style), + Span::styled(" tabs ", label_style), Span::styled( " /help ", Style::default().fg(Color::Black).bg(Color::DarkGray), @@ -2074,6 +2501,9 @@ mod tui { app.messages.push(ChatMessage::system(format!("error: {msg}"))); } Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => { + // The y/a/n prompt lives on the Session tab; make + // sure the user can see what they're answering. + app.active_tab = Tab::Session; let call = if args.is_empty() { tool.clone() } else { @@ -2092,6 +2522,19 @@ mod tui { } } + // ── Cloud tab status fetch resolving ───────────────────────── + status = async { + match app.cloud_rx.as_mut() { + Some(rx) => rx.await, + None => pending().await, + } + } => { + app.cloud_rx = None; + app.cloud_lines = Some(status.unwrap_or_else(|_| { + vec!["error: the status fetch task died — press r to retry".to_string()] + })); + } + // ── thinking / switching spinner tick (100ms) ──────────────── _ = async { if app.thinking || app.switching_model { @@ -2175,6 +2618,28 @@ mod tui { continue; } + // ── Tab-bar navigation ──────────────────────────────── + // Handled before the busy gate so the user can look at + // History/Cloud while inference runs (updates keep + // landing in the Session tab's message list). The Tab + // key only cycles when the input buffer is empty, so + // pasted text containing tabs can't fight it; on + // non-Session tabs the input is hidden, so it always + // cycles there. + if key.kind == KeyEventKind::Press && !app.show_model_picker { + if key.code == KeyCode::Tab + && (app.active_tab != Tab::Session || app.input.is_empty()) + { + let next = app.active_tab.next(); + switch_tab(&mut app, next, &engine); + continue; + } + if app.active_tab != Tab::Session && key.code == KeyCode::Esc { + app.active_tab = Tab::Session; + continue; + } + } + // busy — only cancel keys work if app.is_busy() { if key.kind == KeyEventKind::Press { @@ -2204,6 +2669,15 @@ mod tui { continue; } + // History / Cloud tabs have their own key handling; the + // chat input is inactive there. + if app.active_tab != Tab::Session { + if key.kind == KeyEventKind::Press { + handle_tab_key(&mut app, key, &engine).await; + } + continue; + } + if let Some(text) = handle_key(&mut app, key) { if let Some(cmd) = parse_slash(&text) { exec_slash(&mut app, cmd, Arc::clone(&engine), terminal).await; @@ -2290,7 +2764,49 @@ pub use tui::run_with; #[cfg(test)] mod tests { - use super::{parse_rich_text_segments, strip_think_blocks}; + use std::time::Duration; + + use super::{Tab, format_age, history_row, parse_rich_text_segments, strip_think_blocks}; + + #[test] + fn tab_next_cycles_session_history_cloud() { + assert_eq!(Tab::Session.next(), Tab::History); + assert_eq!(Tab::History.next(), Tab::Cloud); + assert_eq!(Tab::Cloud.next(), Tab::Session); + // Three hops return to the start, matching the tab bar's order. + assert_eq!(Tab::Session.next().next().next(), Tab::Session); + } + + #[test] + fn tab_index_matches_titles_order() { + assert_eq!(Tab::TITLES[Tab::Session.index()], "Session"); + assert_eq!(Tab::TITLES[Tab::History.index()], "History"); + assert_eq!(Tab::TITLES[Tab::Cloud.index()], "Cloud"); + } + + #[test] + fn format_age_picks_the_coarsest_sensible_unit() { + assert_eq!(format_age(Duration::from_secs(0)), "0s ago"); + assert_eq!(format_age(Duration::from_secs(59)), "59s ago"); + assert_eq!(format_age(Duration::from_secs(60)), "1m ago"); + assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago"); + assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago"); + assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago"); + assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago"); + assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago"); + } + + #[test] + fn history_row_formats_id_age_and_count() { + assert_eq!( + history_row("tui", Some(Duration::from_secs(120)), 7), + "tui · 2m ago · 7 message(s)" + ); + assert_eq!( + history_row("sess-1", None, 0), + "sess-1 · age unknown · 0 message(s)" + ); + } #[test] fn strip_think_blocks_separates_thinking_and_visible_reply() {
src/session_store.rs
+94
index 103a9a4..9845a1b 100644 --- a/src/session_store.rs +++ b/src/session_store.rs @@ -104,6 +104,60 @@ pub fn delete(session_id: &str) { } } +/// One saved session as seen on disk. +/// +/// Cross-platform like the rest of the store, though today only the Unix-only +/// TUI (`chat.rs` History tab) consumes it — hence the non-Unix dead-code gate, +/// mirroring `permissions::TUI_SESSION`. +#[cfg_attr(not(unix), allow(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionEntry { + /// The sanitized id (the file stem), which `load`/`delete` accept as-is. + pub id: String, + /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable. + pub modified: std::time::SystemTime, + /// Number of history messages (non-empty lines) in the file. + pub message_count: usize, +} + +/// List the saved sessions, newest first. Non-`.jsonl` entries (temp files, +/// stray junk) are skipped. A missing or unreadable sessions dir yields an +/// empty list. +#[cfg_attr(not(unix), allow(dead_code))] +pub fn list() -> Vec<SessionEntry> { + let Some(dir) = sessions_dir() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let mut sessions: Vec<SessionEntry> = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + return None; + } + let id = path.file_stem()?.to_str()?.to_string(); + // Cheap line count: no JSON parsing, just non-empty lines. + let contents = std::fs::read_to_string(&path).ok()?; + let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count(); + let modified = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::UNIX_EPOCH); + Some(SessionEntry { + id, + modified, + message_count, + }) + }) + .collect(); + sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id))); + sessions +} + #[cfg(test)] mod tests { use super::*; @@ -164,4 +218,44 @@ mod tests { unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; let _ = std::fs::remove_dir_all(&dir); } + + // Same single-test-per-env-var pattern as `save_load_delete_round_trip`: + // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold. + #[test] + fn list_returns_sessions_newest_first_and_skips_junk() { + let _guard = crate::ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // SAFETY: serialized by ENV_TEST_LOCK; restored below. + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; + + // No sessions dir at all → empty list, not an error. + assert!(list().is_empty()); + + let msg = |text: &str| serde_json::json!({ "role": "user", "content": text }); + save("older", &[msg("a"), msg("b"), msg("c")]).unwrap(); + // Distinct mtimes so the newest-first order is deterministic. + std::thread::sleep(std::time::Duration::from_millis(50)); + save("newer", &[msg("x")]).unwrap(); + + // Junk the lister must skip: wrong extension, a stray temp file, and a + // subdirectory. + let sessions = dir.join("sessions"); + std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap(); + std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap(); + std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap(); + + let listed = list(); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].id, "newer"); + assert_eq!(listed[0].message_count, 1); + assert_eq!(listed[1].id, "older"); + assert_eq!(listed[1].message_count, 3); + assert!(listed[0].modified >= listed[1].modified); + + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; + let _ = std::fs::remove_dir_all(&dir); + } }