From aa9922bc985080a8bedc159333909a8a4608e9ab Mon Sep 17 00:00:00 2001 From: cornmander Date: Wed, 12 Nov 2025 16:07:14 -0500 Subject: [PATCH] feat: autogenerate keyboard shortcut docs (#12944) --- docs/cli/keyboard-shortcuts.md | 186 +++++++++------ package.json | 1 + packages/cli/src/config/keyBindings.test.ts | 39 +++- packages/cli/src/config/keyBindings.ts | 132 +++++++++++ scripts/generate-keybindings-doc.ts | 220 ++++++++++++++++++ scripts/generate-settings-doc.ts | 25 +- .../tests/generate-keybindings-doc.test.ts | 68 ++++++ scripts/tests/generate-settings-doc.test.ts | 9 +- scripts/utils/autogen.ts | 36 +++ 9 files changed, 629 insertions(+), 87 deletions(-) create mode 100644 scripts/generate-keybindings-doc.ts create mode 100644 scripts/tests/generate-keybindings-doc.test.ts diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 05a5683ba2..cb312d5f0a 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -1,84 +1,132 @@ # Gemini CLI Keyboard Shortcuts -This document lists the available keyboard shortcuts within Gemini CLI. +Gemini CLI ships with a set of default keyboard shortcuts for editing input, +navigating history, and controlling the UI. Use this reference to learn the +available combinations. -## General + -| Shortcut | Description | -| ----------- | --------------------------------------------------------------------------------------------------------------------- | -| `Esc` | Close dialogs and suggestions. | -| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. | -| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | -| `Ctrl+L` | Clear the screen. | -| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | -| `Ctrl+S` | Toggle copy mode (alternate buffer mode only). | -| `Ctrl+T` | Toggle the display of the todo list. | -| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. | -| `Shift+Tab` | Toggle auto-accepting edits approval mode. | -| `Option+M` | Toggle Markdown rendering for messages (raw markdown mode). | -| `F12` | Toggle the display of the debug console. | +#### Basic Controls -## Input Prompt +| Action | Keys | +| -------------------------------------------- | ------- | +| Confirm the current selection or choice. | `Enter` | +| Dismiss dialogs or cancel the current focus. | `Esc` | -| Shortcut | Description | -| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `!` | Toggle shell mode when the input is empty. | -| `\` (at end of line) + `Enter` | Insert a newline. | -| `Down Arrow` | Navigate down through the input history. | -| `Enter` | Submit the current prompt. | -| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. | -| `Tab` | Autocomplete the current suggestion if one exists. | -| `Up Arrow` | Navigate up through the input history. | -| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. | -| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. | -| `Ctrl+C` | Clear the input prompt | -| `Esc` (double press) | Clear the input prompt. | -| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. | -| `Ctrl+E` / `End` | Move the cursor to the end of the line. | -| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. `Ctrl+F` also toggles focus between input and interactive shell if active. | -| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. | -| `Ctrl+K` | Delete from the cursor to the end of the line. | -| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. | -| `Ctrl+N` | Navigate down through the input history. | -| `Ctrl+P` | Navigate up through the input history. | -| `Ctrl+R` | Activate reverse command search history. | -| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. | -| `Ctrl+U` | Delete from the cursor to the beginning of the line. | -| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. | -| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. | -| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. | -| `Ctrl+Z` | Undo last text edit. | -| `Ctrl+Shift+Z` | Redo last undone text edit. | +#### Cursor Movement -## Suggestions +| Action | Keys | +| ----------------------------------------- | ---------------------- | +| Move the cursor to the start of the line. | `Ctrl + A`
`Home` | +| Move the cursor to the end of the line. | `Ctrl + E`
`End` | -| Shortcut | Description | -| ----------------------- | -------------------------------------- | -| `Down Arrow` / `Ctrl+N` | Navigate down through the suggestions. | -| `Tab` / `Enter` | Accept the selected suggestion. | -| `Up Arrow` / `Ctrl+P` | Navigate up through the suggestions. | +#### Editing -## Radio Button Select +| Action | Keys | +| ------------------------------------------------ | ----------------------------------------- | +| Delete from the cursor to the end of the line. | `Ctrl + K` | +| Delete from the cursor to the start of the line. | `Ctrl + U` | +| Clear all text in the input field. | `Ctrl + C` | +| Delete the previous word. | `Ctrl + Backspace`
`Cmd + Backspace` | -| Shortcut | Description | -| ------------------ | ------------------------------------------------------------------------------------------------------------- | -| `Down Arrow` / `j` | Move selection down. | -| `Enter` | Confirm selection. | -| `Up Arrow` / `k` | Move selection up. | -| `1-9` | Select an item by its number. | -| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. | +#### Screen Control -## IDE Integration +| Action | Keys | +| -------------------------------------------- | ---------- | +| Clear the terminal screen and redraw the UI. | `Ctrl + L` | -| Shortcut | Description | -| -------- | --------------------------------- | -| `Ctrl+G` | See context CLI received from IDE | +#### History & Search -## Meta+key combos on mac +| Action | Keys | +| -------------------------------------------- | --------------------- | +| Show the previous entry in history. | `Ctrl + P (no Shift)` | +| Show the next entry in history. | `Ctrl + N (no Shift)` | +| Start reverse search through history. | `Ctrl + R` | +| Insert the selected reverse-search match. | `Enter (no Ctrl)` | +| Accept a suggestion while reverse searching. | `Tab` | -On Mac, all Meta+char combos should work normally except for these three which -are mapped to special functionality. +#### Navigation -- `meta+b`: "∫" back one word -- `meta+f`: "ƒ" forward one word -- `meta+m`: "µ" toggle markup view +| Action | Keys | +| -------------------------------- | ------------------------------------------- | +| Move selection up in lists. | `Up Arrow (no Shift)` | +| Move selection down in lists. | `Down Arrow (no Shift)` | +| Move up within dialog options. | `Up Arrow (no Shift)`
`K (no Shift)` | +| Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` | + +#### Suggestions & Completions + +| Action | Keys | +| --------------------------------------- | -------------------------------------------------- | +| Accept the inline suggestion. | `Tab`
`Enter (no Ctrl)` | +| Move to the previous completion option. | `Up Arrow (no Shift)`
`Ctrl + P (no Shift)` | +| Move to the next completion option. | `Down Arrow (no Shift)`
`Ctrl + N (no Shift)` | +| Expand an inline suggestion. | `Right Arrow` | +| Collapse an inline suggestion. | `Left Arrow` | + +#### Text Input + +| Action | Keys | +| ------------------------------------ | ------------------------------------------------------------------------------------------- | +| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd, not Paste)` | +| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Paste + Enter`
`Shift + Enter`
`Ctrl + J` | + +#### External Tools + +| Action | Keys | +| ---------------------------------------------- | ---------- | +| Open the current prompt in an external editor. | `Ctrl + X` | +| Paste an image from the clipboard. | `Ctrl + V` | + +#### App Controls + +| Action | Keys | +| ----------------------------------------------------------------- | ---------- | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl + T` | +| Toggle IDE context details. | `Ctrl + G` | +| Toggle Markdown rendering. | `Cmd + M` | +| Toggle copy mode when the terminal is using the alternate buffer. | `Ctrl + S` | +| Expand a height-constrained response to show additional lines. | `Ctrl + S` | +| Toggle focus between the shell and Gemini input. | `Ctrl + F` | + +#### Session Control + +| Action | Keys | +| -------------------------------------------- | ---------- | +| Cancel the current request or quit the CLI. | `Ctrl + C` | +| Exit the CLI when the input buffer is empty. | `Ctrl + D` | + + + +## Additional Context-Specific Shortcuts + +- `Ctrl+Y`: Toggle YOLO (auto-approval) mode for tool calls. +- `Shift+Tab`: Toggle Auto Edit (auto-accept edits) mode. +- `Option+M` (macOS): Entering `µ` with Option+M also toggles Markdown + rendering, matching `Cmd+M`. +- `!` on an empty prompt: Enter or exit shell mode. +- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line + mode. +- `Ctrl+Delete` / `Meta+Delete`: Delete the word to the right of the cursor. +- `Ctrl+B` or `Left Arrow`: Move the cursor one character to the left while + editing text. +- `Ctrl+F` or `Right Arrow`: Move the cursor one character to the right; with an + embedded shell attached, `Ctrl+F` still toggles focus. +- `Ctrl+D` or `Delete`: Remove the character immediately to the right of the + cursor. +- `Ctrl+H` or `Backspace`: Remove the character immediately to the left of the + cursor. +- `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B`: Move one word to the left. +- `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F`: Move one word to the + right. +- `Ctrl+W`: Delete the word to the left of the cursor (in addition to + `Ctrl+Backspace` / `Cmd+Backspace`). +- `Ctrl+Z` / `Ctrl+Shift+Z`: Undo or redo the most recent text edit. +- `Meta+Enter`: Open the current input in an external editor (alias for + `Ctrl+X`). +- `Esc` pressed twice quickly: Clear the current input buffer. +- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a + single-line input, navigate backward or forward through prompt history. +- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to + the numbered radio option and confirm when the full number is entered. diff --git a/package.json b/package.json index c6e108619d..e8a85ef66d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "predocs:settings": "npm run build --workspace @google/gemini-cli-core", "schema:settings": "tsx ./scripts/generate-settings-schema.ts", "docs:settings": "tsx ./scripts/generate-settings-doc.ts", + "docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts", "build": "node scripts/build.js", "build-and-start": "npm run build && npm run start", "build:vscode": "node scripts/build_vscode_companion.js", diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts index f80dd8aecb..2a4debd483 100644 --- a/packages/cli/src/config/keyBindings.test.ts +++ b/packages/cli/src/config/keyBindings.test.ts @@ -6,7 +6,12 @@ import { describe, it, expect } from 'vitest'; import type { KeyBindingConfig } from './keyBindings.js'; -import { Command, defaultKeyBindings } from './keyBindings.js'; +import { + Command, + commandCategories, + commandDescriptions, + defaultKeyBindings, +} from './keyBindings.js'; describe('keyBindings config', () => { describe('defaultKeyBindings', () => { @@ -16,6 +21,7 @@ describe('keyBindings config', () => { for (const command of commands) { expect(defaultKeyBindings[command]).toBeDefined(); expect(Array.isArray(defaultKeyBindings[command])).toBe(true); + expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0); } }); @@ -78,4 +84,35 @@ describe('keyBindings config', () => { expect(defaultKeyBindings[Command.END]).toContainEqual({ key: 'end' }); }); }); + + describe('command metadata', () => { + const commandValues = Object.values(Command); + + it('has a description entry for every command', () => { + const describedCommands = Object.keys(commandDescriptions); + expect(describedCommands.sort()).toEqual([...commandValues].sort()); + + for (const command of commandValues) { + expect(typeof commandDescriptions[command]).toBe('string'); + expect(commandDescriptions[command]?.trim()).not.toHaveLength(0); + } + }); + + it('categorizes each command exactly once', () => { + const seen = new Set(); + + for (const category of commandCategories) { + expect(typeof category.title).toBe('string'); + expect(Array.isArray(category.commands)).toBe(true); + + for (const command of category.commands) { + expect(commandValues).toContain(command); + expect(seen.has(command)).toBe(false); + seen.add(command); + } + } + + expect(seen.size).toBe(commandValues.length); + }); + }); }); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 62d672a520..f166d0427d 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -199,3 +199,135 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], }; + +interface CommandCategory { + readonly title: string; + readonly commands: readonly Command[]; +} + +/** + * Presentation metadata for grouping commands in documentation or UI. + */ +export const commandCategories: readonly CommandCategory[] = [ + { + title: 'Basic Controls', + commands: [Command.RETURN, Command.ESCAPE], + }, + { + title: 'Cursor Movement', + commands: [Command.HOME, Command.END], + }, + { + title: 'Editing', + commands: [ + Command.KILL_LINE_RIGHT, + Command.KILL_LINE_LEFT, + Command.CLEAR_INPUT, + Command.DELETE_WORD_BACKWARD, + ], + }, + { + title: 'Screen Control', + commands: [Command.CLEAR_SCREEN], + }, + { + title: 'History & Search', + commands: [ + Command.HISTORY_UP, + Command.HISTORY_DOWN, + Command.REVERSE_SEARCH, + Command.SUBMIT_REVERSE_SEARCH, + Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, + ], + }, + { + title: 'Navigation', + commands: [ + Command.NAVIGATION_UP, + Command.NAVIGATION_DOWN, + Command.DIALOG_NAVIGATION_UP, + Command.DIALOG_NAVIGATION_DOWN, + ], + }, + { + title: 'Suggestions & Completions', + commands: [ + Command.ACCEPT_SUGGESTION, + Command.COMPLETION_UP, + Command.COMPLETION_DOWN, + Command.EXPAND_SUGGESTION, + Command.COLLAPSE_SUGGESTION, + ], + }, + { + title: 'Text Input', + commands: [Command.SUBMIT, Command.NEWLINE], + }, + { + title: 'External Tools', + commands: [Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD_IMAGE], + }, + { + title: 'App Controls', + commands: [ + Command.SHOW_ERROR_DETAILS, + Command.SHOW_FULL_TODOS, + Command.TOGGLE_IDE_CONTEXT_DETAIL, + Command.TOGGLE_MARKDOWN, + Command.TOGGLE_COPY_MODE, + Command.SHOW_MORE_LINES, + Command.TOGGLE_SHELL_INPUT_FOCUS, + ], + }, + { + title: 'Session Control', + commands: [Command.QUIT, Command.EXIT], + }, +]; + +/** + * Human-readable descriptions for each command, used in docs/tooling. + */ +export const commandDescriptions: Readonly> = { + [Command.RETURN]: 'Confirm the current selection or choice.', + [Command.ESCAPE]: 'Dismiss dialogs or cancel the current focus.', + [Command.HOME]: 'Move the cursor to the start of the line.', + [Command.END]: 'Move the cursor to the end of the line.', + [Command.KILL_LINE_RIGHT]: 'Delete from the cursor to the end of the line.', + [Command.KILL_LINE_LEFT]: 'Delete from the cursor to the start of the line.', + [Command.CLEAR_INPUT]: 'Clear all text in the input field.', + [Command.DELETE_WORD_BACKWARD]: 'Delete the previous word.', + [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', + [Command.HISTORY_UP]: 'Show the previous entry in history.', + [Command.HISTORY_DOWN]: 'Show the next entry in history.', + [Command.NAVIGATION_UP]: 'Move selection up in lists.', + [Command.NAVIGATION_DOWN]: 'Move selection down in lists.', + [Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.', + [Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.', + [Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.', + [Command.COMPLETION_UP]: 'Move to the previous completion option.', + [Command.COMPLETION_DOWN]: 'Move to the next completion option.', + [Command.SUBMIT]: 'Submit the current prompt.', + [Command.NEWLINE]: 'Insert a newline without submitting.', + [Command.OPEN_EXTERNAL_EDITOR]: + 'Open the current prompt in an external editor.', + [Command.PASTE_CLIPBOARD_IMAGE]: 'Paste an image from the clipboard.', + [Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.', + [Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.', + [Command.TOGGLE_IDE_CONTEXT_DETAIL]: 'Toggle IDE context details.', + [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', + [Command.TOGGLE_COPY_MODE]: + 'Toggle copy mode when the terminal is using the alternate buffer.', + [Command.QUIT]: 'Cancel the current request or quit the CLI.', + [Command.EXIT]: 'Exit the CLI when the input buffer is empty.', + [Command.SHOW_MORE_LINES]: + 'Expand a height-constrained response to show additional lines.', + [Command.REVERSE_SEARCH]: 'Start reverse search through history.', + [Command.SUBMIT_REVERSE_SEARCH]: 'Insert the selected reverse-search match.', + [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: + 'Accept a suggestion while reverse searching.', + [Command.TOGGLE_SHELL_INPUT_FOCUS]: + 'Toggle focus between the shell and Gemini input.', + [Command.EXPAND_SUGGESTION]: 'Expand an inline suggestion.', + [Command.COLLAPSE_SUGGESTION]: 'Collapse an inline suggestion.', +}; diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts new file mode 100644 index 0000000000..13f195aca8 --- /dev/null +++ b/scripts/generate-keybindings-doc.ts @@ -0,0 +1,220 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { readFile, writeFile } from 'node:fs/promises'; + +import type { KeyBinding } from '../packages/cli/src/config/keyBindings.js'; +import { + commandCategories, + commandDescriptions, + defaultKeyBindings, +} from '../packages/cli/src/config/keyBindings.js'; +import { + formatWithPrettier, + injectBetweenMarkers, + normalizeForCompare, +} from './utils/autogen.js'; + +const START_MARKER = ''; +const END_MARKER = ''; +const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md']; + +const KEY_NAME_OVERRIDES: Record = { + return: 'Enter', + escape: 'Esc', + tab: 'Tab', + backspace: 'Backspace', + delete: 'Delete', + up: 'Up Arrow', + down: 'Down Arrow', + left: 'Left Arrow', + right: 'Right Arrow', + home: 'Home', + end: 'End', + pageup: 'Page Up', + pagedown: 'Page Down', + clear: 'Clear', + insert: 'Insert', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12', +}; + +export interface KeybindingDocCommand { + description: string; + bindings: readonly KeyBinding[]; +} + +export interface KeybindingDocSection { + title: string; + commands: readonly KeybindingDocCommand[]; +} + +export async function main(argv = process.argv.slice(2)) { + const checkOnly = argv.includes('--check'); + + const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + ); + const docPath = path.join(repoRoot, ...OUTPUT_RELATIVE_PATH); + + const sections = buildDefaultDocSections(); + const generatedBlock = renderDocumentation(sections); + const currentDoc = await readFile(docPath, 'utf8'); + const injectedDoc = injectBetweenMarkers({ + document: currentDoc, + startMarker: START_MARKER, + endMarker: END_MARKER, + newContent: generatedBlock, + paddingBefore: '\n\n', + paddingAfter: '\n', + }); + const updatedDoc = await formatWithPrettier(injectedDoc, docPath); + + if (normalizeForCompare(updatedDoc) === normalizeForCompare(currentDoc)) { + if (!checkOnly) { + console.log('Keybinding documentation already up to date.'); + } + return; + } + + if (checkOnly) { + console.error( + 'Keybinding documentation is out of date. Run `npm run docs:keybindings` to regenerate.', + ); + process.exitCode = 1; + return; + } + + await writeFile(docPath, updatedDoc, 'utf8'); + console.log('Keybinding documentation regenerated.'); +} + +export function buildDefaultDocSections(): readonly KeybindingDocSection[] { + return commandCategories.map((category) => ({ + title: category.title, + commands: category.commands.map((command) => ({ + description: commandDescriptions[command], + bindings: defaultKeyBindings[command], + })), + })); +} + +export function renderDocumentation( + sections: readonly KeybindingDocSection[], +): string { + const renderedSections = sections.map((section) => { + const rows = section.commands.map((command) => { + const formattedBindings = formatBindings(command.bindings); + const keysCell = formattedBindings.join('
'); + return `| ${command.description} | ${keysCell} |`; + }); + + return [ + `#### ${section.title}`, + '', + '| Action | Keys |', + '| --- | --- |', + ...rows, + ].join('\n'); + }); + + return renderedSections.join('\n\n'); +} + +function formatBindings(bindings: readonly KeyBinding[]): string[] { + const seen = new Set(); + const results: string[] = []; + + for (const binding of bindings) { + const label = formatBinding(binding); + if (label && !seen.has(label)) { + seen.add(label); + results.push(label); + } + } + + return results; +} + +function formatBinding(binding: KeyBinding): string { + const modifiers: string[] = []; + if (binding.ctrl) modifiers.push('Ctrl'); + if (binding.command) modifiers.push('Cmd'); + if (binding.shift) modifiers.push('Shift'); + if (binding.paste) modifiers.push('Paste'); + + const keyName = binding.key + ? formatKeyName(binding.key) + : binding.sequence + ? formatSequence(binding.sequence) + : ''; + + if (!keyName) { + return ''; + } + + const segments = [...modifiers, keyName].filter(Boolean); + let combo = segments.join(' + '); + + const restrictions: string[] = []; + if (binding.ctrl === false) restrictions.push('no Ctrl'); + if (binding.shift === false) restrictions.push('no Shift'); + if (binding.command === false) restrictions.push('no Cmd'); + if (binding.paste === false) restrictions.push('not Paste'); + + if (restrictions.length > 0) { + combo = `${combo} (${restrictions.join(', ')})`; + } + + return combo ? `\`${combo}\`` : ''; +} + +function formatKeyName(key: string): string { + const normalized = key.toLowerCase(); + if (KEY_NAME_OVERRIDES[normalized]) { + return KEY_NAME_OVERRIDES[normalized]; + } + if (key.length === 1) { + return key.toUpperCase(); + } + return key; +} + +function formatSequence(sequence: string): string { + if (sequence.length === 1) { + const code = sequence.charCodeAt(0); + if (code >= 1 && code <= 26) { + return String.fromCharCode(code + 64); + } + if (code === 10 || code === 13) { + return 'Enter'; + } + if (code === 9) { + return 'Tab'; + } + } + return JSON.stringify(sequence); +} + +if (process.argv[1]) { + const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href; + if (entryUrl === import.meta.url) { + await main(); + } +} diff --git a/scripts/generate-settings-doc.ts b/scripts/generate-settings-doc.ts index 3a9b6b7e03..0acaf781d9 100644 --- a/scripts/generate-settings-doc.ts +++ b/scripts/generate-settings-doc.ts @@ -12,6 +12,7 @@ import { escapeBackticks, formatDefaultValue, formatWithPrettier, + injectBetweenMarkers, normalizeForCompare, } from './utils/autogen.js'; @@ -52,21 +53,15 @@ export async function main(argv = process.argv.slice(2)) { const generatedBlock = renderSections(sections); const doc = await readFile(docPath, 'utf8'); - const startIndex = doc.indexOf(START_MARKER); - const endIndex = doc.indexOf(END_MARKER); - - if (startIndex === -1 || endIndex === -1 || startIndex >= endIndex) { - throw new Error( - `Could not locate documentation markers (${START_MARKER}, ${END_MARKER}).`, - ); - } - - const before = doc.slice(0, startIndex + START_MARKER.length); - const after = doc.slice(endIndex); - const formattedDoc = await formatWithPrettier( - `${before}\n${generatedBlock}\n${after}`, - docPath, - ); + const injectedDoc = injectBetweenMarkers({ + document: doc, + startMarker: START_MARKER, + endMarker: END_MARKER, + newContent: generatedBlock, + paddingBefore: '\n', + paddingAfter: '\n', + }); + const formattedDoc = await formatWithPrettier(injectedDoc, docPath); if (normalizeForCompare(doc) === normalizeForCompare(formattedDoc)) { if (!checkOnly) { diff --git a/scripts/tests/generate-keybindings-doc.test.ts b/scripts/tests/generate-keybindings-doc.test.ts new file mode 100644 index 0000000000..3f06147d3d --- /dev/null +++ b/scripts/tests/generate-keybindings-doc.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + main as generateKeybindingDocs, + renderDocumentation, + type KeybindingDocSection, +} from '../generate-keybindings-doc.ts'; + +describe('generate-keybindings-doc', () => { + it('keeps keyboard shortcut documentation in sync in check mode', async () => { + const previousExitCode = process.exitCode; + try { + process.exitCode = 0; + await expect( + generateKeybindingDocs(['--check']), + ).resolves.toBeUndefined(); + expect(process.exitCode).toBe(0); + } finally { + process.exitCode = previousExitCode; + } + }); + + it('renders provided sections into markdown tables', () => { + const sections: KeybindingDocSection[] = [ + { + title: 'Custom Controls', + commands: [ + { + description: 'Trigger custom action.', + bindings: [{ key: 'x', ctrl: true }], + }, + { + description: 'Submit with Enter if no modifiers are held.', + bindings: [{ key: 'return', ctrl: false, shift: false }], + }, + ], + }, + { + title: 'Navigation', + commands: [ + { + description: 'Move up through results.', + bindings: [ + { key: 'up', shift: false }, + { key: 'p', ctrl: true, shift: false }, + ], + }, + ], + }, + ]; + + const markdown = renderDocumentation(sections); + expect(markdown).toContain('#### Custom Controls'); + expect(markdown).toContain('Trigger custom action.'); + expect(markdown).toContain('`Ctrl + X`'); + expect(markdown).toContain('Submit with Enter if no modifiers are held.'); + expect(markdown).toContain('`Enter (no Ctrl, no Shift)`'); + expect(markdown).toContain('#### Navigation'); + expect(markdown).toContain('Move up through results.'); + expect(markdown).toContain('`Up Arrow (no Shift)`'); + expect(markdown).toContain('`Ctrl + P (no Shift)`'); + }); +}); diff --git a/scripts/tests/generate-settings-doc.test.ts b/scripts/tests/generate-settings-doc.test.ts index 173c6ec566..01f0e2d089 100644 --- a/scripts/tests/generate-settings-doc.test.ts +++ b/scripts/tests/generate-settings-doc.test.ts @@ -10,7 +10,12 @@ import { main as generateDocs } from '../generate-settings-doc.ts'; describe('generate-settings-doc', () => { it('keeps documentation in sync in check mode', async () => { const previousExitCode = process.exitCode; - await expect(generateDocs(['--check'])).resolves.toBeUndefined(); - expect(process.exitCode).toBe(previousExitCode); + try { + process.exitCode = 0; + await expect(generateDocs(['--check'])).resolves.toBeUndefined(); + expect(process.exitCode).toBe(0); + } finally { + process.exitCode = previousExitCode; + } }); }); diff --git a/scripts/utils/autogen.ts b/scripts/utils/autogen.ts index a4448f2040..e43823ab21 100644 --- a/scripts/utils/autogen.ts +++ b/scripts/utils/autogen.ts @@ -81,3 +81,39 @@ export function formatDefaultValue( return String(value); } } + +interface MarkerInsertionOptions { + document: string; + startMarker: string; + endMarker: string; + newContent: string; + paddingBefore?: string; + paddingAfter?: string; +} + +/** + * Replaces the content between two markers with `newContent`, preserving the + * original document outside the markers and applying optional padding. + */ +export function injectBetweenMarkers({ + document, + startMarker, + endMarker, + newContent, + paddingBefore = '\n', + paddingAfter = '\n', +}: MarkerInsertionOptions): string { + const startIndex = document.indexOf(startMarker); + const endIndex = document.indexOf(endMarker); + + if (startIndex === -1 || endIndex === -1 || startIndex >= endIndex) { + throw new Error( + `Could not locate documentation markers (${startMarker}, ${endMarker}).`, + ); + } + + const before = document.slice(0, startIndex + startMarker.length); + const after = document.slice(endIndex); + + return `${before}${paddingBefore}${newContent}${paddingAfter}${after}`; +}