mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat: autogenerate keyboard shortcut docs (#12944)
This commit is contained in:
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