1use std::sync::Arc;
26
27use lattice_agent::parse_no_args;
28use lattice_grammar::command::LatencyClass;
29use lattice_grammar::effect::{EchoLevel, Effect};
30use lattice_grammar::registry::{CommandRegistry, ExCommandSpec, SurfaceForm};
31
32use crate::mcp::server::ClaudeCodeServerHandle;
33
34pub fn register_claude_code_ex_commands(
37 registry: &mut CommandRegistry,
38 server: ClaudeCodeServerHandle,
39) {
40 let start_server = server.clone();
41 registry.register_ex_command(
42 "claude-code-start",
43 "Start the Claude Code IDE server (loopback WebSocket + discovery \
44 lockfile) so an external `claude` CLI can attach.",
45 ExCommandSpec {
46 latency_class: LatencyClass::Reflex,
47 accepts_bang: false,
48 accepts_range: false,
49 parse_args: Arc::new(parse_no_args),
50 apply: Arc::new(move |_ctx| {
51 start_server.start();
52 Ok(Effect::Echo {
53 level: EchoLevel::Info,
54 text: "claude-code: starting IDE server".to_string(),
55 })
56 }),
57 args_schema: vec![],
58 surface_form: SurfaceForm::Keyword,
59 },
60 );
61
62 let claude_server = server.clone();
70 registry.register_ex_command(
71 "claude",
72 "Launch the `claude` agent CLI in a terminal buffer wired to this \
73 editor's IDE server: starts the server, injects CLAUDE_CODE_SSE_PORT + \
74 ENABLE_IDE_INTEGRATION, and activates claude-code-mode on the terminal.",
75 ExCommandSpec {
76 latency_class: LatencyClass::Reflex,
77 accepts_bang: false,
78 accepts_range: false,
79 parse_args: Arc::new(parse_no_args),
80 apply: Arc::new(move |_ctx| {
81 let Some(port) = claude_server.start() else {
82 return Ok(Effect::Echo {
83 level: EchoLevel::Error,
84 text: "claude: failed to start the IDE server".to_string(),
85 });
86 };
87 Ok(Effect::SpawnTerminal {
88 cwd: None,
91 cmd_line: Some("claude".to_string()),
92 env: vec![
93 ("CLAUDE_CODE_SSE_PORT".to_string(), port.to_string()),
94 ("ENABLE_IDE_INTEGRATION".to_string(), "true".to_string()),
95 ],
96 activate_minor: Some("claude-code-mode".to_string()),
97 })
98 }),
99 args_schema: vec![],
100 surface_form: SurfaceForm::Keyword,
101 },
102 );
103
104 let send_server = server.clone();
109 registry.register_ex_command(
110 "claude-send",
111 "Send the current file + selection to the attached `claude` agent as an \
112 @-mention (adds it to the agent's context).",
113 ExCommandSpec {
114 latency_class: LatencyClass::Reflex,
115 accepts_bang: false,
116 accepts_range: false,
117 parse_args: Arc::new(parse_no_args),
118 apply: Arc::new(move |_ctx| {
119 let cache = send_server.read_cache();
120 let frame = {
121 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
122 guard.active.as_ref().map(|active| {
123 let path = guard
124 .open_buffers
125 .get(&active.buffer)
126 .and_then(|b| b.path.clone());
127 crate::mcp::notifications::at_mentioned_frame(
128 &active.selections,
129 path.as_deref(),
130 )
131 })
132 };
133 match frame {
134 Some(f) => {
135 send_server.notify(f);
136 send_server.ping_mention();
138 Ok(Effect::Echo {
139 level: EchoLevel::Info,
140 text: "claude-send: sent the current selection".to_string(),
141 })
142 }
143 None => Ok(Effect::Echo {
144 level: EchoLevel::Error,
145 text: "claude-send: no active selection to send".to_string(),
146 }),
147 }
148 }),
149 args_schema: vec![],
150 surface_form: SurfaceForm::Keyword,
151 },
152 );
153
154 let stop_server = server;
155 registry.register_ex_command(
156 "claude-code-stop",
157 "Stop the Claude Code IDE server and remove its discovery lockfile.",
158 ExCommandSpec {
159 latency_class: LatencyClass::Reflex,
160 accepts_bang: false,
161 accepts_range: false,
162 parse_args: Arc::new(parse_no_args),
163 apply: Arc::new(move |_ctx| {
164 stop_server.stop();
165 Ok(Effect::Echo {
166 level: EchoLevel::Info,
167 text: "claude-code: stopping IDE server".to_string(),
168 })
169 }),
170 args_schema: vec![],
171 surface_form: SurfaceForm::Keyword,
172 },
173 );
174
175 registry.register_ex_command(
182 "claude-interrupt",
183 "Send `<Esc>` to the focused `claude` terminal to interrupt the running \
184 agent (typing `<Esc>` is consumed by the terminal's modal layer, so \
185 this is the way to forward an interrupt).",
186 ExCommandSpec {
187 latency_class: LatencyClass::Reflex,
188 accepts_bang: false,
189 accepts_range: false,
190 parse_args: Arc::new(parse_no_args),
191 apply: Arc::new(|_ctx| Ok(Effect::TerminalInput(vec![0x1b]))),
192 args_schema: vec![],
193 surface_form: SurfaceForm::Keyword,
194 },
195 );
196}
197
198#[cfg(test)]
199mod tests {
200 #![allow(clippy::unwrap_used, clippy::panic)]
201 use super::*;
202 use crate::mcp::server::{self, ServerConfig};
203 use lattice_grammar::args::Args;
204 use lattice_grammar::registry::ExCommandContext;
205 use lattice_grammar::{CancellationToken, Count, Register};
206 use lattice_runtime::EventBus;
207 use std::sync::Arc;
208
209 fn empty_ctx() -> ExCommandContext {
211 ExCommandContext {
212 bang: false,
213 args: Args::None,
214 range: None,
215 register: Register::default(),
216 count: Count::default(),
217 buffer_id: lattice_core::BufferId::default(),
218 cursor: Default::default(),
225 buffer: Default::default(),
226 path: None,
227 syntax: None,
228 cancel: CancellationToken::new(),
229 }
230 }
231
232 #[tokio::test]
236 async fn claude_command_starts_server_and_emits_spawn_terminal() {
237 let mut registry = CommandRegistry::new();
238 let handle = server::spawn(
239 ServerConfig {
240 workspace_folders: vec![],
241 lock_dir: std::env::temp_dir(),
242 },
243 Arc::new(EventBus::new()),
244 &tokio::runtime::Handle::current(),
245 );
246 register_claude_code_ex_commands(&mut registry, handle.clone());
247
248 let id = registry
249 .id_by_name("claude")
250 .expect("`:claude` is registered");
251 let spec = registry.ex_command_spec(id).expect("spec present");
252 let effect = (spec.apply)(&empty_ctx()).expect("apply ok");
253
254 match effect {
255 Effect::SpawnTerminal {
256 cmd_line,
257 env,
258 activate_minor,
259 cwd: _,
261 } => {
262 assert_eq!(cmd_line.as_deref(), Some("claude"));
263 let port = env
264 .iter()
265 .find(|(k, _)| k == "CLAUDE_CODE_SSE_PORT")
266 .expect("CLAUDE_CODE_SSE_PORT injected");
267 assert!(port.1.parse::<u16>().is_ok(), "port is numeric: {}", port.1);
268 assert!(
269 env.iter()
270 .any(|(k, v)| k == "ENABLE_IDE_INTEGRATION" && v == "true"),
271 "ENABLE_IDE_INTEGRATION=true injected"
272 );
273 assert_eq!(activate_minor.as_deref(), Some("claude-code-mode"));
274 }
275 other => panic!("expected SpawnTerminal, got {other:?}"),
276 }
277
278 let snap = handle.snapshot();
280 assert!(snap.running, "server running after :claude");
281 assert!(snap.port.is_some(), "bound port recorded");
282 handle.stop();
283 }
284
285 #[tokio::test]
289 async fn claude_interrupt_emits_esc_terminal_input() {
290 let mut registry = CommandRegistry::new();
291 let handle = server::spawn(
292 ServerConfig {
293 workspace_folders: vec![],
294 lock_dir: std::env::temp_dir(),
295 },
296 Arc::new(EventBus::new()),
297 &tokio::runtime::Handle::current(),
298 );
299 register_claude_code_ex_commands(&mut registry, handle.clone());
300 let id = registry
301 .id_by_name("claude-interrupt")
302 .expect("`:claude-interrupt` is registered");
303 let spec = registry.ex_command_spec(id).expect("spec present");
304 match (spec.apply)(&empty_ctx()).expect("apply ok") {
305 Effect::TerminalInput(bytes) => assert_eq!(bytes, vec![0x1b]),
306 other => panic!("expected TerminalInput([0x1b]), got {other:?}"),
307 }
308 handle.stop();
309 }
310
311 #[tokio::test]
314 async fn claude_send_requires_an_active_selection() {
315 use lattice_protocol::ids::DocumentId;
316 use lattice_protocol::{Event, SelectionSet};
317
318 let mut registry = CommandRegistry::new();
319 let handle = server::spawn(
320 ServerConfig {
321 workspace_folders: vec![],
322 lock_dir: std::env::temp_dir(),
323 },
324 Arc::new(EventBus::new()),
325 &tokio::runtime::Handle::current(),
326 );
327 register_claude_code_ex_commands(&mut registry, handle.clone());
328 let id = registry
329 .id_by_name("claude-send")
330 .expect("`:claude-send` is registered");
331 let spec = registry.ex_command_spec(id).expect("spec present");
332
333 match (spec.apply)(&empty_ctx()).expect("apply ok") {
335 Effect::Echo { level, .. } => assert_eq!(level, EchoLevel::Error),
336 other => panic!("expected an echo, got {other:?}"),
337 }
338
339 {
341 let cache = handle.read_cache();
342 let mut g = cache.lock().unwrap();
343 g.apply_event(&Event::DocumentOpened {
344 id: DocumentId::new(1),
345 path: Some(std::path::PathBuf::from("/work/a.rs")),
346 version: 1,
347 text: String::new(),
348 });
349 g.apply_event(&Event::SelectionsChanged {
350 id: DocumentId::new(1),
351 version: 1,
352 selections: SelectionSet::default(),
353 });
354 }
355
356 match (spec.apply)(&empty_ctx()).expect("apply ok") {
358 Effect::Echo { level, .. } => assert_eq!(level, EchoLevel::Info),
359 other => panic!("expected an echo, got {other:?}"),
360 }
361 handle.stop();
362 }
363}