reovim-module-vim 0.14.4

Vim policy module for reovim - keybindings and behavior
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
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
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
// Methods made `pub` for test access from `resolvers::tests::normal`.
// This module is private, so `pub` is effectively crate-internal.
#![allow(clippy::missing_panics_doc, clippy::must_use_candidate)]

//! Vim normal mode key resolver.
//!
//! Handles normal mode key interpretation including:
//! - Count prefix accumulation (1-9, then 0)
//! - Register prefix (") handling
//! - Command key lookup via keymap registry
//! - Operator entry transitions
//! - Macro recording (q) and playback (@) - Epic #465 Phase 8D

#![allow(clippy::unused_self)] // Methods may need self for future extensibility

use std::sync::RwLock;

use {
    reovim_driver_input::{
        ArgValue, ExtensionMap, KeyCode, KeyEvent, KeyLookupState, KeySequence, ModeKeyResolver,
        ModeState, ModeTransition, Modifiers, ResolveContext, ResolveInput, ResolveResult,
        TransitionContext,
    },
    reovim_kernel::api::v1::ModeId,
    reovim_module_editor as editor,
};

use crate::{
    ids,
    macros::notation_to_keys,
    modes::VimMode,
    session_state::{PendingCharOp, VimSessionState},
};

use reovim_module_cmdline::CmdlineState;

/// Coordinator command dispatched for find-char motions (#563).
/// Defined locally to reference the motions module's command without
/// compile-time dependency.
const DISPATCH_FIND_CHAR: reovim_kernel::api::v1::CommandId =
    reovim_kernel::api::v1::CommandId::new(
        reovim_kernel::api::v1::ModuleId::new("motions"),
        "dispatch-find-char",
    );

/// Pending macro operation.
///
/// Tracks what macro-related action we're waiting for after pressing `q` or `@`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingMacroOp {
    /// Waiting for register character after `q` (to start recording).
    StartRecording,
    /// Waiting for register character after `@` (to play macro).
    PlayMacro,
}

/// Vim normal mode key resolver.
///
/// In normal mode:
/// - Digits 1-9 (and 0 after other digits) accumulate as count prefix
/// - `"` followed by a character selects a register
/// - Other keys are looked up in the keymap
/// - Operators (d, y, c) trigger transition to operator-pending mode
/// - `q` starts/stops macro recording (Epic #465 Phase 8D)
/// - `@` plays macro from register (Epic #465 Phase 8D)
///
/// # State Management
///
/// The resolver owns its state (counts, pending register) rather than
/// storing it externally. This enables:
/// - Unit testing without full runner
/// - Different editing styles with different state needs
/// - Clean hot-reload (replace resolver, state resets)
///
/// # Example
///
/// ```ignore
/// let resolver = VimNormalResolver::new();
///
/// // Process '3' - accumulates count
/// let result = resolver.resolve(&key_3, &mut state);
/// assert!(matches!(result, ResolveResult::Pending));
///
/// // Process 'j' - executes cursor-down with count=3
/// let result = resolver.resolve(&key_j, &mut state);
/// assert!(matches!(result, ResolveResult::Execute(..)));
/// ```
pub struct VimNormalResolver {
    /// Mode ID for normal mode.
    mode_id: ModeId,

    /// Accumulated count prefix.
    ///
    /// - `None`: No count yet
    /// - `Some(n)`: Count is n
    ///
    /// Reset after command execution or escape.
    pending_count: RwLock<Option<usize>>,

    /// Pending register selection.
    ///
    /// - `None`: No register prefix
    /// - `Some('"')`: Waiting for register character (sentinel)
    /// - `Some('a'..'z')`: Register selected
    ///
    /// Reset after command execution or escape.
    pub pending_register: RwLock<Option<char>>,

    /// Accumulated key sequence for multi-key commands.
    pending_keys: RwLock<KeySequence>,

    /// Pending macro operation (Epic #465 Phase 8D).
    ///
    /// - `None`: No pending macro operation
    /// - `Some(StartRecording)`: Waiting for register after `q`
    /// - `Some(PlayMacro)`: Waiting for register after `@`
    pending_macro: RwLock<Option<PendingMacroOp>>,
}

