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}`;
+}