escriba-command 0.1.37

Command registry + palette for escriba — every user-reachable action is a command with a name, description, handler, and OpenAPI-specable signature.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
//! `escriba-command` — command registry + palette.

extern crate self as escriba_command;

use std::collections::HashMap;

use escriba_core::BufferId;
use escriba_madoguchi::cap::{Buffers, Cursor, Syntax};
use escriba_madoguchi::{BufferView, Native, Negai, Outcome, Snapshot, View, caps, erase};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum CommandError {
    #[error("command not found: {0}")]
    NotFound(String),
    /// A registered command whose action symbol nothing implements yet.
    ///
    /// Distinct from [`NotFound`](Self::NotFound), and the distinction is the
    /// point: `NotFound` means the operator typed a name that does not exist,
    /// while `Unhandled` means the editor ADVERTISED a binding — it is in
    /// `--commands`, it is in the keymap, `--list-rc` counts it — and then did
    /// nothing. The second is the more misleading of the two and used to be
    /// the silent one.
    #[error("action `{0}` is declared but not implemented yet")]
    Unhandled(String),
    #[error("command failed: {0}")]
    Failed(String),
    // NOTE: there was a `Buffer(#[from] BufferError)` variant here. The M2
    // port made it dead: a command no longer performs I/O, so it cannot
    // produce a buffer error. Save/undo/redo failures now surface from the
    // interpreter, which is the thing that actually touches the filesystem.
}

pub type Result<T> = std::result::Result<T, CommandError>;

/// A command body.
///
/// Reads through the counter, returns slips. There is no `&mut` in this
/// signature, which is the point: a command cannot reach editor state, so it
/// cannot corrupt it. It replaces `fn(&mut EditContext, &[String])`, whose
/// `&mut BufferSet` was simultaneously too much power and too little reach —
/// the runtime still had to special-case `:noh` because `EditContext` could
/// not see `SearchState`.
pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;

/// How a command executes when invoked.
///
/// - [`Handler::Native`] wraps a compiled-in Rust `fn` — the
///   built-in command set (`save`, `quit`, …).
/// - [`Handler::Action`] carries a dotted action symbol
///   (e.g. `"buffer.write-all"`, `"picker.files"`) authored via a
///   Tatara-Lisp `(defcmd …)` form and resolved at run time by
///   [`run_action`]. This is what lets `defcmd` register a real,
///   invokable command without a compiled handler.
///
/// A future `Lisp(Thunk)` variant will carry a `tatara-lisp-eval`
/// closure for fully-programmable commands — the imperative tier of
/// the two-tier programmability model. Keeping the handler an enum
/// (not a bare `fn`) is what makes that extension a one-variant add.
#[derive(Debug, Clone)]
pub enum Handler {
    /// Compiled-in Rust handler.
    Native(CommandFn),
    /// Dotted action symbol resolved at run time (Lisp `defcmd`).
    Action(String),
}

#[derive(Debug, Clone)]
pub struct Command {
    pub name: String,
    pub description: String,
    pub handler: Handler,
}

impl Command {
    /// A built-in command backed by a compiled-in Rust `fn`.
    pub fn native(
        name: impl Into<String>,
        description: impl Into<String>,
        handler: CommandFn,
    ) -> Self {
        Self {
            name: name.into(),
            description: description.into(),
            handler: Handler::Native(handler),
        }
    }

