hjkl 0.20.1

Vim-modal terminal editor: standalone TUI built on the hjkl engine.
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
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
//! Keymap construction — builds the app-level chord trie and provides the
//! engine-input-to-crossterm translation used by macro replay.
//!
//! Owns: [`build_app_keymap`], [`engine_input_to_key_event`].

use std::time::Duration;

use hjkl_keymap::Keymap;

use crate::app::{NavDir, SearchDir};
use crate::keymap_actions::AppAction;

use super::keymap;

/// Build the Normal-mode application keymap for the given leader character.
///
/// Every app-handled chord binding is registered here. The resulting
/// `Keymap<AppAction, keymap::HjklMode>` is stored on [`App`] and consulted by the event loop
/// before forwarding keys to the editor engine.
pub(crate) fn build_app_keymap(leader: char) -> Keymap<AppAction, keymap::HjklMode> {
    use keymap::HjklMode as Mode;
    let mut km = Keymap::new(leader);
    // Timeout matches the which-key delay default; overridden by `with_config`.
    km.set_timeout(Duration::from_millis(500));

    let bindings: &[(&str, AppAction, &str)] = &[
        // ── Prompt / overlay entry (Phase 2 — issue #120) ─────────────────
        // These chords are registered in the keymap trie and dispatched via
        // dispatch_action, removing the need for inline intercepts in run().
        // `:` in Normal mode is gated on pending_state.is_none() at the
        // dispatch site (see dispatch_action arm) to preserve `@:` behaviour.
        (":", AppAction::OpenCommandPrompt, "open command prompt"),
        (
            "/",
            AppAction::OpenSearchPrompt(SearchDir::Forward),
            "search forward",
        ),
        (
            "?",
            AppAction::OpenSearchPrompt(SearchDir::Backward),
            "search backward",
        ),
        ("K", AppAction::LspHover, "lsp hover"),
        ("<C-^>", AppAction::BufferAlt, "alt buffer"),
        // <C-6> is the ASCII-terminal alias for <C-^> on some terminals.
        ("<C-6>", AppAction::BufferAlt, "alt buffer"),
        // ── File / buffer / grep pickers ──────────────────────────────────
        ("<leader><leader>", AppAction::OpenFilePicker, "file picker"),
        ("<leader>f", AppAction::OpenFilePicker, "file picker"),
        ("<leader>b", AppAction::OpenBufferPicker, "buffer picker"),
        ("<leader>/", AppAction::OpenGrepPicker, "grep picker"),
        // ── Git sub-commands ───────────────────────────────────────────────
        ("<leader>gs", AppAction::GitStatus, "git status"),
        ("<leader>gl", AppAction::GitLog, "git log"),
        ("<leader>gb", AppAction::GitBranch, "git branches"),
        ("<leader>gB", AppAction::GitFileHistory, "git file history"),
        ("<leader>gS", AppAction::GitStashes, "git stashes"),
        ("<leader>gt", AppAction::GitTags, "git tags"),
        ("<leader>gr", AppAction::GitRemotes, "git remotes"),
        // ── LSP / diagnostics ─────────────────────────────────────────────
        ("<leader>d", AppAction::ShowDiagAtCursor, "show diagnostic"),
        ("<leader>ca", AppAction::LspCodeActions, "code actions"),
        ("<leader>rn", AppAction::LspRename, "rename symbol"),
        // ── g-prefix ──────────────────────────────────────────────────────
        // NOTE: bare `g` is bound separately below as BeginPendingAfterG.
        // The app-level g-chord actions (gt, gd, etc.) are dispatched from
        // the AfterGChord arm in event_loop.rs rather than the trie, so
        // that a bare `g` can immediately set pending state without waiting
        // for the trie timeout (Ambiguous resolution).
        // ── ] / [ bracket motions ─────────────────────────────────────────
        ("]b", AppAction::BufferNext, "next buffer"),
        ("[b", AppAction::BufferPrev, "prev buffer"),
        ("]d", AppAction::DiagNext, "next diagnostic"),
        ("[d", AppAction::DiagPrev, "prev diagnostic"),
        ("]D", AppAction::DiagNextError, "next error"),
        ("[D", AppAction::DiagPrevError, "prev error"),
        // ── <C-w> window motions ──────────────────────────────────────────
        ("<C-w>h", AppAction::FocusLeft, "focus left"),
        ("<C-w>j", AppAction::FocusBelow, "focus down"),
        ("<C-w>k", AppAction::FocusAbove, "focus up"),
        ("<C-w>l", AppAction::FocusRight, "focus right"),
        ("<C-w>w", AppAction::FocusNext, "focus next"),
        ("<C-w>W", AppAction::FocusPrev, "focus prev"),
        ("<C-w>c", AppAction::CloseFocusedWindow, "close window"),
        ("<C-w>q", AppAction::QuitOrClose, "quit/close"),
        ("<C-w>o", AppAction::OnlyFocusedWindow, "close others"),
        ("<C-w>x", AppAction::SwapWithSibling, "swap with sibling"),
        ("<C-w>r", AppAction::SwapWithSibling, "swap with sibling"),
        ("<C-w>R", AppAction::SwapWithSibling, "swap with sibling"),
        ("<C-w>T", AppAction::MoveWindowToNewTab, "move to new tab"),
        ("<C-w>n", AppAction::NewSplit, "new split"),
        ("<C-w>+", AppAction::ResizeHeight(1), "taller"),
        ("<C-w>-", AppAction::ResizeHeight(-1), "shorter"),
        ("<C-w><gt>", AppAction::ResizeWidth(1), "wider"),
        ("<C-w><lt>", AppAction::ResizeWidth(-1), "narrower"),
        ("<C-w>=", AppAction::EqualizeLayout, "equalize"),
        ("<C-w>_", AppAction::MaximizeHeight, "maximize height"),
        ("<C-w>|", AppAction::MaximizeWidth, "maximize width"),
    ];

    for (chord_str, action, desc) in bindings {
        if let Err(e) = km.add(Mode::Normal, chord_str, action.clone(), desc) {
            // Should never fail with our static strings, but log rather than panic.
            eprintln!("hjkl: keymap.add({chord_str:?}) failed: {e}");
        }
    }

    // ── pending-state chords ───────────────────────────────────────────────
    // `r<x>` — begin Replace pending state. Bound in both Normal and Visual so
    // the trie intercepts `r` before the engine FSM sees it.
    let replace_action = AppAction::BeginPendingReplace { count: 1 };
    for mode in [Mode::Normal, Mode::Visual] {
        if let Err(e) = km.add(mode, "r", replace_action.clone(), "replace char") {
            eprintln!("hjkl: keymap.add(r) failed: {e}");
        }
    }

    // `f<x>` / `F<x>` / `t<x>` / `T<x>` — bare find chords, migrated to
    // hjkl-vim's PendingState::Find reducer. Bound in Normal and Visual only.
    // Operator-pending find (`df<x>`, etc.) still goes through the engine FSM.
    for (key, forward, till, desc) in [
        ("f", true, false, "find char forward"),
        ("F", false, false, "find char backward"),
        ("t", true, true, "till char forward"),
        ("T", false, true, "till char backward"),
    ] {
        let action = AppAction::BeginPendingFind {
            forward,
            till,
            count: 1,
        };
        for mode in [Mode::Normal, Mode::Visual] {
            if let Err(e) = km.add(mode, key, action.clone(), desc) {
                eprintln!("hjkl: keymap.add({key}) failed: {e}");
            }
        }
    }

    // `g<x>` — bare g-prefix chord, migrated to hjkl-vim's
    // PendingState::AfterG reducer. Bound in Normal + all three visual
    // modes so `gg` (and other g-chords) work consistently in
    // visual/visual-line/visual-block. Operator-pending g (`dgU`, etc.)
    // and the engine's internal `Pending::G` / `Pending::OpG` still go
    // through the engine FSM.
    let after_g_action = AppAction::BeginPendingAfterG { count: 1 };
    for mode in [
        Mode::Normal,
        Mode::Visual,
        Mode::VisualLine,
        Mode::VisualBlock,
    ] {
        if let Err(e) = km.add(mode, "g", after_g_action.clone(), "g-prefix chord") {
            eprintln!("hjkl: keymap.add(g) failed: {e}");
        }
    }

    // `z<x>` — bare z-prefix chord, migrated to hjkl-vim's
    // PendingState::AfterZ reducer. Bound in Normal + all three visual
    // modes for parity with `g`. Operator-pending z (`zf{motion}`) and
    // the engine's internal `Pending::Z` still go through the engine
    // FSM for non-visual `zf`.
    let after_z_action = AppAction::BeginPendingAfterZ { count: 1 };
    for mode in [
        Mode::Normal,
        Mode::Visual,
        Mode::VisualLine,
        Mode::VisualBlock,
    ] {
        if let Err(e) = km.add(mode, "z", after_z_action.clone(), "z-prefix chord") {
            eprintln!("hjkl: keymap.add(z) failed: {e}");
        }
    }

    // `d` / `y` / `c` / `>` / `<` — bare op-pending entry from Normal mode,
    // migrated to hjkl-vim's PendingState::AfterOp reducer. Bound in Normal
    // mode only. Visual-mode `d`/`y`/`c`/`>`/`<` execute inline through the
    // engine FSM and are NOT intercepted here.
    //
    // The `>` and `<` chars need quoting in the chord string per hjkl-keymap
    // notation (`<gt>` and `<lt>`).
    for (key, op, desc) in [
        ("d", hjkl_vim::OperatorKind::Delete, "delete operator"),
        ("y", hjkl_vim::OperatorKind::Yank, "yank operator"),
        ("c", hjkl_vim::OperatorKind::Change, "change operator"),
        ("<gt>", hjkl_vim::OperatorKind::Indent, "indent operator"),
        ("<lt>", hjkl_vim::OperatorKind::Outdent, "outdent operator"),
        (
            "=",
            hjkl_vim::OperatorKind::AutoIndent,
            "auto-indent operator",
        ),
    ] {
        let action = AppAction::BeginPendingAfterOp { op, count1: 1 };
        if let Err(e) = km.add(Mode::Normal, key, action, desc) {
            eprintln!("hjkl: keymap.add({key}) failed: {e}");
        }
    }

    // Visual-mode operators — fire inline against the current selection.
    // `d` / `y` / `c` / `>` / `<` bound in HjklMode::Visual (covers Visual,
    // VisualLine, and VisualBlock per the mode-collapse in keymap.rs:125).
    //
    // All three modes (Visual, VisualLine, VisualBlock) route through the
    // public range-mutation primitives. Phase 4e follow-ups closed the gaps:
    //   - pending_register() getter exposed (Visual register honors "a prefix)
    //   - run_operator_over_range linewise guard fixed (VisualLine single-row)
    //   - delete_block/yank_block/change_block/indent_block exposed (VisualBlock)
    for (key, op, desc) in [
        ("d", hjkl_vim::OperatorKind::Delete, "delete selection"),
        ("y", hjkl_vim::OperatorKind::Yank, "yank selection"),
        ("c", hjkl_vim::OperatorKind::Change, "change selection"),
        ("<gt>", hjkl_vim::OperatorKind::Indent, "indent selection"),
        ("<lt>", hjkl_vim::OperatorKind::Outdent, "outdent selection"),
        (
            "=",
            hjkl_vim::OperatorKind::AutoIndent,
            "auto-indent selection",
        ),
    ] {
        let action = AppAction::VisualOp { op, count: 1 };
        if let Err(e) = km.add(Mode::Visual, key, action, desc) {
            eprintln!("hjkl: keymap.add({key} Visual) failed: {e}");
        }
    }

    // `"<reg>` — register-prefix chord in Normal mode only. Visual-mode `"`
    // is not intercepted here; the engine FSM handles any Visual-mode `"`
    // input directly (there is no visual-register-select path in the engine).
    // Bound Normal-only, matching how vim treats `"` in Normal vs Visual mode.
    if let Err(e) = km.add(
        Mode::Normal,
        "\"",
        AppAction::BeginPendingSelectRegister,
        "register-prefix chord",
    ) {
        eprintln!("hjkl: keymap.add(\\\") failed: {e}");
    }

    // `m<x>` — mark-set chord. Normal mode only (vim's `m` is not meaningful
    // in Visual mode). The engine FSM arms for `m` in Normal mode are kept
    // intact for macro-replay defensive coverage (deletion in Phase 6).
    if let Err(e) = km.add(
        Mode::Normal,
        "m",
        AppAction::BeginPendingSetMark,
        "set mark chord",
    ) {
        eprintln!("hjkl: keymap.add(m) failed: {e}");
    }

    // `'<x>` — mark-goto-line chord. Normal mode only.
    if let Err(e) = km.add(
        Mode::Normal,
        "'",
        AppAction::BeginPendingGotoMarkLine,
        "goto mark linewise chord",
    ) {
        eprintln!("hjkl: keymap.add(') failed: {e}");
    }

    // `` `<x> `` — mark-goto-char chord. Normal + all three Visual modes.
    // In Visual mode, `` ` `` jumps the cursor to a mark charwise while keeping
    // the selection active (matches engine's pre-existing vim.rs:2058-2066
    // behaviour). The engine FSM arms for `` ` `` are kept for macro-replay.
    for mode in [
        Mode::Normal,
        Mode::Visual,
        Mode::VisualLine,
        Mode::VisualBlock,
    ] {
        if let Err(e) = km.add(
            mode,
            "`",
            AppAction::BeginPendingGotoMarkChar,
            "goto mark charwise chord",
        ) {
            eprintln!("hjkl: keymap.add(`) failed: {e}");
        }
    }

    // ── Phase 3a: char + line motions via hjkl-vim keymap path ───────────
    // Bound in Normal, Visual, VisualLine, and VisualBlock. Engine FSM arms
    // for these keys are kept intact for macro-replay defensive coverage.
    for (chord, kind, desc) in [
        ("h", hjkl_vim::MotionKind::CharLeft, "char left"),
        ("<BS>", hjkl_vim::MotionKind::CharLeft, "char left"),
        ("l", hjkl_vim::MotionKind::CharRight, "char right"),
        ("<Space>", hjkl_vim::MotionKind::CharRight, "char right"),
        ("j", hjkl_vim::MotionKind::LineDown, "line down"),
        ("k", hjkl_vim::MotionKind::LineUp, "line up"),
        (
            "+",
            hjkl_vim::MotionKind::FirstNonBlankDown,
            "next line first non-blank",
        ),
        (
            "-",
            hjkl_vim::MotionKind::FirstNonBlankUp,
            "prev line first non-blank",
        ),
        ("w", hjkl_vim::MotionKind::WordForward, "word forward"),
        (
            "W",
            hjkl_vim::MotionKind::BigWordForward,
            "BIG word forward",
        ),
        ("b", hjkl_vim::MotionKind::WordBackward, "word back"),
        ("B", hjkl_vim::MotionKind::BigWordBackward, "BIG word back"),
        ("e", hjkl_vim::MotionKind::WordEnd, "word end"),
        ("E", hjkl_vim::MotionKind::BigWordEnd, "BIG word end"),
        // Phase 3c: line-anchor motions.
        ("0", hjkl_vim::MotionKind::LineStart, "line start"),
        ("<Home>", hjkl_vim::MotionKind::LineStart, "line start"),
        ("^", hjkl_vim::MotionKind::FirstNonBlank, "first non-blank"),
        ("$", hjkl_vim::MotionKind::LineEnd, "line end"),
        ("<End>", hjkl_vim::MotionKind::LineEnd, "line end"),
        // Phase 3d: doc-level motion.
        ("G", hjkl_vim::MotionKind::GotoLine, "goto line"),
        // Phase 3e: find-repeat motions.
        (";", hjkl_vim::MotionKind::FindRepeat, "find repeat"),
        (
            ",",
            hjkl_vim::MotionKind::FindRepeatReverse,
            "find repeat reverse",
        ),
        // Phase 3f: bracket-match motion.
        ("%", hjkl_vim::MotionKind::BracketMatch, "match bracket"),
        // Phase 3g: scroll / viewport motions.
        // NOTE: H and L are registered separately below (BufferCycleH/L for
        // Normal mode; Motion::Viewport* for Visual modes). Removed from this
        // array so they don't accidentally bind in Normal via the four-mode loop.
        ("M", hjkl_vim::MotionKind::ViewportMiddle, "viewport middle"),
        (
            "<C-d>",
            hjkl_vim::MotionKind::HalfPageDown,
            "half page down",
        ),
        ("<C-u>", hjkl_vim::MotionKind::HalfPageUp, "half page up"),
        (
            "<C-f>",
            hjkl_vim::MotionKind::FullPageDown,
            "full page down",
        ),
        ("<C-b>", hjkl_vim::MotionKind::FullPageUp, "full page up"),
    ] {
        let action = AppAction::Motion { kind, count: 1 };
        for mode in [
            Mode::Normal,
            Mode::Visual,
            Mode::VisualLine,
            Mode::VisualBlock,
        ] {
            if let Err(e) = km.add(mode, chord, action.clone(), desc) {
                eprintln!("hjkl: keymap.add({chord:?}) failed: {e}");
            }
        }
    }

    // ── H / L viewport motions for Visual modes (issue #120 Phase 3) ─────
    // In Normal mode H/L are registered below as BufferCycleH/L (the action
    // checks slots.len() at dispatch time). In all Visual modes H/L remain
    // viewport motions — no buffer-cycle semantics in Visual.
    for (chord, kind, desc) in [
        ("H", hjkl_vim::MotionKind::ViewportTop, "viewport top"),
        ("L", hjkl_vim::MotionKind::ViewportBottom, "viewport bottom"),
    ] {
        let action = AppAction::Motion { kind, count: 1 };
        for mode in [Mode::Visual, Mode::VisualLine, Mode::VisualBlock] {
            if let Err(e) = km.add(mode, chord, action.clone(), desc) {
                eprintln!("hjkl: keymap.add({chord:?} visual) failed: {e}");
            }
        }
    }

    // ── H / L buffer cycle (Normal mode, issue #120 Phase 3) ─────────────
    // BufferCycleH/L dispatch checks slots.len() at call time:
    //   slots > 1  → buffer_prev / buffer_next
    //   single slot → apply_motion(ViewportTop/Bottom, count) directly
    // This replaces the inline H/L intercept that checked slots.len() before
    // forwarding to the engine.
    if let Err(e) = km.add(
        Mode::Normal,
        "H",
        AppAction::BufferCycleH,
        "prev buffer or viewport top",
    ) {
        eprintln!("hjkl: keymap.add(H Normal) failed: {e}");
    }
    if let Err(e) = km.add(
        Mode::Normal,
        "L",
        AppAction::BufferCycleL,
        "next buffer or viewport bottom",
    ) {
        eprintln!("hjkl: keymap.add(L Normal) failed: {e}");
    }

    // ── <C-h/j/k/l> window focus + tmux fallback (issue #120 Phase 3) ───
    // TmuxNavigate dispatch checks whether a neighbour exists:
    //   neighbour present → focus_left/below/above/right
    //   no neighbour, $TMUX set → tmux select-pane
    //   no neighbour, no tmux → no-op
    // <C-Backspace> is an alias for <C-h> on some terminals (mirrors the
    // original inline intercept's `key.code == KeyCode::Backspace` arm).
    for (chord, dir, desc) in [
        ("<C-h>", NavDir::Left, "focus left or tmux left"),
        ("<C-j>", NavDir::Down, "focus down or tmux down"),
        ("<C-k>", NavDir::Up, "focus up or tmux up"),
        ("<C-l>", NavDir::Right, "focus right or tmux right"),
        // <C-BS> is the alias for <C-h> delivered by some terminals (crossterm
        // decodes it as Backspace+CONTROL rather than Char('h')+CONTROL).
        ("<C-BS>", NavDir::Left, "focus left or tmux left"),
    ] {
        if let Err(e) = km.add(Mode::Normal, chord, AppAction::TmuxNavigate(dir), desc) {
            eprintln!("hjkl: keymap.add({chord:?}) failed: {e}");
        }
    }

    // ── Phase 5b: macro record / play chord entry points ─────────────────
    // `q` — record-macro or stop-recording gate (QChord handles the branch).
    // Normal-mode only: macros cannot be started or stopped in Visual mode.
    // Engine FSM arms for `q` are kept for macro-replay defensive coverage.
    if let Err(e) = km.add(
        Mode::Normal,
        "q",
        AppAction::QChord { count: 1 },
        "record macro / stop recording",
    ) {
        eprintln!("hjkl: keymap.add(q) failed: {e}");
    }

    // `@` — begin play-macro chord. Normal-mode only.
    // Engine FSM arms for `@` are kept for macro-replay defensive coverage.
    if let Err(e) = km.add(
        Mode::Normal,
        "@",
        AppAction::BeginPendingPlayMacro { count: 1 },
        "play macro chord",
    ) {
        eprintln!("hjkl: keymap.add(@) failed: {e}");
    }

    // ── Phase 5c: dot-repeat ─────────────────────────────────────────────
    // `.` replays the last buffered change. Normal-mode only.
    // Engine FSM `.` arm stays for macro-replay defensive coverage.
    if let Err(e) = km.add(
        Mode::Normal,
        ".",
        AppAction::DotRepeat { count: 1 },
        "repeat last change",
    ) {
        eprintln!("hjkl: keymap.add(.) failed: {e}");
    }

    // ── Phase 6.4: insert-mode entry ─────────────────────────────────────
    // Normal mode only. Engine FSM arms kept for macro-replay coverage.
    for (chord, action, desc) in [
        (
            "i",
            AppAction::EnterInsertI { count: 1 },
            "insert before cursor",
        ),
        (
            "I",
            AppAction::EnterInsertShiftI { count: 1 },
            "insert at line start",
        ),
        (
            "a",
            AppAction::EnterInsertA { count: 1 },
            "append after cursor",
        ),
        (
            "A",
            AppAction::EnterInsertShiftA { count: 1 },
            "append at line end",
        ),
        ("o", AppAction::EnterInsertO { count: 1 }, "open line below"),
        (
            "O",
            AppAction::EnterInsertShiftO { count: 1 },
            "open line above",
        ),
        (
            "R",
            AppAction::EnterReplace { count: 1 },
            "enter replace mode",
        ),
    ] {
        if let Err(e) = km.add(Mode::Normal, chord, action, desc) {
            eprintln!("hjkl: keymap.add({chord:?}) failed: {e}");
        }
    }

    // ── Phase 6.4: char / line mutation ops ──────────────────────────────
    // Normal mode only. Engine FSM arms kept for macro-replay coverage.
    for (chord, action, desc) in [
        (
            "x",
            AppAction::DeleteCharForward { count: 1 },
            "delete char forward",
        ),
        (
            "X",
            AppAction::DeleteCharBackward { count: 1 },
            "delete char backward",
        ),
        (
            "s",
            AppAction::SubstituteChar { count: 1 },
            "substitute char",
        ),
        (
            "S",
            AppAction::SubstituteLine { count: 1 },
            "substitute line",
        ),
        ("D", AppAction::DeleteToEol, "delete to end of line"),
        ("C", AppAction::ChangeToEol, "change to end of line"),
        (
            "Y",
            AppAction::YankToEol { count: 1 },
            "yank to end of line",
        ),
        ("J", AppAction::JoinLine { count: 1 }, "join lines"),
        ("~", AppAction::ToggleCase { count: 1 }, "toggle case"),
        (
            "p",
            AppAction::PasteAfter { count: 1 },
            "paste after cursor",
        ),
        (
            "P",
            AppAction::PasteBefore { count: 1 },
            "paste before cursor",
        ),
    ] {
        if let Err(e) = km.add(Mode::Normal, chord, action, desc) {
            eprintln!("hjkl: keymap.add({chord:?}) failed: {e}");
        }
    }

    // ── Phase 6.4: undo / redo ────────────────────────────────────────────
    // `u` undo in Normal mode. `<C-r>` redo in Normal mode only —
    // Insert-mode `<C-r>` goes through the engine FSM and is not intercepted.
    if let Err(e) = km.add(Mode::Normal, "u", AppAction::Undo, "undo") {
        eprintln!("hjkl: keymap.add(u) failed: {e}");
    }
    if let Err(e) = km.add(Mode::Normal, "<C-r>", AppAction::Redo, "redo") {
        eprintln!("hjkl: keymap.add(<C-r>) failed: {e}");
    }

    // ── Phase 6.4: jumplist ───────────────────────────────────────────────
    // `<C-o>` / `<C-i>` bound in Normal mode only.
    // Engine FSM arms kept for macro-replay coverage.
    if let Err(e) = km.add(
        Mode::Normal,
        "<C-o>",
        AppAction::JumpBack { count: 1 },
        "jump back",
    ) {
        eprintln!("hjkl: keymap.add(<C-o>) failed: {e}");
    }
    // Tab in Normal mode = <C-i> (vim aliases them). Crossterm delivers the
    // actual Tab key as KeyCode::Tab, not as Char('i')+CTRL, so we bind <Tab>
    // here. The engine FSM also handles the Tab code path for macro-replay
    // defensive coverage.
    if let Err(e) = km.add(
        Mode::Normal,
        "<Tab>",
        AppAction::JumpForward { count: 1 },
        "jump forward",
    ) {
        eprintln!("hjkl: keymap.add(<Tab>) failed: {e}");
    }

    // ── Phase 6.4: scroll-line ops ────────────────────────────────────────
    // `<C-e>` / `<C-y>` — scroll viewport without moving cursor.
    // Bound in Normal mode only. (Phase 3g already bound <C-d>/<C-u>/<C-f>/<C-b>
    // as Motion variants; those are kept intact — no conflict.)
    use hjkl_engine::ScrollDir;
    if let Err(e) = km.add(
        Mode::Normal,
        "<C-e>",
        AppAction::ScrollLine {
            dir: ScrollDir::Down,
            count: 1,
        },
        "scroll line down",
    ) {
        eprintln!("hjkl: keymap.add(<C-e>) failed: {e}");
    }
    if let Err(e) = km.add(
        Mode::Normal,
        "<C-y>",
        AppAction::ScrollLine {
            dir: ScrollDir::Up,
            count: 1,
        },
        "scroll line up",
    ) {
        eprintln!("hjkl: keymap.add(<C-y>) failed: {e}");
    }

    // ── Phase 6.4: search repeat ──────────────────────────────────────────
    // `n` / `N` — repeat last search. Normal + all Visual modes.
    // `*` / `#` / `g*` / `g#` — word-search. Normal mode only
    // (g* / g# are dispatched through AfterG reducer via BeginPendingAfterG).
    for (chord, forward, desc) in [
        ("n", true, "search forward repeat"),
        ("N", false, "search backward repeat"),
    ] {
        let action = AppAction::SearchRepeat { forward, count: 1 };
        for mode in [
            Mode::Normal,
            Mode::Visual,
            Mode::VisualLine,
            Mode::VisualBlock,
        ] {
            if let Err(e) = km.add(mode, chord, action.clone(), desc) {
                eprintln!("hjkl: keymap.add({chord:?}) failed: {e}");
            }
        }
    }
    // `*` / `#` whole-word search. Normal mode only.
    for (chord, forward, desc) in [
        ("*", true, "search word under cursor forward"),
        ("#", false, "search word under cursor backward"),
    ] {
        let action = AppAction::WordSearch {
            forward,
            whole_word: true,
            count: 1,
        };
        if let Err(e) = km.add(Mode::Normal, chord, action, desc) {
            eprintln!("hjkl: keymap.add({chord:?}) failed: {e}");
        }
    }

    // ── Phase 6.4: visual entry from Normal ──────────────────────────────
    // `v` / `V` / `<C-v>` — enter visual from Normal. `gv` is dispatched
    // through the AfterG reducer (BeginPendingAfterG) — not bound here.
    if let Err(e) = km.add(
        Mode::Normal,
        "v",
        AppAction::EnterVisualChar,
        "enter visual charwise",
    ) {
        eprintln!("hjkl: keymap.add(v) failed: {e}");
    }
    if let Err(e) = km.add(
        Mode::Normal,
        "V",
        AppAction::EnterVisualLine,
        "enter visual linewise",
    ) {
        eprintln!("hjkl: keymap.add(V) failed: {e}");
    }
    if let Err(e) = km.add(
        Mode::Normal,
        "<C-v>",
        AppAction::EnterVisualBlock,
        "enter visual block",
    ) {
        eprintln!("hjkl: keymap.add(<C-v>) failed: {e}");
    }

    // ── Phase 6.4: gv — reenter last visual ──────────────────────────────
    // `gv` is routed through AfterG → the AfterGChord arm in event_loop.rs
    // dispatches ReenterLastVisual. We do NOT bind `gv` directly in the trie
    // because `g` is already bound as BeginPendingAfterG (pending state chord).

    // ── Phase 6.4: visual-mode anchor toggle ─────────────────────────────
    // `o` in Visual / VisualLine / VisualBlock — toggle cursor/anchor.
    // Normal `o` is bound above as EnterInsertO. Mode discrimination is
    // handled automatically by the trie (different mode → different action).
    for mode in [Mode::Visual, Mode::VisualLine, Mode::VisualBlock] {
        if let Err(e) = km.add(
            mode,
            "o",
            AppAction::VisualToggleAnchor,
            "visual toggle anchor",
        ) {
            eprintln!("hjkl: keymap.add(o Visual) failed: {e}");
        }
    }

    km
}

