From ea7dffa59d244867e43d710563e0ab72384e2c07 Mon Sep 17 00:00:00 2001 From: Hu Qiantao Date: Sun, 31 May 2026 14:41:35 +0800 Subject: [PATCH] feat: show intent summary before file approval prompt (#2381) When the model invokes write/modify/delete tools, extract its preceding text content as an 'intent summary' and pass it to the approval view. This gives users context about why a change is being made before they review what will change. Changes: - Add intent_summary field to ApprovalRequired event (events.rs) - Extract model text from current_text_visible when write tools are detected in the turn loop (turn_loop.rs) - Add ApprovalRequest::new_with_intent constructor with intent_summary parameter (approval.rs) - Pass intent_summary through TUI event handler to approval view (ui.rs) - Render intent summary in approval widget: up to 3 lines of the model explanation, truncated to available card width, with i18n labels for zh-Hans locale (widgets/mod.rs) - Adapt existing tests to new event field (runtime_threads.rs, ui/tests.rs) Design decisions: - Non-blocking: if the model provides no explanation, the approval still proceeds normally (no extra round-trip or token cost) - Backward compatible: YOLO mode and approval cache unaffected - The new() constructor is gated behind #[cfg(test)] since production code now uses new_with_intent() --- crates/tui/src/core/engine/turn_loop.rs | 22 +++++++++++++ crates/tui/src/core/events.rs | 4 +++ crates/tui/src/runtime_threads.rs | 4 +++ crates/tui/src/tui/approval.rs | 17 ++++++++++ crates/tui/src/tui/ui.rs | 12 ++++++- crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/tui/widgets/mod.rs | 44 +++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 04c5171b..6b1b2c4d 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1344,6 +1344,27 @@ impl Engine { } active_tool_names.extend(deferred_tools_hydrated_this_batch); + // --- Intent summary for write tools (#2381) --- + // When the model invokes write tools, extract its preceding text + // as an "intent summary" so the approval view can show *why* the + // change is being made, not just *what* will change. + let has_write_tools = plans.iter().any(|p| { + !p.read_only + && p.approval_required + && p.blocked_error.is_none() + && p.guard_result.is_none() + }); + let intent_summary: Option = if has_write_tools { + let text = current_text_visible.trim(); + if text.is_empty() { + None + } else { + Some(text.to_string()) + } + } else { + None + }; + let plan_count = plans.len(); let batches = plan_tool_execution_batches(plans); let parallel_chunks = batches @@ -1702,6 +1723,7 @@ impl Engine { description: plan.approval_description.clone(), approval_key, approval_grouping_key, + intent_summary: intent_summary.clone(), }) .await; diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 65e551ce..d5ddda43 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -234,6 +234,10 @@ pub enum Event { /// Lossy / arity-aware fingerprint, used to scope *approvals* so an /// "approve for session" covers later flag variants (v0.8.37). approval_grouping_key: String, + /// The model's explanation of intent before invoking write tools (#2381). + /// Displayed in the approval view so users understand *why* the change + /// is being made before reviewing *what* will change. + intent_summary: Option, }, /// Request user input for a tool call diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index d86b147a..f1a4ade2 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -4217,6 +4217,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "stale approval".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4291,6 +4292,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "external allow".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4369,6 +4371,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "external deny".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; @@ -4556,6 +4559,7 @@ mod tests { tool_name: "exec_command".to_string(), description: "remember=true".to_string(), input: serde_json::json!({}), + intent_summary: None, }) .await?; diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 92e3208e..f237aba8 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -134,15 +134,31 @@ pub struct ApprovalRequest { /// Lossy / arity-aware fingerprint, used to scope *approvals* so an /// "approve for session" covers later flag variants (v0.8.37). pub approval_grouping_key: String, + /// The model's explanation of intent before invoking write tools (#2381). + /// Displayed in the approval view so users understand *why* the change + /// is being made before reviewing *what* will change. + pub intent_summary: Option, } impl ApprovalRequest { + #[cfg(test)] pub fn new( id: &str, tool_name: &str, description: &str, params: &Value, approval_key: &str, + ) -> Self { + Self::new_with_intent(id, tool_name, description, params, approval_key, None) + } + + pub fn new_with_intent( + id: &str, + tool_name: &str, + description: &str, + params: &Value, + approval_key: &str, + intent_summary: Option<&str>, ) -> Self { let category = get_tool_category(tool_name); let risk = classify_risk(tool_name, category, params); @@ -159,6 +175,7 @@ impl ApprovalRequest { params: params.clone(), approval_key: approval_key.to_string(), approval_grouping_key, + intent_summary: intent_summary.map(std::string::ToString::to_string), } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 4e8c2463..d3ecf18f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1929,6 +1929,7 @@ async fn run_event_loop( input, approval_key, approval_grouping_key, + intent_summary, } => { let session_approved = is_session_approved_for_tool(app, &tool_name, &approval_grouping_key); @@ -1980,6 +1981,7 @@ async fn run_event_loop( &description, &tool_input, &approval_key, + intent_summary.as_deref(), ); log_sensitive_event( "tool.approval.prompted", @@ -6609,12 +6611,20 @@ fn push_approval_request_view( description: &str, tool_input: &serde_json::Value, approval_key: &str, + intent_summary: Option<&str>, ) { if tool_name == "apply_patch" { maybe_add_patch_preview(app, tool_input); } - let request = ApprovalRequest::new(id, tool_name, description, tool_input, approval_key); + let request = ApprovalRequest::new_with_intent( + id, + tool_name, + description, + tool_input, + approval_key, + intent_summary, + ); app.view_stack .push(ApprovalView::new_for_locale(request, app.ui_locale)); } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 2d7fac45..d4e37a61 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5529,6 +5529,7 @@ fn approval_prompt_uses_event_input_after_message_complete_drain() { "Run cargo tests", &event_input, "approval-key", + None, ); let mut view = app.view_stack.pop().expect("approval view"); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 4ab2cc84..2082a25c 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1166,6 +1166,50 @@ impl Renderable for ApprovalWidget<'_> { ])); } + // Intent summary — the model's explanation of why this change is needed (#2381). + if let Some(ref summary) = self.request.intent_summary { + if !summary.is_empty() { + let max_width = card_area.width.saturating_sub(14) as usize; + if max_width > 0 { + lines.push(Line::from("")); + let intent_label = match locale { + Locale::ZhHans => "意图:", + _ => "Intent: ", + }; + let summary_lines: Vec<&str> = summary.lines().collect(); + for (i, sline) in summary_lines.iter().take(3).enumerate() { + let prefix = if i == 0 { intent_label } else { " " }; + let truncated = + crate::utils::truncate_with_ellipsis(sline, max_width, "..."); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + prefix, + if i == 0 { + Style::default().fg(palette::TEXT_HINT) + } else { + Style::default() + }, + ), + Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)), + ])); + } + if summary_lines.len() > 3 { + let more = match locale { + Locale::ZhHans => { + format!(" … (还有 {} 行)", summary_lines.len() - 3) + } + _ => format!(" … (+{} lines)", summary_lines.len() - 3), + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(more, Style::default().fg(palette::TEXT_HINT)), + ])); + } + } + } + } + lines.push(Line::from("")); let params_str = self.request.params_display(); let params_width = card_area.width.saturating_sub(14) as usize;