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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user