    /// A Lisp-authored command whose behavior is a dotted action
    /// symbol resolved at run time. Mirrors `(defcmd :name … :action
    /// "buffer.write-all")`.
    pub fn action(
        name: impl Into<String>,
        description: impl Into<String>,
        action: impl Into<String>,
    ) -> Self {
        Self {
            name: name.into(),
            description: description.into(),
            handler: Handler::Action(action.into()),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CommandSpec {
    pub name: String,
    pub description: String,
    #[serde(default)]
    pub args: Vec<CommandArgSpec>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CommandArgSpec {
    pub name: String,
    pub description: String,
    #[serde(default)]
    pub required: bool,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub variants: Vec<String>,
}

#[derive(Debug, Default, Clone)]
pub struct CommandRegistry {
    commands: HashMap<String, Command>,
}

impl CommandRegistry {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    #[must_use]
    pub fn default_set() -> Self {
        let mut r = Self::new();
        r.register(Command::native(
            "save",
            "Write the active buffer to disk",
            erase::<Save>(),
        ));
        r.register(Command::native("quit", "Exit the editor", erase::<Quit>()));
        // Named for the ACTION SYMBOLS the shipped keybindings use, so
        // `<leader>bn` resolves instead of reporting "declared but not
        // implemented yet". These are the first three entries to leave the
        // INERT inventory in escriba/tests/action_resolution.rs.
        r.register(Command::native(
            "buffer.next",
            "Go to the next buffer",
            erase::<BufferNext>(),
        ));
        r.register(Command::native(
            "buffer.prev",
            "Go to the previous buffer",
            erase::<BufferPrev>(),
        ));
        r.register(Command::native(
            "buffer.delete",
            "Close the active buffer",
            erase::<BufferDelete>(),
        ));
        r.register(Command::native(
            "todo.next",
            "Go to the next TODO/FIXME marker",
            erase::<TodoWalk<true>>(),
        ));
        r.register(Command::native(
            "todo.prev",
            "Go to the previous TODO/FIXME marker",
            erase::<TodoWalk<false>>(),
        ));
        for name in ["comment.toggle-line", "comment.toggle-block"] {
            r.register(Command::native(
                name,
                "Toggle the comment on the current line",
                erase::<CommentToggle>(),
            ));
        }
        for alias in ["noh", "nohl", "nohlsearch"] {
            r.register(Command::action(
                alias,
                "Stop highlighting matches, keep the pattern",
                "search.clear-highlight",
            ));
        }
        r.register(Command::native(
            "undo",
            "Undo the last change",
            erase::<Undo>(),
        ));
        r.register(Command::native(
            "redo",
            "Redo the last undone change",
            erase::<Redo>(),
        ));
        r.register(Command::native(
            "buffer-info",
            "Print the active buffer summary",
            erase::<Info>(),
        ));
        r
    }

    pub fn register(&mut self, command: Command) {
        self.commands.insert(command.name.clone(), command);
    }

    /// Is `name` registered? Lets the apply layer report
    /// override-vs-new without exposing the inner map.
    #[must_use]
    pub fn contains(&self, name: &str) -> bool {
        self.commands.contains_key(name)
    }

    /// Number of registered commands.
    #[must_use]
    pub fn len(&self) -> usize {
        self.commands.len()
    }

    /// True when no commands are registered.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.commands.is_empty()
    }

    /// Dispatch `name`.
    ///
    /// `Err` means the registry could not dispatch at all — Phase 0's two
    /// failures, kept distinct because they mean different things to the
    /// operator. `Ok(outcome)` means a body ran and reported for itself.
    pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
        let cmd = self
            .commands
            .get(name)
            .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
        match &cmd.handler {
            Handler::Native(f) => Ok(f(snap, args)),
            Handler::Action(sym) => run_action(sym, snap, args),
        }
    }

    #[must_use]
    pub fn names(&self) -> Vec<&str> {
        let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
        v.sort_unstable();
        v
    }

    #[must_use]
    pub fn specs(&self) -> Vec<CommandSpec> {
        let mut out: Vec<CommandSpec> = self
            .commands
            .values()
            .map(|c| CommandSpec {
                name: c.name.to_string(),
                description: c.description.to_string(),
                args: Vec::new(),
            })
            .collect();
        out.sort_by(|a, b| a.name.cmp(&b.name));
        out
    }
}

/// Resolve a dotted action symbol to a built-in body.
fn run_action(sym: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
    match sym {
        "buffer.save" | "buffer.write" => Ok(erase::<Save>()(snap, args)),
        "buffer.write-all" => Ok(erase::<WriteAll>()(snap, args)),
        "buffer.undo" => Ok(erase::<Undo>()(snap, args)),
        "buffer.redo" => Ok(erase::<Redo>()(snap, args)),
        "buffer.info" => Ok(erase::<Info>()(snap, args)),
        "editor.quit" => Ok(erase::<Quit>()(snap, args)),
        "search.clear-highlight" => Ok(erase::<Noh>()(snap, args)),
        // The not-yet-implemented namespace. The shipped keybindings that
        // land here are enumerated by `escriba/tests/action_resolution.rs`,
        // which asserts SET EQUALITY — so the count is READ from there rather
        // than restated. It said 85 while the real figure had ratcheted to
        // 78; a duplicated number is a number that rots.
        // Inert and ANNOUNCED; see CommandError::Unhandled.
        _ => Err(CommandError::Unhandled(sym.to_string())),
    }
}

/// The active buffer, or the outcome to return when there isn't one.
///
/// "No buffer" is a DECLINE, not a failure: it is a legitimate state (boot,
/// every `--no-defaults` run) and the operator did nothing wrong.
fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
    b.active()
        .map(BufferView::id)
        .ok_or_else(|| Outcome::declined("no active buffer"))
}

type Result2<T> = std::result::Result<T, Outcome>;

/// Save every modified, path-backed buffer.
///
/// Best-effort BY CONSTRUCTION: one slip per buffer, applied independently,
/// so one buffer's permission error cannot abort the rest. Scratch buffers
/// have no path and are skipped.
struct WriteAll;
impl Native for WriteAll {
    type Reads = caps!(Buffers);
    fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
        let b = v.buffers();
        let slips: Vec<Negai> = b
            .ids()
            .into_iter()
            .filter(|id| {
                b.get(*id)
                    .is_some_and(|x| x.is_modified() && x.path().is_some())
            })
            .map(|buffer| Negai::Save { buffer })
            .collect();
        if slips.is_empty() {
            return Outcome::declined("no modified files");
        }
        Outcome::did(slips)
    }
}

