feat(review): add scoped picker and metadata flow

This commit is contained in:
James Peter
2025-09-24 05:31:41 +10:00
10 changed files with 614 additions and 106 deletions
+54 -3
View File
@@ -880,6 +880,7 @@ pub(crate) struct Session {
pro_observer_last_trigger_at: Mutex<Option<Instant>>,
pro_observer_activity_since_last: AtomicU64,
self_handle: Weak<Session>,
active_review: Mutex<Option<ReviewRequest>>,
}
struct HookGuard<'a> {
@@ -953,6 +954,15 @@ impl Session {
self.client_tools.as_ref()
}
fn set_active_review(&self, review_request: ReviewRequest) {
let mut guard = self.active_review.lock().unwrap();
*guard = Some(review_request);
}
fn take_active_review(&self) -> Option<ReviewRequest> {
self.active_review.lock().unwrap().take()
}
fn upgrade_self(&self) -> Option<Arc<Self>> {
self.self_handle.upgrade()
}
@@ -3053,6 +3063,7 @@ async fn submission_loop(
pro_observer_last_trigger_at: Mutex::new(None),
pro_observer_activity_since_last: AtomicU64::new(0),
self_handle: Weak::new(),
active_review: Mutex::new(None),
});
let weak_handle = Arc::downgrade(&new_session);
if let Some(inner) = Arc::get_mut(&mut new_session) {
@@ -3576,6 +3587,7 @@ async fn spawn_review_thread(
}];
let task = AgentTask::review(Arc::clone(&sess), Arc::clone(&review_turn_context), sub_id.clone(), review_input);
sess.set_active_review(review_request.clone());
sess.set_task(task);
let event = sess.make_event(
@@ -3593,6 +3605,8 @@ async fn exit_review_mode(
let event = session.make_event(&task_sub_id, EventMsg::ExitedReviewMode(review_output.clone()));
session.send_event(event).await;
let active_request = session.take_active_review();
let developer_text = match review_output.clone() {
Some(output) => {
let mut sections: Vec<String> = Vec::new();
@@ -3637,8 +3651,43 @@ async fn exit_review_mode(
role: "user".to_string(),
content: vec![ContentItem::InputText { text: developer_text }],
};
let status = if review_output.is_some() {
"complete"
} else {
"no_output"
};
let mut metadata_payload = json!({
"type": "code_review_metadata",
"status": status,
});
if let Some(request) = active_request {
metadata_payload["prompt"] = json!(request.prompt);
metadata_payload["user_facing_hint"] = json!(request.user_facing_hint);
if let Some(meta) = request.metadata {
metadata_payload["review_context"] = serde_json::to_value(meta).unwrap_or(json!({}));
}
}
if let Some(ref output) = review_output {
metadata_payload["overall_correctness"] = json!(output.overall_correctness);
metadata_payload["overall_confidence_score"] = json!(output.overall_confidence_score);
metadata_payload["finding_count"] = json!(output.findings.len());
metadata_payload["findings"] = serde_json::to_value(&output.findings).unwrap_or(json!([]));
}
let metadata_text = serde_json::to_string_pretty(&metadata_payload)
.unwrap_or_else(|_| metadata_payload.to_string());
let metadata_message = ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText { text: metadata_text }],
};
session
.record_conversation_items(&[developer_message])
.record_conversation_items(&[developer_message, metadata_message])
.await;
}
@@ -3926,8 +3975,10 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
// If there are responses, add them to pending input for the next iteration
if !responses.is_empty() {
for response in &responses {
sess.add_pending_input(response.clone());
if !is_review_mode {
for response in &responses {
sess.add_pending_input(response.clone());
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub use codex_protocol::protocol::ReviewCodeLocation;
pub use codex_protocol::protocol::ReviewFinding;
pub use codex_protocol::protocol::ReviewLineRange;
pub use codex_protocol::protocol::ReviewOutputEvent;
pub use codex_protocol::protocol::ReviewRequest;
pub use codex_protocol::protocol::{ReviewContextMetadata, ReviewRequest};
pub use codex_protocol::protocol::GitInfo;
pub use codex_protocol::protocol::RolloutItem;
pub use codex_protocol::protocol::RolloutLine;
+6
View File
@@ -83,6 +83,7 @@ async fn review_op_emits_lifecycle_and_review_output() {
review_request: ReviewRequest {
prompt: "Please review my changes".to_string(),
user_facing_hint: "my changes".to_string(),
metadata: None,
},
})
.await
@@ -185,6 +186,7 @@ async fn review_op_with_plain_text_emits_review_fallback() {
review_request: ReviewRequest {
prompt: "Plain text review".to_string(),
user_facing_hint: "plain text review".to_string(),
metadata: None,
},
})
.await
@@ -254,6 +256,7 @@ async fn review_does_not_emit_agent_message_on_structured_output() {
review_request: ReviewRequest {
prompt: "check structured".to_string(),
user_facing_hint: "check structured".to_string(),
metadata: None,
},
})
.await
@@ -308,6 +311,7 @@ async fn review_uses_custom_review_model_from_config() {
review_request: ReviewRequest {
prompt: "use custom model".to_string(),
user_facing_hint: "use custom model".to_string(),
metadata: None,
},
})
.await
@@ -424,6 +428,7 @@ async fn review_input_isolated_from_parent_history() {
review_request: ReviewRequest {
prompt: review_prompt.clone(),
user_facing_hint: review_prompt.clone(),
metadata: None,
},
})
.await
@@ -537,6 +542,7 @@ async fn review_history_does_not_leak_into_parent_session() {
review_request: ReviewRequest {
prompt: "Start a review".to_string(),
user_facing_hint: "Start a review".to_string(),
metadata: None,
},
})
.await
+19
View File
@@ -1051,6 +1051,25 @@ pub struct GitInfo {
pub struct ReviewRequest {
pub prompt: String,
pub user_facing_hint: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub metadata: Option<ReviewContextMetadata>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, Default)]
pub struct ReviewContextMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub base_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub current_branch: Option<String>,
}
/// Structured review result produced by a child review session.
+66 -1
View File
@@ -1487,7 +1487,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 => {
@@ -1841,6 +1845,67 @@ 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,
metadata,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.start_review_with_scope(prompt, hint, preparation_label, metadata);
}
}
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);
+28
View File
@@ -4,6 +4,8 @@ use codex_core::config_types::ThemeName;
use codex_core::protocol::Event;
use codex_core::protocol::ValidationGroup;
use codex_core::protocol::ApprovedCommandMatchKind;
use codex_core::git_info::CommitLogEntry;
use codex_core::protocol::ReviewContextMetadata;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use crossterm::event::MouseEvent;
@@ -144,6 +146,32 @@ 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>,
metadata: Option<ReviewContextMetadata>,
},
/// 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;
@@ -555,6 +558,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,
+419 -72
View File
@@ -21,6 +21,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;
@@ -83,7 +84,7 @@ use codex_core::protocol::SessionConfiguredEvent;
// MCP tool call handlers moved into chatwidget::tools
use codex_core::protocol::Op;
use codex_core::protocol::ReviewOutputEvent;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::{ReviewContextMetadata, ReviewRequest};
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::TaskCompleteEvent;
@@ -126,6 +127,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::{GroupStatus, ToolRow};
use crate::bottom_pane::BottomPane;
@@ -648,12 +651,13 @@ impl AsRef<str> for StreamId {
// ---- System notice ordering helpers ----
#[derive(Copy, Clone)]
enum SystemPlacement {
/// Place before the next user prompt (used for pre-turn confirmations).
BeforePrompt,
/// Place immediately before the next provider/tool output for the active request.
BeforeNextOutput,
/// Append to the end of the current request window.
Tail,
/// Place near the top of the current request (before most provider output)
EarlyInCurrent,
/// Place at the end of the current request window (after provider output)
EndOfCurrent,
/// Place before the first user prompt of the very first request
/// (used for pre-turn UI confirmations like theme/spinner changes)
PrePromptInCurrent,
}
impl ChatWidget<'_> {
@@ -766,6 +770,8 @@ impl ChatWidget<'_> {
// Derive a stable request bucket for system notices when OrderMeta is absent.
// Default to the current provider request if known; else use a sticky
// pre-turn synthetic req=1 to group UI confirmations before the first turn.
// If a user prompt for the next turn is already queued, attach new
// system notices to the upcoming request to avoid retroactive inserts.
let mut req = if self.last_seen_request_index > 0 {
self.last_seen_request_index
} else {
@@ -774,24 +780,24 @@ impl ChatWidget<'_> {
}
self.synthetic_system_req.unwrap_or(1)
};
if self.pending_user_prompts_for_next_turn > 0 {
if order.is_none() && self.pending_user_prompts_for_next_turn > 0 {
req = req.saturating_add(1);
}
self.internal_seq = self.internal_seq.saturating_add(1);
let out = match placement {
SystemPlacement::BeforePrompt => i32::MIN,
SystemPlacement::BeforeNextOutput => {
if self.pending_user_prompts_for_next_turn > 0 {
i32::MIN
} else {
i32::MIN + 2
}
}
SystemPlacement::Tail => i32::MAX,
let mut out = match placement {
SystemPlacement::EarlyInCurrent => i32::MIN + 2,
SystemPlacement::EndOfCurrent => i32::MAX,
SystemPlacement::PrePromptInCurrent => i32::MIN,
};
if order.is_none()
&& self.pending_user_prompts_for_next_turn > 0
&& matches!(placement, SystemPlacement::EarlyInCurrent)
{
out = i32::MIN;
}
OrderKey {
req,
out,
@@ -823,13 +829,13 @@ impl ChatWidget<'_> {
}
/// Decide where to place a UI confirmation right now.
/// If we're truly pre‑turn (no provider traffic yet, and no queued prompt),
/// If we're truly pre-turn (no provider traffic yet, and no queued prompt),
/// place before the first user prompt. Otherwise, append to end of current.
fn ui_placement_for_now(&self) -> SystemPlacement {
if self.last_seen_request_index == 0 && self.pending_user_prompts_for_next_turn == 0 {
SystemPlacement::BeforePrompt
SystemPlacement::PrePromptInCurrent
} else {
SystemPlacement::Tail
SystemPlacement::EndOfCurrent
}
}
pub(crate) fn enable_perf(&mut self, enable: bool) {
@@ -3179,6 +3185,14 @@ impl ChatWidget<'_> {
let key = self.next_internal_key();
let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "epilogue");
}
/// Insert a background event near the top of the current request so it appears
/// before imminent provider output (e.g. Exec begin).
pub(crate) fn insert_background_event_early(&mut self, message: String) {
self.insert_background_event_with_placement(
message,
BackgroundPlacement::BeforeNextOutput,
);
}
/// Insert a background event using the specified placement semantics.
pub(crate) fn insert_background_event_with_placement(
&mut self,
@@ -3186,8 +3200,14 @@ impl ChatWidget<'_> {
placement: BackgroundPlacement,
) {
let system_placement = match placement {
BackgroundPlacement::Tail => SystemPlacement::Tail,
BackgroundPlacement::BeforeNextOutput => SystemPlacement::BeforeNextOutput,
BackgroundPlacement::Tail => SystemPlacement::EndOfCurrent,
BackgroundPlacement::BeforeNextOutput => {
if self.pending_user_prompts_for_next_turn > 0 {
SystemPlacement::EarlyInCurrent
} else {
SystemPlacement::PrePromptInCurrent
}
}
};
self.push_system_cell(
history_cell::new_background_event(message),
@@ -5230,9 +5250,9 @@ impl ChatWidget<'_> {
// provider output; else append to the tail. Use the event.id for
// in-place replacement.
let placement = if event.order.as_ref().is_some() {
SystemPlacement::BeforeNextOutput
SystemPlacement::EarlyInCurrent
} else {
SystemPlacement::Tail
SystemPlacement::EndOfCurrent
};
let id_for_replace = Some(id.clone());
self.push_system_cell(
@@ -8844,6 +8864,30 @@ fn update_rate_limit_resets(
lines.len()
);
tracing::info!("[order] final Answer id={:?}", id);
if self.is_review_flow_active() {
if let Some(ref want) = id {
if let Some(idx) = self.history_cells.iter().rposition(|c| {
c.as_any()
.downcast_ref::<history_cell::StreamingContentCell>()
.and_then(|sc| sc.id.as_ref())
.map(|existing| existing == want)
.unwrap_or(false)
}) {
self.history_remove_at(idx);
}
self.stream_state
.closed_answer_ids
.insert(StreamId(want.clone()));
} else if let Some(idx) = self.history_cells.iter().rposition(|c| {
c.as_any()
.downcast_ref::<history_cell::StreamingContentCell>()
.is_some()
}) {
self.history_remove_at(idx);
}
self.last_assistant_message = Some(source);
return;
}
// Debug: list last few history cell kinds so we can see what's present
let tail_kinds: String = self
.history_cells
@@ -11967,6 +12011,299 @@ 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();
let metadata_option = Some(ReviewContextMetadata {
scope: Some("commit".to_string()),
commit: Some(sha.clone()),
..Default::default()
});
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()),
metadata: metadata_option.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();
let metadata_option = Some(ReviewContextMetadata {
scope: Some("branch_diff".to_string()),
base_branch: Some(branch_trimmed.to_string()),
current_branch: current_trimmed.clone(),
..Default::default()
});
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()),
metadata: metadata_option.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() {
@@ -11978,103 +12315,113 @@ impl ChatWidget<'_> {
}
let trimmed = args.trim();
let (prompt, hint) = if trimmed.is_empty() {
(
if trimmed.is_empty() {
let metadata = ReviewContextMetadata {
scope: Some("workspace".to_string()),
..Default::default()
};
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()),
Some(metadata),
);
} else {
let value = trimmed.to_string();
(value.clone(), value)
};
let preparation = format!("Preparing code review for {value}");
let metadata = ReviewContextMetadata {
scope: Some("custom".to_string()),
..Default::default()
};
self.start_review_with_scope(value.clone(), value, Some(preparation), Some(metadata));
}
}
pub(crate) fn start_review_with_scope(
&mut self,
prompt: String,
hint: String,
preparation_label: Option<String>,
metadata: Option<ReviewContextMetadata>,
) {
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}")
};
self.insert_background_event_with_placement(
preparation_notice,
BackgroundPlacement::BeforeNextOutput,
);
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();
let review_request = ReviewRequest {
prompt,
user_facing_hint: hint,
metadata,
};
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>,
prompt: Option<&str>,
output: &ReviewOutputEvent,
) -> history_cell::PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
) -> history_cell::AssistantMarkdownCell {
let mut sections: Vec<String> = Vec::new();
let title = match hint {
Some(h) if !h.trim().is_empty() => {
let trimmed = h.trim();
format!("Review summary — {trimmed}")
format!("**Review summary — {trimmed}**")
}
_ => "Review summary".to_string(),
_ => "**Review summary**".to_string(),
};
lines.push(Line::from(vec![RtSpan::styled(
title,
Style::default().add_modifier(Modifier::BOLD),
)]));
sections.push(title);
if let Some(p) = prompt {
let trimmed_prompt = p.trim();
if !trimmed_prompt.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
RtSpan::styled(
"Prompt:",
Style::default().add_modifier(Modifier::BOLD),
),
RtSpan::raw(" "),
RtSpan::raw(trimmed_prompt.to_string()),
]));
sections.push(format!("**Prompt:** {trimmed_prompt}"));
}
}
let mut sections: Vec<String> = Vec::new();
let explanation = output.overall_explanation.trim();
if !explanation.is_empty() {
sections.push(explanation.to_string());
}
if !output.findings.is_empty() {
sections.push(format_review_findings_block(&output.findings, None));
sections.push(format_review_findings_block(&output.findings, None).trim().to_string());
}
let correctness = output.overall_correctness.trim();
if !correctness.is_empty() {
sections.push(format!("Overall correctness: {correctness}"));
sections.push(format!("**Overall correctness:** {correctness}"));
}
if output.overall_confidence_score > 0.0 {
let score = output.overall_confidence_score;
sections.push(format!("Confidence score: {score:.1}"));
sections.push(format!("**Confidence score:** {score:.1}"));
}
if sections.is_empty() {
if sections.len() == 1 {
sections.push("No detailed findings were provided.".to_string());
}
lines.push(Line::from(""));
for (idx, section) in sections.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
for line in section.lines() {
lines.push(Line::from(line.to_string()));
}
}
let markdown = sections
.into_iter()
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
history_cell::PlainHistoryCell::new(lines, history_cell::HistoryCellType::Assistant)
history_cell::AssistantMarkdownCell::new(markdown, &self.config)
}
/// Handle `/branch [task]` command. Creates a worktree under `.code/branches`,
+4 -2
View File
@@ -30,8 +30,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