Skip to content

Commit 18cf0f2

Browse files
committed
feat(ui/shell): declutter and space out the slash command menu
Add a blank gap line between the input row and the slash command popup, drop the redundant [command]/[shell] tag (keeping the distinguishing [skill]/[flow] tags), and add a persistent footer legend set off by its own separator line. When the list overflows, the footer folds in a '+N more' count instead of silently hiding entries; the menu height adapts to the terminal and is capped to leave room for the chrome rows.
1 parent 93582e6 commit 18cf0f2

4 files changed

Lines changed: 201 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
2424
- **Login selector polish.** Configured `/login` providers render with distinct success/state styling; the background working indicator uses the braille spinner, and working tips wrap with a hanging indent under the verb.
2525
- **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them.
2626
- **Readable diff context.** Unchanged context lines in file-edit diff snippets now render in the normal body-text color instead of muted grey, so edited-file previews are easier to read; added/removed lines are unchanged.
27+
- **Cleaner slash command menu.** The slash command popup now has a blank line separating it from the input row, drops the repetitive `[command]`/`[shell]` tag (keeping the distinguishing `[skill]`/`[flow]` ones), and gains a persistent footer (`Enter to select · ↑/↓ to navigate · Esc to cancel`) set off by its own separator line. When the list scrolls, the footer folds in a `+N more` count instead of silently hiding entries, and the menu height adapts to the terminal.
2728

2829
## 0.42.0 (2026-06-12)
2930

src/pythinker_code/ui/shell/prompt.py

Lines changed: 110 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

tests/ui_and_conv/test_prompt_tips.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1385,7 +1385,8 @@ def test_slash_menu_highlights_typed_command_prefix(monkeypatch: Any) -> None:
13851385
)
13861386

13871387
content = SlashCommandMenuControl(left_padding=lambda: 0).create_content(width=60, height=5)
1388-
line = content.get_line(0)
1388+
# Line 0 is the blank gap row; the first command renders on line 1.
1389+
line = content.get_line(1)
13891390

13901391
assert "".join(text for _, text, *_ in line).lstrip().startswith("❯ /model")
13911392
highlighted = [(style, text) for style, text, *_ in line if "command.match" in style]

tests/ui_and_conv/test_slash_completer.py

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ def test_completion_display_uses_canonical_command_name():
248248
assert completions[0].display_meta_text == "help command"
249249

250250

251-
def test_annotated_completion_meta_includes_kind_and_aliases():
251+
def test_annotated_command_meta_drops_generic_tag_but_keeps_aliases():
252+
"""Plain commands no longer carry a redundant scope tag in the menu; the
253+
description and aliases remain so the row stays informative."""
252254
completer = SlashCommandCompleter(
253255
[_make_command("help", aliases=["h", "?"])],
254256
annotate_meta=True,
@@ -258,7 +260,9 @@ def test_annotated_completion_meta_includes_kind_and_aliases():
258260
completions = _completions(completer, "/h")
259261

260262
assert len(completions) == 1
261-
assert completions[0].display_meta_text == "[shell] help command aliases: /h, /?"
263+
assert completions[0].display_meta_text == "help command aliases: /h, /?"
264+
assert "[shell]" not in completions[0].display_meta_text
265+
assert "[command]" not in completions[0].display_meta_text
262266

263267

264268
def test_annotated_skill_completion_uses_skill_kind():
@@ -394,13 +398,88 @@ def test_slash_menu_preselects_first_item_when_index_unset(monkeypatch):
394398
"".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count)
395399
]
396400

