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()
This commit is contained in:
Hu Qiantao
2026-05-31 14:41:35 +08:00
parent 42576a7129
commit ea7dffa59d
7 changed files with 103 additions and 1 deletions
+22
View File
@@ -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<String> = 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;
+4
View File
@@ -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<String>,
},
/// Request user input for a tool call
+4
View File
@@ -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?;
+17
View File
@@ -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<String>,
}
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),
}
}
+11 -1
View File
@@ -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));
}
+1
View File
@@ -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");
+44
View File
@@ -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;