Skip to content

Commit 0d1672b

Browse files
committed
feat: add enhanced vim mode with cursor shapes and autosuggestion support
Add comprehensive vim mode enhancements including: - Dynamic cursor shapes (block/beam/underline) for vim modes - Fish/zsh-style autosuggestion acceptance with 'l' and arrow keys in normal mode - Vim-style completion navigation (Ctrl+j/k) - Toggle autocompletion with Ctrl+Space - Improved key binding documentation in pgclirc Technical improvements: - Idempotent setup_vim_cursor_shapes() with guard flag - Extracted _set_cursor_shape() helper to reduce code duplication - Fixed string encoding in terminal escape sequences - Simplified suggestion handlers by removing redundant checks
1 parent f77fb16 commit 0d1672b

File tree

3 files changed

+152
-4
lines changed

3 files changed

+152
-4
lines changed

pgcli/key_bindings.py

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,77 @@
11
import logging
2+
import sys
23
from prompt_toolkit.enums import EditingMode
34
from prompt_toolkit.key_binding import KeyBindings
5+
from prompt_toolkit.key_binding.vi_state import InputMode, ViState
46
from prompt_toolkit.filters import (
57
completion_is_selected,
68
is_searching,
79
has_completions,
810
has_selection,
911
vi_mode,
12+
vi_insert_mode,
13+
vi_navigation_mode,
1014
)
1115

1216
from .pgbuffer import buffer_should_be_handled, safe_multi_line_mode
1317

1418
_logger = logging.getLogger(__name__)
1519

20+
# Track whether ViState has been patched to avoid redundant monkeypatching
21+
_vim_cursor_shapes_configured = False
22+
23+
24+
def _set_cursor_shape(shape_code):
25+
"""
26+
Send terminal escape sequence to change cursor shape.
27+
28+
Args:
29+
shape_code: Cursor shape code (1=block, 3=underline, 5=beam)
30+
"""
31+
out = getattr(sys.stdout, 'buffer', sys.stdout)
32+
try:
33+
# Write escape sequence as bytes
34+
out.write(f'\x1b[{shape_code} q'.encode('ascii'))
35+
sys.stdout.flush()
36+
except (AttributeError, OSError):
37+
# Silently ignore if terminal doesn't support cursor shape changes
38+
pass
39+
40+
41+
def setup_vim_cursor_shapes():
42+
"""
43+
Configure cursor shape changes for vim modes (idempotent).
44+
45+
Uses terminal escape sequences to change cursor appearance:
46+
- Block cursor (█) in navigation/normal mode
47+
- Beam cursor (|) in insert mode
48+
- Underline cursor (_) in replace mode
49+
50+
This function can be called multiple times safely - it only patches ViState once.
51+
"""
52+
global _vim_cursor_shapes_configured
53+
54+
# Only patch ViState once to avoid issues with multiple calls
55+
if _vim_cursor_shapes_configured:
56+
return
57+
58+
def set_input_mode(self, mode):
59+
# Cursor shape codes: 1=block, 3=underline, 5=beam
60+
shape = {
61+
InputMode.NAVIGATION: 1, # Block cursor for normal mode
62+
InputMode.REPLACE: 3, # Underline cursor for replace mode
63+
InputMode.INSERT: 5, # Beam cursor for insert mode
64+
}.get(mode, 5)
65+
66+
_set_cursor_shape(shape)
67+
self._input_mode = mode
68+
69+
# Patch ViState to include cursor shape changes
70+
ViState._input_mode = InputMode.INSERT
71+
ViState.input_mode = property(lambda self: self._input_mode, set_input_mode)
72+
73+
_vim_cursor_shapes_configured = True
74+
1675

1776
def pgcli_bindings(pgcli):
1877
"""Custom key bindings for pgcli."""
@@ -39,6 +98,11 @@ def _(event):
3998
pgcli.vi_mode = not pgcli.vi_mode
4099
event.app.editing_mode = EditingMode.VI if pgcli.vi_mode else EditingMode.EMACS
41100

101+
if pgcli.vi_mode:
102+
setup_vim_cursor_shapes()
103+
else:
104+
_set_cursor_shape(5)
105+
42106
@kb.add("f5")
43107
def _(event):
44108
"""Toggle between Vi and Emacs mode."""
@@ -73,21 +137,43 @@ def _(event):
73137
@kb.add("c-space")
74138
def _(event):
75139
"""
76-
Initialize autocompletion at cursor.
140+
Toggle autocompletion at cursor.
77141
78142
If the autocompletion menu is not showing, display it with the
79143
appropriate completions for the context.
80144
81-
If the menu is showing, select the next completion.
145+
If the menu is showing, close it (toggle off).
82146
"""
83147
_logger.debug("Detected <C-Space> key.")
84148

85149
b = event.app.current_buffer
86150
if b.complete_state:
87-
b.complete_next()
151+
# Close completion menu (toggle off)
152+
b.complete_state = None
88153
else:
154+
# Open completion menu (toggle on)
89155
b.start_completion(select_first=False)
90156

