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}