Compare commits

...

2 Commits

Author SHA1 Message Date
Daniel Edrisian
6e0f311d79 /review TUI 2025-09-16 19:50:38 -07:00
Daniel Edrisian
118e659848 Revert "fix permissions alignment"
This reverts commit 45bccd36b0.
2025-09-16 19:48:38 -07:00
29 changed files with 2988 additions and 360 deletions

View File

@@ -3293,7 +3293,7 @@ async fn exit_review_mode(
<results>
{findings_str}
</results>
</user_tool>
</user_action>
"#));
} else {
user_message.push_str(r#"<user_action>
@@ -3302,7 +3302,7 @@ async fn exit_review_mode(
<results>
None.
</results>
</user_tool>
</user_action>
"#);
}

View File

@@ -558,6 +558,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
TurnAbortReason::Replaced => {
ts_println!(self, "task aborted: replaced by a new task");
}
TurnAbortReason::ReviewEnded => {
ts_println!(self, "task aborted: review ended");
}
},
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::ConversationPath(_) => {}

View File

@@ -1240,6 +1240,7 @@ pub struct TurnAbortedEvent {
pub enum TurnAbortReason {
Interrupted,
Replaced,
ReviewEnded,
}
#[cfg(test)]

View File

@@ -268,6 +268,9 @@ impl App {
AppEvent::CodexEvent(event) => {
self.chat_widget.handle_codex_event(event);
}
AppEvent::SubmitUserText(text) => {
self.chat_widget.submit_text_message(text);
}
AppEvent::ConversationHistory(ev) => {
self.on_conversation_history_for_backtrack(tui, ev).await?;
}
@@ -291,6 +294,13 @@ impl App {
));
tui.frame_requester().schedule_frame();
}
AppEvent::DiffShortstat {
shortstat,
request_id,
} => {
self.chat_widget
.on_diff_shortstat_ready(shortstat, request_id);
}
AppEvent::StartFileSearch(query) => {
if !query.is_empty() {
self.file_search.on_user_query(query);

View File

@@ -2,6 +2,7 @@ use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
use codex_file_search::FileMatch;
use crate::git_shortstat::DiffShortStat;
use crate::history_cell::HistoryCell;
use codex_core::protocol::AskForApproval;
@@ -23,6 +24,10 @@ pub(crate) enum AppEvent {
/// bubbling channels through layers of widgets.
CodexOp(codex_core::protocol::Op),
/// Ask the ChatWidget to submit a user text message using its
/// standard helpers.
SubmitUserText(String),
/// Kick off an asynchronous file search for the given query (text after
/// the `@`). Previous searches may be cancelled by the app layer so there
/// is at most one in-flight search.
@@ -39,6 +44,12 @@ pub(crate) enum AppEvent {
/// Result of computing a `/diff` command.
DiffResult(String),
/// Result of computing `git diff --shortstat`.
DiffShortstat {
shortstat: Option<DiffShortStat>,
request_id: u64,
},
InsertHistoryCell(Box<dyn HistoryCell>),
StartCommitAnimation,

View File

@@ -2,6 +2,7 @@ use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use std::any::Any;
use crate::app_event_sender::AppEventSender;
use crate::user_approval_widget::ApprovalRequest;
@@ -42,6 +43,10 @@ impl ApprovalModalView {
}
impl BottomPaneView for ApprovalModalView {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
self.current.handle_key_event(key_event);
self.maybe_advance();

View File

@@ -2,12 +2,13 @@ use crate::user_approval_widget::ApprovalRequest;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use std::any::Any;
use super::BottomPane;
use super::CancellationEvent;
/// Trait implemented by every view that can be shown in the bottom pane.
pub(crate) trait BottomPaneView {
pub(crate) trait BottomPaneView: Any {
/// Handle a key event while the view is active. A redraw is always
/// scheduled after this call.
fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {}
@@ -28,6 +29,17 @@ pub(crate) trait BottomPaneView {
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Optional paste handler. Return true if the view modified its state and
/// needs a redraw.
fn handle_paste(&mut self, _pane: &mut BottomPane, _pasted: String) -> bool {
false
}
/// Cursor position when this view is active.
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(
@@ -36,4 +48,6 @@ pub(crate) trait BottomPaneView {
) -> Option<ApprovalRequest> {
Some(request)
}
fn as_any_mut(&mut self) -> &mut dyn Any;
}

View File

@@ -0,0 +1,207 @@
use std::any::Any;
use std::path::Path;
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::git_shortstat::get_diff_shortstat_against;
use super::BottomPane;
use super::SearchableTablePickerView;
use super::TablePickerItem;
use super::TablePickerOnSelected;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use ratatui::style::Stylize;
use ratatui::text::Span;
/// Callback invoked when a branch is selected.
pub(crate) type BranchSelected = TablePickerOnSelected<String>;
/// A searchable branch picker view. Shows the repository's branches with the
/// default/base branch pinned to the top when present.
pub(crate) struct BranchPickerView {
inner: SearchableTablePickerView<String>,
}
impl BranchPickerView {
pub(crate) fn new(
cwd: PathBuf,
app_event_tx: AppEventSender,
on_selected: BranchSelected,
) -> Self {
let branch_items = load_branch_items(&cwd);
let inner = SearchableTablePickerView::new(
"Select a base branch".to_string(),
"Type to search branches".to_string(),
"no matches".to_string(),
branch_items,
MAX_POPUP_ROWS,
app_event_tx,
on_selected,
);
Self { inner }
}
}
impl BottomPaneView for BranchPickerView {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn handle_key_event(&mut self, pane: &mut BottomPane, key_event: crossterm::event::KeyEvent) {
self.inner.handle_key_event(pane, key_event);
}
fn is_complete(&self) -> bool {
self.inner.is_complete()
}
fn on_ctrl_c(&mut self, pane: &mut super::BottomPane) -> super::CancellationEvent {
self.inner.on_ctrl_c(pane)
}
fn desired_height(&self, width: u16) -> u16 {
self.inner.desired_height(width)
}
fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
self.inner.render(area, buf);
}
}
fn load_branch_items(cwd: &PathBuf) -> Vec<TablePickerItem<String>> {
let branches = gather_branches(cwd);
let current_branch = current_branch_name(cwd).unwrap_or_else(|| "(detached HEAD)".to_string());
branches
.into_iter()
.map(|branch| {
let label = format!("{current_branch} -> {branch}");
let search_value = format!("{current_branch} {branch}");
let cwd = cwd.clone();
let detail_branch = branch.clone();
TablePickerItem {
value: branch,
label,
description: None,
search_value,
// Wrap the async function in a blocking call for sync context
detail_builder: Some(Box::new(move || {
// Use block_in_place to avoid blocking the async runtime if present
tokio::task::block_in_place(|| {
tokio::runtime::Handle::try_current()
.ok()
.and_then(|handle| {
handle.block_on(branch_shortstat(&cwd, &detail_branch))
})
.or_else(|| {
// Fallback: create a new runtime if not in one
let rt = tokio::runtime::Runtime::new().ok()?;
rt.block_on(branch_shortstat(&cwd, &detail_branch))
})
})
})),
}
})
.collect()
}
fn gather_branches(cwd: &PathBuf) -> Vec<String> {
let out = std::process::Command::new("git")
.args(["branch", "--format=%(refname:short)"])
.current_dir(cwd)
.output();
let mut branches: Vec<String> = match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
_ => Vec::new(),
};
branches.sort_unstable();
if let Some(base) = default_branch(cwd, &branches)
&& let Some(pos) = branches.iter().position(|name| name == &base)
{
let base_branch = branches.remove(pos);
branches.insert(0, base_branch);
}
branches
}
fn default_branch(cwd: &PathBuf, branches: &[String]) -> Option<String> {
let configured_default = if let Ok(out) = std::process::Command::new("git")
.args(["config", "--get", "init.defaultBranch"])
.current_dir(cwd)
.output()
{
if out.status.success() {
String::from_utf8(out.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
} else {
None
};
if let Some(default_branch) = configured_default
&& branches.iter().any(|branch| branch == &default_branch)
{
return Some(default_branch);
}
for candidate in ["main", "master"] {
if branches.iter().any(|branch| branch == candidate) {
return Some(candidate.to_string());
}
}
None
}
fn current_branch_name(cwd: &PathBuf) -> Option<String> {
let out = std::process::Command::new("git")
.args(["branch", "--show-current"])
.current_dir(cwd)
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout)
.ok()
.map(|s| s.trim().to_string())
.filter(|name| !name.is_empty())
}
async fn branch_shortstat(cwd: &Path, branch: &str) -> Option<Vec<Span<'static>>> {
let stats = get_diff_shortstat_against(cwd, branch)
.await
.ok()
.flatten()?;
if stats.files_changed == 0 && stats.insertions == 0 && stats.deletions == 0 {
return None;
}
let file_label = if stats.files_changed == 1 {
"file changed"
} else {
"files changed"
};
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(format!("- {} {file_label} (", stats.files_changed).dim());
spans.push(format!("+{}", stats.insertions).green());
spans.push(" ".dim());
spans.push(format!("-{}", stats.deletions).red());
spans.push(")".dim());
Some(spans)
}

View File

@@ -142,16 +142,14 @@ impl ChatComposer {
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
+ match &self.active_popup {
ActivePopup::None => FOOTER_HEIGHT_WITH_HINT,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::Command(c) => c.calculate_required_height(),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let popup_constraint = match &self.active_popup {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::Command(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::None => Constraint::Max(FOOTER_HEIGHT_WITH_HINT),
};
@@ -1234,10 +1232,7 @@ impl ChatComposer {
impl WidgetRef for ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let (popup_constraint, hint_spacing) = match &self.active_popup {
ActivePopup::Command(popup) => (
Constraint::Max(popup.calculate_required_height(area.width)),
0,
),
ActivePopup::Command(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0),
ActivePopup::None => (
Constraint::Length(FOOTER_HEIGHT_WITH_HINT),

View File

@@ -92,35 +92,10 @@ impl CommandPopup {
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Determine the preferred height of the popup for a given width.
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
let matches = self.filtered();
let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
Vec::new()
} else {
matches
.into_iter()
.map(|(item, indices, _)| match item {
CommandItem::Builtin(cmd) => GenericDisplayRow {
name: format!("/{}", cmd.command()),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some(cmd.description().to_string()),
},
CommandItem::UserPrompt(i) => GenericDisplayRow {
name: format!("/{}", self.prompts[i].name),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some("send saved prompt".to_string()),
},
})
.collect()
};
measure_rows_height(&rows_all, &self.state, MAX_POPUP_ROWS, width)
/// Determine the preferred height of the popup. This is the number of
/// rows required to show at most MAX_POPUP_ROWS commands.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.filtered_items().len().clamp(1, MAX_POPUP_ROWS) as u16
}
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
@@ -210,12 +185,14 @@ impl WidgetRef for CommandPopup {
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some(cmd.description().to_string()),
styled_name: None,
},
CommandItem::UserPrompt(i) => GenericDisplayRow {
name: format!("/{}", self.prompts[i].name),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
is_current: false,
description: Some("send saved prompt".to_string()),
styled_name: None,
},
})
.collect()

View File

@@ -0,0 +1,134 @@
use std::any::Any;
use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use super::BottomPane;
use super::SearchableTablePickerView;
use super::TablePickerItem;
use super::TablePickerOnSelected;
#[derive(Clone)]
pub(crate) struct CommitSelection {
pub full_sha: String,
pub short_sha: String,
pub summary: String,
}
pub(crate) type CommitSelected = TablePickerOnSelected<CommitSelection>;
pub(crate) struct CommitPickerView {
inner: SearchableTablePickerView<CommitSelection>,
}
impl CommitPickerView {
pub(crate) fn new(
cwd: PathBuf,
app_event_tx: AppEventSender,
on_selected: CommitSelected,
) -> Self {
let commits = load_recent_commits(&cwd);
let inner = SearchableTablePickerView::new(
"Select a commit".to_string(),
"Type to search commits".to_string(),
"no commits found".to_string(),
commits,
10,
app_event_tx,
on_selected,
);
Self { inner }
}
}
impl super::bottom_pane_view::BottomPaneView for CommitPickerView {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn handle_key_event(&mut self, pane: &mut BottomPane, key_event: crossterm::event::KeyEvent) {
self.inner.handle_key_event(pane, key_event);
}
fn is_complete(&self) -> bool {
self.inner.is_complete()
}
fn on_ctrl_c(&mut self, pane: &mut super::BottomPane) -> super::CancellationEvent {
self.inner.on_ctrl_c(pane)
}
fn desired_height(&self, width: u16) -> u16 {
self.inner.desired_height(width)
}
fn render(&self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
self.inner.render(area, buf);
}
}
fn load_recent_commits(cwd: &PathBuf) -> Vec<TablePickerItem<CommitSelection>> {
const FIELDS_SEPARATOR: char = '\u{1f}';
let output = std::process::Command::new("git")
.args([
"log",
"-n",
"100",
"--pretty=format:%H%x1f%h%x1f%an%x1f%ar%x1f%s",
])
.current_dir(cwd)
.output();
let stdout = match output {
Ok(out) if out.status.success() => out.stdout,
_ => Vec::new(),
};
String::from_utf8_lossy(&stdout)
.lines()
.filter_map(|line| parse_log_line(line, FIELDS_SEPARATOR))
.collect()
}
fn parse_log_line(line: &str, separator: char) -> Option<TablePickerItem<CommitSelection>> {
let mut parts = line.split(separator);
let full_sha = parts.next()?.trim().to_string();
let short_sha = parts.next()?.trim().to_string();
let author = parts.next()?.trim().to_string();
let relative_time = parts.next()?.trim().to_string();
let summary = parts.next().unwrap_or_default().trim().to_string();
if full_sha.is_empty() || short_sha.is_empty() {
return None;
}
let label = if summary.is_empty() {
short_sha.clone()
} else {
format!("{short_sha} {summary}")
};
let description = if author.is_empty() && relative_time.is_empty() {
None
} else if author.is_empty() {
Some(relative_time.clone())
} else if relative_time.is_empty() {
Some(author.clone())
} else {
Some(format!("{author} · {relative_time}"))
};
let search_value = format!("{full_sha} {short_sha} {summary} {author} {relative_time}");
Some(TablePickerItem {
value: CommitSelection {
full_sha,
short_sha,
summary,
},
label,
description,
search_value,
detail_builder: None,
})
}

View File

@@ -0,0 +1,243 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::any::Any;
use std::cell::RefCell;
use super::bottom_pane_view::BottomPaneView;
use super::textarea::TextArea;
use super::textarea::TextAreaState;
/// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
/// Minimal multi-line text input view to collect custom review instructions.
pub(crate) struct CustomPromptView {
title: String,
placeholder: String,
context_label: Option<String>,
allow_empty_submit: bool,
on_submit: PromptSubmitted,
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
complete: bool,
}
impl CustomPromptView {
pub(crate) fn new(
title: String,
placeholder: String,
context_label: Option<String>,
allow_empty_submit: bool,
on_submit: PromptSubmitted,
) -> Self {
Self {
title,
placeholder,
context_label,
allow_empty_submit,
on_submit,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
complete: false,
}
}
}
impl BottomPaneView for CustomPromptView {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn handle_key_event(&mut self, _pane: &mut super::BottomPane, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.complete = true;
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let text = self.textarea.text().trim().to_string();
if self.allow_empty_submit || !text.is_empty() {
(self.on_submit)(text);
}
self.complete = true;
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.textarea.input(key_event);
}
other => {
self.textarea.input(other);
}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
1u16 + extra_top + self.input_height(width) + 3u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let input_height = self.input_height(area.width);
// Title line
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
// Optional context line
let mut input_y = area.y.saturating_add(1);
if let Some(context_label) = &self.context_label {
let context_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: 1,
};
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
Paragraph::new(Line::from(spans)).render(context_area, buf);
input_y = input_y.saturating_add(1);
}
// Input line
let input_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: input_height,
};
if input_area.width >= 2 {
for row in 0..input_area.height {
Paragraph::new(Line::from(vec![gutter()])).render(
Rect {
x: input_area.x,
y: input_area.y.saturating_add(row),
width: 2,
height: 1,
},
buf,
);
}
let text_area_height = input_area.height.saturating_sub(1);
if text_area_height > 0 {
if input_area.width > 2 {
let blank_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y,
width: input_area.width.saturating_sub(2),
height: 1,
};
Clear.render(blank_rect, buf);
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
Paragraph::new(Line::from(self.placeholder.clone().dim()))
.render(textarea_rect, buf);
}
}
}
let hint_blank_y = input_area.y.saturating_add(input_height);
if hint_blank_y < area.y.saturating_add(area.height) {
let blank_area = Rect {
x: area.x,
y: hint_blank_y,
width: area.width,
height: 1,
};
Clear.render(blank_area, buf);
}
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(super::standard_popup_hint_line()).render(
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
buf,
);
}
}
fn handle_paste(&mut self, _pane: &mut super::BottomPane, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, &state)
}
}
impl CustomPromptView {
fn input_height(&self, width: u16) -> u16 {
let usable_width = width.saturating_sub(2);
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
text_height.saturating_add(1).min(9)
}
}
fn gutter() -> Span<'static> {
"".cyan()
}

View File

@@ -128,6 +128,7 @@ impl WidgetRef for &FileSearchPopup {
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
description: None,
styled_name: None,
})
.collect()
};

View File

@@ -6,8 +6,10 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::any::Any;
use crate::app_event_sender::AppEventSender;
@@ -17,23 +19,24 @@ use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
use super::standard_popup_hint_line;
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
pub(crate) type SelectionAction = Box<dyn Fn(&mut BottomPane, &AppEventSender) + Send + Sync>;
pub(crate) struct SelectionItem {
pub name: String,
pub description: Option<String>,
pub is_current: bool,
pub actions: Vec<SelectionAction>,
pub styled_label: Option<Vec<Span<'static>>>,
pub dismiss_on_select: bool,
}
pub(crate) struct ListSelectionView {
title: String,
subtitle: Option<String>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
state: ScrollState,
complete: bool,
@@ -46,20 +49,22 @@ impl ListSelectionView {
}
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
Clear.render(area, buf);
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
para.render(area, buf);
}
pub fn new(
title: String,
subtitle: Option<String>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
app_event_tx: AppEventSender,
) -> Self {
let mut s = Self {
title,
subtitle,
footer_hint,
items,
state: ScrollState::new(),
complete: false,
@@ -74,6 +79,19 @@ impl ListSelectionView {
s
}
pub(crate) fn title(&self) -> &str {
&self.title
}
pub(crate) fn update_item<F>(&mut self, index: usize, mut update: F)
where
F: FnMut(&mut SelectionItem),
{
if let Some(item) = self.items.get_mut(index) {
update(item);
}
}
fn move_up(&mut self) {
let len = self.items.len();
self.state.move_up_wrap(len);
@@ -86,13 +104,15 @@ impl ListSelectionView {
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn accept(&mut self) {
fn accept(&mut self, pane: &mut BottomPane) {
if let Some(idx) = self.state.selected_idx {
if let Some(item) = self.items.get(idx) {
for act in &item.actions {
act(&self.app_event_tx);
act(pane, &self.app_event_tx);
}
if item.dismiss_on_select {
self.complete = true;
}
self.complete = true;
}
} else {
self.complete = true;
@@ -106,7 +126,11 @@ impl ListSelectionView {
}
impl BottomPaneView for ListSelectionView {
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn handle_key_event(&mut self, pane: &mut BottomPane, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
@@ -122,7 +146,7 @@ impl BottomPaneView for ListSelectionView {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.accept(),
} => self.accept(pane),
_ => {}
}
}
@@ -136,44 +160,16 @@ impl BottomPaneView for ListSelectionView {
CancellationEvent::Handled
}
fn desired_height(&self, width: u16) -> u16 {
// 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: Vec<GenericDisplayRow> = self
.items
.iter()
.enumerate()
.map(|(i, it)| {
let is_selected = self.state.selected_idx == Some(i);
let prefix = if is_selected { '>' } else { ' ' };
let name_with_marker = if it.is_current {
format!("{} (current)", it.name)
} else {
it.name.clone()
};
let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
GenericDisplayRow {
name: display_name,
match_indices: None,
is_current: it.is_current,
description: it.description.clone(),
}
})
.collect();
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
fn desired_height(&self, _width: u16) -> u16 {
let rows = (self.items.len()).clamp(1, MAX_POPUP_ROWS);
// +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 = rows_height + 2;
// +1 for optional subtitle, +1 for optional footer
let mut height = rows as u16 + 2;
if self.subtitle.is_some() {
// +1 for subtitle (the spacer is accounted for above)
height = height.saturating_add(1);
}
if self.footer_hint.is_some() {
height = height.saturating_add(2);
}
height
height.saturating_add(2)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -217,7 +213,7 @@ impl BottomPaneView for ListSelectionView {
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 };
let footer_reserved = 2;
let rows_area = Rect {
x: area.x,
y: next_y,
@@ -235,17 +231,28 @@ impl BottomPaneView for ListSelectionView {
.map(|(i, it)| {
let is_selected = self.state.selected_idx == Some(i);
let prefix = if is_selected { '>' } else { ' ' };
let number = i + 1;
let label_prefix = format!("{prefix} {number}. ");
let styled_name = if let Some(styled_label) = it.styled_label.as_ref() {
let mut spans = Vec::new();
spans.push(label_prefix.into());
spans.extend(styled_label.clone());
Some(spans)
} else {
None
};
let name_with_marker = if it.is_current {
format!("{} (current)", it.name)
} else {
it.name.clone()
};
let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
let display_name = format!("{prefix} {number}. {name_with_marker}");
GenericDisplayRow {
name: display_name,
match_indices: None,
is_current: it.is_current,
description: it.description.clone(),
styled_name,
}
})
.collect();
@@ -261,16 +268,22 @@ impl BottomPaneView for ListSelectionView {
);
}
if let Some(hint) = &self.footer_hint {
let footer_area = Rect {
if area.height >= 2 {
let spacer_area = Rect {
x: area.x,
y: area.y + area.height - 1,
y: area.y + area.height - 2,
width: area.width,
height: 1,
};
let footer_para = Paragraph::new(hint.clone().dim());
footer_para.render(footer_area, buf);
Clear.render(spacer_area, buf);
}
let footer_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
Paragraph::new(standard_popup_hint_line()).render(footer_area, buf);
}
}
@@ -292,18 +305,21 @@ mod tests {
description: Some("Codex can read files".to_string()),
is_current: true,
actions: vec![],
styled_label: None,
dismiss_on_select: true,
},
SelectionItem {
name: "Full Access".to_string(),
description: Some("Codex can edit files".to_string()),
is_current: false,
actions: vec![],
styled_label: None,
dismiss_on_select: true,
},
];
ListSelectionView::new(
"Select Approval Mode".to_string(),
subtitle.map(str::to_string),
Some("Press Enter to confirm or Esc to go back".to_string()),
items,
tx,
)

View File

@@ -12,22 +12,35 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::WidgetRef;
use std::time::Duration;
mod approval_modal_view;
mod bottom_pane_view;
pub(crate) mod bottom_pane_view;
mod branch_picker_view;
mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod commit_picker_view;
mod custom_prompt_view;
mod file_search_popup;
mod list_selection_view;
mod paste_burst;
mod popup_consts;
mod review_selection_view;
mod scroll_state;
mod searchable_table_picker_view;
mod selection_popup_common;
mod textarea;
pub(crate) const STANDARD_POPUP_HINT: &str = "Press Enter to confirm or Esc to go back";
pub(crate) fn standard_popup_hint_line() -> Line<'static> {
Line::from(vec![STANDARD_POPUP_HINT.dim()])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
Handled,
@@ -40,8 +53,15 @@ use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
use approval_modal_view::ApprovalModalView;
pub(crate) use branch_picker_view::BranchPickerView;
pub(crate) use commit_picker_view::CommitPickerView;
pub(crate) use custom_prompt_view::CustomPromptView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
pub(crate) use review_selection_view::ReviewSelectionView;
pub(crate) use searchable_table_picker_view::SearchableTablePickerView;
pub(crate) use searchable_table_picker_view::TablePickerItem;
pub(crate) use searchable_table_picker_view::TablePickerOnSelected;
/// Pane displayed in the lower half of the chat UI.
pub(crate) struct BottomPane {
@@ -49,8 +69,8 @@ pub(crate) struct BottomPane {
/// input state is retained when the view is closed.
composer: ChatComposer,
/// If present, this is displayed instead of the `composer` (e.g. modals).
active_view: Option<Box<dyn BottomPaneView>>,
/// Stack of views displayed instead of the composer (e.g. popups/modals).
view_stack: Vec<Box<dyn BottomPaneView>>,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
@@ -87,7 +107,7 @@ impl BottomPane {
params.placeholder_text,
params.disable_paste_burst,
),
active_view: None,
view_stack: Vec::new(),
app_event_tx: params.app_event_tx,
frame_requester: params.frame_requester,
has_input_focus: params.has_input_focus,
@@ -99,12 +119,30 @@ impl BottomPane {
}
}
fn active_view(&self) -> Option<&dyn BottomPaneView> {
self.view_stack.last().map(|view| view.as_ref())
}
fn push_view(&mut self, view: Box<dyn BottomPaneView>) {
self.view_stack.push(view);
self.request_redraw();
}
pub(crate) fn clear_views(&mut self) {
if self.view_stack.is_empty() {
return;
}
self.view_stack.clear();
self.resume_status_timer_after_modal();
self.request_redraw();
}
pub fn desired_height(&self, width: u16) -> u16 {
// Always reserve one blank row above the pane for visual spacing.
let top_margin = 1;
// Base height depends on whether a modal/overlay is active.
let base = match self.active_view.as_ref() {
let base = match self.active_view() {
Some(view) => view.desired_height(width),
None => self.composer.desired_height(width).saturating_add(
self.status
@@ -131,7 +169,7 @@ impl BottomPane {
width: area.width,
height: area.height - top_margin - bottom_margin,
};
match self.active_view.as_ref() {
match self.active_view() {
Some(_) => [Rect::ZERO, area],
None => {
let status_height = self
@@ -148,20 +186,22 @@ impl BottomPane {
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
if self.active_view.is_some() {
None
let [_, content] = self.layout(area);
if let Some(view) = self.active_view() {
view.cursor_pos(content)
} else {
let [_, content] = self.layout(area);
self.composer.cursor_pos(content)
}
}
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
if let Some(mut view) = self.active_view.take() {
if let Some(mut view) = self.view_stack.pop() {
let reinsertion_index = self.view_stack.len();
view.handle_key_event(self, key_event);
if !view.is_complete() {
self.active_view = Some(view);
let idx = reinsertion_index.min(self.view_stack.len());
self.view_stack.insert(idx, view);
} else {
self.on_active_view_complete();
}
@@ -193,7 +233,7 @@ impl BottomPane {
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
/// chance to consume the event (e.g. to dismiss itself).
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
let mut view = match self.active_view.take() {
let mut view = match self.view_stack.pop() {
Some(view) => view,
None => {
return if self.composer_is_empty() {
@@ -210,21 +250,31 @@ impl BottomPane {
match event {
CancellationEvent::Handled => {
if !view.is_complete() {
self.active_view = Some(view);
self.view_stack.push(view);
} else {
self.on_active_view_complete();
}
self.show_ctrl_c_quit_hint();
}
CancellationEvent::NotHandled => {
self.active_view = Some(view);
self.view_stack.push(view);
}
}
event
}
pub fn handle_paste(&mut self, pasted: String) {
if self.active_view.is_none() {
if let Some(mut view) = self.view_stack.pop() {
let needs_redraw = view.handle_paste(self, pasted);
if !view.is_complete() {
self.view_stack.push(view);
} else {
self.on_active_view_complete();
}
if needs_redraw {
self.request_redraw();
}
} else {
let needs_redraw = self.composer.handle_paste(pasted);
if needs_redraw {
self.request_redraw();
@@ -322,18 +372,37 @@ impl BottomPane {
&mut self,
title: String,
subtitle: Option<String>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
) {
let view = list_selection_view::ListSelectionView::new(
title,
subtitle,
footer_hint,
items,
self.app_event_tx.clone(),
);
self.active_view = Some(Box::new(view));
self.request_redraw();
self.push_view(Box::new(view));
}
pub(crate) fn update_active_selection_view<F>(&mut self, update: F)
where
F: FnOnce(&mut list_selection_view::ListSelectionView) -> bool,
{
let Some(view) = self.view_stack.last_mut() else {
return;
};
let Some(selection_view) = view
.as_any_mut()
.downcast_mut::<list_selection_view::ListSelectionView>()
else {
return;
};
if update(selection_view) {
self.request_redraw();
}
}
pub(crate) fn has_active_view(&self) -> bool {
!self.view_stack.is_empty()
}
/// Update the queued messages shown under the status header.
@@ -363,7 +432,7 @@ impl BottomPane {
/// overlays or popups and not running a task. This is the safe context to
/// use Esc-Esc for backtracking from the main view.
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
!self.is_task_running && self.active_view.is_none() && !self.composer.popup_active()
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
}
/// Update the *context-window remaining* indicator in the composer. This
@@ -373,9 +442,13 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view);
}
/// Called when the agent requests user approval.
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
let request = if let Some(view) = self.active_view.as_mut() {
let request = if let Some(view) = self.view_stack.last_mut() {
match view.try_consume_approval_request(request) {
Some(request) => request,
None => {
@@ -390,8 +463,7 @@ impl BottomPane {
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.active_view = Some(Box::new(modal));
self.request_redraw()
self.push_view(Box::new(modal));
}
fn on_active_view_complete(&mut self) {
@@ -460,7 +532,7 @@ impl BottomPane {
height: u32,
format_label: &str,
) {
if self.active_view.is_none() {
if self.view_stack.is_empty() {
self.composer
.attach_image(path, width, height, format_label);
self.request_redraw();
@@ -477,7 +549,7 @@ impl WidgetRef for &BottomPane {
let [status_area, content] = self.layout(area);
// When a modal view is active, it owns the whole content area.
if let Some(view) = &self.active_view {
if let Some(view) = self.active_view() {
view.render(content, buf);
} else {
// No active modal:
@@ -587,7 +659,7 @@ mod tests {
// After denial, since the task is still running, the status indicator should be
// visible above the composer. The modal should be gone.
assert!(
pane.active_view.is_none(),
pane.view_stack.is_empty(),
"no active modal view after denial"
);

View File

@@ -0,0 +1,641 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::any::Any;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::history_cell::AgentMessageCell;
use crate::key_hint;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use codex_core::protocol::ReviewFinding;
use codex_core::review_format;
pub(crate) struct ReviewSelectionView {
title: String,
comments: Vec<ReviewFinding>,
selected: Vec<bool>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
}
impl ReviewSelectionView {
const TITLE_ROWS: u16 = 1;
const TOP_SPACER_ROWS: u16 = 1;
const BOTTOM_RESERVED_ROWS: u16 = 3;
pub fn new(title: String, comments: Vec<ReviewFinding>, app_event_tx: AppEventSender) -> Self {
let len = comments.len();
Self {
title,
comments,
selected: vec![true; len],
state: ScrollState::new(),
complete: false,
app_event_tx,
}
.init_selection()
}
fn desired_rows(&self, width: u16) -> u16 {
if width == 0 {
return 0;
}
let wrap_width = Self::wrap_width_for(width);
let total_lines: usize = self.compute_heights(wrap_width).into_iter().sum();
let total = Self::base_rows() as usize + total_lines;
total.min(u16::MAX as usize) as u16
}
fn toggle_current(&mut self) {
if let Some(idx) = self.state.selected_idx
&& let Some(v) = self.selected.get_mut(idx)
{
*v = !*v;
}
}
fn move_up(&mut self) {
let len = self.comments.len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, len);
}
fn move_down(&mut self) {
let len = self.comments.len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, len);
}
fn accept(&mut self) {
use crate::app_event::AppEvent;
let selected: Vec<&ReviewFinding> = self
.comments
.iter()
.enumerate()
.filter_map(|(i, comment)| {
self.selected
.get(i)
.copied()
.unwrap_or(false)
.then_some(comment)
})
.collect();
if selected.is_empty() {
self.complete = true;
return;
}
let message_text =
review_format::format_review_findings_block(&self.comments, Some(&self.selected));
let message_lines: Vec<Line<'static>> = message_text
.lines()
.map(|s| Line::from(s.to_string()))
.collect();
let agent_cell = AgentMessageCell::new(message_lines, true);
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(agent_cell)));
let mut user_message = String::new();
user_message.push_str(if selected.len() == 1 {
"Please fix this review comment:\n"
} else {
"Please fix these review comments:\n"
});
for comment in &selected {
let title = &comment.title;
let location = Self::format_location(comment);
user_message.push_str(&format!("\n- {title}{location}\n"));
for body_line in comment.body.lines() {
if body_line.is_empty() {
user_message.push_str(" \n");
} else {
user_message.push_str(&format!(" {body_line}\n"));
}
}
}
self.app_event_tx.send(AppEvent::SubmitUserText(
user_message.trim_end().to_string(),
));
self.complete = true;
}
fn init_selection(mut self) -> Self {
let len = self.comments.len();
if len > 0 {
self.state.selected_idx = Some(0);
// Default to top when opening; render pass will ensure visibility.
self.state.ensure_visible(len, len);
}
self
}
fn wrap_width_for(width: u16) -> usize {
width.saturating_sub(2).max(1) as usize
}
fn base_rows() -> u16 {
Self::TITLE_ROWS + Self::TOP_SPACER_ROWS + Self::BOTTOM_RESERVED_ROWS
}
fn dim_prefix_span() -> Span<'static> {
"".dim()
}
// Note: we render dim prefix lines inline where needed for clarity.
fn format_location(item: &ReviewFinding) -> String {
let path = item.code_location.absolute_file_path.display();
let start = item.code_location.line_range.start;
let end = item.code_location.line_range.end;
format!("{path}:{start}-{end}")
}
fn title_with_priority(item: &ReviewFinding) -> String {
let t = item.title.as_str();
if t.trim_start().starts_with('[') {
t.to_string()
} else {
let priority = item.priority;
format!("[P{priority}] {t}")
}
}
fn header_text(item: &ReviewFinding) -> String {
let title_with_priority = Self::title_with_priority(item);
let loc = Self::format_location(item);
format!("{title_with_priority}{loc}")
}
/// Build the item's prefix (selection marker + checkbox) and return the
/// prefix as a styled Line plus its display width in characters for
/// subsequent indent alignment.
fn header_prefix(is_selected: bool, checked: bool) -> (Line<'static>, usize) {
let selected_marker = if is_selected { '>' } else { ' ' };
let width_hint = format!("{selected_marker} [x] ").chars().count();
let line = if checked {
Line::from(vec![
Span::from(format!("{selected_marker} [")),
"x".cyan(),
Span::from("] "),
])
} else {
Line::from(format!("{selected_marker} [ ] "))
};
(line, width_hint)
}
fn measure_item_lines(&self, idx: usize, wrap_width: usize) -> usize {
if idx >= self.comments.len() {
return 0;
}
let item = &self.comments[idx];
// Compute header (title + location) wrapped height with indent for marker + checkbox.
let is_selected = self.state.selected_idx == Some(idx);
let is_checked = self.selected.get(idx).copied().unwrap_or(false);
let (prefix_line, prefix_width) = Self::header_prefix(is_selected, is_checked);
let header_line = Line::from(Self::header_text(item));
let header_subseq = " ".repeat(prefix_width);
let header_opts = RtOptions::new(wrap_width)
.initial_indent(prefix_line)
.subsequent_indent(Line::from(header_subseq));
let header_len = word_wrap_line(&header_line, header_opts).len();
// Compute body wrapped height (no preview cap; show full body).
let body_line = Line::from(item.body.as_str());
let body_opts = RtOptions::new(wrap_width)
.initial_indent(Line::from(" "))
.subsequent_indent(Line::from(" "));
let body_len = word_wrap_line(&body_line, body_opts).len();
let spacer = if idx + 1 < self.comments.len() { 1 } else { 0 };
header_len + body_len + spacer
}
fn compute_heights(&self, wrap_width: usize) -> Vec<usize> {
(0..self.comments.len())
.map(|i| self.measure_item_lines(i, wrap_width))
.collect()
}
fn choose_start_for_visibility(
&self,
current_start: usize,
selected_idx: usize,
heights: &[usize],
window_rows: usize,
) -> usize {
if heights.is_empty() || window_rows == 0 {
return 0;
}
let n = heights.len();
let sel = selected_idx.min(n - 1);
let start = current_start.min(n - 1);
// Check visibility from current start.
let mut sum = 0usize;
let mut end = start;
while end < n {
let h = heights[end];
if sum.saturating_add(h) > window_rows {
break;
}
sum += h;
end += 1;
}
if sel >= start && sel < end {
return start;
}
// Slide window so sel is visible; include as many items above as fit.
let mut acc = heights[sel];
let mut s = sel;
while s > 0 {
let h = heights[s - 1];
if acc.saturating_add(h) > window_rows {
break;
}
acc += h;
s -= 1;
}
s
}
}
impl BottomPaneView for ReviewSelectionView {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char(' '),
..
} => self.toggle_current(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.complete = true;
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.accept(),
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn desired_height(&self, width: u16) -> u16 {
self.desired_rows(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let render_prefixed_blank_line = |x: u16, y: u16, width: u16, buf: &mut Buffer| {
if width == 0 {
return;
}
let rest_width = width.saturating_sub(2);
let mut spans = vec![Self::dim_prefix_span()];
if rest_width > 0 {
spans.push(" ".repeat(rest_width as usize).into());
}
Paragraph::new(Line::from(spans)).render(
Rect {
x,
y,
width,
height: 1,
},
buf,
);
};
// Title
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.as_str().bold(),
]))
.render(title_area, buf);
if area.height > Self::TITLE_ROWS {
render_prefixed_blank_line(
area.x,
area.y.saturating_add(Self::TITLE_ROWS),
area.width,
buf,
);
}
// Rows area
// Rows area: reserve 2 rows at top (title + top hint)
// and 3 rows at bottom for two spacers + key hint.
let rows_area = Rect {
x: area.x,
y: area
.y
.saturating_add(Self::TITLE_ROWS + Self::TOP_SPACER_ROWS),
width: area.width,
height: area.height.saturating_sub(Self::base_rows()),
};
let wrap_width = Self::wrap_width_for(rows_area.width);
let heights = self.compute_heights(wrap_width);
let window_rows = rows_area.height as usize;
let selected_idx = self
.state
.selected_idx
.unwrap_or(0)
.min(self.comments.len().saturating_sub(1));
let start = self.choose_start_for_visibility(
self.state.scroll_top,
selected_idx,
&heights,
window_rows,
);
let mut y = rows_area.y;
let mut idx = start;
while idx < self.comments.len() && y < rows_area.y.saturating_add(rows_area.height) {
let item = &self.comments[idx];
// Header: marker + checkbox + wrapped title/location
let is_selected = self.state.selected_idx == Some(idx);
let is_checked = self.selected.get(idx).copied().unwrap_or(false);
let (header_prefix_line, header_subseq_len) =
Self::header_prefix(is_selected, is_checked);
let header_subseq = " ".repeat(header_subseq_len);
let header_opts = RtOptions::new(wrap_width)
.initial_indent(header_prefix_line)
.subsequent_indent(Line::from(header_subseq));
let name_line = Line::from(Self::header_text(item));
let header_wrapped = word_wrap_line(&name_line, header_opts);
let mut header_owned: Vec<Line<'static>> = Vec::new();
push_owned_lines(&header_wrapped, &mut header_owned);
let header_prefixed = prefix_lines(
header_owned,
Self::dim_prefix_span(),
Self::dim_prefix_span(),
);
for l in header_prefixed {
if y >= rows_area.y.saturating_add(rows_area.height) {
break;
}
Paragraph::new(l).render(
Rect {
x: rows_area.x,
y,
width: rows_area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
// Body: fully wrapped (dim), no preview cap
if y >= rows_area.y.saturating_add(rows_area.height) {
break;
}
let body_line = Line::from(item.body.as_str().dim());
let body_opts = RtOptions::new(wrap_width)
.initial_indent(Line::from(" "))
.subsequent_indent(Line::from(" "));
let body_wrapped = word_wrap_line(&body_line, body_opts);
let mut body_owned: Vec<Line<'static>> = Vec::new();
push_owned_lines(&body_wrapped, &mut body_owned);
let body_prefixed =
prefix_lines(body_owned, Self::dim_prefix_span(), Self::dim_prefix_span());
for l in body_prefixed {
if y >= rows_area.y.saturating_add(rows_area.height) {
break;
}
Paragraph::new(l).render(
Rect {
x: rows_area.x,
y,
width: rows_area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
// Spacer line between items (not after the last item).
if idx + 1 < self.comments.len() && y < rows_area.y.saturating_add(rows_area.height) {
render_prefixed_blank_line(rows_area.x, y, rows_area.width, buf);
y = y.saturating_add(1);
}
idx += 1;
}
let pane_bottom = area.y.saturating_add(area.height);
if y < pane_bottom {
render_prefixed_blank_line(rows_area.x, y, rows_area.width, buf);
y = y.saturating_add(1);
}
// Hint with blue keys, matching chat input hint styling.
let hint_spans: Vec<Span<'static>> = vec![
Self::dim_prefix_span(),
key_hint::plain("Enter"),
"=fix selected issues".dim(),
" ".into(),
key_hint::plain("Space"),
"=toggle".dim(),
" ".into(),
key_hint::plain("↑/↓"),
"=scroll".dim(),
" ".into(),
key_hint::plain("Esc"),
"=cancel".dim(),
];
if y < pane_bottom {
Paragraph::new(Line::from(hint_spans)).render(
Rect {
x: rows_area.x,
y,
width: rows_area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
while y < pane_bottom {
render_prefixed_blank_line(rows_area.x, y, rows_area.width, buf);
y = y.saturating_add(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_core::protocol::ReviewCodeLocation;
use codex_core::protocol::ReviewFinding;
use codex_core::protocol::ReviewLineRange;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tokio::sync::mpsc::unbounded_channel;
/// Accepting the view submits a user message summarizing checked findings
/// and renders history output that reflects each checkbox state.
#[test]
fn accept_emits_history_and_user_message_for_selected_findings() {
let (sender, mut rx) = test_sender();
let findings = vec![
finding("Leak fix", "Close the file handle", 4),
finding("Rename", "Use snake case", 10),
];
let mut view =
ReviewSelectionView::new("Select review comments".to_string(), findings, sender);
// Deselect the second finding so only the first is submitted.
view.move_down();
view.toggle_current();
view.accept();
let mut history_lines = Vec::new();
let mut user_messages = Vec::new();
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => {
history_lines.push(lines_to_strings(cell.display_lines(120)));
}
AppEvent::SubmitUserText(text) => {
user_messages.push(text);
}
_ => {}
}
}
assert!(
view.complete,
"view should close after accepting selections"
);
assert_eq!(
user_messages,
vec![String::from(
"Please fix this review comment:\n\n- Leak fix — src/lib.rs:4-5\n Close the file handle",
),]
);
assert_eq!(
history_lines,
vec![vec![
String::from("> Full review comments:"),
String::from(" "),
String::from(" - [x] Leak fix — src/lib.rs:4-5"),
String::from(" Close the file handle"),
String::from(" "),
String::from(" - [ ] Rename — src/lib.rs:10-11"),
String::from(" Use snake case"),
]]
);
}
/// Accepting with every finding unchecked completes and leaves the event
/// stream untouched.
#[test]
fn accept_with_no_selected_findings_completes_without_emitting_events() {
let (sender, mut rx) = test_sender();
let findings = vec![finding("First", "body", 1), finding("Second", "body", 3)];
let mut view =
ReviewSelectionView::new("Select review comments".to_string(), findings, sender);
// Deselect both findings so none remain checked.
view.toggle_current();
view.move_down();
view.toggle_current();
view.accept();
assert!(view.complete);
assert!(
rx.try_recv().is_err(),
"no events should be emitted when nothing is selected"
);
}
fn test_sender() -> (
AppEventSender,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
let (tx, rx) = unbounded_channel();
(AppEventSender::new(tx), rx)
}
fn finding(title: &str, body: &str, start: u32) -> ReviewFinding {
ReviewFinding {
title: title.to_string(),
body: body.to_string(),
confidence_score: 0.75,
priority: 1,
code_location: ReviewCodeLocation {
absolute_file_path: PathBuf::from("src/lib.rs"),
line_range: ReviewLineRange {
start,
end: start + 1,
},
},
}
}
fn lines_to_strings(lines: Vec<Line<'static>>) -> Vec<String> {
lines
.into_iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.to_string())
.collect::<String>()
})
.collect()
}
}

View File

@@ -0,0 +1,368 @@
use crate::app_event_sender::AppEventSender;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::any::Any;
use std::cell::RefCell;
use super::BottomPane;
use super::bottom_pane_view::BottomPaneView;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use super::standard_popup_hint_line;
pub(crate) type TablePickerOnSelected<T> =
Box<dyn Fn(&AppEventSender, &mut BottomPane, T) + Send + Sync>;
pub(crate) struct TablePickerItem<T: Clone> {
pub value: T,
pub label: String,
pub description: Option<String>,
pub search_value: String,
pub detail_builder: Option<Box<dyn Fn() -> Option<Vec<Span<'static>>>>>,
}
pub(crate) struct SearchableTablePickerView<T: Clone> {
title: String,
search_placeholder: String,
empty_message: String,
items: Vec<TablePickerItem<T>>,
filtered_indices: Vec<usize>,
query: String,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
on_selected: TablePickerOnSelected<T>,
max_visible_rows: usize,
detail_cache: Vec<RefCell<Option<Vec<Span<'static>>>>>,
}
impl<T: Clone> SearchableTablePickerView<T> {
fn clear_area(area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let x_end = area.x.saturating_add(area.width);
let y_end = area.y.saturating_add(area.height);
for y in area.y..y_end {
for x in area.x..x_end {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.reset();
}
}
}
}
fn render_left_gutter_line(area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
Self::clear_area(area, buf);
Paragraph::new(Line::from("".dim())).render(area, buf);
}
pub(crate) fn new(
title: String,
search_placeholder: String,
empty_message: String,
items: Vec<TablePickerItem<T>>,
max_visible_rows: usize,
app_event_tx: AppEventSender,
on_selected: TablePickerOnSelected<T>,
) -> Self {
let max_visible_rows = max_visible_rows.max(1);
let mut view = Self {
title,
search_placeholder,
empty_message,
items,
filtered_indices: Vec::new(),
query: String::new(),
state: ScrollState::new(),
complete: false,
app_event_tx,
on_selected,
max_visible_rows,
detail_cache: Vec::new(),
};
view.reset_detail_cache();
view.apply_filter();
view
}
fn detail_for(&self, idx: usize) -> Option<Vec<Span<'static>>> {
let cache = self.detail_cache.get(idx)?;
if let Some(detail) = cache.borrow().clone() {
return Some(detail);
}
let detail = self
.items
.get(idx)
.and_then(|item| item.detail_builder.as_ref())
.and_then(|builder| builder());
cache.replace(detail.clone());
detail
}
fn reset_detail_cache(&mut self) {
self.detail_cache = self.items.iter().map(|_| RefCell::new(None)).collect();
}
fn apply_filter(&mut self) {
self.filtered_indices = if self.query.is_empty() {
(0..self.items.len()).collect()
} else {
let query_lower = self.query.to_lowercase();
self.items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
let haystack = item.search_value.to_lowercase();
haystack.contains(&query_lower).then_some(idx)
})
.collect()
};
let len = self.filtered_indices.len();
self.state.clamp_selection(len);
self.state
.ensure_visible(len, len.min(self.max_visible_rows).max(1));
}
fn move_up(&mut self) {
let len = self.filtered_indices.len();
self.state.move_up_wrap(len);
self.state
.ensure_visible(len, len.min(self.max_visible_rows).max(1));
}
fn move_down(&mut self) {
let len = self.filtered_indices.len();
self.state.move_down_wrap(len);
self.state
.ensure_visible(len, len.min(self.max_visible_rows).max(1));
}
fn accept(&mut self, pane: &mut BottomPane) {
if let Some(selected_idx) = self.state.selected_idx
&& let Some(actual_idx) = self.filtered_indices.get(selected_idx)
&& let Some(item) = self.items.get(*actual_idx)
{
(self.on_selected)(&self.app_event_tx, pane, item.value.clone());
self.complete = true;
} else {
self.complete = true;
}
}
fn cancel(&mut self) {
self.complete = true;
}
fn render_title(&self, area: Rect, buf: &mut Buffer) {
let spans = vec!["".dim(), self.title.clone().bold()];
Paragraph::new(Line::from(spans)).render(area, buf);
}
fn render_search(&self, area: Rect, buf: &mut Buffer) {
let line = if self.query.is_empty() {
Line::from(vec!["".dim(), self.search_placeholder.clone().dim()])
} else {
let query = &self.query;
Line::from(vec!["".dim(), query.into()])
};
Paragraph::new(line).render(area, buf);
}
}
impl<T: Clone + 'static> BottomPaneView for SearchableTablePickerView<T> {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn handle_key_event(&mut self, pane: &mut super::BottomPane, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Esc, ..
} => self.cancel(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.accept(pane),
KeyEvent {
code: KeyCode::Backspace,
..
} => {
self.query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(c),
..
} => {
if !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT)
{
self.query.push(c);
self.apply_filter();
}
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane) -> super::CancellationEvent {
self.cancel();
super::CancellationEvent::Handled
}
fn desired_height(&self, _width: u16) -> u16 {
let rows = self
.filtered_indices
.len()
.clamp(1, self.max_visible_rows)
.max(1) as u16;
5 + rows
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
self.render_title(title_area, buf);
let search_area = Rect {
x: area.x,
y: area.y.saturating_add(1),
width: area.width,
height: 1,
};
self.render_search(search_area, buf);
let (spacer_height, rows_offset) = if area.height > 2 { (1, 3) } else { (0, 2) };
if spacer_height == 1 {
let spacer_area = Rect {
x: area.x,
y: area.y.saturating_add(2),
width: area.width,
height: 1,
};
Self::render_left_gutter_line(spacer_area, buf);
}
let remaining_height = area.height.saturating_sub(rows_offset);
let hint_reserved = match remaining_height {
h if h >= 2 => 2,
1 => 1,
_ => 0,
};
let rows_area = Rect {
x: area.x,
y: area.y.saturating_add(rows_offset),
width: area.width,
height: remaining_height.saturating_sub(hint_reserved),
};
let rows: Vec<GenericDisplayRow> = self
.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, source_idx)| {
self.items.get(*source_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '>' } else { ' ' };
let number = visible_idx + 1;
let label = &item.label;
let prefix_str = format!("{prefix} {number}. ");
let display = format!("{prefix_str}{label}");
let detail_spans = if is_selected {
self.detail_for(*source_idx)
} else {
None
};
let styled_name = detail_spans.map(|mut detail| {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(display.clone().into());
if !detail.is_empty() {
spans.push(" ".into());
}
spans.append(&mut detail);
spans
});
GenericDisplayRow {
name: display,
match_indices: None,
is_current: false,
description: item.description.clone(),
styled_name,
}
})
})
.collect();
render_rows(
rows_area,
buf,
&rows,
&self.state,
self.max_visible_rows,
true,
&self.empty_message,
);
if hint_reserved > 0 {
let hint_y = area.y.saturating_add(area.height).saturating_sub(1);
if hint_y >= area.y {
let hint_area = Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
};
Paragraph::new(standard_popup_hint_line()).render(hint_area, buf);
}
if hint_reserved >= 2 {
let spacer_y = hint_y.saturating_sub(1);
if spacer_y >= area.y {
let spacer_area = Rect {
x: area.x,
y: spacer_y,
width: area.width,
height: 1,
};
Self::clear_area(spacer_area, buf);
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
// Note: Table-based layout previously used Constraint; the manual renderer
// below no longer requires it.
use ratatui::prelude::Constraint;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
@@ -11,7 +11,9 @@ use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use super::scroll_state::ScrollState;
@@ -22,65 +24,11 @@ pub(crate) struct GenericDisplayRow {
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub is_current: bool,
pub description: Option<String>, // optional grey text after the name
pub styled_name: Option<Vec<Span<'static>>>,
}
impl GenericDisplayRow {}
/// Compute a shared description-column start based on the widest visible name
/// plus two spaces of padding. Ensures at least one column is left for the
/// description.
fn compute_desc_col(
rows_all: &[GenericDisplayRow],
start_idx: usize,
visible_items: usize,
content_width: u16,
) -> usize {
let visible_range = start_idx..(start_idx + visible_items);
let max_name_width = rows_all
.iter()
.enumerate()
.filter(|(i, _)| visible_range.contains(i))
.map(|(_, r)| Line::from(r.name.clone()).width())
.max()
.unwrap_or(0);
let mut desc_col = max_name_width.saturating_add(2);
if (desc_col as u16) >= content_width {
desc_col = content_width.saturating_sub(1) as usize;
}
desc_col
}
/// Build the full display line for a row with the description padded to start
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
/// dims the description.
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
if let Some(idxs) = row.match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in row.name.chars().enumerate() {
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
name_spans.push(ch.to_string().bold());
} else {
name_spans.push(ch.to_string().into());
}
}
} else {
name_spans.push(row.name.clone().into());
}
let this_name_width = Line::from(name_spans.clone()).width();
let mut full_spans: Vec<Span> = name_spans;
if let Some(desc) = row.description.as_ref() {
let gap = desc_col.saturating_sub(this_name_width);
if gap > 0 {
full_spans.push(" ".repeat(gap).into());
}
full_spans.push(desc.clone().dim());
}
Line::from(full_spans)
}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
pub(crate) fn render_rows(
@@ -92,168 +40,102 @@ pub(crate) fn render_rows(
_dim_non_selected: bool,
empty_message: &str,
) {
// Always draw a dim left border to match other popups.
let block = Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().add_modifier(Modifier::DIM));
block.render(area, buf);
// Content renders to the right of the border.
let content_area = Rect {
x: area.x.saturating_add(1),
y: area.y,
width: area.width.saturating_sub(1),
height: area.height,
};
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
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;
}
rows.push(Row::new(vec![Cell::from(Line::from(
empty_message.dim().italic(),
))]));
} else {
let max_rows_from_area = area.height as usize;
let visible_rows = max_results
.min(rows_all.len())
.min(max_rows_from_area.max(1));
// 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 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 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;
// Compute starting index based on scroll state and selection.
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 {
start_idx = sel;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if sel > bottom {
start_idx = sel + 1 - visible_rows;
}
}
}
}
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_rows)
{
let GenericDisplayRow {
name,
match_indices,
is_current: _is_current,
description,
styled_name,
} = row;
// 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()
.enumerate()
.skip(start_idx)
.take(visible_items)
{
if cur_y >= content_area.y + content_area.height {
break;
}
// Highlight fuzzy indices when present.
let mut spans: Vec<Span> = if let Some(styled) = styled_name.as_ref() {
let mut spans = styled.clone();
if Some(i) == state.selected_idx
&& let Some(label_idx) = spans
.iter()
.position(|span| !span.content.trim().is_empty())
&& let Some(label_span) = spans.get_mut(label_idx)
{
let content = label_span.content.clone().into_owned();
*label_span = content.cyan().bold();
}
spans
} else {
let mut spans = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
spans.push(ch.to_string().bold());
} else {
spans.push(ch.to_string().into());
}
}
} else {
spans.push(name.clone().into());
}
spans
};
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 let Some(desc) = description.as_ref() {
spans.push(" ".into());
spans.push(desc.clone().dim());
}
let mut cell = Cell::from(Line::from(spans));
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);
}
let para = Paragraph::new(line);
para.render(
Rect {
x: content_area.x,
y: cur_y,
width: content_area.width,
height: 1,
},
buf,
);
cur_y = cur_y.saturating_add(1);
}
}
}
/// 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
/// caller can allocate sufficient vertical space.
pub(crate) fn measure_rows_height(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
width: u16,
) -> u16 {
if rows_all.is_empty() {
return 1; // placeholder "no matches" line
}
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 {
start_idx = sel;
} else if visible_items > 0 {
let bottom = start_idx + visible_items - 1;
if sel > bottom {
start_idx = sel + 1 - visible_items;
cell = cell.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
}
rows.push(Row::new(vec![cell]));
}
}
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width);
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().add_modifier(Modifier::DIM)),
)
.widths([Constraint::Percentage(100)]);
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let mut total: u16 = 0;
for row in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
.map(|(_, r)| r)
{
let full_line = build_full_line(row, desc_col);
let opts = RtOptions::new(content_width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(desc_col)));
total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16);
}
total.max(1)
table.render(area, buf);
}

View File

@@ -4,5 +4,5 @@ expression: terminal.backend()
---
"▌ /mo "
"▌ "
"▌/model choose what model and reasoning effort to use "
"▌/model choose what model and reasoning effort to use "
"▌/mention mention a file "

View File

@@ -6,6 +6,6 @@ expression: render_lines(&view)
▌ Switch between Codex approval presets
▌> 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back

View File

@@ -5,6 +5,6 @@ expression: render_lines(&view)
▌ Select Approval Mode
▌> 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back

View File

@@ -19,6 +19,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::InputItem;
use codex_core::protocol::InputMessageKind;
use codex_core::protocol::ListCustomPromptsResponseEvent;
@@ -27,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::Op;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TokenUsage;
@@ -36,6 +38,7 @@ use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UserMessageEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::parse_command::ParsedCommand;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -62,6 +65,7 @@ use crate::bottom_pane::SelectionItem;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::diff_render::display_path_for;
use crate::get_git_diff::get_git_diff;
use crate::git_shortstat::DiffShortStat;
use crate::history_cell;
use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
@@ -79,6 +83,10 @@ use self::agent::spawn_agent;
use self::agent::spawn_agent_from_existing;
mod session_header;
use self::session_header::SessionHeader;
mod review;
use self::review::ReviewController;
use self::review::ReviewExitResult;
use self::review::ReviewState;
use crate::streaming::controller::AppEventHistorySink;
use crate::streaming::controller::StreamController;
use codex_common::approval_presets::ApprovalPreset;
@@ -91,7 +99,6 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_file_search::FileMatch;
use codex_protocol::mcp_protocol::ConversationId;
// Track information about an in-flight exec command.
struct RunningCommand {
@@ -141,6 +148,7 @@ pub(crate) struct ChatWidget {
queued_user_messages: VecDeque<UserMessage>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
review_state: ReviewState,
}
struct UserMessage {
@@ -279,11 +287,13 @@ impl ChatWidget {
}
/// Finalize any active exec as failed, push an error message into history,
/// and stop/clear running UI state.
fn finalize_turn_with_error_message(&mut self, message: String) {
fn finalize_turn_with_error_message(&mut self, message: Option<String>) {
// Ensure any spinner is replaced by a red ✗ and flushed into history.
self.finalize_active_exec_cell_as_failed();
// Emit the provided error message/history cell.
self.add_to_history(history_cell::new_error_event(message));
if let Some(message) = message {
self.add_to_history(history_cell::new_error_event(message));
}
// Reset running state and clear streaming buffers.
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
@@ -291,7 +301,7 @@ impl ChatWidget {
}
fn on_error(&mut self, message: String) {
self.finalize_turn_with_error_message(message);
self.finalize_turn_with_error_message(Some(message));
self.request_redraw();
// After an error ends the turn, try sending the next queued input.
@@ -301,11 +311,13 @@ impl ChatWidget {
/// Handle a turn aborted due to user interrupt (Esc).
/// When there are queued user messages, restore them into the composer
/// separated by newlines rather than autosubmitting the next one.
fn on_interrupted_turn(&mut self) {
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
// Finalize, log a gentle prompt, and clear running state.
self.finalize_turn_with_error_message(
"Conversation interrupted - tell the model what to do differently".to_owned(),
);
self.finalize_turn_with_error_message(if reason == TurnAbortReason::ReviewEnded {
None
} else {
Some("Conversation interrupted - tell the model what to do differently".to_owned())
});
// If any messages were queued during the task, restore them into the composer.
if !self.queued_user_messages.is_empty() {
@@ -398,9 +410,9 @@ impl ChatWidget {
fn on_web_search_end(&mut self, ev: WebSearchEndEvent) {
self.flush_answer_stream_with_separator();
let query = ev.query;
self.add_to_history(history_cell::new_web_search_call(format!(
"Searched: {}",
ev.query
"Searched: {query}"
)));
}
@@ -631,7 +643,11 @@ impl ChatWidget {
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height);
let bottom_min = if self.review_state.is_review_mode() {
3.min(area.height)
} else {
self.bottom_pane.desired_height(area.width).min(area.height)
};
let remaining = area.height.saturating_sub(bottom_min);
let active_desired = self
@@ -700,6 +716,7 @@ impl ChatWidget {
show_welcome_banner: true,
suppress_session_configured_redraw: false,
pending_notification: None,
review_state: ReviewState::new(),
}
}
@@ -756,6 +773,7 @@ impl ChatWidget {
show_welcome_banner: true,
suppress_session_configured_redraw: true,
pending_notification: None,
review_state: ReviewState::new(),
}
}
@@ -870,6 +888,9 @@ impl ChatWidget {
self.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
SlashCommand::Review => {
self.open_review_popup();
}
SlashCommand::Model => {
self.open_model_popup();
}
@@ -976,8 +997,8 @@ impl ChatWidget {
fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(active)));
let cell: Box<dyn history_cell::HistoryCell> = Box::new(active);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
}
@@ -1087,11 +1108,14 @@ impl ChatWidget {
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
EventMsg::TurnAborted(ev) => match ev.reason {
TurnAbortReason::Interrupted => {
self.on_interrupted_turn();
self.on_interrupted_turn(ev.reason);
}
TurnAbortReason::Replaced => {
self.on_error("Turn aborted: replaced by a new task".to_owned())
}
TurnAbortReason::ReviewEnded => {
self.on_interrupted_turn(ev.reason);
}
},
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
EventMsg::ExecApprovalRequest(ev) => {
@@ -1128,11 +1152,83 @@ impl ChatWidget {
self.app_event_tx
.send(crate::app_event::AppEvent::ConversationHistory(ev));
}
EventMsg::EnteredReviewMode(_) => {}
EventMsg::ExitedReviewMode(_) => {}
EventMsg::EnteredReviewMode(review_request) => {
self.on_entered_review_mode(review_request)
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
}
}
fn on_entered_review_mode(&mut self, review: ReviewRequest) {
if let Some(banner) = ReviewController::new(
&mut self.review_state,
&self.config,
&mut self.bottom_pane,
&self.app_event_tx,
)
.enter_review_mode(&review.user_facing_hint)
{
self.add_to_history(history_cell::new_review_status_line(banner));
}
self.request_redraw();
}
fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) {
let update: review::ReviewExitUpdate = ReviewController::new(
&mut self.review_state,
&self.config,
&mut self.bottom_pane,
&self.app_event_tx,
)
.exit_review_mode(review.review_output);
if update.should_flush_stream {
self.flush_answer_stream_with_separator();
self.flush_interrupt_queue();
self.flush_active_exec_cell();
}
match update.result {
ReviewExitResult::None => {}
ReviewExitResult::ShowMessage(body_lines) => {
let body_cell = crate::history_cell::AgentMessageCell::new(body_lines, false);
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
}
ReviewExitResult::ShowFindings(findings) => {
// Always emit a full review findings block in history so results are visible
// even if the selection overlay is skipped due to queued input.
let message_text =
codex_core::review_format::format_review_findings_block(&findings, None);
let message_lines: Vec<ratatui::text::Line<'static>> = message_text
.lines()
.map(|s| ratatui::text::Line::from(s.to_string()))
.collect();
let body_cell = crate::history_cell::AgentMessageCell::new(message_lines, true);
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
// Only show the review selection panel if there isn't another
// user message queued. Otherwise the queued message would start
// a new turn and run behind the overlay.
if self.queued_user_messages.is_empty() {
let view = crate::bottom_pane::ReviewSelectionView::new(
"Select review comments to add to chat".to_string(),
findings,
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
} else {
// Skip opening the panel; queued message will run next.
}
}
}
// Append the finishing banner after emitting any findings/results so the
// banner appears last in history for this turn.
self.add_to_history(history_cell::new_review_status_line(update.banner));
self.request_redraw();
}
fn on_user_message_event(&mut self, event: UserMessageEvent) {
match event.kind {
Some(InputMessageKind::EnvironmentContext)
@@ -1220,6 +1316,32 @@ impl ChatWidget {
));
}
pub(crate) fn on_diff_shortstat_ready(
&mut self,
shortstat: Option<DiffShortStat>,
request_id: u64,
) {
let mut controller = ReviewController::new(
&mut self.review_state,
&self.config,
&mut self.bottom_pane,
&self.app_event_tx,
);
if controller.on_diff_shortstat_ready(shortstat, request_id) {
self.request_redraw();
}
}
pub(crate) fn open_review_popup(&mut self) {
let mut controller = ReviewController::new(
&mut self.review_state,
&self.config,
&mut self.bottom_pane,
&self.app_event_tx,
);
controller.open_review_popup();
}
/// Open a popup to choose the model preset (model + reasoning effort).
pub(crate) fn open_model_popup(&mut self) {
let current_model = self.config.model.clone();
@@ -1235,7 +1357,7 @@ impl ChatWidget {
let model_slug = preset.model.to_string();
let effort = preset.effort;
let current_model = current_model.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
let actions: Vec<SelectionAction> = vec![Box::new(move |_, tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
@@ -1267,13 +1389,14 @@ impl ChatWidget {
description,
is_current,
actions,
styled_label: None,
dismiss_on_select: true,
});
}
self.bottom_pane.show_selection_view(
"Select model and reasoning level".to_string(),
Some("Switch between OpenAI models for this and future Codex CLI session".to_string()),
Some("Press Enter to confirm or Esc to go back".to_string()),
items,
);
}
@@ -1291,7 +1414,7 @@ impl ChatWidget {
let sandbox = preset.sandbox.clone();
let name = preset.label.to_string();
let description = Some(preset.description.to_string());
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
let actions: Vec<SelectionAction> = vec![Box::new(move |_, tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(approval),
@@ -1308,15 +1431,13 @@ impl ChatWidget {
description,
is_current,
actions,
styled_label: None,
dismiss_on_select: true,
});
}
self.bottom_pane.show_selection_view(
"Select Approval Mode".to_string(),
None,
Some("Press Enter to confirm or Esc to go back".to_string()),
items,
);
self.bottom_pane
.show_selection_view("Select Approval Mode".to_string(), None, items);
}
/// Set the approval policy in the widget's config copy.
@@ -1406,7 +1527,10 @@ impl ChatWidget {
self.bottom_pane.clear_esc_backtrack_hint();
}
/// Forward an `Op` directly to codex.
pub(crate) fn submit_op(&self, op: Op) {
pub(crate) fn submit_op(&mut self, op: Op) {
if matches!(op, Op::Review { .. }) {
self.bottom_pane.clear_views();
}
// Record outbound operation for session replay fidelity.
crate::session_log::log_outbound_op(&op);
if let Err(e) = self.codex_op_tx.send(op) {
@@ -1432,7 +1556,14 @@ impl ChatWidget {
if text.is_empty() {
return;
}
self.submit_user_message(text.into());
let user_message: UserMessage = text.into();
if self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.refresh_queued_user_messages();
} else {
self.submit_user_message(user_message);
}
}
pub(crate) fn token_usage(&self) -> TokenUsage {
@@ -1467,13 +1598,15 @@ impl WidgetRef for &ChatWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area);
(&self.bottom_pane).render(bottom_pane_area, buf);
if !active_cell_area.is_empty()
self.review_state.render_hint(area, bottom_pane_area, buf);
if !self.bottom_pane.has_active_view()
&& !active_cell_area.is_empty()
&& let Some(cell) = &self.active_exec_cell
{
let mut active_cell_area = active_cell_area;
active_cell_area.y = active_cell_area.y.saturating_add(1);
active_cell_area.height -= 1;
cell.render_ref(active_cell_area, buf);
let mut area_to_render = active_cell_area;
area_to_render.y = area_to_render.y.saturating_add(1);
area_to_render.height = area_to_render.height.saturating_sub(1);
cell.render_ref(area_to_render, buf);
}
}
}

View File

@@ -0,0 +1,557 @@
use codex_core::config::Config;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewFinding;
use codex_core::protocol::ReviewOutputEvent;
use codex_core::protocol::ReviewRequest;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line as RtLine;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::path::PathBuf;
use tokio::runtime::Handle;
use tokio::spawn;
use tracing::debug;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BranchPickerView;
use crate::bottom_pane::CommitPickerView;
use crate::bottom_pane::CustomPromptView;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::git_shortstat::DiffShortStat;
use crate::git_shortstat::get_diff_shortstat;
use codex_core::review_format;
pub(crate) struct ReviewState {
is_review_mode: bool,
diff_shortstat: Option<DiffShortStat>,
diff_shortstat_inflight: Option<u64>,
diff_shortstat_generation: u64,
}
pub(crate) struct ReviewExitUpdate {
pub banner: String,
pub should_flush_stream: bool,
pub result: ReviewExitResult,
}
pub(crate) enum ReviewExitResult {
None,
ShowMessage(Vec<RtLine<'static>>),
ShowFindings(Vec<ReviewFinding>),
}
impl ReviewState {
pub(crate) fn new() -> Self {
Self {
is_review_mode: false,
diff_shortstat: None,
diff_shortstat_inflight: None,
diff_shortstat_generation: 0,
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn is_review_mode(&self) -> bool {
self.is_review_mode
}
pub(crate) fn enter_review_mode(&mut self, user_facing_hint: &str) -> Option<String> {
let banner = format!(">> Code review started: {user_facing_hint} <<");
let should_emit_banner = !self.is_review_mode;
self.is_review_mode = true;
should_emit_banner.then_some(banner)
}
pub(crate) fn exit_review_mode(
&mut self,
review_output: Option<ReviewOutputEvent>,
) -> ReviewExitUpdate {
self.is_review_mode = false;
let mut result = ReviewExitResult::None;
let mut should_flush_stream = false;
if let Some(output) = review_output {
should_flush_stream = true;
if output.findings.is_empty() {
// Use the shared formatter to keep header/structure consistent
// with other review displays. For an empty findings list this
// yields a simple "Review comment:" header.
let mut body_lines: Vec<RtLine<'static>> =
review_format::format_review_findings_block(&[], None)
.lines()
.map(|s| RtLine::from(s.to_string()))
.collect();
// Add details (overall explanation or a fallback message).
body_lines.push(RtLine::from(""));
if !output.overall_explanation.trim().is_empty() {
for line in output.overall_explanation.lines() {
body_lines.push(RtLine::from(line.to_string()));
}
} else {
body_lines.push(RtLine::from("Review failed -- no response found"));
}
result = ReviewExitResult::ShowMessage(body_lines);
} else {
result = ReviewExitResult::ShowFindings(output.findings);
}
}
ReviewExitUpdate {
banner: "<< Code review finished >>".to_string(),
should_flush_stream,
result,
}
}
pub(crate) fn on_diff_shortstat_ready(
&mut self,
shortstat: Option<DiffShortStat>,
request_id: u64,
) -> bool {
if let Some(expected) = self.diff_shortstat_inflight {
if expected != request_id {
return false;
}
} else if request_id != self.diff_shortstat_generation {
return false;
}
self.diff_shortstat_inflight = None;
self.diff_shortstat = shortstat;
true
}
fn current_changes_styled_label(&self) -> Option<(String, Vec<Span<'static>>)> {
let stats = self.diff_shortstat?;
let files_changed = stats.files_changed;
let insertions = stats.insertions;
let deletions = stats.deletions;
let file_label = if stats.files_changed == 1 {
"file changed"
} else {
"files changed"
};
let summary_text = format!(
"Review current changes - {files_changed} {file_label} (+{insertions} -{deletions})"
);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push("Review current changes".into());
let prefix = format!(" - {files_changed} {file_label} (").dim();
spans.push(prefix);
spans.push(format!("+{insertions}").green());
spans.push(" ".dim());
spans.push(format!("-{deletions}").red());
spans.push(")".dim());
Some((summary_text, spans))
}
pub(crate) fn render_hint(&self, area: Rect, bottom_pane_area: Rect, buf: &mut Buffer) {
if !self.is_review_mode {
return;
}
let hint_y = bottom_pane_area
.y
.saturating_add(bottom_pane_area.height)
.saturating_sub(1);
if hint_y < bottom_pane_area.y || hint_y >= area.y.saturating_add(area.height) {
return;
}
let blank_line = " ".repeat(bottom_pane_area.width as usize);
let render_blank_line = |buf: &mut Buffer, y: u16| {
if y < bottom_pane_area.y || y >= area.y.saturating_add(area.height) {
return;
}
Paragraph::new(RtLine::from(blank_line.as_str())).render(
Rect {
x: bottom_pane_area.x,
y,
width: bottom_pane_area.width,
height: 1,
},
buf,
);
};
if bottom_pane_area.width > 0 && hint_y > bottom_pane_area.y {
render_blank_line(buf, hint_y.saturating_sub(1));
}
let hint_area = Rect {
x: bottom_pane_area.x,
y: hint_y,
width: bottom_pane_area.width,
height: 1,
};
#[allow(clippy::disallowed_methods)]
let line = RtLine::from("✎ Review in progress (esc to cancel)".yellow());
Paragraph::new(line).render(hint_area, buf);
let below_y = hint_y.saturating_add(1);
if bottom_pane_area.width > 0 && below_y < area.y.saturating_add(area.height) {
render_blank_line(buf, below_y);
}
}
}
pub(crate) struct ReviewController<'a> {
state: &'a mut ReviewState,
config: &'a Config,
bottom_pane: &'a mut BottomPane,
app_event_tx: &'a AppEventSender,
}
impl<'a> ReviewController<'a> {
pub(crate) fn new(
state: &'a mut ReviewState,
config: &'a Config,
bottom_pane: &'a mut BottomPane,
app_event_tx: &'a AppEventSender,
) -> Self {
Self {
state,
config,
bottom_pane,
app_event_tx,
}
}
pub(crate) fn enter_review_mode(&mut self, user_facing_hint: &str) -> Option<String> {
self.state.enter_review_mode(user_facing_hint)
}
pub(crate) fn exit_review_mode(
&mut self,
review_output: Option<ReviewOutputEvent>,
) -> ReviewExitUpdate {
self.state.exit_review_mode(review_output)
}
pub(crate) fn request_diff_shortstat(&mut self, force: bool) {
if Handle::try_current().is_err() {
return;
}
if !force && self.state.diff_shortstat_inflight.is_some() {
return;
}
let request_id = self.state.diff_shortstat_generation.wrapping_add(1);
self.state.diff_shortstat_generation = request_id;
self.state.diff_shortstat_inflight = Some(request_id);
let tx = self.app_event_tx.clone();
let cwd = self.config.cwd.clone();
spawn(async move {
let shortstat = match get_diff_shortstat(&cwd).await {
Ok(value) => value,
Err(err) => {
debug!(error = ?err, "failed to compute git shortstat");
None
}
};
tx.send(AppEvent::DiffShortstat {
shortstat,
request_id,
});
});
}
pub(crate) fn on_diff_shortstat_ready(
&mut self,
shortstat: Option<DiffShortStat>,
request_id: u64,
) -> bool {
let updated = self.state.on_diff_shortstat_ready(shortstat, request_id);
if updated {
self.refresh_review_preset_label();
}
updated
}
pub(crate) fn open_review_popup(&mut self) {
self.request_diff_shortstat(true);
let mut items: Vec<SelectionItem> = Vec::new();
let build_review_actions =
|review_request: ReviewRequest, context_line: String| -> Vec<SelectionAction> {
vec![Box::new(move |pane, tx: &AppEventSender| {
let base_prompt = review_request.prompt.clone();
let hint = review_request.user_facing_hint.clone();
// Always show custom prompt as last step; empty input submits base prompt.
let title = "Custom Instructions".to_string();
let placeholder =
"Add anything else the reviewer should know (optional)".to_string();
let context_label = context_line.clone();
let on_submit = {
let tx = tx.clone();
move |custom: String| {
let trimmed = custom.trim().to_string();
let prompt = if trimmed.is_empty() {
base_prompt.clone()
} else {
format!("{base_prompt}\n\n{trimmed}")
};
let user_facing_hint = if trimmed.is_empty() {
hint.clone()
} else {
format!("{hint} ({trimmed})")
};
tx.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt,
user_facing_hint,
},
}));
}
};
let view = CustomPromptView::new(
title,
placeholder,
Some(context_label),
true, // allow empty submit to send base prompt
Box::new(on_submit),
);
pane.show_view(Box::new(view));
})]
};
let (name, styled_label) = self
.state
.current_changes_styled_label()
.map(|(summary, spans)| (summary, Some(spans)))
.unwrap_or_else(|| ("Review current changes".to_string(), None));
let base_prompt = "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string();
let user_facing_hint = "current changes".to_string();
let context_line = "Review current changes".to_string();
let actions = build_review_actions(
ReviewRequest {
prompt: base_prompt,
user_facing_hint,
},
context_line,
);
items.push(SelectionItem {
name,
description: None,
is_current: false,
actions,
styled_label,
dismiss_on_select: true,
});
let commit_cwd = self.config.cwd.clone();
items.push(SelectionItem {
name: "Review commit".to_string(),
description: None,
is_current: false,
actions: vec![Self::commit_picker_action(commit_cwd)],
styled_label: None,
dismiss_on_select: false,
});
let branch_cwd = self.config.cwd.clone();
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: None,
is_current: false,
actions: vec![Self::branch_picker_action(branch_cwd)],
styled_label: None,
dismiss_on_select: false,
});
items.push(SelectionItem {
name: "Custom Instructions".to_string(),
description: None,
is_current: false,
actions: vec![Self::custom_prompt_action(
"Enter custom review instructions".to_string(),
)],
styled_label: None,
dismiss_on_select: false,
});
self.bottom_pane
.show_selection_view("Select a review preset".into(), None, items);
}
fn current_changes_label(&self) -> (String, Option<Vec<Span<'static>>>) {
self.state
.current_changes_styled_label()
.map(|(summary, spans)| (summary, Some(spans)))
.unwrap_or_else(|| ("Review current changes".to_string(), None))
}
fn refresh_review_preset_label(&mut self) {
let (name, styled_label) = self.current_changes_label();
self.bottom_pane.update_active_selection_view(|view| {
if view.title() != "Select a review preset" {
return false;
}
view.update_item(0, |item| {
item.name = name.clone();
item.styled_label = styled_label.clone();
});
true
});
}
fn branch_picker_action(cwd: PathBuf) -> SelectionAction {
Box::new(move |pane, tx| {
Self::open_branch_picker(cwd.clone(), pane, tx);
})
}
fn commit_picker_action(cwd: PathBuf) -> SelectionAction {
Box::new(move |pane, tx| {
Self::open_commit_picker(cwd.clone(), pane, tx);
})
}
fn custom_prompt_action(title: String) -> SelectionAction {
Box::new(move |pane, tx| {
Self::open_custom_prompt(pane, tx, title.clone());
})
}
pub(crate) fn open_branch_picker(
cwd: PathBuf,
bottom_pane: &mut BottomPane,
app_event_tx: &AppEventSender,
) {
let view = BranchPickerView::new(
cwd,
app_event_tx.clone(),
Box::new(move |tx2, bottom_pane, branch: String| {
let prompt = format!(
"Review the code changes against the base branch '{branch}'. Start by running `git diff {branch}`. Provide prioritized, actionable findings."
);
let context = format!("changes against '{branch}'");
let title = "Custom Instructions".to_string();
let placeholder =
"Add anything else the reviewer should know (optional)".to_string();
let context_label = format!("Review against base branch '{branch}'");
let tx3 = tx2.clone();
let on_submit = move |custom: String| {
let trimmed = custom.trim().to_string();
let full_prompt = if trimmed.is_empty() {
prompt.clone()
} else {
format!("{prompt}\n\n{trimmed}")
};
let user_facing_hint = if trimmed.is_empty() {
context.clone()
} else {
format!("{context} ({trimmed})")
};
tx3.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt: full_prompt,
user_facing_hint,
},
}));
};
let view = CustomPromptView::new(
title,
placeholder,
Some(context_label),
true,
Box::new(on_submit),
);
bottom_pane.show_view(Box::new(view));
}),
);
bottom_pane.show_view(Box::new(view));
}
pub(crate) fn open_commit_picker(
cwd: PathBuf,
bottom_pane: &mut BottomPane,
app_event_tx: &AppEventSender,
) {
let view = CommitPickerView::new(
cwd,
app_event_tx.clone(),
Box::new(move |tx, bottom_pane, selection| {
let full_sha = selection.full_sha;
let summary_label = if selection.summary.is_empty() {
selection.short_sha
} else {
format!("{} {}", selection.short_sha, selection.summary)
};
let context = format!("commit {summary_label}");
let prompt = format!(
"Review commit {summary_label} and provide prioritized, actionable findings. Start by running `git show {full_sha}`."
);
let title = "Custom Instructions".to_string();
let placeholder =
"Add anything else the reviewer should know (optional)".to_string();
let context_label = format!("Review commit {summary_label}");
let tx2 = tx.clone();
let on_submit = move |custom: String| {
let trimmed = custom.trim().to_string();
let full_prompt = if trimmed.is_empty() {
prompt.clone()
} else {
format!("{prompt}\n\n{trimmed}")
};
let user_facing_hint = if trimmed.is_empty() {
context.clone()
} else {
format!("{context} ({trimmed})")
};
tx2.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt: full_prompt,
user_facing_hint,
},
}));
};
let view = CustomPromptView::new(
title,
placeholder,
Some(context_label),
true,
Box::new(on_submit),
);
bottom_pane.show_view(Box::new(view));
}),
);
bottom_pane.show_view(Box::new(view));
}
pub(crate) fn open_custom_prompt(
bottom_pane: &mut BottomPane,
app_event_tx: &AppEventSender,
title: String,
) {
let tx = app_event_tx.clone();
let view = CustomPromptView::new(
title,
"Type instructions and press Enter".to_string(),
None,
false,
Box::new(move |prompt: String| {
let trimmed = prompt.trim().to_string();
if trimmed.is_empty() {
return;
}
tx.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt: trimmed.clone(),
user_facing_hint: trimmed,
},
}));
}),
);
bottom_pane.show_view(Box::new(view));
}
}

View File

@@ -1,3 +1,4 @@
use super::review::ReviewState;
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -19,10 +20,17 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::InputMessageKind;
use codex_core::protocol::Op;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::ReviewCodeLocation;
use codex_core::protocol::ReviewFinding;
use codex_core::protocol::ReviewLineRange;
use codex_core::protocol::ReviewOutputEvent;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TaskStartedEvent;
@@ -184,6 +192,187 @@ fn resumed_initial_messages_render_history() {
);
}
/// Entering review mode uses the hint provided by the review request.
#[test]
fn entered_review_mode_uses_request_hint() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
prompt: "Review the latest changes".to_string(),
user_facing_hint: "feature branch".to_string(),
}),
});
let cells = drain_insert_history(&mut rx);
let banner = lines_to_single_string(cells.last().expect("review banner"));
assert_eq!(banner, ">> Code review started: feature branch <<\n");
assert!(chat.review_state.is_review_mode());
}
/// Entering review mode renders the current changes banner when requested.
#[test]
fn entered_review_mode_defaults_to_current_changes_banner() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
prompt: "Review the current changes".to_string(),
user_facing_hint: "current changes".to_string(),
}),
});
let cells = drain_insert_history(&mut rx);
let banner = lines_to_single_string(cells.last().expect("review banner"));
assert_eq!(banner, ">> Code review started: current changes <<\n");
assert!(chat.review_state.is_review_mode());
}
#[test]
fn cancel_commit_picker_reveals_review_selection() {
let (mut chat, _rx, _ops) = make_chatwidget_manual();
chat.open_review_popup();
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
chat.bottom_pane.has_active_view(),
"review selection should remain after closing commit picker"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
chat.bottom_pane.is_normal_backtrack_mode(),
"second escape should return to composer"
);
}
#[test]
fn cancel_branch_picker_reveals_review_selection() {
let (mut chat, _rx, _ops) = make_chatwidget_manual();
chat.open_review_popup();
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
chat.bottom_pane.has_active_view(),
"review selection should remain after closing branch picker"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
chat.bottom_pane.is_normal_backtrack_mode(),
"second escape should return to composer"
);
}
#[test]
fn cancel_custom_prompt_reveals_review_selection() {
let (mut chat, _rx, _ops) = make_chatwidget_manual();
chat.open_review_popup();
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
chat.bottom_pane.has_active_view(),
"review selection should remain after closing custom prompt"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
chat.bottom_pane.is_normal_backtrack_mode(),
"second escape should return to composer"
);
}
#[test]
fn current_changes_action_sends_review_op() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
chat.open_review_popup();
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Custom prompt opens as last step; submit empty to send base prompt.
chat.bottom_pane
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = rx.try_recv().expect("expected review app event");
match event {
AppEvent::CodexOp(Op::Review { review_request }) => {
assert_eq!(
review_request.user_facing_hint, "current changes",
"review preset should set current-changes hint"
);
assert!(
review_request
.prompt
.contains("Review the current code changes"),
"review prompt should describe current changes"
);
}
other => panic!("expected review app event, got {other:?}"),
}
}
/// Completing review with findings shows the selection popup and finishes with
/// the closing banner while clearing review mode state.
#[test]
fn exited_review_mode_with_findings_opens_selection_view() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
let review = ReviewOutputEvent {
findings: vec![ReviewFinding {
title: "[P1] Fix bug".to_string(),
body: "Something went wrong".to_string(),
confidence_score: 0.9,
priority: 1,
code_location: ReviewCodeLocation {
absolute_file_path: PathBuf::from("src/lib.rs"),
line_range: ReviewLineRange { start: 10, end: 12 },
},
}],
overall_correctness: "needs work".to_string(),
overall_explanation: "Investigate the failure".to_string(),
overall_confidence_score: 0.5,
};
chat.handle_codex_event(Event {
id: "review-end".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: Some(review),
}),
});
assert!(
!chat.bottom_pane.is_normal_backtrack_mode(),
"review selection view should suspend normal composer"
);
let cells = drain_insert_history(&mut rx);
let banner = lines_to_single_string(cells.last().expect("finished banner"));
assert_eq!(banner, "<< Code review finished >>\n");
assert!(!chat.review_state.is_review_mode());
}
#[cfg_attr(
target_os = "macos",
ignore = "system configuration APIs are blocked under macOS seatbelt"
@@ -252,6 +441,7 @@ fn make_chatwidget_manual() -> (
queued_user_messages: VecDeque::new(),
suppress_session_configured_redraw: false,
pending_notification: None,
review_state: ReviewState::new(),
};
(widget, rx, op_rx)
}

