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:
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user