Compare commits

...
6 changed files with 187 additions and 0 deletions

No files matched your search

+1
View File
@@ -4219,6 +4219,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
xTermRuntimeContextRef.current = {
host,
localShellType: shellType,
fontFamilyId,
resolvedFontFamily,
fontSize: effectiveFontSize,
@@ -165,6 +165,7 @@ import {
type TerminalOutputHistoryPreview,
} from "./terminalOutputHistory";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import { shouldPastePlainCtrlV } from "./terminalPasteChord";
import {
isMacCommandPeriodInterruptChord,
shouldUseUrgentTerminalInterrupt,
@@ -302,6 +303,7 @@ export const resetKittyKeyboardModeStateForSession = (
export type CreateXTermRuntimeContext = {
container: HTMLDivElement;
host: Host;
localShellType?: TerminalSession["shellType"];
fontFamilyId: string;
resolvedFontFamily: string;
fontSize: number;
@@ -2419,6 +2421,37 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
}
// Clipboard-based dictation tools can deliver a transcript by placing it
// on the clipboard and simulating plain Ctrl+V. The #3468 reporter
// confirmed this path for Wispr Flow by remapping Paste to Ctrl+V.
// In xterm's legacy key path, plain Ctrl+V can reach the remote as \x16
// (readline quoted-insert) instead of a paste (#3468). Route the chord
// through the shared paste pipeline like the Ctrl+Shift+V binding: text
// pastes, and a local image-only clipboard forwards raw Ctrl+V to nested
// TUIs. Kitty and native Windows shells in Win32 input mode retain their
// own encoding of the chord.
// Gate on the actual OS: on other platforms plain Ctrl+V is a live
// terminal key (readline quoted-insert, Vim visual-block), so it must
// keep forwarding as \x16 regardless of the configured hotkey scheme.
if (shouldPastePlainCtrlV(e, {
platform,
connected: Boolean(ctx.sessionRef.current) && ctx.statusRef.current === "connected",
kittySequenceForKeyDown,
win32InputMode: term.modes.win32InputMode,
localShellType: ctx.localShellType,
})) {
const id = ctx.sessionRef.current;
if (id) {
e.preventDefault();
e.stopPropagation();
// Always share the context-menu paste path so local image-only
// clipboard can forward Ctrl+V, and remote auto-upload stays
// gated inside handleTerminalClipboardPaste.
void ctx.terminalContextActionsRef?.current?.onPaste?.();
return false;
}
}
// Sogou/macOS CJK punctuation: keydown still reports ASCII "," while the
// full-width "," arrives on insertText. Returning false (without
// preventDefault) stops xterm/Kitty from sending the half-width key so the
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isPlainCtrlVPasteChord, shouldPastePlainCtrlV } from "./terminalPasteChord.ts";
const keyboardEvent = (
key: string,
code: string,
modifiers: Partial<KeyboardEvent> = {},
): KeyboardEvent => ({
key,
code,
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false,
...modifiers,
}) as KeyboardEvent;
test("plain Ctrl+V is the dictation paste chord", () => {
assert.equal(isPlainCtrlVPasteChord(keyboardEvent("v", "KeyV", { ctrlKey: true })), true);
assert.equal(isPlainCtrlVPasteChord(keyboardEvent("V", "KeyV", { ctrlKey: true })), true);
});
test("plain Ctrl+V follows the physical V key on non-Latin layouts", () => {
const event = keyboardEvent("м", "KeyV", { ctrlKey: true });
assert.equal(isPlainCtrlVPasteChord(event), true);
});
test("Ctrl+V with extra modifiers or other keys is not the paste chord", () => {
assert.equal(
isPlainCtrlVPasteChord(keyboardEvent("v", "KeyV", { ctrlKey: true, shiftKey: true })),
false,
);
assert.equal(
isPlainCtrlVPasteChord(keyboardEvent("v", "KeyV", { ctrlKey: true, altKey: true })),
false,
);
assert.equal(
isPlainCtrlVPasteChord(keyboardEvent("v", "KeyV", { ctrlKey: true, metaKey: true })),
false,
);
assert.equal(isPlainCtrlVPasteChord(keyboardEvent("c", "KeyC", { ctrlKey: true })), false);
assert.equal(isPlainCtrlVPasteChord(keyboardEvent("v", "KeyV")), false);
assert.equal(isPlainCtrlVPasteChord(keyboardEvent("v", "KeyV", { metaKey: true })), false);
});
test("Windows legacy input pastes, including SSH sessions", () => {
assert.equal(shouldPastePlainCtrlV(keyboardEvent("v", "KeyV", { ctrlKey: true }), {
platform: "win32",
connected: true,
kittySequenceForKeyDown: null,
win32InputMode: false,
}), true);
});
test("ConPTY Unix shells route Ctrl+V to paste while native Windows shells retain their key handling", () => {
const event = keyboardEvent("v", "KeyV", { ctrlKey: true });
const context = {
platform: "win32" as const,
connected: true,
kittySequenceForKeyDown: null,
win32InputMode: true,
};
for (const localShellType of ["posix", "fish"] as const) {
assert.equal(shouldPastePlainCtrlV(event, { ...context, localShellType }), true, localShellType);
}
for (const localShellType of ["cmd", "powershell", "unknown"] as const) {
assert.equal(shouldPastePlainCtrlV(event, { ...context, localShellType }), false, localShellType);
}
assert.equal(shouldPastePlainCtrlV(event, context), false);
});
test("other platforms, negotiated Kitty keys, composition and disconnected sessions keep their key path", () => {
const event = keyboardEvent("v", "KeyV", { ctrlKey: true });
const context = {
platform: "win32" as const,
connected: true,
kittySequenceForKeyDown: null,
win32InputMode: false,
};
assert.equal(shouldPastePlainCtrlV(event, { ...context, platform: "linux" }), false);
assert.equal(shouldPastePlainCtrlV(event, { ...context, platform: "darwin" }), false);
assert.equal(shouldPastePlainCtrlV(event, { ...context, kittySequenceForKeyDown: "sequence" }), false);
assert.equal(shouldPastePlainCtrlV(event, { ...context, connected: false }), false);
assert.equal(shouldPastePlainCtrlV(keyboardEvent("v", "KeyV", {
ctrlKey: true,
isComposing: true,
}), context), false);
assert.equal(shouldPastePlainCtrlV(keyboardEvent("Process", "KeyV", {
ctrlKey: true,
keyCode: 229,
}), context), false);
});
@@ -0,0 +1,46 @@
type PasteChordKeyEvent = Pick<
KeyboardEvent,
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey" | "isComposing" | "keyCode"
>;
type PasteChordContext = {
platform: "darwin" | "linux" | "win32";
connected: boolean;
kittySequenceForKeyDown: string | null;
win32InputMode: boolean;
localShellType?: "posix" | "fish" | "powershell" | "cmd" | "unknown";
};
function isPhysicalVKey(e: PasteChordKeyEvent): boolean {
return e.key.toLowerCase() === "v" || e.code === "KeyV";
}
/**
* Unmodified Ctrl+V — the system paste chord on Windows.
*
* Clipboard-based dictation tools can deliver their transcript by placing it
* on the clipboard and simulating the system paste shortcut. The #3468
* reporter confirmed this path for Wispr Flow by remapping Paste to Ctrl+V.
* In xterm's legacy key path, plain Ctrl+V can reach the remote as \x16
* (readline quoted-insert) instead of a paste (#3468).
*
* Also matches non-Latin layouts via the physical KeyV code, mirroring
* isPlainCtrlCInterruptChord.
*/
export function isPlainCtrlVPasteChord(e: PasteChordKeyEvent): boolean {
return e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& isPhysicalVKey(e);
}
export function shouldPastePlainCtrlV(e: PasteChordKeyEvent, context: PasteChordContext): boolean {
return context.platform === "win32"
&& context.connected
&& !context.kittySequenceForKeyDown
&& (!context.win32InputMode || context.localShellType === "posix" || context.localShellType === "fish")
&& !e.isComposing
&& e.keyCode !== 229
&& isPlainCtrlVPasteChord(e);
}
@@ -237,3 +237,14 @@ test('normal boot and hibernate wake share terminal link error feedback', () =>
/createXTermRuntime\(\{[\s\S]*?onOpenExternalError,/,
);
});
test('normal boot and hibernate wake preserve the local shell type for dictation paste', () => {
assert.match(
effectsSource,
/createXTermRuntime\(\{\s*container: containerRef\.current,\s*host,\s*localShellType: ctx\.shellType,/,
);
assert.match(
terminalSource,
/xTermRuntimeContextRef\.current = \{\s*host,\s*localShellType: shellType,/,
);
});
@@ -473,6 +473,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const runtime = createXTermRuntime({
container: containerRef.current,
host,
localShellType: ctx.shellType,
fontFamilyId,
resolvedFontFamily,
fontSize,