feat: autogenerate keyboard shortcut docs (#12944)

This commit is contained in:
cornmander
2025-11-12 16:07:14 -05:00
committed by GitHub
parent 0075b4f118
commit aa9922bc98
9 changed files with 629 additions and 87 deletions

View File

@@ -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
<!-- KEYBINDINGS-AUTOGEN:START -->
| 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`<br />`Home` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`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`<br />`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)`<br />`K (no Shift)` |
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
#### Suggestions & Completions
| Action | Keys |
| --------------------------------------- | -------------------------------------------------- |
| Accept the inline suggestion. | `Tab`<br />`Enter (no Ctrl)` |
| Move to the previous completion option. | `Up Arrow (no Shift)`<br />`Ctrl + P (no Shift)` |
| Move to the next completion option. | `Down Arrow (no Shift)`<br />`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`<br />`Cmd + Enter`<br />`Paste + Enter`<br />`Shift + Enter`<br />`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` |
<!-- KEYBINDINGS-AUTOGEN:END -->
## 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.

View File

@@ -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",

View File

@@ -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<Command>();
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);
});
});
});

View File

@@ -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<Record<Command, string>> = {
[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.',
};

View File

@@ -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 = '<!-- KEYBINDINGS-AUTOGEN:START -->';
const END_MARKER = '<!-- KEYBINDINGS-AUTOGEN:END -->';
const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md'];
const KEY_NAME_OVERRIDES: Record<string, string> = {
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('<br />');
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<string>();
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();
}
}

View File

@@ -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) {

View File

@@ -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)`');
});
});

View File

@@ -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;
}
});
});

View File

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