From 6b4c12eb043c987b24414d1e205eefbc6977df00 Mon Sep 17 00:00:00 2001 From: DeWitt Clinton Date: Sat, 6 Sep 2025 14:16:58 -0700 Subject: [PATCH] Allow for slash commands to opt-out of autocompletion and /help discovery. (#7847) --- .../cli/src/services/CommandService.test.ts | 32 ---------- packages/cli/src/services/CommandService.ts | 4 +- packages/cli/src/ui/commands/corgiCommand.ts | 2 +- packages/cli/src/ui/components/Help.test.tsx | 63 +++++++++++++++++++ packages/cli/src/ui/components/Help.tsx | 20 +++--- .../ui/hooks/slashCommandProcessor.test.ts | 12 ---- .../src/ui/hooks/useSlashCompletion.test.ts | 25 ++++++++ .../cli/src/ui/hooks/useSlashCompletion.ts | 4 +- 8 files changed, 103 insertions(+), 59 deletions(-) create mode 100644 packages/cli/src/ui/components/Help.test.tsx diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 362e4b8b62..e2d5b9f585 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -349,36 +349,4 @@ describe('CommandService', () => { expect(deployExtension).toBeDefined(); expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); - - it('should filter out hidden commands', async () => { - const visibleCommand = createMockCommand('visible', CommandKind.BUILT_IN); - const hiddenCommand = { - ...createMockCommand('hidden', CommandKind.BUILT_IN), - hidden: true, - }; - const initiallyVisibleCommand = createMockCommand( - 'initially-visible', - CommandKind.BUILT_IN, - ); - const hiddenOverrideCommand = { - ...createMockCommand('initially-visible', CommandKind.FILE), - hidden: true, - }; - - const mockLoader = new MockCommandLoader([ - visibleCommand, - hiddenCommand, - initiallyVisibleCommand, - hiddenOverrideCommand, - ]); - - const service = await CommandService.create( - [mockLoader], - new AbortController().signal, - ); - - const commands = service.getCommands(); - expect(commands).toHaveLength(1); - expect(commands[0].name).toBe('visible'); - }); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 808ab61bf5..5f1e09d50d 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -85,9 +85,7 @@ export class CommandService { }); } - const finalCommands = Object.freeze( - Array.from(commandMap.values()).filter((cmd) => !cmd.hidden), - ); + const finalCommands = Object.freeze(Array.from(commandMap.values())); return new CommandService(finalCommands); } diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts index bdbcc05f56..2da6ad3ed1 100644 --- a/packages/cli/src/ui/commands/corgiCommand.ts +++ b/packages/cli/src/ui/commands/corgiCommand.ts @@ -9,8 +9,8 @@ import { CommandKind, type SlashCommand } from './types.js'; export const corgiCommand: SlashCommand = { name: 'corgi', description: 'Toggles corgi mode.', - kind: CommandKind.BUILT_IN, hidden: true, + kind: CommandKind.BUILT_IN, action: (context, _args) => { context.ui.toggleCorgiMode(); }, diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx new file mode 100644 index 0000000000..ff749643ba --- /dev/null +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import { Help } from './Help.js'; +import type { SlashCommand } from '../commands/types.js'; +import { CommandKind } from '../commands/types.js'; + +const mockCommands: readonly SlashCommand[] = [ + { + name: 'test', + description: 'A test command', + kind: CommandKind.BUILT_IN, + }, + { + name: 'hidden', + description: 'A hidden command', + hidden: true, + kind: CommandKind.BUILT_IN, + }, + { + name: 'parent', + description: 'A parent command', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'visible-child', + description: 'A visible child command', + kind: CommandKind.BUILT_IN, + }, + { + name: 'hidden-child', + description: 'A hidden child command', + hidden: true, + kind: CommandKind.BUILT_IN, + }, + ], + }, +]; + +describe('Help Component', () => { + it('should not render hidden commands', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain('/test'); + expect(output).not.toContain('/hidden'); + }); + + it('should not render hidden subcommands', () => { + const { lastFrame } = render(); + const output = lastFrame(); + + expect(output).toContain('visible-child'); + expect(output).not.toContain('hidden-child'); + }); +}); diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index 124ab26081..b2a8a4400a 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -65,7 +65,7 @@ export const Help: React.FC = ({ commands }) => ( Commands: {commands - .filter((command) => command.description) + .filter((command) => command.description && !command.hidden) .map((command: SlashCommand) => ( @@ -79,15 +79,17 @@ export const Help: React.FC = ({ commands }) => ( {command.description && ' - ' + command.description} {command.subCommands && - command.subCommands.map((subCommand) => ( - - - {' '} - {subCommand.name} + command.subCommands + .filter((subCommand) => !subCommand.hidden) + .map((subCommand) => ( + + + {' '} + {subCommand.name} + + {subCommand.description && ' - ' + subCommand.description} - {subCommand.description && ' - ' + subCommand.description} - - ))} + ))} ))} diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 7e855403e2..1d164445e9 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -223,18 +223,6 @@ describe('useSlashCommandProcessor', () => { expect(fileAction).toHaveBeenCalledTimes(1); expect(builtinAction).not.toHaveBeenCalled(); }); - - it('should not include hidden commands in the command list', async () => { - const visibleCommand = createTestCommand({ name: 'visible' }); - const hiddenCommand = createTestCommand({ name: 'hidden', hidden: true }); - const result = setupProcessorHook([visibleCommand, hiddenCommand]); - - await waitFor(() => { - expect(result.current.slashCommands).toHaveLength(1); - }); - - expect(result.current.slashCommands[0].name).toBe('visible'); - }); }); describe('Command Execution Logic', () => { diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 6b8ce9682b..b5568ce9b4 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -347,6 +347,31 @@ describe('useSlashCompletion', () => { expect(result.current.suggestions).toHaveLength(0); }); + + it('should not suggest hidden commands', async () => { + const slashCommands = [ + createTestCommand({ + name: 'visible', + description: 'A visible command', + }), + createTestCommand({ + name: 'hidden', + description: 'A hidden command', + hidden: true, + }), + ]; + const { result } = renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/', + slashCommands, + mockCommandContext, + ), + ); + + expect(result.current.suggestions.length).toBe(1); + expect(result.current.suggestions[0].label).toBe('visible'); + }); }); describe('Sub-Commands', () => { diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 87288090fe..a284e0bc6e 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -225,7 +225,7 @@ function useCommandSuggestions( if (partial === '') { // If no partial query, show all available commands potentialSuggestions = commandsToSearch.filter( - (cmd) => cmd.description, + (cmd) => cmd.description && !cmd.hidden, ); } else { // Use fuzzy search for non-empty partial queries with fallback @@ -400,7 +400,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { const commandMap = new Map(); commands.forEach((cmd) => { - if (cmd.description) { + if (cmd.description && !cmd.hidden) { commandItems.push(cmd.name); commandMap.set(cmd.name, cmd);