Skip to main content

lattice_ai/acp/
commands.rs

1//! ACP lifecycle ex-commands: `:opencode` / `:ai-prompt` / `:ai-stop`.
2//!
3//! Per `feedback_mode_owns_its_surface`, BOTH the binding (the command name)
4//! AND the handler body live here: each `apply` closure captures the
5//! [`AiClientHandle`](crate::acp::handle::AiClientHandle) and drives it directly
6//! (a non-blocking `cmd_tx` send), returning an `Effect::Echo` for user
7//! feedback. The host's only role is calling [`register_ai_ex_commands`] once at
8//! boot (from `crate::acp::install`).
9//!
10//! The transport-neutral `:ai-log` command is NOT here -- it lives in the
11//! crate-root `commands` module (`register_ai_log_command`), because the log
12//! substrate is shared by every transport (AG‑3).
13
14use std::sync::Arc;
15
16use lattice_agent::{parse_no_args, parse_rest_as_text};
17use lattice_grammar::args::Args;
18use lattice_grammar::command::LatencyClass;
19use lattice_grammar::effect::{EchoLevel, Effect};
20use lattice_grammar::registry::{CommandRegistry, ExCommandSpec, SurfaceForm};
21
22use crate::acp::handle::AiClientHandle;
23use crate::acp::providers::ProviderConfig;
24
25/// Register `:opencode` / `:ai-prompt` / `:ai-stop` against `registry`,
26/// wiring each to `handle`. Called once from ACP boot (`crate::acp::install`).
27pub fn register_ai_ex_commands(registry: &mut CommandRegistry, handle: AiClientHandle) {
28    let start = handle.clone();
29    registry.register_ex_command(
30        // The primary opencode integration: lattice drives `opencode acp`
31        // headlessly and renders the conversation itself, because opencode's
32        // native TUI (opentui) needs modern-terminal image/capability features
33        // lattice's emulator doesn't provide, so the terminal path can't render
34        // it. That terminal spawn is kept as `:opencode-term` (see
35        // `crate::opencode`) for agents whose TUIs degrade gracefully.
36        "opencode",
37        "Launch the opencode agent over ACP and open the *ai:opencode* \
38         conversation buffer wired to this editor.",
39        ExCommandSpec {
40            latency_class: LatencyClass::Reflex,
41            accepts_bang: false,
42            accepts_range: false,
43            parse_args: Arc::new(parse_no_args),
44            apply: Arc::new(move |_ctx| {
45                start.start(ProviderConfig::opencode());
46                // Start AND open the conversation buffer (the user's decision):
47                // a generic open of the `*ai:opencode*` synthetic buffer under
48                // the `ai-conversation` mode. The mode's `on_activate` seeds +
49                // live-tails from the ConversationStore.
50                Ok(Effect::OpenSyntheticBuffer {
51                    name: crate::acp::conversation_mode::conversation_buffer_name(),
52                    mode_id: crate::acp::conversation_mode::AiConversationMode::mode_id()
53                        .as_str()
54                        .to_string(),
55                    content: None,
56                    cursor: None,
57                    activate_minor: None,
58                })
59            }),
60            args_schema: vec![],
61            surface_form: SurfaceForm::Keyword,
62        },
63    );
64
65    let prompt = handle.clone();
66    registry.register_ex_command(
67        "ai-prompt",
68        "Send the rest of the line to the running AI agent as a prompt.",
69        ExCommandSpec {
70            latency_class: LatencyClass::Reflex,
71            accepts_bang: false,
72            accepts_range: false,
73            parse_args: Arc::new(parse_rest_as_text),
74            apply: Arc::new(move |ctx| match &ctx.args {
75                Args::String(t) if !t.is_empty() => {
76                    prompt.prompt(t.clone());
77                    Ok(Effect::Echo {
78                        level: EchoLevel::Info,
79                        text: "ai-prompt: sent".to_string(),
80                    })
81                }
82                _ => Ok(Effect::Echo {
83                    level: EchoLevel::Error,
84                    text: "ai-prompt: empty prompt".to_string(),
85                }),
86            }),
87            args_schema: vec![],
88            surface_form: SurfaceForm::Keyword,
89        },
90    );
91
92    let stop = handle;
93    registry.register_ex_command(
94        "ai-stop",
95        "Stop the running AI agent and close its session.",
96        ExCommandSpec {
97            latency_class: LatencyClass::Reflex,
98            accepts_bang: false,
99            accepts_range: false,
100            parse_args: Arc::new(parse_no_args),
101            apply: Arc::new(move |_ctx| {
102                stop.stop();
103                Ok(Effect::Echo {
104                    level: EchoLevel::Info,
105                    text: "ai: stopping agent".to_string(),
106                })
107            }),
108            args_schema: vec![],
109            surface_form: SurfaceForm::Keyword,
110        },
111    );
112
113    // PU-B: open the permission menu for the oldest pending request. Data-only
114    // — the host ensures the `*ai-permission*` popup buffer under
115    // `ai-permission-mode` and its `on_activate` projects the request. Steal
116    // focus (the agent is blocked awaiting the decision); `Esc` defers.
117    registry.register_ex_command(
118        "ai-permission",
119        "Open the permission menu for the oldest pending agent request.",
120        ExCommandSpec {
121            latency_class: LatencyClass::Reflex,
122            accepts_bang: false,
123            accepts_range: false,
124            parse_args: Arc::new(parse_no_args),
125            apply: Arc::new(|_ctx| {
126                Ok(Effect::OpenPopup {
127                    name: crate::acp::permission_mode::PERMISSION_BUFFER_NAME.to_string(),
128                    mode_id: crate::acp::permission_mode::AiPermissionMode::mode_id()
129                        .as_str()
130                        .to_string(),
131                    placement: lattice_core::ui::popup::PopupPlacement::Centered,
132                    focus: lattice_core::ui::popup::PopupFocus::Steal,
133                })
134            }),
135            args_schema: vec![],
136            surface_form: SurfaceForm::Keyword,
137        },
138    );
139}
140
141#[cfg(test)]
142mod tests {
143    #![allow(clippy::unwrap_used, clippy::panic)]
144    use super::*;
145    use crate::acp::handle::{AiCmd, AiState};
146    use arc_swap::ArcSwap;
147    use lattice_grammar::registry::ExCommandContext;
148    use lattice_grammar::{CancellationToken, Count, Register};
149    use std::sync::Arc;
150    use std::sync::atomic::AtomicUsize;
151
152    fn test_handle() -> (AiClientHandle, tokio::sync::mpsc::UnboundedReceiver<AiCmd>) {
153        let (cmd_tx, rx) = tokio::sync::mpsc::unbounded_channel();
154        (
155            AiClientHandle {
156                cmd_tx,
157                state: Arc::new(ArcSwap::from_pointee(AiState::default())),
158                queue_len: Arc::new(AtomicUsize::new(0)),
159            },
160            rx,
161        )
162    }
163
164    fn ctx_with(args: Args) -> ExCommandContext {
165        ExCommandContext {
166            bang: false,
167            args,
168            range: None,
169            register: Register::default(),
170            count: Count::default(),
171            buffer_id: lattice_core::BufferId::default(),
172            // OC.10 added these four so a PLUGIN ex-command could name the
173            // buffer its `Effect::ApplyEdit` targets. Every command in this
174            // module is transport-level — it starts an agent, opens a log,
175            // toggles a server — and reads none of them, so they carry their
176            // empty forms rather than a fabricated cursor into a buffer this
177            // test never builds.
178            cursor: Default::default(),
179            buffer: Default::default(),
180            path: None,
181            syntax: None,
182            cancel: CancellationToken::new(),
183        }
184    }
185
186    #[test]
187    fn opencode_starts_and_opens_the_conversation_buffer() {
188        let (handle, mut rx) = test_handle();
189        let mut registry = CommandRegistry::new();
190        register_ai_ex_commands(&mut registry, handle);
191
192        let id = registry
193            .id_by_name("opencode")
194            .expect("`:opencode` is registered");
195        let spec = registry.ex_command_spec(id).expect("spec present");
196        let effect = (spec.apply)(&ctx_with(Args::None)).expect("apply ok");
197
198        // `:opencode` both starts the agent AND opens the `*ai:opencode*`
199        // conversation buffer under the `ai-conversation` mode.
200        match effect {
201            Effect::OpenSyntheticBuffer { name, mode_id, .. } => {
202                assert_eq!(name, "*ai:opencode*");
203                assert_eq!(mode_id, "ai-conversation-mode");
204            }
205            other => panic!("expected OpenSyntheticBuffer, got {other:?}"),
206        }
207        match rx.try_recv().expect("AiCmd sent") {
208            AiCmd::Start(_) => {}
209            _ => panic!("expected AiCmd::Start"),
210        }
211    }
212
213    #[test]
214    fn ai_prompt_sends_text() {
215        let (handle, mut rx) = test_handle();
216        let mut registry = CommandRegistry::new();
217        register_ai_ex_commands(&mut registry, handle);
218
219        let id = registry
220            .id_by_name("ai-prompt")
221            .expect("`:ai-prompt` is registered");
222        let spec = registry.ex_command_spec(id).expect("spec present");
223        let effect = (spec.apply)(&ctx_with(Args::String("hi".to_string()))).expect("apply ok");
224
225        match effect {
226            Effect::Echo { level, .. } => assert_eq!(level, EchoLevel::Info),
227            other => panic!("expected an Echo, got {other:?}"),
228        }
229        match rx.try_recv().expect("AiCmd sent") {
230            AiCmd::Prompt(t) => assert_eq!(t, "hi"),
231            _ => panic!("expected AiCmd::Prompt"),
232        }
233    }
234
235    #[test]
236    fn ai_prompt_rejects_empty() {
237        let (handle, _rx) = test_handle();
238        let mut registry = CommandRegistry::new();
239        register_ai_ex_commands(&mut registry, handle);
240
241        let id = registry
242            .id_by_name("ai-prompt")
243            .expect("`:ai-prompt` is registered");
244        let spec = registry.ex_command_spec(id).expect("spec present");
245        let effect = (spec.apply)(&ctx_with(Args::String(String::new()))).expect("apply ok");
246
247        match effect {
248            Effect::Echo { level, .. } => assert_eq!(level, EchoLevel::Error),
249            other => panic!("expected an Echo, got {other:?}"),
250        }
251    }
252
253    #[test]
254    fn ai_stop_stops() {
255        let (handle, mut rx) = test_handle();
256        let mut registry = CommandRegistry::new();
257        register_ai_ex_commands(&mut registry, handle);
258
259        let id = registry
260            .id_by_name("ai-stop")
261            .expect("`:ai-stop` is registered");
262        let spec = registry.ex_command_spec(id).expect("spec present");
263        let effect = (spec.apply)(&ctx_with(Args::None)).expect("apply ok");
264
265        match effect {
266            Effect::Echo { level, .. } => assert_eq!(level, EchoLevel::Info),
267            other => panic!("expected an Echo, got {other:?}"),
268        }
269        match rx.try_recv().expect("AiCmd sent") {
270            AiCmd::Stop => {}
271            _ => panic!("expected AiCmd::Stop"),
272        }
273    }
274}