Skip to content
Merged

undo #18147

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,11 +343,11 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md).
These shortcuts apply directly to the input prompt for text manipulation.

- **Undo:**
- **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action
- **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
in the input prompt.

- **Redo:**
- **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the
- **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
last undone action in the input prompt.

## At commands (`@`)
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { isITerm2 } from './utils/terminalUtils.js';

function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
Expand Down Expand Up @@ -1472,7 +1473,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShowErrorDetails((prev) => !prev);
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z');
const undoMessage = isITerm2()
? 'Undo has been moved to Option + Z'
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
handleWarning(undoMessage);
return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev);
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/constants/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export const INFORMATIVE_TIPS = [
'Delete from the cursor to the end of the line with Ctrl+K…',
'Clear the entire input prompt with a double-press of Esc…',
'Paste from your clipboard with Ctrl+V…',
'Undo text edits in the input with Cmd+Z or Alt+Z…',
'Redo undone text edits with Shift+Cmd+Z or Shift+Alt+Z…',
'Undo text edits in the input with Alt+Z or Cmd+Z…',
'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z…',
'Open the current prompt in an external editor with Ctrl+X…',
'In menus, move up/down with k/j or the arrow keys…',
'In menus, select an item by typing its number…',
Expand Down
170 changes: 115 additions & 55 deletions packages/cli/src/ui/contexts/KeypressContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -821,65 +821,72 @@ describe('KeypressContext', () => {
// Terminals to test
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];

// Key mappings: letter -> [keycode, accented character]
const keys: Record<string, [number, string]> = {
b: [98, '\u222B'],
f: [102, '\u0192'],
m: [109, '\u00B5'],
// Key mappings: letter -> [keycode, accented character, shift]
const keys: Record<string, [number, string, boolean]> = {
b: [98, '\u222B', false],
f: [102, '\u0192', false],
m: [109, '\u00B5', false],
z: [122, '\u03A9', false],
Z: [122, '\u00B8', true],
};

it.each(
terminals.flatMap((terminal) =>
Object.entries(keys).map(([key, [keycode, accentedChar]]) => {
if (terminal === 'Ghostty') {
// Ghostty uses kitty protocol sequences
return {
terminal,
key,
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else if (terminal === 'MacTerminal') {
// Mac Terminal sends ESC + letter
return {
terminal,
key,
kitty: false,
chunk: `\x1b${key}`,
expected: {
sequence: `\x1b${key}`,
name: key,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with alt:false on iTerm2/VSCode but
// gets converted to m with alt:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
shift: false,
alt: true, // Always expect alt:true after conversion
ctrl: false,
cmd: false,
sequence: accentedChar,
},
};
}
}),
Object.entries(keys).map(
([key, [keycode, accentedChar, shiftValue]]) => {
if (terminal === 'Ghostty') {
// Ghostty uses kitty protocol sequences
// Modifier 3 is Alt, 4 is Shift+Alt
const modifier = shiftValue ? 4 : 3;
return {
terminal,
key,
chunk: `\x1b[${keycode};${modifier}u`,
expected: {
name: key.toLowerCase(),
shift: shiftValue,
alt: true,
ctrl: false,
cmd: false,
},
};
} else if (terminal === 'MacTerminal') {
// Mac Terminal sends ESC + letter
const chunk = shiftValue
? `\x1b${key.toUpperCase()}`
: `\x1b${key.toLowerCase()}`;
return {
terminal,
key,
kitty: false,
chunk,
expected: {
sequence: chunk,
name: key.toLowerCase(),
shift: shiftValue,
alt: true,
ctrl: false,
cmd: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ, Ω, ¸)
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key.toLowerCase(),
shift: shiftValue,
alt: true, // Always expect alt:true after conversion
ctrl: false,
cmd: false,
sequence: accentedChar,
},
};
}
},
),
),
)(
'should handle Alt+$key in $terminal',
Expand Down Expand Up @@ -1302,4 +1309,57 @@ describe('KeypressContext', () => {
}
});
});

describe('Greek support', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it.each([
{
lang: 'en_US.UTF-8',
expected: { name: 'z', alt: true, insertable: false },
desc: 'non-Greek locale (Option+z)',
},
{
lang: 'el_GR.UTF-8',
expected: { name: '', insertable: true },
desc: 'Greek LANG',
},
{
lcAll: 'el_GR.UTF-8',
expected: { name: '', insertable: true },
desc: 'Greek LC_ALL',
},
{
lang: 'en_US.UTF-8',
lcAll: 'el_GR.UTF-8',
expected: { name: '', insertable: true },
desc: 'LC_ALL overriding non-Greek LANG',
},
{
lang: 'el_GR.UTF-8',
char: '\u00B8',
expected: { name: 'z', alt: true, shift: true },
desc: 'Cedilla (\u00B8) in Greek locale (should be Option+Shift+z)',
},
])(
'should handle $char correctly in $desc',
async ({ lang, lcAll, char = '\u03A9', expected }) => {
if (lang) vi.stubEnv('LANG', lang);
if (lcAll) vi.stubEnv('LC_ALL', lcAll);

const { keyHandler } = setupKeypressTest();

act(() => stdin.write(char));

expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
...expected,
sequence: char,
}),
);
},
);
});
});
17 changes: 15 additions & 2 deletions packages/cli/src/ui/contexts/KeypressContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u222B': 'b', // "∫" back one word
'\u0192': 'f', // "ƒ" forward one word
'\u00B5': 'm', // "µ" toggle markup view
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
};