157+
@kb.add("c-j", filter=has_completions)
158+
def _(event):
159+
"""
160+
Navigate to next completion (down) in autocomplete menu.
161+
162+
Works like Ctrl+n but uses Vim-style j (down) binding.
163+
"""
164+
_logger.debug("Detected <C-j> key.")
165+
event.current_buffer.complete_next()
166+
167+
@kb.add("c-k", filter=has_completions)
168+
def _(event):
169+
"""
170+
Navigate to previous completion (up) in autocomplete menu.
171+
172+
Works like Ctrl+p but uses Vim-style k (up) binding.
173+
"""
174+
_logger.debug("Detected <C-k> key.")
175+
event.current_buffer.complete_previous()
176+
91177
@kb.add("enter", filter=completion_is_selected)
92178
def _(event):
93179
"""Makes the enter key work as the tab key only when showing the menu.
@@ -129,4 +215,49 @@ def _(event):
129215
"""Move down in history."""
130216
event.current_buffer.history_forward(count=event.arg)
131217

218+
# Add these bindings with eager=True to take precedence when suggestions are available
219+
# This is key for fish/zsh-style autosuggestion acceptance in vim normal mode
220+
from prompt_toolkit.filters import Condition
221+
222+
@Condition
223+
def has_suggestion_at_end():
224+
"""Check if there's a suggestion and cursor is at end of line."""
225+
from prompt_toolkit.application.current import get_app
226+
app = get_app()
227+
buffer = app.current_buffer
228+
return (
229+
buffer.suggestion is not None
230+
and buffer.document.is_cursor_at_the_end_of_line
231+
)
232+
233+
@kb.add("l", filter=vi_navigation_mode & has_suggestion_at_end, eager=True)
234+
def _(event):
235+
"""
236+
Accept autosuggestion with 'l' in vi normal mode when at end of line.
237+
238+
This takes precedence over normal 'l' movement when a suggestion is available.
239+
"""
240+
_logger.debug("Accepting suggestion with 'l' in normal mode")
241+
buff = event.current_buffer
242+
buff.insert_text(buff.suggestion.text)
243+
244+
@kb.add("l", filter=vi_navigation_mode, eager=False)
245+
def _(event):
246+
"""Normal 'l' forward movement when no suggestion or not at end."""
247+
buff = event.current_buffer
248+
buff.cursor_position += buff.document.get_cursor_right_position()
249+
250+
@kb.add("right", filter=vi_navigation_mode & has_suggestion_at_end, eager=True)
251+
def _(event):
252+
"""Accept autosuggestion with right arrow in vi normal mode when at end of line."""
253+
_logger.debug("Accepting suggestion with right arrow in normal mode")
254+
buff = event.current_buffer
255+
buff.insert_text(buff.suggestion.text)
256+
257+
@kb.add("right", filter=vi_navigation_mode, eager=False)
258+
def _(event):
259+
"""Normal right arrow forward movement when no suggestion or not at end."""
260+
buff = event.current_buffer
261+
buff.cursor_position += buff.document.get_cursor_right_position()
262+
132263
return kb

pgcli/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
get_config,
6868
get_config_filename,
6969
)
70-
from .key_bindings import pgcli_bindings
70+
from .key_bindings import pgcli_bindings, setup_vim_cursor_shapes
7171
from .packages.formatter.sqlformatter import register_new_formatter
7272
from .packages.prompt_utils import confirm, confirm_destructive_query
7373
from .packages.parseutils import is_destructive
@@ -991,6 +991,10 @@ def handle_watch_command(self, text):
991991
def _build_cli(self, history):
992992
key_bindings = pgcli_bindings(self)
993993

994+
# Setup cursor shape changes for vim mode
995+
if self.vi_mode:
996+
setup_vim_cursor_shapes()
997+
994998
def get_message():
995999
if self.dsn_alias and self.prompt_dsn_format is not None:
9961000
prompt_format = self.prompt_dsn_format

pgcli/pgclirc

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ syntax_style = default
136136
# When Vi mode is enabled you can use modal editing features offered by Vi in the REPL.
137137
# When Vi mode is disabled emacs keybindings such as Ctrl-A for home and Ctrl-E
138138
# for end are available in the REPL.
139+
#
140+
# Available key bindings:
141+
# F2 - Toggle smart completion on/off
142+
# F3 - Toggle multiline mode on/off
143+
# F4 - Toggle between Vi and Emacs mode
144+
# F5 - Toggle EXPLAIN mode on/off
145+
# Tab - Trigger/cycle through completions
146+
# Esc - Close completion menu
147+
# Ctrl+Space - Toggle completion menu on/off
148+
# Ctrl+j - Navigate to next completion (down) - Vim style
149+
# Ctrl+k - Navigate to previous completion (up) - Vim style
150+
# Ctrl+p - Move up in history
151+
# Ctrl+n - Move down in history
139152
vi = False
140153

141154
# Error handling

0 commit comments

Comments
 (0)