feat(tui/review): add picker dialog and suppress raw review stream

This commit is contained in:
James Peter
2025-09-23 12:11:20 +10:00
parent 87da16e3c6
commit 97ede60fb8
6 changed files with 436 additions and 40 deletions
+65 -1
View File
@@ -1341,7 +1341,11 @@ impl App<'_> {
match command {
SlashCommand::Review => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_review_command(command_args);
if command_args.is_empty() {
widget.open_review_dialog();
} else {
widget.handle_review_command(command_args);
}
}
}
SlashCommand::Branch => {
@@ -1690,6 +1694,66 @@ impl App<'_> {
widget.submit_text_message_with_preface(visible, preface);
}
}
AppEvent::RunReviewCommand(args) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_review_command(args);
}
}
AppEvent::RunReviewWithScope {
prompt,
hint,
preparation_label,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.start_review_with_scope(prompt, hint, preparation_label);
}
}
AppEvent::OpenReviewCustomPrompt => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_custom_prompt();
}
}
AppEvent::StartReviewCommitPicker => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_commit_loading();
}
let cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let commits = codex_core::git_info::recent_commits(&cwd, 60).await;
tx.send(AppEvent::PresentReviewCommitPicker { commits });
});
}
AppEvent::PresentReviewCommitPicker { commits } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_review_commit_picker(commits);
}
}
AppEvent::StartReviewBranchPicker => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_review_branch_loading();
}
let cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let (branches, current_branch) = tokio::join!(
codex_core::git_info::local_git_branches(&cwd),
codex_core::git_info::current_branch_name(&cwd),
);
tx.send(AppEvent::PresentReviewBranchPicker {
current_branch,
branches,
});
});
}
AppEvent::PresentReviewBranchPicker {
current_branch,
branches,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.present_review_branch_picker(current_branch, branches);
}
}
AppEvent::DiffResult(text) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(text);
+26
View File
@@ -3,6 +3,7 @@ use codex_core::config_types::TextVerbosity;
use codex_core::config_types::ThemeName;
use codex_core::protocol::Event;
use codex_core::protocol::ApprovedCommandMatchKind;
use codex_core::git_info::CommitLogEntry;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use crossterm::event::MouseEvent;
@@ -135,6 +136,31 @@ pub(crate) enum AppEvent {
/// Submit a message with hidden preface instructions
SubmitTextWithPreface { visible: String, preface: String },
/// Run a review with an explicit prompt/hint pair (used by TUI selections)
RunReviewWithScope {
prompt: String,
hint: String,
preparation_label: Option<String>,
},
/// Run the review command with the given argument string (mirrors `/review <args>`)
RunReviewCommand(String),
/// Open a bottom-pane form that lets the user select a commit to review.
StartReviewCommitPicker,
/// Populate the commit picker with retrieved commit entries.
PresentReviewCommitPicker { commits: Vec<CommitLogEntry> },
/// Open a bottom-pane form that lets the user select a base branch to diff against.
StartReviewBranchPicker,
/// Populate the branch picker with branch metadata once loaded asynchronously.
PresentReviewBranchPicker {
current_branch: Option<String>,
branches: Vec<String>,
},
/// Show the multi-line prompt input to collect custom review instructions.
OpenReviewCustomPrompt,
/// Update the theme (with history event)
UpdateTheme(ThemeName),
/// Add or update a subagent command in memory (UI already persisted to config.toml)
@@ -62,13 +62,13 @@ impl CustomPromptView {
}
}
impl BottomPaneView for CustomPromptView {
fn handle_key_event(&mut self, _pane: &mut super::BottomPane, key_event: KeyEvent) {
impl BottomPaneView<'_> for CustomPromptView {
fn handle_key_event(&mut self, pane: &mut super::BottomPane<'_>, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c(_pane);
self.on_ctrl_c(pane);
}
KeyEvent {
code: KeyCode::Enter,
@@ -93,7 +93,7 @@ impl BottomPaneView for CustomPromptView {
}
}
fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane) -> CancellationEvent {
fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane<'_>) -> CancellationEvent {
self.complete = true;
if let Some(cb) = &self.on_escape {
cb(&self.app_event_tx);
@@ -212,32 +212,12 @@ impl BottomPaneView for CustomPromptView {
}
}
fn handle_paste(&mut self, _pane: &mut super::BottomPane, pasted: String) -> bool {
fn handle_paste(&mut self, pasted: String) -> super::bottom_pane_view::ConditionalUpdate {
if pasted.is_empty() {
return false;
return super::bottom_pane_view::ConditionalUpdate::NoRedraw;
}
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)
super::bottom_pane_view::ConditionalUpdate::NeedsRedraw
}
}
+10
View File
@@ -19,6 +19,7 @@ mod chat_composer;
mod chat_composer_history;
pub mod chrome_selection_view;
mod diff_popup;
mod custom_prompt_view;
mod command_popup;
mod file_search_popup;
mod paste_burst;
@@ -30,6 +31,8 @@ mod model_selection_view;
mod scroll_state;
mod selection_popup_common;
pub mod list_selection_view;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use custom_prompt_view::CustomPromptView;
pub mod resume_selection_view;
pub mod agents_settings_view;
mod github_settings_view;
@@ -523,6 +526,13 @@ impl BottomPane<'_> {
self.request_redraw()
}
/// Show a multi-line prompt input view (used for custom review instructions)
pub fn show_custom_prompt(&mut self, view: CustomPromptView) {
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
}
/// Show a generic list selection popup with items and actions.
pub fn show_list_selection(
&mut self,
+324 -10
View File
@@ -20,6 +20,7 @@ use codex_common::model_presets::ModelPreset;
use codex_common::model_presets::builtin_model_presets;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::git_info::CommitLogEntry;
use codex_core::config_types::AgentConfig;
use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
@@ -125,6 +126,8 @@ use crate::app_event::{
TerminalRunController,
};
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CustomPromptView;
use crate::bottom_pane::list_selection_view::{ListSelectionView, SelectionItem};
use crate::bottom_pane::validation_settings_view;
use crate::bottom_pane::validation_settings_view::ToolStatus;
use crate::bottom_pane::BottomPane;
@@ -4079,6 +4082,16 @@ impl ChatWidget<'_> {
// Close out any running tool/exec indicators before inserting final answer.
self.finalize_all_running_due_to_answer();
if self.is_review_flow_active() {
tracing::debug!("Suppressing review final message id={} to avoid duplicate output", id);
self.stream_state
.closed_answer_ids
.insert(StreamId(id.clone()));
self.active_task_ids.remove(&id);
self.maybe_hide_spinner();
return;
}
// Route final message through streaming controller so AppEvent::InsertFinalAnswer
// is the single source of truth for assistant content.
let sink = AppEventHistorySink(self.app_event_tx.clone());
@@ -4145,6 +4158,10 @@ impl ChatWidget<'_> {
tracing::debug!("Ignoring Answer delta after interrupt");
return;
}
if self.is_review_flow_active() {
tracing::debug!("Suppressing review streaming delta id={}", id);
return;
}
// Ignore late deltas for ids that have already finalized in this turn
if self
.stream_state
@@ -11632,6 +11649,286 @@ fn update_rate_limit_resets(
}
impl ChatWidget<'_> {
pub(crate) fn open_review_dialog(&mut self) {
if self.is_task_running() {
self.history_push(crate::history_cell::new_error_event(
"`/review` — complete or cancel the current task before starting a new review.".to_string(),
));
self.request_redraw();
return;
}
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Review current workspace changes".to_string(),
description: Some("Include staged, unstaged, and untracked files".to_string()),
is_current: false,
actions: vec![Box::new(|tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::RunReviewCommand(String::new()));
})],
});
items.push(SelectionItem {
name: "Review a specific commit".to_string(),
description: Some("Pick from recent commits".to_string()),
is_current: false,
actions: vec![Box::new(|tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::StartReviewCommitPicker);
})],
});
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: Some("Diff current branch against another".to_string()),
is_current: false,
actions: vec![Box::new(|tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::StartReviewBranchPicker);
})],
});
items.push(SelectionItem {
name: "Custom review instructions".to_string(),
description: Some("Describe exactly what to audit".to_string()),
is_current: false,
actions: vec![Box::new(|tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::OpenReviewCustomPrompt);
})],
});
let view: ListSelectionView = ListSelectionView::new(
" Review options ".to_string(),
Some("Choose what scope to review".to_string()),
Some("Enter select · Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
6,
);
self.bottom_pane.show_list_selection(
"Review options".to_string(),
None,
None,
view,
);
}
pub(crate) fn show_review_custom_prompt(&mut self) {
let submit_tx = self.app_event_tx.clone();
let on_submit: Box<dyn Fn(String) + Send + Sync> = Box::new(move |text: String| {
submit_tx.send(crate::app_event::AppEvent::RunReviewCommand(text));
});
let view = CustomPromptView::new(
"Custom review instructions".to_string(),
"Describe the files or changes you want reviewed".to_string(),
Some("Press Enter to submit · Esc cancel".to_string()),
self.app_event_tx.clone(),
None,
on_submit,
);
self.bottom_pane.show_custom_prompt(view);
}
pub(crate) fn show_review_commit_loading(&mut self) {
let loading_item = SelectionItem {
name: "Loading recent commits…".to_string(),
description: None,
is_current: true,
actions: Vec::new(),
};
let view = ListSelectionView::new(
" Select a commit ".to_string(),
Some("Fetching recent commits from git".to_string()),
Some("Esc cancel".to_string()),
vec![loading_item],
self.app_event_tx.clone(),
6,
);
self.bottom_pane.show_list_selection(
"Select a commit".to_string(),
None,
None,
view,
);
}
pub(crate) fn present_review_commit_picker(&mut self, commits: Vec<CommitLogEntry>) {
if commits.is_empty() {
self.bottom_pane
.flash_footer_notice("No recent commits found for review".to_string());
self.request_redraw();
return;
}
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
for entry in commits {
let subject = entry.subject.trim().to_string();
let sha = entry.sha.trim().to_string();
if sha.is_empty() {
continue;
}
let short_sha: String = sha.chars().take(7).collect();
let title = if subject.is_empty() {
short_sha.clone()
} else {
format!("{short_sha} — {subject}")
};
let prompt = if subject.is_empty() {
format!(
"Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings."
)
} else {
format!(
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
)
};
let hint = format!("commit {short_sha}");
let preparation = format!("Preparing code review for commit {short_sha}");
let prompt_closure = prompt.clone();
let hint_closure = hint.clone();
let prep_closure = preparation.clone();
items.push(SelectionItem {
name: title,
description: None,
is_current: false,
actions: vec![Box::new(move |tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::RunReviewWithScope {
prompt: prompt_closure.clone(),
hint: hint_closure.clone(),
preparation_label: Some(prep_closure.clone()),
});
})],
});
}
if items.is_empty() {
self.bottom_pane
.flash_footer_notice("No recent commits found for review".to_string());
self.request_redraw();
return;
}
let view = ListSelectionView::new(
" Select a commit ".to_string(),
Some("Choose a commit to review".to_string()),
Some("Enter select · Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
10,
);
self.bottom_pane.show_list_selection(
"Select a commit to review".to_string(),
None,
None,
view,
);
}
pub(crate) fn show_review_branch_loading(&mut self) {
let loading_item = SelectionItem {
name: "Loading local branches…".to_string(),
description: None,
is_current: true,
actions: Vec::new(),
};
let view = ListSelectionView::new(
" Select a base branch ".to_string(),
Some("Fetching local branches".to_string()),
Some("Esc cancel".to_string()),
vec![loading_item],
self.app_event_tx.clone(),
6,
);
self.bottom_pane.show_list_selection(
"Select a base branch".to_string(),
None,
None,
view,
);
}
pub(crate) fn present_review_branch_picker(
&mut self,
current_branch: Option<String>,
branches: Vec<String>,
) {
let current_trimmed = current_branch.as_ref().map(|s| s.trim().to_string());
let mut items: Vec<SelectionItem> = Vec::new();
for branch in branches {
let branch_trimmed = branch.trim();
if branch_trimmed.is_empty() {
continue;
}
if current_trimmed
.as_ref()
.is_some_and(|current| current == branch_trimmed)
{
continue;
}
let title = if let Some(current) = current_trimmed.as_ref() {
format!("{current} → {branch_trimmed}")
} else {
format!("Compare against {branch_trimmed}")
};
let prompt = if let Some(current) = current_trimmed.as_ref() {
format!(
"Review the code changes between the current branch '{current}' and '{branch_trimmed}'. Identify bugs, regressions, risky patterns, and missing tests before merging."
)
} else {
format!(
"Review the code changes that would merge into '{branch_trimmed}'. Identify bugs, regressions, risky patterns, and missing tests before merge."
)
};
let hint = format!("against {branch_trimmed}");
let preparation = format!("Preparing code review against {branch_trimmed}");
let prompt_closure = prompt.clone();
let hint_closure = hint.clone();
let prep_closure = preparation.clone();
items.push(SelectionItem {
name: title,
description: None,
is_current: false,
actions: vec![Box::new(move |tx: &crate::app_event_sender::AppEventSender| {
tx.send(crate::app_event::AppEvent::RunReviewWithScope {
prompt: prompt_closure.clone(),
hint: hint_closure.clone(),
preparation_label: Some(prep_closure.clone()),
});
})],
});
}
if items.is_empty() {
self.bottom_pane
.flash_footer_notice("No alternative branches found for review".to_string());
self.request_redraw();
return;
}
let subtitle = current_trimmed
.as_ref()
.map(|current| format!("Current branch: {current}"));
let view = ListSelectionView::new(
" Select a base branch ".to_string(),
subtitle,
Some("Enter select · Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
10,
);
self.bottom_pane.show_list_selection(
"Compare against a branch".to_string(),
None,
None,
view,
);
}
/// Handle `/review [focus]` command by starting a dedicated review session.
pub(crate) fn handle_review_command(&mut self, args: String) {
if self.is_task_running() {
@@ -11643,24 +11940,37 @@ impl ChatWidget<'_> {
}
let trimmed = args.trim();
let (prompt, hint) = if trimmed.is_empty() {
(
if trimmed.is_empty() {
self.start_review_with_scope(
"Review the current workspace changes and highlight bugs, regressions, risky patterns, and missing tests before merge.".to_string(),
"current workspace changes".to_string(),
)
Some("Preparing code review request...".to_string()),
);
} else {
let value = trimmed.to_string();
(value.clone(), value)
};
let preparation = format!("Preparing code review for {value}");
self.start_review_with_scope(value.clone(), value, Some(preparation));
}
}
pub(crate) fn start_review_with_scope(
&mut self,
prompt: String,
hint: String,
preparation_label: Option<String>,
) {
self.active_review_hint = None;
self.active_review_prompt = None;
let preparation_notice = if trimmed.is_empty() {
"Preparing code review request...".to_string()
} else {
format!("Preparing code review for {hint}")
};
let trimmed_hint = hint.trim();
let preparation_notice = preparation_label.unwrap_or_else(|| {
if trimmed_hint.is_empty() {
"Preparing code review request...".to_string()
} else {
format!("Preparing code review for {trimmed_hint}")
}
});
self.insert_background_event_early(preparation_notice);
self.request_redraw();
@@ -11672,6 +11982,10 @@ impl ChatWidget<'_> {
self.submit_op(Op::Review { review_request });
}
fn is_review_flow_active(&self) -> bool {
self.active_review_hint.is_some() || self.active_review_prompt.is_some()
}
fn build_review_summary_cell(
&self,
hint: Option<&str>,
+4 -2
View File
@@ -29,8 +29,10 @@ Notes
from the repository root (not inside another branch worktree).
- `/merge`: merge the current worktree branch back into the default branch and
remove the worktree. Run this from inside the worktree created by `/branch`.
- `/review [focus]`: start a code review using the configured review model. Add
optional guidance to steer what the reviewer should focus on.
- `/review [focus]`: without arguments, opens a review picker so you can audit
the workspace, a specific commit, compare against another branch, or enter
custom instructions. With a focus argument, skips the picker and uses your
text directly.
- `/cmd <name>`: run a project command defined for the current workspace.
## UX & Display