View File

@@ -0,0 +1,137 @@
use std::io;
use std::path::Path;
use tokio::process::Command;
/// Parsed summary of `git diff --shortstat` output.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct DiffShortStat {
pub files_changed: u32,
pub insertions: u32,
pub deletions: u32,
}
/// Run `git diff --shortstat` for the current workspace directory and parse the
/// resulting summary if available.
pub(crate) async fn get_diff_shortstat(cwd: &Path) -> io::Result<Option<DiffShortStat>> {
let output = match Command::new("git")
.current_dir(cwd)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.args(["diff", "HEAD", "--shortstat"])
.output()
.await
{
Ok(output) => output,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
if !output.status.success() && output.status.code() != Some(1) {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_shortstat(stdout.trim()))
}
pub(crate) async fn get_diff_shortstat_against(
cwd: &Path,
base: &str,
) -> io::Result<Option<DiffShortStat>> {
if base.trim().is_empty() {
return Ok(None);
}
let output = match Command::new("git")
.current_dir(cwd)
.args(["diff", base, "--shortstat"])
.output()
.await
{
Ok(output) => output,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
if !(output.status.success() || output.status.code() == Some(1)) {
return Ok(None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_shortstat(stdout.trim()))
}
fn parse_shortstat(stdout: &str) -> Option<DiffShortStat> {
if stdout.is_empty() {
// Zero-diff should still show shortstat with zeros.
return Some(DiffShortStat::default());
}
let mut files_changed: Option<u32> = None;
let mut insertions: Option<u32> = None;
let mut deletions: Option<u32> = None;
for part in stdout.split(',') {
let trimmed = part.trim();
if trimmed.is_empty() {
continue;
}
let value = match trimmed.split_whitespace().next() {
Some(num) => match num.parse::<u32>() {
Ok(parsed) => parsed,
Err(_) => continue,
},
None => continue,
};
if trimmed.contains("file") {
files_changed = Some(value);
} else if trimmed.contains("insertion") {
insertions = Some(value);
} else if trimmed.contains("deletion") {
deletions = Some(value);
}
}
Some(DiffShortStat {
files_changed: files_changed.unwrap_or_default(),
insertions: insertions.unwrap_or_default(),
deletions: deletions.unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_shortstat_recognizes_full_output() {
let summary = " 2 files changed, 7 insertions(+), 3 deletions(-)";
let parsed = parse_shortstat(summary).expect("should parse stats");
assert_eq!(parsed.files_changed, 2);
assert_eq!(parsed.insertions, 7);
assert_eq!(parsed.deletions, 3);
}
#[test]
fn parse_shortstat_handles_missing_fields() {
let summary = " 1 file changed";
let parsed = parse_shortstat(summary).expect("should parse stats");
assert_eq!(parsed.files_changed, 1);
assert_eq!(parsed.insertions, 0);
assert_eq!(parsed.deletions, 0);
}
#[test]
fn parse_shortstat_returns_zeros_for_empty_stdout() {
let parsed = parse_shortstat("").expect("should parse stats");
assert_eq!(parsed.files_changed, 0);
assert_eq!(parsed.insertions, 0);
assert_eq!(parsed.deletions, 0);
}
}

View File

@@ -229,6 +229,28 @@ impl HistoryCell for TranscriptOnlyHistoryCell {
}
}
/// Insert a transcript line describing the current review status.
#[derive(Debug)]
pub(crate) struct ReviewStatusHistoryCell {
message: String,
}
impl HistoryCell for ReviewStatusHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![Line::from(self.message.clone().cyan())]
}
fn is_stream_continuation(&self) -> bool {
// Treat status lines as part of the same history block to avoid an extra
// blank separator when following other content in the same turn.
true
}
}
pub(crate) fn new_review_status_line(message: String) -> ReviewStatusHistoryCell {
ReviewStatusHistoryCell { message }
}
#[derive(Debug)]
pub(crate) struct PatchHistoryCell {
event_type: PatchEventType,
@@ -691,6 +713,11 @@ pub(crate) fn new_session_info(
"/model".into(),
" - choose what model and reasoning effort to use".dim(),
]),
Line::from(vec![
" ".into(),
"/review".into(),
" - review current changes and find issues".dim(),
]),
];
CompositeHistoryCell {

View File

@@ -44,6 +44,7 @@ mod exec_command;
mod file_search;
mod frames;
mod get_git_diff;
mod git_shortstat;
mod history_cell;
pub mod insert_history;
mod key_hint;

View File

@@ -339,7 +339,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
let q = if state.query.is_empty() {
"Type to search".dim().to_string()
} else {
format!("Search: {}", state.query)
state.query.clone()
};
frame.render_widget_ref(Line::from(q), search);

View File

@@ -14,6 +14,7 @@ pub enum SlashCommand {
// more frequently used commands should be listed first.
Model,
Approvals,
Review,
New,
Init,
Compact,
@@ -34,6 +35,7 @@ impl SlashCommand {
SlashCommand::New => "start a new chat during a conversation",
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
@@ -61,6 +63,7 @@ impl SlashCommand {
| SlashCommand::Compact
| SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::Review
| SlashCommand::Logout => false,
SlashCommand::Diff
| SlashCommand::Mention