From 23c9d6672516b252a4e9142db8dff24041f48ecc Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:46:47 +0000 Subject: [PATCH] maple-agent: drop the approval card, mode chip and default-mode setting The app no longer has an approval surface: the permission card and its heading, the composer's "Approval mode" chip and menu, the `chat.allow_permission` shortcut slot, the `MAPLE_PERMISSION_MODE` override, the "Default permission mode" row in Settings, the `PermissionMode` type and the `default_permission_mode` field, the backend's `set_permission_mode` and `permission_respond`, the `PendingPermission` state, the application-Vim choice on the card, and the "Maple needs permission" notification. New tasks and sends carry no mode; `PermissionRequested` is received and ignored until the runtime API drops it. An old `settings.json` with `default_permission_mode` or a shortcut override for `chat.allow_permission` still loads: the unknown field is ignored and the next save drops it. The retired slot joins a new `RETIRED_SLOT_IDS` list so its override is ignored without the "unavailable slot" compatibility banner and dropped on the next shortcut edit, the way renamed slots already migrate. Kept: the question card (Escape still skips a question), the MCP elicitation row, the trust dialog (reworded without the permission clause), and the `controlled_externally` tool status is gone with the only writer. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 15 +- apps/maple-agent/app/src/backend.rs | 54 +--- apps/maple-agent/app/src/keymap.rs | 17 +- apps/maple-agent/app/src/settings.rs | 125 ++------- apps/maple-agent/app/src/shortcuts.rs | 40 ++- apps/maple-agent/app/src/ui/chat/commands.rs | 15 +- apps/maple-agent/app/src/ui/chat/composer.rs | 39 --- apps/maple-agent/app/src/ui/chat/dialogs.rs | 22 +- apps/maple-agent/app/src/ui/chat/mod.rs | 244 ++---------------- .../maple-agent/app/src/ui/chat/navigation.rs | 26 -- apps/maple-agent/app/src/ui/chat/tests.rs | 226 ++-------------- .../maple-agent/app/src/ui/chat/transcript.rs | 123 +-------- apps/maple-agent/app/src/ui/settings.rs | 33 +-- .../app/src/ui/settings/navigation.rs | 32 ++- apps/maple-agent/app/src/ui/theme.rs | 4 +- 15 files changed, 159 insertions(+), 856 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index deaa2934f..ce93c6288 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -109,15 +109,15 @@ has an independent dependency graph. projects that provide skills ask once for a trust decision before their guidance loads. - Sessions grouped by project, with rename, archive, and restore. -- Settings: General (default permission mode, web tools, appearance, - tool call details, desktop notifications, tool call summaries, composer Vim, - application Vim, and the speech voice and speed), Keyboard Shortcuts, System - prompt, Integrations (detected built-ins and custom MCP servers), Usage (plan - meter from the billing API plus totals from the Goose ledger), and About. +- Settings: General (web tools, appearance, tool call details, desktop + notifications, tool call summaries, composer Vim, application Vim, and the + speech voice and speed), Keyboard Shortcuts, System prompt, Integrations + (detected built-ins and custom MCP servers), Usage (plan meter from the + billing API plus totals from the Goose ledger), and About. - Dark and light themes; the default follows the system. - Billing status from the Maple billing API. -- Desktop notifications when a task finishes, asks a question, or needs - permission while the window is not focused. +- Desktop notifications when a task finishes or asks a question while the + window is not focused. - Release check on launch: a banner links to a newer GitHub release. Nothing is downloaded or installed by the app. - Window size and maximized state persist between launches. @@ -441,7 +441,6 @@ ignored. See [build profiles](docs/release-profiles.md). | `MAPLE_BILLING_API_URL` | Maple billing API. | `https://billing.opensecret.cloud` | | `MAPLE_CLIENT_ID` | OpenSecret client id (UUID). | Maple's id | | `MAPLE_MODEL` | Model to select at start. | Runtime default | -| `MAPLE_PERMISSION_MODE` | `smart_approve` or `auto`. Overrides the saved setting. | Saved setting | | `MAPLE_CONTEXT_LIMIT` | Context window size in tokens, when the model catalog does not report one. | Catalog value | | `GOOSE_SHELL` | Shell for the agent's shell tool. | `bash` (Windows: `cmd`) | | `MAPLE_UPDATE_REPO` | GitHub `owner/repo` containing stable `maple-agent-vX.Y.Z` releases. | `MaplePrivacyLabs/Maple` | diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 4dad94912..5cdc01683 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -45,18 +45,6 @@ pub struct PendingQuestion { pub questions: Vec, } -#[derive(Debug, Clone)] -pub struct PendingPermission { - pub session_id: String, - pub run_id: String, - pub request_id: String, - pub tool_name: String, - pub prompt: Option, - /// Pretty-printed tool arguments, formatted once when the request - /// arrives instead of on every frame. - pub arguments: Arc, -} - // Re-exported for the settings screens; a headless build has no reader. #[cfg_attr(not(feature = "desktop"), allow(unused_imports))] pub use maple_agent::maple_api::{ @@ -2093,24 +2081,6 @@ impl AgentBackend { .await } - /// Set the permission policy for a session: "smart_approve" asks for - /// each gated tool, "auto" approves everything (bypass). - pub async fn set_permission_mode( - &self, - user_id: &str, - session_id: &str, - mode: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .set_permission_mode(maple_agent::agent::AgentPermissionModeRequest { - session_id: session_id.to_string(), - mode: mode.to_string(), - }) - .await - } - /// Compact a session's history now; reload the session afterwards. pub async fn compact_session(&self, user_id: &str, session_id: &str) -> Result<(), String> { self.service @@ -2356,30 +2326,8 @@ impl AgentBackend { .await } - pub async fn permission_respond( - &self, - user_id: &str, - session_id: &str, - request_id: &str, - allow: bool, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .permission_respond(maple_agent::agent::AgentPermissionResponse { - session_id: session_id.to_string(), - request_id: request_id.to_string(), - decision: if allow { - "allow_once".to_string() - } else { - "deny_once".to_string() - }, - }) - .await - } - /// Standard start request for this app: the saved project root (see - /// `start_runtime`) with the configured model and the SmartApprove policy. + /// `start_runtime`) with the configured model. pub fn default_start_request(&self) -> AgentStartRequest { AgentStartRequest { project_root: None, diff --git a/apps/maple-agent/app/src/keymap.rs b/apps/maple-agent/app/src/keymap.rs index 35a2bfb5f..182f75405 100644 --- a/apps/maple-agent/app/src/keymap.rs +++ b/apps/maple-agent/app/src/keymap.rs @@ -61,7 +61,6 @@ enum SlotAction { ChooseProject, PreviousTask, NextTask, - AllowPermission, PickQuestionOption(usize), CopySelection, SelectAllTranscript, @@ -156,7 +155,6 @@ impl SlotAction { Self::ChooseProject => Box::new(chat::ChooseProject), Self::PreviousTask => Box::new(chat::PreviousTask), Self::NextTask => Box::new(chat::NextTask), - Self::AllowPermission => Box::new(chat::AllowPermission), Self::PickQuestionOption(index) => Box::new(chat::PickQuestionOption { index }), Self::CopySelection => Box::new(chat::CopySelection), Self::SelectAllTranscript => Box::new(chat::SelectAllTranscript), @@ -271,6 +269,13 @@ fn slot( } } +/// Slot ids removed since they could be customized. A saved override under +/// one of these is ignored without a warning and dropped on the next edit. +pub(crate) const RETIRED_SLOT_IDS: &[&str] = &[ + // The approval card went with Maple's tool permissions. + "chat.allow_permission", +]; + /// Slot ids renamed since they could be customized, and their current ids. /// A saved override under an old id still applies to the renamed slot. pub(crate) const RENAMED_SLOT_IDS: &[(&str, &str)] = &[ @@ -405,14 +410,6 @@ fn catalog_for(os: HostOs) -> Vec { next_task, SlotAction::NextTask, ), - slot( - "chat.allow_permission", - "Allow permission request", - ShortcutCategory::Chat, - Some("Chat"), - "secondary-y", - SlotAction::AllowPermission, - ), ]; if matches!(os, HostOs::Macos) { slots.splice( diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 92393cd31..32519270e 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -10,9 +10,6 @@ use std::path::PathBuf; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AppSettings { - /// Default permission policy for new sessions; see [`PermissionMode`]. - #[serde(default)] - pub default_permission_mode: PermissionMode, /// Whether tool cards show input/output payloads by default. #[serde(default = "default_tool_details")] pub tool_details: bool, @@ -41,8 +38,8 @@ pub struct AppSettings { /// Display names for project roots, keyed by absolute path. #[serde(default)] pub project_names: std::collections::HashMap, - /// Whether run completion, permissions, and questions raise desktop - /// notifications while the window is not focused. + /// Whether run completion and questions raise desktop notifications + /// while the window is not focused. #[serde(default = "default_desktop_notifications")] pub desktop_notifications: bool, /// Skip looping and reveal animations. gpui reads no OS preference for @@ -121,86 +118,6 @@ fn default_tts_speed() -> f32 { DEFAULT_TTS_SPEED } -/// Permission policy for a session: whether a gated tool call needs a -/// decision from the user. Modelled on [`crate::ui::theme::Preference`], -/// including the same infallible `parse` so an unknown value on disk -/// degrades to the safer mode instead of failing the whole settings load. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PermissionMode { - /// Confirm each gated tool call. - #[default] - SmartApprove, - /// Approve every tool call without asking. - Auto, -} - -impl PermissionMode { - /// Anything unknown reads as the safer mode. - pub fn parse(value: &str) -> Self { - match value { - "auto" => Self::Auto, - _ => Self::SmartApprove, - } - } - - /// The mode named by `value`, or `None` when it names no mode. Use - /// this where an unknown value must fall back to a saved default - /// rather than to the safer mode. - pub fn from_str(value: &str) -> Option { - match value { - "auto" => Some(Self::Auto), - "smart_approve" => Some(Self::SmartApprove), - _ => None, - } - } - - /// Icon name: a bolt for allow all, a shield for ask first. - pub fn icon(self) -> &'static str { - match self { - Self::SmartApprove => "shield-check", - Self::Auto => "zap", - } - } - - /// The value written to disk and handed to the agent runtime. - pub fn as_str(self) -> &'static str { - match self { - Self::SmartApprove => "smart_approve", - Self::Auto => "auto", - } - } - - pub fn label(self) -> &'static str { - match self { - Self::SmartApprove => "Ask first", - Self::Auto => "Allow all", - } - } - - /// One line of explanation under the label. - pub fn note(self) -> &'static str { - match self { - Self::SmartApprove => "Confirm each gated tool call", - Self::Auto => "Approve every tool call without asking", - } - } -} - -// Serialized as the bare string it has always been, so settings.json and -// the runtime's mode field keep their format. -impl serde::Serialize for PermissionMode { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> serde::Deserialize<'de> for PermissionMode { - fn deserialize>(deserializer: D) -> Result { - let value = ::deserialize(deserializer)?; - Ok(Self::parse(&value)) - } -} - /// Persisted window geometry. Position is left to the window manager: /// Wayland does not expose it, and a stale position can open the window /// off-screen after a monitor change. @@ -274,7 +191,6 @@ fn default_tool_summaries() -> bool { impl Default for AppSettings { fn default() -> Self { Self { - default_permission_mode: PermissionMode::default(), tool_details: default_tool_details(), default_web_enabled: default_web_enabled(), tool_summaries: default_tool_summaries(), @@ -591,27 +507,24 @@ mod tests { assert_eq!(usage.totals.total_tokens, 1200); } + /// A settings file written while the app still saved a permission mode + /// and a shortcut for the approval card loads as it is; the next save + /// drops the mode, and the retired shortcut slot is ignored at run time + /// (see `shortcuts::tests::an_override_for_a_retired_slot_is_dropped_quietly`). #[test] - fn permission_mode_round_trips_as_a_string() { - for mode in [PermissionMode::SmartApprove, PermissionMode::Auto] { - let json = serde_json::to_string(&mode).expect("serialize"); - assert_eq!(json, format!("\"{}\"", mode.as_str())); - let back: PermissionMode = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back, mode); - } - } - - #[test] - fn unknown_permission_mode_reads_as_the_safer_one() { - assert_eq!(PermissionMode::parse("chat"), PermissionMode::SmartApprove); - assert_eq!(PermissionMode::parse(""), PermissionMode::SmartApprove); - assert_eq!(PermissionMode::default(), PermissionMode::SmartApprove); - } - - #[test] - fn default_settings_keep_the_on_disk_permission_string() { - let json = serde_json::to_value(AppSettings::default()).expect("serialize"); - assert_eq!(json["default_permission_mode"], "smart_approve"); + fn settings_with_a_saved_permission_mode_still_load() { + let settings: AppSettings = serde_json::from_str( + r#"{"default_permission_mode":"auto","tool_details":true, + "shortcut_overrides":{"chat.allow_permission":null,"chat.new_task":"secondary-shift-n"}}"#, + ) + .expect("an old settings file still loads"); + assert!(settings.tool_details); + assert_eq!( + settings.shortcut_overrides.get("chat.allow_permission"), + Some(&None) + ); + let json = serde_json::to_value(&settings).expect("serialize"); + assert!(json.get("default_permission_mode").is_none()); } #[test] diff --git a/apps/maple-agent/app/src/shortcuts.rs b/apps/maple-agent/app/src/shortcuts.rs index 3c15d24eb..de27dfc37 100644 --- a/apps/maple-agent/app/src/shortcuts.rs +++ b/apps/maple-agent/app/src/shortcuts.rs @@ -9,7 +9,7 @@ use gpui::{App, KeyBinding, Keystroke}; use crate::ui::{application_vim, text_input::vim_actions}; -use crate::keymap::{RENAMED_SLOT_IDS, build_binding, catalog, install}; +use crate::keymap::{RENAMED_SLOT_IDS, RETIRED_SLOT_IDS, build_binding, catalog, install}; pub(crate) type ShortcutOverrides = BTreeMap>; @@ -159,14 +159,18 @@ impl ShortcutRuntime { } /// Move overrides saved under a renamed slot id to the slot's current id, -/// unless the current id has its own. Run before editing overrides so a reset -/// or change also retires the old key. +/// unless the current id has its own, and drop overrides saved under a +/// retired slot id. Run before editing overrides so a reset or change also +/// retires the old keys. pub(crate) fn migrate_renamed_slots(overrides: &mut ShortcutOverrides) { for (old, new) in RENAMED_SLOT_IDS { if let Some(saved) = overrides.remove(*old) { overrides.entry((*new).to_owned()).or_insert(saved); } } + for retired in RETIRED_SLOT_IDS { + overrides.remove(*retired); + } } pub(super) struct PreparedShortcuts { @@ -181,7 +185,9 @@ pub(super) fn prepare(overrides: &ShortcutOverrides) -> Result>(); @@ -563,6 +569,32 @@ mod tests { ); } + /// A build that removed a slot must not warn about the override a user + /// saved for it; the next edit drops the stale key. + #[test] + fn an_override_for_a_retired_slot_is_dropped_quietly() { + let mut overrides = ShortcutOverrides::new(); + overrides.insert("chat.allow_permission".into(), None); + overrides.insert("chat.new_task".into(), Some("secondary-shift-n".into())); + let prepared = prepare(&overrides).unwrap(); + assert_eq!(prepared.compatibility_warning, None); + assert!( + prepared + .rows + .iter() + .all(|row| row.slot_id != "chat.allow_permission") + ); + + migrate_renamed_slots(&mut overrides); + assert_eq!( + overrides.into_iter().collect::>(), + vec![( + "chat.new_task".to_string(), + Some("secondary-shift-n".to_string()) + )] + ); + } + #[test] fn native_equivalent_spelling_is_an_exact_conflict() { let snapshot = ShortcutSnapshot { diff --git a/apps/maple-agent/app/src/ui/chat/commands.rs b/apps/maple-agent/app/src/ui/chat/commands.rs index d347759b0..d2dcc2766 100644 --- a/apps/maple-agent/app/src/ui/chat/commands.rs +++ b/apps/maple-agent/app/src/ui/chat/commands.rs @@ -9,8 +9,8 @@ use gpui::{Context, Window}; use super::navigation::ApplicationVimCommand; use super::{ - AllowPermission, ChatEscape, ChatScreen, ChooseProject, CopySelection, FocusSearch, NewTask, - NextTask, OpenAppSettings, OpenSettings, PickQuestionOption, PreviousTask, SelectAllTranscript, + ChatEscape, ChatScreen, ChooseProject, CopySelection, FocusSearch, NewTask, NextTask, + OpenAppSettings, OpenSettings, PickQuestionOption, PreviousTask, SelectAllTranscript, ToggleArchived, ToggleSidebar, }; @@ -26,7 +26,6 @@ pub(super) enum ChatCommand { OpenSettings, PickQuestionOption { index: usize }, PreviousTask, - RespondPermission { allow: bool }, SelectAllTranscript, ToggleArchived, ToggleSidebar, @@ -66,22 +65,12 @@ impl ChatScreen { self.pick_and_submit_question_option(index, cx) } ChatCommand::PreviousTask => self.step_task(-1, cx), - ChatCommand::RespondPermission { allow } => self.respond_permission(allow, cx), ChatCommand::SelectAllTranscript => self.select_all_text(cx), ChatCommand::ToggleArchived => self.toggle_archived_visibility(cx), ChatCommand::ToggleSidebar => self.toggle_sidebar_visibility(cx), } } - pub(super) fn allow_permission( - &mut self, - _: &AllowPermission, - window: &mut Window, - cx: &mut Context, - ) { - self.execute_command(ChatCommand::RespondPermission { allow: true }, window, cx); - } - pub(super) fn chat_escape( &mut self, _: &ChatEscape, diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index a477c2c70..7ce74450c 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -1,7 +1,6 @@ //! The chat pane chrome: the header and its menus, the slash palette, //! the plan and side-question cards, and the composer itself. -use crate::settings::PermissionMode; use std::sync::Arc; use gpui::{Context, Div, IntoElement, SharedString, Window, div, prelude::*, px}; @@ -261,29 +260,6 @@ impl ChatScreen { })) } - fn mode_menu(&self) -> Menu { - Menu::new("mode-menu", px(320.)) - .label("Approval mode") - .application_vim(self.application_vim_enabled) - .items( - [PermissionMode::Auto, PermissionMode::SmartApprove].map(|mode| { - MenuItem::new( - SharedString::from(format!("mode-{}", mode.as_str())), - mode.label(), - move |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.permission_mode = mode; - this.uses_default_permission_mode = false; - this.apply_permission_mode(cx); - cx.notify(); - }, - ) - .icon(mode.icon()) - .note(mode.note()) - .current(self.permission_mode == mode) - }), - ) - } - /// The task's integrations, each with a switch. A server that still /// needs setup opens Settings instead. fn integrations_menu(&self) -> Menu { @@ -976,21 +952,6 @@ impl ChatScreen { cx, |this, _| this.model_menu(), )) - .child(self.with_menu( - ChatPopup::Mode, - chip( - "permission-mode-toggle", - Some(self.permission_mode.icon()), - self.permission_mode.label().to_string(), - true, - self.popup.is_open(&ChatPopup::Mode), - false, - ), - Placement::AboveStart, - window, - cx, - |this, _| this.mode_menu(), - )) .child(self.with_menu( ChatPopup::Integrations, chip( diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index 82f58f978..3a9657c96 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -183,9 +183,8 @@ impl ChatScreen { .child( "Trusting a project lets Maple use project-provided guidance, \ including agent skills. These instructions can influence how \ - agents work and use tools. Maple's tool permissions still \ - apply, and you can change this choice later from the \ - project's menu.", + agents work and use tools. You can change this choice later \ + from the project's menu.", ), ) .child( @@ -384,21 +383,10 @@ impl ChatScreen { } /// Drop the selection and everything the composer shows for it: the - /// transcript, the side thread, the queue, and a permission card that - /// belongs to the task. Questions stay queued per session. + /// transcript, the side thread, and the queue. Questions stay queued + /// per session. pub(super) fn leave_selected_session(&mut self, cx: &mut Context) { - let left = self.clear_selected_session_presentation(cx); - if let Some(left) = left.as_deref() { - let showing = self - .pending_permissions - .iter() - .any(|permission| permission.session_id == left); - self.pending_permissions - .retain(|permission| permission.session_id != left); - if showing { - self.permission_responding = false; - } - } + self.clear_selected_session_presentation(cx); } /// Move one task between active, settled, and archived. The service diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index c4bfe9902..a1665ae9b 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -1,8 +1,7 @@ //! The agent chat surface: session sidebar, streaming transcript with tool -//! calls, permission prompts, and the composer. Pure consumer of the backend +//! calls, questions, and the composer. Pure consumer of the backend //! facade + event stream. -use crate::settings::PermissionMode; use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; @@ -18,7 +17,7 @@ use maple_agent::agent::{ AgentTimelineItem, SideQuestionEvent, }; -use crate::backend::{AgentBackend, PendingPermission, PendingQuestion}; +use crate::backend::{AgentBackend, PendingQuestion}; use crate::ui::icons::{icon, spinner, wordmark}; use crate::ui::markdown; use crate::ui::motion; @@ -51,14 +50,13 @@ use self::navigation::ApplicationVimState; use self::sidebar::SessionActivity; use self::sidebar::{Sidebar, SidebarEvent, root_display_name, session_summary_eq}; use self::transcript::{ - ActiveSubagent, PlanEntry, PlanStatus, plan_entries, render_permission_card, - render_question_card, render_waiting_indicator, + ActiveSubagent, PlanEntry, PlanStatus, plan_entries, render_question_card, + render_waiting_indicator, }; gpui::actions!( chat, [ - AllowPermission, ChatEscape, ChooseProject, CopySelection, @@ -180,7 +178,6 @@ pub(super) enum ChatPopup { /// The header chip's project menu. Project, Model, - Mode, Integrations, /// The transcript's right-click menu, at a window position. Transcript(gpui::Point), @@ -226,13 +223,12 @@ struct DraftMcpChange { #[derive(Debug, Clone, PartialEq, Eq)] struct NewSessionChanges { web: Option, - mode: Option, mcp: Vec, } impl NewSessionChanges { fn is_empty(&self) -> bool { - self.web.is_none() && self.mode.is_none() && self.mcp.is_empty() + self.web.is_none() && self.mcp.is_empty() } } @@ -337,11 +333,6 @@ pub struct ChatScreen { /// capped at `FINISHED_RUNS_KEPT`. A send that returns after its run /// finished must not mark that run active again. finished_runs: std::collections::VecDeque, - /// Permission requests waiting for a decision, one per request, - /// across every session. The card shows the first one for the - /// selected session; the others wait until their session is opened. - pending_permissions: Vec, - permission_responding: bool, /// Suppresses duplicate session creation while one is in flight. session_setup_pending: bool, /// The new-task screen is a draft the user started (or the auto-select @@ -424,12 +415,6 @@ pub struct ChatScreen { /// Whether tool cards show their input/output payloads. Toggled from /// the header; off gives a one-line card per tool call. tool_details: bool, - /// Permission policy for new runs: ask per gated tool, or approve - /// every call. - permission_mode: PermissionMode, - /// False once the user picks a mode for this specific session; the - /// settings default then no longer overrides it. - uses_default_permission_mode: bool, /// Project context shown by the UI and used explicitly for new tasks. /// Existing tasks always execute in their own persisted project root. project_root: Option, @@ -1017,8 +1002,6 @@ impl ChatScreen { active_runs: HashMap::new(), completed_unread_sessions: HashSet::new(), finished_runs: std::collections::VecDeque::new(), - pending_permissions: Vec::new(), - permission_responding: false, session_setup_pending: false, draft: false, pending_first_send: None, @@ -1052,14 +1035,6 @@ impl ChatScreen { loading_session: None, list_state: transcript_list_state(), tool_details: settings.tool_details, - // An unset or unknown value in either place means "use the - // saved default", so only a known mode counts as an override. - permission_mode: std::env::var("MAPLE_PERMISSION_MODE") - .ok() - .and_then(|mode| PermissionMode::from_str(&mode)) - .or(Some(settings.default_permission_mode)) - .unwrap_or_default(), - uses_default_permission_mode: std::env::var("MAPLE_PERMISSION_MODE").is_err(), project_root: None, recent_roots: Vec::new(), popup: Popup::new(|this| &mut this.popup, cx), @@ -1948,18 +1923,16 @@ impl ChatScreen { } /// The settings the composer shows that `session`, just created, does - /// not have: web access, the permission mode, and the integration - /// rows whose switch differs from `task_mcp`. Chips flipped while the - /// create was in flight show up here like any other. + /// not have: web access and the integration rows whose switch differs + /// from `task_mcp`. Chips flipped while the create was in flight show + /// up here like any other. fn new_session_changes( &self, session: &AgentSessionSummary, task_mcp: &[DraftMcpChange], ) -> NewSessionChanges { - let mode = self.permission_mode.as_str(); NewSessionChanges { web: (self.web_enabled != session.web_enabled).then_some(self.web_enabled), - mode: (mode != session.mode).then(|| mode.to_string()), mcp: self .session_mcp .iter() @@ -2015,13 +1988,6 @@ impl ChatScreen { .await .map_err(|message| format!("Could not change web access: {message}"))?; } - if let Some(mode) = changes.mode { - backend - .set_permission_mode(&user_id, &session.id, &mode) - .await - .map_err(|message| format!("Could not set permission mode: {message}"))?; - session.mode = mode; - } for change in &changes.mcp { backend .set_session_mcp_server_enabled( @@ -2180,12 +2146,7 @@ impl ChatScreen { title: None, model: self.selected_model.clone(), context_limit: None, - // Persist the composer's mode — the saved default until the - // user picks one for this task — so the created row, its - // summary, and the chip agree. `None` would fall back to the - // runtime's SmartApprove startup default, and adopting that - // summary would reset the chip to Ask First. - mode: Some(self.permission_mode.as_str().to_string()), + mode: None, // The draft's switches decide which servers start with the // task; the runtime reads curated MCP integrations (CUA) from // this list too. External agents are not servers and are @@ -2209,7 +2170,7 @@ impl ChatScreen { } /// Replace the timeline after a mid-run history compaction without - /// disturbing the active run or a pending permission. + /// disturbing the active run or a pending question. fn reload_timeline(&mut self, session_id: &str, cx: &mut Context) { self.load_session(session_id, LoadMode::Reload, LOAD_RETRIES, cx); } @@ -2392,9 +2353,8 @@ impl ChatScreen { self.refresh_selected_title(); } - /// Apply settings-default changes when returning from the settings - /// screen: tool verbosity updates live; the permission default only - /// affects sessions that still follow the default. + /// Apply settings changes when returning from the settings screen: + /// every default the composer and transcript read updates live. pub fn apply_defaults( &mut self, settings: &crate::settings::AppSettings, @@ -2430,10 +2390,6 @@ impl ChatScreen { self.screen_focus_pending = true; self.tts_voice.clone_from(&settings.tts_voice); self.tts_speed = settings.tts_speed; - if self.uses_default_permission_mode { - self.permission_mode = settings.default_permission_mode; - self.apply_permission_mode(cx); - } // Servers may have been added or removed in settings. self.refresh_session_mcp(cx); cx.notify(); @@ -2633,33 +2589,10 @@ impl ChatScreen { ); } - fn apply_permission_mode(&self, cx: &mut Context) { - let Some(session_id) = self.selected_session.clone() else { - return; - }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - let mode = self.permission_mode.as_str().to_string(); - self.call( - async move { - backend - .set_permission_mode(&user_id, &session_id, &mode) - .await - }, - cx, - |this, result, cx| { - if let Err(message) = result { - this.notice = Some(format!("Could not set permission mode: {message}").into()); - cx.notify(); - } - }, - ); - } - /// Leave the selected task's presentation without discarding anything /// the account runtime still needs for background work. Pending - /// permissions and questions remain keyed by session and reappear when - /// that task is opened again. + /// questions remain keyed by session and reappear when that task is + /// opened again. pub(super) fn clear_selected_session_presentation( &mut self, cx: &mut Context, @@ -2711,7 +2644,6 @@ impl ChatScreen { self.list_state.scroll_to_end(); self.awaiting_first_token = false; self.lightbox = None; - self.permission_responding = false; self.popup.close(cx); } @@ -2753,11 +2685,6 @@ impl ChatScreen { } self.refresh_selected_title(); self.set_queue(Vec::new()); - // Adopt the session's stored policy; it persists per session in the - // runtime. - if let Some(mode) = PermissionMode::from_str(&session.mode) { - self.permission_mode = mode; - } self.web_enabled = session.web_enabled; self.apply_session_model(&session, cx); self.replace_timeline(timeline); @@ -3476,10 +3403,6 @@ impl ChatScreen { self.skip_question(cx); return; } - if self.current_permission().is_some() { - self.respond_permission(false, cx); - return; - } if let Some(selection) = self.selection.clone() && selection.read(cx).has_selection() { @@ -4020,7 +3943,7 @@ impl ChatScreen { text: text.clone(), model, context_limit: None, - mode: Some(self.permission_mode.as_str().to_string()), + mode: None, vision_capable, steer: steer && run_active, queue_id, @@ -4405,47 +4328,6 @@ impl ChatScreen { composed } - fn respond_permission(&mut self, allow: bool, cx: &mut Context) { - let Some(permission) = self.current_permission().cloned() else { - return; - }; - if self.permission_responding { - return; - } - self.permission_responding = true; - cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - let request_id = permission.request_id.clone(); - self.call( - async move { - backend - .permission_respond( - &user_id, - &permission.session_id, - &permission.request_id, - allow, - ) - .await - }, - cx, - move |this, result, cx| { - this.permission_responding = false; - match result { - Ok(()) => { - this.pending_permissions - .retain(|pending| pending.request_id != request_id); - } - Err(message) => { - // Keep the card so the decision can be retried. - this.notice = Some(format!("Permission response failed: {message}").into()); - } - } - cx.notify(); - }, - ); - } - pub(crate) fn sign_out(&mut self, cx: &mut Context) { self.stop_speech(cx); if self.recording { @@ -4661,7 +4543,6 @@ impl ChatScreen { // work; the summary check reads the stored item after the merge. let completed = item.status.as_deref() == Some("completed"); let is_tool = matches!(item.item_type.as_str(), "tool" | "toolCall"); - self.retire_decided_permission(&item); self.load_attachment_images_for(&item, cx); if let Some(plan) = plan_entries(&item) { self.set_plan(plan); @@ -4689,36 +4570,6 @@ impl ChatScreen { self.refresh_context_usage(cx); } } - /// Drop the permission card when the runtime decided its request - /// without the card: switching the session to "Allow all" approves - /// every pending request and replaces the permission row with a - /// status. - fn retire_decided_permission(&mut self, item: &AgentTimelineItem) { - if item.status.is_none() { - return; - } - let Some(request_id) = item.id.strip_prefix("permission-") else { - return; - }; - let showing = self - .current_permission() - .is_some_and(|permission| permission.request_id == request_id); - let before = self.pending_permissions.len(); - self.pending_permissions - .retain(|pending| pending.request_id != request_id); - if showing && self.pending_permissions.len() != before { - self.permission_responding = false; - } - } - - /// The permission card shown for the selected session, if any. - fn current_permission(&self) -> Option<&PendingPermission> { - let selected = self.selected_session.as_deref()?; - self.pending_permissions - .iter() - .find(|permission| permission.session_id == selected) - } - pub fn handle_service_events( &mut self, events: Vec, @@ -4900,47 +4751,9 @@ impl ChatScreen { return false; } } - AgentRunEvent::PermissionRequested { request, item } => { - let selected = self.is_selected(session_id); - if selected { - // The permission row stays in the transcript so the - // decision is visible after the card is answered. - self.load_attachment_images_for(&item, cx); - self.apply_timeline_item(session_id, item); - } - let arguments = serde_json::Value::Object(request.arguments); - let arguments: std::sync::Arc = if arguments.is_null() { - "".into() - } else { - serde_json::to_string_pretty(&arguments) - .unwrap_or_default() - .into() - }; - let prompt = request - .prompt - .clone() - .unwrap_or_else(|| format!("Run tool {}?", request.tool_name)); - self.notify_desktop(session_id, "Maple needs permission", &prompt, cx); - // The request is kept even when its session is not on - // screen: the run blocks until it is answered, so the card - // must appear when the user opens that session. - self.pending_permissions - .retain(|pending| pending.request_id != request.request_id); - self.pending_permissions.push(PendingPermission { - session_id: session_id.to_string(), - run_id: run_id.to_string(), - request_id: request.request_id, - tool_name: request.tool_name, - prompt: request.prompt, - arguments, - }); - if selected { - self.permission_responding = false; - } else { - self.bump_timeline_revision(session_id); - return false; - } - } + // The runtime no longer asks for tool permissions; the variant + // stays declared until the API drops it. + AgentRunEvent::PermissionRequested { .. } => {} AgentRunEvent::SubagentStarted { id, task, @@ -4984,8 +4797,7 @@ impl ChatScreen { } AgentRunEvent::HistoryReplaced => { if self.is_selected(session_id) { - // Replace history only; the run and any pending - // permission keep flowing. + // Replace history only; the run keeps flowing. let session_id = session_id.to_string(); self.reload_timeline(&session_id, cx); } @@ -5042,14 +4854,6 @@ impl ChatScreen { // questions would block the composer forever. self.clear_session_questions(session_id, cx); } - let showing = self - .current_permission() - .is_some_and(|permission| permission.run_id == run_id); - self.pending_permissions - .retain(|permission| permission.run_id != run_id); - if showing { - self.permission_responding = false; - } if self.is_selected(session_id) { // Another task finishing must not hide the dots for // the send that is still waiting here. @@ -5155,7 +4959,6 @@ impl Render for ChatScreen { if self.question_focus_pending { self.question_focus_pending = false; if self.application_vim_enabled { - self.application_vim.permission_choice = 0; if let Some(handle) = self.application_focus.clone() { window.focus(&handle, cx); } @@ -5210,14 +5013,6 @@ impl Render for ChatScreen { cx, )) }) - .when_some(self.current_permission(), |container, permission| { - container.child(render_permission_card( - permission, - self.permission_responding, - self.application_permission_choice(), - cx, - )) - }) .child( div() .relative() @@ -5270,7 +5065,6 @@ impl Render for ChatScreen { .on_action(cx.listener(Self::choose_project)) .on_action(cx.listener(Self::previous_task)) .on_action(cx.listener(Self::next_task)) - .on_action(cx.listener(Self::allow_permission)) .on_action(cx.listener(Self::pick_question_option)) .on_action(cx.listener(Self::select_all_transcript)) .on_action(cx.listener(Self::app_vim_next)) diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index 74a744684..1f9fed14f 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -40,7 +40,6 @@ pub(super) struct ApplicationVimState { pub(super) count: CountState, transcript_by_task: HashMap, follow_by_task: HashMap, - pub(super) permission_choice: usize, } impl Default for ApplicationVimState { @@ -52,7 +51,6 @@ impl Default for ApplicationVimState { count: CountState::default(), transcript_by_task: HashMap::new(), follow_by_task: HashMap::new(), - permission_choice: 0, } } } @@ -330,16 +328,6 @@ impl ChatScreen { } return; } - if self.current_permission().is_some() { - self.application_vim.permission_choice = stepped_index( - Some(self.application_vim.permission_choice), - 2, - direction, - count, - ); - cx.notify(); - return; - } match self.application_vim.region { ChatRegion::Sidebar => self.move_sidebar_selection(direction, count, cx), ChatRegion::Transcript => self.move_transcript_selection(direction, count, cx), @@ -362,11 +350,6 @@ impl ChatScreen { } return; } - if self.current_permission().is_some() { - self.application_vim.permission_choice = usize::from(!first); - cx.notify(); - return; - } match self.application_vim.region { ChatRegion::Sidebar => self.select_sidebar_edge(first, cx), ChatRegion::Transcript => self.select_transcript_edge(first, !first, cx), @@ -545,10 +528,6 @@ impl ChatScreen { } return; } - if self.current_permission().is_some() { - self.respond_permission(self.application_vim.permission_choice == 0, cx); - return; - } match self.application_vim.region { ChatRegion::Sidebar => match self.sidebar.read(cx).vim_selected().cloned() { Some(SidebarTarget::NewTask) => self.new_session(cx), @@ -927,11 +906,6 @@ impl ChatScreen { } } - pub(super) fn application_permission_choice(&self) -> Option { - (self.application_vim_enabled && self.current_permission().is_some()) - .then_some(self.application_vim.permission_choice) - } - // Typed GPUI action adapters. Every one enters through ChatCommand so // pointer and keyboard behavior keep the same shallow command seam. pub(super) fn app_vim_next( diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index b5134b4ca..4f84f5de7 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -549,96 +549,6 @@ mod state_tests { }); } - #[gpui::test] - fn test_decided_permission_row_clears_the_card(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - let screen = screen(cx); - screen.update(cx, |this, cx| { - this.selected_session = Some("s1".to_string()); - this.pending_permissions.push(PendingPermission { - session_id: "s1".to_string(), - run_id: "r1".to_string(), - request_id: "req-1".to_string(), - tool_name: "shell".to_string(), - prompt: None, - arguments: "".into(), - }); - this.permission_responding = true; - // A row for another request must not clear the card. - let mut other = item("permission-req-2", "permission", None); - other.status = Some("allow_once".to_string()); - this.handle_service_event( - AgentServiceEvent::TimelineItem { - session_id: "s1".to_string(), - run_id: None, - item: other, - }, - cx, - ); - assert!(this.current_permission().is_some()); - // The runtime approved the request (Allow all) and replaced - // its row with a decision. - let mut decided = item("permission-req-1", "permission", None); - decided.status = Some("allow_once".to_string()); - this.handle_service_event( - AgentServiceEvent::TimelineItem { - session_id: "s1".to_string(), - run_id: None, - item: decided, - }, - cx, - ); - assert!(this.current_permission().is_none()); - assert!(!this.permission_responding); - }); - } - - #[gpui::test] - fn test_permission_for_other_session_waits_until_selected(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - let screen = screen(cx); - screen.update(cx, |this, cx| { - this.selected_session = Some("s1".to_string()); - let request = maple_agent::agent::AgentPermissionRequest { - request_id: "req-2".to_string(), - tool_name: "shell".to_string(), - arguments: serde_json::Map::new(), - prompt: None, - }; - this.handle_service_event( - AgentServiceEvent::Run { - session_id: "s2".to_string(), - run_id: "r2".to_string(), - event: maple_agent::agent::AgentRunEvent::PermissionRequested { - request, - item: item("permission-req-2", "permission", None), - }, - }, - cx, - ); - // Not shown for the session on screen. - assert!(this.current_permission().is_none()); - // Shown once that session is opened. - this.selected_session = Some("s2".to_string()); - assert_eq!( - this.current_permission().map(|p| p.request_id.as_str()), - Some("req-2") - ); - // Gone when its run ends. - this.handle_service_event( - AgentServiceEvent::Run { - session_id: "s2".to_string(), - run_id: "r2".to_string(), - event: maple_agent::agent::AgentRunEvent::Finished( - maple_agent::agent::AgentRunTerminal::Cancelled, - ), - }, - cx, - ); - assert!(this.current_permission().is_none()); - }); - } - #[gpui::test] fn test_session_upsert_never_duplicates(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -1016,26 +926,6 @@ mod state_tests { }); } - /// Escape answers a showing prompt before it reaches the running - /// task: a permission card is denied, not left behind by a stop. - #[gpui::test] - fn test_escape_denies_a_showing_permission(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - let screen = screen(cx); - screen.update(cx, |this, cx| { - this.pending_permissions.push(PendingPermission { - session_id: "s1".to_string(), - run_id: "r1".to_string(), - request_id: "req-1".to_string(), - tool_name: "shell".to_string(), - prompt: None, - arguments: "".into(), - }); - this.escape(cx); - assert!(this.permission_responding); - }); - } - #[gpui::test] fn test_first_agent_item_clears_waiting_state(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -1915,11 +1805,11 @@ mod state_tests { } /// The created task is compared with the composer before anything is - /// sent: web access and the mode the create could not carry, a server - /// switched on after Enter, and an external agent, which no request - /// switches on. With a difference the send waits for the apply; with - /// none it goes out at once. Before, the first turn ran under the - /// mode and web access the task happened to be created with. + /// sent: web access the create could not carry, a server switched on + /// after Enter, and an external agent, which no request switches on. + /// With a difference the send waits for the apply; with none it goes + /// out at once. Before, the first turn ran under the web access the + /// task happened to be created with. #[gpui::test] fn test_first_send_applies_the_composer_before_sending(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -1931,7 +1821,6 @@ mod state_tests { this.draft = true; this.default_web_enabled = false; this.web_enabled = false; - this.permission_mode = PermissionMode::Auto; this.set_draft_mcp_defaults(vec![ draft_mcp_row(mcp_server("docs", true)), draft_mcp_row(mcp_server("wiki", false)), @@ -1946,15 +1835,13 @@ mod state_tests { }, ]); let request = this.new_session_request().expect("draft request"); - assert_eq!(request.mode.as_deref(), Some("auto")); + assert!(request.mode.is_none()); assert_eq!(request.mcp_server_names, Some(vec!["docs".to_string()])); this.send_text("hello".to_string(), cx); assert!(this.session_setup_pending); let generation = this.selection_generation; - // While the create is in flight: Approve all -> Ask first, wiki - // on, Codex on. - this.permission_mode = PermissionMode::SmartApprove; + // While the create is in flight: wiki on, Codex on. this.toggle_session_mcp( "wiki".to_string(), AgentSessionIntegrationKind::Mcp, @@ -1970,7 +1857,6 @@ mod state_tests { // The create lands with what it was asked for. let mut created = summary_at("created", "New Task", "/work/alpha"); - created.mode = "auto".to_string(); created.web_enabled = true; let task_mcp = this.created_task_mcp(request.mcp_server_names.as_deref()); assert_eq!( @@ -1997,7 +1883,6 @@ mod state_tests { this.new_session_changes(&created, &task_mcp), NewSessionChanges { web: Some(false), - mode: Some("smart_approve".to_string()), mcp: vec![ DraftMcpChange { name: "wiki".to_string(), @@ -2027,7 +1912,6 @@ mod state_tests { assert!(!this.awaiting_first_token); // Once the task matches the composer the send goes out. - created.mode = "smart_approve".to_string(); created.web_enabled = false; let mut task_mcp = task_mcp; task_mcp[1].enabled = true; @@ -2037,7 +1921,6 @@ mod state_tests { assert!(!this.session_setup_pending); assert!(this.pending_first_send.is_none()); assert_eq!(this.selected_session.as_deref(), Some("created")); - assert_eq!(this.permission_mode, PermissionMode::SmartApprove); assert!(!this.web_enabled, "the chip is not clobbered"); assert!(this.awaiting_first_token, "the send was dispatched"); }); @@ -4016,6 +3899,22 @@ mod state_tests { cx.simulate_click(bounds.center(), gpui::Modifiers::default()); } + /// The composer offers the model and integration chips; there is no + /// approval-mode chip because every tool call runs without asking. + #[gpui::test] + fn composer_offers_no_approval_mode(cx: &mut TestAppContext) { + let (_chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.models = vec!["voxtral-small-24b".to_string()]; + this.sync_sidebar(cx); + }); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!(cx.debug_bounds("model-picker").is_some()); + assert!(cx.debug_bounds("mcp-menu").is_some()); + assert!(cx.debug_bounds("permission-mode-toggle").is_none()); + } + /// Issue #997: a second press on the button that opened a menu closes /// it. Before, the menu's outside-press handler closed it on the way /// down and the button's click opened it again. @@ -4030,7 +3929,6 @@ mod state_tests { ("projects-header", "Switcher"), ("menu-session-s1", "Task(s1)"), ("model-picker", "Model"), - ("permission-mode-toggle", "Mode"), ("mcp-menu", "Integrations"), ("root-picker", "Project"), ] { @@ -4067,7 +3965,7 @@ mod state_tests { ("model-picker", "menu-session-s1", "Task(s1)"), ("model-picker", "projects-header", "Switcher"), ("menu-session-s1", "projects-header", "Switcher"), - ("root-picker", "permission-mode-toggle", "Mode"), + ("root-picker", "mcp-menu", "Integrations"), ] { press(cx, first); press(cx, then); @@ -4458,20 +4356,11 @@ mod state_tests { queue_id: "q1".to_string(), draft: String::new(), }); - this.pending_permissions.push(PendingPermission { - session_id: "s1".to_string(), - run_id: "run-1".to_string(), - request_id: "req-1".to_string(), - tool_name: "bash".to_string(), - prompt: None, - arguments: "".into(), - }); this.leave_selected_session(cx); assert_eq!(this.selected_session, None); assert!(this.btw.is_none()); assert!(this.queue.is_empty()); assert!(this.queue_edit.is_none()); - assert!(this.pending_permissions.is_empty()); }); } @@ -4483,14 +4372,6 @@ mod state_tests { this.booting = false; this.active_runs .insert("s1".to_string(), "run-1".to_string()); - this.pending_permissions.push(PendingPermission { - session_id: "s1".to_string(), - run_id: "run-1".to_string(), - request_id: "req-1".to_string(), - tool_name: "bash".to_string(), - prompt: None, - arguments: "".into(), - }); this.pending_questions = vec![crate::backend::PendingQuestion { session_id: "s1".to_string(), request_id: "question-1".to_string(), @@ -4511,7 +4392,6 @@ mod state_tests { this.active_runs.get("s1").map(String::as_str), Some("run-1") ); - assert_eq!(this.pending_permissions.len(), 1); assert_eq!(this.pending_questions.len(), 1); }); } @@ -4664,61 +4544,9 @@ mod state_tests { this.project_root = Some("/work/beta".to_string()); let request = this.new_session_request().expect("explicit root request"); assert_eq!(request.project_root.as_deref(), Some("/work/beta")); - assert_eq!( - request.mode.as_deref(), - Some("smart_approve"), - "a new task carries the composer's current mode" - ); - }); - } - - /// A saved "Allow all" default must reach task creation: the created - /// row's mode is what the chip adopts and the first prompt sends, so a - /// None mode here would restart every task at Ask First. - #[gpui::test] - fn test_new_task_takes_the_saved_permission_default(cx: &mut TestAppContext) { - let _guard = SETTINGS_LOCK.lock(); - let dir = std::env::temp_dir().join(format!( - "maple-agent-test-permission-default-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - let config = dir.join("maple-agent"); - std::fs::create_dir_all(&config).unwrap(); - std::fs::write( - config.join("settings.json"), - r#"{"default_permission_mode":"auto"}"#, - ) - .unwrap(); - let previous = std::env::var_os("XDG_CONFIG_HOME"); - unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) }; - cx.executor().allow_parking(); - let screen = cx.new(|cx| { - ChatScreen::new_inner( - std::sync::Arc::new( - crate::backend::AgentBackend::new( - "http://127.0.0.1:9".to_string(), - String::new(), - ) - .expect("backend"), - ), - "user".to_string(), - cx, - ) - }); - match previous { - Some(value) => unsafe { std::env::set_var("XDG_CONFIG_HOME", value) }, - None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, - } - let _ = std::fs::remove_dir_all(&dir); - - screen.update(cx, |this, _cx| { - this.project_root = Some("/work/beta".to_string()); - let request = this.new_session_request().expect("explicit root request"); - assert_eq!( - request.mode.as_deref(), - Some("auto"), - "the saved default must be persisted onto the new task" + assert!( + request.mode.is_none(), + "a new task names no permission mode" ); }); } diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 716218587..e520580f0 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -12,10 +12,8 @@ use maple_agent::agent::{ }; use super::cache::{MAX_DIFF_LINES, MarkdownKind}; -use super::commands::ChatCommand; use super::speech::speak_message_button; use super::{CONTENT_WIDTH, ChatScreen, QuestionSelection, TranscriptCtx}; -use crate::backend::PendingPermission; use crate::ui::icons::{icon, spinner, spinner_with_id}; use crate::ui::markdown; @@ -554,7 +552,7 @@ fn tool_status_style(status: Option<&str>) -> (&'static str, u32) { match status { Some("completed") => ("completed", theme::status_success()), Some("failed") | Some("error") => ("failed", theme::status_error()), - Some("cancelled") | Some("controlled_externally") => ("stopped", theme::text_muted()), + Some("cancelled") => ("stopped", theme::text_muted()), _ => ("running", theme::status_running()), } } @@ -929,7 +927,7 @@ fn render_tool( let (label, status_color) = tool_status_style(item.status.as_deref()); let running = !matches!( item.status.as_deref(), - Some("completed" | "failed" | "error" | "cancelled" | "controlled_externally") + Some("completed" | "failed" | "error" | "cancelled") ); let item_id = item.id.clone(); let chat_header = transcript.chat.clone(); @@ -1244,11 +1242,13 @@ fn render_error(item: &AgentTimelineItem) -> Div { widgets::banner(theme::status_error()).child(text) } +/// The row for an MCP elicitation ("Input requested"). It has no answer +/// path yet; the row shows whether the turn settled it. fn render_permission_row(item: &AgentTimelineItem) -> Div { let title = item .title .clone() - .unwrap_or_else(|| "Permission".to_string()); + .unwrap_or_else(|| "Input requested".to_string()); let status = match item.status.as_deref() { Some("completed") => ("allowed", theme::status_success()), Some("denied") | Some("cancelled") => ("denied", theme::text_muted()), @@ -1519,116 +1519,3 @@ pub(super) fn render_waiting_indicator() -> gpui::Stateful
{ .child("Maple is thinking"), ) } - -/// The card's heading names who is asking: Maple's own tools, or an -/// external agent whose request Maple relays. -pub(super) fn permission_card_heading(tool_name: &str) -> &'static str { - match tool_name { - "claude_tool" => "Claude Code wants to use a tool", - "codex_command" => "Codex wants to run a command", - "codex_file_change" => "Codex wants to change files", - _ => "Permission required", - } -} - -pub(super) fn render_permission_card( - permission: &PendingPermission, - responding: bool, - application_choice: Option, - cx: &mut Context, -) -> Div { - let description: SharedString = match permission.prompt.as_deref() { - Some(prompt) => prompt.to_string().into(), - None => format!("Run tool {}?", permission.tool_name).into(), - }; - let heading = permission_card_heading(&permission.tool_name); - let arguments: SharedString = permission.arguments.clone().into(); - let mut card = div() - .my_3() - .mx_auto() - .w_full() - .max_w(CONTENT_WIDTH - px(48.)) - .px_4() - .py_3() - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::permission_fill())) - .border_1() - .border_color(gpui::rgb(theme::permission_border())) - .flex() - .flex_col() - .gap_2() - .child( - div() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(gpui::rgb(theme::text_primary())) - .child(heading), - ) - .child( - div() - .text_sm() - .text_color(gpui::rgb(theme::text_secondary())) - .child(description), - ); - if !arguments.is_empty() { - card = card.child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .font_family(crate::assets::FONT_MONO) - .max_h(gpui::px(120.)) - .overflow_hidden() - .child(arguments), - ); - } - let mut buttons = div().flex().gap_2(); - for (index, (id, label, allow, color)) in [ - ( - "permission-allow-once", - "Allow once", - true, - theme::status_success(), - ), - ("permission-deny", "Deny", false, theme::status_error()), - ] - .into_iter() - .enumerate() - { - buttons = buttons.child( - div() - .id(id) - .px_4() - .py_1() - .rounded_full() - .font_weight(gpui::FontWeight::MEDIUM) - .when(application_choice == Some(index), |button| { - button - .border_2() - .border_color(gpui::rgb(theme::text_primary())) - }) - .bg(gpui::rgb(if responding { theme::border() } else { color })) - .text_sm() - .text_color(gpui::rgb(theme::on_accent())) - .when(!responding, |el| { - el.hover(|style| style.opacity(0.9).cursor_pointer()) - .active(|style| style.opacity(0.75)) - .on_click(cx.listener(move |this, _event, window, cx| { - this.execute_command( - ChatCommand::RespondPermission { allow }, - window, - cx, - ); - })) - }) - .child(label.to_string()), - ); - } - if responding { - card = card.child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child("Sending decision…"), - ); - } - card.child(buttons) -} diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 7a01cf701..24e53b1ec 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -20,7 +20,7 @@ use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; use crate::backend::AgentBackend; -use crate::settings::{self, AppSettings, PermissionMode, UsageSummary}; +use crate::settings::{self, AppSettings, UsageSummary}; use crate::shortcuts::{ ShortcutConflict, ShortcutConflictKind, ShortcutContextOverlap, ShortcutOverrides, ShortcutSnapshot, @@ -92,7 +92,6 @@ impl Section { /// toggling. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SettingMenu { - Permission, Appearance, ChatFont, ChatSize, @@ -104,7 +103,6 @@ impl SettingMenu { /// Stable id fragment for the popup panel and its rows. fn id(self) -> &'static str { match self { - Self::Permission => "permission", Self::Appearance => "appearance", Self::ChatFont => "chat-font", Self::ChatSize => "chat-size", @@ -768,13 +766,6 @@ impl SettingsScreen { /// share this order, so an index means the same option in both. fn menu_options(&self, menu: SettingMenu) -> Vec { match menu { - SettingMenu::Permission => [PermissionMode::SmartApprove, PermissionMode::Auto] - .iter() - .map(|&mode| SettingOption { - label: mode.label().to_string(), - current: self.settings.default_permission_mode == mode, - }) - .collect(), SettingMenu::Appearance => [ theme::Preference::System, theme::Preference::Dark, @@ -824,7 +815,6 @@ impl SettingsScreen { /// The saved value shown on the dropdown's trigger button. fn menu_value(&self, menu: SettingMenu) -> String { match menu { - SettingMenu::Permission => self.settings.default_permission_mode.label().to_string(), SettingMenu::Appearance => self.theme.label().to_string(), SettingMenu::ChatFont => { crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family) @@ -944,14 +934,6 @@ impl SettingsScreen { cx: &mut Context, ) { match menu { - SettingMenu::Permission => { - let Some(mode) = [PermissionMode::SmartApprove, PermissionMode::Auto].get(index) - else { - return; - }; - let mode = *mode; - self.edit_setting(move |settings| settings.default_permission_mode = mode, cx); - } SettingMenu::Appearance => { let Some(preference) = [ theme::Preference::System, @@ -1536,19 +1518,6 @@ impl SettingsScreen { Section::General => { pane = pane .child(section_title("Defaults")) - .child({ - let mode = self.settings.default_permission_mode; - self.application_target( - || SettingsTarget::General(GeneralTarget::Permission), - self.setting_menu_row( - "Default permission mode", - mode.note(), - SettingMenu::Permission, - window, - cx, - ), - ) - }) .child(self.application_target( || SettingsTarget::General(GeneralTarget::Web), toggle_row( diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 54aeb8af1..b8252d4dc 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -24,7 +24,6 @@ pub(super) enum SettingsRegion { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum GeneralTarget { - Permission, Web, Appearance, ChatFont, @@ -125,7 +124,6 @@ impl SettingsScreen { pub(super) fn visible_application_targets(&self) -> Vec { match self.section { Section::General => [ - GeneralTarget::Permission, GeneralTarget::Web, GeneralTarget::Appearance, GeneralTarget::ChatFont, @@ -370,7 +368,6 @@ impl SettingsScreen { fn activate_general_target(&mut self, target: GeneralTarget, cx: &mut Context) { match target { - GeneralTarget::Permission => self.toggle_setting_menu(SettingMenu::Permission, cx), GeneralTarget::Web => self.toggle_web_default(cx), GeneralTarget::Appearance => self.toggle_setting_menu(SettingMenu::Appearance, cx), GeneralTarget::ChatFont => self.toggle_setting_menu(SettingMenu::ChatFont, cx), @@ -857,10 +854,16 @@ mod tests { "entering the pane defers reveal until its selected row is rendered" ); + // General starts at the web toggle: there is no permission + // row to land on first. + assert_eq!( + this.visible_application_targets().first(), + Some(&SettingsTarget::General(GeneralTarget::Web)) + ); this.move_application_selection(1, 1, window, cx); assert_eq!( this.application_vim.target, - Some(SettingsTarget::General(GeneralTarget::Web)) + Some(SettingsTarget::General(GeneralTarget::Appearance)) ); assert!( this.application_reveal_pending, @@ -1009,6 +1012,27 @@ mod tests { (settings, cx) } + /// The General pane starts at the web toggle: there is no "Default + /// permission mode" row because every tool call runs without asking. + #[gpui::test] + fn general_settings_have_no_permission_row(cx: &mut TestAppContext) { + let (settings, cx) = dropdown_window(cx, true); + assert!( + cx.debug_bounds("setting-value-default-permission-mode") + .is_none() + ); + assert!( + cx.debug_bounds("setting-value-appearance").is_some(), + "the other General rows still render" + ); + settings.update(cx, |this, _| { + assert_eq!( + this.visible_application_targets().first(), + Some(&SettingsTarget::General(GeneralTarget::Web)) + ); + }); + } + #[gpui::test] fn dropdown_rows_select_highlight_and_close(cx: &mut TestAppContext) { let (settings, cx) = dropdown_window(cx, false); diff --git a/apps/maple-agent/app/src/ui/theme.rs b/apps/maple-agent/app/src/ui/theme.rs index 7e84fed43..e384e852a 100644 --- a/apps/maple-agent/app/src/ui/theme.rs +++ b/apps/maple-agent/app/src/ui/theme.rs @@ -154,7 +154,7 @@ tokens! { text_muted: 0x9c9dab, 0x6b6b6b; text_faint: 0x5e5f6e, 0xd4d4d4; - /// Maple coral (maple-500): send button, focus, caret, permission prompts. + /// Maple coral (maple-500): send button, focus, caret, question cards. accent: 0xff9771, 0xff9771; /// Hover for accent fills: maple-400 on dark, maple-600 on light. accent_hover: 0xffa88a, 0xf67d57; @@ -175,7 +175,7 @@ tokens! { /// Links take the tertiary Bark scale (bark-300 / bark-500). link: 0xc29a8d, 0x9e7469; - /// Permission card fill/border: the coral container and maple-300. + /// Elicitation row fill/border: the coral container and maple-300. permission_fill: 0x2a1a14, 0xffe8e0; permission_border: 0x784a38, 0xffbaa2;