Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
preferredTerminalEditor,
resolvePathLinkTarget,
} from "../terminal-links";
import { isTerminalClearShortcut } from "../keybindings";
import {
isTerminalClearShortcut,
terminalNavigationShortcutData,
} from "../keybindings";
import {
DEFAULT_THREAD_TERMINAL_HEIGHT,
DEFAULT_THREAD_TERMINAL_ID,
Expand Down Expand Up @@ -152,24 +155,32 @@ function TerminalViewport({
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;

const sendClearShortcut = async () => {
const sendTerminalInput = async (data: string, fallbackError: string) => {
const activeTerminal = terminalRef.current;
if (!activeTerminal) return;
try {
await api.terminal.write({ threadId, terminalId, data: "\u000c" });
await api.terminal.write({ threadId, terminalId, data });
} catch (error) {
writeSystemMessage(
activeTerminal,
error instanceof Error ? error.message : "Failed to clear terminal",
error instanceof Error ? error.message : fallbackError,
);
}
};

terminal.attachCustomKeyEventHandler((event) => {
const navigationData = terminalNavigationShortcutData(event);
if (navigationData !== null) {
event.preventDefault();
event.stopPropagation();
void sendTerminalInput(navigationData, "Failed to move cursor");
return false;
}

if (!isTerminalClearShortcut(event)) return true;
event.preventDefault();
event.stopPropagation();
void sendClearShortcut();
void sendTerminalInput("\u000c", "Failed to clear terminal");
return false;
});

Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
isTerminalSplitShortcut,
isTerminalToggleShortcut,
shortcutLabelForCommand,
terminalNavigationShortcutData,
type ShortcutEventLike,
} from "./keybindings";

Expand Down Expand Up @@ -324,6 +325,65 @@ describe("isTerminalClearShortcut", () => {
it("matches Cmd+K on macOS", () => {
assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel"));
});

it("ignores non-keydown events", () => {
assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux"));
});
});

describe("terminalNavigationShortcutData", () => {
it("maps Option+Arrow on macOS to word movement", () => {
assert.strictEqual(
terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"),
"\u001bb",
);
assert.strictEqual(
terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"),
"\u001bf",
);
});

it("maps Cmd+Arrow on macOS to line movement", () => {
assert.strictEqual(
terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"),
"\u0001",
);
assert.strictEqual(
terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"),
"\u0005",
);
});

it("maps Ctrl+Arrow on non-macOS to word movement", () => {
assert.strictEqual(
terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"),
"\u001bb",
);
assert.strictEqual(
terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"),
"\u001bf",
);
});

it("rejects unsupported combinations", () => {
assert.isNull(
terminalNavigationShortcutData(
event({ key: "ArrowLeft", shiftKey: true, altKey: true }),
"MacIntel",
),
);
assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux"));
assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel"));
});

it("ignores non-keydown events", () => {
assert.isNull(
terminalNavigationShortcutData(
event({ type: "keyup", key: "ArrowLeft", altKey: true }),
"MacIntel",
),
);
});
});

describe("plus key parsing", () => {
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { isMacPlatform } from "./lib/utils";

export interface ShortcutEventLike {
type?: string;
key: string;
metaKey: boolean;
ctrlKey: boolean;
Expand All @@ -25,6 +26,11 @@ interface ShortcutMatchOptions {
context?: Partial<ShortcutMatchContext>;
}

const TERMINAL_WORD_BACKWARD = "\u001bb";
const TERMINAL_WORD_FORWARD = "\u001bf";
const TERMINAL_LINE_START = "\u0001";
const TERMINAL_LINE_END = "\u0005";

function normalizeEventKey(key: string): string {
const normalized = key.toLowerCase();
if (normalized === "esc") return "escape";
Expand Down Expand Up @@ -204,6 +210,10 @@ export function isTerminalClearShortcut(
event: ShortcutEventLike,
platform = navigator.platform,
): boolean {
if (event.type !== undefined && event.type !== "keydown") {
return false;
}

const key = event.key.toLowerCase();

if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
Expand All @@ -219,3 +229,42 @@ export function isTerminalClearShortcut(
!event.shiftKey
);
}

export function terminalNavigationShortcutData(
event: ShortcutEventLike,
platform = navigator.platform,
): string | null {
if (event.type !== undefined && event.type !== "keydown") {
return null;
}

if (event.shiftKey) return null;

const key = normalizeEventKey(event.key);
if (key !== "arrowleft" && key !== "arrowright") {
return null;
}

const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD;
const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END;

if (isMacPlatform(platform)) {
if (event.altKey && !event.metaKey && !event.ctrlKey) {
return moveWord;
}
if (event.metaKey && !event.altKey && !event.ctrlKey) {
return moveLine;
}
return null;
}

if (event.ctrlKey && !event.metaKey && !event.altKey) {
return moveWord;
}

if (event.altKey && !event.metaKey && !event.ctrlKey) {
return moveWord;
}

return null;
}