Skip to content

Commit 4b863cc

Browse files
committed
exec/tui: show resume rollout path; picker [project] tag; plain verify + filter; panic hook for alt-screen cleanup; tests green
Signed-off-by: Graham Anderson <graham@grahama.co>
1 parent d7286e9 commit 4b863cc

File tree

7 files changed

+176
-10
lines changed

7 files changed

+176
-10
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/exec/src/event_processor_with_human_output.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
141141
/// Print a concise summary of the effective configuration that will be used
142142
/// for the session. This mirrors the information shown in the TUI welcome
143143
/// screen.
144-
fn print_config_summary(&mut self, config: &Config, prompt: &str, _: &SessionConfiguredEvent) {
144+
fn print_config_summary(&mut self, config: &Config, prompt: &str, ev: &SessionConfiguredEvent) {
145145
const VERSION: &str = env!("CARGO_PKG_VERSION");
146146
ts_println!(
147147
self,
@@ -155,6 +155,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
155155
println!("{} {}", format!("{key}:").style(self.bold), value);
156156
}
157157

158+
// Make the rollout (resume) file location visible at session start.
159+
// Available for both new and resumed sessions; especially useful on resume.
160+
println!(
161+
"{} {}",
162+
"resume file:".style(self.bold),
163+
ev.rollout_path.display()
164+
);
165+
158166
println!("--------");
159167

160168
// Echo the prompt that will be sent to the agent so it is visible in the

codex-rs/exec/tests/suite/resume.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,63 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
266266
assert!(content.contains(&marker2));
267267
Ok(())
268268
}
269+
270+
#[test]
271+
fn exec_resume_summary_shows_rollout_path() -> anyhow::Result<()> {
272+
let home = TempDir::new()?;
273+
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
274+
.join("tests/fixtures/cli_responses_fixture.sse");
275+
276+
// First run creates a session
277+
let marker = format!("resume-summary-{}", Uuid::new_v4());
278+
let prompt = format!("echo {marker}");
279+
280+
Command::cargo_bin("codex-exec")
281+
.context("should find binary for codex-exec")?
282+
.env("CODEX_HOME", home.path())
283+
.env("OPENAI_API_KEY", "dummy")
284+
.env("CODEX_RS_SSE_FIXTURE", &fixture)
285+
.env("OPENAI_BASE_URL", "http://unused.local")
286+
.arg("--skip-git-repo-check")
287+
.arg("-C")
288+
.arg(env!("CARGO_MANIFEST_DIR"))
289+
.arg(&prompt)
290+
.assert()
291+
.success();
292+
293+
// Locate created rollout file
294+
let sessions_dir = home.path().join("sessions");
295+
let path = find_session_file_containing_marker(&sessions_dir, &marker)
296+
.expect("no session file found after first run");
297+
298+
// Resume and capture stdout
299+
let marker2 = format!("resume-summary-2-{}", Uuid::new_v4());
300+
let prompt2 = format!("echo {marker2}");
301+
302+
let output = Command::cargo_bin("codex-exec")
303+
.context("should find binary for codex-exec")?
304+
.env("CODEX_HOME", home.path())
305+
.env("OPENAI_API_KEY", "dummy")
306+
.env("CODEX_RS_SSE_FIXTURE", &fixture)
307+
.env("OPENAI_BASE_URL", "http://unused.local")
308+
.arg("--skip-git-repo-check")
309+
.arg("-C")
310+
.arg(env!("CARGO_MANIFEST_DIR"))
311+
.arg(&prompt2)
312+
.arg("resume")
313+
.arg("--last")
314+
.output()
315+
.context("resume run should succeed")?;
316+
317+
assert!(output.status.success(), "resume run failed: {output:?}");
318+
let stdout = String::from_utf8(output.stdout)?;
319+
320+
// Expect the rollout path to be printed in the summary
321+
let expected_line = format!("resume file: {}", path.display());
322+
assert!(
323+
stdout.contains(&expected_line),
324+
"stdout missing resume path line. expected to find: {expected_line}\nstdout was:\n{stdout}"
325+
);
326+
327+
Ok(())
328+
}

codex-rs/tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ shlex = { workspace = true }
6464
strum = { workspace = true }
6565
strum_macros = { workspace = true }
6666
supports-color = { workspace = true }
67+
is-terminal = "0.4"
6768
tempfile = { workspace = true }
6869
textwrap = { workspace = true }
6970
tokio = { workspace = true, features = [

codex-rs/tui/src/history_cell.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,8 @@ pub(crate) fn new_session_info(
376376
session_id: _,
377377
history_log_id: _,
378378
history_entry_count: _,
379-
initial_messages: _,
380-
rollout_path: _,
379+
initial_messages,
380+
rollout_path,
381381
} = event;
382382
if is_first_event {
383383
// Header box rendered as history (so it appears at the very top)
@@ -416,12 +416,21 @@ pub(crate) fn new_session_info(
416416
]),
417417
];
418418

419-
CompositeHistoryCell {
420-
parts: vec![
421-
Box::new(header),
422-
Box::new(PlainHistoryCell { lines: help_lines }),
423-
],
419+
// If resuming, surface the rollout (resume) file path for quick discovery.
420+
// We detect resume via presence of `initial_messages` in SessionConfiguredEvent.
421+
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
422+
if initial_messages.is_some() {
423+
let line: Line<'static> = vec![
424+
// Keep label styling consistent with other summary lines (dim label).
425+
Span::from("resume file: ").dim(),
426+
Span::from(rollout_path.display().to_string()),
427+
]
428+
.into();
429+
parts.push(Box::new(PlainHistoryCell { lines: vec![line] }));
424430
}
431+
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));
432+
433+
CompositeHistoryCell { parts }
425434
} else if config.model == model {
426435
CompositeHistoryCell { parts: vec![] }
427436
} else {

codex-rs/tui/src/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,30 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
8181
use crate::onboarding::onboarding_screen::run_onboarding_app;
8282
use crate::tui::Tui;
8383
pub use cli::Cli;
84+
use codex_core::internal_storage::InternalStorage;
85+
use crossterm::terminal::LeaveAlternateScreen;
86+
use crossterm::terminal::disable_raw_mode;
87+
88+
#[allow(clippy::print_stderr)]
89+
fn install_panic_hook_once() {
90+
static ONCE: std::sync::Once = std::sync::Once::new();
91+
ONCE.call_once(|| {
92+
std::panic::set_hook(Box::new(|info| {
93+
// Best-effort cleanup so panic is visible to the user.
94+
let _ = disable_raw_mode();
95+
let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen);
96+
eprintln!("\n\ncodex-tui panic: {info}");
97+
}));
98+
});
99+
}
84100

