Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion codex-rs/exec/src/event_processor_with_human_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
/// Print a concise summary of the effective configuration that will be used
/// for the session. This mirrors the information shown in the TUI welcome
/// screen.
fn print_config_summary(&mut self, config: &Config, prompt: &str, _: &SessionConfiguredEvent) {
fn print_config_summary(&mut self, config: &Config, prompt: &str, ev: &SessionConfiguredEvent) {
const VERSION: &str = env!("CARGO_PKG_VERSION");
ts_println!(
self,
Expand All @@ -155,6 +155,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
println!("{} {}", format!("{key}:").style(self.bold), value);
}

// Make the rollout (resume) file location visible at session start.
// Available for both new and resumed sessions; especially useful on resume.
println!(
"{} {}",
"resume file:".style(self.bold),
ev.rollout_path.display()
);

println!("--------");

// Echo the prompt that will be sent to the agent so it is visible in the
Expand Down
60 changes: 60 additions & 0 deletions codex-rs/exec/tests/suite/resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,63 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
assert!(content.contains(&marker2));
Ok(())
}

#[test]
fn exec_resume_summary_shows_rollout_path() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");

// First run creates a session
let marker = format!("resume-summary-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");

Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt)
.assert()
.success();

// Locate created rollout file
let sessions_dir = home.path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");

// Resume and capture stdout
let marker2 = format!("resume-summary-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");

let output = Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg("--last")
.output()
.context("resume run should succeed")?;

assert!(output.status.success(), "resume run failed: {output:?}");
let stdout = String::from_utf8(output.stdout)?;

// Expect the rollout path to be printed in the summary
let expected_line = format!("resume file: {}", path.display());
assert!(
stdout.contains(&expected_line),
"stdout missing resume path line. expected to find: {expected_line}\nstdout was:\n{stdout}"
);

Ok(())
}
1 change: 1 addition & 0 deletions codex-rs/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ shlex = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
supports-color = { workspace = true }
is-terminal = "0.4"
tempfile = { workspace = true }
textwrap = { workspace = true }
tokio = { workspace = true, features = [
Expand Down
23 changes: 16 additions & 7 deletions codex-rs/tui/src/history_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,8 @@ pub(crate) fn new_session_info(
session_id: _,
history_log_id: _,
history_entry_count: _,
initial_messages: _,
rollout_path: _,
initial_messages,
rollout_path,
} = event;
if is_first_event {
// Header box rendered as history (so it appears at the very top)
Expand Down Expand Up @@ -416,12 +416,21 @@ pub(crate) fn new_session_info(
]),
];

CompositeHistoryCell {
parts: vec![
Box::new(header),
Box::new(PlainHistoryCell { lines: help_lines }),
],
// If resuming, surface the rollout (resume) file path for quick discovery.
// We detect resume via presence of `initial_messages` in SessionConfiguredEvent.
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
if initial_messages.is_some() {
let line: Line<'static> = vec![
// Keep label styling consistent with other summary lines (dim label).
Span::from("resume file: ").dim(),
Span::from(rollout_path.display().to_string()),
]
.into();
parts.push(Box::new(PlainHistoryCell { lines: vec![line] }));
}
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));

CompositeHistoryCell { parts }
} else if config.model == model {
CompositeHistoryCell { parts: vec![] }
} else {
Expand Down
17 changes: 17 additions & 0 deletions codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,30 @@ use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::onboarding::onboarding_screen::run_onboarding_app;
use crate::tui::Tui;
pub use cli::Cli;
use codex_core::internal_storage::InternalStorage;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::disable_raw_mode;

#[allow(clippy::print_stderr)]
fn install_panic_hook_once() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::panic::set_hook(Box::new(|info| {
// Best-effort cleanup so panic is visible to the user.
let _ = disable_raw_mode();
let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen);
eprintln!("\n\ncodex-tui panic: {info}");
}));
});
}

// (tests access modules directly within the crate)

