Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/desktop/src/app/chat/composer/help-hint.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { act, cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import { I18nProvider } from '@/i18n/context'
import { DESKTOP_COMMON_COMMANDS, isDesktopSlashCommand } from '@/lib/desktop-slash-commands'

import { HelpHint } from './help-hint'

// The four commands the pre-fix drawer hardcoded that the desktop refuses to
// run (all in NO_DESKTOP_SURFACE.terminal). None may reach the DOM again.
const RETIRED_TERMINAL_COMMANDS = ['/clear', '/details', '/copy', '/quit']

async function renderHelpHint() {
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
<HelpHint />
</I18nProvider>
)
})
return result!

Check warning on line 22 in apps/desktop/src/app/chat/composer/help-hint.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
}

// Render-level guard for the #-quick-help regression: the drawer used to
// hardcode /clear, /details, /copy, /quit — all terminal-only — so tapping a
// row errored. The component must render exactly the spec-derived list and
// no terminal-only command may reach the DOM.
describe('HelpHint drawer', () => {
afterEach(cleanup)

it('renders a row for every DESKTOP_COMMON_COMMAND', async () => {
const { container } = await renderHelpHint()
const drawer = container.querySelector('[data-slot="composer-completion-drawer"]')!
for (const command of DESKTOP_COMMON_COMMANDS) {

Check warning on line 35 in apps/desktop/src/app/chat/composer/help-hint.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
// getAllByText: /help also appears in the drawer footer, so >=1 row.
expect(within(drawer as HTMLElement).getAllByText(command.name).length).toBeGreaterThan(0)
}
})

it('never advertises a terminal-only command', async () => {
await renderHelpHint()
for (const name of RETIRED_TERMINAL_COMMANDS) {

Check warning on line 43 in apps/desktop/src/app/chat/composer/help-hint.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
expect(screen.queryByText(name)).toBeNull()
}
})

it('only advertises commands the desktop can actually run', async () => {
await renderHelpHint()
for (const command of DESKTOP_COMMON_COMMANDS) {

Check warning on line 50 in apps/desktop/src/app/chat/composer/help-hint.test.tsx

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
expect(isDesktopSlashCommand(command.name)).toBe(true)
}
})
})
12 changes: 8 additions & 4 deletions apps/desktop/src/app/chat/composer/help-hint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import type { ReactNode } from 'react'

import { KbdCombo } from '@/components/ui/kbd'
import { useI18n } from '@/i18n'
import { DESKTOP_COMMON_COMMANDS } from '@/lib/desktop-slash-commands'

import { COMPLETION_DRAWER_CLASS } from './completion-drawer'

const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']