struct Save;
impl Native for Save {
    type Reads = caps!(Buffers);
    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        match active_or_decline(&v.buffers()) {
            Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
            Err(o) => o,
        }
    }
}

struct Undo;
impl Native for Undo {
    type Reads = caps!(Buffers);
    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        match active_or_decline(&v.buffers()) {
            Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
            Err(o) => o,
        }
    }
}

struct Redo;
impl Native for Redo {
    type Reads = caps!(Buffers);
    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        match active_or_decline(&v.buffers()) {
            Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
            Err(o) => o,
        }
    }
}

/// Report the active buffer's shape.
///
/// This used to `eprintln!`. From a TUI holding the alternate screen that
/// writes straight through the ratatui frame and corrupts it — a latent bug
/// the port removed for free, because a command's only way to say something
/// is now `Negai::Message`, which lands on the status line.
struct Info;
impl Native for Info {
    type Reads = caps!(Buffers);
    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        let b = v.buffers();
        let Some(buf) = b.active() else {
            return Outcome::declined("no active buffer");
        };
        let mut m = String::with_capacity(48);
        m.push_str("buffer ");
        m.push_str(&buf.id().0.to_string());
        m.push_str("");
        m.push_str(&buf.line_count().to_string());
        m.push_str(" line(s)");
        if buf.is_modified() {
            m.push_str(" [modified]");
        }
        Outcome::did(vec![Negai::Message(m)])
    }
}

/// Quit reads NOTHING.
///
/// Worth pausing on: under the old `EditContext` this function was handed
/// `&mut BufferSet` and `&mut ModalState` in order to set one bool. Its
/// capability set is now literally empty, and the type system enforces that
/// — `caps!()` proves no membership, so every accessor on its view is
/// unbuildable.
/// `buffer.next` / `buffer.prev` — walk the buffer list.
struct BufferNext;
impl Native for BufferNext {
    type Reads = caps!();
    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        Outcome::did(vec![Negai::CycleBuffer { forward: true }])
    }
}

struct BufferPrev;
impl Native for BufferPrev {
    type Reads = caps!();
    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        Outcome::did(vec![Negai::CycleBuffer { forward: false }])
    }
}

/// `buffer.delete` — close the active buffer.
///
/// Reads `Buffers` only to name WHICH buffer; whether a modified buffer may
/// close, and what becomes active afterwards, are the interpreter's policy.
struct BufferDelete;
impl Native for BufferDelete {
    type Reads = caps!(Buffers);
    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        match active_or_decline(&v.buffers()) {
            Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
            Err(o) => o,
        }
    }
}

/// `comment.toggle-line` / `comment.toggle-block` — the first commands to
/// need TWO capabilities, and the first consumer of `:commentstring`.
///
/// Toggle, not comment: if the line is already commented it is uncommented.
/// A one-way "comment" verb makes the same keystroke mean two things
/// depending on state, which is how you end up with `//// x`.
struct CommentToggle;
impl Native for CommentToggle {
    type Reads = caps!(Buffers, Cursor, Syntax);
    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        let Some(ft) = v.syntax().filetype() else {
            return Outcome::declined("no filetype for this buffer");
        };
        let Some(comment) = ft.comment.as_ref() else {
            let mut m = String::from("no comment syntax for ");
            m.push_str(&ft.name);
            return Outcome::declined(m);
        };
        let b = v.buffers();
        let Some(buf) = b.active() else {
            return Outcome::declined("no active buffer");
        };
        let line_no = v.cursor().position().line;
        let Some(line) = buf.line(line_no) else {
            return Outcome::declined("cursor past the end of the buffer");
        };
        // An empty line has nothing to comment, and commenting it would
        // leave a bare marker the next toggle cannot recognise as content.
        if line.trim().is_empty() {
            return Outcome::declined("nothing on this line");
        }

