Skip to main content

lattice_ai/mcp/
commands.rs

1//! Ex-commands owned by the Claude Code IDE peer.
2//!
3//! `:claude-code-start` / `:claude-code-stop` control the IDE server's
4//! lifecycle. Per `feedback_mode_owns_its_surface`, BOTH the binding (the
5//! command name) AND the handler body live in this crate: the `apply`
6//! closure captures the [`ClaudeCodeServerHandle`] and drives it directly
7//! (a non-blocking `cmd_tx` send), returning an `Effect::Echo` for user
8//! feedback. The host's only role is calling
9//! [`register_claude_code_ex_commands`] once at boot.
10//!
11//! The names are registered **bare** (no `ex:` namespace prefix), so they
12//! resolve directly via `id_by_name` on the `:` line with no host
13//! alias-table entry — the command surface is fully crate-owned. They are
14//! `CommandKind::ExCommand`, so they enumerate in completion / `:apropos`
15//! and obey the dashed + namespaced naming rule (like `lsp-format`).
16//!
17//! Why an `apply` closure that captures a handle rather than a mode
18//! `ActionHandler`: the `:` line rejects `CommandKind::Action`
19//! (`excommand.rs`), and an ex-command `apply` (`Fn(&ExCommandContext) ->
20//! Effect`) gets no `services`, so it cannot reach the
21//! `ActionHandlerRegistry`. Capturing the subsystem handle is the
22//! mode-ownership-compliant route — it keeps the handler body in the
23//! crate without a new host `Effect` variant. See design §2.
24
25use 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
34/// Register `:claude-code-start` / `:claude-code-stop` against `registry`,
35/// wiring each to `server`. Called once from editor boot.
36pub 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    // I5.1: `:claude` — launch the agent CLI wired to this editor. Starts the
63    // IDE server (pre-bind → port), then emits `Effect::SpawnTerminal` so the
64    // host spawns `claude` in a terminal buffer with `CLAUDE_CODE_SSE_PORT` +
65    // `ENABLE_IDE_INTEGRATION` injected (so the agent connects back) and
66    // `claude-code-mode` activated. Mode-ownership-compliant: the binding +
67    // the body both live here; the host action is requested via the Effect
68    // vocabulary (the host boundary), not a bespoke channel.
69    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                    // PC.2: no override — spawn at the active buffer's
89                    // project root, which is what this always did.
90                    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    // I6.2: `:claude-send` (the `@`-mention) — push the current file + selected
105    // line range into the attached agent's context. Reads the crate-owned read
106    // cache (the active selection + its path) and broadcasts an `at_mentioned`
107    // notification frame to every connection via the server handle.
108    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                        // D-fix.6 follow-up: flash the `@sent` echo on the modeline.
137                        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    // D-fix.4: forward `<Esc>` to the focused `claude` terminal to interrupt
176    // the running agent. Required (not polish): pressing `<Esc>` directly is
177    // consumed by the terminal's modal layer (Insert→Normal, the desired
178    // flow), so it never reaches the PTY — this ex-command is the only
179    // interrupt path. Emits the host-owned `Effect::TerminalInput`, which the
180    // host writes to the active pane's terminal PTY.
181    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    /// The `:claude` apply ignores its context, so any valid one works.
210    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            // OC.10 added these four so a PLUGIN ex-command could name the
219            // buffer its `Effect::ApplyEdit` targets. Every command in this
220            // module is transport-level — it starts an agent, opens a log,
221            // toggles a server — and reads none of them, so they carry their
222            // empty forms rather than a fabricated cursor into a buffer this
223            // test never builds.
224            cursor: Default::default(),
225            buffer: Default::default(),
226            path: None,
227            syntax: None,
228            cancel: CancellationToken::new(),
229        }
230    }
231
232    /// I5.1: `:claude` starts the IDE server and emits an `Effect::SpawnTerminal`
233    /// that launches `claude` with `CLAUDE_CODE_SSE_PORT` (the bound port) +
234    /// `ENABLE_IDE_INTEGRATION=true` injected and `claude-code-mode` activated.
235    #[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                // PC.2: this arm does not act on the cwd.
260                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        // `:claude` started the server: it's now running on the bound port.
279        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    /// D-fix.4: `:claude-interrupt` emits `Effect::TerminalInput([0x1b])` to
286    /// forward an `<Esc>` interrupt to the focused claude terminal — the only
287    /// path, since typing `<Esc>` is consumed by the terminal's modal layer.
288    #[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    /// I6.2: `:claude-send` errors with no active selection, and once a
312    /// selection exists it broadcasts an at-mention (Info echo).
313    #[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        // No active selection → an error echo (nothing to send).
334        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        // Seed the read cache with an active selection.
340        {
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        // Now there's something to mention → Info echo.
357        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}