fix(terminal): refine command navigation shortcuts

This commit is contained in:
Kang committed 2026-09-28 14:12:33 +08:00
1 parent 2b4c90ff3e
commit dd3769cb81
12 files changed
+512 -302

No files matched your search

-2
View File
@@ -39,5 +39,3 @@ temp/
.claude
.codex
.trae
.history
temporary
+73 -267
View File
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import MultiLinePasteDialog from "@/components/dialog/terminal/MultiLinePasteDialog";
import ExternalFileDropOverlay from "@/components/ExternalFileDropOverlay";
import { useApp, useTerminalAppSettings } from "@/context/AppContext";
import { useTerminalAppSettings } from "@/context/AppContext";
import { useTheme } from "@/context/ThemeContext";
import { useTransfer } from "@/context/TransferContext";
import { useActionLinks } from "@/hooks/useActionLinks";
@@ -28,7 +28,6 @@ import {
buildTerminalThemeColors,
isTerminalTransparencyEnabled,
} from "@/lib/backgroundImage";
import { matchesKeyEvent, resolveKeys } from "@/lib/shortcutRegistry";
import {
readClipboardPathPayload,
readClipboardText,
@@ -84,6 +83,11 @@ import type { AiCaptureEvent } from "@/types/global";
import ActionLinkMenu from "./ActionLinkMenu";
import ActionLinkTooltip from "./ActionLinkTooltip";
import CommandSuggestions from "./CommandSuggestions";
import {
CommandNavigation,
shouldRecordFallbackCommand,
startsObviousInteractiveSession,
} from "./commandNavigation";
import CredentialSuggestions from "./CredentialSuggestions";
import { installRemoteColorOscGuard } from "./remoteColorOscGuard";
import SyncActionOverlay from "./SyncActionOverlay";
@@ -225,12 +229,6 @@ export default function XTerminal({
completeExternalTransfer,
failExternalTransfer,
} = useTransfer();
const { appSettings } = useApp();
const keybindingsRef = useRef(appSettings.keybindings);
useEffect(() => {
keybindingsRef.current = appSettings.keybindings;
}, [appSettings.keybindings]);
const terminalAppSettings = useTerminalAppSettings();
const {
appearance,
@@ -651,7 +649,7 @@ export default function XTerminal({
});
// Shell integration state
const { shellIntegrationRef, commandMarkersRef, pushCommandMarker, clearCommandMarkers } = useShellIntegration();
const { shellIntegrationRef } = useShellIntegration();
const canShowCommandSuggestions = useCallback(
(options?: { allowEmpty?: boolean }) => {
if (credentialPromptInputUntilRef.current > Date.now()) {
@@ -1002,47 +1000,14 @@ export default function XTerminal({
terminalRef.current = terminal;
setTerminalInstance(terminal);
// 命令导航游标:Ctrl+Shift+←/→ 沿候选行移动时用的“当前位置”。
// 不能直接用终端光标行,因为 terminal.scrollToLine 只滚动视图、
// 不会移动光标,那样第二次按方向键 currentLine 一直不变,就跳不动了。
let commandNavCursorLine: number | null = null;
// 累积选择锚点:Ctrl+Shift+/ 第一次选中“当前命令到下一命令前一行”,
// 再按一次向后扩展一段(包括下一条命令及其输出)。
let commandSelectionAnchor: { startLine: number; endLine: number } | null = null;
const commandNavigation = new CommandNavigation();
let fallbackInteractive = false;
const getAbsCursorLine = () => {
const buffer = terminal.buffer.active;
return buffer.baseY + buffer.cursorY;
};
// 清空导航 / 累积选择状态。在以下场景调用:
// - 用户按普通方向键(shell 光标移动,之前导航位置已失效)
// - 用户鼠标点击终端(重新从光标位置出发)
// - 用户清掉选择(让 Ctrl+Shift+/ 从当前光标重新开始,而不是卡在“已到最底”分支)
// 不清会导致:下一次 Ctrl+Shift+←/→ 从错误位置出发;Ctrl+Shift+/ 第二次后彻底失效。
const resetCommandNavigationState = () => {
commandNavCursorLine = null;
commandSelectionAnchor = null;
};
const getCommandNavCursorLine = () => {
if (commandNavCursorLine === null) {
commandNavCursorLine = getAbsCursorLine();
}
return commandNavCursorLine;
};
// 命令导航候选 = 每条命令的起始 marker + 当前光标所在行(“最底部”)
const getCommandNavigationPositions = (): number[] => {
const cursorLine = getAbsCursorLine();
const set = new Set<number>();
for (const m of commandMarkersRef.current) {
if (!m.isDisposed && Number.isFinite(m.line)) set.add(m.line);
}
set.add(cursorLine);
return Array.from(set).sort((a, b) => a - b);
};
// 目标行高亮:跳转 / 复制后短暂高亮对应的行,给用户视觉反馈
let highlightDecorations: { dispose: () => void }[] = [];
let highlightTimer: number | null = null;
@@ -1076,184 +1041,32 @@ export default function XTerminal({
el.style.backgroundColor = "rgba(100, 150, 255, 0.35)";
el.style.pointerEvents = "none";
});
highlightDecorations.push(deco);
highlightDecorations.push({
dispose: () => {
deco.dispose();
if (!marker.isDisposed) marker.dispose();
},
});
}
highlightTimer = window.setTimeout(() => {
clearHighlightDecorations();
}, 1800);
};
const handleCommandNavigationKey = (event: KeyboardEvent): boolean | null => {
// 单独的 ←/→/↑/↓ 会移动 shell 光标,重置导航游标
const isPlainArrow =
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
(event.key === "ArrowLeft" ||
event.key === "ArrowRight" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown");
if (isPlainArrow) {
resetCommandNavigationState();
return null;
}
// 所有导航 / 选择 / 全部清除快捷键都从注册表读取,允许设置页自定义
const kb = keybindingsRef.current;
const isNavPrev = matchesKeyEvent(
resolveKeys("terminal.commandNav.prev", kb),
event,
);
const isNavNext = matchesKeyEvent(
resolveKeys("terminal.commandNav.next", kb),
event,
);
const isNavSelect = matchesKeyEvent(
resolveKeys("terminal.commandNav.select", kb),
event,
);
const isClearAll = matchesKeyEvent(
resolveKeys("terminal.clearAll", kb),
event,
);
if (isNavPrev || isNavNext) {
const positions = getCommandNavigationPositions();
if (positions.length <= 1) return true;
const currentLine = getCommandNavCursorLine();
let target: number | null = null;
if (isNavPrev) {
// 找严格小于 currentLine 的最大位置;不要用 cursorLine+1 之类技巧,
// 否则最上面那条命令(line 可能等于 0)会被直接跳过。
for (let i = positions.length - 1; i >= 0; i--) {
if (positions[i] < currentLine) {
target = positions[i];
break;
}
}
} else {
for (let i = 0; i < positions.length; i++) {
if (positions[i] > currentLine) {
target = positions[i];
break;
}
}
}
if (target !== null) {
commandNavCursorLine = target;
// 跳转时清掉累积选择锚点,避免与 Ctrl+Shift+/ 状态混在一起
commandSelectionAnchor = null;
terminal.scrollToLine(target);
highlightLines([target]);
}
return true;
}
if (isNavSelect) {
const markers = commandMarkersRef.current
.filter((m) => !m.isDisposed && Number.isFinite(m.line))
.map((m) => m.line)
.sort((a, b) => a - b);
if (markers.length === 0) return true;
const cursorLine = getAbsCursorLine();
let nextStart: number;
let nextEnd: number;
if (commandSelectionAnchor === null) {
// 首次:从当前命令(导航游标 / 光标行所在命令)开始,
// 到下一个命令的起始行前一行结束
const anchorLine = commandNavCursorLine ?? cursorLine;
let startLine: number | null = null;
for (let i = markers.length - 1; i >= 0; i--) {
if (markers[i] <= anchorLine) {
startLine = markers[i];
break;
}
}
if (startLine === null) return true;
let nextLine: number | null = null;
for (let i = 0; i < markers.length; i++) {
if (markers[i] > startLine) {
nextLine = markers[i];
break;
}
}
nextStart = startLine;
nextEnd = nextLine !== null ? nextLine - 1 : cursorLine;
} else {
// 已有选中:找当前 endLine 之后的下一个 marker 作为新增段起点
let extensionStart: number | null = null;
for (let i = 0; i < markers.length; i++) {
if (markers[i] > commandSelectionAnchor.endLine) {
extensionStart = markers[i];
break;
}
}
if (extensionStart === null) return true; // 已到最底,不再扩展
nextStart = commandSelectionAnchor.startLine;
let nextLine: number | null = null;
for (let i = 0; i < markers.length; i++) {
if (markers[i] > extensionStart) {
nextLine = markers[i];
break;
}
}
nextEnd = nextLine !== null ? nextLine - 1 : cursorLine;
}
commandSelectionAnchor = { startLine: nextStart, endLine: nextEnd };
// 用 xterm 的选区 API 选中该区域,让用户自己决定是否复制
terminal.selectLines(nextStart, nextEnd);
terminal.scrollToLine(nextStart);
return true;
}
if (isClearAll) {
clearAllRef.current();
return true;
}
return null;
};
terminal.attachCustomKeyEventHandler((event) => {
const handled = handleCommandNavigationKey(event);
if (handled === true) {
event.preventDefault();
event.stopPropagation();
return false;
}
return true;
});
// 兜底:某些 WebView / 快捷键冲突会绕过 xterm 的 key handler,
// 这里在捕获阶段直接监听容器,确保快捷键能生效。
// 注意:捕获阶段一旦命中并 stopPropagation,xterm 自带的 keydown 就不会再触发,
// 所以 handleCommandNavigationKey 只会执行一次,不会重复跳。
const commandNavCaptureHandler = (event: KeyboardEvent) => {
if (!containerRef.current?.contains(event.target as Node)) return;
const handled = handleCommandNavigationKey(event);
if (handled === true) {
event.preventDefault();
event.stopPropagation();
const navigateCommand = (direction: -1 | 1) => {
const target = commandNavigation.navigate(direction, getAbsCursorLine());
if (target !== null) {
terminal.scrollToLine(target);
highlightLines([target]);
}
};
window.addEventListener("keydown", commandNavCaptureHandler, true);
const removeCommandNavCapture = () => {
window.removeEventListener("keydown", commandNavCaptureHandler, true);
const selectCommandBlock = () => {
const range = commandNavigation.select(getAbsCursorLine());
if (range) {
terminal.selectLines(range.start, range.end);
terminal.scrollToLine(range.start);
}
};
fitAddonRef.current = fitAddon;
inputStateRef.current = createTerminalInputState();
credentialPromptBufferRef.current = "";
@@ -1871,6 +1684,10 @@ export default function XTerminal({
syncSuggestionsWithInputState,
lastSelectionRef,
appLockedRef,
navigateCommand,
selectCommandBlock,
clearAll: () => clearAllRef.current(),
resetCommandNavigation: () => commandNavigation.reset(),
});
const blockedColorOscIds = new Set<number>();
@@ -1899,33 +1716,32 @@ export default function XTerminal({
const oscDisposable = terminal.parser.registerOscHandler(133, (data) => {
const si = shellIntegrationRef.current;
if (data.startsWith("A")) {
const phase = data.split(";", 1)[0];
if (phase === "A") {
si.enabled = true;
si.commandRunning = false;
const marker = terminal.registerMarker(0);
if (marker) {
pushCommandMarker(marker);
}
commandNavigation.promptStart(terminal.registerMarker(0));
return false;
}
if (data.startsWith("B")) {
if (phase === "B") {
si.enabled = true;
si.commandRunning = false;
resetCommandSuggestionSuppression();
return false;
}
if (data.startsWith("C")) {
if (phase === "C") {
si.enabled = true;
si.commandRunning = true;
commandNavigation.commandStart();
inputStateRef.current = createTerminalInputState();
resetCommandSuggestionSuppression();
dismissSuggestions();
return false;
}
if (data.startsWith("D")) {
if (phase === "D") {
si.enabled = true;
si.commandRunning = false;
resetCommandSuggestionSuppression();
@@ -2017,6 +1833,8 @@ export default function XTerminal({
clearAllRef.current = () => {
if (appLockedRef.current) return;
commandNavigation.clear();
clearHighlightDecorations();
lineTimestampsRef.current = new Map();
gutterLineOffsetRef.current = 0;
clearTerminalAll(terminal, {
@@ -2413,40 +2231,6 @@ export default function XTerminal({
);
const dataDisposable = terminal.onData((data) => {
// 兜底:本地 CMD / PowerShell 等不发送 OSC 133 的 shell,
// 以用户按 Enter 那一刻的光标位置作为“命令开始”标记。
if (data === "\r" || data === "\n" || data === "\r\n") {
const buffer = terminal.buffer.active;
const cursorLine = buffer.baseY + buffer.cursorY;
// 从光标行向上找最近一个非空行作为命令起始行。
// 这样即使回车回显已经让光标挪到空行,也能正确标记命令所在行,
// 避免把空行当成命令位置导致某些命令“被忽略”。
let markerLine = cursorLine;
while (markerLine >= 0) {
const line = buffer.getLine(markerLine);
if (line && line.translateToString(true).trim().length > 0) break;
markerLine--;
}
if (markerLine >= 0) {
const existingMarkers = commandMarkersRef.current;
const lastMarker = existingMarkers[existingMarkers.length - 1];
const alreadyMarked =
lastMarker &&
!lastMarker.isDisposed &&
lastMarker.line === markerLine;
if (!alreadyMarked) {
const offset = markerLine - cursorLine;
const marker = terminal.registerMarker(offset);
if (marker) {
pushCommandMarker(marker);
}
}
}
}
const trackedOrigin = inputOriginTracker.consume();
const origin = resolveXTerminalDataOrigin(
trackedOrigin,
@@ -2633,6 +2417,37 @@ export default function XTerminal({
if (data === "\r") {
refreshCommandLineTimestamp();
}
if (
data === "\r" &&
shouldRecordFallbackCommand({
command,
sessionType: sessionTypeRef.current,
shellIntegrationEnabled: shellIntegrationRef.current.enabled,
bufferType: terminal.buffer.active.type,
disconnected: disconnectedRef.current,
aiCapturing: aiCapturingRef.current,
credentialPrompt: isCredentialPromptInputMode(),
interactive:
fallbackInteractive ||
commandSuggestionSuppressedRef.current ||
commandStartsSuggestionSuppressingProgram(command) ||
isPagerSearchOrCommandInput(command),
})
) {
const buffer = terminal.buffer.active;
const cursorLine = buffer.baseY + buffer.cursorY;
let startLine = cursorLine;
while (startLine > 0 && buffer.getLine(startLine)?.isWrapped)
startLine--;
const marker = terminal.registerMarker(startLine - cursorLine);
if (marker) commandNavigation.add(marker);
}
if (data === "\r" && command && !shellIntegrationRef.current.enabled) {
if (startsObviousInteractiveSession(command)) fallbackInteractive = true;
else if (fallbackInteractive && /^(?:exit|quit|\.exit)(?:\(\))?$/iu.test(command.trim())) {
fallbackInteractive = false;
}
}
inputStateRef.current = applyTerminalInputData(
inputStateRef.current,
data,
@@ -2692,22 +2507,14 @@ export default function XTerminal({
const containerEl = containerRef.current;
// 鼠标点击终端 = 用户重新指定当前位置,清掉导航 / 累积选择缓存,
// 否则下一次 Ctrl+Shift+←/→ 会从旧位置继续,Ctrl+Shift+/ 也会卡在旧锚点上。
const handleTerminalPointerDown = () => {
commandNavCursorLine = null;
// 点击会让 xterm 清掉选择,下一次 Ctrl+Shift+/ 应该从新位置重新开始
if (!terminal.hasSelection()) {
commandSelectionAnchor = null;
}
commandNavigation.reset();
};
containerEl.addEventListener("pointerdown", handleTerminalPointerDown, true);
// 用户通过点击 / 拖拽清空了选区时,同步清掉累积选择锚点,防止
// Ctrl+Shift+/ 走进“已到最底,不再扩展”的分支后彻底没反应。
const commandSelectionChangeDisposable = terminal.onSelectionChange(() => {
if (!terminal.hasSelection()) {
commandSelectionAnchor = null;
commandNavigation.resetSelection();
}
});
@@ -2803,9 +2610,8 @@ export default function XTerminal({
clearCredentialPromptInputMode();
shellIntegrationRef.current.enabled = false;
shellIntegrationRef.current.commandRunning = false;
clearCommandMarkers();
commandNavigation.clear();
clearHighlightDecorations();
removeCommandNavCapture();
containerEl.removeEventListener(
"pointerdown",
handleTerminalPointerDown,
@@ -0,0 +1,142 @@
import type { IMarker } from "@xterm/xterm";
import { describe, expect, it, vi } from "vitest";
import {
CommandNavigation,
shouldRecordFallbackCommand,
startsObviousInteractiveSession,
} from "./commandNavigation";
function marker(line: number) {
const callbacks: (() => void)[] = [];
const value = {
line,
isDisposed: false,
onDispose: vi.fn((callback: () => void) => {
callbacks.push(callback);
return { dispose: vi.fn() };
}),
dispose: vi.fn(() => {
if (value.isDisposed) return;
value.isDisposed = true;
callbacks.forEach((callback) => {
callback();
});
}),
};
return value as unknown as IMarker & {
line: number;
dispose: ReturnType<typeof vi.fn>;
};
}
describe("CommandNavigation", () => {
it("navigates previous and next across boundaries and the current prompt", () => {
const navigation = new CommandNavigation();
navigation.add(marker(0));
navigation.add(marker(5));
navigation.add(marker(10));
expect(navigation.navigate(-1, 15)).toBe(10);
expect(navigation.navigate(-1, 15)).toBe(5);
expect(navigation.navigate(-1, 15)).toBe(0);
expect(navigation.navigate(-1, 15)).toBeNull();
expect(navigation.navigate(1, 15)).toBe(5);
expect(navigation.navigate(1, 15)).toBe(10);
expect(navigation.navigate(1, 15)).toBe(15);
expect(navigation.navigate(1, 15)).toBeNull();
});
it("removes disposed markers and follows their current lines after resize", () => {
const navigation = new CommandNavigation();
const first = marker(2);
const second = marker(8);
navigation.add(first);
navigation.add(second);
first.dispose();
expect(navigation.size).toBe(1);
expect(navigation.navigate(-1, 12)).toBe(8);
second.line = 6;
navigation.resetPosition();
expect(navigation.navigate(-1, 12)).toBe(6);
});
it("selects a command block, extends it, and resets after selection clears", () => {
const navigation = new CommandNavigation();
navigation.add(marker(1));
navigation.add(marker(5));
navigation.add(marker(9));
expect(navigation.select(12)).toEqual({ start: 9, end: 12 });
navigation.resetSelection();
expect(navigation.navigate(-1, 12)).toBe(9);
expect(navigation.navigate(-1, 12)).toBe(5);
expect(navigation.select(12)).toEqual({ start: 5, end: 8 });
expect(navigation.select(12)).toEqual({ start: 5, end: 12 });
expect(navigation.select(12)).toBeNull();
navigation.resetSelection();
expect(navigation.select(12)).toEqual({ start: 5, end: 8 });
});
it("commits OSC prompt markers only at command start and clears each marker once", () => {
const navigation = new CommandNavigation();
const emptyPrompt = marker(0);
const submitted = marker(2);
navigation.promptStart(emptyPrompt);
navigation.promptStart(submitted);
expect(emptyPrompt.dispose).toHaveBeenCalledOnce();
expect(navigation.size).toBe(0);
navigation.commandStart();
navigation.add(marker(2));
expect(navigation.size).toBe(1);
navigation.clear();
expect(navigation.size).toBe(0);
expect(submitted.dispose).toHaveBeenCalledOnce();
navigation.clear();
expect(submitted.dispose).toHaveBeenCalledOnce();
});
});
describe("fallback command detection", () => {
const valid = {
command: "dir",
sessionType: "Local" as const,
shellIntegrationEnabled: false,
bufferType: "normal",
disconnected: false,
aiCapturing: false,
credentialPrompt: false,
interactive: false,
};
it("accepts a tracked local shell submission without OSC 133", () => {
expect(shouldRecordFallbackCommand(valid)).toBe(true);
});
it("rejects OSC, alternate screen, disconnected and interactive input", () => {
for (const change of [
{ shellIntegrationEnabled: true },
{ bufferType: "alternate" },
{ disconnected: true },
{ aiCapturing: true },
{ credentialPrompt: true },
{ interactive: true },
{ command: "" },
{ sessionType: "SSH" as const },
]) {
expect(shouldRecordFallbackCommand({ ...valid, ...change })).toBe(false);
}
});
it("identifies obvious REPL and nested-shell launches", () => {
for (const command of [
"python",
"python3",
"node",
"pwsh",
"cmd /k",
"bash",
]) {
expect(startsObviousInteractiveSession(command)).toBe(true);
}
expect(startsObviousInteractiveSession("python script.py")).toBe(false);
expect(startsObviousInteractiveSession("dir")).toBe(false);
});
});
@@ -0,0 +1,155 @@
import type { IMarker } from "@xterm/xterm";
import type { SessionType } from "@/types/global";
export function startsObviousInteractiveSession(command: string): boolean {
return /^(?:[^\s/\\]+[/\\])?(?:python(?:3(?:\.\d+)?)?|node|irb|psql|sqlite3|mysql|cmd|powershell|pwsh|bash|zsh|fish|sh|wsl)(?:\.exe)?(?:\s+[-/]i|\s+[/]k)?\s*$/iu.test(
command.trim(),
);
}
export function shouldRecordFallbackCommand(options: {
command: string;
sessionType: SessionType;
shellIntegrationEnabled: boolean;
bufferType: string;
disconnected: boolean;
aiCapturing: boolean;
credentialPrompt: boolean;
interactive: boolean;
}): boolean {
return (
Boolean(options.command) &&
options.sessionType === "Local" &&
!options.shellIntegrationEnabled &&
options.bufferType === "normal" &&
!options.disconnected &&
!options.aiCapturing &&
!options.credentialPrompt &&
!options.interactive
);
}
export class CommandNavigation {
private readonly markers = new Set<IMarker>();
private lastAdded: IMarker | null = null;
private pendingPrompt: IMarker | null = null;
private position: IMarker | null = null;
private selectionStart: IMarker | null = null;
private selectionEnd: IMarker | null = null;
add(marker: IMarker): void {
if (marker.isDisposed) return;
if (
this.lastAdded &&
!this.lastAdded.isDisposed &&
this.lastAdded.line === marker.line
) {
marker.dispose();
return;
}
this.markers.add(marker);
this.lastAdded = marker;
marker.onDispose(() => {
this.markers.delete(marker);
if (this.lastAdded === marker) this.lastAdded = null;
if (this.position === marker) this.position = null;
if (this.selectionStart === marker || this.selectionEnd === marker) {
this.resetSelection();
}
});
}
promptStart(marker: IMarker | null): void {
if (this.pendingPrompt && !this.pendingPrompt.isDisposed)
this.pendingPrompt.dispose();
this.pendingPrompt = marker;
}
commandStart(): void {
if (this.pendingPrompt && !this.pendingPrompt.isDisposed)
this.add(this.pendingPrompt);
this.pendingPrompt = null;
}
clear(): void {
if (this.pendingPrompt && !this.pendingPrompt.isDisposed)
this.pendingPrompt.dispose();
this.pendingPrompt = null;
const markers = [...this.markers];
this.markers.clear();
this.lastAdded = null;
this.reset();
for (const marker of markers) {
if (!marker.isDisposed) marker.dispose();
}
}
reset(): void {
this.position = null;
this.resetSelection();
}
resetPosition(): void {
this.position = null;
}
resetSelection(): void {
this.selectionStart = null;
this.selectionEnd = null;
}
private ordered(): IMarker[] {
return [...this.markers]
.filter((marker) => !marker.isDisposed)
.sort((a, b) => a.line - b.line);
}
navigate(direction: -1 | 1, cursorLine: number): number | null {
const current = this.position?.line ?? cursorLine;
const markers = this.ordered();
if (direction < 0) {
const target = markers.filter((marker) => marker.line < current).pop();
if (!target) return null;
this.position = target;
this.resetSelection();
return target.line;
}
const target = markers.find((marker) => marker.line > current);
if (target && target.line < cursorLine) {
this.position = target;
this.resetSelection();
return target.line;
}
if (current < cursorLine) {
this.position = null;
this.resetSelection();
return cursorLine;
}
return null;
}
select(cursorLine: number): { start: number; end: number } | null {
const markers = this.ordered();
const start =
this.selectionStart ??
markers
.filter((marker) => marker.line <= (this.position?.line ?? cursorLine))
.pop();
if (!start) return null;
const included = this.selectionEnd ?? start;
const next = markers.find((marker) => marker.line > included.line);
if (this.selectionEnd && !next) return null;
const last = this.selectionEnd ? next! : start;
const following = markers.find((marker) => marker.line > last.line);
this.selectionStart = start;
this.selectionEnd = last;
return {
start: start.line,
end: Math.max(start.line, following ? following.line - 1 : cursorLine),
};
}
get size(): number {
return this.markers.size;
}
}
@@ -41,7 +41,7 @@ function createHarness(
imeRoute: XTerminalImeKeyboardRoute,
sessionType: SessionType = "Local",
keybindings: Record<string, string> = {},
options: { isMacOS?: boolean; appLocked?: boolean } = {},
options: { isMacOS?: boolean; appLocked?: boolean; disconnected?: boolean } = {},
) {
const keyHandlerRef: {
current: ((event: KeyboardEvent) => boolean) | null;
@@ -52,6 +52,7 @@ function createHarness(
}),
getSelection: vi.fn(() => ""),
hasSelection: vi.fn(() => false),
clearSelection: vi.fn(),
input: vi.fn(),
buffer: { active: { baseY: 0, viewportY: 0 } },
scrollToBottom: vi.fn(),
@@ -60,6 +61,9 @@ function createHarness(
const pasteClipboard = vi.fn(async () => {});
const sendRawInput = vi.fn(async () => {});
const syncSuggestionsWithInputState = vi.fn();
const navigateCommand = vi.fn();
const selectCommandBlock = vi.fn();
const clearAll = vi.fn();
const inputStateRef = {
current: {
...createTerminalInputState(),
@@ -78,7 +82,7 @@ function createHarness(
sessionTypeRef: { current: sessionType },
inputStateRef,
appLockedRef: { current: options.appLocked ?? false },
disconnectedRef: { current: false },
disconnectedRef: { current: options.disconnected ?? false },
onDisconnectedCloseRequestedRef: { current: undefined },
showSuggestionsRef: { current: false },
suggestionsRef: { current: [] },
@@ -100,6 +104,10 @@ function createHarness(
replaceInputSelection: vi.fn(),
syncSuggestionsWithInputState,
lastSelectionRef: { current: "" },
navigateCommand,
selectCommandBlock,
clearAll,
resetCommandNavigation: vi.fn(),
});
const keyHandler = keyHandlerRef.current;
@@ -115,14 +123,77 @@ function createHarness(
sendRawInput,
syncSuggestionsWithInputState,
terminal,
navigateCommand,
selectCommandBlock,
clearAll,
};
}
function shortcutEvent(key: string, code: string, options: KeyboardEventInit = {}) {
return new KeyboardEvent("keydown", {
key,
code,
ctrlKey: true,
bubbles: true,
cancelable: true,
...options,
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("installXTerminalKeyboardController IME Backspace routing", () => {
it("runs custom command shortcuts through the sole keyboard handler", () => {
const harness = createHarness("application", "SSH", {
"terminal.commandNav.prev": "ctrl+alt+j",
"terminal.commandNav.next": "ctrl+alt+k",
"terminal.commandNav.select": "ctrl+alt+u",
"terminal.clearAll": "ctrl+alt+y",
});
for (const [key, action] of [
["j", harness.navigateCommand],
["k", harness.navigateCommand],
["u", harness.selectCommandBlock],
["y", harness.clearAll],
] as const) {
const event = shortcutEvent(key, `Key${key.toUpperCase()}`, { altKey: true });
expect(harness.keyHandler(event)).toBe(false);
expect(event.defaultPrevented).toBe(true);
expect(action).toHaveBeenCalled();
}
expect(harness.navigateCommand).toHaveBeenNthCalledWith(1, -1);
expect(harness.navigateCommand).toHaveBeenNthCalledWith(2, 1);
});
it("blocks command shortcuts while locked and retains copy, paste, find and clear", () => {
const locked = createHarness("application", "SSH", {}, { appLocked: true });
expect(
locked.keyHandler(
shortcutEvent("L", "KeyL", { ctrlKey: true, altKey: true, shiftKey: true }),
),
).toBe(false);
expect(locked.clearAll).not.toHaveBeenCalled();
const harness = createHarness("application", "SSH");
expect(harness.keyHandler(shortcutEvent("C", "KeyC", { shiftKey: true }))).toBe(false);
expect(harness.keyHandler(shortcutEvent("V", "KeyV", { shiftKey: true }))).toBe(false);
expect(harness.keyHandler(shortcutEvent("F", "KeyF", { shiftKey: true }))).toBe(false);
expect(harness.keyHandler(shortcutEvent("l", "KeyL"))).toBe(false);
expect(harness.navigateCommand).not.toHaveBeenCalled();
expect(harness.clearAll).not.toHaveBeenCalled();
expect(harness.pasteClipboard).toHaveBeenCalledOnce();
expect(harness.terminal.input).toHaveBeenCalledWith("\x0c", true);
});
it("swallows command navigation while disconnected", () => {
const harness = createHarness("application", "SSH", {}, { disconnected: true });
const event = shortcutEvent("ArrowLeft", "ArrowLeft", { shiftKey: true });
expect(harness.keyHandler(event)).toBe(false);
expect(event.defaultPrevented).toBe(true);
expect(harness.navigateCommand).not.toHaveBeenCalled();
});
it("swallows keyboard input before direct send paths while locked", () => {
const harness = createHarness("application", "Local", {}, { appLocked: true });
const event = backspaceEvent(8);
@@ -69,6 +69,10 @@ interface InstallXTerminalKeyboardControllerParams {
) => void;
syncSuggestionsWithInputState: () => void;
lastSelectionRef: MutableRef<string>;
navigateCommand: (direction: -1 | 1) => void;
selectCommandBlock: () => void;
clearAll: () => void;
resetCommandNavigation: () => void;
}
export function installXTerminalKeyboardController({
@@ -101,6 +105,10 @@ export function installXTerminalKeyboardController({
replaceInputSelection,
syncSuggestionsWithInputState,
lastSelectionRef,
navigateCommand,
selectCommandBlock,
clearAll,
resetCommandNavigation,
}: InstallXTerminalKeyboardControllerParams) {
const inputFromKeyboardController = (data: string) => {
markTerminalUserInput(terminal);
@@ -209,6 +217,51 @@ export function installXTerminalKeyboardController({
return false;
}
if (!e.isComposing && e.keyCode !== 229) {
if (
matchesKeyEvent(resolveShortcutKeys("terminal.commandNav.prev", kb), e)
) {
e.preventDefault();
if (!disconnectedRef.current) navigateCommand(-1);
return false;
}
if (
matchesKeyEvent(resolveShortcutKeys("terminal.commandNav.next", kb), e)
) {
e.preventDefault();
if (!disconnectedRef.current) navigateCommand(1);
return false;
}
if (
matchesKeyEvent(
resolveShortcutKeys("terminal.commandNav.select", kb),
e,
)
) {
e.preventDefault();
if (!disconnectedRef.current) selectCommandBlock();
return false;
}
if (matchesKeyEvent(resolveShortcutKeys("terminal.clearAll", kb), e)) {
e.preventDefault();
if (!disconnectedRef.current) clearAll();
return false;
}
}
if (
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
!e.shiftKey &&
(e.key === "ArrowLeft" ||
e.key === "ArrowRight" ||
e.key === "ArrowUp" ||
e.key === "ArrowDown")
) {
resetCommandNavigation();
}
// Plain Cmd+C: when the application has enabled keyboard reporting modes
// (e.g. kitty keyboard in vim), xterm may consume the key event and the
// browser's native copy event never fires, so copy the live selection
+2 -16
View File
@@ -1,5 +1,4 @@
import { useRef } from "react";
import type { IMarker } from "@xterm/xterm";
export interface ShellIntegrationState {
enabled: boolean;
@@ -11,18 +10,5 @@ export function useShellIntegration() {
enabled: false,
commandRunning: false,
});
const commandMarkersRef = useRef<IMarker[]>([]);
const pushCommandMarker = (marker: IMarker) => {
commandMarkersRef.current.push(marker);
};
const clearCommandMarkers = () => {
for (const marker of commandMarkersRef.current) {
marker.dispose();
}
commandMarkersRef.current = [];
};
return { shellIntegrationRef, commandMarkersRef, pushCommandMarker, clearCommandMarkers };
}
return { shellIntegrationRef };
}
+3 -3
View File
@@ -2477,9 +2477,9 @@
},
"shortcutLabels": {
"closeTab": "Close Active Tab",
"commandNavPrev": "Jump to Previous Command",
"commandNavNext": "Jump to Next Command",
"commandNavSelect": "Select Command Output Block",
"commandNavNext": "Jump to Next Command",
"commandNavPrev": "Jump to Previous Command",
"commandNavSelect": "Select Command Output Block",
"copyFiles": "Copy Files",
"copySelectedSavedConnections": "Copy Selected Saved Connections",
"cutFiles": "Cut Files",
+3 -3
View File
@@ -2475,9 +2475,9 @@
},
"shortcutLabels": {
"closeTab": "활성 탭 닫기",
"commandNavPrev": "이전 명령으로 이동",
"commandNavNext": "다음 명령으로 이동",
"commandNavSelect": "명령 출력 블록 선택",
"commandNavNext": "다음 명령으로 이동",
"commandNavPrev": "이전 명령으로 이동",
"commandNavSelect": "명령 출력 블록 선택",
"copyFiles": "파일 복사",
"copySelectedSavedConnections": "선택한 저장된 연결 복사",
"cutFiles": "파일 잘라내기",
+3 -3
View File
@@ -2475,9 +2475,9 @@
},
"shortcutLabels": {
"closeTab": "关闭当前标签",
"commandNavPrev": "跳到上一个命令",
"commandNavNext": "跳到下一个命令",
"commandNavSelect": "选择命令输出段",
"commandNavNext": "跳到下一个命令",
"commandNavPrev": "跳到上一个命令",
"commandNavSelect": "选择命令输出段",
"copyFiles": "复制文件",
"copySelectedSavedConnections": "复制选中的已保存连接",
"cutFiles": "剪切文件",
+3 -3
View File
@@ -2470,9 +2470,9 @@
},
"shortcutLabels": {
"closeTab": "關閉目前標籤",
"commandNavPrev": "跳到上一個命令",
"commandNavNext": "跳到下一個命令",
"commandNavSelect": "選擇命令輸出段",
"commandNavNext": "跳到下一個命令",
"commandNavPrev": "跳到上一個命令",
"commandNavSelect": "選擇命令輸出段",
"copyFiles": "複製檔案",
"copySelectedSavedConnections": "複製選取的已儲存連線",
"cutFiles": "剪下檔案",
+2 -3
View File
@@ -94,7 +94,7 @@ export const SHORTCUT_REGISTRY: ShortcutDefinition[] = [
id: "terminal.clearAll",
category: "terminal",
labelKey: "terminalCtx.clearAll",
defaultKeys: "ctrl+shift+l, meta+shift+l",
defaultKeys: "ctrl+alt+shift+l, meta+alt+shift+l",
},
{
id: "terminal.commandNav.prev",
@@ -301,8 +301,7 @@ export const SHORTCUT_REGISTRY: ShortcutDefinition[] = [
id: "special.lockScreen",
category: "special",
labelKey: "settings.shortcutLabels.lockScreen",
// Ctrl+Shift+L 让给 terminal.clearAll(全部清除),锁屏挪到 Ctrl+Alt+L
defaultKeys: "ctrl+alt+l, meta+alt+l",
defaultKeys: "ctrl+shift+l, meta+shift+l",
},
];