feat(tui): implement help panel (F1) with context-sensitive help
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 18s
CI / helm (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 3m20s
CI / quality (pull_request) Successful in 3m44s
CI / typecheck (pull_request) Successful in 3m58s
CI / security (pull_request) Successful in 4m10s
CI / unit_tests (pull_request) Successful in 9m2s
CI / docker (pull_request) Successful in 1m30s
CI / coverage (pull_request) Successful in 9m3s
CI / e2e_tests (pull_request) Successful in 22m5s
CI / integration_tests (pull_request) Successful in 24m37s
CI / status-check (pull_request) Successful in 2s
CI / benchmark-regression (pull_request) Successful in 55m59s

Add a dedicated TUI help overlay toggled by F1 and resolve its content from the current prompt mode for main-screen, slash-command, reference, and shell contexts.

Extend Behave and Robot coverage for the new help-panel widget, app wiring, context switching, and toggle behavior.

ISSUES CLOSED: #1013
This commit is contained in:
2026-04-01 21:51:14 +00:00
parent 49058c3a72
commit 0ae733d01f
10 changed files with 321 additions and 4 deletions
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Added a context-sensitive TUI help panel overlay toggled by `F1`, with
help content that varies for main-screen, slash-command, reference, and
shell prompt modes. Updated Behave and Robot coverage for help-panel
rendering and mode switching. (#1013)
- Expanded the TUI slash command overlay catalog to include 67 commands across
14 groups, aligned with the specification command reference for session,
persona, scope, plan, project, registry/config, context, and utility flows.
+29
View File
@@ -115,11 +115,13 @@ def _install_mock_textual(context):
sys.modules[key] = mod
# Reload widget modules so they pick up the mock Static/Input base class
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
@@ -141,11 +143,13 @@ def _restore_modules(context):
sys.modules[key] = val
# Reload widget modules so they pick up the real Static/Input base class again
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
import cleveragents.tui.widgets.reference_picker as rp_mod
import cleveragents.tui.widgets.slash_command_overlay as sco_mod
importlib.reload(hp_mod)
importlib.reload(pb_mod)
importlib.reload(prompt_mod)
importlib.reload(rp_mod)
@@ -327,6 +331,15 @@ def step_persona_bar_has_content(context):
assert bar._text # non-empty
@then("the help panel should be hidden on mount")
def step_help_panel_hidden_on_mount(context):
from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
panel = context._tui_app.query_one("#help-panel", HelpPanelOverlay)
assert panel.visible is False
assert panel._text == ""
@then("the reference picker should have suggestions initialised")
def step_ref_picker_initialised(context):
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
@@ -352,6 +365,14 @@ def step_call_action_help(context):
context._tui_app.action_help()
@when('I set the prompt text to "{text}"')
def step_set_prompt_text(context, text):
from cleveragents.tui.widgets.prompt import PromptInput
prompt = context._tui_app.query_one("#prompt", PromptInput)
prompt.value = text
@then('the conversation widget should contain "{text}"')
def step_conversation_contains(context, text):
MockStatic = context._tui_mock_static
@@ -359,6 +380,14 @@ def step_conversation_contains(context, text):
assert text in conv._text, f"Expected '{text}' in '{conv._text}'"
@then('the help panel text should contain "{text}"')
def step_help_panel_contains(context, text):
from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
panel = context._tui_app.query_one("#help-panel", HelpPanelOverlay)
assert text in panel._text, f"Expected '{text}' in '{panel._text}'"
# ---------------------------------------------------------------------------
# action_cycle_preset (lines 127-129)
# ---------------------------------------------------------------------------
@@ -0,0 +1,56 @@
"""Step definitions for tui_help_panel_overlay_coverage.feature."""
from __future__ import annotations
from behave import given, then, when
from cleveragents.tui.widgets.help_panel_overlay import (
HelpPanelOverlay,
resolve_help_context,
)
@when("I resolve help context for an empty prompt")
def step_resolve_help_context_empty(context):
context.resolved_help_context = resolve_help_context("")
@when('I resolve help context for prompt text "{text}"')
def step_resolve_help_context(context, text):
context.resolved_help_context = resolve_help_context(text)
@then('the resolved help context should be "{expected}"')
def step_verify_resolved_help_context(context, expected):
assert context.resolved_help_context == expected
@given("a fresh help panel overlay")
def step_fresh_help_panel_overlay(context):
context.help_panel = HelpPanelOverlay()
@when('I show help for context "{context_name}"')
def step_show_help_for_context(context, context_name):
context.help_panel.show_context(context_name)
@when('I toggle help for context "{context_name}"')
def step_toggle_help_for_context(context, context_name):
context.help_panel.toggle(context_name)
@then('the standalone help panel text should contain "{text}"')
def step_standalone_help_panel_text_contains(context, text):
assert text in context.help_panel._text
@then("the help panel should be visible")
def step_help_panel_visible(context):
assert context.help_panel.visible is True
@then("the help panel should be hidden")
def step_help_panel_hidden(context):
assert context.help_panel.visible is False
assert context.help_panel._text == ""
+28 -2
View File
@@ -64,17 +64,43 @@ Feature: TUI App Coverage
When I instantiate the Textual TUI app
And I call on_mount on the app
Then the persona bar should have content set
And the help panel should be hidden on mount
And the reference picker should have suggestions initialised
And the slash overlay should have commands initialised
# --- action_help method (lines 123-125) ---
Scenario: action_help updates conversation with hotkey info
Scenario: action_help opens a main-screen help panel
Given a mock command router and persona state
When I instantiate the Textual TUI app
And I call on_mount on the app
And I call action_help on the app
Then the conversation widget should contain "Hotkeys"
Then the help panel text should contain "Help: Main Screen"
And the help panel text should contain "ctrl+q"
Scenario: action_help reflects slash command context
Given a mock command router and persona state
When I instantiate the Textual TUI app
And I call on_mount on the app
And I set the prompt text to "/persona list"
And I call action_help on the app
Then the help panel text should contain "Help: Slash Commands"
Scenario: action_help reflects shell mode context
Given a mock command router and persona state
When I instantiate the Textual TUI app
And I call on_mount on the app
And I set the prompt text to "!ls"
And I call action_help on the app
Then the help panel text should contain "Help: Shell Mode"
Scenario: action_help toggles the visible help panel off
Given a mock command router and persona state
When I instantiate the Textual TUI app
And I call on_mount on the app
And I call action_help on the app
And I call action_help on the app
Then the help panel should be hidden on mount
# --- action_cycle_preset method (lines 127-129) ---
@@ -0,0 +1,27 @@
Feature: TUI Help Panel Overlay Coverage
Scenarios exercising the context-sensitive help overlay widget.
Scenario: resolve_help_context detects the active prompt mode
When I resolve help context for an empty prompt
Then the resolved help context should be "Main Screen"
When I resolve help context for prompt text "/persona list"
Then the resolved help context should be "Slash Commands"
When I resolve help context for prompt text "inspect @README.md"
Then the resolved help context should be "Reference Picker"
When I resolve help context for prompt text "!ls"
Then the resolved help context should be "Shell Mode"
Scenario: help panel overlay renders context-specific content
Given a fresh help panel overlay
When I show help for context "Main Screen"
Then the standalone help panel text should contain "Help: Main Screen"
And the standalone help panel text should contain "ctrl+q"
And the standalone help panel text should contain "enter"
Scenario: help panel overlay toggle hides the current context
Given a fresh help panel overlay
When I toggle help for context "Slash Commands"
Then the help panel should be visible
And the standalone help panel text should contain "Help: Slash Commands"
When I toggle help for context "Slash Commands"
Then the help panel should be hidden
+22
View File
@@ -40,3 +40,25 @@ TUI Headless Includes Router Help Payload
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} "help"
Should Contain ${result.stdout} /persona
TUI Help Panel Context Switching
${script}= Catenate SEPARATOR=\n
... from types import SimpleNamespace
... from features.steps import tui_app_coverage_steps as steps
... context = SimpleNamespace(add_cleanup=lambda fn: None)
... steps._install_mock_textual(context)
... steps.step_create_mock_deps(context)
... steps.step_instantiate_app(context)
... steps.step_call_on_mount(context)
... steps.step_set_prompt_text(context, "/persona list")
... steps.step_call_action_help(context)
... steps.step_help_panel_contains(context, "Help: Slash Commands")
... steps.step_set_prompt_text(context, "!ls")
... steps.step_call_action_help(context)
... steps.step_help_panel_contains(context, "Help: Shell Mode")
... steps._restore_modules(context)
... steps._cleanup_tmpdir(context)
... print("tui-help-panel-ok")
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tui-help-panel-ok
+11 -2
View File
@@ -11,6 +11,10 @@ from cleveragents.tui.input.modes import InputMode, InputModeRouter
from cleveragents.tui.input.reference_parser import suggestions
from cleveragents.tui.persona.state import PersonaState
from cleveragents.tui.slash_catalog import slash_command_names
from cleveragents.tui.widgets.help_panel_overlay import (
HelpPanelOverlay,
resolve_help_context,
)
from cleveragents.tui.widgets.persona_bar import PersonaBar
from cleveragents.tui.widgets.prompt import PromptInput
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
@@ -104,6 +108,7 @@ if _TEXTUAL_AVAILABLE:
yield _Header(show_clock=True)
with _Vertical(id="main-column"):
yield _Static("CleverAgents TUI", id="conversation")
yield HelpPanelOverlay(id="help-panel")
yield ReferencePickerOverlay(id="reference-picker")
yield SlashCommandOverlay(id="slash-overlay")
yield PromptInput(
@@ -114,14 +119,18 @@ if _TEXTUAL_AVAILABLE:
def on_mount(self) -> None:
self._refresh_persona_bar()
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
help_panel.hide()
ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
ref_picker.set_suggestions("", [])
slash = self.query_one("#slash-overlay", SlashCommandOverlay)
slash.set_commands("", slash_command_names())
def action_help(self) -> None:
conversation = self.query_one("#conversation", _Static)
conversation.update("Hotkeys: F1 help, Ctrl+Q quit, Ctrl+T cycle preset")
prompt = self.query_one("#prompt", PromptInput)
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
context_name = resolve_help_context(prompt.value)
help_panel.toggle(context_name)
def action_cycle_preset(self) -> None:
self._persona_state.cycle_preset(self._session.session_id)
+8
View File
@@ -14,6 +14,14 @@ Screen {
background: $panel;
}
#help-panel {
height: auto;
max-height: 12;
padding: 0 1;
border: round $primary;
background: $panel-lighten-1;
}
#reference-picker {
height: auto;
max-height: 8;
+2
View File
@@ -1,11 +1,13 @@
"""Widget collection for CleverAgents TUI."""
from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay
from cleveragents.tui.widgets.persona_bar import PersonaBar
from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
__all__ = [
"HelpPanelOverlay",
"PersonaBar",
"PromptInput",
"PromptSubmitted",
@@ -0,0 +1,133 @@
"""Context-sensitive help panel overlay for the TUI."""
from __future__ import annotations
import importlib
from typing import Any
def _load_static_base() -> type[Any]:
try:
return importlib.import_module("textual.widgets").Static
except Exception: # pragma: no cover
class _FallbackStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
return _FallbackStatic
_StaticBase = _load_static_base()
_GLOBAL_ITEMS = (
("ctrl+q", "Quit TUI immediately"),
("F1", "Toggle help panel"),
("escape", "Close current overlay / return to prompt"),
)
_CONTEXT_ITEMS: dict[str, tuple[tuple[str, str], ...]] = {
"Main Screen": (
("enter", "Submit prompt"),
("tab", "Cycle to next persona"),
("ctrl+tab", "Cycle to next argument preset"),
("@", "Open Reference Picker overlay"),
("/", "Open Slash Command overlay"),
("! / $", "Activate shell mode"),
),
"Slash Commands": (
("/", "Show slash command candidates"),
("enter", "Submit slash command"),
("escape", "Dismiss overlay and return to prompt"),
),
"Reference Picker": (
("@", "Search reference candidates"),
("enter", "Submit prompt with references"),
("escape", "Dismiss overlay and return to prompt"),
),
"Shell Mode": (
("enter", "Run shell command"),
("tab", "Tab-complete file or directory path"),
("backspace", "Exit shell mode at position 0"),
),
}
def resolve_help_context(prompt_text: str) -> str:
"""Resolve the help context from the current prompt text."""
stripped = prompt_text.lstrip()
if stripped.startswith("/"):
return "Slash Commands"
if stripped.startswith(("!", "$")):
return "Shell Mode"
if "@" in prompt_text:
return "Reference Picker"
return "Main Screen"
def render_help_panel(context_name: str) -> str:
"""Render help content for the requested context."""
items = _CONTEXT_ITEMS.get(context_name, _CONTEXT_ITEMS["Main Screen"])
lines = [f"Help: {context_name}", "", "Global"]
for key, description in _GLOBAL_ITEMS:
lines.append(f" {key:<10} {description}")
lines.append("")
lines.append(context_name)
for key, description in items:
lines.append(f" {key:<10} {description}")
return "\n".join(lines)
class HelpPanelOverlay(_StaticBase):
"""Simple static overlay that toggles contextual help text."""
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self._context_name = ""
self._visible = False
self._text = ""
@property
def visible(self) -> bool:
"""Return whether help is currently displayed."""
return self._visible
@property
def context_name(self) -> str:
"""Return the currently rendered help context name."""
return self._context_name
def show_context(self, context_name: str) -> None:
"""Show help content for a specific context."""
self._context_name = context_name
self._visible = True
self._text = render_help_panel(context_name)
self.update(self._text)
def hide(self) -> None:
"""Hide the help panel and clear its text."""
self._context_name = ""
self._visible = False
self._text = ""
self.update("")
def toggle(self, context_name: str) -> bool:
"""Toggle the panel for the given context.
Returns ``True`` when the panel ends visible and ``False`` when hidden.
"""
if self._visible and self._context_name == context_name:
self.hide()
return False
self.show_context(context_name)
return True