fix(terminal): refine command navigation shortcuts
This commit is contained in:
1 parent
2b4c90ff3e
commit
dd3769cb81
12 files changed
+512
-302
No files matched your search
@@ -39,5 +39,3 @@ temp/
|
||||
.claude
|
||||
.codex
|
||||
.trae
|
||||
.history
|
||||
temporary
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -2475,9 +2475,9 @@
|
||||
},
|
||||
"shortcutLabels": {
|
||||
"closeTab": "활성 탭 닫기",
|
||||
"commandNavPrev": "이전 명령으로 이동",
|
||||
"commandNavNext": "다음 명령으로 이동",
|
||||
"commandNavSelect": "명령 출력 블록 선택",
|
||||
"commandNavNext": "다음 명령으로 이동",
|
||||
"commandNavPrev": "이전 명령으로 이동",
|
||||
"commandNavSelect": "명령 출력 블록 선택",
|
||||
"copyFiles": "파일 복사",
|
||||
"copySelectedSavedConnections": "선택한 저장된 연결 복사",
|
||||
"cutFiles": "파일 잘라내기",
|
||||
|
||||
@@ -2475,9 +2475,9 @@
|
||||
},
|
||||
"shortcutLabels": {
|
||||
"closeTab": "关闭当前标签",
|
||||
"commandNavPrev": "跳到上一个命令",
|
||||
"commandNavNext": "跳到下一个命令",
|
||||
"commandNavSelect": "选择命令输出段",
|
||||
"commandNavNext": "跳到下一个命令",
|
||||
"commandNavPrev": "跳到上一个命令",
|
||||
"commandNavSelect": "选择命令输出段",
|
||||
"copyFiles": "复制文件",
|
||||
"copySelectedSavedConnections": "复制选中的已保存连接",
|
||||
"cutFiles": "剪切文件",
|
||||
|
||||
@@ -2470,9 +2470,9 @@
|
||||
},
|
||||
"shortcutLabels": {
|
||||
"closeTab": "關閉目前標籤",
|
||||
"commandNavPrev": "跳到上一個命令",
|
||||
"commandNavNext": "跳到下一個命令",
|
||||
"commandNavSelect": "選擇命令輸出段",
|
||||
"commandNavNext": "跳到下一個命令",
|
||||
"commandNavPrev": "跳到上一個命令",
|
||||
"commandNavSelect": "選擇命令輸出段",
|
||||
"copyFiles": "複製檔案",
|
||||
"copySelectedSavedConnections": "複製選取的已儲存連線",
|
||||
"cutFiles": "剪下檔案",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user