mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
feat: autogenerate keyboard shortcut docs (#12944)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
220
scripts/generate-keybindings-doc.ts
Normal file
220
scripts/generate-keybindings-doc.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
68
scripts/tests/generate-keybindings-doc.test.ts
Normal file
68
scripts/tests/generate-keybindings-doc.test.ts
Normal 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)`');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user