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|invoke name="read"><|DSML|parameter name="path" string="true">README.md +"#; + 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>, pub(super) markdown: markdown::Content, pub(super) transcript: text_editor::Content, pub(super) a2ui_lines_processed: usize, @@ -229,6 +230,11 @@ impl From for ChatMessage { reasoning_open: false, content: message.content, model_content: message.model_content, + tool_approval_reasons: message + .tool_approval_reasons + .as_deref() + .and_then(|reasons| serde_json::from_str(reasons).ok()) + .unwrap_or_default(), markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, @@ -789,6 +795,26 @@ impl App { self.pending_tool_approval = Some((prompt, decision)); self.activity = Some(format!("Tool {} · Awaiting approval", index + 1)); } + crate::agent::ToolEvent::ApprovalReason { index, reason } => { + if let Some(card) = self.tool_cards.get_mut(index) { + card.approval_reason = Some(reason); + } + let reasons = self + .tool_cards + .iter() + .map(|card| card.approval_reason.clone()) + .collect::>(); + if let Some(message) = self.conversation.last_mut() { + message.tool_approval_reasons = reasons.clone(); + if let Some(database) = &mut self.database + && let Err(error) = database + .update_message_tool_approval_reasons(message.id, &reasons) + { + self.error = + Some(format!("Could not save tool approval reasons: {error}")); + } + } + } } } } @@ -1961,6 +1987,7 @@ mod tests { reasoning_open: false, content: content.to_owned(), model_content: None, + tool_approval_reasons: Vec::new(), markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, @@ -2156,6 +2183,7 @@ mod tests { content: "### Core / Setup\n\n| File | Lines |\n|---|---:|\n| `src/app.rs` | **1,750** |\n| `src/engine.rs` | 2,400 |\n\n### Summary\n\nDone." .to_owned(), model_content: None, + tool_approval_reasons: Vec::new(), markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, @@ -2223,6 +2251,7 @@ mod tests { reasoning_open: false, content: format!("message {id}"), model_content: None, + tool_approval_reasons: Vec::new(), markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, @@ -2262,6 +2291,7 @@ mod tests { reasoning_open: false, content: format!("message {id}"), model_content: None, + tool_approval_reasons: Vec::new(), markdown: iced::widget::markdown::Content::new(), transcript: iced::widget::text_editor::Content::new(), a2ui_lines_processed: 0, diff --git a/src/app/view.rs b/src/app/view.rs index ba688af..48dd0ac 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -40,6 +40,7 @@ const ICON_MORE: &[u8] = include_bytes!("../../assets/icons/more.svg"); const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg"); const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg"); const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg"); +const ICON_ROBOT: &[u8] = include_bytes!("../../assets/icons/robot.svg"); const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg"); const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg"); const ICON_SIDEBAR: &[u8] = include_bytes!("../../assets/icons/sidebar.svg"); diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index ceb45c4..ed42267 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -81,6 +81,7 @@ impl App { self.config.model, &self.conversation[index - 1].content, None, + &self.conversation[index - 1].tool_approval_reasons, ) .is_empty() { @@ -191,6 +192,7 @@ impl App { self.config.model, &message.content, stored_result, + &message.tool_approval_reasons, ) }; if !cards.is_empty() { @@ -540,9 +542,25 @@ fn tool_cards(cards: Vec) -> Element<'static, Message> { .gap(6), ); } + let mut name = row![text(card.call.name).size(13)] + .spacing(5) + .align_y(Alignment::Center); + if let Some(reason) = &card.approval_reason { + name = name.push( + tooltip( + icon(ICON_ROBOT, 13), + container(text(reason.clone()).size(12)) + .padding(8) + .max_width(320) + .style(preference_group_style), + tooltip::Position::Top, + ) + .gap(5), + ); + } let mut content = column![ row![ - text(card.call.name).size(13), + name, Space::new().width(Length::Fill), text(card.state.label()).size(11).color(muted_text()), actions, diff --git a/src/app/view/stats.rs b/src/app/view/stats.rs index 2e6973b..1f4fb03 100644 --- a/src/app/view/stats.rs +++ b/src/app/view/stats.rs @@ -825,6 +825,7 @@ mod tests { reasoning_open: false, content: content.to_owned(), 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/database.rs b/src/database.rs index 182e90d..659bbea 100644 --- a/src/database.rs +++ b/src/database.rs @@ -132,6 +132,7 @@ pub struct StoredMessage { pub reasoning_complete: bool, pub content: String, pub model_content: Option, + pub tool_approval_reasons: Option, pub system: bool, pub compaction: bool, pub compaction_tail_start: Option, @@ -151,6 +152,7 @@ struct NewMessage<'a> { reasoning_complete: bool, content: &'a str, model_content: Option<&'a str>, + tool_approval_reasons: Option<&'a str>, system: bool, compaction: bool, compaction_tail_start: Option, @@ -197,6 +199,7 @@ impl<'a> NewMessage<'a> { reasoning_complete: true, content, model_content: None, + tool_approval_reasons: None, system, compaction: false, compaction_tail_start: None, @@ -599,6 +602,19 @@ impl Database { .map_err(|error| error.to_string()) } + pub fn update_message_tool_approval_reasons( + &mut self, + id: i32, + reasons: &[Option], + ) -> Result<(), String> { + let reasons = serde_json::to_string(reasons).map_err(|error| error.to_string())?; + diesel::update(messages::table.find(id)) + .set(messages::tool_approval_reasons.eq(reasons)) + .execute(&mut self.connection) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + pub fn update_message_generation_stats( &mut self, id: i32, @@ -973,6 +989,12 @@ mod tests { database .update_message(assistant.id, Some("Reasoning"), true, "Answer") .unwrap(); + database + .update_message_tool_approval_reasons( + assistant.id, + &[Some("Reads repository metadata only.".into())], + ) + .unwrap(); database .update_message_generation_stats(assistant.id, 12_345, 1_024, 768, 256) .unwrap(); @@ -1013,6 +1035,10 @@ mod tests { assert_eq!(messages[2].reasoning.as_deref(), Some("Reasoning")); assert!(messages[2].reasoning_complete); assert_eq!(messages[2].content, "Answer"); + assert_eq!( + messages[2].tool_approval_reasons.as_deref(), + Some(r#"["Reads repository metadata only."]"#) + ); assert_eq!(messages[2].generation_duration_ms, Some(12_345)); assert_eq!(messages[2].input_tokens, Some(1_024)); assert_eq!(messages[2].cached_tokens, Some(768)); diff --git a/src/schema.rs b/src/schema.rs index 2be4805..a264915 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -18,6 +18,7 @@ diesel::table! { reasoning_complete -> Bool, content -> Text, model_content -> Nullable, + tool_approval_reasons -> Nullable, system -> Bool, compaction -> Bool, compaction_tail_start -> Nullable,