lattice_ai/mcp/modes.rs
1//! `claude-code-mode` — the minor mode layered on the IDE terminal buffer.
2//!
3//! I1 registers it as a Manual-activation marker mode (no per-buffer
4//! resources). In I5 it becomes the minor mode `:claude` activates on the
5//! `BufferKind::Terminal` buffer running the agent: its `on_activate` will
6//! ensure the server is running, contribute the headerline status row, and
7//! own the diff affordances (design §3). The `-mode` suffix matches the
8//! `ModeRegistry` naming convention (enforced at registration).
9
10use lattice_mode::registry::ModeRegistry;
11use lattice_mode::{
12 ActivationPolicy, Keymap, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
13};
14
15use crate::mcp::server::ClaudeCodeServerHandle;
16
17/// The Claude Code IDE minor mode.
18pub struct ClaudeCodeMode;
19
20impl ClaudeCodeMode {
21 /// The mode id (`claude-code-mode`).
22 pub fn mode_id() -> ModeId {
23 ModeId::new("claude-code-mode")
24 }
25}
26
27/// I7: per-activation Guard. While `claude-code-mode` is active on a buffer,
28/// that buffer shows the `claude-code` modeline status segment; the Guard's
29/// `Drop` (on deactivate / buffer close) unregisters it so the segment clears.
30/// `None` when the server handle wasn't available at activation (e.g. tests
31/// without the service) — then the Guard is an inert no-op.
32pub struct ClaudeCodeStatusGuard {
33 inner: Option<(ClaudeCodeServerHandle, lattice_core::BufferId)>,
34}
35
36impl Drop for ClaudeCodeStatusGuard {
37 fn drop(&mut self) {
38 if let Some((handle, buffer)) = &self.inner {
39 handle.unregister_status_buffer(*buffer);
40 }
41 }
42}
43
44impl Mode for ClaudeCodeMode {
45 type Guard = ClaudeCodeStatusGuard;
46
47 fn id(&self) -> ModeId {
48 Self::mode_id()
49 }
50
51 fn kind(&self) -> ModeKind {
52 ModeKind::Minor
53 }
54
55 /// Manual — activated explicitly by `:claude` (I5) on the agent terminal.
56 fn activation_policy(&self) -> ActivationPolicy {
57 ActivationPolicy::Manual
58 }
59
60 /// K.2.4 path: the `<C-c>` interrupt chord, contributed by the mode
61 /// itself and picked up by `translate_mode_keymaps` at boot. Resolves
62 /// `"ex:claude-interrupt"` against the `CommandRegistry` (registered in
63 /// `crate::mcp::commands`). K.1.c gates it to `claude-code-mode` buffers, and —
64 /// because it binds in `Normal` binding-mode — it only fires in
65 /// Normal-in-terminal; in Terminal-Insert `<C-c>` still reaches the shell
66 /// as SIGINT. This is the one-key complement to the `:claude-interrupt`
67 /// ex-command once you've dropped into Normal-in-terminal with `<Esc>`.
68 fn keymap(&self) -> Keymap {
69 Keymap::from_entries(claude_code_keymap_entries())
70 }
71
72 /// I7: register this buffer to show the IDE status segment. The server
73 /// handle is a boot service; absent it (tests), the mode degrades to a
74 /// no-op Guard. The status content itself is published off-thread by the
75 /// crate's status publisher (`crate::mcp::status`).
76 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, ClaudeCodeStatusGuard> {
77 Box::pin(async move {
78 // Register the `claude-code` modeline descriptor (idempotent,
79 // last-write-wins). Done here, not at install, because the host
80 // registers the `ModelineServiceHandle` after the Phase-B install
81 // list runs — by activation time (runtime) it is present.
82 if let Some(svc) = ctx.service::<lattice_mode::ModelineServiceHandle>() {
83 crate::mcp::status::register_status_descriptor(&svc);
84 }
85 let buffer = lattice_core::BufferId(ctx.buffer_id().0 as u32);
86 let inner = ctx.service::<ClaudeCodeServerHandle>().map(|handle| {
87 handle.register_status_buffer(buffer);
88 ((*handle).clone(), buffer)
89 });
90 Ok(ClaudeCodeStatusGuard { inner })
91 })
92 }
93}
94
95/// The `claude-code-mode` keymap entries. Contributed through
96/// [`ClaudeCodeMode::keymap`] and resolved against the `CommandRegistry` by
97/// the host's K.2.4 translate pass, which pushes them under
98/// `MinorMode(claude-code-mode)`. K.1.c gates the chords to buffers where
99/// `claude-code-mode` is active; the `Normal` binding-mode scopes `<C-c>` to
100/// Normal-in-terminal (in Terminal-Insert `<C-c>` still reaches the shell as
101/// SIGINT). An unregistered `cmd` name is dropped by the translate pass with
102/// a `warn!` — never a boot panic (graceful degradation).
103fn claude_code_keymap_entries() -> &'static [lattice_mode::KeymapEntry] {
104 use std::sync::OnceLock;
105 static ENTRIES: OnceLock<Vec<lattice_mode::KeymapEntry>> = OnceLock::new();
106 ENTRIES.get_or_init(|| {
107 vec![lattice_mode::keymap_entry! {
108 mode: Normal, chord: "<C-c>",
109 doc: "Interrupt the running Claude agent (sends <Esc> to the terminal)",
110 cmd: "ex:claude-interrupt"
111 }]
112 })
113}
114
115/// Register `claude-code-mode` against `registry`. Called from editor boot.
116pub fn register_claude_code_modes(registry: &mut ModeRegistry) {
117 registry
118 .register(ClaudeCodeMode)
119 .expect("claude-code-mode register");
120}
121
122#[cfg(test)]
123mod keymap_tests {
124 use super::claude_code_keymap_entries;
125
126 /// The mode binds exactly `<C-c>` (Normal) → the `claude-interrupt`
127 /// ex-command. Catches a chord swap, a name drift, or an extra/dropped
128 /// binding. The end-to-end resolution on the `MinorMode(claude-code-mode)`
129 /// layer is the host's K.2.4 translate pass (shared with tutor / diff);
130 /// this pins the mode's contribution.
131 #[test]
132 fn ctrl_c_binds_to_claude_interrupt_ex_command() {
133 let pairs: Vec<(&str, Option<&str>)> = claude_code_keymap_entries()
134 .iter()
135 .map(|e| (e.chord, e.command))
136 .collect();
137 assert_eq!(pairs, vec![("<C-c>", Some("ex:claude-interrupt"))]);
138 }
139}