diff --git a/assets/icons/robot.svg b/assets/icons/robot.svg
new file mode 100644
index 0000000..19e55dc
--- /dev/null
+++ b/assets/icons/robot.svg
@@ -0,0 +1,3 @@
+
diff --git a/migrations/20260802090000_add_tool_approval_reasons/down.sql b/migrations/20260802090000_add_tool_approval_reasons/down.sql
new file mode 100644
index 0000000..9cce11c
--- /dev/null
+++ b/migrations/20260802090000_add_tool_approval_reasons/down.sql
@@ -0,0 +1 @@
+ALTER TABLE messages DROP COLUMN tool_approval_reasons;
diff --git a/migrations/20260802090000_add_tool_approval_reasons/up.sql b/migrations/20260802090000_add_tool_approval_reasons/up.sql
new file mode 100644
index 0000000..cd5c9c3
--- /dev/null
+++ b/migrations/20260802090000_add_tool_approval_reasons/up.sql
@@ -0,0 +1 @@
+ALTER TABLE messages ADD COLUMN tool_approval_reasons TEXT;
diff --git a/src/agent.rs b/src/agent.rs
index 0967590..c9d9004 100644
--- a/src/agent.rs
+++ b/src/agent.rs
@@ -129,6 +129,7 @@ pub(crate) struct ToolCard {
pub(crate) call: ToolCall,
pub(crate) state: ToolLifecycle,
pub(crate) result: Option,
+ pub(crate) approval_reason: Option,
}
impl ToolCard {
@@ -137,6 +138,7 @@ impl ToolCard {
call,
state: ToolLifecycle::Parsing,
result: None,
+ approval_reason: None,
}
}
@@ -179,24 +181,38 @@ impl ShellApprovalMode {
call: &ToolCall,
root: &Path,
cancel: &AtomicBool,
- ) -> Option {
- let command = (call.name == "bash").then(|| string(call, "command"))??;
- let reason = match self {
- Self::Heuristic => risky_shell_reason(command, root).map(str::to_owned),
+ ) -> (Option, Option) {
+ let Some(command) = (call.name == "bash")
+ .then(|| string(call, "command"))
+ .flatten()
+ else {
+ return (None, None);
+ };
+ let (reason, ai_reason) = match self {
+ Self::Heuristic => (risky_shell_reason(command, root).map(str::to_owned), None),
#[cfg(target_os = "macos")]
Self::Ai(classifier) => match classifier.assess(command, root, cancel) {
- Ok(assessment) if !assessment.risky => None,
- Ok(assessment) => Some(assessment.reason),
- Err(error) => Some(format!(
- "The AI risk check could not complete ({error}); approval is required."
- )),
+ Ok(assessment) => {
+ let reason = (!assessment.reason.is_empty()).then_some(assessment.reason);
+ let approval = assessment.risky.then(|| reason.clone()).flatten();
+ (approval, reason)
+ }
+ Err(error) => (
+ Some(format!(
+ "The AI risk check could not complete ({error}); approval is required."
+ )),
+ None,
+ ),
},
- }?;
- Some(ApprovalPrompt {
- title: "Allow shell command?".into(),
- detail: format!("{reason}\n\n{command}"),
- working_directory: root.to_owned(),
- })
+ };
+ (
+ reason.map(|reason| ApprovalPrompt {
+ title: "Allow shell command?".into(),
+ detail: format!("{reason}\n\n{command}"),
+ working_directory: root.to_owned(),
+ }),
+ ai_reason,
+ )
}
}
@@ -315,6 +331,10 @@ pub(crate) enum ToolEvent {
prompt: ApprovalPrompt,
decision: Sender,
},
+ ApprovalReason {
+ index: usize,
+ reason: String,
+ },
}
struct BashJob {
@@ -1225,9 +1245,16 @@ pub(crate) fn execute_async(
if approval_mode.uses_ai(call) {
send_state(&event_sender, index, ToolLifecycle::AssessingRisk, None);
}
- let prompt = tools
- .browser_approval(call)
- .or_else(|| approval_mode.approval(call, &tools.root, &worker_cancel));
+ let browser_prompt = tools.browser_approval(call);
+ let (shell_prompt, ai_reason) = if browser_prompt.is_none() {
+ approval_mode.approval(call, &tools.root, &worker_cancel)
+ } else {
+ (None, None)
+ };
+ if let Some(reason) = ai_reason {
+ let _ = event_sender.send(ToolEvent::ApprovalReason { index, reason });
+ }
+ let prompt = browser_prompt.or(shell_prompt);
if let Some(prompt) = prompt {
match request_approval(&event_sender, index, prompt, &worker_cancel) {
Ok(()) => {}
@@ -1458,6 +1485,7 @@ pub(crate) fn stored_tool_cards(
model: ModelChoice,
assistant: &str,
result: Option<&str>,
+ approval_reasons: &[Option],
) -> Vec {
let calls = parse_tool_calls(model, assistant)
.map(|(_, calls)| calls)
@@ -1479,6 +1507,7 @@ pub(crate) fn stored_tool_cards(
call,
state,
result,
+ approval_reason: approval_reasons.get(index).cloned().flatten(),
}
})
.collect()
@@ -2054,6 +2083,26 @@ mod tests {
);
}
+ #[test]
+ fn stored_tool_cards_restore_ai_approval_reasons_by_call() {
+ let assistant = r#"<|DSML|tool_calls>
+<|DSML|invoke name="bash"><|DSML|parameter name="command" string="true">pwd|DSML|parameter>|DSML|invoke>
+<|DSML|invoke name="read"><|DSML|parameter name="path" string="true">README.md|DSML|parameter>|DSML|invoke>
+|DSML|tool_calls>"#;
+ let cards = stored_tool_cards(
+ ModelChoice::DeepSeekV4Flash,
+ assistant,
+ None,
+ &[Some("Only reads the working directory.".into()), None],
+ );
+
+ assert_eq!(
+ cards[0].approval_reason.as_deref(),
+ Some("Only reads the working directory.")
+ );
+ assert_eq!(cards[1].approval_reason, None);
+ }
+
#[test]
fn denial_and_stop_cancel_commands_awaiting_approval() {
let directory = std::env::temp_dir().join(format!(
@@ -2082,6 +2131,7 @@ mod tests {
break decision;
}
ToolEvent::State { .. } => {}
+ ToolEvent::ApprovalReason { .. } => {}
}
};
decision.send(false).unwrap();
@@ -2108,6 +2158,7 @@ mod tests {
break decision;
}
ToolEvent::State { .. } => {}
+ ToolEvent::ApprovalReason { .. } => {}
}
};
active.cancel.store(true, Ordering::Relaxed);
diff --git a/src/app.rs b/src/app.rs
index 6e33660..78b59db 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2729,6 +2729,7 @@ mod tests {
reasoning_open: true,
content: String::new(),
model_content: None,
+ tool_approval_reasons: Vec::new(),
markdown: markdown::Content::new(),
transcript: text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -2760,6 +2761,7 @@ mod tests {
reasoning_open: false,
content: content.into(),
model_content: None,
+ tool_approval_reasons: Vec::new(),
markdown: markdown::Content::new(),
transcript: text_editor::Content::new(),
a2ui_lines_processed: 0,
diff --git a/src/app/generation.rs b/src/app/generation.rs
index ff127f3..dfc7332 100644
--- a/src/app/generation.rs
+++ b/src/app/generation.rs
@@ -62,6 +62,7 @@ pub(crate) struct ChatMessage {
pub(super) reasoning_open: bool,
pub(super) content: String,
pub(super) model_content: Option,
+ pub(super) tool_approval_reasons: Vec