397-
assert content.line_count == len(completions)
398-
assert content.cursor_position.y == 0
399-
# First row is highlighted, second row is not.
400-
assert "❯" in rendered_lines[0]
401-
assert "❯" not in rendered_lines[1]
402-
assert "Ctrl-O" in rendered_lines[0]
403-
assert rendered_lines[0].count("/editor") == 1
401+
# A blank gap line precedes the list; a blank separator and footer legend
402+
# follow it, so the menu reads as its own region.
403+
assert content.line_count == len(completions) + 3
404+
assert rendered_lines[0].strip() == ""
405+
assert content.cursor_position.y == 1
406+
# First item row is highlighted, second is not.
407+
assert "❯" in rendered_lines[1]
408+
assert "❯" not in rendered_lines[2]
409+
assert "Ctrl-O" in rendered_lines[1]
410+
assert rendered_lines[1].count("/editor") == 1
411+
# Blank separator then the footer legend on the last two lines.
412+
assert rendered_lines[-2].strip() == ""
413+
assert rendered_lines[-1].strip() == "Enter to select · ↑/↓ to navigate · Esc to cancel"
414+
415+
416+
def _slash_completions(count: int) -> list[Completion]:
417+
return [
418+
Completion(
419+
text=f"/cmd{i}",
420+
start_position=0,
421+
display=f"/cmd{i}",
422+
display_meta=f"command number {i}",
423+
)
424+
for i in range(count)
425+
]
426+
427+
428+
def test_slash_menu_footer_folds_in_overflow_count_when_list_exceeds_height(monkeypatch):
429+
"""When more completions exist than fit, the footer leads with the hidden
430+
count alongside the navigation legend instead of silently truncating."""
431+
completions = _slash_completions(20)
432+
complete_state = SimpleNamespace(completions=completions, complete_index=None)
433+
app = SimpleNamespace(current_buffer=SimpleNamespace(complete_state=complete_state))
434+
monkeypatch.setattr(prompt_mod, "get_app_or_none", lambda: app)
435+
436+
control = SlashCommandMenuControl(left_padding=lambda: 0)
437+
content = control.create_content(width=80, height=5)
438+
439+
rendered_lines = [
440+
"".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count)
441+
]
442+
443+
# Gap line + visible items + blank separator + footer, within the 5-row budget.
444+
assert content.line_count == 5
445+
assert rendered_lines[0].strip() == ""
446+
assert rendered_lines[-2].strip() == ""
447+
footer = rendered_lines[-1].strip()
448+
visible_items = content.line_count - 3 # minus gap, separator, footer
449+
assert footer.startswith(f"+{20 - visible_items} more · ")
450+
assert footer.endswith("Enter to select · ↑/↓ to navigate · Esc to cancel")
451+
452+
453+
def test_slash_menu_footer_shows_legend_without_count_when_list_fits(monkeypatch):
454+
completions = _slash_completions(2)
455+
complete_state = SimpleNamespace(completions=completions, complete_index=None)
456+
app = SimpleNamespace(current_buffer=SimpleNamespace(complete_state=complete_state))
457+
monkeypatch.setattr(prompt_mod, "get_app_or_none", lambda: app)
458+
459+
control = SlashCommandMenuControl(left_padding=lambda: 0)
460+
content = control.create_content(width=80, height=6)
461+
462+
rendered_lines = [
463+
"".join(fragment[1] for fragment in content.get_line(i)) for i in range(content.line_count)
464+
]
465+
466+
assert content.line_count == len(completions) + 3 # gap + items + separator + footer
467+
assert rendered_lines[-2].strip() == ""
468+
assert rendered_lines[-1].strip() == "Enter to select · ↑/↓ to navigate · Esc to cancel"
469+
assert not any("more · " in line for line in rendered_lines)
470+
471+
472+
def test_annotated_plain_command_meta_has_no_tag():
473+
completer = SlashCommandCompleter(
474+
[_make_command("help")],
475+
annotate_meta=True,
476+
command_scope="command",
477+
)
478+
479+
completions = _completions(completer, "/he")
480+
481+
assert completions[0].display_meta_text == "help command"
482+
assert "[command]" not in completions[0].display_meta_text
404483

405484

406485
def test_find_prompt_float_container_supports_conditional_container_shape():

0 commit comments

Comments
 (0)