Skip to main content

lattice_terminal/
modes.rs

1use std::sync::Arc;
2
3use lattice_config::OptionOverrideSet;
4use lattice_core::{BufferId, BufferKind};
5use lattice_mode::{
6    CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, ModeRegistry,
7};
8
9use crate::synthetic::TerminalStoreHandle;
10
11pub struct TerminalMode;
12
13impl TerminalMode {
14    pub fn mode_id() -> ModeId {
15        ModeId::new("terminal-mode")
16    }
17}
18
19impl Mode for TerminalMode {
20    type Guard = ();
21    fn id(&self) -> ModeId {
22        Self::mode_id()
23    }
24    fn kind(&self) -> ModeKind {
25        ModeKind::Major
26    }
27    /// H.2: terminal buffers (`BufferKind::Terminal`) dispatch to
28    /// this major via the registry's kind index.
29    fn target_buffer_kind(&self) -> Option<BufferKind> {
30        Some(BufferKind::Terminal)
31    }
32    fn options(&self) -> OptionOverrideSet {
33        // Terminal buffers are PTY-backed cell grids, not
34        // on-disk files: `:q` must not warn about unsaved
35        // changes, `:w` is a no-op. Mutation flows through the
36        // PTY stdin path (T2), not the rope-operator path; we
37        // flag read-only here so the dispatcher rejects naive
38        // text inserts in Normal-in-terminal until T2's encoder
39        // gate is in place.
40        lattice_config::overrides! {
41            lattice_config::ReadOnly = true,
42            lattice_config::NoFile = true,
43        }
44    }
45    fn required_capabilities(&self) -> CapabilitySet {
46        CapabilitySet::empty()
47    }
48    /// 2026-05-26: claim invocation dispatch for terminal panes.
49    /// The host's runner registry maps `terminal-mode` to
50    /// `Editor::run_terminal_invocation`; `Editor::run_invocation`
51    /// looks the runner up via this hook instead of branching
52    /// on `BufferKind::Terminal`.
53    fn invocation_runner(&self) -> Option<ModeId> {
54        Some(Self::mode_id())
55    }
56    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
57        Box::pin(async { Ok(()) })
58    }
59}
60
61/// Terminal-mode T2.a (2026-05-25): the minor mode that, when
62/// active on a Terminal buffer, switches the translate layer
63/// from vim-grammar-over-scrollback to keystroke-encoded-PTY-input.
64///
65/// Conceptually analogous to Insert mode but scoped per buffer
66/// (a minor) rather than globally (a `ModalState` variant): the
67/// editor's modal state stays `Normal` underneath, and pane
68/// switches automatically pick up the destination buffer's mode
69/// set — no implicit auto-Esc handshake when leaving the
70/// terminal pane mid-Insert.
71///
72/// Entry chord: `i` (Normal-in-terminal). Exit chord:
73/// `<C-\><C-n>`. T2.b adds `a` / `I` / `A` entry variants and
74/// the optional `<Esc>` exit gated by `terminal.esc_exits`.
75pub struct TerminalInsertMode;
76
77impl TerminalInsertMode {
78    pub fn mode_id() -> ModeId {
79        ModeId::new("terminal-insert-mode")
80    }
81}
82
83impl Mode for TerminalInsertMode {
84    type Guard = ();
85    fn id(&self) -> ModeId {
86        Self::mode_id()
87    }
88    fn kind(&self) -> ModeKind {
89        ModeKind::Minor
90    }
91    fn options(&self) -> OptionOverrideSet {
92        // No option contributions — the mode is a pure
93        // translate-layer discriminator. Read-only / NoFile
94        // already come from the underlying terminal-mode major.
95        OptionOverrideSet::default()
96    }
97    fn required_capabilities(&self) -> CapabilitySet {
98        CapabilitySet::empty()
99    }
100    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
101        Box::pin(async { Ok(()) })
102    }
103}
104
105/// T-mode-1 (2026-05-27): the minor mode that runs the central
106/// vim grammar against a synthetic, read-only Document built from
107/// the terminal's scrollback. Mutually exclusive with
108/// [`TerminalInsertMode`] — the host's transition path
109/// (`do_enter_terminal_insert` / `do_exit_terminal_insert`)
110/// flips between them.
111///
112/// Lifecycle:
113/// - `on_activate`: pull `TerminalStoreHandle` from services,
114///   call `install_synthetic(buffer_id)`. The store builds the
115///   SyntheticDoc via `SharedTerm::build_normal_snapshot` and
116///   stashes it on the `TerminalBuffer`.
117/// - Drop the returned [`TerminalNormalModeGuard`]: call
118///   `clear_synthetic(buffer_id)`. The buffer's `synthetic`
119///   field goes back to `None`; PTY output resumes feeding
120///   alacritty normally on Insert re-entry.
121///
122/// When the service is unavailable (test harness without
123/// registry wiring), `on_activate` succeeds with a no-op Guard
124/// — the mode is still "active" for the cascade's sake but no
125/// rope build happens. Matches `LspMode`'s graceful-degradation
126/// shape.
127///
128/// See `docs/dev/architecture/terminal-as-document.md` §3.6 for
129/// the architectural framing.
130pub struct TerminalNormalMode;
131
132impl TerminalNormalMode {
133    pub fn mode_id() -> ModeId {
134        ModeId::new("terminal-normal-mode")
135    }
136}
137
138impl Mode for TerminalNormalMode {
139    type Guard = TerminalNormalModeGuard;
140    fn id(&self) -> ModeId {
141        Self::mode_id()
142    }
143    fn kind(&self) -> ModeKind {
144        ModeKind::Minor
145    }
146    fn options(&self) -> OptionOverrideSet {
147        // No option contributions — read-only / NoFile already
148        // come from the underlying `terminal-mode` major.
149        OptionOverrideSet::default()
150    }
151    fn required_capabilities(&self) -> CapabilitySet {
152        CapabilitySet::empty()
153    }
154    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
155        Box::pin(async move {
156            let buf_id = BufferId(ctx.buffer_id().0 as u32);
157            let store = ctx.service::<TerminalStoreHandle>();
158            if let Some(store) = store.as_ref() {
159                // Build + stash the SyntheticDoc. The store
160                // returns `false` if no terminal buffer exists
161                // for this id — we treat that as a graceful
162                // no-op (mode still activates), matching the
163                // LspMode shape.
164                store.install_synthetic(buf_id);
165            }
166            Ok(TerminalNormalModeGuard {
167                store,
168                buffer_id: buf_id,
169            })
170        })
171    }
172}
173
174/// Guard returned from [`TerminalNormalMode::on_activate`].
175/// Holds a clone of the `TerminalStoreHandle` so its `Drop`
176/// can clear the SyntheticDoc on the underlying buffer when the
177/// mode deactivates. `store = None` when the service wasn't
178/// registered at activation time (test harness) — Drop is then
179/// a no-op.
180pub struct TerminalNormalModeGuard {
181    store: Option<Arc<TerminalStoreHandle>>,
182    buffer_id: BufferId,
183}
184
185impl Drop for TerminalNormalModeGuard {
186    fn drop(&mut self) {
187        if let Some(store) = &self.store {
188            store.clear_synthetic(self.buffer_id);
189        }
190    }
191}
192
193pub fn register_terminal_modes(registry: &mut ModeRegistry) {
194    registry
195        .register(TerminalMode)
196        .expect("terminal-mode register");
197    registry
198        .register(TerminalInsertMode)
199        .expect("terminal-insert-mode register");
200    registry
201        .register(TerminalNormalMode)
202        .expect("terminal-normal-mode register");
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn terminal_mode_id_kind() {
211        assert_eq!(TerminalMode.id(), TerminalMode::mode_id());
212        assert_eq!(TerminalMode::mode_id().as_str(), "terminal-mode");
213        assert_eq!(TerminalMode.kind(), ModeKind::Major);
214    }
215
216    #[test]
217    fn terminal_insert_mode_id_kind() {
218        assert_eq!(TerminalInsertMode.id(), TerminalInsertMode::mode_id());
219        assert_eq!(
220            TerminalInsertMode::mode_id().as_str(),
221            "terminal-insert-mode",
222        );
223        assert_eq!(TerminalInsertMode.kind(), ModeKind::Minor);
224    }
225
226    #[test]
227    fn register_terminal_modes_populates_both() {
228        let mut registry = ModeRegistry::new();
229        register_terminal_modes(&mut registry);
230        assert!(registry.is_registered(TerminalMode::mode_id()));
231        assert!(registry.is_registered(TerminalInsertMode::mode_id()));
232        // T-mode-1 (2026-05-27): the new normal-mode joins both.
233        assert!(registry.is_registered(TerminalNormalMode::mode_id()));
234    }
235
236    #[test]
237    fn terminal_normal_mode_id_kind() {
238        assert_eq!(TerminalNormalMode.id(), TerminalNormalMode::mode_id());
239        assert_eq!(
240            TerminalNormalMode::mode_id().as_str(),
241            "terminal-normal-mode",
242        );
243        assert_eq!(TerminalNormalMode.kind(), ModeKind::Minor);
244    }
245
246    /// Mock `TerminalStore` used by the lifecycle tests below.
247    /// Records install / clear counts so the test can assert that
248    /// `on_activate` triggered an install and that dropping the
249    /// returned Guard triggered a clear.
250    struct RecordingStore {
251        installs: std::sync::atomic::AtomicUsize,
252        clears: std::sync::atomic::AtomicUsize,
253    }
254
255    impl RecordingStore {
256        fn new() -> Self {
257            Self {
258                installs: std::sync::atomic::AtomicUsize::new(0),
259                clears: std::sync::atomic::AtomicUsize::new(0),
260            }
261        }
262        fn installs(&self) -> usize {
263            self.installs.load(std::sync::atomic::Ordering::SeqCst)
264        }
265        fn clears(&self) -> usize {
266            self.clears.load(std::sync::atomic::Ordering::SeqCst)
267        }
268    }
269
270    impl crate::synthetic::TerminalStore for RecordingStore {
271        fn install_synthetic(&self, _id: BufferId) -> bool {
272            self.installs
273                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
274            true
275        }
276        fn clear_synthetic(&self, _id: BufferId) -> bool {
277            self.clears
278                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
279            true
280        }
281    }
282
283    #[tokio::test]
284    async fn on_activate_installs_synthetic_via_store() {
285        let recording = Arc::new(RecordingStore::new());
286        let store_dyn: Arc<dyn crate::synthetic::TerminalStore> = recording.clone();
287        let handle = crate::synthetic::TerminalStoreHandle::new(store_dyn);
288
289        let mut services = lattice_mode::ServiceRegistry::new();
290        services.register(handle);
291        let services = Arc::new(services);
292
293        let ctx = ModeContext::new(
294            lattice_protocol::ids::BufferId::new(7),
295            TerminalNormalMode::mode_id(),
296            Arc::new(lattice_config::ConfigRegistry::new()),
297            Arc::new(lattice_runtime::EventBus::new()),
298            services,
299        );
300
301        let _guard = TerminalNormalMode
302            .on_activate(ctx)
303            .await
304            .expect("activate ok");
305        assert_eq!(
306            recording.installs(),
307            1,
308            "install_synthetic should fire once on activate"
309        );
310        assert_eq!(recording.clears(), 0, "no clear before guard drop");
311        // Guard dropped at end of scope below.
312        drop(_guard);
313        assert_eq!(
314            recording.clears(),
315            1,
316            "clear_synthetic should fire on guard drop"
317        );
318    }
319
320    #[tokio::test]
321    async fn on_activate_succeeds_with_no_store_service() {
322        // No TerminalStoreHandle registered — mode should still
323        // activate gracefully (Guard's Drop is a no-op). Same
324        // shape as LspMode's graceful-degradation path.
325        let services = Arc::new(lattice_mode::ServiceRegistry::new());
326        let ctx = ModeContext::new(
327            lattice_protocol::ids::BufferId::new(9),
328            TerminalNormalMode::mode_id(),
329            Arc::new(lattice_config::ConfigRegistry::new()),
330            Arc::new(lattice_runtime::EventBus::new()),
331            services,
332        );
333        let guard = TerminalNormalMode
334            .on_activate(ctx)
335            .await
336            .expect("activate ok without store");
337        drop(guard); // should not panic
338    }
339}