Skip to main content

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}