        // Indentation is preserved: a comment marker inserted before the
        // indent would destroy the alignment the code is relying on.
        let indent_len = line.len() - line.trim_start().len();
        let (indent, body) = line.split_at(indent_len);
        let toggled = match comment.strip(body) {
            Some(uncommented) => uncommented.to_string(),
            None => comment.wrap(body),
        };
        let mut text = String::with_capacity(indent.len() + toggled.len());
        text.push_str(indent);
        text.push_str(&toggled);

        Outcome::did(vec![Negai::Edit {
            buffer: buf.id(),
            edit: escriba_core::Edit {
                range: escriba_core::Range::new(
                    escriba_core::Position::new(line_no, 0),
                    escriba_core::Position::new(
                        line_no,
                        u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
                    ),
                ),
                kind: escriba_core::EditKind::Replace { text },
            },
        }])
    }
}

/// `todo.next` / `todo.prev` — walk the marker list.
///
/// Scans on every invocation rather than relying on a cached list. The scan
/// is pure text and costs nothing at keyboard cadence, and re-scanning means
/// the list is always fresh — the freshness machinery then guards the window
/// BETWEEN a publish and a walk, which is where a stale list would otherwise
/// slip through.
///
/// This is also the shape every later producer takes: the command COMPUTES
/// (it has the text through `Buffers`) and asks the interpreter to publish.
/// Nothing here touches the registry.
struct TodoWalk<const FORWARD: bool>;
impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
    type Reads = caps!(Buffers);
    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        let b = v.buffers();
        let Some(buf) = b.active() else {
            return Outcome::declined("no active buffer");
        };
        let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
        if findings.is_empty() {
            return Outcome::declined("no TODO markers in this buffer");
        }
        Outcome::did(vec![
            Negai::PublishFindings {
                list: "todo".to_string(),
                findings,
            },
            Negai::WalkList {
                list: "todo".to_string(),
                forward: FORWARD,
            },
        ])
    }
}

struct Quit;
impl Native for Quit {
    type Reads = caps!();
    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        Outcome::did(vec![Negai::Quit])
    }
}

