1use 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
32pub const PERMISSION_BUFFER_NAME: &str = "*ai-permission*";
35
36const FIRST_OPTION_LINE: u32 = 2;
41
42#[derive(Default)]
45struct MenuState {
46 request_id: Option<String>,
47 option_ids: Vec<PermissionOptionId>,
48}
49
50pub type PermissionMenuCoordinatorHandle = Arc<PermissionMenuCoordinator>;
53
54#[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#[derive(Clone, Default)]
97pub struct AiPermissionMode {
98 menu: Arc<Mutex<MenuState>>,
99}
100
101pub 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 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 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 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
219fn 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
271fn 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 Some(Effect::DismissPopup)
286 })
287}
288
289fn 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
307pub 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
335pub 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
362async 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); 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 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"); 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 coord.set_open(true);
443 store.resolve_permission("perm-1", PermissionOptionId::new("allow-once"));
444 coord.set_open(false); 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 #[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 #[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}