Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Ibrahim
e352f37940 fix 2025-09-30 15:09:26 -07:00
24 changed files with 508 additions and 503 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -773,6 +773,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]

View File

@@ -55,6 +55,14 @@ In the transcript preview, the footer shows an `Esc edit prev` hint while editin
Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session.
### Resuming sessions
When you use `codex resume`, provide any follow-up prompt *before* an optional session id. This keeps combinations like `codex resume --last "fix the tests"` working while still letting you resume a specific session when needed:
- `codex resume --last "kick off linting"` — resume the most recent session and immediately send a new prompt.
- `codex resume "draft release notes" d9b7b8b8-3a1f-4a4d-b0a2-4f04bb8d58df` — resume a specific session and send a follow-up prompt.
- `codex resume d9b7b8b8-3a1f-4a4d-b0a2-4f04bb8d58df` — resume a session without sending a prompt (the CLI treats lone UUIDs as session ids).
### Shell completions
Generate shell completion scripts via:

View File

@@ -36,6 +36,7 @@ ctor = { workspace = true }
owo-colors = { workspace = true }
serde_json = { workspace = true }
supports-color = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",

View File

@@ -1,5 +1,7 @@
use clap::CommandFactory;
use clap::Parser;
use clap::error::Error as ClapError;
use clap::error::ErrorKind as ClapErrorKind;
use clap_complete::Shell;
use clap_complete::generate;
use codex_arg0::arg0_dispatch_or_else;
@@ -22,6 +24,7 @@ use codex_tui::Cli as TuiCli;
use owo_colors::OwoColorize;
use std::path::PathBuf;
use supports_color::Stream;
use uuid::Uuid;
mod mcp_cmd;
@@ -112,17 +115,17 @@ struct CompletionCommand {
#[derive(Debug, Parser)]
struct ResumeCommand {
/// Conversation/session id (UUID). When provided, resumes this session.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
/// Continue the most recent session without showing the picker.
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
#[clap(flatten)]
config_overrides: TuiCli,
/// Continue the most recent session without showing the picker.
#[arg(long = "last", default_value_t = false)]
last: bool,
/// Conversation/session id (UUID). When provided, resumes this session.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID", index = 2)]
session_id: Option<String>,
}
#[derive(Debug, Parser)]
@@ -286,11 +289,15 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
Some(Subcommand::AppServer) => {
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
}
Some(Subcommand::Resume(ResumeCommand {
session_id,
last,
config_overrides,
})) => {
Some(Subcommand::Resume(mut resume_cmd)) => {
if let Err(err) = resume_cmd.normalize() {
err.exit();
}
let ResumeCommand {
config_overrides,
last,
session_id,
} = resume_cmd;
interactive = finalize_resume_interactive(
interactive,
root_config_overrides.clone(),
@@ -491,14 +498,16 @@ mod tests {
subcommand,
} = cli;
let Subcommand::Resume(ResumeCommand {
session_id,
last,
config_overrides: resume_cli,
}) = subcommand.expect("resume present")
else {
unreachable!()
let mut resume_cmd = match subcommand.expect("resume present") {
Subcommand::Resume(cmd) => cmd,
_ => unreachable!(),
};
resume_cmd.normalize().expect("normalize resume args");
let ResumeCommand {
config_overrides: resume_cli,
last,
session_id,
} = resume_cmd;
finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli)
}
@@ -575,12 +584,45 @@ mod tests {
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_last_accepts_follow_up_prompt() {
let interactive = finalize_from_args(["codex", "resume", "--last", "hi there"].as_ref());
assert!(interactive.resume_last);
assert_eq!(interactive.prompt.as_deref(), Some("hi there"));
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_prompt_before_session_id() {
let interactive = finalize_from_args(
[
"codex",
"resume",
"summarize progress",
"123e4567-e89b-12d3-a456-426614174000",
]
.as_ref(),
);
assert_eq!(interactive.prompt.as_deref(), Some("summarize progress"));
assert_eq!(
interactive.resume_session_id.as_deref(),
Some("123e4567-e89b-12d3-a456-426614174000"),
);
assert!(!interactive.resume_last);
assert!(!interactive.resume_picker);
}
#[test]
fn resume_picker_logic_with_session_id() {
let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref());
let interactive = finalize_from_args(
["codex", "resume", "123e4567-e89b-12d3-a456-426614174000"].as_ref(),
);
assert!(!interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
assert_eq!(
interactive.resume_session_id.as_deref(),
Some("123e4567-e89b-12d3-a456-426614174000")
);
}
#[test]
@@ -589,7 +631,7 @@ mod tests {
[
"codex",
"resume",
"sid",
"123e4567-e89b-12d3-a456-426614174000",
"--oss",
"--full-auto",
"--search",
@@ -637,7 +679,10 @@ mod tests {
assert!(has_a && has_b);
assert!(!interactive.resume_picker);
assert!(!interactive.resume_last);
assert_eq!(interactive.resume_session_id.as_deref(), Some("sid"));
assert_eq!(
interactive.resume_session_id.as_deref(),
Some("123e4567-e89b-12d3-a456-426614174000")
);
}
#[test]
@@ -656,3 +701,45 @@ mod tests {
assert_eq!(interactive.resume_session_id, None);
}
}
impl ResumeCommand {
fn normalize(&mut self) -> Result<(), ClapError> {
if self.last {
if let Some(value) = self.session_id.take() {
if Self::looks_like_session_id(&value) {
return Err(ClapError::raw(
ClapErrorKind::ArgumentConflict,
"The argument '--last' cannot be used with '[SESSION_ID]'",
));
}
if let Some(existing) = &mut self.config_overrides.prompt {
if !existing.is_empty() {
existing.push(' ');
}
existing.push_str(&value);
} else {
self.config_overrides.prompt = Some(value);
}
}
return Ok(());
}
if self.session_id.is_some() {
return Ok(());
}
if let Some(prompt) = self.config_overrides.prompt.take() {
if Self::looks_like_session_id(&prompt) {
self.session_id = Some(prompt);
} else {
self.config_overrides.prompt = Some(prompt);
}
}
Ok(())
}
fn looks_like_session_id(value: &str) -> bool {
Uuid::parse_str(value).is_ok()
}
}

View File

@@ -30,6 +30,7 @@ codex-protocol = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
uuid = { workspace = true }
shlex = { workspace = true }
tokio = { workspace = true, features = [
"io-std",

View File

@@ -1,7 +1,10 @@
use clap::Parser;
use clap::ValueEnum;
use clap::error::Error as ClapError;
use clap::error::ErrorKind as ClapErrorKind;
use codex_common::CliConfigOverrides;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Parser, Debug)]
#[command(version)]
@@ -100,18 +103,59 @@ pub enum Command {
#[derive(Parser, Debug)]
pub struct ResumeArgs {
/// Conversation/session id (UUID). When provided, resumes this session.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID")]
pub session_id: Option<String>,
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
#[arg(value_name = "PROMPT", index = 1)]
pub prompt: Option<String>,
/// Resume the most recent recorded session (newest) without specifying an id.
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
#[arg(long = "last", default_value_t = false)]
pub last: bool,
/// Prompt to send after resuming the session. If `-` is used, read from stdin.
#[arg(value_name = "PROMPT")]
pub prompt: Option<String>,
/// Conversation/session id (UUID). When provided, resumes this session.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID", index = 2)]
pub session_id: Option<String>,
}
impl ResumeArgs {
pub fn normalize(&mut self) -> Result<(), ClapError> {
if self.last {
if let Some(value) = self.session_id.take() {
if Self::looks_like_session_id(&value) {
return Err(ClapError::raw(
ClapErrorKind::ArgumentConflict,
"The argument '--last' cannot be used with '[SESSION_ID]'",
));
}
if let Some(existing) = &mut self.prompt {
if !existing.is_empty() {
existing.push(' ');
}
existing.push_str(&value);
} else {
self.prompt = Some(value);
}
}
return Ok(());
}
if self.session_id.is_some() {
return Ok(());
}
if let Some(value) = self.prompt.take() {
if Self::looks_like_session_id(&value) {
self.session_id = Some(value);
} else {
self.prompt = Some(value);
}
}
Ok(())
}
fn looks_like_session_id(value: &str) -> bool {
Uuid::parse_str(value).is_ok()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]

View File

@@ -61,18 +61,24 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
json: json_mode,
experimental_json,
sandbox_mode: sandbox_mode_cli_arg,
prompt,
prompt: parent_prompt,
output_schema: output_schema_path,
include_plan_tool,
config_overrides,
} = cli;
// Determine the prompt source (parent or subcommand) and read from stdin if needed.
let prompt_arg = match &command {
let mut command = command;
let prompt_arg = match &mut command {
// Allow prompt before the subcommand by falling back to the parent-level prompt
// when the Resume subcommand did not provide its own prompt.
Some(ExecCommand::Resume(args)) => args.prompt.clone().or(prompt),
None => prompt,
Some(ExecCommand::Resume(args)) => {
if let Err(err) = args.normalize() {
err.exit();
}
args.prompt.clone().or_else(|| parent_prompt.clone())
}
None => parent_prompt,
};
let prompt = match prompt_arg {

View File

@@ -130,6 +130,62 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
Ok(())
}
#[test]
fn exec_resume_last_accepts_prompt_after_flag() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let marker = format!("resume-last-flag-{}", 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();
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");
let marker2 = format!("resume-last-flag-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
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("resume")
.arg("--last")
.arg(&prompt2)
.assert()
.success();
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no resumed session file containing marker2");
assert_eq!(
resumed_path, path,
"resume --last should reuse the existing file",
);
let content = std::fs::read_to_string(&resumed_path)?;
assert!(content.contains(&marker));
assert!(content.contains(&marker2));
Ok(())
}
#[test]
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let home = TempDir::new()?;

View File

@@ -94,14 +94,8 @@ impl ApprovalOverlay {
);
};
let (options, title) = match &state.variant {
ApprovalVariant::Exec { .. } => (
exec_options(),
"Would you like to run the following command?".to_string(),
),
ApprovalVariant::ApplyPatch { .. } => (
patch_options(),
"Would you like to apply these changes?".to_string(),
),
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
};
let items = options
@@ -116,14 +110,9 @@ impl ApprovalOverlay {
})
.collect();
let footer_hint = match &state.variant {
ApprovalVariant::Exec { .. } => "Press Enter to continue".to_string(),
ApprovalVariant::ApplyPatch { .. } => "Press Enter to continue".to_string(),
};
let params = SelectionViewParams {
title,
footer_hint: Some(footer_hint),
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
items,
header: state.header.clone(),
..Default::default()
@@ -292,8 +281,9 @@ impl From<ApprovalRequest> for ApprovalRequestState {
}
let command_snippet = exec_snippet(&command);
if !command_snippet.is_empty() {
header.push(HeaderLine::Command {
command: command_snippet,
header.push(HeaderLine::Text {
text: format!("Command: {command_snippet}"),
italic: false,
});
header.push(HeaderLine::Spacer);
}
@@ -539,7 +529,7 @@ mod tests {
assert!(
rendered
.iter()
.any(|line| line.contains("$ echo hello world")),
.any(|line| line.contains("Command: echo hello world")),
"expected header to include command snippet, got {rendered:?}"
);
}

View File

@@ -8,7 +8,6 @@ use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_common::fuzzy_match::fuzzy_match;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
@@ -96,7 +95,7 @@ impl CommandPopup {
use super::selection_popup_common::measure_rows_height;
let rows = self.rows_from_matches(self.filtered());
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width, LIVE_PREFIX_COLS)
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
}
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
@@ -213,7 +212,6 @@ impl WidgetRef for CommandPopup {
MAX_POPUP_ROWS,
"no matches",
false,
LIVE_PREFIX_COLS,
);
}
}

View File

@@ -7,7 +7,6 @@ use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::ui_consts::LIVE_PREFIX_COLS;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {
@@ -147,7 +146,6 @@ impl WidgetRef for &FileSearchPopup {
MAX_POPUP_ROWS,
empty_message,
false,
LIVE_PREFIX_COLS,
);
}
}

View File

@@ -11,7 +11,6 @@ use ratatui::widgets::Widget;
use textwrap::wrap;
use crate::app_event_sender::AppEventSender;
use crate::render::border::draw_history_border;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -27,7 +26,6 @@ pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HeaderLine {
Text { text: String, italic: bool },
Command { command: String },
Spacer,
}
@@ -68,6 +66,15 @@ pub(crate) struct ListSelectionView {
}
impl ListSelectionView {
fn dim_prefix_span() -> Span<'static> {
"".dim()
}
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
para.render(area, buf);
}
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
let mut s = Self {
title: params.title,
@@ -164,7 +171,7 @@ impl ListSelectionView {
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '' } else { ' ' };
let prefix = if is_selected { '>' } else { ' ' };
let name = item.name.as_str();
let name_with_marker = if item.is_current {
format!("{name} (current)")
@@ -229,7 +236,8 @@ impl ListSelectionView {
if self.header.is_empty() || width == 0 {
return Vec::new();
}
let available = width.max(1) as usize;
let prefix_width = Self::dim_prefix_span().width() as u16;
let available = width.saturating_sub(prefix_width).max(1) as usize;
let mut lines = Vec::new();
for entry in &self.header {
match entry {
@@ -248,22 +256,6 @@ impl ListSelectionView {
lines.push(vec![span]);
}
}
HeaderLine::Command { command } => {
if command.is_empty() {
lines.push(Vec::new());
continue;
}
let prompt_width = 2usize;
let content_width = available.saturating_sub(prompt_width).max(1);
let parts = wrap(command, content_width);
for (idx, part) in parts.into_iter().enumerate() {
let mut spans = Vec::new();
let prefix = if idx == 0 { "$ " } else { " " };
spans.push(Span::from(prefix).dim());
spans.push(Span::from(part.into_owned()));
lines.push(spans);
}
}
}
}
lines
@@ -272,28 +264,6 @@ impl ListSelectionView {
fn header_height(&self, width: u16) -> u16 {
self.header_spans_for_width(width).len() as u16
}
fn push_line(
buf: &mut Buffer,
inner: Rect,
cursor_y: &mut u16,
inner_bottom: u16,
line: Line<'static>,
) {
if *cursor_y >= inner_bottom {
return;
}
Paragraph::new(line).render(
Rect {
x: inner.x,
y: *cursor_y,
width: inner.width,
height: 1,
},
buf,
);
*cursor_y = (*cursor_y).saturating_add(1);
}
}
impl BottomPaneView for ListSelectionView {
@@ -348,161 +318,155 @@ impl BottomPaneView for ListSelectionView {
}
fn desired_height(&self, width: u16) -> u16 {
let inner_width = width.saturating_sub(4);
if inner_width == 0 {
return 3;
}
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
// Build the same display rows used by the renderer so wrapping math matches.
let rows = self.build_rows();
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner_width, 0);
let mut height = self.header_height(inner_width);
height = height.saturating_add(1); // title
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
// +1 for the title row, +1 for a spacer line beneath the header,
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
let mut height = self.header_height(width);
height = height.saturating_add(rows_height + 2);
if self.is_searchable {
height = height.saturating_add(1);
}
if self.subtitle.is_some() {
// +1 for subtitle (the spacer is accounted for above)
height = height.saturating_add(1);
}
height = height.saturating_add(1); // spacer between metadata and rows
height = height.saturating_add(rows_height);
if self.footer_hint.is_some() {
height = height.saturating_add(2);
}
height = height.saturating_add(2); // top + bottom border
height.max(3)
height
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height < 3 || area.width < 4 {
if area.height == 0 || area.width == 0 {
return;
}
let Some(inner) = draw_history_border(buf, area) else {
return;
};
if inner.width == 0 || inner.height == 0 {
return;
}
let mut cursor_y = inner.y;
let inner_bottom = inner.y.saturating_add(inner.height);
for spans in self.header_spans_for_width(inner.width) {
if cursor_y >= inner_bottom {
break;
let mut next_y = area.y;
let header_spans = self.header_spans_for_width(area.width);
for spans in header_spans.into_iter() {
if next_y >= area.y + area.height {
return;
}
let line = if spans.is_empty() {
Line::from(String::new())
} else {
Line::from(spans)
let row = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Self::push_line(buf, inner, &mut cursor_y, inner_bottom, line);
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
if spans.is_empty() {
prefixed.push(String::new().into());
} else {
prefixed.extend(spans);
}
Paragraph::new(Line::from(prefixed)).render(row, buf);
next_y = next_y.saturating_add(1);
}
if cursor_y >= inner_bottom {
if next_y >= area.y + area.height {
return;
}
Self::push_line(
buf,
inner,
&mut cursor_y,
inner_bottom,
Line::from(self.title.clone().bold()),
);
let title_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.clone().bold(),
]))
.render(title_area, buf);
next_y = next_y.saturating_add(1);
if cursor_y >= inner_bottom {
return;
}
if self.is_searchable {
if self.is_searchable && next_y < area.y + area.height {
let search_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let query_span: Span<'static> = if self.search_query.is_empty() {
self.search_placeholder
.as_ref()
.map(|placeholder| placeholder.clone().dim())
.unwrap_or_else(|| String::new().into())
.unwrap_or_else(|| "".into())
} else {
self.search_query.clone().into()
};
Self::push_line(
buf,
inner,
&mut cursor_y,
inner_bottom,
Line::from(vec![query_span]),
);
}
if cursor_y >= inner_bottom {
return;
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
.render(search_area, buf);
next_y = next_y.saturating_add(1);
}
if let Some(sub) = &self.subtitle {
Self::push_line(
if next_y >= area.y + area.height {
return;
}
let subtitle_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
.render(subtitle_area, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let spacer_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Self::render_dim_prefix_line(spacer_area, buf);
next_y = next_y.saturating_add(1);
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
if next_y >= area.y + area.height {
return;
}
let rows_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: area
.height
.saturating_sub(next_y.saturating_sub(area.y))
.saturating_sub(footer_reserved),
};
let rows = self.build_rows();
if rows_area.height > 0 {
render_rows(
rows_area,
buf,
inner,
&mut cursor_y,
inner_bottom,
Line::from(sub.clone().dim()),
&rows,
&self.state,
MAX_POPUP_ROWS,
"no matches",
true,
);
}
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
let mut rows_height = inner_bottom
.saturating_sub(cursor_y)
.saturating_sub(footer_reserved);
let rows = self.build_rows();
if !rows.is_empty() && rows_height > 0 {
let estimated_rows =
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner.width, 0);
let mut rows_start = cursor_y;
if rows_height > estimated_rows && rows_height > 1 {
Self::push_line(
buf,
inner,
&mut cursor_y,
inner_bottom,
Line::from(String::new()),
);
rows_start = cursor_y;
rows_height = rows_height.saturating_sub(1);
}
if rows_height > 0 {
let rows_area = Rect {
x: inner.x,
y: rows_start,
width: inner.width,
height: rows_height,
};
render_rows(
rows_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"no matches",
false,
0,
);
}
}
if let Some(hint) = &self.footer_hint {
if inner.height > 0 && inner_bottom > 0 {
let footer_y = inner_bottom.saturating_sub(1);
Paragraph::new(hint.clone().dim()).render(
Rect {
x: inner.x,
y: footer_y,
width: inner.width,
height: 1,
},
buf,
);
}
let footer_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
}
}
}
@@ -615,6 +579,6 @@ mod tests {
view.set_search_query("filters".to_string());
let lines = render_lines(&view);
assert!(lines.contains("filters"));
assert!(lines.contains("filters"));
}
}

View File

@@ -69,12 +69,6 @@ impl ScrollState {
self.scroll_top = 0;
return;
}
if self.scroll_top >= len {
let clamp = visible_rows.min(len);
self.scroll_top = len.saturating_sub(clamp);
}
if let Some(sel) = self.selected_idx {
if sel < self.scroll_top {
self.scroll_top = sel;
@@ -85,7 +79,7 @@ impl ScrollState {
}
}
} else {
self.selected_idx = Some(self.scroll_top.min(len - 1));
self.scroll_top = 0;
}
}
}

View File

@@ -12,10 +12,8 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use super::scroll_state::ScrollState;
use crate::ui_consts::LIVE_PREFIX_COLS;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
@@ -120,13 +118,13 @@ pub(crate) fn render_rows(
max_results: usize,
empty_message: &str,
include_border: bool,
prefix_cols: u16,
) {
if include_border {
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
// Always draw a dim left border to match other popups.
let block = Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
@@ -134,6 +132,9 @@ pub(crate) fn render_rows(
block.render(area, buf);
}
// Content renders to the right of the border with the same live prefix
// padding used by the composer so the popup aligns with the input text.
let prefix_cols = LIVE_PREFIX_COLS;
let content_area = Rect {
x: area.x.saturating_add(prefix_cols),
y: area.y,
@@ -141,13 +142,11 @@ pub(crate) fn render_rows(
height: area.height,
};
let padding_cols = prefix_cols.saturating_sub(if include_border { 1 } else { 0 });
// Clear the padding column(s) so stale characters never peek between the
// border and the popup contents.
let padding_cols = prefix_cols.saturating_sub(1);
if padding_cols > 0 {
let pad_start = if include_border {
area.x.saturating_add(1)
} else {
area.x
};
let pad_start = area.x.saturating_add(1);
let pad_end = pad_start
.saturating_add(padding_cols)
.min(area.x.saturating_add(area.width));
@@ -161,89 +160,45 @@ pub(crate) fn render_rows(
}
}
if content_area.width == 0 || content_area.height == 0 {
return;
}
if rows_all.is_empty() {
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
para.render(
Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
},
buf,
);
if content_area.height > 0 {
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
para.render(
Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
},
buf,
);
}
return;
}
// Determine which logical rows (items) are visible given the selection and
// the max_results clamp. Scrolling is still item-based for simplicity.
let max_rows_from_area = content_area.height as usize;
let max_items = max_results.min(rows_all.len());
let sel = state
.selected_idx
.unwrap_or(0)
.min(rows_all.len().saturating_sub(1));
let visible_items = max_results
.min(rows_all.len())
.min(max_rows_from_area.max(1));
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if start_idx > sel {
start_idx = sel;
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_items > 0 {
let bottom = start_idx + visible_items - 1;
if sel > bottom {
start_idx = sel + 1 - visible_items;
}
}
}
let (visible_items, desc_col) = loop {
let candidate_count = max_items
.min(rows_all.len().saturating_sub(start_idx))
.max(1);
let desc_col_candidate =
compute_desc_col(rows_all, start_idx, candidate_count, content_area.width);
let mut used_lines = 0usize;
let mut temp_visible = 0usize;
for idx in start_idx..(start_idx + candidate_count) {
let full_line = build_full_line(&rows_all[idx], desc_col_candidate);
let options = RtOptions::new(content_area.width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(desc_col_candidate)));
let line_count = word_wrap_line(&full_line, options).len();
if temp_visible > 0 && used_lines + line_count > max_rows_from_area {
break;
}
if used_lines + line_count > max_rows_from_area && temp_visible == 0 {
temp_visible = 1;
break;
}
used_lines = used_lines.saturating_add(line_count);
temp_visible += 1;
if used_lines >= max_rows_from_area {
break;
}
}
if temp_visible == 0 {
temp_visible = 1;
}
let end_idx = start_idx + temp_visible - 1;
if sel <= end_idx || start_idx == sel {
let desc = compute_desc_col(rows_all, start_idx, temp_visible, content_area.width);
break (temp_visible, desc);
}
if start_idx >= rows_all.len().saturating_sub(1) {
let desc = compute_desc_col(rows_all, start_idx, temp_visible, content_area.width);
break (temp_visible, desc);
}
start_idx += 1;
};
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
// Render items, wrapping descriptions and aligning wrapped lines under the
// shared description column. Stop when we run out of vertical space.
let mut cur_y = content_area.y;
for (i, row) in rows_all
.iter()
@@ -255,24 +210,44 @@ pub(crate) fn render_rows(
break;
}
let full_line = build_full_line(row, desc_col);
let GenericDisplayRow {
name,
match_indices,
is_current: _is_current,
description,
} = row;
let full_line = build_full_line(
&GenericDisplayRow {
name: name.clone(),
match_indices: match_indices.clone(),
is_current: *_is_current,
description: description.clone(),
},
desc_col,
);
// Wrap with subsequent indent aligned to the description column.
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let options = RtOptions::new(content_area.width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(desc_col)));
let wrapped = word_wrap_line(&full_line, options);
// Render the wrapped lines.
for mut line in wrapped {
if cur_y >= content_area.y + content_area.height {
break;
}
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
line.style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
} else if row.is_current {
line.style = Style::default().add_modifier(Modifier::ITALIC);
}
Paragraph::new(line).render(
let para = Paragraph::new(line);
para.render(
Rect {
x: content_area.x,
y: cur_y,
@@ -285,6 +260,7 @@ pub(crate) fn render_rows(
}
}
}
/// Compute the number of terminal rows required to render up to `max_results`
/// items from `rows_all` given the current scroll/selection state and the
/// available `width`. Accounts for description wrapping and alignment so the
@@ -294,15 +270,14 @@ pub(crate) fn measure_rows_height(
state: &ScrollState,
max_results: usize,
width: u16,
prefix_cols: u16,
) -> u16 {
if rows_all.is_empty() {
return 1;
return 1; // placeholder "no matches" line
}
let content_width = width.saturating_sub(prefix_cols).max(1);
let visible_items = max_results.min(rows_all.len());
let content_width = width.saturating_sub(1).max(1);
let visible_items = max_results.min(rows_all.len());
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {

View File

@@ -1,16 +1,11 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 581
expression: render_lines(&view)
---
╭──────────────────────────────────────────────╮
Select Approval Mode │
│ Switch between Codex approval presets │
│ │
1. Read Only (current) Codex can read │
files │
│ 2. Full Access Codex can edit │
│ files │
│ │
│ Press Enter to confirm or Esc to go back │
╰──────────────────────────────────────────────╯
▌ Select Approval Mode
Switch between Codex approval presets
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back

View File

@@ -1,15 +1,10 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 572
expression: render_lines(&view)
---
╭──────────────────────────────────────────────╮
│ Select Approval Mode
│ │
1. Read Only (current) Codex can read │
files │
│ 2. Full Access Codex can edit │
│ files │
│ │
│ Press Enter to confirm or Esc to go back │
╰──────────────────────────────────────────────╯
▌ Select Approval Mode
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back

View File

@@ -1,21 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1200
expression: terminal.backend()
---
" "
"╭──────────────────────────────────────────────────────────────────────────────╮"
"│ this is a test reason such as one that would be produced by the model │"
" "
"│ $ echo hello world "
" "
"│ Would you like to run the following command? │"
" "
" 1. Approve and run now (Y) Run this command one time │"
"│ 2. Always approve this session (A) Automatically approve this command for │"
" the rest of the session "
"│ 3. Cancel (N) Do not run the command │"
" "
"│ Press Enter to continue │"
"╰──────────────────────────────────────────────────────────────────────────────╯"
"▌ this is a test reason such as one that would be produced by the model "
""
"▌ Command: echo hello world "
" "
"▌ Allow command? "
""
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -1,19 +1,16 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1227
expression: terminal.backend()
---
" "
"╭──────────────────────────────────────────────────────────────────────────────╮"
"│ $ echo hello world "
" "
"│ Would you like to run the following command? │"
" "
" 1. Approve and run now (Y) Run this command one time │"
"│ 2. Always approve this session (A) Automatically approve this command for │"
" the rest of the session "
"│ 3. Cancel (N) Do not run the command │"
" "
"│ Press Enter to continue │"
"╰──────────────────────────────────────────────────────────────────────────────╯"
"▌ Command: echo hello world "
" "
"▌ Allow command? "
""
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -1,19 +1,16 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1262
expression: terminal.backend()
---
" "
"╭──────────────────────────────────────────────────────────────────────────────╮"
"│ The model wants to apply changes "
" "
"│ Grant write access to /tmp for the remainder of this session. │"
" "
"│ Would you like to apply these changes? "
" "
" 1. Approve (Y) Apply the proposed changes "
" 2. Cancel (N) Do not apply the changes │"
" "
"│ Press Enter to continue │"
"╰──────────────────────────────────────────────────────────────────────────────╯"
"▌ The model wants to apply changes "
" "
"▌ Grant write access to /tmp for the remainder of this session. "
""
"▌ Apply changes? "
" "
"▌ > 1. Approve (Y) Apply the proposed changes "
"▌ 2. Cancel (N) Do not apply the changes "
" "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -1,21 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1429
expression: terminal.backend()
---
" "
"╭──────────────────────────────────────────────────────────────────────────────╮"
"│ this is a test reason such as one that would be produced by the model │"
" "
"│ $ echo 'hello world' "
" "
"│ Would you like to run the following command? │"
" "
" 1. Approve and run now (Y) Run this command one time │"
"│ 2. Always approve this session (A) Automatically approve this command for │"
" the rest of the session "
"│ 3. Cancel (N) Do not run the command │"
" "
"│ Press Enter to continue │"
"╰──────────────────────────────────────────────────────────────────────────────╯"
"▌ this is a test reason such as one that would be produced by the model "
""
"▌ Command: echo 'hello world' "
" "
"▌ Allow command? "
""
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -933,31 +933,18 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf);
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
let s = buf[(x, y)].symbol();
if s.is_empty() {
row.push(' ');
} else {
row.push_str(s);
}
}
if row.chars().any(|c| {
!c.is_whitespace()
&& c != '╭'
&& c != '╮'
&& c != '╯'
&& c != '╰'
&& c != '─'
&& c != '│'
}) {
return row;
let mut row = String::new();
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
let y = 1u16.min(height.saturating_sub(1));
for x in 0..area.width {
let s = buf[(x, y)].symbol();
if s.is_empty() {
row.push(' ');
} else {
row.push_str(s);
}
}
String::new()
row
}
#[test]
@@ -1777,14 +1764,14 @@ fn apply_patch_untrusted_shows_approval_modal() {
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("Would you like to apply these changes?") {
if row.contains("Apply changes?") {
contains_title = true;
break;
}
}
assert!(
contains_title,
"expected approval modal to be visible with title 'Would you like to apply these changes?'"
"expected approval modal to be visible with title 'Apply changes?'"
);
}

View File

@@ -7,7 +7,7 @@ use std::path::PathBuf;
#[command(version)]
pub struct Cli {
/// Optional user prompt to start the session.
#[arg(value_name = "PROMPT")]
#[arg(value_name = "PROMPT", index = 1)]
pub prompt: Option<String>,
/// Optional image(s) to attach to the initial prompt.

View File

@@ -1,82 +0,0 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
/// Draw the standard Codex rounded border into `buf` and return the interior
/// rectangle where content should render. The border mirrors the appearance of
/// `history_cell::with_border`, including one column of padding on each side.
pub(crate) fn draw_history_border(buf: &mut Buffer, area: Rect) -> Option<Rect> {
if area.width < 4 || area.height < 3 {
return None;
}
let dim_style = Style::default().add_modifier(Modifier::DIM);
let left = area.x;
let right = area.x + area.width - 1;
let top = area.y;
let bottom = area.y + area.height - 1;
if let Some(cell) = buf.cell_mut((left, top)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
for x in left + 1..right {
if let Some(cell) = buf.cell_mut((x, top)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
}
if let Some(cell) = buf.cell_mut((right, top)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
if let Some(cell) = buf.cell_mut((left, bottom)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
for x in left + 1..right {
if let Some(cell) = buf.cell_mut((x, bottom)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
}
if let Some(cell) = buf.cell_mut((right, bottom)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
for y in top + 1..bottom {
if let Some(cell) = buf.cell_mut((left, y)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
if let Some(cell) = buf.cell_mut((left + 1, y)) {
cell.set_symbol(" ");
cell.set_style(dim_style);
}
for x in left + 2..right - 1 {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(" ");
cell.set_style(Style::default());
}
}
if let Some(cell) = buf.cell_mut((right - 1, y)) {
cell.set_symbol(" ");
cell.set_style(dim_style);
}
if let Some(cell) = buf.cell_mut((right, y)) {
cell.set_symbol("");
cell.set_style(dim_style);
}
}
Some(Rect {
x: area.x + 2,
y: area.y + 1,
width: area.width.saturating_sub(4),
height: area.height.saturating_sub(2),
})
}

View File

@@ -1,3 +1,2 @@
pub mod border;
pub mod highlight;
pub mod line_utils;