/// `:noh` — the command that proves the seam, and it also reads nothing.
///
/// It lived as a hard-coded branch inside `EditorState::run_command`,
/// bypassing the registry entirely, because the old `EditContext` exposed
/// buffers and modal state and could not reach `SearchState`. It is now an
/// ordinary command asking for an ordinary slip, and it turns out not to
/// need a view at all — it does not READ the search, it asks to change it.
struct Noh;
impl Native for Noh {
    type Reads = caps!();
    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
        Outcome::did(vec![Negai::ClearSearchHighlight])
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use escriba_core::BufferId;
    use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};

    /// A snapshot holding one dirty, path-backed buffer.
    fn dirty_file() -> FakeSnapshot {
        let mut s = FakeSnapshot::default();
        s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
        s.active = Some(BufferId(1));
        s
    }

    #[test]
    fn default_set_is_populated() {
        let r = CommandRegistry::default_set();
        let names = r.names();
        assert!(names.contains(&"save"));
        assert!(names.contains(&"quit"));
    }

    #[test]
    fn specs_are_sorted() {
        let r = CommandRegistry::default_set();
        let specs = r.specs();
        assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
    }

    #[test]
    fn not_found_errors() {
        // Phase 0's first failure: a name nobody registered. Still an Err,
        // because the runtime tells a typo apart from an unbuilt capability.
        let r = CommandRegistry::new();
        let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
        assert!(matches!(err, CommandError::NotFound(_)));
    }

    #[test]
    fn a_command_asks_rather_than_acts() {
        // The whole point of the port. `write-all` used to reach into
        // `&mut BufferSet` and call `.save()`. It now RETURNS a request per
        // modified path-backed buffer and touches nothing — which is also
        // why it is best-effort by construction: the interpreter applies
        // each slip independently, so one permission error cannot abort the
        // rest.
        let mut r = CommandRegistry::new();
        r.register(Command::action(
            "w-all",
            "Write every modified buffer",
            "buffer.write-all",
        ));
        let out = r
            .run("w-all", &dirty_file(), &[])
            .expect("registered command dispatches");
        assert_eq!(
            out.slips,
            vec![Negai::Save {
                buffer: BufferId(1)
            }]
        );
        assert_eq!(out.verdict, Verdict::Did);
    }

    #[test]
    fn nothing_to_save_declines_rather_than_claiming_success() {
        // Three verdicts, not two. A scratch buffer has no path, so there is
        // genuinely nothing to write — and saying "Did" would be the same
        // silent lie Phase 0 removed.
        let mut r = CommandRegistry::new();
        r.register(Command::action("w-all", "Write all", "buffer.write-all"));
        let out = r
            .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
            .expect("dispatches");
        assert!(out.slips.is_empty());
        assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
    }

    #[test]
    fn no_active_buffer_declines_rather_than_failing() {
        // Boot, and every `--no-defaults` run, reach commands with no
        // buffer. The operator did nothing wrong, so it is not an error.
        let mut r = CommandRegistry::new();
        r.register(Command::action("w", "Save", "buffer.save"));
        let out = r
            .run("w", &FakeSnapshot::default(), &[])
            .expect("dispatches");
        assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
        assert!(out.slips.is_empty(), "a decline asks for nothing");
    }

    #[test]
    fn unknown_action_symbol_is_reported_not_silent() {
        // This test used to assert the DEFECT — it called `.expect()` on the
        // Ok, pinning `_ => Ok(())`, under which a dead keybinding and a
        // working one were indistinguishable at every layer.
        //
        // Inert is still correct: `picker.files` genuinely has not landed.
        // SILENT was never correct. It must be `Unhandled`, not `NotFound`:
        // the command IS registered, which is what made the silence
        // misleading in the first place.
        let mut r = CommandRegistry::new();
        r.register(Command::action("pick", "Pick a file", "picker.files"));
        let err = r
            .run("pick", &FakeSnapshot::default(), &[])
            .expect_err("an unimplemented action must report, not report success");
        assert!(
            matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
            "expected Unhandled(picker.files), got {err:?}",
        );
        assert!(r.contains("pick"), "the command survives its own failure");
    }

    #[test]
    fn action_naming_a_command_is_inert_not_recursive() {
        // `:action` takes action SYMBOLS, not command names: `run_action`
        // resolves dotted symbols and does NOT recurse into the registry.
        // Recursion would let a handler reach anything by naming it, which
        // is the ceiling madoguchi exists to remove.
        //
        // What changed with the port: the non-recursion is now REPORTED
        // rather than looking like a successful save.
        let mut r = CommandRegistry::new();
        r.register(Command::action("alias", "aliases save by name", "save"));
        let err = r
            .run("alias", &dirty_file(), &[])
            .expect_err("a command-name alias resolves nothing, and says so");
        assert!(
            matches!(&err, CommandError::Unhandled(s) if s == "save"),
            "expected Unhandled(save), got {err:?}",
        );
    }

    #[test]
    fn quit_is_a_request_not_a_flag_poke() {
        // Was `*ctx.quit_requested = true` — a command reaching into a
        // borrowed flag. Quit is now a request like any other, and the
        // interpreter decides, because the interpreter is the thing that
        // knows about unsaved buffers.
        let mut r = CommandRegistry::new();
        r.register(Command::action("bye", "Quit", "editor.quit"));
        let out = r
            .run("bye", &FakeSnapshot::default(), &[])
            .expect("dispatches");
        assert_eq!(out.slips, vec![Negai::Quit]);
    }

    #[test]
    fn buffer_info_speaks_through_a_slip_not_stderr() {
        // It used to `eprintln!`, which from a TUI holding the alternate
        // screen writes straight through the ratatui frame and corrupts it.
        // A command's only way to say anything is now Negai::Message.
        let mut r = CommandRegistry::new();
        r.register(Command::action("info", "Buffer info", "buffer.info"));
        let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
        let Some(Negai::Message(m)) = out.slips.first() else {
            panic!("expected a Message slip, got {:?}", out.slips);
        };
        assert!(m.contains("buffer 1"), "{m}");
        assert!(m.contains("[modified]"), "{m}");
    }
}