Skip to main content

lattice_mode/
repl_mode.rs

1//! `repl-mode` — a builtin minor mode contributing the REPL **input surface**.
2//!
3//! The Normal-mode insert-entry keys (`i`/`a`/`o`/`A`/`I`/`O`) don't insert in
4//! place: they move the caret to the buffer's prompt (its last line) and enter
5//! Insert. That is the affordance a REPL-like buffer wants — the transcript
6//! above the prompt is read-only, so "start typing" should always land you at
7//! the prompt.
8//!
9//! ## Why a minor mode, not a major-mode keymap
10//!
11//! This behaviour previously lived on `ai-conversation-mode`, a **major** mode,
12//! whose keymap overrides vim's universal insert keys. A major-mode keymap is
13//! gated by the buffer's active major (K.1.c), but binding the single most
14//! common Normal-mode keys there is fragile — any gap in that gating resurfaces
15//! them everywhere (the `:describe-key i` / dashboard-jumps-to-EOF saga).
16//!
17//! Modelling the affordance as a `Manual` **minor** mode that each REPL major
18//! (`ai-conversation`, and later terminal / claude) pulls in via
19//! [`Mode::implies`](crate::Mode::implies) keeps it OFF every ordinary buffer
20//! (a regular `i` stays vim-native), reusable across REPLs, and rides the
21//! well-tested minor-mode keymap gating. The user is free to `:set` it on any
22//! buffer where it makes sense.
23
24use std::sync::Arc;
25
26use lattice_grammar::ModalState;
27use lattice_grammar::effect::Effect;
28
29use crate::registry::ModeRegistry;
30use crate::{
31    ActionContext, ActionHandler, ActionHandlerContribution, ActivationPolicy, BufferStoreHandle,
32    Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, keymap_entry,
33};
34
35/// `repl-mode` minor. A marker mode: it owns a keymap layer + one action
36/// handler, but allocates no per-buffer resources (`Guard = ()`).
37pub struct ReplMode;
38
39impl ReplMode {
40    /// The canonical id, `"repl-mode"` — what [`Mode::id`](crate::Mode::id)
41    /// returns. Use it to name this mode without an instance (activation,
42    /// `implies`, keymap layers, tests).
43    pub fn mode_id() -> ModeId {
44        ModeId::new("repl-mode")
45    }
46}
47
48impl Mode for ReplMode {
49    type Guard = ();
50
51    fn id(&self) -> ModeId {
52        Self::mode_id()
53    }
54
55    fn kind(&self) -> ModeKind {
56        ModeKind::Minor
57    }
58
59    /// Explicit-only: `repl-mode` is activated by a REPL major's
60    /// [`implies`](crate::Mode::implies) (or a user `:set`), never
61    /// auto-activated on ordinary buffers. That isolation is the whole point —
62    /// a regular buffer's `i` must stay vim-native.
63    fn activation_policy(&self) -> ActivationPolicy {
64        ActivationPolicy::Manual
65    }
66
67    /// The insert-entry chords. Pushed once at boot under
68    /// `MinorMode(repl-mode)`; K.1.c's per-keystroke filter gates them to
69    /// buffers where `repl-mode` is active — the `diff-mode` / `snippet-mode`
70    /// pattern.
71    fn keymap(&self) -> Keymap {
72        Keymap::from_entries(repl_mode_keymap_entries())
73    }
74
75    /// The generic focus-the-prompt handler, mode-owned. Bound globally at boot
76    /// by the host's `register_mode_action_handlers` walk (keyed on the
77    /// `CommandId`, active on many buffers at once — the `snippet-expand`
78    /// precedent), and gated to `repl-mode`-active buffers by K.1.c.
79    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
80        vec![ActionHandlerContribution {
81            action_name: "action:repl-focus-input",
82            handler: focus_input_handler(),
83        }]
84    }
85
86    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
87        Box::pin(async { Ok(()) })
88    }
89}
90
91/// The `i`/`a`/`o`/`A`/`I`/`O` → `action:repl-focus-input` entries. All six
92/// insert-entry chords route to the same handler so entering Insert always
93/// relocates the caret to the prompt (the transcript is read-only, so there is
94/// nothing to insert-at in place).
95fn repl_mode_keymap_entries() -> &'static [KeymapEntry] {
96    use std::sync::OnceLock;
97    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
98    ENTRIES.get_or_init(|| {
99        let focus = |chord: &'static str| {
100            keymap_entry! {
101                mode: Normal, chord: chord,
102                doc: "repl: move the cursor to the prompt and enter Insert",
103                cmd: "action:repl-focus-input"
104            }
105        };
106        vec![
107            focus("i"),
108            focus("a"),
109            focus("o"),
110            focus("A"),
111            focus("I"),
112            focus("O"),
113        ]
114    })
115}
116
117/// Place the cursor at the end of the buffer's last line (the prompt) and enter
118/// Insert. Generic — reads the buffer through the `BufferStoreHandle` service
119/// (the `ActionContext` carries no buffer text), so any REPL buffer whose
120/// prompt is its trailing line works without mode-specific wiring.
121fn focus_input_handler() -> ActionHandler {
122    Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
123        let store = ctx.services.get::<BufferStoreHandle>()?;
124        let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
125        let handle = store.handle_for(buffer_id)?;
126        let snap = handle.snapshot();
127        // CV.3: content space — the REPL prompt is the last REAL line.
128        // Ropey's raw count would focus the phantom empty line after a
129        // terminating newline instead of the prompt.
130        let last_line = snap.buffer.content_line_count().saturating_sub(1);
131        let end_byte = snap
132            .buffer
133            .line(last_line)
134            .unwrap_or_default()
135            .trim_end_matches('\n')
136            .len() as u32;
137        let pos = lattice_protocol::position::Position::new(last_line, end_byte);
138        Some(Effect::Many(vec![
139            Effect::CursorMove(pos),
140            Effect::EnterMode(ModalState::Insert),
141        ]))
142    })
143}
144
145/// Register `repl-mode` against `registry`. Called from
146/// [`register_foundation_modes`](crate::register_foundation_modes) — it is a
147/// builtin, so there is no separate boot call.
148pub fn register_repl_mode(registry: &mut ModeRegistry) {
149    registry
150        .register(ReplMode)
151        .expect("repl-mode must register without conflict");
152}
153
154/// Register the `action:repl-focus-input` command so the mode's keymap `cmd`
155/// name resolves at boot (the `register_ai_conversation_actions` pattern). The
156/// `apply` body is a dead `Effect::None`: the mode's `action_handlers` closure
157/// intercepts before the grammar Action gate, so this never runs. It exists so
158/// the `CommandId` resolves for the chord binding + handler registration.
159pub fn register_repl_mode_actions(registry: &mut lattice_grammar::CommandRegistry) {
160    use lattice_grammar::registry::ActionSpec;
161    registry.register_action(
162        "action:repl-focus-input",
163        "repl: move the cursor to the prompt and enter Insert (mode-owned).",
164        ActionSpec {
165            apply: Arc::new(|_| Ok(Effect::None)),
166            args_schema: vec![],
167        },
168    );
169}
170
171#[cfg(test)]
172mod tests {
173    #![allow(clippy::unwrap_used)]
174    use super::*;
175
176    #[test]
177    fn mode_id_uses_the_mode_suffix() {
178        assert_eq!(ReplMode::mode_id().as_str(), "repl-mode");
179        assert!(ReplMode::mode_id().as_str().ends_with("-mode"));
180    }
181
182    #[test]
183    fn is_a_manual_minor_mode() {
184        // Manual so it never auto-activates on ordinary buffers — a regular
185        // `i` stays vim-native; only a REPL major's `implies` (or `:set`)
186        // turns it on.
187        assert_eq!(<ReplMode as Mode>::kind(&ReplMode), ModeKind::Minor);
188        assert!(matches!(
189            <ReplMode as Mode>::activation_policy(&ReplMode),
190            ActivationPolicy::Manual
191        ));
192    }
193
194    #[test]
195    fn binds_every_insert_entry_key_to_the_focus_action() {
196        let pairs: Vec<(&str, Option<&str>)> = repl_mode_keymap_entries()
197            .iter()
198            .map(|e| (e.chord, e.command))
199            .collect();
200        assert_eq!(
201            pairs,
202            vec![
203                ("i", Some("action:repl-focus-input")),
204                ("a", Some("action:repl-focus-input")),
205                ("o", Some("action:repl-focus-input")),
206                ("A", Some("action:repl-focus-input")),
207                ("I", Some("action:repl-focus-input")),
208                ("O", Some("action:repl-focus-input")),
209            ],
210        );
211    }
212
213    #[test]
214    fn contributes_the_focus_input_handler() {
215        let names: Vec<&str> = ReplMode
216            .action_handlers()
217            .iter()
218            .map(|c| c.action_name)
219            .collect();
220        assert_eq!(names, vec!["action:repl-focus-input"]);
221    }
222
223    #[test]
224    fn register_action_makes_the_command_resolvable() {
225        let mut r = lattice_grammar::CommandRegistry::new();
226        register_repl_mode_actions(&mut r);
227        assert!(r.id_by_name("action:repl-focus-input").is_some());
228    }
229}