mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user