impl VimNormalResolver {
    /// Create a new normal mode resolver.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            mode_id: VimMode::NORMAL_ID,
            pending_count: RwLock::new(None),
            pending_register: RwLock::new(None),
            pending_keys: RwLock::new(KeySequence::new()),
            pending_macro: RwLock::new(None),
        }
    }

    /// Get the accumulated count, if any.
    ///
    /// # Panics
    ///
    /// Panics if the internal lock is poisoned.
    #[must_use]
    pub fn pending_count(&self) -> Option<usize> {
        *self.pending_count.read().expect("lock poisoned")
    }

    /// Get the pending register, if any.
    ///
    /// # Panics
    ///
    /// Panics if the internal lock is poisoned.
    #[must_use]
    pub fn pending_register(&self) -> Option<char> {
        *self.pending_register.read().expect("lock poisoned")
    }

    /// Check if waiting for register character.
    #[must_use]
    pub fn is_waiting_for_register(&self) -> bool {
        self.pending_register() == Some('"')
    }

    /// Check if a key is a count digit.
    ///
    /// - First digit must be 1-9 (not 0, since 0 is a motion)
    /// - Subsequent digits can be 0-9
    pub fn is_count_digit(&self, key: &KeyEvent) -> bool {
        // Only plain digits (no modifiers) are count digits
        if key.modifiers != Modifiers::NONE {
            return false;
        }

        match key.code {
            KeyCode::Char('1'..='9') => true,
            KeyCode::Char('0') => {
                // 0 is only a count digit if we already have a count
                self.pending_count().is_some()
            }
            _ => false,
        }
    }

    /// Accumulate a count digit.
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub fn accumulate_count(&self, key: &KeyEvent) {
        if let KeyCode::Char(c @ '0'..='9') = key.code {
            let digit = c.to_digit(10).expect("valid digit") as usize;
            let mut guard = self.pending_count.write().expect("lock poisoned");
            *guard = Some(guard.unwrap_or(0) * 10 + digit);
        }
    }

    /// Check if a key is the register prefix (`"`).
    pub fn is_register_prefix(key: &KeyEvent) -> bool {
        key.modifiers == Modifiers::NONE && key.code == KeyCode::Char('"')
    }

    /// Handle register character after `"` prefix.
    pub fn handle_register_char(&self, key: &KeyEvent) -> ResolveResult {
        if let KeyCode::Char(c) = key.code {
            // Valid register characters: a-z, A-Z, 0-9, and special registers
            if c.is_ascii_alphanumeric() || "+-*/.%#:".contains(c) {
                *self.pending_register.write().expect("lock poisoned") = Some(c);
                // Don't execute yet - wait for command
                return ResolveResult::Pending;
            }
        }

        // Invalid register character - cancel and pass key through
        *self.pending_register.write().expect("lock poisoned") = None;
        ResolveResult::NotHandled
    }

    /// Take the accumulated count, clearing it.
    pub fn take_count(&self) -> Option<usize> {
        self.pending_count.write().expect("lock poisoned").take()
    }

    /// Take the pending register, clearing it.
    pub fn take_register(&self) -> Option<char> {
        let reg = self.pending_register.write().expect("lock poisoned").take();
        // Don't return the sentinel
        reg.filter(|&r| r != '"')
    }

    /// Clear pending keys.
    fn clear_pending_keys(&self) {
        self.pending_keys.write().expect("lock poisoned").clear();
    }

    /// Add a key to pending sequence.
    pub fn push_pending_key(&self, key: KeyEvent) {
        self.pending_keys.write().expect("lock poisoned").push(key);
    }

    /// Get a clone of pending keys for lookup.
    pub fn get_pending_keys(&self) -> KeySequence {
        self.pending_keys.read().expect("lock poisoned").clone()
    }

    /// Clear all internal state (for use from &self via interior mutability).
    pub fn clear_state(&self) {
        *self.pending_count.write().expect("lock poisoned") = None;
        *self.pending_register.write().expect("lock poisoned") = None;
        self.pending_keys.write().expect("lock poisoned").clear();
        *self.pending_macro.write().expect("lock poisoned") = None;
    }

    // ========================================================================
    // Macro Recording/Playback Helpers (Epic #465 Phase 8D)
    // ========================================================================

    /// Check if we're waiting for a macro operation register.
    pub fn pending_macro_op(&self) -> Option<PendingMacroOp> {
        *self.pending_macro.read().expect("lock poisoned")
    }

    /// Set pending macro operation.
    pub fn set_pending_macro(&self, op: PendingMacroOp) {
        *self.pending_macro.write().expect("lock poisoned") = Some(op);
    }

    /// Clear pending macro operation.
    pub fn clear_pending_macro(&self) {
        *self.pending_macro.write().expect("lock poisoned") = None;
    }

    /// Check if a key is the macro record key (`q` without modifiers).
    pub fn is_macro_record_key(key: &KeyEvent) -> bool {
        key.modifiers == Modifiers::NONE && key.code == KeyCode::Char('q')
    }

    /// Check if a key is the macro play key (`@` without modifiers).
    pub fn is_macro_play_key(key: &KeyEvent) -> bool {
        key.modifiers == Modifiers::NONE && key.code == KeyCode::Char('@')
    }

    /// Handle `q` key for macro recording.
    ///
    /// - If currently recording: stop recording, store to register
    /// - If not recording: set pending macro state to wait for register
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn handle_macro_record_key(
        &self,
        vim: &mut VimSessionState,
        input: &ResolveInput<'_>,
    ) -> ResolveResult {
        if vim.is_recording() {
            // Stop recording - store keys to register
            if let Some((register, keys)) = vim.stop_recording() {
                // Convert keys to notation string and store in register
                let notation = crate::macros::keys_to_notation(&keys);
                tracing::debug!(
                    register = %register,
                    key_count = keys.len(),
                    notation = %notation,
                    "Stopped macro recording"
                );

                // Store in register via input's register access
                // Note: We store as text - macros are just key notation strings
                if let Some(registers) = input.registers {
                    use reovim_kernel::api::v1::RegisterContent;
                    registers
                        .write()
                        .set_named(register, RegisterContent::characterwise(&notation));
                }
            }
            ResolveResult::Completed
        } else {
            // Start recording - wait for register character
            self.set_pending_macro(PendingMacroOp::StartRecording);
            ResolveResult::Pending
        }
    }

    /// Handle register character after `q` (start recording).
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn handle_macro_record_register(
        &self,
        key: &KeyEvent,
        vim: &mut VimSessionState,
    ) -> ResolveResult {
        self.clear_pending_macro();

        if let KeyCode::Char(c) = key.code
            && c.is_ascii_lowercase()
            && vim.start_recording(c)
        {
            tracing::debug!(register = %c, "Started macro recording");
            return ResolveResult::Completed;
        }

        // Invalid register - cancel
        tracing::debug!(?key.code, "Invalid macro register");
        ResolveResult::NotHandled
    }

    /// Handle `@` key for macro playback.
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn handle_macro_play_key(&self, vim: &VimSessionState) -> ResolveResult {
        // Check if we can enter playback (depth limit)
        if vim.is_macro_depth_exceeded() {
            tracing::warn!(depth = vim.macro_playback_depth, "Macro playback depth exceeded");
            return ResolveResult::NotHandled;
        }

        // Wait for register character
        self.set_pending_macro(PendingMacroOp::PlayMacro);
        ResolveResult::Pending
    }

    /// Handle register character after `@` (play macro).
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn handle_macro_play_register(
        &self,
        key: &KeyEvent,
        vim: &mut VimSessionState,
        input: &ResolveInput<'_>,
    ) -> ResolveResult {
        self.clear_pending_macro();

        let register = if key.code == KeyCode::Char('@') {
            // @@ - repeat last macro
            vim.last_macro_register
        } else if let KeyCode::Char(c) = key.code {
            if c.is_ascii_lowercase() {
                Some(c)
            } else {
                None
            }
        } else {
            None
        };

        let Some(register) = register else {
            tracing::debug!(?key.code, "Invalid macro playback register");
            return ResolveResult::NotHandled;
        };

        // Get macro content from register
        let Some(registers) = input.registers else {
            tracing::warn!("No register access for macro playback");
            return ResolveResult::NotHandled;
        };

        let content = {
            let guard = registers.read();
            guard.get_by_name(Some(register)).cloned()
        };

        let Some(content) = content else {
            tracing::debug!(register = %register, "Macro register is empty");
            return ResolveResult::NotHandled;
        };

        // Parse the notation string to keys
        let Some(keys) = notation_to_keys(&content.text) else {
            tracing::warn!(
                register = %register,
                content = %content.text,
                "Failed to parse macro content"
            );
            return ResolveResult::NotHandled;
        };

        if keys.is_empty() {
            return ResolveResult::Completed;
        }

        // Update last macro register for @@ support
        vim.last_macro_register = Some(register);

        // Get count for playback
        let count = vim.pending_count.take().unwrap_or(1);

        // Enter playback
        if !vim.enter_macro_playback() {
            return ResolveResult::NotHandled;
        }

        // Build full key sequence (count repetitions)
        let mut all_keys = Vec::with_capacity(keys.len() * count);
        for _ in 0..count {
            all_keys.extend(keys.iter().copied());
        }

        tracing::debug!(
            register = %register,
            count,
            key_count = all_keys.len(),
            "Playing macro"
        );

        // Return keys to be injected
        // The runner will inject these and call exit_macro_playback when done
        ResolveResult::InjectKeys {
            keys: all_keys,
            exit_macro_playback: true,
        }
    }

    /// Build resolve context with count and register.
    pub fn build_context(&self, keys: KeySequence) -> ResolveContext {
        let mut ctx = ResolveContext::new().keys(keys);

        if let Some(count) = self.take_count() {
            ctx = ctx.count(count);
        }

        if let Some(reg) = self.take_register() {
            ctx = ctx.register(reg);
        }

        ctx
    }

    // ========================================================================
    // Extension-based helpers (Epic #385 - use VimSessionState)
    // ========================================================================

    /// Check if a key is a count digit (extension-based version).
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub fn is_count_digit_ext(&self, key: &KeyEvent, vim: &VimSessionState) -> bool {
        if key.modifiers != Modifiers::NONE {
            return false;
        }

        match key.code {
            KeyCode::Char('1'..='9') => true,
            KeyCode::Char('0') => vim.pending_count.is_some(),
            _ => false,
        }
    }

    /// Accumulate a count digit (extension-based version).
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub fn accumulate_count_ext(&self, key: &KeyEvent, vim: &mut VimSessionState) {
        if let KeyCode::Char(c @ '0'..='9') = key.code {
            let digit = c.to_digit(10).expect("valid digit") as usize;
            vim.pending_count = Some(vim.pending_count.unwrap_or(0) * 10 + digit);
        }
    }

    /// Handle register character after `"` prefix (extension-based version).
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub fn handle_register_char_ext(
        &self,
        key: &KeyEvent,
        vim: &mut VimSessionState,
    ) -> ResolveResult {
        if let KeyCode::Char(c) = key.code {
            // Valid register characters: a-z, A-Z, 0-9, and special registers
            if c.is_ascii_alphanumeric() || "+-*/.%#:".contains(c) {
                vim.pending_register = Some(c);
                return ResolveResult::Pending;
            }
        }

        // Invalid register character - cancel and pass key through
        vim.pending_register = None;
        ResolveResult::NotHandled
    }

    /// Build resolve context with count and register (extension-based version).
    pub fn build_context_ext(
        &self,
        keys: KeySequence,
        vim: &mut VimSessionState,
    ) -> ResolveContext {
        let mut ctx = ResolveContext::new().keys(keys);

        if let Some(count) = vim.pending_count.take() {
            ctx = ctx.count(count);
        }

        // Don't return the sentinel
        if let Some(reg) = vim.pending_register.take()
            && reg != '"'
        {
            ctx = ctx.register(reg);
        }

        ctx
    }

    /// Classify a command as a find-char operation, if applicable.
    ///
    /// Returns the corresponding `PendingCharOp` if the command is one of the
    /// find-char commands (f, F, t, T), otherwise returns `None`.
    ///
    /// This enables the resolver to intercept these commands and handle the
    /// character wait internally, rather than relying on the runner.
    pub fn classify_find_char_command(
        cmd: &reovim_kernel::api::v1::CommandId,
    ) -> Option<PendingCharOp> {
        // Check if this is a motions module command
        if cmd.module().as_str() != "motions" {
            return None;
        }

        // Match by command name within the motions module
        match cmd.name() {
            "find-char-forward" => Some(PendingCharOp::FindForward),
            "find-char-backward" => Some(PendingCharOp::FindBackward),
            "till-char-forward" => Some(PendingCharOp::TillForward),
            "till-char-backward" => Some(PendingCharOp::TillBackward),
            _ => None,
        }
    }

    /// Classify a command as a mark operation, if applicable (#654).
    ///
    /// Returns the corresponding `PendingCharOp` if the command is one of the
    /// mark commands (m, ', `` ` ``), otherwise returns `None`.
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub fn classify_mark_command(cmd: &reovim_kernel::api::v1::CommandId) -> Option<PendingCharOp> {
        if *cmd == editor::ids::SET_MARK {
            Some(PendingCharOp::SetMark)
        } else if *cmd == editor::ids::GOTO_MARK_LINE {
            Some(PendingCharOp::GotoMarkLine)
        } else if *cmd == editor::ids::GOTO_MARK_EXACT {
            Some(PendingCharOp::GotoMarkExact)
        } else {
            None
        }
    }

    /// Check if a command is an insert entry command (#577).
    ///
    /// These commands start a change that should be recorded for dot repeat.
    /// The recording starts here and continues through insert mode until
    /// `ExitToNormal` finishes it.
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub fn is_insert_entry_command(cmd: &reovim_kernel::api::v1::CommandId) -> bool {
        *cmd == ids::ENTER_INSERT
            || *cmd == ids::ENTER_INSERT_AFTER
            || *cmd == ids::ENTER_INSERT_EOL
            || *cmd == ids::ENTER_INSERT_BOL
            || *cmd == ids::OPEN_LINE_BELOW
            || *cmd == ids::OPEN_LINE_ABOVE
    }

    /// Classify an operator entry command and return the target mode.
    ///
    /// Returns the `ModeId` for the dedicated operator mode (DELETE, YANK, CHANGE)
    /// if the command is an operator entry command, otherwise returns `None`.
    ///
    /// # Epic #415 - Dedicated Operator Modes
    ///
    /// Instead of routing all operators to a generic `operator-pending` mode,
    /// we now push to dedicated modes where:
    /// - The MODE itself carries operator semantics (no runtime lookup needed)
    /// - Each resolver is focused (~300 lines) and easier to debug
    /// - Statusline shows "DELETE" instead of "OP-PENDING"
    ///
    /// The enter-*-operator commands in the editor module are "shell" commands
    /// (they do nothing). The real work is done by:
    /// 1. This resolver: intercepts and pushes to the specific operator mode
    /// 2. Dedicated operator resolver (delete/yank/change): captures motion, returns range
    /// 3. Runner: executes the operator on the range
    ///
    /// # Compile-time Safety
    ///
    /// This function uses hard-typed comparisons against the editor module's
    /// `CommandId` constants. If those constants are renamed or removed, this
    /// code will fail to compile rather than silently break at runtime.
    #[cfg_attr(coverage_nightly, coverage(off))]
    pub fn classify_operator_mode(cmd: &reovim_kernel::api::v1::CommandId) -> Option<ModeId> {
        // Hard-typed check using editor module constants (compile-time safe)
        // Returns the dedicated operator mode (not generic operator-pending)
        if *cmd == editor::ids::ENTER_DELETE_OPERATOR {
            Some(VimMode::DELETE_ID)
        } else if *cmd == editor::ids::ENTER_YANK_OPERATOR {
            Some(VimMode::YANK_ID)
        } else if *cmd == editor::ids::ENTER_CHANGE_OPERATOR {
            Some(VimMode::CHANGE_ID)
        } else if *cmd == editor::ids::ENTER_LOWERCASE_OPERATOR {
            Some(VimMode::LOWERCASE_ID)
        } else if *cmd == editor::ids::ENTER_UPPERCASE_OPERATOR {
            Some(VimMode::UPPERCASE_ID)
        } else if *cmd == editor::ids::ENTER_TOGGLE_CASE_OPERATOR {
            Some(VimMode::TOGGLE_CASE_ID)
        } else {
            None
        }
    }
}

