diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md new file mode 100644 index 0000000000000000000000000000000000000000..ae319a08c0d423abf1a1da23f5d881f68e3bd3ed --- /dev/null +++ b/KEYBINDINGS.md @@ -0,0 +1,85 @@ +# Keybindings + +T3 Code reads keybindings from: + +- `~/.t3/keybindings.json` + +The file must be a JSON array of rules: + +```json +[ + { "key": "mod+j", "command": "terminal.toggle" }, + { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, + { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" } +] +``` + +## Rule Shape + +Each entry supports: + +- `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d` +- `command` (required): action ID +- `when` (optional): boolean expression controlling when the shortcut is active + +Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server. + +## Available Commands + +- `terminal.toggle`: open/close terminal drawer +- `terminal.split`: split terminal (in focused terminal context by default) +- `terminal.new`: create new terminal (in focused terminal context by default) + +## Key Syntax + +Supported modifiers: + +- `mod` (`cmd` on macOS, `ctrl` on non-macOS) +- `cmd` / `meta` +- `ctrl` / `control` +- `shift` +- `alt` / `option` + +Examples: + +- `mod+j` +- `mod+shift+d` +- `ctrl+l` +- `cmd+k` + +## `when` Conditions + +Currently available context keys: + +- `terminalFocus` +- `terminalOpen` + +Supported operators: + +- `!` (not) +- `&&` (and) +- `||` (or) +- parentheses: `(` `)` + +Examples: + +- `"when": "terminalFocus"` +- `"when": "terminalOpen && !terminalFocus"` +- `"when": "terminalFocus || terminalOpen"` + +Unknown condition keys evaluate to `false`. + +## Defaults + +Built-in defaults: + +- `terminal.toggle`: `mod+j` +- `terminal.split`: `mod+d` when `terminalFocus` +- `terminal.new`: `mod+shift+d` when `terminalFocus` + +If you define any rules for a command, the default rule for that command is removed. + +## Precedence + +- Rules are evaluated in array order. +- Later matching rules win over earlier matching rules for the same command. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index baf70eaca2b367a99d7f2b3c218578f3ba1650c2..288de25288a889a92e1e840915f58af25a29de09 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -307,7 +307,151 @@ describe("WebSocket Server", () => { const response = await sendRequest(ws, WS_METHODS.serverGetConfig); expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ cwd: "/my/workspace" }); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [], + }); + }); + + it("reads keybindings from ~/.t3/keybindings.json", async () => { + const fakeHome = makeTempDir("t3code-home-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "keybindings.json"), + JSON.stringify([ + { key: "cmd+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + ]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [ + { key: "cmd+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + ], + }); + }); + + it("warns and ignores invalid keybinding entries", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fakeHome = makeTempDir("t3code-home-invalid-entry-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "keybindings.json"), + JSON.stringify([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+z", command: "invalid.command", when: "terminalFocus" }, + ]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [{ key: "mod+j", command: "terminal.toggle" }], + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes("ignoring invalid keybinding entries"), + ), + ).toBe(true); + }); + + it("warns and ignores unsupported keybindings config format", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fakeHome = makeTempDir("t3code-home-unsupported-format-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, "keybindings.json"), + JSON.stringify({ "terminal.toggle": "mod+j" }), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [], + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes("unsupported format; expected array"), + ), + ).toBe(true); + }); + + it("warns and ignores malformed keybindings config files", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fakeHome = makeTempDir("t3code-home-invalid-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindings: [], + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes("ignoring malformed keybindings config"), + ), + ).toBe(true); }); it("returns error for unknown methods", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 6b12b13917b164fbc128fc6dcd1b7018b4484df4..ddd4d53e765e4ce8aece7c43b3177796443d0172 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -9,6 +9,8 @@ import { EDITORS, WS_CHANNELS, WS_METHODS, + keybindingRuleSchema, + type KeybindingsConfig, type TerminalEvent, type WsPush, type WsRequest, @@ -68,6 +70,55 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { return undefined; } +function readKeybindingsConfig(logger: ReturnType): KeybindingsConfig { + const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + logger.warn("ignoring keybindings config with unsupported format; expected array", { + path: configPath, + }); + return []; + } + + const sanitized: KeybindingsConfig = []; + let invalidEntries = 0; + for (const entry of parsed) { + const result = keybindingRuleSchema.safeParse(entry); + if (result.success) { + sanitized.push(result.data); + if (sanitized.length >= 256) { + logger.warn("truncating keybindings config to max entries", { + path: configPath, + maxEntries: 256, + }); + break; + } + continue; + } + invalidEntries += 1; + } + if (invalidEntries > 0) { + logger.warn("ignoring invalid keybinding entries", { + path: configPath, + invalidEntries, + totalEntries: parsed.length, + }); + } + return sanitized; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return []; + } + logger.warn("ignoring malformed keybindings config", { + path: configPath, + error: error instanceof Error ? error.message : String(error), + }); + } + return []; +} + export function createServer(options: ServerOptions) { const { port, @@ -410,7 +461,10 @@ export function createServer(options: ServerOptions) { return undefined; case WS_METHODS.serverGetConfig: - return { cwd }; + return { + cwd, + keybindings: readKeybindingsConfig(logger), + }; default: throw new Error(`Unknown method: ${request.method}`); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f9649d2f9f7221cd08f994ce774fdc43e28a3e82..dd3667a60b05f7fe8aa597f587bf68fb1a1892fc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,10 +1,12 @@ import { + EDITORS, + type EditorId, + ModelSlug, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - type ProviderSendTurnAttachmentInput, type ProviderApprovalDecision, - ModelSlug, + type ProviderSendTurnAttachmentInput, } from "@t3tools/contracts"; import { type ClipboardEvent, @@ -25,7 +27,6 @@ import { gitCreateWorktreeMutationOptions, } from "~/lib/gitReactQuery"; -import { EDITORS, type EditorId } from "@t3tools/contracts"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; import { @@ -50,7 +51,14 @@ import { useStore } from "../store"; import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; -import { isTerminalToggleShortcut } from "../terminal-shortcuts"; +import { + DEFAULT_TERMINAL_KEYBINDINGS, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, + resolveTerminalKeybindings, + shortcutLabelForCommand, +} from "../terminal-shortcuts"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; @@ -144,6 +152,7 @@ export default function ChatView() { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS); const messagesScrollRef = useRef(null); const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); @@ -256,12 +265,45 @@ export default function ChatView() { const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; + const splitTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"), + [terminalKeybindings], + ); + const newTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"), + [terminalKeybindings], + ); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); + + useEffect(() => { + if (!api || !api.server || typeof api.server.getConfig !== "function") { + setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); + return; + } + + let disposed = false; + + void api.server + .getConfig() + .then((config) => { + if (disposed) return; + setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings)); + }) + .catch(() => { + if (disposed) return; + setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); + }); + + return () => { + disposed = true; + }; + }, [api]); + const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { if (!image.previewUrl) continue; @@ -490,15 +532,64 @@ export default function ChatView() { }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); useEffect(() => { + const isTerminalFocused = (): boolean => { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; + return activeElement.closest(".thread-terminal-drawer .xterm") !== null; + }; + const handler = (event: globalThis.KeyboardEvent) => { - if (!activeThreadId) return; - if (!isTerminalToggleShortcut(event)) return; + if (!activeThreadId || event.defaultPrevented) return; + const shortcutContext = { + terminalFocus: isTerminalFocused(), + terminalOpen: Boolean(activeThread?.terminalOpen), + }; + + if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + toggleTerminalVisibility(); + return; + } + + if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) { + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open: true, + }); + } + splitTerminal(); + return; + } + + if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return; event.preventDefault(); - toggleTerminalVisibility(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) { + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open: true, + }); + } + createNewTerminal(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [activeThreadId, toggleTerminalVisibility]); + }, [ + activeThread?.terminalOpen, + activeThreadId, + createNewTerminal, + dispatch, + splitTerminal, + terminalKeybindings, + toggleTerminalVisibility, + ]); const setThreadError = (threadId: string | null, error: string | null) => { if (!threadId) return; @@ -1350,6 +1441,8 @@ export default function ChatView() { focusRequestId={terminalFocusRequestId} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + newShortcutLabel={newTerminalShortcutLabel ?? undefined} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={(height) => diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index eaa8b97fdc4a1df39362ebbc90dda0b8dd434544..3373f913662c1ce58f88da2abbbd17fe6e700aec 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -416,6 +416,8 @@ interface ThreadTerminalDrawerProps { focusRequestId: number; onSplitTerminal: () => void; onNewTerminal: () => void; + splitShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; @@ -468,6 +470,8 @@ export default function ThreadTerminalDrawer({ focusRequestId, onSplitTerminal, onNewTerminal, + splitShortcutLabel, + newShortcutLabel, onActiveTerminalChange, onCloseTerminal, onHeightChange, @@ -694,7 +698,7 @@ export default function ThreadTerminalDrawer({ @@ -702,7 +706,7 @@ export default function ThreadTerminalDrawer({ @@ -779,14 +783,20 @@ export default function ThreadTerminalDrawer({ diff --git a/apps/web/src/terminal-shortcuts.test.ts b/apps/web/src/terminal-shortcuts.test.ts index bbc13f068d5a12aeebc1619875421abf31d27708..6de3d641a8e070fb855d1d674e54642f1782b187 100644 --- a/apps/web/src/terminal-shortcuts.test.ts +++ b/apps/web/src/terminal-shortcuts.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_TERMINAL_KEYBINDINGS, + formatShortcutLabel, isTerminalClearShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, isTerminalToggleShortcut, + resolveTerminalKeybindings, + shortcutLabelForCommand, type ShortcutEventLike, } from "./terminal-shortcuts"; @@ -19,22 +25,140 @@ function event(overrides: Partial = {}): ShortcutEventLike { describe("isTerminalToggleShortcut", () => { it("matches Cmd+J on macOS", () => { - expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); + expect( + isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + }), + ).toBe(true); }); it("matches Ctrl+J on non-macOS", () => { - expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32")).toBe(true); + expect( + isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "Win32", + }), + ).toBe(true); }); +}); - it("rejects wrong modifiers", () => { - expect(isTerminalToggleShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false); - expect(isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel")).toBe( - false, - ); +describe("split/new terminal shortcuts", () => { + it("requires terminalFocus for default split/new bindings", () => { + expect( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ).toBe(false); + expect( + isTerminalNewShortcut( + event({ key: "d", ctrlKey: true, shiftKey: true }), + DEFAULT_TERMINAL_KEYBINDINGS, + { + platform: "Linux", + context: { terminalFocus: false }, + }, + ), + ).toBe(false); }); - it("rejects non-j keys", () => { - expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false); + it("matches split/new when terminalFocus is true", () => { + expect( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ).toBe(true); + expect( + isTerminalNewShortcut( + event({ key: "d", ctrlKey: true, shiftKey: true }), + DEFAULT_TERMINAL_KEYBINDINGS, + { + platform: "Linux", + context: { terminalFocus: true }, + }, + ), + ).toBe(true); + }); + + it("supports configured overrides with when expressions", () => { + const keybindings = resolveTerminalKeybindings([ + { + key: "mod+\\", + command: "terminal.split", + when: "terminalOpen && !terminalFocus", + }, + { + key: "mod+shift+n", + command: "terminal.new", + when: "terminalOpen && !terminalFocus", + }, + ]); + expect( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ).toBe(true); + expect( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: false, terminalFocus: false }, + }), + ).toBe(false); + expect( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ).toBe(true); + }); +}); + +describe("resolveTerminalKeybindings", () => { + it("uses defaults when config is empty", () => { + expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); + }); + + it("replaces defaults for commands configured by user", () => { + const resolved = resolveTerminalKeybindings([ + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + ]); + + expect(resolved).toEqual([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + ]); + }); + + it("ignores invalid configured values", () => { + expect( + resolveTerminalKeybindings([ + { key: " ", command: "terminal.toggle" }, + { key: "mod+d+f", command: "terminal.split" }, + ]), + ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); + }); +}); + +describe("shortcutLabelForCommand", () => { + it("returns the most recent binding label", () => { + const resolved = resolveTerminalKeybindings([ + { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, + ]); + + expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); + }); +}); + +describe("formatShortcutLabel", () => { + it("formats labels for macOS", () => { + expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D"); + }); + + it("formats labels for non-macOS", () => { + expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D"); }); }); @@ -47,17 +171,4 @@ describe("isTerminalClearShortcut", () => { it("matches Cmd+K on macOS", () => { expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); }); - - it("rejects Cmd+K on non-macOS", () => { - expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32")).toBe(false); - }); - - it("rejects wrong modifiers", () => { - expect( - isTerminalClearShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), "Linux"), - ).toBe(false); - expect( - isTerminalClearShortcut(event({ key: "k", metaKey: true, altKey: true }), "MacIntel"), - ).toBe(false); - }); }); diff --git a/apps/web/src/terminal-shortcuts.ts b/apps/web/src/terminal-shortcuts.ts index bc76a145ffc4a0fb8fd5bbb5c6edcef210a9f067..c876f8bc81c56fa46448db2fbd6387117e993b9b 100644 --- a/apps/web/src/terminal-shortcuts.ts +++ b/apps/web/src/terminal-shortcuts.ts @@ -1,3 +1,5 @@ +import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; + export interface ShortcutEventLike { key: string; metaKey: boolean; @@ -6,23 +8,436 @@ export interface ShortcutEventLike { altKey: boolean; } +export interface ShortcutMatchContext { + terminalFocus: boolean; + terminalOpen: boolean; + [key: string]: boolean; +} + +export interface ResolvedTerminalKeybinding { + key: string; + command: KeybindingCommand; + when?: string | undefined; +} + +export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; + +export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, +]; + +interface ParsedShortcut { + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + modKey: boolean; +} + +interface ShortcutMatchOptions { + platform?: string; + context?: Partial; +} + +type WhenNode = + | { type: "identifier"; name: string } + | { type: "not"; node: WhenNode } + | { type: "and"; left: WhenNode; right: WhenNode } + | { type: "or"; left: WhenNode; right: WhenNode }; + +type WhenToken = + | { type: "identifier"; value: string } + | { type: "not" } + | { type: "and" } + | { type: "or" } + | { type: "lparen" } + | { type: "rparen" }; + +const WHEN_AST_CACHE = new Map(); + function isMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } +function normalizeKeyToken(token: string): string { + if (token === "space") return " "; + if (token === "esc") return "escape"; + return token; +} + +function parseShortcutValue(value: string): ParsedShortcut | null { + const tokens = value + .toLowerCase() + .split("+") + .map((token) => token.trim()) + .filter((token) => token.length > 0); + if (tokens.length === 0) return null; + + let key: string | null = null; + let metaKey = false; + let ctrlKey = false; + let shiftKey = false; + let altKey = false; + let modKey = false; + + for (const token of tokens) { + switch (token) { + case "cmd": + case "meta": + metaKey = true; + break; + case "ctrl": + case "control": + ctrlKey = true; + break; + case "shift": + shiftKey = true; + break; + case "alt": + case "option": + altKey = true; + break; + case "mod": + modKey = true; + break; + default: { + if (key !== null) { + return null; + } + key = normalizeKeyToken(token); + } + } + } + + if (key === null) return null; + + return { + key, + metaKey, + ctrlKey, + shiftKey, + altKey, + modKey, + }; +} + +function normalizeEventKey(key: string): string { + const normalized = key.toLowerCase(); + if (normalized === "esc") return "escape"; + return normalized; +} + +function matchesShortcutValue( + event: ShortcutEventLike, + shortcutValue: string, + platform = navigator.platform, +): boolean { + const parsed = parseShortcutValue(shortcutValue); + if (!parsed) return false; + + const key = normalizeEventKey(event.key); + if (key !== parsed.key) return false; + + const useMetaForMod = isMacPlatform(platform); + const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); + const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); + return ( + event.metaKey === expectedMeta && + event.ctrlKey === expectedCtrl && + event.shiftKey === parsed.shiftKey && + event.altKey === parsed.altKey + ); +} + +function tokenizeWhenExpression(expression: string): WhenToken[] | null { + const tokens: WhenToken[] = []; + let index = 0; + + while (index < expression.length) { + const current = expression[index]; + if (!current) break; + + if (/\s/.test(current)) { + index += 1; + continue; + } + if (expression.startsWith("&&", index)) { + tokens.push({ type: "and" }); + index += 2; + continue; + } + if (expression.startsWith("||", index)) { + tokens.push({ type: "or" }); + index += 2; + continue; + } + if (current === "!") { + tokens.push({ type: "not" }); + index += 1; + continue; + } + if (current === "(") { + tokens.push({ type: "lparen" }); + index += 1; + continue; + } + if (current === ")") { + tokens.push({ type: "rparen" }); + index += 1; + continue; + } + + const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); + if (!identifier) { + return null; + } + tokens.push({ type: "identifier", value: identifier[0] }); + index += identifier[0].length; + } + + return tokens; +} + +function parseWhenExpression(expression: string): WhenNode | null { + const tokens = tokenizeWhenExpression(expression); + if (!tokens || tokens.length === 0) return null; + let index = 0; + + const parsePrimary = (): WhenNode | null => { + const token = tokens[index]; + if (!token) return null; + + if (token.type === "identifier") { + index += 1; + return { type: "identifier", name: token.value }; + } + + if (token.type === "lparen") { + index += 1; + const expressionNode = parseOr(); + const closeToken = tokens[index]; + if (!expressionNode || !closeToken || closeToken.type !== "rparen") { + return null; + } + index += 1; + return expressionNode; + } + + return null; + }; + + const parseUnary = (): WhenNode | null => { + const token = tokens[index]; + if (token?.type === "not") { + index += 1; + const node = parseUnary(); + if (!node) return null; + return { type: "not", node }; + } + return parsePrimary(); + }; + + const parseAnd = (): WhenNode | null => { + let left = parseUnary(); + if (!left) return null; + + while (tokens[index]?.type === "and") { + index += 1; + const right = parseUnary(); + if (!right) return null; + left = { type: "and", left, right }; + } + + return left; + }; + + const parseOr = (): WhenNode | null => { + let left = parseAnd(); + if (!left) return null; + + while (tokens[index]?.type === "or") { + index += 1; + const right = parseAnd(); + if (!right) return null; + left = { type: "or", left, right }; + } + + return left; + }; + + const ast = parseOr(); + if (!ast || index !== tokens.length) return null; + return ast; +} + +function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean { + switch (node.type) { + case "identifier": + return Boolean(context[node.name]); + case "not": + return !evaluateWhenNode(node.node, context); + case "and": + return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); + case "or": + return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); + } +} + +function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { + if (!when) return true; + const normalized = when.trim(); + if (normalized.length === 0) return true; + + if (!WHEN_AST_CACHE.has(normalized)) { + WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); + } + const ast = WHEN_AST_CACHE.get(normalized); + if (!ast) return false; + return evaluateWhenNode(ast, context); +} + +function resolvePlatform(options: ShortcutMatchOptions | undefined): string { + return options?.platform ?? navigator.platform; +} + +function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { + return { + terminalFocus: false, + terminalOpen: false, + ...options?.context, + }; +} + +function normalizeConfiguredKeybinding( + keybinding: KeybindingsConfig[number], +): ResolvedTerminalKeybinding | null { + const key = keybinding.key.trim(); + if (key.length === 0) return null; + if (!parseShortcutValue(key)) return null; + + const when = keybinding.when?.trim(); + return { + key, + command: keybinding.command, + ...(when ? { when } : {}), + }; +} + +function matchesCommandShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): boolean { + const platform = resolvePlatform(options); + const context = resolveContext(options); + + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + if (!matchesWhenExpression(binding.when, context)) continue; + if (!matchesShortcutValue(event, binding.key, platform)) continue; + return true; + } + return false; +} + +export function resolveTerminalKeybindings( + input: KeybindingsConfig | null | undefined, +): ResolvedTerminalKeybindings { + if (!input || input.length === 0) { + return [...DEFAULT_TERMINAL_KEYBINDINGS]; + } + + const normalizedInput = input + .map((binding) => normalizeConfiguredKeybinding(binding)) + .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); + if (normalizedInput.length === 0) { + return [...DEFAULT_TERMINAL_KEYBINDINGS]; + } + + const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + return [...retainedDefaults, ...normalizedInput]; +} + +function formatShortcutKeyLabel(key: string): string { + if (key === " ") return "Space"; + if (key.length === 1) return key.toUpperCase(); + if (key === "escape") return "Esc"; + if (key === "arrowup") return "Up"; + if (key === "arrowdown") return "Down"; + if (key === "arrowleft") return "Left"; + if (key === "arrowright") return "Right"; + return key.slice(0, 1).toUpperCase() + key.slice(1); +} + +export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string { + const parsed = parseShortcutValue(shortcutValue); + if (!parsed) { + return shortcutValue; + } + + const keyLabel = formatShortcutKeyLabel(parsed.key); + const useMetaForMod = isMacPlatform(platform); + const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); + const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); + const showAlt = parsed.altKey; + const showShift = parsed.shiftKey; + + if (useMetaForMod) { + return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; + } + + const parts: string[] = []; + if (showCtrl) parts.push("Ctrl"); + if (showAlt) parts.push("Alt"); + if (showShift) parts.push("Shift"); + if (showMeta) parts.push("Meta"); + parts.push(keyLabel); + return parts.join("+"); +} + +export function shortcutLabelForCommand( + keybindings: ResolvedTerminalKeybindings, + command: KeybindingCommand, + platform = navigator.platform, +): string | null { + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + return formatShortcutLabel(binding.key, platform); + } + return null; +} + export function isTerminalToggleShortcut( event: ShortcutEventLike, - platform = navigator.platform, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, ): boolean { - const key = event.key.toLowerCase(); - if (key !== "j") return false; - if (event.shiftKey || event.altKey) return false; + return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); +} - if (isMacPlatform(platform)) { - return event.metaKey && !event.ctrlKey; - } +export function isTerminalSplitShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.split", options); +} - return event.ctrlKey && !event.metaKey; +export function isTerminalNewShortcut( + event: ShortcutEventLike, + keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.new", options); } export function isTerminalClearShortcut( diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 57c381f2245adf4d93b762043acf4dc1ee039c95..e444cec0b9f1f40db101b8133094b72830d84ed8 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi { return showContextMenuFallback(items, position); }, }, + server: { + getConfig: () => transport.request(WS_METHODS.serverGetConfig), + }, }; instance = { api, transport }; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 430f4fd84077d4e439b8f5165571e6be7c87baa3..f8f6d534028e48f720ac71c2b24abb4d43c57025 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,5 +5,6 @@ export * from "./terminal"; export * from "./provider"; export * from "./model"; export * from "./ws"; +export * from "./server"; export * from "./project"; export * from "./git"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4216172e7d821ba624def10bafa27ad983ff5714..3c1f8c265a0cf72eae3f7f61c6f61da4c3815fcd 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -31,6 +31,7 @@ import type { ProjectListResult, ProjectRemoveInput, } from "./project"; +import type { ServerConfig } from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -113,4 +114,7 @@ export interface NativeApi { position?: { x: number; y: number }, ) => Promise; }; + server: { + getConfig: () => Promise; + }; } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6c51dd28e699209afc67adbe13dd1966996299a --- /dev/null +++ b/packages/contracts/src/server.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const keybindingCommandSchema = z.enum([ + "terminal.toggle", + "terminal.split", + "terminal.new", +]); + +const keybindingValueSchema = z.string().trim().min(1).max(64); +const keybindingWhenSchema = z.string().trim().min(1).max(256); + +export const keybindingRuleSchema = z.object({ + key: keybindingValueSchema, + command: keybindingCommandSchema, + when: keybindingWhenSchema.optional(), +}); + +export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); + +export const serverConfigSchema = z.object({ + cwd: z.string().min(1), + keybindings: keybindingsConfigSchema.default([]), +}); + +export type KeybindingCommand = z.infer; +export type KeybindingRule = z.infer; +export type KeybindingsConfig = z.infer; +export type ServerConfig = z.infer;