diff --git a/package-lock.json b/package-lock.json
index 371d621009d..0cdb8fe21ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16709,9 +16709,9 @@
}
},
"node_modules/vite": {
- "version": "7.1.9",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
- "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
+ "version": "7.1.11",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
+ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index d2107ff40bb..f5b303128bd 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -956,3 +956,127 @@ describe('Drag and Drop Handling', () => {
});
});
});
+
+describe('Terminal-specific Alt+key combinations', () => {
+ let stdin: MockStdin;
+ const mockSetRawMode = vi.fn();
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ stdin = new MockStdin();
+ (useStdin as Mock).mockReturnValue({
+ stdin,
+ setRawMode: mockSetRawMode,
+ });
+ });
+
+ // Terminals to test
+ const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
+
+ // Key mappings: letter -> [keycode, accented character, shouldHaveMeta]
+ // Note: µ (mu) is sent with meta:false on iTerm2/VSCode
+ const keys: Record = {
+ a: [97, 'å', true],
+ o: [111, 'ø', true],
+ m: [109, 'µ', false],
+ };
+
+ it.each(
+ terminals.flatMap((terminal) =>
+ Object.entries(keys).map(
+ ([key, [keycode, accentedChar, shouldHaveMeta]]) => {
+ if (terminal === 'Ghostty') {
+ // Ghostty uses kitty protocol sequences
+ return {
+ terminal,
+ key,
+ kittySequence: `\x1b[${keycode};3u`,
+ expected: {
+ name: key,
+ ctrl: false,
+ meta: true,
+ shift: false,
+ paste: false,
+ kittyProtocol: true,
+ },
+ };
+ } else if (terminal === 'MacTerminal') {
+ // Mac Terminal sends ESC + letter
+ return {
+ terminal,
+ key,
+ input: {
+ sequence: `\x1b${key}`,
+ name: key,
+ ctrl: false,
+ meta: true,
+ shift: false,
+ paste: false,
+ },
+ expected: {
+ sequence: `\x1b${key}`,
+ name: key,
+ ctrl: false,
+ meta: true,
+ shift: false,
+ paste: false,
+ },
+ };
+ } else {
+ // iTerm2 and VSCode send accented characters (å, ø, µ)
+ // Note: µ comes with meta:false but gets converted to m with meta:true
+ return {
+ terminal,
+ key,
+ input: {
+ name: key,
+ ctrl: false,
+ meta: shouldHaveMeta,
+ shift: false,
+ paste: false,
+ sequence: accentedChar,
+ },
+ expected: {
+ name: key,
+ ctrl: false,
+ meta: true, // Always expect meta:true after conversion
+ shift: false,
+ paste: false,
+ sequence: accentedChar,
+ },
+ };
+ }
+ },
+ ),
+ ),
+ )(
+ 'should handle Alt+$key in $terminal',
+ ({
+ kittySequence,
+ input,
+ expected,
+ }: {
+ kittySequence?: string;
+ input?: Partial;
+ expected: Partial;
+ }) => {
+ const keyHandler = vi.fn();
+ const { result } = renderHook(() => useKeypressContext(), { wrapper });
+ act(() => result.current.subscribe(keyHandler));
+
+ if (kittySequence) {
+ act(() => stdin.sendKittySequence(kittySequence));
+ } else if (input) {
+ act(() => stdin.pressKey(input));
+ }
+
+ expect(keyHandler).toHaveBeenCalledWith(
+ expect.objectContaining(expected),
+ );
+ },
+ );
+});
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index 4930f7101d0..b8b89dceeb3 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -45,6 +45,36 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m
export const SINGLE_QUOTE = "'";
export const DOUBLE_QUOTE = '"';
+const ALT_KEY_CHARACTER_MAP: Record = {
+ '\u00E5': 'a',
+ '\u222B': 'b',
+ '\u00E7': 'c',
+ '\u2202': 'd',
+ '\u00B4': 'e',
+ '\u0192': 'f',
+ '\u00A9': 'g',
+ '\u02D9': 'h',
+ '\u02C6': 'i',
+ '\u2206': 'j',
+ '\u02DA': 'k',
+ '\u00AC': 'l',
+ '\u00B5': 'm',
+ '\u02DC': 'n',
+ '\u00F8': 'o',
+ '\u03C0': 'p',
+ '\u0153': 'q',
+ '\u00AE': 'r',
+ '\u00DF': 's',
+ '\u2020': 't',
+ '\u00A8': 'u',
+ '\u221A': 'v',
+ '\u2211': 'w',
+ '\u2248': 'x',
+ '\u00A5': 'y',
+ '\\': 'y',
+ '\u03A9': 'z',
+};
+
export interface Key {
name: string;
ctrl: boolean;
@@ -327,9 +357,9 @@ export function KeypressProvider({
};
}
- // Ctrl+letters
+ // Ctrl+letters and Alt+letters
if (
- ctrl &&
+ (ctrl || alt) &&
keyCode >= 'a'.charCodeAt(0) &&
keyCode <= 'z'.charCodeAt(0)
) {
@@ -337,7 +367,7 @@ export function KeypressProvider({
return {
key: {
name: letter,
- ctrl: true,
+ ctrl,
meta: alt,
shift,
paste: false,
@@ -435,6 +465,19 @@ export function KeypressProvider({
return;
}
+ const mappedLetter = ALT_KEY_CHARACTER_MAP[key.sequence];
+ if (mappedLetter && !key.meta) {
+ broadcast({
+ name: mappedLetter,
+ ctrl: false,
+ meta: true,
+ shift: false,
+ paste: isPaste,
+ sequence: key.sequence,
+ });
+ return;
+ }
+
if (key.name === 'return' && waitingForEnterAfterBackslash) {
if (backslashTimeout) {
clearTimeout(backslashTimeout);