4040 UIStateMachine ,
4141 get_leader_bindings ,
4242)
43+ from .plugins import Plugin , discover_plugins
4344from .ui .mixins import (
4445 AutocompleteMixin ,
4546 ConnectionMixin ,
5657 ResultsFilterInput ,
5758 SqlitDataTable ,
5859 TreeFilterInput ,
60+ VimCommandLine ,
5961 VimMode ,
6062)
6163
@@ -112,6 +114,10 @@ class SSMSTUI(
112114 &:focus > .text-area--cursor-line {
113115 background: $surface-lighten-1;
114116 }
117+ /* Make visual mode selection more visible with orange tint */
118+ & > .text-area--selection {
119+ background: $warning 40%;
120+ }
115121 }
116122
117123 DataTable.flash-cell:focus > .datatable--cursor,
@@ -339,7 +345,7 @@ def __init__(
339345 self .current_config : ConnectionConfig | None = None
340346 self .current_adapter : DatabaseAdapter | None = None
341347 self .current_ssh_tunnel : Any | None = None
342- self .vim_mode : VimMode = VimMode .NORMAL
348+ self .editor_mode : VimMode = VimMode .INSERT # Editor state (INSERT=editing, NORMAL=read-only)
343349 self ._expanded_paths : set [str ] = set ()
344350 self ._loading_nodes : set [str ] = set ()
345351 self ._session : Any | None = None
@@ -369,6 +375,7 @@ def __init__(
369375 self ._leader_timer : Timer | None = None
370376 self ._leader_pending : bool = False
371377 self ._dialog_open : bool = False
378+ self ._plugins : list [Plugin ] = [] # Loaded plugins
372379 self ._last_active_pane : str | None = None
373380 self ._query_worker : Worker [Any ] | None = None
374381 self ._query_executing : bool = False
@@ -534,7 +541,7 @@ def compose(self) -> ComposeResult:
534541 with Horizontal (id = "content" ):
535542 with Vertical (id = "sidebar" ):
536543 yield TreeFilterInput (id = "tree-filter" )
537- tree : Tree [ Any ] = Tree ("Servers" , id = "object-tree" )
544+ tree = Tree ("Servers" , id = "object-tree" )
538545 tree .show_root = False
539546 tree .guide_depth = 2
540547 yield tree
@@ -548,6 +555,7 @@ def compose(self) -> ComposeResult:
548555 read_only = True ,
549556 )
550557 yield Lazy (AutocompleteDropdown (id = "autocomplete-dropdown" ))
558+ yield VimCommandLine (id = "vim-command-line" )
551559
552560 with Container (id = "results-area" ):
553561 yield ResultsFilterInput (id = "results-filter" )
@@ -588,6 +596,10 @@ def on_mount(self) -> None:
588596 self .refresh_tree ()
589597 self ._startup_stamp ("tree_refreshed" )
590598
599+ # Load and initialize plugins
600+ self ._load_plugins (settings )
601+ self ._startup_stamp ("plugins_initialized" )
602+
591603 self .object_tree .focus ()
592604 self ._startup_stamp ("tree_focused" )
593605 # Move cursor to first node if available
@@ -614,6 +626,27 @@ def _apply_mock_settings(self, settings: dict) -> None:
614626 self ._mock_profile = mock_profile
615627 self ._session_factory = self ._create_mock_session_factory (mock_profile )
616628
629+ def _load_plugins (self , settings : dict ) -> None :
630+ """Load and initialize plugins."""
631+ for plugin_cls in discover_plugins ():
632+ plugin = plugin_cls ()
633+ plugin .register (self )
634+ plugin .on_settings_load (self , settings )
635+ self ._plugins .append (plugin )
636+
637+ def _get_vim_plugin (self ) -> Any :
638+ """Get the vim mode plugin if loaded."""
639+ for plugin in self ._plugins :
640+ if plugin .name == "vim_mode" :
641+ return plugin
642+ return None
643+
644+ @property
645+ def vim_enabled (self ) -> bool :
646+ """Check if vim plugin is enabled (delegates to vim plugin)."""
647+ vim_plugin = self ._get_vim_plugin ()
648+ return vim_plugin .enabled if vim_plugin else False
649+
617650 def _setup_startup_connection (self , config : ConnectionConfig ) -> None :
618651 """Set up a startup connection to auto-connect after mount."""
619652 if not config .name :
@@ -816,3 +849,53 @@ def _apply_omarchy_theme(self) -> None:
816849 """Match and apply the current Omarchy theme."""
817850 matched_theme = get_matching_textual_theme (self .available_themes )
818851 self ._apply_theme_safe (matched_theme )
852+
853+ def on_key (self , event : Any ) -> None :
854+ """Handle key events, routing through plugins."""
855+ from textual .events import Key
856+
857+ if not isinstance (event , Key ):
858+ return
859+
860+ # Only handle keys when query input has focus
861+ if not self .query_input .has_focus :
862+ return
863+
864+ # Handle autocomplete navigation when dropdown is visible
865+ # Works in INSERT mode (vim enabled) or always when vim is disabled
866+ if self ._autocomplete_visible and (not self .vim_enabled or self .editor_mode == VimMode .INSERT ):
867+ dropdown = self .autocomplete_dropdown
868+ if event .key in ("down" , "ctrl+n" ):
869+ dropdown .move_selection (1 )
870+ event .prevent_default ()
871+ event .stop ()
872+ return
873+ elif event .key in ("up" , "ctrl+p" ):
874+ dropdown .move_selection (- 1 )
875+ event .prevent_default ()
876+ event .stop ()
877+ return
878+ elif event .key in ("tab" , "enter" , "return" ) and dropdown .filtered_items :
879+ self ._apply_autocomplete ()
880+ event .prevent_default ()
881+ event .stop ()
882+ return
883+ elif event .key == "escape" :
884+ self ._hide_autocomplete ()
885+ event .prevent_default ()
886+ event .stop ()
887+ return
888+
889+ # In default mode NORMAL state, space triggers leader key
890+ if not self .vim_enabled and self .editor_mode == VimMode .NORMAL and event .key == "space" :
891+ self .action_leader_key ()
892+ event .prevent_default ()
893+ event .stop ()
894+ return
895+
896+ # Route to plugins
897+ for plugin in self ._plugins :
898+ if plugin .on_key (self , event ):
899+ event .prevent_default ()
900+ event .stop ()
901+ return
0 commit comments