fix(desktop): reveal complete slash help across the window

Preserve producer descriptions without identity wrappers and size the existing themed tooltip to the viewport. Replace skipped and structural tests with two behavioral invariants. Native Electron before/after hover, click and keyboard verification passed; campaign suite validation remains queued.
This commit is contained in:
Teknium
2026-09-07 02:52:13 -07:00
parent 00bb169d1b
commit f83ff89780
7 changed files with 66 additions and 106 deletions
+2
View File
@@ -146,6 +146,8 @@ trigger within 300ms opens instantly. The cooldown starts on close, so a
hover a second later waits again. Close is immediate. `OverflowTip` stays
on its own longer delay (list titles must not trail while scanning).
**Slash descriptions.** Keep autocomplete rows single-line and ellipsized, but reveal the complete catalog description in the shared themed tooltip when hovering anywhere on a slash row. Size that tooltip to the window with collision padding and word wrapping; it must not intercept row selection. Catalog and completion producers preserve the full author-supplied description.
**Keybind hints in tooltips.** On a tipped button bound to a rebindable hotkey,
use `<TipKeybindLabel actionId="..." />` — it reads the i18n label and the
current combo from `$bindings`. Pass `text={...}` only when the label is
@@ -0,0 +1,24 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
import { ComposerTriggerPopover } from './trigger-popover'
afterEach(cleanup)
it('reveals the complete slash description on hover without intercepting selection', async () => {
const description = 'Complete command help '.repeat(20)
const item = { id: '/proof', type: 'slash', label: 'proof', metadata: { display: '/proof', meta: description } }
const onPick = vi.fn()
render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerTriggerPopover activeIndex={0} items={[item]} kind="/" loading={false} onHover={vi.fn()} onPick={onPick} />
</I18nProvider>
)
const row = screen.getByRole('button')
fireEvent.pointerMove(row, { pointerType: 'mouse' })
expect((await screen.findByRole('tooltip')).textContent).toBe(description)
fireEvent.click(row)
expect(onPick).toHaveBeenCalledWith(item)
})
@@ -1,5 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n'
@@ -108,93 +107,6 @@ describe('ComposerTriggerPopover keyboard scrolling', () => {
)
}
describe('full description tooltip', () => {
afterEach(() => {
vi.useRealTimers()
})
const descItem = {
id: '/skill-command',
type: 'slash',
label: 'skill-command',
metadata: {
command: '/skill-command',
display: '/skill-command',
group: 'Skills',
meta: 'This is a long skill command description that exceeds the single truncated line shown in the row',
rawText: '/skill-command',
action: ''
}
}
function popoverWithDesc() {
return (
<I18nProvider configClient={null} initialLocale="en">
<ComposerTriggerPopover
activeIndex={0}
items={[descItem]}
kind="/"
loading={false}
onHover={vi.fn()}
onPick={vi.fn()}
/>
</I18nProvider>
)
}
it('wraps the row button in a tooltip trigger', () => {
render(popoverWithDesc())
const button = screen.getByRole('button')
const trigger = button.closest('[data-slot="tooltip-trigger"]')
expect(trigger).toBeTruthy()
expect(trigger?.getAttribute('data-state')).toBe('closed')
})
it.skip('shows the full description after settled hover', () => {
vi.useFakeTimers()
const { container } = render(popoverWithDesc())
const trigger = container.querySelector('[data-slot="tooltip-trigger"]') as HTMLElement
expect(trigger).toBeTruthy()
act(() => {
fireEvent.pointerEnter(trigger)
vi.advanceTimersByTime(700)
})
expect(screen.getByRole('tooltip').textContent).toContain(descItem.metadata.meta)
})
it('stays closed for an item without a description', () => {
vi.useFakeTimers()
const noDescItem = { ...descItem, metadata: { ...descItem.metadata, meta: '' } }
const { container } = render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerTriggerPopover
activeIndex={0}
items={[noDescItem]}
kind="/"
loading={false}
onHover={vi.fn()}
onPick={vi.fn()}
/>
</I18nProvider>
)
const button = container.querySelector('button[type="button"]') as HTMLElement
act(() => {
fireEvent.pointerEnter(button)
vi.advanceTimersByTime(500)
})
expect(screen.queryByRole('tooltip')).toBeNull()
})
})
it('keeps keyboard navigation visible and restores the group header on wrap', () => {
const { container, rerender } = render(popover(0))
const drawer = container.querySelector('[data-slot="composer-completion-drawer"]') as HTMLElement
@@ -287,4 +199,4 @@ describe('ComposerTriggerPopover keyboard scrolling', () => {
expect(drawer.scrollTop).toBe(30)
})
})
})
@@ -200,7 +200,13 @@ export function ComposerTriggerPopover({
return (
<Fragment key={item.id}>
{showHeader && <div className={cn(GROUP_HEADER_CLASS, isFirstHeader ? 'pt-0.5' : 'pt-2')}>{group}</div>}
<Tip delayDuration={400} label={description || undefined} sideOffset={4}>
<Tip
className="max-w-[calc(100vw-2rem)] wrap-anywhere"
collisionPadding={16}
delayDuration={400}
label={kind === '/' ? description : undefined}
sideOffset={4}
>
<button
className={ROW_CLASS}
data-highlighted={active ? '' : undefined}
+4 -9
View File
@@ -43,11 +43,6 @@ def _personalities_from_cli_config() -> Dict[str, Any]:
return _personalities_memo[1]
def _short_desc(info: Mapping[str, Any], default: str) -> str:
"""Full description, passed through to the completion menu."""
return str(info.get("description", default))
def _file_size_label(path: str) -> str:
"""Return a compact human-readable file size, or '' on error."""
try:
@@ -334,7 +329,7 @@ class SlashCommandCompleter(Completer):
# Exact match: trailing space keeps the dropdown open for the next stacked token.
yield _completion(
f"{cmd} " if cmd == word_key else cmd, current_word, cmd,
f"⚡ {_short_desc(info, 'Skill command')}")
f"⚡ {info.get('description', 'Skill command')}")
@staticmethod
def _completion_text(cmd_name: str, word: str) -> str:
@@ -454,16 +449,16 @@ class SlashCommandCompleter(Completer):
if cmd[1:].startswith(word):
skill_count = len(info.get("skills", []))
yield _cmd_completion(
cmd[1:], f"▣ {_short_desc(info, 'Skill bundle')} ({skill_count} skills)")
cmd[1:], f"▣ {info.get('description', 'Skill bundle')} ({skill_count} skills)")
for cmd, info in self._iter_skill_commands().items():
if cmd[1:].startswith(word):
yield _cmd_completion(cmd[1:], f"⚡ {_short_desc(info, 'Skill command')}")
yield _cmd_completion(cmd[1:], f"⚡ {info.get('description', 'Skill command')}")
try:
from hermes_cli.plugins import get_plugin_commands
for cmd_name, cmd_info in get_plugin_commands().items():
if cmd_name.startswith(word):
yield _cmd_completion(
cmd_name, f"🔌 {_short_desc(cmd_info, 'Plugin command')}")
cmd_name, f"🔌 {cmd_info.get('description', 'Plugin command')}")
except Exception:
pass
@@ -0,0 +1,25 @@
"""Command descriptions survive both catalog and completion transport."""
from prompt_toolkit.completion import CompleteEvent
from prompt_toolkit.document import Document
def test_full_descriptions_survive_catalog_and_completion(monkeypatch):
from agent import skill_commands
from hermes_cli import plugins
from hermes_cli.commands_completion import SlashCommandCompleter
from tui_gateway import server
description = "Read the entire description before selecting a command. " * 8
skills = {"/proof-skill": {"name": "proof-skill", "description": description}}
monkeypatch.setattr(skill_commands, "scan_skill_commands", lambda: skills)
monkeypatch.setattr(plugins, "get_plugin_commands", lambda: {"proof-plugin": {"description": description}})
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"proof-quick": {"description": description}}})
catalog = server._methods["commands.catalog"](1, {})["result"]
pairs = dict(catalog["pairs"])
for name in ("/proof-skill", "/proof-plugin", "/proof-quick"):
assert pairs[name] == description
completer = SlashCommandCompleter(skill_commands_provider=lambda: skills)
completions = list(completer.get_completions(Document("/proof"), CompleteEvent()))
assert len(completions) == 2
assert all(description in completion.display_meta_text for completion in completions)
+3 -7
View File
@@ -150,10 +150,6 @@ def _rewind_or_err(rid, session, keep: int, value_err: tuple, fail_prefix: str,
return None, _err(rid, 5008, f"{fail_prefix}{exc}")
def _clip(text: str, n: int = 120) -> str:
return text
def _exec_out(rid, output: str) -> dict:
"""command.dispatch display-only result."""
return _ok(rid, {"type": "exec", "output": output})
@@ -376,7 +372,7 @@ def _catalog_quick_commands(cat: _Catalog) -> None:
qtype = qc.get("type", "")
default_desc = {"exec": f"exec: {qc.get('command', '')}", "alias": f"alias → {qc.get('target', '')}"}
desc = str(qc.get("description") or default_desc.get(qtype, qtype or "quick command"))
cat.add(f"/{qname}", _clip(desc), "User commands")
cat.add(f"/{qname}", desc, "User commands")
def _catalog_plugin_commands(cat: _Catalog) -> None:
@@ -387,7 +383,7 @@ def _catalog_plugin_commands(cat: _Catalog) -> None:
key = f"/{pname}"
if not isinstance(info, dict) or key.lower() in cat.canon:
continue
cat.add(key, _clip(str(info.get("description") or "Plugin command")), "Plugin commands")
cat.add(key, str(info.get("description") or "Plugin command"), "Plugin commands")
mode = info.get("argument_mode")
if mode not in {"options", "text", "mixed"}:
mode = "text" if str(info.get("args_hint") or "").strip() else None
@@ -398,7 +394,7 @@ def _catalog_skills(cat: _Catalog, skills: dict[str, dict]) -> None:
"""Append skill pairs and fill ``skills`` = ``{key: {usage, origin}}`` (every consumer ranks by them)."""
usage, origin_of = _skill_usage_lookup()
for k, info in sorted(_tools_mod("agent.skill_commands").scan_skill_commands().items()):
cat.pairs.append([k, _clip(str(info.get("description", "Skill")))])
cat.pairs.append([k, str(info.get("description", "Skill"))])
name = str(info.get("name") or k.lstrip("/"))
skills[k] = {"usage": usage(name), "origin": origin_of(name)}