/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
const COMPOSER_HOTKEY_ROWS = [
{ id: 'composer.mention', combos: ['@'] },
Expand All @@ -26,8 +25,13 @@ export function HelpHint() {
return (
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
<Section title={c.commonCommands}>
{COMMON_COMMAND_KEYS.map(key => (
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
{DESKTOP_COMMON_COMMANDS.map(command => (
<Row
description={c.commandDescs[command.name] ?? command.description}
key={command.name}
keyLabel={command.name}
mono
/>
))}
</Section>

Expand Down
6 changes: 1 addition & 5 deletions apps/desktop/src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1648,11 +1648,7 @@ export const ar = defineLocale({
helpFooter: 'استخدم الأسهم للتنقل و Enter للاختيار.',
commandDescs: {
'/help': 'قائمة كاملة بالأوامر + اختصارات لوحة المفاتيح',
'/clear': 'بدء جلسة جديدة',
'/resume': 'استئناف جلسة سابقة',
'/details': 'التحكم في مستوى تفاصيل النص',
'/copy': 'نسخ التحديد أو آخر رسالة من المساعد',
'/quit': 'الخروج من hermes'
'/resume': 'استئناف جلسة سابقة'
},
hotkeyDescs: {
'composer.mention': 'الإشارة إلى الملفات والمجلدات والروابط وgit',
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1928,11 +1928,11 @@ export const en: Translations = {
helpFooter: 'opens the full panel · backspace dismisses',
commandDescs: {
'/help': 'full list of commands + hotkeys',
'/clear': 'start a new session',
'/new': 'start a new chat',
'/resume': 'resume a prior session',
'/details': 'control transcript detail level',
'/copy': 'copy selection or last assistant message',
'/quit': 'exit hermes'
'/compress': 'compress this conversation context',
'/usage': 'token usage for this session',
'/status': 'current session status'
},
hotkeyDescs: {
'composer.mention': 'reference files, folders, urls, git',
Expand Down
6 changes: 1 addition & 5 deletions apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1794,11 +1794,7 @@ export const ja = defineLocale({
helpFooter: 'フルパネルを開く · Backspace で閉じる',
commandDescs: {
'/help': 'コマンドとホットキーの全リスト',
'/clear': '新しいセッションを開始',
'/resume': '以前のセッションを再開',
'/details': 'トランスクリプトの詳細レベルを制御',
'/copy': '選択または最後のアシスタントメッセージをコピー',
'/quit': 'hermes を終了'
'/resume': '以前のセッションを再開'
},
hotkeyDescs: {
'composer.mention': 'ファイル、フォルダー、URL、Git を参照',
Expand Down
6 changes: 1 addition & 5 deletions apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1740,11 +1740,7 @@ export const zhHant = defineLocale({
helpFooter: '開啟完整面板 · 退格鍵關閉',
commandDescs: {
'/help': '指令與快捷鍵的完整清單',
'/clear': '開始新工作階段',
'/resume': '繼續之前的工作階段',
'/details': '控制對話記錄的詳細程度',
'/copy': '複製所選內容或最後一條助手訊息',
'/quit': '結束 hermes'
'/resume': '繼續之前的工作階段'
},
hotkeyDescs: {
'composer.mention': '參照檔案、資料夾、URL、git',
Expand Down
6 changes: 1 addition & 5 deletions apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2117,11 +2117,7 @@ export const zh: Translations = {
helpFooter: '打开完整面板 · 退格键关闭',
commandDescs: {
'/help': '命令与快捷键的完整列表',
'/clear': '开始新会话',
'/resume': '恢复之前的会话',
'/details': '控制对话记录的详细程度',
'/copy': '复制所选内容或最后一条助手消息',
'/quit': '退出 hermes'
'/resume': '恢复之前的会话'
},
hotkeyDescs: {
'composer.mention': '引用文件、文件夹、URL、git',
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'

import {
DESKTOP_COMMON_COMMANDS,
desktopSkinSlashCompletions,
desktopSlashDescription,
desktopSlashUnavailableMessage,
Expand Down Expand Up @@ -269,6 +270,27 @@ describe('desktop slash command curation', () => {
expect(isModelPickerCommand('/resume')).toBe(false)
})

it('advertises only runnable commands in the ? quick-help drawer', () => {
const advertised = DESKTOP_COMMON_COMMANDS.map(command => command.name)

// Pin the list so a typo'd entry fails loudly instead of being silently
// filtered out of the drawer.
expect(advertised).toEqual(['/help', '/new', '/resume', '/compress', '/usage', '/status'])

// Regression guard: the drawer once hardcoded /clear, /details, /copy, and
// /quit — all terminal-only, so tapping them errored. Every advertised
// command must execute on desktop and appear in the slash popover.
expect(advertised.filter(name => isDesktopSlashCommand(name))).toEqual(advertised)
expect(advertised.filter(name => isDesktopSlashSuggestion(name))).toEqual(advertised)
})

it('carries a spec-table description for every advertised command (i18n fallback)', () => {
for (const command of DESKTOP_COMMON_COMMANDS) {
expect(command.description).toBeTruthy()
expect(resolveDesktopCommand(command.name)?.surface.kind).not.toBe('unavailable')
}
})

it('resolves commands and aliases to their declared surface', () => {
expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/lib/desktop-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export interface DesktopThemeCommandOption {
name: string
}

/** A command advertised in the composer's `?` quick-help drawer. */
export interface DesktopCommonCommand {
/** Spec-table English description — locales may override, falling back here. */
description: string
/** Canonical command, leading slash included. */
name: string
}

/**
* Local client action a command resolves to. Each id maps to exactly one
* handler in the dispatcher (`use-prompt-actions`), so adding a command never
Expand Down Expand Up @@ -302,6 +310,25 @@ const ALIAS_TO_CANONICAL = new Map<string, string>(
ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const))
)

const COMMON_COMMAND_NAMES = ['/help', '/new', '/resume', '/compress', '/usage', '/status']

/**
* Commands advertised in the composer's `?` quick-help drawer. Derived from
* the spec table so the drawer can never advertise a command the desktop
* refuses to run: anything without a real desktop surface is filtered out
* here, and the test suite pins the expected list so a bad entry fails loudly
* instead of silently disappearing.
*/
export const DESKTOP_COMMON_COMMANDS: readonly DesktopCommonCommand[] = COMMON_COMMAND_NAMES.flatMap(name => {
const spec = SPEC_BY_NAME.get(name)

if (!spec || spec.surface.kind === 'unavailable') {
return []
}

return [{ description: spec.description ?? '', name: spec.name }]
})

const UNAVAILABLE_MESSAGE: Record<DesktopUnavailableReason, (command: string) => string> = {
advanced: command =>
`${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`,
Expand Down
Loading