impl Default for VimNormalResolver {
    fn default() -> Self {
        Self::new()
    }
}

impl ModeKeyResolver for VimNormalResolver {
    /// Vim-style key resolution with keymap access.
    ///
    /// This method queries the keymap and applies Vim policy to determine
    /// whether to execute immediately or wait for more keys.
    ///
    /// # Vim Policy
    ///
    /// | Lookup State | Vim Behavior |
    /// |--------------|--------------|
    /// | `ExactWithLonger` | `Pending` - wait for more keys (d might become dd) |
    /// | `ExactOnly` | `Execute` - run the command immediately |
    /// | `PrefixOnly` | `Pending` - wait for more keys |
    /// | `NotFound` | `NotHandled` - delegate to fallback handler |
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn resolve_with_keymap(
        &self,
        key: &KeyEvent,
        _state: &mut ModeState,
        input: &ResolveInput<'_>,
    ) -> ResolveResult {
        // Handle escape - reset state and return NotHandled
        if key.code == KeyCode::Escape {
            self.clear_state();
            return ResolveResult::NotHandled;
        }

        // Check for register prefix waiting for character
        if self.is_waiting_for_register() {
            return self.handle_register_char(key);
        }

        // Check for register prefix start
        if Self::is_register_prefix(key) {
            *self.pending_register.write().expect("lock poisoned") = Some('"'); // Sentinel
            return ResolveResult::Pending;
        }

        // Check for count digit
        if self.is_count_digit(key) {
            self.accumulate_count(key);
            return ResolveResult::Pending;
        }

        // Add to pending keys for lookup
        self.push_pending_key(*key);
        let keys = self.get_pending_keys();

        // Query keymap for facts about what bindings exist
        let lookup_state = input.keymap.query(input.mode, &keys);

        // Apply Vim policy (inline match - no separate trait needed)
        match lookup_state {
            KeyLookupState::ExactWithLonger { .. } => {
                // Wait for longer sequence (d might become dd)
                // Keep pending_keys for next lookup
                ResolveResult::Pending
            }
            KeyLookupState::ExactOnly(cmd) => {
                // Execute with context containing count and register
                let ctx = self.build_context(keys);
                self.clear_pending_keys();
                ResolveResult::Execute(cmd, ctx)
            }
            KeyLookupState::PrefixOnly => {
                // Wait for more keys (g waiting for gg, etc.)
                // Keep pending_keys for next lookup
                ResolveResult::Pending
            }
            KeyLookupState::NotFound => {
                // No binding found - clear keys and let runner handle
                self.clear_pending_keys();
                ResolveResult::NotHandled
            }
        }
    }