85101
// (tests access modules directly within the crate)
86102

87103
pub async fn run_main(
88104
cli: Cli,
89105
codex_linux_sandbox_exe: Option<PathBuf>,
90106
) -> std::io::Result<AppExitInfo> {
107+
install_panic_hook_once();
91108
let (sandbox_mode, approval_policy) = if cli.full_auto {
92109
(
93110
Some(SandboxMode::WorkspaceWrite),

codex-rs/tui/src/resume_picker.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use ratatui::layout::Rect;
1919
use ratatui::style::Stylize as _;
2020
use ratatui::text::Line;
2121
use ratatui::text::Span;
22+
use std::env;
2223
use tokio::sync::mpsc;
2324
use tokio_stream::StreamExt;
2425
use tokio_stream::wrappers::UnboundedReceiverStream;
@@ -30,6 +31,7 @@ use crate::tui::TuiEvent;
3031
use codex_protocol::models::ContentItem;
3132
use codex_protocol::models::ResponseItem;
3233
use codex_protocol::protocol::InputMessageKind;
34+
use codex_protocol::protocol::SessionMetaLine;
3335
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
3436

3537
const PAGE_SIZE: usize = 25;
@@ -64,6 +66,41 @@ enum BackgroundEvent {
6466
/// search and pagination. Shows the first user input as the preview, relative
6567
/// time (e.g., "5 seconds ago"), and the absolute path.
6668
pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<ResumeSelection> {
69+
// Plain, non-interactive verification mode: print rows and exit.
70+
// Enables checking the '[project]' tag & preview without onboarding/TUI.
71+
if env::var("CODEX_TUI_PLAIN").as_deref() == Ok("1") {
72+
match RolloutRecorder::list_conversations(codex_home, PAGE_SIZE, None).await {
73+
Ok(page) => {
74+
let rows: Vec<Row> = page.items.iter().map(|it| head_to_row(it)).collect();
75+
let no_color = env::var("NO_COLOR").is_ok();
76+
let dumb = env::var("TERM").unwrap_or_default() == "dumb";
77+
let use_color = !no_color && !dumb;
78+
for (i, r) in rows.iter().enumerate() {
79+
let mark = if i == 0 { "> " } else { " " };
80+
let ts =
81+
r.ts.as_ref()
82+
.map(|dt| human_time_ago(dt.clone()))
83+
.unwrap_or_else(|| "-".to_string());
84+
let tag = r.project.as_deref().unwrap_or("<cwd>");
85+
// Sanitize preview to a single line, limited length similar to TUI
86+
let mut pv = r.preview.replace('\n', " ");
87+
if pv.len() > 80 {
88+
pv.truncate(79);
89+
pv.push('…');
90+
}
91+
if use_color {
92+
println!("{mark}{ts:<12} \x1b[36;1m[{tag}]\x1b[0m {pv}");
93+
} else {
94+
println!("{mark}{ts:<12} [{tag}] {pv}");
95+
}
96+
}
97+
}
98+
Err(e) => {
99+
eprintln!("Failed to list conversations: {e}");
100+
}
101+
}
102+
return Ok(ResumeSelection::StartFresh);
103+
}
67104
let alt = AltScreenGuard::enter(tui);
68105
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
69106

@@ -91,6 +128,12 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<Resum
91128
page_loader,
92129
);
93130
state.load_initial_page().await?;
131+
if let Ok(q) = env::var("CODEX_TUI_FILTER") {
132+
let q = q.trim();
133+
if !q.is_empty() {
134+
state.set_query(q.to_string());
135+
}
136+
}
94137
state.request_frame();
95138

96139
let mut tui_events = alt.tui.event_stream().fuse();
@@ -219,6 +262,7 @@ struct Row {
219262
path: PathBuf,
220263
preview: String,
221264
ts: Option<DateTime<Utc>>,
265+
project: Option<String>,
222266
}
223267

224268
impl PickerState {
@@ -565,13 +609,27 @@ fn rows_from_items(items: Vec<ConversationItem>) -> Vec<Row> {
565609

566610
fn head_to_row(item: &ConversationItem) -> Row {
567611
let mut ts: Option<DateTime<Utc>> = None;
612+
let mut project: Option<String> = None;
568613
if let Some(first) = item.head.first()
569614
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
570615
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
571616
{
572617
ts = Some(parsed.with_timezone(&Utc));
573618
}
574619

620+
// Attempt to derive the project tag from the SessionMeta line (cwd basename).
621+
for value in &item.head {
622+
if let Ok(meta_line) = serde_json::from_value::<SessionMetaLine>(value.clone()) {
623+
let cwd = meta_line.meta.cwd;
624+
if let Some(name) = cwd.file_name().and_then(|s| s.to_str()) {
625+
if !name.is_empty() {
626+
project = Some(name.to_string());
627+
}
628+
}
629+
break;
630+
}
631+
}
632+
575633
let preview = preview_from_head(&item.head)
576634
.map(|s| s.trim().to_string())
577635
.filter(|s| !s.is_empty())
@@ -581,6 +639,7 @@ fn head_to_row(item: &ConversationItem) -> Row {
581639
path: item.path.clone(),
582640
preview,
583641
ts,
642+
project,
584643
}
585644
}
586645

@@ -696,10 +755,21 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
696755
.map(human_time_ago)
697756
.unwrap_or_else(|| "".to_string())
698757
.dim();
699-
let max_cols = area.width.saturating_sub(6) as usize;
758+
// Calculate remaining width for preview text after fixed columns.
759+
let mut max_cols = area.width.saturating_sub(6) as usize;
760+
if let Some(tag) = &row.project {
761+
max_cols = max_cols.saturating_sub(tag.len() + 4);
762+
}
700763
let preview = truncate_text(&row.preview, max_cols);
701764

702-
let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
765+
// Build line: marker, time, optional [project], preview
766+
let mut spans: Vec<Span<'static>> = vec![marker, ts, " ".into()];
767+
if let Some(tag) = &row.project {
768+
spans.push(format!("[{}]", tag).cyan().bold());
769+
spans.push(" ".into());
770+
}
771+
spans.push(preview.into());
772+
let line: Line = spans.into();
703773
let rect = Rect::new(area.x, y, area.width, 1);
704774
frame.render_widget_ref(line, rect);
705775
y = y.saturating_add(1);

0 commit comments

Comments
 (0)