feat(resume): add per-directory session index and fast /resume picker with themed table UI; humanized times, snippet truncation, startup resume tip; filter empty sessions and system status; improve replay cleanliness and alignment; fix last-row rendering and build warnings

This commit is contained in:
James Peter
2025-08-25 16:59:25 +10:00
parent bee040acdc
commit 0488753331
6 changed files with 521 additions and 104 deletions
+148 -6
View File
@@ -35,6 +35,9 @@ pub struct SessionMeta {
/// with older rollout files that did not record this information.
#[serde(default)]
pub cwd: Option<String>,
/// Model identifier for the session.
#[serde(default)]
pub model: Option<String>,
}
#[derive(Serialize)]
@@ -87,11 +90,7 @@ impl RolloutRecorder {
uuid: Uuid,
instructions: Option<String>,
) -> std::io::Result<Self> {
let LogFileInfo {
file,
session_id,
timestamp,
} = create_log_file(config, uuid)?;
let LogFileInfo { file, session_id, timestamp, path } = create_log_file(config, uuid)?;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
@@ -119,8 +118,11 @@ impl RolloutRecorder {
id: session_id,
instructions,
cwd: Some(config.cwd.to_string_lossy().to_string()),
model: Some(config.model.clone()),
}),
cwd,
config.codex_home.clone(),
path,
));
Ok(Self { tx })
@@ -229,7 +231,9 @@ impl RolloutRecorder {
tokio::fs::File::from_std(file),
rx,
None,
cwd,
cwd.clone(),
crate::config::find_codex_home().unwrap_or_else(|_| std::env::temp_dir()),
path.to_path_buf(),
));
info!("Resumed rollout successfully from {path:?}");
Ok((Self { tx }, saved))
@@ -260,6 +264,9 @@ struct LogFileInfo {
/// Timestamp for the start of the session.
timestamp: OffsetDateTime,
/// Absolute path to the rollout file on disk.
path: std::path::PathBuf,
}
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
@@ -293,6 +300,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
file,
session_id,
timestamp,
path,
})
}
@@ -301,6 +309,8 @@ async fn rollout_writer(
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
codex_home: std::path::PathBuf,
rollout_path: std::path::PathBuf,
) -> std::io::Result<()> {
let mut writer = JsonlWriter { file };
@@ -314,12 +324,27 @@ async fn rollout_writer(
// Write the SessionMeta as the first item in the file
writer.write_line(&session_meta_with_git).await?;
// Best-effort initial directory index update
if let Err(e) = append_dir_index_update(
&codex_home,
&cwd,
&session_meta_with_git,
&rollout_path,
0,
None,
) {
warn!("failed to write dir index: {e}");
}
}
// Process rollout commands
while let Some(cmd) = rx.recv().await {
match cmd {
RolloutCmd::AddItems(items) => {
// Track message count and last user snippet while writing
let mut msg_count_delta = 0usize;
let mut last_user_snippet: Option<String> = None;
for item in items {
match item {
ResponseItem::Message { .. }
@@ -329,11 +354,54 @@ async fn rollout_writer(
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => {
// Skip internal system status messages from persistence
if let ResponseItem::Message { content, .. } = &item {
let contains_system_status = content.iter().any(|ci| match ci {
codex_protocol::models::ContentItem::InputText { text }
| codex_protocol::models::ContentItem::OutputText { text } => text.contains("== System Status =="),
_ => false,
});
if contains_system_status {
continue;
}
}
writer.write_line(&item).await?;
if let ResponseItem::Message { role, content, .. } = &item {
if role == "user" {
// Synthesize a short snippet
let mut s = String::new();
for c in content {
if let codex_protocol::models::ContentItem::InputText { text } = c {
if !text.is_empty() {
s.push_str(text);
break;
}
}
}
if s.is_empty() {
s = "(image/message)".to_string();
}
last_user_snippet = Some(s);
}
msg_count_delta = msg_count_delta.saturating_add(1);
}
}
ResponseItem::Other => {}
}
}
// Best-effort index append after batch
if let Err(e) = append_dir_index_update(
&codex_home,
&cwd,
&SessionMetaWithGit { meta: SessionMeta { id: Uuid::nil(), timestamp: String::new(), instructions: None, cwd: Some(cwd.to_string_lossy().to_string()), model: None }, git: None },
&rollout_path,
msg_count_delta,
last_user_snippet,
) {
warn!("failed to write dir index: {e}");
}
}
RolloutCmd::UpdateState(state) => {
#[derive(Serialize)]
@@ -371,3 +439,77 @@ impl JsonlWriter {
Ok(())
}
}
#[derive(Serialize)]
struct DirIndexLine<'a> {
record_type: &'static str,
cwd: &'a str,
session_file: &'a str,
session_id: String,
created_ts: Option<&'a str>,
modified_ts: String,
message_count_delta: usize,
model: Option<&'a str>,
branch: Option<&'a str>,
last_user_snippet: Option<String>,
}
fn dir_index_path(codex_home: &std::path::Path, cwd: &std::path::Path) -> std::path::PathBuf {
let mut name = cwd.to_string_lossy().to_string();
name = name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if name.len() > 160 {
name.truncate(160);
}
let mut p = codex_home.to_path_buf();
p.push(SESSIONS_SUBDIR);
p.push("index");
p.push("by-dir");
let _ = fs::create_dir_all(&p);
p.push(format!("{}.jsonl", name));
p
}
fn append_dir_index_update(
codex_home: &std::path::Path,
cwd: &std::path::Path,
session_meta_with_git: &SessionMetaWithGit,
rollout_path: &std::path::Path,
message_count_delta: usize,
last_user_snippet: Option<String>,
) -> std::io::Result<()> {
let path = dir_index_path(codex_home, cwd);
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&path)?;
let now = OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "".to_string());
let meta = &session_meta_with_git.meta;
let branch = session_meta_with_git.git.as_ref().and_then(|g| g.branch.as_deref());
let sf_binding = rollout_path.to_string_lossy();
let line = DirIndexLine {
record_type: "dir_index",
cwd: meta.cwd.as_deref().unwrap_or("") ,
session_file: sf_binding.as_ref(),
session_id: meta.id.to_string(),
created_ts: Some(&meta.timestamp),
modified_ts: now,
message_count_delta,
model: meta.model.as_deref(),
branch,
last_user_snippet,
};
let s = serde_json::to_string(&line)
.map_err(|e| IoError::other(format!("failed to serialize dir index line: {e}")))?;
use std::io::Write as _;
f.write_all(s.as_bytes())?;
f.write_all(b"\n")
}
@@ -8,7 +8,8 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::{Paragraph, Block, Borders, Clear};
use ratatui::layout::Alignment;
use ratatui::widgets::Widget;
use crate::app_event_sender::AppEventSender;
@@ -156,57 +157,33 @@ impl BottomPaneView<'_> for ListSelectionView {
return;
}
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
// Clear and draw a bordered block matching other slash popups
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::light_blue()))
.title(self.title.clone())
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let title_spans: Vec<Span<'static>> = vec![
Self::dim_prefix_span(),
Span::styled(
self.title.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
];
let title_para = Paragraph::new(Line::from(title_spans));
title_para.render(title_area, buf);
let mut next_y = area.y.saturating_add(1);
// Layout inside the block: optional subtitle header, rows, footer
let mut next_y = inner.y;
if let Some(sub) = &self.subtitle {
let subtitle_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let subtitle_area = Rect { x: inner.x, y: next_y, width: inner.width, height: 1 };
let subtitle_spans: Vec<Span<'static>> = vec![
Self::dim_prefix_span(),
Span::styled(sub.clone(), Style::default().add_modifier(Modifier::DIM)),
Span::styled(sub.clone(), Style::default().fg(crate::colors::text_dim())),
];
let subtitle_para = Paragraph::new(Line::from(subtitle_spans));
subtitle_para.render(subtitle_area, buf);
// Render the extra spacer line with the dimmed prefix to align with title/subtitle
let spacer_area = Rect {
x: area.x,
y: next_y.saturating_add(1),
width: area.width,
height: 1,
};
Self::render_dim_prefix_line(spacer_area, buf);
next_y = next_y.saturating_add(2);
Paragraph::new(Line::from(subtitle_spans)).render(subtitle_area, buf);
next_y = next_y.saturating_add(1);
}
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
let footer_reserved = if self.footer_hint.is_some() { 1 } else { 0 };
let rows_area = Rect {
x: area.x,
x: inner.x,
y: next_y,
width: area.width,
height: area
.height
.saturating_sub(next_y.saturating_sub(area.y))
.saturating_sub(footer_reserved),
width: inner.width,
height: inner.height.saturating_sub(next_y.saturating_sub(inner.y)).saturating_sub(footer_reserved),
};
let rows: Vec<GenericDisplayRow> = self
@@ -235,18 +212,17 @@ impl BottomPaneView<'_> for ListSelectionView {
render_rows(rows_area, buf, &rows, &self.state, MAX_POPUP_ROWS, true);
}
if let Some(hint) = &self.footer_hint {
let footer_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
let footer_para = Paragraph::new(Line::from(Span::styled(
hint.clone(),
Style::default().add_modifier(Modifier::DIM),
)));
footer_para.render(footer_area, buf);
if self.footer_hint.is_some() {
let footer_area = Rect { x: inner.x, y: inner.y + inner.height - 1, width: inner.width, height: 1 };
let line = Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Select "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
]);
Paragraph::new(line).render(footer_area, buf);
}
}
}
+6 -19
View File
@@ -25,7 +25,8 @@ mod reasoning_selection_view;
mod scroll_state;
mod selection_popup_common;
pub mod list_selection_view;
use list_selection_view::{ListSelectionView, SelectionItem, SelectionAction};
pub mod resume_selection_view;
// no direct use of list_selection_view or its items here
mod textarea;
mod theme_selection_view;
mod verbosity_selection_view;
@@ -390,29 +391,15 @@ impl BottomPane<'_> {
self.request_redraw()
}
/// Show the resume selection UI given a list of (rows, path) tuples.
/// Show the resume selection UI with structured rows
pub fn show_resume_selection(
&mut self,
title: String,
subtitle: Option<String>,
entries: Vec<(String, Option<String>, std::path::PathBuf)>,
rows: Vec<resume_selection_view::ResumeRow>,
) {
let items: Vec<SelectionItem> = entries
.into_iter()
.map(|(name, description, path)| SelectionItem {
name,
description,
is_current: false,
actions: vec![Box::new(move |tx: &AppEventSender| tx.send(AppEvent::ResumeFrom(path.clone()))) as SelectionAction],
})
.collect();
let view = ListSelectionView::new(
title,
subtitle,
Some("Enter: Select Esc: Cancel".to_string()),
items,
self.app_event_tx.clone(),
);
use resume_selection_view::ResumeSelectionView;
let view = ResumeSelectionView::new(title, subtitle.unwrap_or_default(), rows, self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw()
@@ -0,0 +1,159 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Row, Table};
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::{BottomPaneView, ConditionalUpdate};
use super::{BottomPane, popup_consts::MAX_POPUP_ROWS};
pub struct ResumeRow {
pub modified: String,
pub created: String,
pub msgs: String,
pub branch: String,
pub summary: String,
pub path: std::path::PathBuf,
}
pub struct ResumeSelectionView {
title: String,
subtitle: String,
rows: Vec<ResumeRow>,
selected: usize,
complete: bool,
app_event_tx: AppEventSender,
}
impl ResumeSelectionView {
pub fn new(title: String, subtitle: String, rows: Vec<ResumeRow>, app_event_tx: AppEventSender) -> Self {
Self { title, subtitle, rows, selected: 0, complete: false, app_event_tx }
}
fn move_up(&mut self) {
if self.rows.is_empty() { return; }
if self.selected == 0 { self.selected = self.rows.len().saturating_sub(1); }
else { self.selected -= 1; }
}
fn move_down(&mut self) {
if self.rows.is_empty() { return; }
self.selected = (self.selected + 1) % self.rows.len();
}
}
impl BottomPaneView<'_> for ResumeSelectionView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up => self.move_up(),
KeyCode::Down => self.move_down(),
KeyCode::Enter => {
if let Some(row) = self.rows.get(self.selected) {
self.app_event_tx.send(AppEvent::ResumeFrom(row.path.clone()));
self.complete = true;
}
}
KeyCode::Esc => self.complete = true,
_ => {}
}
}
fn is_complete(&self) -> bool { self.complete }
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> super::CancellationEvent {
self.complete = true; super::CancellationEvent::Handled
}
fn update_status_text(&mut self, _text: String) -> ConditionalUpdate { ConditionalUpdate::NeedsRedraw }
fn desired_height(&self, _width: u16) -> u16 {
// Include block borders (+2), optional subtitle (+1), table header (+1),
// clamped rows, spacer (+1), footer (+1)
let rows = self.rows.len().clamp(1, MAX_POPUP_ROWS) as u16;
let subtitle = if self.subtitle.is_empty() { 0 } else { 1 };
2 + subtitle + 1 + rows + 1 + 1
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 { return; }
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::light_blue()))
.title(self.title.clone())
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
// Optional subtitle (path, etc.)
let mut next_y = inner.y;
if !self.subtitle.is_empty() {
Paragraph::new(Line::from(Span::styled(
&self.subtitle,
Style::default().fg(crate::colors::text_dim()),
)))
.render(Rect { x: inner.x, y: next_y, width: inner.width, height: 1 }, buf);
next_y = next_y.saturating_add(1);
}
// Reserve one blank spacer line above the footer
let footer_reserved: u16 = 2;
let table_area = Rect {
x: inner.x,
y: next_y,
width: inner.width,
height: inner
.height
.saturating_sub(footer_reserved + (next_y - inner.y)),
};
// Build rows
let rows_iter = self.rows.iter().enumerate().map(|(i, r)| {
let cells = vec![
r.modified.clone(), r.created.clone(), r.msgs.clone(), r.branch.clone(), r.summary.clone()
].into_iter().map(|c| ratatui::widgets::Cell::from(c));
let mut row = Row::new(cells).height(1);
if i == self.selected {
row = row.style(Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD));
}
row
});
// Column constraints roughly match header widths
let widths = [
Constraint::Length(10), // Modified
Constraint::Length(10), // Created
Constraint::Length(6), // #Msgs
Constraint::Length(10), // Branch
Constraint::Min(10), // Summary
];
let header = Row::new(vec!["Modified", "Created", "#Msgs", "Branch", "Summary"]).height(1)
.style(Style::default().fg(crate::colors::text_bright()));
let table = Table::new(rows_iter, widths)
.header(header)
.highlight_symbol("")
.column_spacing(1);
table.render(table_area, buf);
// Footer hints
// Draw a spacer line above footer (implicit by not drawing into that row)
let footer = Rect { x: inner.x, y: inner.y + inner.height - 1, width: inner.width, height: 1 };
let footer_line = Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Select "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
]);
Paragraph::new(footer_line).render(footer, buf);
}
}
+84 -16
View File
@@ -471,14 +471,52 @@ impl ChatWidget<'_> {
));
return;
}
// Convert to simple rows
let items: Vec<(String, Option<String>, std::path::PathBuf)> = candidates
// Convert to simple rows with aligned columns and human-friendly times
fn human_ago(ts: &str) -> String {
use chrono::{DateTime, Utc};
if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
let now = Utc::now();
let delta = now.signed_duration_since(dt.with_timezone(&Utc));
let secs = delta.num_seconds().max(0);
let mins = secs / 60;
let hours = mins / 60;
let days = hours / 24;
if days >= 7 {
// Show date for older entries
return dt.format("%Y-%m-%d").to_string();
}
if days >= 1 {
return format!("{}d ago", days);
}
if hours >= 1 {
return format!("{}h ago", hours);
}
if mins >= 1 {
return format!("{}m ago", mins);
}
return "just now".to_string();
}
ts.to_string()
}
let rows: Vec<crate::bottom_pane::resume_selection_view::ResumeRow> = candidates
.into_iter()
.map(|c| (c.title, c.subtitle, c.path))
.map(|c| {
let modified = human_ago(&c.modified_ts.unwrap_or_default());
let created = human_ago(&c.created_ts.unwrap_or_default());
let msgs = format!("{}", c.message_count);
let branch = c.branch.unwrap_or_else(|| "-".to_string());
let mut summary = c.snippet.unwrap_or_else(|| c.subtitle.unwrap_or_default());
const SNIPPET_MAX: usize = 64;
if summary.chars().count() > SNIPPET_MAX {
summary = summary.chars().take(SNIPPET_MAX).collect::<String>() + "…";
}
crate::bottom_pane::resume_selection_view::ResumeRow { modified, created, msgs, branch, summary, path: c.path }
})
.collect();
let title = "Resume Session".to_string();
let subtitle = Some(format!("{}", cwd.display()));
self.bottom_pane.show_resume_selection(title, subtitle, items);
let title = format!("Resume Session — {}", cwd.display());
let subtitle = Some(String::new());
self.bottom_pane.show_resume_selection(title, subtitle, rows);
}
/// Render a single recorded ResponseItem into history without executing tools
@@ -496,6 +534,10 @@ impl ChatWidget<'_> {
_ => {}
}
}
// Skip internal system status messages
if text.contains("== System Status ==") {
return;
}
if role == "user" {
self.add_to_history(crate::history_cell::new_user_prompt(text));
} else {
@@ -509,19 +551,24 @@ impl ChatWidget<'_> {
}
ResponseItem::Reasoning { summary, .. } => {
for s in summary {
if let codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } = s {
// Reasoning cell – use the existing reasoning output styling
let sink = crate::streaming::controller::AppEventHistorySink(self.app_event_tx.clone());
self.current_stream_kind = Some(crate::streaming::StreamKind::Reasoning);
let _ = self.stream.apply_final_reasoning(&text, &sink);
// finalize immediately for static replay
self.stream.finalize(crate::streaming::StreamKind::Reasoning, true, &sink);
}
let codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } = s;
// Reasoning cell – use the existing reasoning output styling
let sink = crate::streaming::controller::AppEventHistorySink(self.app_event_tx.clone());
self.current_stream_kind = Some(crate::streaming::StreamKind::Reasoning);
let _ = self.stream.apply_final_reasoning(&text, &sink);
// finalize immediately for static replay
self.stream.finalize(crate::streaming::StreamKind::Reasoning, true, &sink);
}
}
ResponseItem::FunctionCallOutput { output, .. } => {
// Show as a background event for now
self.add_to_history(crate::history_cell::new_background_event(output.content));
// Try to unwrap common JSON wrapper {"output": "...", ...}
let mut content = output.content;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(s) = v.get("output").and_then(|x| x.as_str()) {
content = s.to_string();
}
}
self.add_to_history(crate::history_cell::new_background_event(content));
}
_ => {
// Ignore other item kinds for replay (tool calls, etc.)
@@ -1266,6 +1313,27 @@ impl ChatWidget<'_> {
// add AnimatedWelcomeCell silently
history_cells.push(welcome_cell);
// Startup hint: show /resume tip if a recent session exists for this cwd (within 72h)
{
let cwd = config.cwd.clone();
let codex_home = config.codex_home.clone();
let recent = crate::resume::discovery::list_sessions_for_cwd(&cwd, &codex_home).into_iter().next();
if let Some(rec) = recent {
if let Some(ts) = rec.modified_ts.as_ref() {
use chrono::{DateTime, Utc};
if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
let now = Utc::now();
let age = now.signed_duration_since(dt.with_timezone(&Utc));
if age.num_hours() <= 72 {
history_cells.push(Box::new(history_cell::new_background_event(
"Tip: /resume to continue your last session".to_string(),
)));
}
}
}
}
}
// Initialize image protocol for rendering screenshots
let new_widget = Self {
+92 -7
View File
@@ -7,9 +7,13 @@ use std::path::{Path, PathBuf};
/// One candidate session for the picker
pub struct ResumeCandidate {
pub path: PathBuf,
pub title: String,
pub subtitle: Option<String>,
pub sort_key: String,
pub created_ts: Option<String>,
pub modified_ts: Option<String>,
pub message_count: usize,
pub branch: Option<String>,
pub snippet: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -27,23 +31,105 @@ struct MetaLine {
/// Return rollout files under ~/.codex/sessions matching the provided cwd.
/// Reads only the first line of each file to avoid heavy IO.
pub fn list_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCandidate> {
// First: try per-directory index
if let Some(mut v) = read_dir_index(codex_home, cwd) {
v.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
return v.into_iter().take(200).collect();
}
// Fallback: scan rollouts
let mut out: Vec<ResumeCandidate> = Vec::new();
let mut sessions_dir = codex_home.to_path_buf();
sessions_dir.push("sessions");
let target = match cwd.canonicalize() { Ok(p) => p, Err(_) => cwd.to_path_buf() };
let paths = walk_sessions(&sessions_dir);
for path in paths {
if let Some(candidate) = read_first_line(&path).and_then(|v| build_candidate(&path, v, &target)) {
out.push(candidate);
}
}
// Sort by timestamp descending (string compare works for RFC3339-like format)
out.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
out.into_iter().take(200).collect()
}
#[derive(Deserialize)]
struct DirIndexLine {
record_type: String,
cwd: String,
session_file: String,
created_ts: Option<String>,
modified_ts: Option<String>,
message_count_delta: Option<usize>,
model: Option<String>,
branch: Option<String>,
last_user_snippet: Option<String>,
}
fn read_dir_index(codex_home: &Path, cwd: &Path) -> Option<Vec<ResumeCandidate>> {
let index_path = super_sanitize_dir_index_path(codex_home, cwd);
let f = fs::File::open(index_path).ok()?;
let reader = BufReader::new(f);
use std::collections::HashMap;
struct Accum {
created: Option<String>,
modified: Option<String>,
count: usize,
model: Option<String>,
branch: Option<String>,
snippet: Option<String>,
}
let mut map: HashMap<String, Accum> = HashMap::new();
for line in reader.lines() {
let Ok(l) = line else { continue };
if l.trim().is_empty() { continue; }
let Ok(v) = serde_json::from_str::<DirIndexLine>(&l) else { continue };
if v.record_type != "dir_index" { continue; }
if v.cwd.is_empty() { continue; }
let e = map.entry(v.session_file.clone()).or_insert(Accum {
created: v.created_ts.clone(),
modified: v.modified_ts.clone(),
count: 0,
model: v.model.clone(),
branch: v.branch.clone(),
snippet: None,
});
if e.created.is_none() { e.created = v.created_ts.clone(); }
e.modified = v.modified_ts.clone().or(e.modified.take());
e.count = e.count.saturating_add(v.message_count_delta.unwrap_or(0));
if let Some(s) = v.last_user_snippet { if !s.is_empty() { e.snippet = Some(s); } }
if e.model.is_none() { e.model = v.model.clone(); }
if e.branch.is_none() { e.branch = v.branch.clone(); }
}
let mut out = Vec::new();
for (path, a) in map.into_iter() {
if a.count == 0 { continue; }
let subtitle = a.snippet.clone();
out.push(ResumeCandidate {
path: PathBuf::from(path),
subtitle: subtitle.clone(),
sort_key: a.modified.clone().unwrap_or_default(),
created_ts: a.created,
modified_ts: a.modified,
message_count: a.count,
branch: a.branch,
snippet: subtitle,
});
}
Some(out)
}
fn super_sanitize_dir_index_path(codex_home: &Path, cwd: &Path) -> PathBuf {
let mut name = cwd.to_string_lossy().to_string();
name = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
if name.len() > 160 { name.truncate(160); }
let mut p = codex_home.to_path_buf();
p.push("sessions");
p.push("index");
p.push("by-dir");
p.push(format!("{}.jsonl", name));
p
}
fn walk_sessions(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if !root.exists() { return files; }
@@ -92,12 +178,11 @@ fn build_candidate(path: &Path, v: Value, target_cwd: &Path) -> Option<ResumeCan
// Build fields for UI (title + subtitle)
let ts = meta.timestamp.unwrap_or_default();
let model = meta.model.unwrap_or_default();
let _model = meta.model.unwrap_or_default();
let id = meta.id.unwrap_or_default();
let title = format!("{} • {}", ts, model);
let subtitle = Some(truncate_middle(&id, 12));
Some(ResumeCandidate { path: path.to_path_buf(), title, subtitle, sort_key: ts })
Some(ResumeCandidate { path: path.to_path_buf(), subtitle, sort_key: ts.clone(), created_ts: Some(ts), modified_ts: None, message_count: 0, branch: None, snippet: None })
}
fn truncate_middle(s: &str, max: usize) -> String {