1use 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
25pub fn register_ai_ex_commands(registry: &mut CommandRegistry, handle: AiClientHandle) {
28 let start = handle.clone();
29 registry.register_ex_command(
30 "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 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 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 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 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}