/// Translate an `hjkl_engine::Input` back to a `crossterm::event::KeyEvent`
/// for re-feeding through `route_chord_key` during macro replay.
///
/// This is the inverse of `Editor::handle_key`'s `crossterm_to_input` path.
/// Modifier flags (ctrl, alt, shift) are preserved. Keys that have no
/// crossterm equivalent (e.g. `Key::Null`, `Key::PageUp` without a standard
/// mapping) produce a `KeyCode::Null` sentinel that the replay loop skips.
pub(crate) fn engine_input_to_key_event(input: hjkl_engine::Input) -> crossterm::event::KeyEvent {
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use hjkl_engine::Key;

    let code = match input.key {
        Key::Char(c) => KeyCode::Char(c),
        Key::Backspace => KeyCode::Backspace,
        Key::Delete => KeyCode::Delete,
        Key::Enter => KeyCode::Enter,
        Key::Left => KeyCode::Left,
        Key::Right => KeyCode::Right,
        Key::Up => KeyCode::Up,
        Key::Down => KeyCode::Down,
        Key::Home => KeyCode::Home,
        Key::End => KeyCode::End,
        Key::Tab => KeyCode::Tab,
        Key::Esc => KeyCode::Esc,
        Key::PageUp => KeyCode::PageUp,
        Key::PageDown => KeyCode::PageDown,
        Key::Null => KeyCode::Null,
    };
    let mut mods = KeyModifiers::NONE;
    if input.ctrl {
        mods |= KeyModifiers::CONTROL;
    }
    if input.alt {
        mods |= KeyModifiers::ALT;
    }
    if input.shift {
        mods |= KeyModifiers::SHIFT;
    }
    KeyEvent::new(code, mods)
}