pub async fn run_main(
cli: Cli,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<AppExitInfo> {
install_panic_hook_once();
let (sandbox_mode, approval_policy) = if cli.full_auto {
(
Some(SandboxMode::WorkspaceWrite),
Expand Down
74 changes: 72 additions & 2 deletions codex-rs/tui/src/resume_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::text::Span;
use std::env;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::UnboundedReceiverStream;
Expand All @@ -30,6 +31,7 @@ use crate::tui::TuiEvent;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InputMessageKind;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;

const PAGE_SIZE: usize = 25;
Expand Down Expand Up @@ -64,6 +66,41 @@ enum BackgroundEvent {
/// search and pagination. Shows the first user input as the preview, relative
/// time (e.g., "5 seconds ago"), and the absolute path.
pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<ResumeSelection> {
// Plain, non-interactive verification mode: print rows and exit.
// Enables checking the '[project]' tag & preview without onboarding/TUI.
if env::var("CODEX_TUI_PLAIN").as_deref() == Ok("1") {
match RolloutRecorder::list_conversations(codex_home, PAGE_SIZE, None).await {
Ok(page) => {
let rows: Vec<Row> = page.items.iter().map(|it| head_to_row(it)).collect();
let no_color = env::var("NO_COLOR").is_ok();
let dumb = env::var("TERM").unwrap_or_default() == "dumb";
let use_color = !no_color && !dumb;
for (i, r) in rows.iter().enumerate() {
let mark = if i == 0 { "> " } else { " " };
let ts =
r.ts.as_ref()
.map(|dt| human_time_ago(dt.clone()))
.unwrap_or_else(|| "-".to_string());
let tag = r.project.as_deref().unwrap_or("<cwd>");
// Sanitize preview to a single line, limited length similar to TUI
let mut pv = r.preview.replace('\n', " ");
if pv.len() > 80 {
pv.truncate(79);
pv.push('…');
}
if use_color {
println!("{mark}{ts:<12} \x1b[36;1m[{tag}]\x1b[0m {pv}");
} else {
println!("{mark}{ts:<12} [{tag}] {pv}");
}
}
}
Err(e) => {
eprintln!("Failed to list conversations: {e}");
}
}
return Ok(ResumeSelection::StartFresh);
}
let alt = AltScreenGuard::enter(tui);
let (bg_tx, bg_rx) = mpsc::unbounded_channel();

Expand Down Expand Up @@ -91,6 +128,12 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<Resum
page_loader,
);
state.load_initial_page().await?;
if let Ok(q) = env::var("CODEX_TUI_FILTER") {
let q = q.trim();
if !q.is_empty() {
state.set_query(q.to_string());
}
}
state.request_frame();

let mut tui_events = alt.tui.event_stream().fuse();
Expand Down Expand Up @@ -219,6 +262,7 @@ struct Row {
path: PathBuf,
preview: String,
ts: Option<DateTime<Utc>>,
project: Option<String>,
}

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

fn head_to_row(item: &ConversationItem) -> Row {
let mut ts: Option<DateTime<Utc>> = None;
let mut project: Option<String> = None;
if let Some(first) = item.head.first()
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
{
ts = Some(parsed.with_timezone(&Utc));
}

// Attempt to derive the project tag from the SessionMeta line (cwd basename).
for value in &item.head {
if let Ok(meta_line) = serde_json::from_value::<SessionMetaLine>(value.clone()) {
let cwd = meta_line.meta.cwd;
if let Some(name) = cwd.file_name().and_then(|s| s.to_str()) {
if !name.is_empty() {
project = Some(name.to_string());
}
}
break;
}
}

let preview = preview_from_head(&item.head)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
Expand All @@ -581,6 +639,7 @@ fn head_to_row(item: &ConversationItem) -> Row {
path: item.path.clone(),
preview,
ts,
project,
}
}

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

let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
// Build line: marker, time, optional [project], preview
let mut spans: Vec<Span<'static>> = vec![marker, ts, " ".into()];
if let Some(tag) = &row.project {
spans.push(format!("[{}]", tag).cyan().bold());
spans.push(" ".into());
}
spans.push(preview.into());
let line: Line = spans.into();
let rect = Rect::new(area.x, y, area.width, 1);
frame.render_widget_ref(line, rect);
y = y.saturating_add(1);
Expand Down