@@ -467,14 +467,21 @@ def _display_meta(self, cmd: SlashCommand[Any]) -> str:
467467 if not self ._annotate_meta :
468468 return cmd .description
469469
470+ # Only surface a kind tag when it distinguishes the entry from a plain
471+ # command. Skills and flows are interleaved with commands in the agent
472+ # menu, so their tag carries information; the generic command/shell scope
473+ # is already obvious from the menu itself, so tagging every row is noise.
470474 if cmd .name .startswith ("skill:" ):
471- kind = "skill"
475+ kind : str | None = "skill"
472476 elif cmd .name .startswith ("flow:" ):
473477 kind = "flow"
474478 else :
475- kind = self . _command_scope
479+ kind = None
476480
477- parts = [f"[{ kind } ]" , cmd .description ]
481+ parts : list [str ] = []
482+ if kind is not None :
483+ parts .append (f"[{ kind } ]" )
484+ parts .append (cmd .description )
478485 if cmd .aliases :
479486 parts .append (f"aliases: { ', ' .join ('/' + alias for alias in cmd .aliases )} " )
480487 return " " .join (part for part in parts if part )
@@ -851,6 +858,14 @@ class SlashCommandMenuControl(UIControl):
851858 """Render slash command completions as a full-width menu that matches the shell UI."""
852859
853860 _MAX_EXPANDED_META_LINES = 3
861+ # One blank line is reserved above the list as breathing room from the input
862+ # row, so the menu reads as its own region rather than crowding what's typed.
863+ _GAP_LINES = 1
864+ # A persistent footer block at the bottom: a blank separator line plus the
865+ # navigation legend (which folds in the overflow count when the list scrolls).
866+ # The separator gives the legend the same breathing room as the top gap.
867+ _FOOTER_LINES = 2
868+ _FOOTER_LEGEND = "Enter to select · ↑/↓ to navigate · Esc to cancel"
854869
855870 def __init__ (
856871 self ,
@@ -881,19 +896,27 @@ def preferred_height(
881896 if complete_state is None :
882897 return 0
883898 completions = complete_state .completions
899+ if not completions :
900+ return 0
884901 selected_index = complete_state .complete_index
885902 if selected_index is None :
886- return min (max_available_height , len (completions ))
887- menu_width = max (0 , width - self ._left_padding ())
888- marker_width = 2
889- command_width = self ._command_column_width (completions , menu_width , marker_width )
890- gap_width = 3 if menu_width > command_width + 6 else 1
891- meta_width = max (0 , menu_width - marker_width - command_width - gap_width )
892- selected_meta_lines = self ._selected_meta_lines (
893- completions [selected_index ].display_meta_text ,
894- meta_width ,
895- )
896- return min (max_available_height , len (completions ) + len (selected_meta_lines ) - 1 )
903+ content_height = len (completions )
904+ else :
905+ menu_width = max (0 , width - self ._left_padding ())
906+ marker_width = 2
907+ command_width = self ._command_column_width (completions , menu_width , marker_width )
908+ gap_width = 3 if menu_width > command_width + 6 else 1
909+ meta_width = max (0 , menu_width - marker_width - command_width - gap_width )
910+ selected_meta_lines = self ._selected_meta_lines (
911+ completions [selected_index ].display_meta_text ,
912+ meta_width ,
913+ )
914+ content_height = (len (completions ) - 1 ) + len (selected_meta_lines )
915+ # Reserve the gap line above the list and the footer line below it. When
916+ # the list is taller than the space the window allows, the window caps the
917+ # height and create_content lays the list out within whatever rows remain.
918+ chrome = self ._GAP_LINES + self ._FOOTER_LINES
919+ return min (max_available_height , content_height + chrome )
897920
898921 def create_content (self , width : int , height : int ) -> UIContent :
899922 app = get_app_or_none ()
@@ -905,7 +928,6 @@ def create_content(self, width: int, height: int) -> UIContent:
905928
906929 completions = complete_state .completions
907930 selected_index = complete_state .complete_index
908- available_rows = max (1 , height )
909931 match_prefix_len = self ._match_prefix_len (app )
910932
911933 menu_width = max (0 , width - self ._left_padding ())
@@ -914,16 +936,21 @@ def create_content(self, width: int, height: int) -> UIContent:
914936 gap_width = 3 if menu_width > command_width + 6 else 1
915937 meta_width = max (0 , menu_width - marker_width - command_width - gap_width )
916938
917- rendered_lines : list [FormattedText ] = []
918- selected_line_index = 0
939+ total_rows = max (1 , height )
940+ # The gap line above the list and the footer line below it are always
941+ # present, so the list itself lays out within the remaining rows.
942+ item_rows = max (1 , total_rows - self ._GAP_LINES - self ._FOOTER_LINES )
943+
944+ rendered_lines : list [FormattedText ] = [self ._blank_line ()]
945+ cursor_y = 0
919946
920947 if selected_index is None :
921948 # Pre-highlight index 0 even before the user navigates: pressing
922949 # Enter accepts the first completion, so the visual state should
923950 # match that behavior. Without this the menu looks ambiguous (no
924951 # row highlighted) but Enter still commits the top row.
925- end = min (len (completions ) - 1 , available_rows - 1 )
926- for index in range (0 , end + 1 ):
952+ shown = min (len (completions ), item_rows )
953+ for index in range (shown ):
927954 rendered_lines .append (
928955 self ._render_single_line_item (
929956 width = width ,
@@ -936,62 +963,83 @@ def create_content(self, width: int, height: int) -> UIContent:
936963 match_prefix_len = match_prefix_len ,
937964 )
938965 )
939-
940- return UIContent (
941- get_line = lambda i : rendered_lines [i ],
942- line_count = len (rendered_lines ),
943- cursor_position = Point (x = 0 , y = 0 ),
966+ cursor_y = 1 if shown else 0
967+ hidden = len (completions ) - shown
968+ else :
969+ selected_meta_lines = self ._selected_meta_lines (
970+ completions [selected_index ].display_meta_text ,
971+ meta_width ,
944972 )
945-
946- selected_meta_lines = self ._selected_meta_lines (
947- completions [selected_index ].display_meta_text ,
948- meta_width ,
949- )
950- start , end = self ._visible_window_bounds (
951- completion_count = len (completions ),
952- selected_index = selected_index ,
953- available_rows = available_rows ,
954- selected_item_height = len (selected_meta_lines ),
955- )
956- selected_line_index = 0
957-
958- for index in range (start , end + 1 ):
959- completion = completions [index ]
960- if index == selected_index :
961- selected_line_index = len (rendered_lines )
962- rendered_lines .extend (
963- self ._render_selected_item_lines (
973+ start , end = self ._visible_window_bounds (
974+ completion_count = len (completions ),
975+ selected_index = selected_index ,
976+ available_rows = item_rows ,
977+ selected_item_height = len (selected_meta_lines ),
978+ )
979+ for index in range (start , end + 1 ):
980+ completion = completions [index ]
981+ if index == selected_index :
982+ cursor_y = len (rendered_lines )
983+ rendered_lines .extend (
984+ self ._render_selected_item_lines (
985+ width = width ,
986+ completion = completion ,
987+ marker_width = marker_width ,
988+ command_width = command_width ,
989+ meta_width = meta_width ,
990+ gap_width = gap_width ,
991+ meta_lines = selected_meta_lines ,
992+ match_prefix_len = match_prefix_len ,
993+ )
994+ )
995+ continue
996+ rendered_lines .append (
997+ self ._render_single_line_item (
964998 width = width ,
965999 completion = completion ,
9661000 marker_width = marker_width ,
9671001 command_width = command_width ,
9681002 meta_width = meta_width ,
9691003 gap_width = gap_width ,
970- meta_lines = selected_meta_lines ,
1004+ is_current = False ,
9711005 match_prefix_len = match_prefix_len ,
9721006 )
9731007 )
974- continue
1008+ hidden = len ( completions ) - ( end - start + 1 )
9751009
976- rendered_lines .append (
977- self ._render_single_line_item (
978- width = width ,
979- completion = completion ,
980- marker_width = marker_width ,
981- command_width = command_width ,
982- meta_width = meta_width ,
983- gap_width = gap_width ,
984- is_current = False ,
985- match_prefix_len = match_prefix_len ,
986- )
1010+ rendered_lines .append (self ._blank_line ())
1011+ rendered_lines .append (
1012+ self ._render_footer_line (
1013+ width = width , marker_width = marker_width , hidden_count = max (0 , hidden )
9871014 )
988-
1015+ )
9891016 return UIContent (
9901017 get_line = lambda i : rendered_lines [i ],
9911018 line_count = len (rendered_lines ),
992- cursor_position = Point (x = 0 , y = selected_line_index ),
1019+ cursor_position = Point (x = 0 , y = cursor_y ),
9931020 )
9941021
1022+ def _blank_line (self ) -> FormattedText :
1023+ return FormattedText ([("class:slash-completion-menu" , "" )])
1024+
1025+ def _render_footer_line (
1026+ self , * , width : int , marker_width : int , hidden_count : int
1027+ ) -> FormattedText :
1028+ # Persistent navigation legend, rendered in the dim meta style and aligned
1029+ # under the command column. When the list scrolled, the count of hidden
1030+ # entries leads so it survives truncation on narrow terminals.
1031+ indent = self ._left_padding () + marker_width
1032+ text = self ._FOOTER_LEGEND
1033+ if hidden_count > 0 :
1034+ text = f"+{ hidden_count } more · { text } "
1035+ body = _truncate_to_width (text , max (0 , width - indent ))
1036+ trailing = max (0 , width - indent - get_cwidth (body ))
1037+ fragments : FormattedText = FormattedText ()
1038+ fragments .append (("class:slash-completion-menu" , " " * indent ))
1039+ fragments .append (("class:slash-completion-menu.meta" , body ))
1040+ fragments .append (("class:slash-completion-menu" , " " * trailing ))
1041+ return fragments
1042+
9951043 def _match_prefix_len (self , app : Any ) -> int :
9961044 document = getattr (getattr (app , "current_buffer" , None ), "document" , None )
9971045 if not isinstance (document , Document ):
@@ -2637,7 +2685,10 @@ def _install_slash_completion_menu(self) -> None:
26372685 Window (
26382686 content = self ._slash_menu_control ,
26392687 dont_extend_height = True ,
2640- height = Dimension (max = 10 ),
2688+ # Cap leaves room for the gap + separator + footer chrome (3 rows)
2689+ # while still showing ~9 commands; preferred_height clamps to the
2690+ # terminal's available height so it never overflows a short window.
2691+ height = Dimension (max = 12 ),
26412692 style = "class:slash-completion-menu" ,
26422693 ),
26432694 filter = has_completions & slash_completion_filter ,
0 commit comments