mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
feat(review): add scoped picker and metadata flow
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user