Restore keyboard mode when exiting the editor (#13350)

This commit is contained in:
Tommaso Sciortino
2025-11-19 11:37:30 -08:00
committed by GitHub
parent 3f8d636501
commit 84573992b4
6 changed files with 57 additions and 50 deletions
+7 -1
View File
@@ -110,6 +110,7 @@ import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js'; import { useSettings } from './contexts/SettingsContext.js';
import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js';
const WARNING_PROMPT_DURATION_MS = 1000; const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
@@ -381,6 +382,11 @@ export const AppContainer = (props: AppContainerProps) => {
setHistoryRemountKey((prev) => prev + 1); setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, stdout, isAlternateBuffer]); }, [setHistoryRemountKey, stdout, isAlternateBuffer]);
const handleEditorClose = useCallback(() => {
enableSupportedProtocol();
refreshStatic();
}, [refreshStatic]);
const { const {
isThemeDialogOpen, isThemeDialogOpen,
openThemeDialog, openThemeDialog,
@@ -687,7 +693,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
performMemoryRefresh, performMemoryRefresh,
modelSwitchedFromQuotaError, modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
refreshStatic, handleEditorClose,
onCancelSubmit, onCancelSubmit,
setEmbeddedShellFocused, setEmbeddedShellFocused,
terminalWidth, terminalWidth,
@@ -207,7 +207,6 @@ describe('InputPrompt', () => {
); );
mockedUseKittyKeyboardProtocol.mockReturnValue({ mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: false,
enabled: false, enabled: false,
checking: false, checking: false,
}); });
@@ -1244,7 +1243,6 @@ describe('InputPrompt', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
mockedUseKittyKeyboardProtocol.mockReturnValue({ mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: false,
enabled: false, enabled: false,
checking: false, checking: false,
}); });
@@ -1328,7 +1326,6 @@ describe('InputPrompt', () => {
name: 'kitty', name: 'kitty',
setup: () => setup: () =>
mockedUseKittyKeyboardProtocol.mockReturnValue({ mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: true,
enabled: true, enabled: true,
checking: false, checking: false,
}), }),
@@ -404,7 +404,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (key.paste) { if (key.paste) {
// Record paste time to prevent accidental auto-submission // Record paste time to prevent accidental auto-submission
if (!isTerminalPasteTrusted(kittyProtocol.supported)) { if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
setRecentUnsafePasteTime(Date.now()); setRecentUnsafePasteTime(Date.now());
// Clear any existing paste timeout // Clear any existing paste timeout
@@ -820,7 +820,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
recentUnsafePasteTime, recentUnsafePasteTime,
commandSearchActive, commandSearchActive,
commandSearchCompletion, commandSearchCompletion,
kittyProtocol.supported, kittyProtocol.enabled,
tryLoadQueuedMessages, tryLoadQueuedMessages,
setBannerVisible, setBannerVisible,
], ],
@@ -20,6 +20,7 @@ import {
import type { Key } from '../../contexts/KeypressContext.js'; import type { Key } from '../../contexts/KeypressContext.js';
import type { VimAction } from './vim-buffer-actions.js'; import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js';
import { enableSupportedProtocol } from '../../utils/kittyProtocolDetector.js';
export type Direction = export type Direction =
| 'left' | 'left'
@@ -1891,6 +1892,7 @@ export function useTextBuffer({
} catch (err) { } catch (err) {
console.error('[useTextBuffer] external editor error', err); console.error('[useTextBuffer] external editor error', err);
} finally { } finally {
enableSupportedProtocol();
if (wasRaw) setRawMode?.(true); if (wasRaw) setRawMode?.(true);
try { try {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
@@ -5,13 +5,9 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { import { isKittyProtocolEnabled } from '../utils/kittyProtocolDetector.js';
isKittyProtocolEnabled,
isKittyProtocolSupported,
} from '../utils/kittyProtocolDetector.js';
export interface KittyProtocolStatus { export interface KittyProtocolStatus {
supported: boolean;
enabled: boolean; enabled: boolean;
checking: boolean; checking: boolean;
} }
@@ -22,7 +18,6 @@ export interface KittyProtocolStatus {
*/ */
export function useKittyKeyboardProtocol(): KittyProtocolStatus { export function useKittyKeyboardProtocol(): KittyProtocolStatus {
const [status] = useState<KittyProtocolStatus>({ const [status] = useState<KittyProtocolStatus>({
supported: isKittyProtocolSupported(),
enabled: isKittyProtocolEnabled(), enabled: isKittyProtocolEnabled(),
checking: false, checking: false,
}); });
@@ -5,8 +5,11 @@
*/ */
let detectionComplete = false; let detectionComplete = false;
let protocolSupported = false;
let protocolEnabled = false; let kittySupported = false;
let sgrMouseSupported = false;
let kittyEnabled = false;
let sgrMouseEnabled = false; let sgrMouseEnabled = false;
/** /**
@@ -14,15 +17,15 @@ let sgrMouseEnabled = false;
* Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/ * Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/
* This function should be called once at app startup. * This function should be called once at app startup.
*/ */
export async function detectAndEnableKittyProtocol(): Promise<boolean> { export async function detectAndEnableKittyProtocol(): Promise<void> {
if (detectionComplete) { if (detectionComplete) {
return protocolSupported; return;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stdout.isTTY) { if (!process.stdin.isTTY || !process.stdout.isTTY) {
detectionComplete = true; detectionComplete = true;
resolve(false); resolve();
return; return;
} }
@@ -35,14 +38,24 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
let progressiveEnhancementReceived = false; let progressiveEnhancementReceived = false;
let timeoutId: NodeJS.Timeout | undefined; let timeoutId: NodeJS.Timeout | undefined;
const onTimeout = () => { const finish = () => {
timeoutId = undefined; if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
process.stdin.removeListener('data', handleData); process.stdin.removeListener('data', handleData);
if (!originalRawMode) { if (!originalRawMode) {
process.stdin.setRawMode(false); process.stdin.setRawMode(false);
} }
if (kittySupported || sgrMouseSupported) {
enableSupportedProtocol();
process.on('exit', disableAllProtocols);
process.on('SIGTERM', disableAllProtocols);
}
detectionComplete = true; detectionComplete = true;
resolve(false); resolve();
}; };
const handleData = (data: Buffer) => { const handleData = (data: Buffer) => {
@@ -59,37 +72,20 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
// indication the terminal probably supports kitty and we just need to // indication the terminal probably supports kitty and we just need to
// wait a bit longer for a response. // wait a bit longer for a response.
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = setTimeout(onTimeout, 1000); timeoutId = setTimeout(finish, 1000);
} }
// Check for device attributes response (CSI ? <attrs> c) // Check for device attributes response (CSI ? <attrs> c)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) { if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
clearTimeout(timeoutId);
timeoutId = undefined;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
if (progressiveEnhancementReceived) { if (progressiveEnhancementReceived) {
// Enable the protocol kittySupported = true;
process.stdout.write('\x1b[>1u');
protocolSupported = true;
protocolEnabled = true;
} }
// Broaden mouse support by enabling SGR mode if we get any device // Broaden mouse support by enabling SGR mode if we get any device
// attribute response, which is a strong signal of a modern terminal. // attribute response, which is a strong signal of a modern terminal.
process.stdout.write('\x1b[?1006h'); sgrMouseSupported = true;
sgrMouseEnabled = true;
// Set up cleanup on exit for all enabled protocols finish();
process.on('exit', disableAllProtocols);
process.on('SIGTERM', disableAllProtocols);
detectionComplete = true;
resolve(protocolSupported);
} }
}; };
@@ -102,14 +98,18 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
// Timeout after 200ms // Timeout after 200ms
// When a iterm2 terminal does not have focus this can take over 90s on a // When a iterm2 terminal does not have focus this can take over 90s on a
// fast macbook so we need a somewhat longer threshold than would be ideal. // fast macbook so we need a somewhat longer threshold than would be ideal.
timeoutId = setTimeout(onTimeout, 200); timeoutId = setTimeout(finish, 200);
}); });
} }
export function isKittyProtocolEnabled(): boolean {
return kittyEnabled;
}
function disableAllProtocols() { function disableAllProtocols() {
if (protocolEnabled) { if (kittyEnabled) {
process.stdout.write('\x1b[<u'); process.stdout.write('\x1b[<u');
protocolEnabled = false; kittyEnabled = false;
} }
if (sgrMouseEnabled) { if (sgrMouseEnabled) {
process.stdout.write('\x1b[?1006l'); // Disable SGR Mouse process.stdout.write('\x1b[?1006l'); // Disable SGR Mouse
@@ -117,10 +117,17 @@ function disableAllProtocols() {
} }
} }
export function isKittyProtocolEnabled(): boolean { /**
return protocolEnabled; * This is exported so we can reenable this after exiting an editor which might
} * change the mode.
*/
export function isKittyProtocolSupported(): boolean { export function enableSupportedProtocol(): void {
return protocolSupported; if (kittySupported) {
process.stdout.write('\x1b[>1u');
kittyEnabled = true;
}
if (sgrMouseSupported) {
process.stdout.write('\x1b[?1006h');
sgrMouseEnabled = true;
}
} }