Skip to content

Commit 1614e06

Browse files
AndrewTilsonAndrew Tilson - awtilso
andauthored
Add Tab-toggled fullscreen help overlay, trim startup tips (#852)
* Add Tab-toggled fullscreen help overlay, trim startup tips Replaces the verbose startup tip spam with a single bold 'Press Tab for help' hint. Pressing Tab on an empty input buffer now opens a fullscreen vi/vim-style help overlay covering commands, keybindings, modes, and MCP/plugins; Tab on a non-empty buffer keeps its existing completion behavior. - help_catalog.py: assembles overlay content from the existing command registry and plugin callback sources (no new registration framework) plus curated static sections for keybindings/modes. - help_overlay.py: fullscreen renderer/launcher with vi-style navigation (j/k, gg/G), paging, and defensive error handling so a broken plugin callback can never crash the Tab key. - Reconciled with the double-Ctrl+C-to-quit feature that landed upstream in the same files during rebase (non-overlapping insertion point, no functional conflict). Ref: https://jira.walmart.com/browse/PUP-352 * Apply ruff format to fix CI quality check * Bold the startup Tab-help hint (was plain text) The SYSTEM message renderer escapes Rich markup in plain strings before printing, so inline bold markup in the i18n string would show up as literal brackets. Wrap it in a rich.text.Text with style=bold instead, matching the private fork's implementation. * Trim over-explained comments and redundant help tests Comments were written defensively and had drifted into narrating history rather than explaining code: references to internal planning docs and review sessions a reader has no access to, plus rationale repeated in three places. Kept the non-obvious ones (the launch-race lock, the AsyncMock requirement, why Ctrl+K is conditional) and cut the rest. Tests: 10 of them mocked get_available_agents, which this module never calls. Collapsed 8 single-shape parser tests and 2 column-width tests into parametrized cases, and dropped an exception test wholly subsumed by the lock-release test. Fixed a shadowed duplicate test name that was silently preventing one lock test from running at all. --------- Co-authored-by: Andrew Tilson - awtilso <Andrew.Tilson@walmart.com>
1 parent e0fa4ff commit 1614e06

16 files changed

Lines changed: 1034 additions & 51 deletions

code_puppy/cli_runner.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
from code_puppy.http_utils import find_available_port
4141
from code_puppy.keymap import (
4242
KeymapError,
43-
get_cancel_agent_display_name,
4443
validate_cancel_agent_key,
4544
)
4645
from code_puppy.messaging import emit_info
@@ -747,26 +746,18 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
747746
from code_puppy.command_line.command_handler import handle_command
748747

749748
display_console = message_renderer.console
749+
from rich.text import Text
750+
750751
from code_puppy.messaging import emit_info, emit_system_message
751752

752-
emit_system_message(t("cli.help.exit"))
753-
emit_system_message(t("cli.help.clear"))
754-
emit_system_message(t("cli.help.commands"))
755-
emit_system_message(t("cli.help.completion"))
756-
emit_system_message(t("cli.help.paste_images"))
757-
import platform
758-
759-
if platform.system() == "Darwin":
760-
emit_system_message(t("cli.help.macos_paste"))
761-
cancel_key = get_cancel_agent_display_name()
762-
emit_system_message(t("cli.help.cancel_key", cancel_key=cancel_key))
763-
emit_system_message(t("cli.help.editor_shortcuts"))
764-
emit_system_message(t("cli.help.autosave_load"))
765-
emit_system_message(t("cli.help.diff"))
766-
emit_system_message(t("cli.help.tutorial"))
767-
emit_system_message(t("cli.help.shell_passthrough"))
753+
# Pass a Text object (not a plain str): the SYSTEM renderer escapes Rich
754+
# markup in plain strings before printing (see renderers.py), so inline
755+
# "[bold]...[/bold]" in the i18n string would show up as literal
756+
# brackets. A Text object bypasses that string branch entirely and
757+
# renders as one line, actually bold.
758+
emit_system_message(Text(t("cli.help.press_tab"), style="bold"))
768759
# Print truecolor warning LAST so it's the most visible thing on startup
769-
# Big ugly red box should be impossible to miss! 🔴
760+
# Big ugly red box should be impossible to miss!
770761
print_truecolor_warning(display_console)
771762

772763
# Shell pass-through for initial_command: !<cmd> bypasses the agent
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
"""Content assembly for the Tab-toggled help overlay (see help_overlay.py).
2+
3+
An assembler, not a source of truth: content comes from the existing
4+
command registry and plugin callbacks, plus static sections for things
5+
with no registry of their own (keybindings, input modes).
6+
7+
Scope is deliberately the first layer -- commands themselves, not their
8+
arguments. ``/set`` gets a row; its individual config keys and the
9+
environment variables behind them do not.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from dataclasses import dataclass, field
15+
from typing import Dict, List, Tuple
16+
17+
from code_puppy.keymap import get_cancel_agent_display_name
18+
19+
20+
@dataclass(frozen=True)
21+
class HelpEntry:
22+
"""One row in a help section: a short left-hand label + description."""
23+
24+
left: str
25+
right: str = ""
26+
27+
28+
@dataclass(frozen=True)
29+
class HelpSection:
30+
"""A titled group of :class:`HelpEntry` rows."""
31+
32+
title: str
33+
entries: List[HelpEntry] = field(default_factory=list)
34+
35+
36+
_CATEGORY_TITLES: Dict[str, str] = {
37+
"core": "Core Commands",
38+
"config": "Configuration Commands",
39+
"session": "Session Commands",
40+
"tools": "Tool Commands",
41+
}
42+
43+
#: Folded into the callback-sourced "Plugin / Private Commands" section
44+
#: rather than getting its own, which would render a bare "PLUGIN" heading
45+
#: directly above it.
46+
_PLUGIN_REGISTRY_CATEGORY = "plugin"
47+
48+
49+
def _normalize_custom_command_entries() -> List[Tuple[str, str]]:
50+
"""Flatten the several return shapes ``on_custom_command_help()`` allows.
51+
52+
Mirrors the tolerant parsing in ``command_handler.get_commands_help()``
53+
and ``SlashCompleter.get_completions()``, and additionally strips a
54+
leading "/" so a slash-prefixed name can't render as "//name".
55+
"""
56+
entries: List[Tuple[str, str]] = []
57+
try:
58+
from code_puppy import callbacks, plugins
59+
60+
plugins.load_plugin_callbacks()
61+
for res in callbacks.on_custom_command_help():
62+
entries.extend(_parse_custom_command_result(res))
63+
except Exception:
64+
# Cheat sheet content must never crash the Tab key.
65+
pass
66+
return entries
67+
68+
69+
def _parse_custom_command_result(res) -> List[Tuple[str, str]]:
70+
"""Parse one plugin's ``on_custom_command_help()`` return value.
71+
72+
Tolerates every shape the callback contract allows: a bare
73+
``(name, description)`` tuple, a list of such tuples, or the legacy
74+
list-of-strings form (``"/name - Description"``).
75+
"""
76+
if not res:
77+
return []
78+
if isinstance(res, tuple) and len(res) == 2:
79+
return [(_strip_leading_slash(res[0]), str(res[1]))]
80+
if isinstance(res, list):
81+
parsed: List[Tuple[str, str]] = []
82+
for item in res:
83+
if isinstance(item, tuple) and len(item) == 2:
84+
parsed.append((_strip_leading_slash(item[0]), str(item[1])))
85+
elif isinstance(item, str) and item.startswith("/") and " - " in item:
86+
name, _, description = item.partition(" - ")
87+
parsed.append((_strip_leading_slash(name), description.strip()))
88+
return parsed
89+
return []
90+
91+
92+
def _strip_leading_slash(name) -> str:
93+
return str(name).lstrip("/").strip()
94+
95+
96+
def _builtin_command_sections() -> Tuple[List[HelpSection], List[HelpEntry]]:
97+
"""Group registered commands into titled sections.
98+
99+
Returns ``(sections, plugin_category_entries)``. Commands registered
100+
with ``category="plugin"`` come back separately so the caller can merge
101+
them into the single section built by ``_plugin_command_section()``.
102+
"""
103+
from code_puppy.command_line.command_registry import get_unique_commands
104+
105+
try:
106+
commands = get_unique_commands()
107+
except Exception:
108+
return [], []
109+
110+
by_category: Dict[str, List[HelpEntry]] = {}
111+
for cmd in sorted(commands, key=lambda c: c.name):
112+
label = cmd.usage or f"/{cmd.name}"
113+
if cmd.aliases:
114+
alias_list = ", ".join("/" + a for a in cmd.aliases)
115+
label += f" (aliases: {alias_list})"
116+
by_category.setdefault(cmd.category, []).append(
117+
HelpEntry(label, cmd.description)
118+
)
119+
120+
plugin_category_entries = by_category.pop(_PLUGIN_REGISTRY_CATEGORY, [])
121+
122+
sections = []
123+
# Stable, curated order first; anything unexpected still shows up.
124+
for category in ("core", "config", "session", "tools"):
125+
entries = by_category.pop(category, None)
126+
if entries:
127+
title = _CATEGORY_TITLES.get(category, category.title())
128+
sections.append(HelpSection(title, entries))
129+
for category, entries in by_category.items():
130+
sections.append(
131+
HelpSection(_CATEGORY_TITLES.get(category, category.title()), entries)
132+
)
133+
return sections, plugin_category_entries
134+
135+
136+
def _plugin_command_section(
137+
builtin_plugin_entries: List[HelpEntry],
138+
) -> List[HelpSection]:
139+
"""One merged section for both plugin-command sources.
140+
141+
Combines callback-advertised commands (``on_custom_command_help()``)
142+
with registry commands filed under ``category="plugin"``.
143+
"""
144+
callback_entries = _normalize_custom_command_entries()
145+
callback_rows = [HelpEntry(f"/{name}", desc) for name, desc in callback_entries]
146+
all_rows = list(builtin_plugin_entries) + callback_rows
147+
if not all_rows:
148+
return []
149+
all_rows.sort(key=lambda e: e.left)
150+
return [HelpSection("Plugin / Private Commands", all_rows)]
151+
152+
153+
def _keybinding_section() -> HelpSection:
154+
cancel_key = get_cancel_agent_display_name()
155+
entries = [
156+
HelpEntry("Tab (empty line)", "Toggle this help overlay"),
157+
HelpEntry("Tab (mid-word)", "Complete / cycle completions forward"),
158+
HelpEntry("Shift+Tab (mid-word)", "Cycle completions backward"),
159+
HelpEntry("/exit, /quit, Ctrl+D", "Exit interactive mode"),
160+
HelpEntry(
161+
cancel_key,
162+
"Clear input if composing; cancel task if empty",
163+
),
164+
]
165+
# Only meaningful once the cancel key has been remapped: plain Ctrl+C
166+
# keeps its own separate clear-the-line behavior in that case.
167+
if cancel_key != "Ctrl+C":
168+
entries.append(HelpEntry("Ctrl+C", "Clear the current input buffer"))
169+
entries.append(
170+
HelpEntry(
171+
"Alt+Enter",
172+
"Submit as a queued turn (after current, or now if idle)",
173+
)
174+
)
175+
entries.extend(
176+
[
177+
HelpEntry("Alt+M or F2", "Toggle multiline input"),
178+
HelpEntry(
179+
"Ctrl+J, Shift+Enter, or Ctrl+Enter",
180+
"Insert a newline (Ctrl+J is most reliable across terminals)",
181+
),
182+
HelpEntry("Ctrl+V / F3", "Paste an image (Ctrl+V works on macOS too)"),
183+
HelpEntry("Ctrl+X Ctrl+E", "Edit the prompt in $EDITOR"),
184+
HelpEntry("Ctrl+X Ctrl+B", "Background a running shell command"),
185+
HelpEntry("Ctrl+X Ctrl+X", "Kill a running shell command"),
186+
HelpEntry("@", "Path completion / attach a file"),
187+
HelpEntry("Ctrl+A / Ctrl+E", "Jump to the start / end of the line"),
188+
HelpEntry("Ctrl+U", "Clear the whole input buffer"),
189+
HelpEntry("Ctrl+W", "Delete the word before the cursor"),
190+
HelpEntry("Ctrl+R", "Start a reverse history search"),
191+
HelpEntry(
192+
"Ctrl+Left/Right, Option+Left/Right, or Meta-b/f",
193+
"Jump the cursor by one word",
194+
),
195+
]
196+
)
197+
# Ctrl+K is kill-to-end-of-line (line_editor.py), but when it's the
198+
# configured cancel key it never reaches the editor, so documenting
199+
# that binding would be a lie.
200+
if cancel_key != "Ctrl+K":
201+
entries.append(
202+
HelpEntry("Ctrl+K", "Kill (delete) from the cursor to the end of the line")
203+
)
204+
return HelpSection("Keybindings", entries)
205+
206+
207+
def _modes_section() -> HelpSection:
208+
return HelpSection(
209+
"Modes & Passthrough",
210+
[
211+
HelpEntry("Multiline mode", "Alt+M / F2 toggles; Enter inserts a newline"),
212+
HelpEntry("YOLO mode", "/set yolo_mode on -- skip confirmation prompts"),
213+
HelpEntry("!<command>", "Run a shell command directly (e.g. !git status)"),
214+
HelpEntry("/autosave_load", "Resume a previous autosave session"),
215+
HelpEntry("/diff", "Configure diff highlighting colors"),
216+
HelpEntry("/tutorial", "Re-run the onboarding tutorial"),
217+
],
218+
)
219+
220+
221+
def _mcp_plugins_section() -> HelpSection:
222+
return HelpSection(
223+
"MCP & Plugins",
224+
[
225+
HelpEntry("/mcp", "List, add, and manage MCP servers"),
226+
HelpEntry(
227+
"Plugins",
228+
"Loaded automatically at startup; extend commands, models, and callbacks",
229+
),
230+
],
231+
)
232+
233+
234+
_SECTION_ORDER: Tuple[str, ...] = (
235+
"Session Commands",
236+
"Keybindings",
237+
"Core Commands",
238+
"Modes & Passthrough",
239+
"Configuration Commands",
240+
"MCP & Plugins",
241+
"Plugin / Private Commands",
242+
"Tool Commands",
243+
)
244+
245+
246+
def build_help_sections() -> List[HelpSection]:
247+
"""Assemble every section shown in the Tab-toggled help overlay.
248+
249+
Sections are sorted into ``_SECTION_ORDER``, a curated display order.
250+
Titles missing from that tuple (e.g. a new command category) sort to
251+
the end rather than vanishing or raising.
252+
253+
Agent switching is covered by the ``/agent`` row the command registry
254+
already provides; listing every installed agent here would duplicate
255+
what ``/agent`` prints on its own.
256+
"""
257+
sections: List[HelpSection] = []
258+
builtin_sections, builtin_plugin_entries = _builtin_command_sections()
259+
sections.extend(builtin_sections)
260+
sections.extend(_plugin_command_section(builtin_plugin_entries))
261+
sections.append(_keybinding_section())
262+
sections.append(_modes_section())
263+
sections.append(_mcp_plugins_section())
264+
265+
order_index = {title: i for i, title in enumerate(_SECTION_ORDER)}
266+
sections.sort(key=lambda s: order_index.get(s.title, len(_SECTION_ORDER)))
267+
return sections

0 commit comments

Comments
 (0)