    /// Vim-style key resolution with keymap AND session extensions access.
    ///
    /// This is the new architecture (Epic #385) that uses `VimSessionState` from
    /// extensions instead of the runner's `AppState`. The resolver owns the vim
    /// policy, the runner is pure mechanism.
    ///
    /// # State Management
    ///
    /// | State | Source | Notes |
    /// |-------|--------|-------|
    /// | `pending_count` | `VimSessionState` | From extensions |
    /// | `pending_register` | `VimSessionState` | From extensions |
    /// | `pending_keys` | Internal `RwLock` | Multi-key sequence |
    ///
    /// Note: For backward compatibility, we still use internal `pending_keys`.
    /// Once all resolvers are migrated, these can move to `VimSessionState` too.
    #[allow(clippy::too_many_lines)]
    #[cfg_attr(coverage_nightly, coverage(off))]
    fn resolve_with_extensions(
        &self,
        key: &KeyEvent,
        _state: &mut ModeState,
        input: &ResolveInput<'_>,
        _shared_extensions: &mut ExtensionMap,
        client_extensions: &mut ExtensionMap,
    ) -> ResolveResult {
        // Clear any pending cmdline message on next keypress (#558)
        if let Some(cmdline) = client_extensions.get_mut::<CmdlineState>() {
            cmdline.clear_message();
        }

        // Get vim session state from client extensions (per-client state)
        let vim = client_extensions.get_or_insert::<VimSessionState>();

        // Handle escape - reset state and return NotHandled
        if key.code == KeyCode::Escape {
            vim.clear_pending();
            self.clear_pending_keys();
            return ResolveResult::NotHandled;
        }

        // =====================================================================
        // Epic #385 - Handle pending find-char operation
        // =====================================================================
        // If pending_char is set, the next character completes the find motion.
        // We create an Execute result with EXECUTE_FIND_CHAR and the char in metadata.
        // Note: take() consumes pending_char regardless of whether the key is a char,
        // which correctly cancels the operation if a non-char key is pressed.
        if let Some(pending_op) = vim.pending_char.take()
            && let KeyCode::Char(c) = key.code
        {
            let mut ctx = self.build_context_ext(KeySequence::new(), vim);
            self.clear_pending_keys();

            if pending_op.is_motion() {
                // Find-char motion (f/F/t/T)
                ctx.metadata
                    .insert("find_char".to_string(), ArgValue::Char(c));
                let direction = if pending_op.is_forward() {
                    "forward"
                } else {
                    "backward"
                };
                ctx.metadata
                    .insert("find_direction".to_string(), ArgValue::String(direction.to_string()));
                let inclusive = pending_op.is_find();
                ctx.metadata
                    .insert("find_inclusive".to_string(), ArgValue::Bool(inclusive));
                return ResolveResult::Execute(DISPATCH_FIND_CHAR, ctx);
            }

            // Replace operation (r) — dispatch to editor::REPLACE_CHAR
            if matches!(pending_op, PendingCharOp::Replace) {
                ctx.metadata
                    .insert("replace_char".to_string(), ArgValue::Char(c));
                return ResolveResult::Execute(editor::ids::REPLACE_CHAR, ctx);
            }

            // #654 - Mark operations (m, ', `)
            let cmd_id = match pending_op {
                PendingCharOp::SetMark => editor::ids::SET_MARK,
                PendingCharOp::GotoMarkLine => editor::ids::GOTO_MARK_LINE,
                PendingCharOp::GotoMarkExact => editor::ids::GOTO_MARK_EXACT,
                _ => unreachable!("All pending ops handled above"),
            };
            ctx.metadata
                .insert("mark_char".to_string(), ArgValue::Char(c));
            return ResolveResult::Execute(cmd_id, ctx);
        }

        // =====================================================================
        // Epic #465 Phase 8D - Macro Recording/Playback
        // =====================================================================

        // Handle pending macro operations first (waiting for register after q or @)
        if let Some(pending_op) = self.pending_macro_op() {
            match pending_op {
                PendingMacroOp::StartRecording => {
                    // Record key if we're recording (except q that stops)
                    // Note: We're about to potentially start recording, so don't record this key
                    return self.handle_macro_record_register(key, vim);
                }
                PendingMacroOp::PlayMacro => {
                    return self.handle_macro_play_register(key, vim, input);
                }
            }
        }

        // Check for macro record key (q)
        if Self::is_macro_record_key(key) {
            return self.handle_macro_record_key(vim, input);
        }

        // Check for macro play key (@)
        if Self::is_macro_play_key(key) {
            return self.handle_macro_play_key(vim);
        }

        // Record key if we're recording (before normal processing)
        // The key will be recorded regardless of what it does
        if vim.is_recording() {
            vim.record_key(*key);
        }

        // Check for register prefix waiting for character
        if vim.pending_register == Some('"') {
            return self.handle_register_char_ext(key, vim);
        }

        // Check for register prefix start
        if Self::is_register_prefix(key) {
            vim.pending_register = Some('"'); // Sentinel
            return ResolveResult::Pending;
        }

        // Check for count digit
        if self.is_count_digit_ext(key, vim) {
            self.accumulate_count_ext(key, vim);
            return ResolveResult::Pending;
        }

        // Add to pending keys for lookup
        self.push_pending_key(*key);
        let keys = self.get_pending_keys();

        // Query keymap for facts about what bindings exist
        let lookup_state = input.keymap.query(input.mode, &keys);

        // Apply Vim policy
        match lookup_state {
            KeyLookupState::ExactWithLonger { exact: cmd } => {
                // Epic #415: Operators push to dedicated modes (DELETE, YANK, CHANGE)
                // Even though dd exists, we don't wait - the dedicated mode handles dd
                // via the is_line_operator check when the second 'd' is pressed.
                //
                // Key insight: we DON'T take pending_count/pending_register here!
                // The dedicated resolver reads them on its first key press.
                // This simplifies the flow and eliminates vim.pending_operator.
                if let Some(target_mode) = Self::classify_operator_mode(&cmd) {
                    self.clear_pending_keys();
                    // #577: Start recording keys for dot repeat
                    vim.start_repeat_recording();
                    vim.record_repeat_key(*key);
                    return ResolveResult::ModeTransition(ModeTransition::Push {
                        mode: target_mode,
                        context: TransitionContext::new(),
                    });
                }

                // Not an operator - wait for longer sequence
                ResolveResult::Pending
            }
            KeyLookupState::ExactOnly(cmd) => {
                // #577 - Intercept dot repeat and replay recorded keys
                if cmd == ids::DOT_REPEAT
                    && let Some(ref lc) = vim.last_change
                    && !lc.keys.is_empty()
                {
                    let replay_keys = lc.keys.clone();
                    self.clear_pending_keys();
                    return ResolveResult::InjectKeys {
                        keys: replay_keys,
                        exit_macro_playback: false,
                    };
                }

                // Epic #385 - Intercept find-char commands
                // Instead of executing commands that return WaitingForChar,
                // set pending_char in VimSessionState and return Pending.
                if let Some(pending_op) = Self::classify_find_char_command(&cmd) {
                    vim.pending_char = Some(pending_op);
                    self.clear_pending_keys();
                    return ResolveResult::Pending;
                }

                // #554 - Intercept replace-char-start (r)
                // Like find-char, this sets pending_char and waits for the next char.
                if cmd == editor::ids::REPLACE_CHAR_START {
                    vim.pending_char = Some(PendingCharOp::Replace);
                    self.clear_pending_keys();
                    return ResolveResult::Pending;
                }

                // #654 - Intercept mark commands (m, ', `)
                if let Some(mark_op) = Self::classify_mark_command(&cmd) {
                    vim.pending_char = Some(mark_op);
                    self.clear_pending_keys();
                    return ResolveResult::Pending;
                }

                // Epic #415 - Push to dedicated operator modes (DELETE, YANK, CHANGE)
                // Instead of executing enter-*-operator commands (which do nothing),
                // push to the specific operator mode. The resolver reads pending_count
                // and pending_register from VimSessionState on its first key press.
                if let Some(target_mode) = Self::classify_operator_mode(&cmd) {
                    self.clear_pending_keys();
                    // #577: Start recording keys for dot repeat
                    vim.start_repeat_recording();
                    vim.record_repeat_key(*key);
                    return ResolveResult::ModeTransition(ModeTransition::Push {
                        mode: target_mode,
                        context: TransitionContext::new(),
                    });
                }

                // #577: Start recording on insert entry commands
                if Self::is_insert_entry_command(&cmd) {
                    vim.start_repeat_recording();
                    vim.record_repeat_key(*key);
                }

                // Execute with context containing count and register
                let ctx = self.build_context_ext(keys, vim);
                self.clear_pending_keys();
                ResolveResult::Execute(cmd, ctx)
            }
            KeyLookupState::PrefixOnly => {
                // Wait for more keys
                ResolveResult::Pending
            }
            KeyLookupState::NotFound => {
                // No binding found - clear keys and let runner handle
                self.clear_pending_keys();
                ResolveResult::NotHandled
            }
        }
    }

    fn mode_id(&self) -> &ModeId {
        &self.mode_id
    }

    fn inherits_from(&self) -> Option<&ModeId> {
        None
    }

    #[cfg_attr(coverage_nightly, coverage(off))]
    fn pending_keys(&self) -> KeySequence {
        self.get_pending_keys()
    }

    fn reset(&mut self) {
        *self.pending_count.write().expect("lock poisoned") = None;
        *self.pending_register.write().expect("lock poisoned") = None;
        self.pending_keys.write().expect("lock poisoned").clear();
        *self.pending_macro.write().expect("lock poisoned") = None;
    }
}

#[cfg(test)]
impl VimNormalResolver {
    /// Get a clone of pending keys (for testing).
    pub fn pending_keys(&self) -> KeySequence {
        self.get_pending_keys()
    }
}