Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` |
Expand Down
54 changes: 1 addition & 53 deletions apps/maple-agent/app/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,6 @@ pub struct PendingQuestion {
pub questions: Vec<maple_agent::agent::AgentQuestion>,
}

#[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<String>,
/// Pretty-printed tool arguments, formatted once when the request
/// arrives instead of on every frame.
pub arguments: Arc<str>,
}

// 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::{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 7 additions & 10 deletions apps/maple-agent/app/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ enum SlotAction {
ChooseProject,
PreviousTask,
NextTask,
AllowPermission,
PickQuestionOption(usize),
CopySelection,
SelectAllTranscript,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)] = &[
Expand Down Expand Up @@ -405,14 +410,6 @@ fn catalog_for(os: HostOs) -> Vec<ShortcutSlot> {
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(
Expand Down
125 changes: 19 additions & 106 deletions apps/maple-agent/app/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,8 +38,8 @@ pub struct AppSettings {
/// Display names for project roots, keyed by absolute path.
#[serde(default)]
pub project_names: std::collections::HashMap<String, String>,
/// 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
Expand Down Expand Up @@ -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<Self> {
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}

impl<'de> serde::Deserialize<'de> for PermissionMode {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = <String as serde::Deserialize>::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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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]
Expand Down
40 changes: 36 additions & 4 deletions apps/maple-agent/app/src/shortcuts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Option<String>>;

Expand Down Expand Up @@ -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 {
Expand All @@ -181,7 +185,9 @@ pub(super) fn prepare(overrides: &ShortcutOverrides) -> Result<PreparedShortcuts
let unknown = overrides
.keys()
.filter(|id| {
!known_ids.contains(id.as_str()) && !RENAMED_SLOT_IDS.iter().any(|(old, _)| old == id)
!known_ids.contains(id.as_str())
&& !RENAMED_SLOT_IDS.iter().any(|(old, _)| old == id)
&& !RETIRED_SLOT_IDS.contains(&id.as_str())
})
.cloned()
.collect::<Vec<_>>();
Expand Down Expand Up @@ -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<_>>(),
vec![(
"chat.new_task".to_string(),
Some("secondary-shift-n".to_string())
)]
);
}

#[test]
fn native_equivalent_spelling_is_an_exact_conflict() {
let snapshot = ShortcutSnapshot {
Expand Down
Loading
Loading