function nonKeyboardEventFilter(
Expand Down Expand Up @@ -305,6 +307,10 @@ function createDataListener(keypressHandler: KeypressHandler) {
function* emitKeys(
keypressHandler: KeypressHandler,
): Generator<void, void, string> {
const lang = process.env['LANG'] || '';
const lcAll = process.env['LC_ALL'] || '';
const isGreek = lang.startsWith('el') || lcAll.startsWith('el');

while (true) {
let ch = yield;
let sequence = ch;
Expand Down Expand Up @@ -574,8 +580,15 @@ function* emitKeys(
} else if (MAC_ALT_KEY_CHARACTER_MAP[ch]) {
// Note: we do this even if we are not on Mac, because mac users may
// remotely connect to non-Mac systems.
name = MAC_ALT_KEY_CHARACTER_MAP[ch];
alt = true;
// We skip this mapping for Greek users to avoid blocking the Omega character.
if (isGreek && ch === '\u03A9') {
insertable = true;
} else {
const mapped = MAC_ALT_KEY_CHARACTER_MAP[ch];
name = mapped.toLowerCase();
shift = mapped !== name;
alt = true;
}
} else if (sequence === `${ESC}${ESC}`) {
// Double escape
name = 'escape';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

exports[`terminalSetup > configureVSCodeStyle > should create new keybindings file if none exists 1`] = `
[
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "shift+alt+z",
"when": "terminalFocus",
},
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "shift+cmd+z",
"when": "terminalFocus",
},
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "alt+z",
"when": "terminalFocus",
},
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "cmd+z",
"when": "terminalFocus",
},
{
"args": {
"text": "\\
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/ui/utils/terminalSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('terminalSetup', () => {

expect(result.success).toBe(true);
const writtenContent = JSON.parse(mocks.writeFile.mock.calls[0][1]);
expect(writtenContent).toHaveLength(2); // Shift+Enter and Ctrl+Enter
expect(writtenContent).toHaveLength(6); // Shift+Enter, Ctrl+Enter, Cmd+Z, Alt+Z, Shift+Cmd+Z, Shift+Alt+Z
});

it('should not modify if bindings already exist', async () => {
Expand All @@ -145,6 +145,26 @@ describe('terminalSetup', () => {
command: 'workbench.action.terminal.sendSequence',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
},
{
key: 'cmd+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;9u' },
},
{
key: 'alt+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;3u' },
},
{
key: 'shift+cmd+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;10u' },
},
{
key: 'shift+alt+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;4u' },
},
];
mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));

Expand Down
Loading
Loading