Skip to main content

lattice_ui_tui/app/
cmdline.rs

1//! Ex command line (`:`) state machine -- the App-side
2//! cmdline-completion + history navigation. Driven by
3//! `Action::CommandLine{Complete,CompletePrev,
4//! AcceptCompletion,DescribeUnderCursor,HistoryStep}`.
5//!
6//! Methods that live here:
7//! - `do_command_line_describe_under_cursor` (`<C-h>`
8//!   resolves the word under cursor to its
9//!   `:describe-command` view).
10//! - `do_command_line_complete_or_advance` (`<Tab>`),
11//!   `do_command_line_complete_prev` (`<S-Tab>`),
12//!   `do_command_line_accept_completion`.
13//! - `do_command_history_step` (`<Up>` / `<Down>` walks
14//!   `:` history with snapshot + restore semantics).
15//! - `try_resolve_missing_arg_prompt` (the cmdline-submit
16//!   missing-required-arg detector) plus its
17//!   `MissingArgPrompt` return shape.
18//! - `chord_capture_active` (the input-layer gate that
19//!   tells `translate_command_chord_capture` whether the
20//!   current cmdline cursor is on an `ArgKind::Chord` slot).
21//! - `compute_completion_state` (slot-detect + run the
22//!   completion pipeline + alias-rewrite command
23//!   candidates) and `refresh_completion_popup` (re-run
24//!   on `CommandLine{Append,Backspace,DeleteWordBackward}`
25//!   while the popup is open). Plus the `CompletionComputeError`
26//!   error enum and the `prefer_aliases_for_command_candidates`
27//!   / `subsequence_match_ranges` helpers used only here.
28//!
29//! What does NOT live here yet: the ex-command bodies
30//! themselves (`do_edit`, `do_write`, `do_quit`,
31//! `do_global`, etc.). Those each belong to their own
32//! feature group (`do_edit` -> lifecycle, `do_write` ->
33//! lifecycle / oil, `do_global` -> search, ...) and migrate
34//! with their respective slices.
35//!
36//! Also stays in `app.rs` for now: `execute_ex_line` (the
37//! top-level submit dispatcher). It moves with a later
38//! cmdline.dispatch slice.
39
40use lattice_grammar::ModalState;
41
42use super::{App, CompletionState, EchoLevel};
43
44/// Result of resolving a missing-arg prompt (DESIGN.md §B.1).
45/// Returned by [`App::try_resolve_missing_arg_prompt`] when the
46/// user submits a bare command with a required first arg empty.
47pub(super) struct MissingArgPrompt {
48    /// New value for `command_line`. Already contains the command
49    /// word + bang + a trailing space; the cursor lands at end-of-
50    /// line, in the first arg slot.
51    pub(super) prefill: String,
52    /// Kind of the first arg. Drives whether the App arms the
53    /// chord-capture overlay (kind == Chord) or just leaves the
54    /// cmdline open for typed input.
55    pub(super) kind: lattice_grammar::ArgKind,
56    /// Prompt text for the echo area, taken from the schema's
57    /// `prompt` field (or `"<name>:"` when empty).
58    pub(super) prompt: String,
59}
60
61impl App {
62    /// Hybrid `<C-h>` resolution (DESIGN.md §5.11.3 Q11). Walk the
63    /// `:` line up to the cursor (v1: cursor is at end), find the
64    /// "word" the user is hovering on, and:
65    ///
66    /// 1. If the word resolves to a registered command (via alias
67    ///    expansion), describe THAT -- the user is asking about the
68    ///    command they're typing.
69    /// 2. Else, if we can identify the slot as an arg of a known
70    ///    command, describe the parent command scrolled to
71    ///    `arg:<name>`.
72    /// 3. Else, no-op + status message.
73    pub(super) fn do_command_line_describe_under_cursor(&mut self) {
74        if !matches!(self.ad().modal, ModalState::Command) {
75            return;
76        }
77        let line = self.command_line();
78        let cursor = line.len();
79        let alias_resolver = |short: &str| {
80            crate::excommand::aliases()
81                .get(short)
82                .map(|s| (*s).to_string())
83        };
84        // Slice 3c.final.E.5e: registry accessed through the
85        // Arc-cloning helper; locally-bound so the `&reg` borrow
86        // stays valid for the parser + lookup calls below.
87        let reg = self.registry();
88        let slot = lattice_completion::current_slot(&line, cursor, &reg, &alias_resolver);
89
90        let word = slot.prefix();
91        let canonical = if word.is_empty() {
92            None
93        } else {
94            alias_resolver(word).or_else(|| reg.id_by_name(word).and(Some(word.to_string())))
95        };
96
97        if let Some(name) = canonical
98            && reg.id_by_name(&name).is_some()
99        {
100            self.do_describe_command(&name, None);
101            return;
102        }
103
104        match &slot {
105            lattice_completion::CommandLineSlot::Arg {
106                command_name,
107                arg_spec,
108                ..
109            } => {
110                let anchor = format!("arg:{}", arg_spec.name);
111                self.do_describe_command(command_name, Some(&anchor));
112            }
113            lattice_completion::CommandLineSlot::CommandName { prefix, .. } => {
114                if prefix.is_empty() {
115                    self.set_message(
116                        EchoLevel::Info,
117                        "type a command name then C-h for its help".to_string(),
118                    );
119                } else {
120                    self.set_message(EchoLevel::Error, format!("no command named `{prefix}`"));
121                }
122            }
123            _ => {
124                self.set_message(
125                    EchoLevel::Info,
126                    "no command-line context for `C-h`".to_string(),
127                );
128            }
129        }
130    }
131
132    /// `<Tab>` opens the completion popup or advances within an
133    /// open one. Slot detection drives generator selection; the
134    /// pipeline runs through the registered matcher / ranker /
135    /// annotators.
136    pub(super) fn do_command_line_complete_or_advance(&mut self) {
137        if !matches!(self.ad().modal, ModalState::Command) {
138            return;
139        }
140        // Slice 3c.final.E.5i: advance the open popup's selection
141        // through `mutate_editor_with`; return `true` from the
142        // closure when the popup was open (outer body short-
143        // circuits) or `false` when there's no popup yet (the
144        // open-completion-popup path runs).
145        let advanced = self.mutate_editor_with(|e| {
146            if let Some(state) = e.completion_state.as_mut() {
147                if !state.candidates.is_empty() {
148                    state.selected = (state.selected + 1) % state.candidates.len();
149                }
150                true
151            } else {
152                false
153            }
154        });
155        if advanced {
156            return;
157        }
158        self.open_completion_popup();
159    }
160
161    // 5.5.G.15: `do_command_line_complete_prev` /
162    // `do_command_line_accept_completion` migrated to
163    // [`lattice_host::dispatch::Editor`].
164
165    // 5.5.G.13: body migrated to
166    // [`lattice_host::dispatch::Editor::do_command_history_step`].
167
168    /// On `Action::CommandLineSubmit`, decide whether the line is
169    /// an empty-arg invocation of a command whose first required
170    /// arg is `Chord`. If so, return the prefill string for the
171    /// cmdline (`<command-word> ` -- with trailing space) so the
172    /// caller can transition into a chord-capture prompt.
173    /// `None` means submit normally.
174    /// Generalized missing-arg detection (DESIGN.md §B.1).
175    ///
176    /// When the user submits a bare command with a required first
177    /// arg empty -- e.g. `:write<CR>` (path required), `:edit<CR>`
178    /// (path required), `:describe-command<CR>` (name required) --
179    /// resolve the spec, look up the schema's first required arg,
180    /// and return enough info for the App to prefill the cmdline
181    /// + show a prompt.
182    ///
183    /// Returns `None` when:
184    /// - The cmdline is empty.
185    /// - The user already supplied an arg (parser handles it).
186    /// - The command is unknown (parser errors anyway).
187    /// - There's no first arg or it's not Required.
188    /// - The command's args use the delimiter form (`:s/.../.../`).
189    pub(super) fn try_resolve_missing_arg_prompt(&self) -> Option<MissingArgPrompt> {
190        let line_owned = self.command_line();
191        let line = line_owned.trim();
192        if line.is_empty() {
193            return None;
194        }
195        // Split off the command word + bang the same way
196        // `excommand::parse_invocation` does. We don't go through
197        // the full parser because we explicitly want the
198        // `args == empty` case here (the parser would error).
199        let (raw_cmd, rest) = match line.find(char::is_whitespace) {
200            Some(i) => (&line[..i], line[i..].trim()),
201            None => (line, ""),
202        };
203        if !rest.is_empty() {
204            // User supplied an arg -- normal submit handles it.
205            return None;
206        }
207        let cmd = raw_cmd.strip_suffix('!').unwrap_or(raw_cmd);
208        // Slice 3c.final.E.5e: registry through the Arc helper;
209        // bound locally so the `&reg`-equivalent lookups below stay
210        // alive across the lifetime of the closure-passed callable.
211        let reg = self.registry();
212        let canonical = reg.id_by_name(cmd).or_else(|| {
213            crate::excommand::aliases()
214                .get(cmd)
215                .copied()
216                .and_then(|c| reg.id_by_name(c))
217        })?;
218        let spec = reg.ex_command_spec(canonical)?;
219        // Delimiter-form commands (`:s`, `:g`, `:v`) don't go
220        // through the keyword arg-prompt path -- their syntax is
221        // its own UX.
222        if matches!(
223            spec.surface_form,
224            lattice_grammar::SurfaceForm::Delimiter { .. }
225        ) {
226            return None;
227        }
228        let first = spec.args_schema.first()?;
229        if !matches!(first.default, lattice_grammar::ArgDefault::Required) {
230            // Non-required arg has a fallback; let the parser take
231            // the default path.
232            return None;
233        }
234        let prompt = if first.prompt.is_empty() {
235            format!("{}:", first.name)
236        } else {
237            first.prompt.to_string()
238        };
239        Some(MissingArgPrompt {
240            // Preserve the user's spelling (alias vs canonical) plus
241            // any bang they typed; append a trailing space so the
242            // cursor lands in the arg slot.
243            prefill: format!("{raw_cmd} "),
244            kind: first.kind,
245            prompt,
246        })
247    }
248
249    /// True when the cmdline cursor is on an `ArgKind::Chord` arg
250    /// slot. Drives the input layer's chord-capture overlay
251    /// (`translate_command_chord_capture`). v1: `:describe-key`'s
252    /// `chord` arg is the only `Chord`-kinded arg in the registry;
253    /// when `:map` / `:nnoremap` land they reuse this gate.
254    pub fn chord_capture_active(&self) -> bool {
255        // Slice 3c.final.E.5i: modal read now routes through the
256        // published `ad().modal` mirror. Tests that mutate
257        // `editor.modal` directly are updated to call
258        // `publish_render_state()` after the mutation so the
259        // mirror reflects the change.
260        if !matches!(self.ad().modal, ModalState::Command) {
261            return false;
262        }
263        let line = self.command_line();
264        let line = line.as_str();
265        let alias_resolver = |short: &str| {
266            crate::excommand::aliases()
267                .get(short)
268                .map(|s| (*s).to_string())
269        };
270        // Slice 3c.final.E.5e: registry through the Arc helper.
271        let reg = self.registry();
272        let slot = lattice_completion::current_slot(line, line.len(), &reg, &alias_resolver);
273        matches!(
274            &slot,
275            lattice_completion::CommandLineSlot::Arg { arg_spec, .. }
276                if arg_spec.kind == lattice_grammar::ArgKind::Chord
277        )
278    }
279
280    /// Build the pipeline for the current slot and run it. Caches
281    /// results into `completion_state`.
282    ///
283    /// When `completion.auto_insert_single` is on (the default) and
284    /// the pipeline returns exactly one candidate, the popup is
285    /// skipped and that candidate is applied to the command line
286    /// directly -- same effect as `<Tab><CR>` but without the
287    /// confirm keystroke for an unambiguous match. The popup-open
288    /// boundary is the only fire point; narrowing an already-open
289    /// popup to one candidate while typing does not auto-insert.
290    /// 5.5.G.23.cmdline: body migrated to
291    /// [`lattice_host::dispatch::Editor::open_completion_popup`].
292    /// Retained as a 1-line delegate while App callers (cmdline arms
293    /// + complete-or-advance helper) still reach for it directly.
294    pub(super) fn open_completion_popup(&mut self) {
295        // Slice 3c.final.E.3: route through `mutate_editor`.
296        self.mutate_editor(|e| e.open_completion_popup());
297    }
298
299    /// 5.5.G.23.cmdline: body migrated to
300    /// [`lattice_host::dispatch::Editor::refresh_completion_popup`].
301    pub(super) fn refresh_completion_popup(&mut self) {
302        // Slice 3c.final.E.3: route through `mutate_editor`.
303        self.mutate_editor(|e| e.refresh_completion_popup());
304    }
305
306    /// Slot-detect, build the pipeline, run it, and host-rewrite
307    /// command candidates to user-facing aliases. Pure -- no
308    /// `set_message` side effects, so both the open and the refresh
309    /// path can share it. Errors carry enough info for the open path
310    /// to surface them via echo.
311    /// 5.5.G.23.cmdline: body migrated to
312    /// [`lattice_host::dispatch::Editor::compute_completion_state`].
313    /// Kept as a delegate for callers that compose this with their
314    /// own dispatch (today: `open_completion_popup` /
315    /// `refresh_completion_popup` — both also host-resident).
316    pub(super) fn compute_completion_state(
317        &self,
318    ) -> Result<CompletionState, CompletionComputeError> {
319        self.read_editor(move |e| e.compute_completion_state())
320    }
321}
322
323// 5.5.G.23.cmdline: `CompletionComputeError` enum + `echo()` impl
324// migrated to [`lattice_host::dispatch::CompletionComputeError`].
325// Re-exported here so App callers + in-file tests continue working
326// unchanged.
327pub(super) use lattice_host::dispatch::CompletionComputeError;
328
329// 5.5.G.23.cmdline: `prefer_aliases_for_command_candidates` migrated
330// to `lattice_host::dispatch`. Test-only re-export so the in-file
331// test module (`prefer_aliases_*` tests) keeps calling it unchanged
332// without leaking the symbol into the non-test build.
333#[cfg(test)]
334use lattice_host::dispatch::prefer_aliases_for_command_candidates;
335
336// 5.5.G.23.cmdline: `subsequence_match_ranges` retired (zero
337// remaining App callers; lives as a private host fn alongside
338// `prefer_aliases_for_command_candidates`).
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::app::test_helpers::{
344        app_in_command_mode, app_with, invoke_motion, press, press_chars, submit_ex, unique_tempdir,
345    };
346    use crate::app::*;
347
348    // ── MB.1 (rich minibuffer, tier 1): the `:` line is a real
349    //    buffer edited through the universal Insert dispatcher ──
350
351    /// The `:` line is buffer-backed: `:` focuses the synthetic
352    /// `*command-line*` buffer, typing flows through the universal Insert
353    /// dispatcher, and — the whole point of MB.1 — the cursor moves so
354    /// edits land mid-line, not only appended at the end.
355    #[test]
356    fn mb1_command_line_supports_midline_editing() {
357        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
358        let mut a = app_with("hello\n", 10);
359        a.apply(Action::EnterCommandLine);
360        assert!(
361            a.editor.command_line_active(),
362            "`:` must focus the *command-line* buffer"
363        );
364        press_chars(&mut a, "eabc");
365        assert_eq!(a.editor.command_line(), "eabc");
366        // Walk the cursor back two with the `<Left>` arrow and insert —
367        // impossible with the old `String` + append path.
368        press(&mut a, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
369        press(&mut a, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
370        press_chars(&mut a, "X");
371        assert_eq!(
372            a.editor.command_line(),
373            "eaXbc",
374            "insert must land at the cursor, not the end of the line"
375        );
376        // `<C-b>` (readline) moves the caret the same way the arrow does:
377        // from after `X` to before it, so `Y` lands between `a` and `X`.
378        press(
379            &mut a,
380            KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
381        );
382        press_chars(&mut a, "Y");
383        assert_eq!(a.editor.command_line(), "eaYXbc");
384    }
385
386    /// `<Esc>` / `CommandLineCancel` closes the `:` line and restores the
387    /// prior editing buffer (focus-swap unwind), with no command run.
388    #[test]
389    fn mb1_command_line_cancel_restores_prior_buffer() {
390        let mut a = app_with("hello\n", 10);
391        let before = a.editor.document_buffer_id;
392        a.apply(Action::EnterCommandLine);
393        press_chars(&mut a, "e foo");
394        assert!(a.editor.command_line_active());
395        a.apply(Action::CommandLineCancel);
396        assert!(
397            !a.editor.command_line_active(),
398            "cancel must close the `:` line"
399        );
400        assert_eq!(
401            a.editor.document_buffer_id, before,
402            "cancel must restore the prior editing buffer"
403        );
404    }
405
406    /// History walk seeds the `:` line from `command_history` and the
407    /// seeded text is itself buffer-backed (editable + submittable).
408    #[test]
409    fn mb1_command_line_history_walk_seeds_the_buffer() {
410        let mut a = app_with("hello\n", 10);
411        a.editor.command_history.push("edit foo".to_string());
412        a.apply(Action::EnterCommandLine);
413        a.apply(Action::CommandLineHistoryPrev);
414        assert_eq!(
415            a.editor.command_line(),
416            "edit foo",
417            "`<C-p>` seeds the `:` buffer from history"
418        );
419    }
420
421    // ── MB.2 (rich minibuffer, tier 2): `<C-x><C-e>` in-place expand ──
422
423    /// `<C-x><C-e>` toggles the `:` line between the one-row readline line
424    /// (`ModalState::Command`) and the expanded full-modal band
425    /// (`ModalState::Insert`, full grammar). Collapse returns to the
426    /// one-row line for review; the edited text survives both ways.
427    #[test]
428    fn mb2_toggle_expand_flips_state_and_modal_preserving_text() {
429        let mut a = app_with("hello\n", 10);
430        a.apply(Action::EnterCommandLine);
431        press_chars(&mut a, "e foo");
432        assert!(!a.editor.command_line_expanded());
433        assert!(matches!(a.editor.modal, ModalState::Command));
434
435        a.apply(Action::CommandLineToggleExpand);
436        assert!(a.editor.command_line_expanded(), "expand sets tier-2");
437        assert!(
438            matches!(a.editor.modal, ModalState::Insert),
439            "expand drops into full-modal Insert on the band"
440        );
441        assert_eq!(a.editor.command_line(), "e foo", "text survives expand");
442
443        a.apply(Action::CommandLineToggleExpand);
444        assert!(!a.editor.command_line_expanded(), "collapse clears tier-2");
445        assert!(
446            matches!(a.editor.modal, ModalState::Command),
447            "collapse returns to the readline line for review"
448        );
449        assert_eq!(a.editor.command_line(), "e foo", "text survives collapse");
450    }
451
452    /// `:` is a no-op while the command line is already open — it must not
453    /// open a nested command line (the guard the expanded tier-2 band's
454    /// Normal mode relies on).
455    #[test]
456    fn mb2_colon_is_noop_while_command_line_open() {
457        let mut a = app_with("hello\n", 10);
458        a.apply(Action::EnterCommandLine);
459        press_chars(&mut a, "abc");
460        let buf = a.editor.document_buffer_id;
461        a.apply(Action::EnterCommandLine);
462        assert!(a.editor.command_line_active());
463        assert_eq!(a.editor.command_line(), "abc", "text preserved, not reset");
464        assert_eq!(
465            a.editor.document_buffer_id, buf,
466            "same `*command-line*` buffer, not a nested one"
467        );
468    }
469
470    /// In the expanded band, `<CR>` inserts a newline (multi-line editing)
471    /// and does NOT submit — submit happens only from the collapsed line.
472    #[test]
473    fn mb2_expanded_cr_inserts_newline_not_submit() {
474        let mut a = app_with("hello\n", 10);
475        a.apply(Action::EnterCommandLine);
476        press_chars(&mut a, "e foo");
477        a.apply(Action::CommandLineToggleExpand); // expand → Insert
478        a.apply(Action::CommandLineSubmit); // `<CR>` in the band
479        press_chars(&mut a, "bar");
480        assert!(
481            a.editor.command_line_active(),
482            "expanded `<CR>` must not submit / close the command line"
483        );
484        assert!(
485            a.editor.document.text().starts_with("e foo\nbar"),
486            "expanded `<CR>` inserts a newline (multi-line): {:?}",
487            a.editor.document.text()
488        );
489        assert_eq!(
490            a.editor.command_line(),
491            "e foo",
492            "`command_line()` reads the first line"
493        );
494    }
495
496    /// In the expanded band, `<Esc>` from Insert drops to Normal (full
497    /// modal) rather than cancelling the command line.
498    #[test]
499    fn mb2_expanded_esc_from_insert_goes_to_normal() {
500        let mut a = app_with("hello\n", 10);
501        a.apply(Action::EnterCommandLine);
502        a.apply(Action::CommandLineToggleExpand);
503        assert!(matches!(a.editor.modal, ModalState::Insert));
504        a.apply(Action::CommandLineCancel); // `<Esc>` in the band
505        assert!(
506            a.editor.command_line_active(),
507            "`<Esc>` in the band must not cancel the command line"
508        );
509        assert!(
510            matches!(a.editor.modal, ModalState::Normal),
511            "`<Esc>` in the expanded band drops to Normal for full modal"
512        );
513    }
514
515    /// The renderer's band data path: `command_line_full_text()` carries
516    /// every line (what the band draws) while `command_line()` stays the
517    /// first-line one-row view.
518    #[test]
519    fn mb2_expanded_full_text_exposes_multiline_for_the_band() {
520        let mut a = app_with("hello\n", 10);
521        a.apply(Action::EnterCommandLine);
522        press_chars(&mut a, "e foo");
523        a.apply(Action::CommandLineToggleExpand);
524        a.apply(Action::CommandLineSubmit); // newline in the band
525        press_chars(&mut a, "bar");
526        assert!(a.editor.command_line_expanded());
527        assert_eq!(a.editor.command_line(), "e foo");
528        assert!(
529            a.editor.command_line_full_text().starts_with("e foo\nbar"),
530            "band reads the full multi-line text: {:?}",
531            a.editor.command_line_full_text()
532        );
533    }
534
535    /// MB.2e: the `command-line.expand-height` option drives the
536    /// expanded band's height. The renderer reads it via
537    /// `command_line_expand_height()` and resolves it against the live
538    /// frame height (`ExpandHeight::rows`).
539    #[test]
540    fn mb2e_expand_height_option_drives_band_rows() {
541        use lattice_config::{CommandLineExpandHeight, ExpandHeight};
542        let a = app_with("hello\n", 40);
543        // Default is `half`: half of a 40-row frame, clamped.
544        assert_eq!(a.command_line_expand_height(), ExpandHeight::Half);
545        assert_eq!(a.command_line_expand_height().rows(40), 20);
546
547        // `full` grows as tall as the frame allows (one pane row kept).
548        let _ = a
549            .editor
550            .config
551            .set_typed::<CommandLineExpandHeight>(ExpandHeight::Full);
552        assert_eq!(a.command_line_expand_height(), ExpandHeight::Full);
553        assert_eq!(a.command_line_expand_height().rows(40), 38);
554
555        // A fixed row count pins the band, clamped to the frame.
556        let _ = a
557            .editor
558            .config
559            .set_typed::<CommandLineExpandHeight>(ExpandHeight::Fixed(7));
560        assert_eq!(a.command_line_expand_height().rows(40), 7);
561    }
562
563    // ── MB.3 (rich minibuffer, phase 3): the `q:` / `:history`
564    //    fuzzy history picker. Accept LOADS the picked command into
565    //    the editable `:` line — it does NOT execute. ──
566
567    /// `q:` (Normal chord) opens the history picker; accepting a row
568    /// loads that command into the `:` line WITHOUT running it, and a
569    /// subsequent `<CR>` executes it. The whole MB.3 contract in one
570    /// flow.
571    #[test]
572    fn mb3_q_colon_picker_loads_editable_line_then_cr_runs() {
573        let mut a = app_with("hello\n", 10);
574        // Build history through the real submit path.
575        submit_ex(&mut a, "set number");
576        submit_ex(&mut a, "set wrap");
577        assert!(!a.editor.command_line_active());
578
579        // `q:` in Normal opens the history picker (chord routes through
580        // the trie: `q` arms partial, `:` resolves the exact path).
581        press_chars(&mut a, "q:");
582        let p = a
583            .editor
584            .picker
585            .as_ref()
586            .expect("`q:` must open the history picker");
587        assert_eq!(p.source_id.as_deref(), Some("history"));
588        // Newest-first: `set wrap` is the top row.
589        assert!(p.candidates[0].raw.display.contains("set wrap"));
590
591        // Narrow to `set number` and accept.
592        for c in "number".chars() {
593            a.apply(Action::PickerAppend(c));
594        }
595        a.apply(Action::PickerAccept);
596
597        // Picker closed; the `:` line is open, seeded, NOT executed.
598        assert!(a.editor.picker.is_none());
599        assert!(
600            a.editor.command_line_active(),
601            "accept must open the `:` line"
602        );
603        assert_eq!(a.editor.command_line(), "set number");
604        // Nothing ran yet: history is unchanged (no dup pushed).
605        assert_eq!(
606            a.editor.command_history,
607            vec!["set number".to_string(), "set wrap".to_string()]
608        );
609
610        // `<CR>` now runs the loaded command.
611        a.apply(Action::CommandLineSubmit);
612        assert!(!a.editor.command_line_active());
613    }
614
615    /// `:history` ex-command opens the same picker as `q:`.
616    #[test]
617    fn mb3_history_ex_command_opens_picker() {
618        let mut a = app_with("hello\n", 10);
619        submit_ex(&mut a, "write");
620        submit_ex(&mut a, "history");
621        assert_eq!(
622            a.editor.picker.as_ref().unwrap().source_id.as_deref(),
623            Some("history")
624        );
625    }
626
627    /// `q:` with no history is graceful: no picker opens, no panic —
628    /// the source returns an error the host echoes.
629    #[test]
630    fn mb3_q_colon_empty_history_is_graceful() {
631        let mut a = app_with("hello\n", 10);
632        assert!(a.editor.command_history.is_empty());
633        press_chars(&mut a, "q:");
634        assert!(
635            a.editor.picker.is_none(),
636            "empty history must not open a picker"
637        );
638        assert!(!a.editor.command_line_active());
639    }
640
641    /// `q:` from the expanded tier-2 band's Normal mode seeds the
642    /// picker filter with the in-progress `:` text (vim's
643    /// command-window muscle memory).
644    #[test]
645    fn mb3_q_colon_from_expanded_band_seeds_filter() {
646        let mut a = app_with("hello\n", 10);
647        submit_ex(&mut a, "set number");
648        submit_ex(&mut a, "write");
649        // Open the `:` line, expand to tier-2, drop to Normal in the band.
650        a.apply(Action::EnterCommandLine);
651        press_chars(&mut a, "set");
652        a.apply(Action::CommandLineToggleExpand);
653        a.apply(Action::CommandLineCancel); // `<Esc>` in the band → Normal
654        assert!(a.editor.command_line_expanded());
655        assert!(matches!(a.editor.modal, ModalState::Normal));
656
657        // `q:` in the band's Normal mode opens the picker pre-filtered
658        // to `set`, so only `set number` survives.
659        press_chars(&mut a, "q:");
660        let p = a
661            .editor
662            .picker
663            .as_ref()
664            .expect("`q:` in the band must open the history picker");
665        assert_eq!(
666            p.query, "set",
667            "filter seeded from the in-progress `:` text"
668        );
669        assert_eq!(p.candidates.len(), 1);
670        assert!(p.candidates[0].raw.display.contains("set number"));
671    }
672
673    // ── MB.4 (rich minibuffer, phase 4): live `:` line decorations
674    //    — syntax highlighting, error indicator, parameter hint. ──
675
676    /// Typing a known command populates the published decorations:
677    /// the command word is a keyword span and a param hint appears;
678    /// no error. (Not submitted — `:write` would touch the fs; the
679    /// submit-clears path is exercised via cancel below and the
680    /// host-side submit handler.)
681    #[test]
682    fn mb4_typing_known_command_populates_decorations() {
683        use lattice_cells::style::Style;
684        let mut a = app_with("hello\n", 10);
685        a.apply(Action::EnterCommandLine);
686        press_chars(&mut a, "write foo");
687        let d = a
688            .editor
689            .command_line_decorations
690            .as_ref()
691            .expect("decorations produced on edit");
692        assert_eq!(
693            d.spans[0].style,
694            Style::Keyword,
695            "command word is a keyword"
696        );
697        assert!(d.error.is_none(), "known command has no error");
698        assert!(d.param_hint.is_some(), "`:write <file>` offers a hint");
699    }
700
701    /// An unknown command, once committed with a trailing space, sets
702    /// the live error indicator as the user types.
703    #[test]
704    fn mb4_unknown_command_sets_live_error() {
705        let mut a = app_with("hello\n", 10);
706        a.apply(Action::EnterCommandLine);
707        press_chars(&mut a, "frobnicate ");
708        let d = a
709            .editor
710            .command_line_decorations
711            .as_ref()
712            .expect("decorations");
713        assert_eq!(
714            d.error.as_deref(),
715            Some("unknown command: frobnicate"),
716            "committed unknown command flags an error"
717        );
718    }
719
720    /// Cancelling the `:` line clears the decorations (no stale
721    /// highlight lingers after close).
722    #[test]
723    fn mb4_cancel_clears_decorations() {
724        let mut a = app_with("hello\n", 10);
725        a.apply(Action::EnterCommandLine);
726        press_chars(&mut a, "write");
727        assert!(a.editor.command_line_decorations.is_some());
728        a.apply(Action::CommandLineCancel);
729        assert!(a.editor.command_line_decorations.is_none());
730    }
731
732    #[test]
733    fn prefer_aliases_rewrites_canonical_to_alias() {
734        use lattice_completion::{
735            CandidateData, CandidateKind, MatchScore, RawCandidate, RenderedCandidate,
736        };
737        use lattice_grammar::source::SourceLocation;
738        let mut candidates = vec![RenderedCandidate {
739            raw: RawCandidate {
740                insert_text: None,
741                text: "ex:describe-command".into(),
742                display: "ex:describe-command".into(),
743                kind: CandidateKind::Command,
744                data: CandidateData::Command {
745                    name: "ex:describe-command".into(),
746                    doc: "doc".into(),
747                    kind_label: "ex-command".into(),
748                    source: SourceLocation::synthetic("test"),
749                },
750                source: None,
751                accept_action: None,
752                annotations: Vec::new(),
753                display_spans: Vec::new(),
754            },
755            score: MatchScore::PERFECT,
756            match_ranges: vec![],
757            annotations: vec![],
758        }];
759        prefer_aliases_for_command_candidates(&mut candidates, "descri");
760        assert_eq!(candidates[0].raw.text, "describe-command");
761        assert_eq!(candidates[0].raw.display, "describe-command");
762        // Match ranges recomputed against the new text.
763        assert!(!candidates[0].match_ranges.is_empty());
764    }
765
766    #[test]
767    #[allow(clippy::single_range_in_vec_init)]
768    fn prefer_aliases_leaves_non_command_candidates_alone() {
769        use lattice_completion::{
770            CandidateData, CandidateKind, MatchScore, RawCandidate, RenderedCandidate,
771        };
772        let mut candidates = vec![RenderedCandidate {
773            raw: RawCandidate {
774                insert_text: None,
775                text: "/tmp/foo.rs".into(),
776                display: "foo.rs".into(),
777                kind: CandidateKind::File,
778                data: CandidateData::File {
779                    path: "/tmp/foo.rs".into(),
780                    is_dir: false,
781                    size: None,
782                },
783                source: None,
784                accept_action: None,
785                annotations: Vec::new(),
786                display_spans: Vec::new(),
787            },
788            score: MatchScore::PERFECT,
789            match_ranges: vec![0..3],
790            annotations: vec![],
791        }];
792        prefer_aliases_for_command_candidates(&mut candidates, "tmp");
793        // File candidate untouched.
794        assert_eq!(candidates[0].raw.text, "/tmp/foo.rs");
795    }
796
797    #[test]
798    fn prefer_aliases_keeps_canonical_when_alias_does_not_match_query() {
799        use lattice_completion::{
800            CandidateData, CandidateKind, MatchScore, RawCandidate, RenderedCandidate,
801        };
802        use lattice_grammar::source::SourceLocation;
803        // "next-error" → alias "cnext". Query "next-" matches the
804        // canonical name but NOT the alias (no '-' in "cnext").
805        // The canonical name must be preserved.
806        let mut candidates = vec![RenderedCandidate {
807            raw: RawCandidate {
808                insert_text: None,
809                text: "next-error".into(),
810                display: "next-error".into(),
811                kind: CandidateKind::Command,
812                data: CandidateData::Command {
813                    name: "next-error".into(),
814                    doc: "jump to next error".into(),
815                    kind_label: "ex-command".into(),
816                    source: SourceLocation::synthetic("test"),
817                },
818                source: None,
819                accept_action: None,
820                annotations: Vec::new(),
821                display_spans: Vec::new(),
822            },
823            score: MatchScore::PREFIX,
824            match_ranges: Vec::from([0..5]),
825            annotations: vec![],
826        }];
827        prefer_aliases_for_command_candidates(&mut candidates, "next-");
828        // Must keep the canonical name since alias "cnext" doesn't
829        // contain '-' and thus doesn't match the query.
830        assert_eq!(candidates[0].raw.text, "next-error");
831        assert_eq!(candidates[0].raw.display, "next-error");
832        // Original pipeline match ranges preserved.
833        assert_eq!(candidates[0].match_ranges, vec![0..5]);
834    }
835
836    #[test]
837    fn enter_command_line_clears_buffer_and_sets_modal() {
838        let mut a = app_with("abc", 10);
839        // A stale echo message must be cleared when the `:` line opens.
840        // (The buffer starts empty on a fresh open; MB.2's `:`-no-op guard
841        // means we can't seed a "stale" buffer without opening the line,
842        // which is itself the no-op path — covered separately.)
843        a.editor.last_message = Some(EchoMessage {
844            text: "stale".into(),
845            level: EchoLevel::Info,
846        });
847        a.apply(Action::EnterCommandLine);
848        assert_eq!(a.editor.modal, ModalState::Command);
849        assert_eq!(a.editor.command_line(), "");
850        assert!(a.editor.last_message.is_none());
851    }
852
853    #[test]
854    fn command_line_append_pushes_chars() {
855        let mut a = app_with("", 10);
856        a.apply(Action::EnterCommandLine);
857        a.apply(Action::CommandLineAppend('w'));
858        a.apply(Action::CommandLineAppend('q'));
859        assert_eq!(a.editor.command_line(), "wq");
860    }
861
862    #[test]
863    fn command_line_backspace_pops_chars() {
864        let mut a = app_with("", 10);
865        a.apply(Action::EnterCommandLine);
866        a.apply(Action::CommandLineAppend('w'));
867        a.apply(Action::CommandLineAppend('q'));
868        a.apply(Action::CommandLineBackspace);
869        assert_eq!(a.editor.command_line(), "w");
870    }
871
872    #[test]
873    fn command_line_backspace_on_empty_exits_command_modal() {
874        let mut a = app_with("", 10);
875        a.apply(Action::EnterCommandLine);
876        a.apply(Action::CommandLineBackspace);
877        assert_eq!(a.editor.modal, ModalState::Normal);
878    }
879
880    #[test]
881    fn command_line_cancel_clears_and_returns_to_normal() {
882        let mut a = app_with("", 10);
883        a.apply(Action::EnterCommandLine);
884        a.apply(Action::CommandLineAppend('w'));
885        a.apply(Action::CommandLineCancel);
886        assert_eq!(a.editor.modal, ModalState::Normal);
887        assert_eq!(a.editor.command_line(), "");
888    }
889
890    #[test]
891    fn submit_q_on_clean_buffer_quits() {
892        let mut a = app_with("hello", 10);
893        a.apply(Action::EnterCommandLine);
894        for c in "q".chars() {
895            a.apply(Action::CommandLineAppend(c));
896        }
897        a.apply(Action::CommandLineSubmit);
898        assert!(a.editor.should_quit);
899        assert_eq!(a.editor.modal, ModalState::Normal);
900    }
901
902    #[test]
903    fn submit_q_on_dirty_buffer_refuses() {
904        let mut a = app_with("hello", 10);
905        a.apply(Action::EnterMode(ModalState::Insert));
906        a.apply(Action::Insert("X".into()));
907        a.apply(Action::EnterMode(ModalState::Normal));
908        assert!(a.editor.document.dirty());
909
910        a.apply(Action::EnterCommandLine);
911        a.apply(Action::CommandLineAppend('q'));
912        a.apply(Action::CommandLineSubmit);
913        assert!(!a.editor.should_quit);
914        let msg = a.editor.last_message.as_ref().unwrap();
915        assert_eq!(msg.level, EchoLevel::Error);
916        assert!(msg.text.contains("no write since last change"));
917    }
918
919    #[test]
920    fn submit_q_bang_quits_even_when_dirty() {
921        let mut a = app_with("hello", 10);
922        a.apply(Action::EnterMode(ModalState::Insert));
923        a.apply(Action::Insert("X".into()));
924        a.apply(Action::EnterMode(ModalState::Normal));
925        a.apply(Action::EnterCommandLine);
926        for c in "q!".chars() {
927            a.apply(Action::CommandLineAppend(c));
928        }
929        a.apply(Action::CommandLineSubmit);
930        assert!(a.editor.should_quit);
931    }
932
933    #[test]
934    fn submit_w_without_path_errors() {
935        let mut a = app_with("hello", 10);
936        a.apply(Action::EnterCommandLine);
937        a.apply(Action::CommandLineAppend('w'));
938        a.apply(Action::CommandLineSubmit);
939        let msg = a.editor.last_message.as_ref().unwrap();
940        assert_eq!(msg.level, EchoLevel::Error);
941        assert!(msg.text.contains("no file name"));
942    }
943
944    #[test]
945    fn submit_w_with_path_writes_and_clears_dirty() {
946        let dir = unique_tempdir();
947        let path = dir.join("out.txt");
948        let mut a = App::new(Document::from_text("hello"));
949        a.set_viewport_height(10);
950        // Move to end of line, then enter insert and append "!".
951        a.apply(invoke_motion(a.editor.builtins.line_end));
952        a.apply(Action::EnterMode(ModalState::Insert));
953        a.apply(Action::Insert("!".into()));
954        a.apply(Action::EnterMode(ModalState::Normal));
955        assert!(a.editor.document.dirty());
956
957        a.apply(Action::EnterCommandLine);
958        for c in format!("w {}", path.display()).chars() {
959            a.apply(Action::CommandLineAppend(c));
960        }
961        a.apply(Action::CommandLineSubmit);
962
963        assert!(!a.editor.document.dirty());
964        let msg = a.editor.last_message.as_ref().unwrap();
965        assert_eq!(msg.level, EchoLevel::Info);
966        assert!(msg.text.contains("written"));
967        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello!");
968        std::fs::remove_dir_all(&dir).ok();
969    }
970
971    #[test]
972    fn submit_wq_writes_then_quits() {
973        let dir = unique_tempdir();
974        let path = dir.join("out.txt");
975        std::fs::write(&path, "first").unwrap();
976
977        let mut a = App::new(Document::open(&path).unwrap());
978        a.set_viewport_height(10);
979        a.apply(Action::EnterMode(ModalState::Insert));
980        a.apply(Action::Insert("X".into()));
981        a.apply(Action::EnterMode(ModalState::Normal));
982
983        a.apply(Action::EnterCommandLine);
984        for c in "wq".chars() {
985            a.apply(Action::CommandLineAppend(c));
986        }
987        a.apply(Action::CommandLineSubmit);
988
989        assert!(a.editor.should_quit);
990        let on_disk = std::fs::read_to_string(&path).unwrap();
991        assert!(on_disk.starts_with("X"));
992        std::fs::remove_dir_all(&dir).ok();
993    }
994
995    #[test]
996    fn submit_unknown_command_surfaces_error() {
997        let mut a = app_with("hello", 10);
998        a.apply(Action::EnterCommandLine);
999        for c in "frobnicate".chars() {
1000            a.apply(Action::CommandLineAppend(c));
1001        }
1002        a.apply(Action::CommandLineSubmit);
1003        let msg = a.editor.last_message.as_ref().unwrap();
1004        assert_eq!(msg.level, EchoLevel::Error);
1005        assert!(msg.text.contains("frobnicate"));
1006    }
1007
1008    #[test]
1009    fn submit_pushes_command_into_history() {
1010        let mut a = app_with("hello", 10);
1011        submit_ex(&mut a, "set number");
1012        assert_eq!(a.editor.command_history, vec!["set number".to_string()]);
1013    }
1014
1015    #[test]
1016    fn submit_dedupes_consecutive_identical_history() {
1017        let mut a = app_with("hello", 10);
1018        submit_ex(&mut a, "set number");
1019        submit_ex(&mut a, "set number");
1020        assert_eq!(a.editor.command_history.len(), 1);
1021    }
1022
1023    #[test]
1024    fn empty_submit_does_not_push_history() {
1025        let mut a = app_with("hello", 10);
1026        a.apply(Action::EnterCommandLine);
1027        a.apply(Action::CommandLineSubmit);
1028        assert!(a.editor.command_history.is_empty());
1029    }
1030
1031    #[test]
1032    fn up_in_command_walks_to_most_recent_history() {
1033        let mut a = app_with("hello", 10);
1034        submit_ex(&mut a, "set number");
1035        submit_ex(&mut a, "set nonumber");
1036        a.apply(Action::EnterCommandLine);
1037        a.apply(Action::CommandLineHistoryPrev);
1038        assert_eq!(a.editor.command_line(), "set nonumber");
1039    }
1040
1041    #[test]
1042    fn up_then_up_walks_to_older() {
1043        let mut a = app_with("hello", 10);
1044        submit_ex(&mut a, "set number");
1045        submit_ex(&mut a, "set nonumber");
1046        a.apply(Action::EnterCommandLine);
1047        a.apply(Action::CommandLineHistoryPrev);
1048        a.apply(Action::CommandLineHistoryPrev);
1049        assert_eq!(a.editor.command_line(), "set number");
1050    }
1051
1052    #[test]
1053    fn down_returns_to_in_progress_typed_text() {
1054        let mut a = app_with("hello", 10);
1055        submit_ex(&mut a, "set number");
1056        a.apply(Action::EnterCommandLine);
1057        for c in "se".chars() {
1058            a.apply(Action::CommandLineAppend(c));
1059        }
1060        // User starts typing "se", presses Up -> walks to "set number".
1061        a.apply(Action::CommandLineHistoryPrev);
1062        assert_eq!(a.editor.command_line(), "set number");
1063        // Down returns to "se".
1064        a.apply(Action::CommandLineHistoryNext);
1065        assert_eq!(a.editor.command_line(), "se");
1066    }
1067
1068    #[test]
1069    fn history_navigation_with_no_history_is_no_op() {
1070        let mut a = app_with("hello", 10);
1071        a.apply(Action::EnterCommandLine);
1072        a.apply(Action::CommandLineAppend('w'));
1073        a.apply(Action::CommandLineHistoryPrev);
1074        assert_eq!(a.editor.command_line(), "w");
1075    }
1076
1077    #[test]
1078    fn history_persists_across_command_sessions() {
1079        let mut a = app_with("hello", 10);
1080        submit_ex(&mut a, "set number");
1081        // Reopen command line; Up should still recall.
1082        a.apply(Action::EnterCommandLine);
1083        a.apply(Action::CommandLineHistoryPrev);
1084        assert_eq!(a.editor.command_line(), "set number");
1085    }
1086
1087    /// Regression scaffold for the user-reported "completion is
1088    /// broken" bug: simulates the full keystroke pipeline (`:`,
1089    /// then each char, then `<Tab>`) instead of pre-staging modal +
1090    /// command_line. If `tab_in_command_mode_opens_completion_popup`
1091    /// passes but this fails, the bug lives in the
1092    /// keystroke/translate/dispatch path, not in
1093    /// `do_command_line_complete_or_advance` itself.
1094    #[test]
1095    fn typing_desc_then_tab_opens_completion_popup() {
1096        let mut a = app_with("xx", 10);
1097        // Enter cmdline mode via `:` keystroke.
1098        a.apply(Action::EnterCommandLine);
1099        assert_eq!(a.editor.modal, ModalState::Command);
1100        // Append `desc`.
1101        for c in "desc".chars() {
1102            a.apply(Action::CommandLineAppend(c));
1103        }
1104        assert_eq!(a.editor.command_line(), "desc");
1105        // Tab — should open the completion popup with command-name
1106        // candidates like `describe-command`, `describe-key`.
1107        a.apply(Action::CommandLineCompleteOrAdvance);
1108        let state = a
1109            .editor
1110            .completion_state
1111            .as_ref()
1112            .expect("popup should open after :desc<Tab>");
1113        assert!(
1114            state
1115                .candidates
1116                .iter()
1117                .any(|c| c.raw.text == "describe-command"),
1118            "candidates: {:?}",
1119            state
1120                .candidates
1121                .iter()
1122                .map(|c| &c.raw.text)
1123                .collect::<Vec<_>>(),
1124        );
1125    }
1126
1127    /// `:describe-mode <Tab>` must list registered mode names
1128    /// (`gen:modes`). Arg-slot completion, not command-name
1129    /// completion -- the trailing space moves the cursor past the
1130    /// command word.
1131    #[test]
1132    fn describe_mode_arg_tab_lists_mode_names() {
1133        let mut a = app_in_command_mode("describe-mode ");
1134        a.apply(Action::CommandLineCompleteOrAdvance);
1135        let state = a
1136            .editor
1137            .completion_state
1138            .as_ref()
1139            .expect("popup should open at the `:describe-mode ` arg slot");
1140        assert!(
1141            state.candidates.iter().any(|c| c.raw.text == "text-mode"),
1142            "candidates: {:?}",
1143            state
1144                .candidates
1145                .iter()
1146                .map(|c| &c.raw.text)
1147                .collect::<Vec<_>>(),
1148        );
1149    }
1150
1151    /// The reported bug: `:describe-mode<Tab>` — command name already
1152    /// complete, no trailing space — used to be a silent no-op. The
1153    /// single command-name candidate equals the typed text, so
1154    /// auto-insert rewrote the same string and returned, and no
1155    /// number of `<Tab>`s ever reached the mode-name slot. `<Tab>`
1156    /// must step into the arg slot and show `gen:modes`.
1157    #[test]
1158    fn tab_on_complete_command_name_steps_into_the_arg_slot() {
1159        let mut a = app_in_command_mode("describe-mode");
1160        a.apply(Action::CommandLineCompleteOrAdvance);
1161        assert_eq!(
1162            a.editor.command_line(),
1163            "describe-mode ",
1164            "Tab on a complete command name must advance into its arg slot"
1165        );
1166        let state = a
1167            .editor
1168            .completion_state
1169            .as_ref()
1170            .expect("stepping into the arg slot must open that arg's popup");
1171        assert!(
1172            state.candidates.iter().any(|c| c.raw.text == "text-mode"),
1173            "candidates: {:?}",
1174            state
1175                .candidates
1176                .iter()
1177                .map(|c| &c.raw.text)
1178                .collect::<Vec<_>>(),
1179        );
1180    }
1181
1182    /// The step-right is gated on the arg actually being completable,
1183    /// so an arg-less command never grows a trailing space. `:list-modes`
1184    /// has an empty `args_schema`.
1185    #[test]
1186    fn tab_on_argless_command_does_not_append_a_space() {
1187        let mut a = app_in_command_mode("list-modes");
1188        a.apply(Action::CommandLineCompleteOrAdvance);
1189        assert_eq!(a.editor.command_line(), "list-modes");
1190    }
1191
1192    /// Same slot, partial prefix: `:describe-mode ma<Tab>`.
1193    #[test]
1194    fn describe_mode_arg_tab_filters_on_prefix() {
1195        let mut a = app_in_command_mode("describe-mode ma");
1196        a.apply(Action::CommandLineCompleteOrAdvance);
1197        let state = a
1198            .editor
1199            .completion_state
1200            .as_ref()
1201            .expect("popup should open at the `:describe-mode ma` arg slot");
1202        assert!(
1203            state
1204                .candidates
1205                .iter()
1206                .any(|c| c.raw.text.starts_with("magit-")),
1207            "candidates: {:?}",
1208            state
1209                .candidates
1210                .iter()
1211                .map(|c| &c.raw.text)
1212                .collect::<Vec<_>>(),
1213        );
1214    }
1215
1216    #[test]
1217    fn tab_in_command_mode_opens_completion_popup() {
1218        let mut a = app_in_command_mode("descri");
1219        a.apply(Action::CommandLineCompleteOrAdvance);
1220        let state = a
1221            .editor
1222            .completion_state
1223            .as_ref()
1224            .expect("popup should open");
1225        // Candidates use the user-facing alias form, not the
1226        // canonical `ex:*` registry name. Both `:describe-command`
1227        // and `:ex:describe-command` parse correctly via the
1228        // dispatcher's two-stage resolution; the popup shows the
1229        // form a user actually types.
1230        assert!(
1231            state
1232                .candidates
1233                .iter()
1234                .any(|c| c.raw.text == "describe-command")
1235        );
1236        assert!(
1237            state
1238                .candidates
1239                .iter()
1240                .any(|c| c.raw.text == "describe-buffer")
1241        );
1242        assert_eq!(state.selected, 0);
1243    }
1244
1245    #[test]
1246    fn shift_tab_walks_back_through_candidates() {
1247        let mut a = app_in_command_mode("descri");
1248        a.apply(Action::CommandLineCompleteOrAdvance);
1249        a.apply(Action::CommandLineCompleteOrAdvance);
1250        a.apply(Action::CommandLineCompleteOrAdvance);
1251        a.apply(Action::CommandLineCompletePrev);
1252        assert_eq!(a.editor.completion_state.as_ref().unwrap().selected, 1);
1253    }
1254
1255    #[test]
1256    fn chord_capture_active_only_when_in_chord_arg_slot() {
1257        // Slice 3c.final.E.5i: `chord_capture_active` reads modal
1258        // through `ad()` (RS-backed mirror), so direct field
1259        // mutations need an explicit `publish_render_state()` to
1260        // refresh the mirror.
1261        let mut a = app_with("xx", 10);
1262        a.editor.modal = ModalState::Command;
1263        a.editor.publish_render_state();
1264        // Empty cmdline -> CommandName slot, not chord-capture.
1265        a.editor.set_command_line_text("");
1266        a.editor.publish_render_state();
1267        assert!(!a.chord_capture_active());
1268        // Mid command-name slot.
1269        a.editor.set_command_line_text("describe-key");
1270        a.editor.publish_render_state();
1271        assert!(!a.chord_capture_active());
1272        // Now the cursor is past the space; arg slot is `chord`
1273        // with kind=Chord -> capture is active.
1274        a.editor.set_command_line_text("describe-key ");
1275        a.editor.publish_render_state();
1276        assert!(a.chord_capture_active());
1277        // describe-command's first arg is String, NOT Chord ->
1278        // no capture even though we're in an arg slot.
1279        a.editor.set_command_line_text("describe-command ");
1280        a.editor.publish_render_state();
1281        assert!(!a.chord_capture_active());
1282        // Outside Command modal, never active.
1283        a.editor.modal = ModalState::Normal;
1284        a.editor.publish_render_state();
1285        a.editor.set_command_line_text("describe-key ");
1286        a.editor.publish_render_state();
1287        assert!(!a.chord_capture_active());
1288    }
1289
1290    #[test]
1291    fn chord_capture_active_for_canonical_command_name() {
1292        // `:ex:describe-key ` (canonical, not the alias). The slot
1293        // detector tries `id_by_name` first and only falls back
1294        // to alias-expand, so both forms switch into chord-capture.
1295        let mut a = app_with("xx", 10);
1296        a.editor.modal = ModalState::Command;
1297        a.editor.publish_render_state();
1298        a.editor.set_command_line_text("ex:describe-key ");
1299        a.editor.publish_render_state();
1300        assert!(a.chord_capture_active());
1301    }
1302
1303    #[test]
1304    fn empty_submit_of_describe_key_arms_chord_prompt() {
1305        // User typed `:describe-key<CR>` with no arg. The required
1306        // Chord arg is missing -- we shouldn't error; we should
1307        // prefill the cmdline and arm auto-submit.
1308        let mut a = app_in_command_mode("describe-key");
1309        a.apply(Action::CommandLineSubmit);
1310        assert_eq!(a.editor.command_line(), "describe-key ");
1311        assert!(a.editor.auto_submit_after_chord);
1312        assert!(matches!(a.editor.modal, ModalState::Command));
1313    }
1314
1315    // The three tests below press real keys, so they need crossterm's types
1316    // in scope alongside the `Action`-level helpers this module uses.
1317    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1318
1319    /// **Reported 2026-09-11.** `:describe-key`, then `y`, then Enter
1320    /// answered "`y<CR>` is not bound".
1321    ///
1322    /// `y` is both a complete binding and a prefix (`yy`, `yiw`), so the
1323    /// trie's "can anything extend this?" question is `true` forever and the
1324    /// capture never ended — whatever was pressed next got welded on. Every
1325    /// vim operator was undescribable the same way.
1326    #[test]
1327    fn enter_terminates_a_chord_capture_that_is_also_a_prefix() {
1328        let mut a = app_in_command_mode("describe-key");
1329        a.apply(Action::CommandLineSubmit);
1330        assert!(a.editor.auto_submit_after_chord, "precondition: armed");
1331
1332        press(
1333            &mut a,
1334            KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
1335        );
1336        assert_eq!(
1337            a.editor.command_line(),
1338            "describe-key y",
1339            "`y` alone does not submit — `yy` and `yiw` exist, so the capture \
1340             is right to wait"
1341        );
1342
1343        press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1344        assert!(
1345            !a.editor
1346                .last_message
1347                .as_ref()
1348                .map(|m| m.text.contains("y<CR>"))
1349                .unwrap_or(false),
1350            "the terminator must not become part of the chord, got {:?}",
1351            a.editor.last_message
1352        );
1353    }
1354
1355    /// The property the reserved-key removal was protecting, kept: with
1356    /// NOTHING captured yet, Enter still describes itself. That proviso is
1357    /// what lets `<CR>` be a terminator at all.
1358    #[test]
1359    fn enter_as_the_first_captured_key_still_describes_enter() {
1360        let mut a = app_in_command_mode("describe-key");
1361        a.apply(Action::CommandLineSubmit);
1362        press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1363        assert!(
1364            !a.editor.auto_submit_after_chord,
1365            "the capture completed rather than waiting"
1366        );
1367    }
1368
1369    /// A genuinely multi-key chord still auto-submits without a terminator —
1370    /// `gg` is `Bound` and nothing extends it.
1371    #[test]
1372    fn a_complete_multi_key_chord_still_needs_no_terminator() {
1373        let mut a = app_in_command_mode("describe-key");
1374        a.apply(Action::CommandLineSubmit);
1375        press(
1376            &mut a,
1377            KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
1378        );
1379        press(
1380            &mut a,
1381            KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
1382        );
1383        assert!(
1384            !a.editor.command_line().contains("<CR>"),
1385            "no terminator was needed: {:?}",
1386            a.editor.command_line()
1387        );
1388    }
1389
1390    #[test]
1391    fn empty_submit_of_canonical_describe_key_arms_chord_prompt() {
1392        // Same prompt path through the canonical name, not just
1393        // the alias.
1394        let mut a = app_in_command_mode("ex:describe-key");
1395        a.apply(Action::CommandLineSubmit);
1396        assert_eq!(a.editor.command_line(), "ex:describe-key ");
1397        assert!(a.editor.auto_submit_after_chord);
1398    }
1399
1400    #[test]
1401    fn empty_submit_of_describe_command_arms_prompt_without_chord_capture() {
1402        // describe-command's first arg is String (Required) -- the
1403        // generalized missing-arg path arms a prompt, prefills the
1404        // cmdline, and leaves the user in Command mode to type the
1405        // arg. Auto-submit is OFF (only Chord-kind args auto-submit
1406        // on the next keystroke).
1407        let mut a = app_in_command_mode("describe-command");
1408        a.apply(Action::CommandLineSubmit);
1409        assert!(matches!(a.editor.modal, ModalState::Command));
1410        assert!(!a.editor.auto_submit_after_chord);
1411        // Prefilled with the command word + space; cursor in arg slot.
1412        assert_eq!(a.editor.command_line(), "describe-command ");
1413        // Echo area carries the arg's prompt.
1414        assert!(a.editor.last_message.is_some());
1415    }
1416
1417    #[test]
1418    fn empty_submit_of_optional_arg_command_does_not_arm_prompt() {
1419        // `:write` (alias for `ex:write`) has an OPTIONAL path arg
1420        // (default = `None` -- absent means "use current path").
1421        // Submitting bare runs the command normally; no prompt arm.
1422        let mut a = app_in_command_mode("w");
1423        a.apply(Action::CommandLineSubmit);
1424        // Cmdline closed -- the missing-arg prompt path skipped this
1425        // command because its schema's first arg is Optional.
1426        assert!(matches!(a.editor.modal, ModalState::Normal));
1427        assert!(!a.editor.auto_submit_after_chord);
1428    }
1429
1430    #[test]
1431    fn missing_arg_prompt_preserves_user_alias() {
1432        // User typed the alias `apropos`; prefill must preserve the
1433        // alias rather than normalising to the canonical
1434        // `ex:apropos`. (Apropos's `pattern` arg is Required.)
1435        let mut a = app_in_command_mode("apropos");
1436        a.apply(Action::CommandLineSubmit);
1437        assert_eq!(a.editor.command_line(), "apropos ");
1438        assert!(matches!(a.editor.modal, ModalState::Command));
1439    }
1440
1441    #[test]
1442    fn submit_with_arg_supplied_takes_normal_path() {
1443        // `describe-key j` with explicit arg should NOT enter
1444        // prompt mode -- it should just dispatch.
1445        let mut a = app_in_command_mode("describe-key j");
1446        a.apply(Action::CommandLineSubmit);
1447        assert!(!a.editor.auto_submit_after_chord);
1448        assert!(matches!(a.editor.modal, ModalState::Normal));
1449        assert!(a.editor.popup_buffer.is_some());
1450    }
1451
1452    #[test]
1453    fn ctrl_h_on_known_command_describes_it_directly() {
1454        // `:describe-command` on the cmdline; <C-h> describes that
1455        // command itself (smart-resolve).
1456        let mut a = app_in_command_mode("describe-command");
1457        a.apply(Action::CommandLineDescribeUnderCursor);
1458        let h = a.popup_help().expect("help should open");
1459        assert!(h.title.contains("ex:describe-command"));
1460    }
1461
1462    #[test]
1463    fn ctrl_h_on_arg_describes_parent_command_at_arg_anchor() {
1464        // `:describe-command moti` -- the cursor's word `moti`
1465        // doesn't resolve to a command; fall back to describing
1466        // the parent (`ex:describe-command`) scrolled to the
1467        // `arg:name` anchor.
1468        let mut a = app_in_command_mode("describe-command moti");
1469        a.apply(Action::CommandLineDescribeUnderCursor);
1470        let h = a.popup_help().expect("help should open");
1471        assert!(h.title.contains("ex:describe-command"));
1472        let scroll = h.scroll;
1473        // scroll should be set to the arg:name anchor's line.
1474        let anchors = a.popup_help_anchors().expect("help anchors seeded");
1475        let arg_anchor = anchors.iter().find(|a| a.name == "arg:name").unwrap();
1476        assert_eq!(scroll, arg_anchor.line as usize);
1477    }
1478
1479    #[test]
1480    fn ctrl_h_on_arg_value_that_is_a_known_command_describes_it() {
1481        // `:describe-command motion:line-down` -- the arg VALUE
1482        // resolves to a known command. Hybrid: describe THAT.
1483        let mut a = app_in_command_mode("describe-command motion:line-down");
1484        a.apply(Action::CommandLineDescribeUnderCursor);
1485        let h = a.popup_help().expect("help should open");
1486        assert!(h.title.contains("motion:line-down"));
1487    }
1488
1489    #[test]
1490    fn ctrl_h_on_unknown_word_emits_error_message() {
1491        let mut a = app_in_command_mode("no-such-command");
1492        a.apply(Action::CommandLineDescribeUnderCursor);
1493        assert!(a.editor.popup_buffer.is_none());
1494        let msg = a.editor.last_message.as_ref().unwrap();
1495        assert_eq!(msg.level, EchoLevel::Error);
1496    }
1497
1498    #[test]
1499    fn cmdline_completion_includes_lsp_subcommand_aliases() {
1500        // Diagnostic: typing `:lsp-` and tabbing should surface
1501        // `lsp-trace`, `lsp-restart`, `lsp-status`, etc. -- the
1502        // user-facing aliases for `ex:lsp-trace` etc. The
1503        // CommandsGenerator returns canonical names (`ex:lsp-trace`);
1504        // `prefer_aliases_for_command_candidates` rewrites them
1505        // to the longest alias (`lsp-trace`). User reported these
1506        // not appearing; pin the wiring with a regression test.
1507        let mut a = app_in_command_mode("lsp-");
1508        a.apply(Action::CommandLineCompleteOrAdvance);
1509        let state = a
1510            .editor
1511            .completion_state
1512            .as_ref()
1513            .expect("popup should open");
1514        let texts: Vec<&str> = state
1515            .candidates
1516            .iter()
1517            .map(|c| c.raw.text.as_str())
1518            .collect();
1519        for needle in [
1520            "lsp-trace",
1521            "lsp-status",
1522            "lsp-restart",
1523            "lsp-log",
1524            "lsp-log-level",
1525            "lsp-log-clear",
1526        ] {
1527            assert!(
1528                texts.contains(&needle),
1529                "completion should include `{needle}` -- got {:?}",
1530                texts
1531            );
1532        }
1533    }
1534}
1535
1536// ── The generic prompt (`Effect::OpenPrompt`) key path ──────────────
1537//
1538// Reported 2026-08-03: an open prompt could not be cancelled — `<Esc>`
1539// did nothing and the editor had to be killed. Root cause was that
1540// `input::translate`'s modal dispatch table had no `ModalState::Prompt`
1541// arm, so EVERY key fell to its `_ => Action::None` catch-all: not just
1542// cancel, but typing and submit too.
1543//
1544// These drive real `KeyEvent`s through `translate` + `apply` (the
1545// `press` harness), because the hole was in translate. Every prior test
1546// of this feature called `do_prompt_line_submit` directly, which is why
1547// a completely dead key path shipped: the handlers were right, and
1548// nothing could reach them.
1549#[cfg(test)]
1550mod prompt_line {
1551    use crate::app::test_helpers::{app_with, press};
1552    use crate::app::{Action, App};
1553    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1554    use lattice_grammar::ModalState;
1555
1556    fn app_with_prompt() -> App {
1557        let mut a = app_with("hello\n", 10);
1558        a.editor.open_prompt_line(
1559            "Test prompt: ".to_string(),
1560            String::new(),
1561            "action:no-such-action".to_string(),
1562            Some("*magit:test-prompt*".to_string()),
1563        );
1564        // `press` dispatches against the PUBLISHED snapshot, exactly as
1565        // the real input loop does. Without this the keys would be
1566        // translated in the pre-prompt mode and the test would exercise
1567        // nothing — the first reproduction attempt did precisely that
1568        // and misread a stale-snapshot artefact as the bug.
1569        a.editor.publish_render_state();
1570        assert!(
1571            matches!(a.editor.modal, ModalState::Prompt),
1572            "the prompt must actually be open before the key is pressed"
1573        );
1574        a
1575    }
1576
1577    /// The reported bug. `<Esc>` must leave the prompt.
1578    #[test]
1579    fn esc_cancels_an_open_prompt() {
1580        let mut a = app_with_prompt();
1581        press(&mut a, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1582        assert!(
1583            !matches!(a.editor.modal, ModalState::Prompt),
1584            "`<Esc>` must cancel the prompt — with no arm in translate's \
1585             modal table there was NO way out of this state"
1586        );
1587    }
1588
1589    /// `<C-c>` is the other advertised cancel, and the one a user
1590    /// reaches for when Esc appears not to work.
1591    #[test]
1592    fn ctrl_c_cancels_an_open_prompt() {
1593        let mut a = app_with_prompt();
1594        press(
1595            &mut a,
1596            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
1597        );
1598        assert!(
1599            !matches!(a.editor.modal, ModalState::Prompt),
1600            "`<C-c>` must cancel the prompt too"
1601        );
1602    }
1603
1604    /// Typing was dead for the same reason, which is worth its own test:
1605    /// a prompt you cannot type into is not merely hard to leave, it
1606    /// never worked at all.
1607    #[test]
1608    fn typing_reaches_the_prompt_buffer() {
1609        let mut a = app_with_prompt();
1610        for c in "v1.0".chars() {
1611            press(&mut a, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
1612            a.editor.publish_render_state();
1613        }
1614        assert_eq!(
1615            a.editor.document.text().lines().next().unwrap_or_default(),
1616            "v1.0",
1617            "printable keys must reach the focused prompt buffer"
1618        );
1619    }
1620
1621    /// And `<CR>` must submit. Paired with the typing test so a fix that
1622    /// restores one without the other cannot pass.
1623    #[test]
1624    fn cr_submits_and_leaves_the_prompt() {
1625        let mut a = app_with_prompt();
1626        for c in "abc".chars() {
1627            press(&mut a, KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
1628            a.editor.publish_render_state();
1629        }
1630        press(&mut a, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1631        assert!(
1632            !matches!(a.editor.modal, ModalState::Prompt),
1633            "`<CR>` must close the prompt even when the submit action is \
1634             unknown — otherwise a typo'd action name is another trap"
1635        );
1636    }
1637
1638    /// Bracketed paste must land in the PROMPT, not in the document
1639    /// behind it. `do_paste_text` routes by modal state, and a state it
1640    /// does not know falls to the generic arm — which is exactly how a
1641    /// paste with a picker open once silently edited the user's file
1642    /// (fixed in `645b8934`). Pasting a URL into the clone prompt is
1643    /// the common case, so this is pinned rather than assumed.
1644    #[test]
1645    fn paste_lands_in_the_prompt_not_the_document_behind_it() {
1646        let mut a = app_with("hello\n", 10);
1647        a.editor.open_prompt_line(
1648            "Clone repository: ".to_string(),
1649            String::new(),
1650            "action:no-such-action".to_string(),
1651            Some("*magit:clone*".to_string()),
1652        );
1653        a.editor.publish_render_state();
1654        a.editor.do_paste_text("https://github.com/owner/repo.git");
1655        assert_eq!(
1656            a.editor.document.text().lines().next().unwrap_or_default(),
1657            "https://github.com/owner/repo.git",
1658            "the pasted URL must reach the prompt buffer"
1659        );
1660    }
1661
1662    /// Paste appends at the cursor, so it composes with a seeded value
1663    /// and with typing — you can paste a URL after correcting a prefix.
1664    #[test]
1665    fn paste_inserts_at_the_cursor_alongside_typed_text() {
1666        let mut a = app_with("hello\n", 10);
1667        a.editor.open_prompt_line(
1668            "Clone into: ".to_string(),
1669            "/tmp/".to_string(),
1670            "action:no-such-action".to_string(),
1671            None,
1672        );
1673        a.editor.publish_render_state();
1674        a.editor.do_paste_text("repo");
1675        assert_eq!(
1676            a.editor.document.text().lines().next().unwrap_or_default(),
1677            "/tmp/repo",
1678            "paste must land at the cursor, after the seeded text"
1679        );
1680    }
1681
1682    /// A copied line usually carries its newline. A one-line prompt must
1683    /// not become multi-line from it — the row renders one line, so the
1684    /// rest would be invisible text that `do_prompt_line_submit`'s
1685    /// first-line read then silently drops.
1686    #[test]
1687    fn a_pasted_newline_does_not_split_a_one_line_prompt() {
1688        let mut a = app_with("hello\n", 10);
1689        a.editor.open_prompt_line(
1690            "Clone repository: ".to_string(),
1691            String::new(),
1692            "action:no-such-action".to_string(),
1693            None,
1694        );
1695        a.editor.publish_render_state();
1696        a.editor.do_paste_text("https://host/o/r.git\n");
1697        assert_eq!(
1698            a.editor.document.text().lines().count(),
1699            1,
1700            "a trailing newline must not add a second line: {:?}",
1701            a.editor.document.text()
1702        );
1703        assert_eq!(
1704            a.editor.document.text().lines().next().unwrap_or_default(),
1705            "https://host/o/r.git",
1706            "and the value itself survives whole"
1707        );
1708    }
1709
1710    /// The three readline surfaces must agree, which is the whole point
1711    /// of them sharing one arm. A pasted newline is flattened on every
1712    /// one of them, not just the prompt that prompted the fix.
1713    ///
1714    /// Non-vacuous by construction: the payload has an INTERIOR newline,
1715    /// so a `trim`-only implementation (or none at all) leaves two lines
1716    /// and fails. The earlier version of this test used a trailing
1717    /// newline and `trim_end_matches`, which passed against the
1718    /// unflattened behaviour it was meant to catch.
1719    #[test]
1720    fn every_readline_surface_flattens_a_pasted_newline() {
1721        let mut a = app_with("hello\n", 10);
1722        a.apply(Action::EnterCommandLine);
1723        a.editor.publish_render_state();
1724        a.editor.do_paste_text("e A\nB");
1725        assert_eq!(
1726            a.editor.command_line_full_text(),
1727            "e A B",
1728            "the `:` line stays one row"
1729        );
1730
1731        let mut b = app_with("hello\n", 10);
1732        b.apply(Action::EnterSearch(
1733            lattice_grammar::SearchDirection::Forward,
1734        ));
1735        b.editor.publish_render_state();
1736        b.editor.do_paste_text("A\nB");
1737        assert_eq!(
1738            b.editor.document.text().lines().count(),
1739            1,
1740            "the search line stays one row: {:?}",
1741            b.editor.document.text()
1742        );
1743
1744        let mut c = app_with("hello\n", 10);
1745        c.editor.open_prompt_line(
1746            "P: ".to_string(),
1747            String::new(),
1748            "action:no-such-action".to_string(),
1749            None,
1750        );
1751        c.editor.publish_render_state();
1752        c.editor.do_paste_text("A\nB");
1753        assert_eq!(
1754            c.editor.document.text().lines().next().unwrap_or_default(),
1755            "A B",
1756            "and so does the prompt"
1757        );
1758    }
1759
1760    /// The expanded mini-buffer band (`C-x C-e`) is multi-line on
1761    /// purpose — `<CR>` inserts a newline there — so a paste must keep
1762    /// its newlines. This is the case that makes the flattening
1763    /// conditional rather than unconditional.
1764    #[test]
1765    fn the_expanded_band_keeps_pasted_newlines() {
1766        let mut a = app_with("hello\n", 10);
1767        a.apply(Action::EnterCommandLine);
1768        a.apply(Action::CommandLineToggleExpand);
1769        a.editor.publish_render_state();
1770        a.editor.do_paste_text("e A\nB");
1771        assert!(
1772            a.editor.command_line_full_text().contains('\n'),
1773            "the band is multi-line by design: {:?}",
1774            a.editor.command_line_full_text()
1775        );
1776    }
1777
1778    // ── OS paste (the GUI peer's Cmd+V) ─────────────────────────────
1779    //
1780    // The TUI gets its paste payload from the terminal as a
1781    // bracketed-paste burst. GPUI has no such event — a GUI toolkit
1782    // delivers the shortcut as an ordinary key and expects the
1783    // application to read the clipboard itself — so the peer had no
1784    // paste route at all and Cmd+V did nothing there.
1785    //
1786    // `do_paste_from_clipboard` is that route, and it is tested here
1787    // rather than in the GPUI crate because the semantics are the host's
1788    // and the renderer half is three lines of key matching.
1789
1790    fn with_clipboard(app: &mut App, text: &str) {
1791        let cb: lattice_core::ClipboardHandle =
1792            std::sync::Arc::new(lattice_core::FakeClipboard::new());
1793        cb.write(text.to_string());
1794        if let Some(services) = std::sync::Arc::get_mut(&mut app.editor.services) {
1795            services.register(cb);
1796        }
1797    }
1798
1799    /// The clipboard's text reaches the buffer at the cursor.
1800    #[test]
1801    fn os_paste_inserts_the_clipboard_at_the_cursor() {
1802        let mut a = app_with("hello\n", 10);
1803        with_clipboard(&mut a, " world");
1804        a.editor.cursor = lattice_protocol::position::Position::new(0, 5);
1805        a.editor.do_paste_from_clipboard();
1806        assert_eq!(
1807            a.editor.document.text().lines().next().unwrap_or_default(),
1808            "hello world",
1809            "OS paste inserts literally at the cursor — not vim's `p` \
1810             placement, which is what every other application does with \
1811             this shortcut"
1812        );
1813    }
1814
1815    /// It reaches a readline surface too, which is the case that was
1816    /// reported broken: Cmd+V into `:` or a prompt did nothing.
1817    #[test]
1818    fn os_paste_reaches_an_open_prompt() {
1819        let mut a = app_with("hello\n", 10);
1820        with_clipboard(&mut a, "https://host/o/r.git");
1821        a.editor.open_prompt_line(
1822            "Clone repository: ".to_string(),
1823            String::new(),
1824            "action:no-such-action".to_string(),
1825            None,
1826        );
1827        a.editor.publish_render_state();
1828        a.editor.do_paste_from_clipboard();
1829        assert_eq!(
1830            a.editor.document.text().lines().next().unwrap_or_default(),
1831            "https://host/o/r.git",
1832            "the prompt, not the document behind it"
1833        );
1834    }
1835
1836    /// And it goes through the same one-row flattening every readline
1837    /// surface gets — a Cmd+V of a copied line carries its newline.
1838    #[test]
1839    fn os_paste_into_a_readline_surface_is_flattened() {
1840        let mut a = app_with("hello\n", 10);
1841        with_clipboard(&mut a, "A\nB");
1842        a.apply(Action::EnterCommandLine);
1843        a.editor.publish_render_state();
1844        a.editor.do_paste_from_clipboard();
1845        assert_eq!(
1846            a.editor.command_line_full_text(),
1847            "A B",
1848            "the `:` line stays one row"
1849        );
1850    }
1851
1852    /// An empty clipboard is a no-op, not an empty edit — a paste that
1853    /// silently cleared a selection or bumped the cursor would be worse
1854    /// than nothing happening.
1855    #[test]
1856    fn an_empty_clipboard_does_nothing() {
1857        let mut a = app_with("hello\n", 10);
1858        let before = a.editor.document.text();
1859        a.editor.do_paste_from_clipboard();
1860        assert_eq!(a.editor.document.text(), before);
1861    }
1862
1863    /// The initial text is seeded and editable: the destination prompts
1864    /// (`C-c f v`'s `HEAD`, the clone wizard's derived directory) are
1865    /// only useful if the user can correct what was pre-filled.
1866    #[test]
1867    fn seeded_text_can_be_edited() {
1868        let mut a = app_with("hello\n", 10);
1869        a.editor.open_prompt_line(
1870            "Clone into: ".to_string(),
1871            "/tmp/repo".to_string(),
1872            "action:no-such-action".to_string(),
1873            None,
1874        );
1875        a.editor.publish_render_state();
1876        press(
1877            &mut a,
1878            KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
1879        );
1880        press(
1881            &mut a,
1882            KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE),
1883        );
1884        assert_eq!(
1885            a.editor.document.text().lines().next().unwrap_or_default(),
1886            "/tmp/rep2",
1887            "the pre-filled value must be editable, not read-only"
1888        );
1889    }
1890}