Skip to main content

lattice_ai/acp/
permission_mode.rs

1//! `ai-permission-mode` — the ACP permission menu (PU-B).
2//!
3//! A momentary popup surface over a dynamic option list (design §5.3). The
4//! buffer is a `BufferData::Help` popup (so the popup renderer draws it) whose
5//! MAJOR mode is this one: the KIND names the surface, this mode names the
6//! behaviour. `on_activate` reads the oldest `Pending` permission from the
7//! [`ConversationStore`] and owner-writes the projection; `<CR>` on an option
8//! line resolves it (the file-tree / oil `entry_at_line` model), `Esc`/`q`
9//! defer. Resolution routes through
10//! [`ConversationStore::resolve_permission`](crate::acp::conversation::ConversationStore::resolve_permission)
11//! by the agent's `option_id` (PU-B.2a).
12//!
13//! v1 uses `<CR>`-by-cursor as the selector. The `1`–`9` digit accelerators the
14//! design also calls for need a count-override seam (bare digits are parsed as
15//! vim counts, `Action::PushDigit`, before any mode chord lookup), which no mode
16//! has today — deferred to a follow-up.
17
18use std::collections::HashSet;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::{Arc, Mutex, OnceLock};
21
22use agent_client_protocol::schema::v1::PermissionOptionId;
23use lattice_grammar::effect::Effect;
24use lattice_mode::{
25    ActionContext, ActionHandler, ActionHandlerContribution, BufferStoreHandle, CapabilitySet,
26    Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
27    keymap_entry,
28};
29
30use crate::acp::conversation::{ConversationStore, PendingPermissionView};
31
32/// The synthetic buffer name the popup menu is projected into. `:ai-permission`
33/// and the (PU-B.3) auto-opener both name it in `Effect::OpenPopup`.
34pub const PERMISSION_BUFFER_NAME: &str = "*ai-permission*";
35
36/// Projection layout: `title` then a blank line, so the first option line is
37/// row 2. `<CR>`-by-cursor maps `cursor.line - FIRST_OPTION_LINE` → option index.
38/// A `description`, when present, is rendered AFTER the options so this offset
39/// stays fixed.
40const FIRST_OPTION_LINE: u32 = 2;
41
42/// Menu state populated by `on_activate`, read by the select handler: the
43/// request id to resolve and the option ids in wire order (index → option).
44#[derive(Default)]
45struct MenuState {
46    request_id: Option<String>,
47    option_ids: Vec<PermissionOptionId>,
48}
49
50/// Service handle for `ServiceRegistry` lookup (the `Arc<T>` alias convention,
51/// `feedback_servicesregistry_arc_typeid`).
52pub type PermissionMenuCoordinatorHandle = Arc<PermissionMenuCoordinator>;
53
54/// PU-B.3: cross-cutting auto-open state shared by the mode (which sets it) and
55/// the install-time auto-open tick callback (which reads it). Registered as a
56/// service so both reach the same instance.
57///
58/// - `menu_open` gates the tick callback: it opens the next request only when no
59///   menu is showing (the `lsp.rs::open_next_queued_show_message_request`
60///   precedent). The mode's `on_activate` sets it true and its guard `Drop`
61///   (on dismiss / resolve) sets it false, so the queue advances on close.
62/// - `deferred` holds ids the user `Esc`-deferred; the tick callback skips them
63///   so a deferral is not immediately re-opened (the inline block + the explicit
64///   `:ai-permission` still surface them).
65#[derive(Default)]
66pub struct PermissionMenuCoordinator {
67    menu_open: AtomicBool,
68    deferred: Mutex<HashSet<String>>,
69}
70
71impl PermissionMenuCoordinator {
72    pub fn new() -> Self {
73        Self::default()
74    }
75    fn set_open(&self, open: bool) {
76        self.menu_open.store(open, Ordering::Relaxed);
77    }
78    fn is_open(&self) -> bool {
79        self.menu_open.load(Ordering::Relaxed)
80    }
81    fn defer(&self, id: &str) {
82        self.deferred
83            .lock()
84            .expect("permission deferred set poisoned")
85            .insert(id.to_string());
86    }
87    fn is_deferred(&self, id: &str) -> bool {
88        self.deferred
89            .lock()
90            .expect("permission deferred set poisoned")
91            .contains(id)
92    }
93}
94
95/// `ai-permission-mode`: the major mode of the `*ai-permission*` popup buffer.
96#[derive(Clone, Default)]
97pub struct AiPermissionMode {
98    menu: Arc<Mutex<MenuState>>,
99}
100
101/// PU-B.3: clears the coordinator's `menu_open` flag when the menu closes
102/// (dismiss tears the buffer down → removes the active mode → drops this guard),
103/// so the auto-open tick callback opens the next queued request.
104pub struct AiPermissionGuard {
105    coordinator: Option<PermissionMenuCoordinatorHandle>,
106}
107
108impl Drop for AiPermissionGuard {
109    fn drop(&mut self) {
110        if let Some(coordinator) = &self.coordinator {
111            coordinator.set_open(false);
112        }
113    }
114}
115
116impl AiPermissionMode {
117    pub fn new() -> Self {
118        Self::default()
119    }
120    pub fn mode_id() -> ModeId {
121        ModeId::new("ai-permission-mode")
122    }
123}
124
125impl Mode for AiPermissionMode {
126    type Guard = AiPermissionGuard;
127
128    fn id(&self) -> ModeId {
129        Self::mode_id()
130    }
131
132    fn kind(&self) -> ModeKind {
133        ModeKind::Major
134    }
135
136    /// The menu is a read-only, non-file surface — Insert / operators never edit
137    /// it; the projection is owner-written in `on_activate`.
138    fn options(&self) -> OptionOverrideSet {
139        lattice_config::overrides! {
140            lattice_config::ReadOnly = true,
141            lattice_config::NoFile = true,
142        }
143    }
144
145    fn required_capabilities(&self) -> CapabilitySet {
146        CapabilitySet::empty()
147    }
148
149    fn keymap(&self) -> Keymap {
150        Keymap::from_entries(ai_permission_keymap_entries())
151    }
152
153    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
154        vec![
155            ActionHandlerContribution {
156                action_name: "action:ai-perm-select",
157                handler: select_at_cursor_handler(self.menu.clone()),
158            },
159            ActionHandlerContribution {
160                action_name: "action:ai-perm-dismiss",
161                handler: dismiss_handler(self.menu.clone()),
162            },
163        ]
164    }
165
166    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
167        let menu = self.menu.clone();
168        Box::pin(async move {
169            // PU-B.3: mark the menu open so the auto-open tick callback holds the
170            // queue until this menu closes (guard `Drop` clears it). Set it even
171            // on the early-return paths below — the popup buffer still opened.
172            // `ctx.service::<Handle>()` yields `Arc<Handle>` (a double `Arc`);
173            // unwrap one layer to the shared coordinator.
174            let coordinator: Option<PermissionMenuCoordinatorHandle> = ctx
175                .service::<PermissionMenuCoordinatorHandle>()
176                .map(|outer| (*outer).clone());
177            if let Some(coordinator) = &coordinator {
178                coordinator.set_open(true);
179            }
180            let guard = AiPermissionGuard { coordinator };
181
182            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
183            let Some(store) = ctx.service::<BufferStoreHandle>() else {
184                return Ok(guard);
185            };
186            let Some(handle) = store.handle_for(buffer_id) else {
187                return Ok(guard);
188            };
189            let Some(conv_store) = ctx.service::<ConversationStore>() else {
190                return Ok(guard);
191            };
192            // Each open is a fresh buffer (dismiss tears the prior one down), so
193            // projecting the CURRENT oldest-pending request on every activation
194            // keeps the menu in sync as requests resolve.
195            let (text, state) = match conv_store.oldest_pending_permission() {
196                Some(pending) => {
197                    let state = MenuState {
198                        request_id: Some(pending.id.clone()),
199                        option_ids: pending
200                            .options
201                            .iter()
202                            .map(|o| o.option_id.clone())
203                            .collect(),
204                    };
205                    (project_permission(&pending), state)
206                }
207                None => (
208                    "No pending permission request.\n\n  Esc  close\n".to_string(),
209                    MenuState::default(),
210                ),
211            };
212            full_replace(&handle, &text).await;
213            *menu.lock().expect("permission menu mutex poisoned") = state;
214            Ok(guard)
215        })
216    }
217}
218
219/// Project the request into the popup buffer (design §5.3):
220/// ```text
221/// {title}
222///
223///   1  {option name}
224///   2  {option name}
225///
226///   Esc  decide later
227/// ```
228/// A `description`, when present, follows the options so `FIRST_OPTION_LINE`
229/// stays fixed for `<CR>`-by-cursor.
230fn project_permission(p: &PendingPermissionView) -> String {
231    let mut out = String::new();
232    out.push_str(&p.title);
233    out.push('\n');
234    out.push('\n');
235    for (i, opt) in p.options.iter().enumerate() {
236        out.push_str(&format!("  {}  {}\n", i + 1, opt.name));
237    }
238    out.push('\n');
239    if let Some(desc) = &p.description {
240        out.push_str(desc);
241        out.push('\n');
242        out.push('\n');
243    }
244    out.push_str("  Esc  decide later\n");
245    out
246}
247
248fn ai_permission_keymap_entries() -> &'static [KeymapEntry] {
249    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
250    ENTRIES.get_or_init(|| {
251        vec![
252            keymap_entry! {
253                mode: Normal, chord: "<CR>",
254                doc: "ai-permission: select the option under the cursor",
255                cmd: "action:ai-perm-select"
256            },
257            keymap_entry! {
258                mode: Normal, chord: "<Esc>",
259                doc: "ai-permission: defer the request (leave it pending)",
260                cmd: "action:ai-perm-dismiss"
261            },
262            keymap_entry! {
263                mode: Normal, chord: "q",
264                doc: "ai-permission: defer the request (leave it pending)",
265                cmd: "action:ai-perm-dismiss"
266            },
267        ]
268    })
269}
270
271/// `<CR>`: resolve the option on the cursor line (file-tree / oil
272/// `entry_at_line` model). Lines outside the option range (title, blanks, the
273/// `Esc` hint) map to no option, so the keystroke is a harmless no-op there.
274fn select_at_cursor_handler(menu: Arc<Mutex<MenuState>>) -> ActionHandler {
275    Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
276        let (id, option_id) = {
277            let guard = menu.lock().ok()?;
278            let id = guard.request_id.clone()?;
279            let index = ctx.cursor.line.checked_sub(FIRST_OPTION_LINE)? as usize;
280            (id, guard.option_ids.get(index)?.clone())
281        };
282        let store = ctx.services.get::<ConversationStore>()?;
283        store.resolve_permission(&id, option_id);
284        // A choice was made — close the menu.
285        Some(Effect::DismissPopup)
286    })
287}
288
289/// `Esc`/`q`: dismiss the popup WITHOUT resolving — the request stays `Pending`
290/// (the inline block keeps rendering it; `:ai-permission` reopens the menu).
291/// PU-B.3: record the request as deferred so the auto-open tick callback does
292/// not immediately re-open it (the queue skips past it to the next request).
293fn dismiss_handler(menu: Arc<Mutex<MenuState>>) -> ActionHandler {
294    Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
295        if let (Some(id), Some(coordinator)) = (
296            menu.lock().ok().and_then(|m| m.request_id.clone()),
297            ctx.services
298                .get::<PermissionMenuCoordinatorHandle>()
299                .map(|outer| (*outer).clone()),
300        ) {
301            coordinator.defer(&id);
302        }
303        Some(Effect::DismissPopup)
304    })
305}
306
307/// PU-B.3: the auto-open decision, run every editor tick by the install-time
308/// tick callback (`run_tick_pending` fires on the actor's `async_landed` wake —
309/// no keystroke, so a permission arriving while the user is idle opens the menu
310/// on its own). Opens the oldest non-deferred pending request when no menu is
311/// showing, and sets `menu_open` optimistically so it emits ONCE rather than
312/// every tick until `on_activate` lands. Returns the effects to apply.
313pub fn auto_open_tick(
314    conv_store: &ConversationStore,
315    coordinator: &PermissionMenuCoordinator,
316) -> Vec<Effect> {
317    if coordinator.is_open() {
318        return Vec::new();
319    }
320    if conv_store
321        .oldest_pending_permission_where(|id| !coordinator.is_deferred(id))
322        .is_none()
323    {
324        return Vec::new();
325    }
326    coordinator.set_open(true);
327    vec![Effect::OpenPopup {
328        name: PERMISSION_BUFFER_NAME.to_string(),
329        mode_id: AiPermissionMode::mode_id().as_str().to_string(),
330        placement: lattice_core::ui::popup::PopupPlacement::Centered,
331        focus: lattice_core::ui::popup::PopupFocus::Steal,
332    }]
333}
334
335/// Register the `ai-permission` action commands so the mode's keymap `cmd`
336/// names resolve at boot (the `register_ai_conversation_actions` pattern). The
337/// specs are pure shells returning `Effect::None`; the real bodies live in
338/// [`AiPermissionMode::action_handlers`], consulted before the CommandSpec.
339pub fn register_ai_permission_actions(registry: &mut lattice_grammar::CommandRegistry) {
340    use lattice_grammar::registry::ActionSpec;
341    for (name, doc) in [
342        (
343            "action:ai-perm-select",
344            "ai-permission: select the option under the cursor.",
345        ),
346        (
347            "action:ai-perm-dismiss",
348            "ai-permission: defer the request (leave it pending).",
349        ),
350    ] {
351        registry.register_action(
352            name,
353            doc,
354            ActionSpec {
355                apply: Arc::new(|_| Ok(Effect::None)),
356                args_schema: vec![],
357            },
358        );
359    }
360}
361
362/// Owner-write the whole buffer to `text` (a single full-range replace). The
363/// menu buffer is read-only to the user, so this bypasses the modal edit gate
364/// the same way the conversation drain seeds its transcript.
365async fn full_replace(handle: &std::sync::Arc<dyn lattice_runtime::Document>, text: &str) {
366    let snap = handle.snapshot();
367    let last_line = snap.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — whole-buffer extent
368    let last_len = snap.buffer.line(last_line).unwrap_or_default().len() as u32;
369    let range = lattice_protocol::Range::new(
370        lattice_protocol::position::Position::new(0, 0),
371        lattice_protocol::position::Position::new(last_line, last_len),
372    );
373    let edit = lattice_protocol::edit::Edit::replace(range, text.to_string());
374    let _ = handle.apply_edit_batch(vec![edit]).await;
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use agent_client_protocol::schema::v1::{PermissionOption, PermissionOptionKind};
381    use lattice_agent::SessionKey;
382
383    fn store_with_pending(ids: &[&str]) -> ConversationStore {
384        let store = ConversationStore::new(Arc::new(|_| {}));
385        for id in ids {
386            let (tx, _rx) = tokio::sync::oneshot::channel();
387            // Leak the receiver so the oneshot stays alive (the request stays
388            // pending) for the duration of the test.
389            std::mem::forget(_rx);
390            store.push_permission_request(
391                &SessionKey::new("opencode", 1),
392                id.to_string(),
393                "Allow?".to_string(),
394                None,
395                vec![PermissionOption::new(
396                    "allow-once",
397                    "Allow once",
398                    PermissionOptionKind::AllowOnce,
399                )],
400                tx,
401            );
402        }
403        store
404    }
405
406    #[test]
407    fn auto_open_emits_once_then_gates_on_menu_open() {
408        let store = store_with_pending(&["perm-1"]);
409        let coord = PermissionMenuCoordinator::new();
410
411        let first = auto_open_tick(&store, &coord);
412        assert!(
413            matches!(first.as_slice(), [Effect::OpenPopup { name, .. }] if name == PERMISSION_BUFFER_NAME),
414            "a pending request auto-opens the menu",
415        );
416        assert!(
417            coord.is_open(),
418            "menu_open set optimistically so it emits once"
419        );
420        assert!(
421            auto_open_tick(&store, &coord).is_empty(),
422            "gated while a menu is open — no repeat emit every tick",
423        );
424    }
425
426    #[test]
427    fn auto_open_skips_a_deferred_request() {
428        let store = store_with_pending(&["perm-1"]);
429        let coord = PermissionMenuCoordinator::new();
430        coord.defer("perm-1"); // user pressed Esc
431        assert!(
432            auto_open_tick(&store, &coord).is_empty(),
433            "an Esc-deferred request is not auto-reopened",
434        );
435    }
436
437    #[test]
438    fn auto_open_advances_to_next_pending_when_menu_closes() {
439        let store = store_with_pending(&["perm-1", "perm-2"]);
440        let coord = PermissionMenuCoordinator::new();
441        // perm-1's menu is open, then resolved → perm-1 no longer pending.
442        coord.set_open(true);
443        store.resolve_permission("perm-1", PermissionOptionId::new("allow-once"));
444        coord.set_open(false); // guard Drop on dismiss
445        assert!(
446            matches!(
447                auto_open_tick(&store, &coord).as_slice(),
448                [Effect::OpenPopup { .. }]
449            ),
450            "the next pending request opens once the menu closes",
451        );
452    }
453
454    #[test]
455    fn auto_open_noop_without_pending() {
456        let store = ConversationStore::new(Arc::new(|_| {}));
457        let coord = PermissionMenuCoordinator::new();
458        assert!(auto_open_tick(&store, &coord).is_empty());
459    }
460
461    fn view() -> PendingPermissionView {
462        PendingPermissionView {
463            id: "perm-1".to_string(),
464            title: "Allow `cargo test`?".to_string(),
465            description: None,
466            options: vec![
467                PermissionOption::new("allow-once", "Allow once", PermissionOptionKind::AllowOnce),
468                PermissionOption::new("reject-once", "Reject", PermissionOptionKind::RejectOnce),
469            ],
470        }
471    }
472
473    /// The projection's option rows MUST start at `FIRST_OPTION_LINE`, because
474    /// the `<CR>`-by-cursor handler maps `cursor.line - FIRST_OPTION_LINE` to the
475    /// option index. This pins the layout↔handler contract.
476    #[test]
477    fn projection_places_options_at_first_option_line() {
478        let text = project_permission(&view());
479        let lines: Vec<&str> = text.lines().collect();
480        assert_eq!(lines[0], "Allow `cargo test`?");
481        assert_eq!(lines[1], "");
482        assert_eq!(lines[FIRST_OPTION_LINE as usize], "  1  Allow once");
483        assert_eq!(lines[FIRST_OPTION_LINE as usize + 1], "  2  Reject");
484        assert!(text.contains("Esc  decide later"));
485    }
486
487    /// A description is rendered AFTER the options, so it never shifts
488    /// `FIRST_OPTION_LINE` and the cursor→index mapping stays correct.
489    #[test]
490    fn projection_renders_description_after_options() {
491        let mut v = view();
492        v.description = Some("Runs an arbitrary shell command.".to_string());
493        let text = project_permission(&v);
494        let lines: Vec<&str> = text.lines().collect();
495        assert_eq!(lines[FIRST_OPTION_LINE as usize], "  1  Allow once");
496        assert!(text.contains("Runs an arbitrary shell command."));
497    }
498}