| author | |
| committer | |
| log | 645b037c9e994ca408bec4ab658db2966eac7a25 |
| tree | 864fdb76b35b891748fa756b9518c6c41492c6c8 |
| parent | 7fc53dadc7d66483cdeac70f233059490785c9e2 |
- extend `server.getConfig` to return sanitized keybinding rules from `~/.t3/keybindings.json`
- apply resolved bindings in the web app for toggle/split/new terminal shortcuts and labels
- add contracts/docs and tests for config parsing, validation, and shortcut behavior11 files changed, 991 insertions(+), 43 deletions(-)
KEYBINDINGS.md created+85| ... | @@ -0,0 +1,85 @@ | ||
| 1 | # Keybindings | ||
| 2 | |||
| 3 | T3 Code reads keybindings from: | ||
| 4 | |||
| 5 | - `~/.t3/keybindings.json` | ||
| 6 | |||
| 7 | The file must be a JSON array of rules: | ||
| 8 | |||
| 9 | ```json | ||
| 10 | [ | ||
| 11 | { "key": "mod+j", "command": "terminal.toggle" }, | ||
| 12 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, | ||
| 13 | { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" } | ||
| 14 | ] | ||
| 15 | ``` | ||
| 16 | |||
| 17 | ## Rule Shape | ||
| 18 | |||
| 19 | Each entry supports: | ||
| 20 | |||
| 21 | - `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d` | ||
| 22 | - `command` (required): action ID | ||
| 23 | - `when` (optional): boolean expression controlling when the shortcut is active | ||
| 24 | |||
| 25 | Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server. | ||
| 26 | |||
| 27 | ## Available Commands | ||
| 28 | |||
| 29 | - `terminal.toggle`: open/close terminal drawer | ||
| 30 | - `terminal.split`: split terminal (in focused terminal context by default) | ||
| 31 | - `terminal.new`: create new terminal (in focused terminal context by default) | ||
| 32 | |||
| 33 | ## Key Syntax | ||
| 34 | |||
| 35 | Supported modifiers: | ||
| 36 | |||
| 37 | - `mod` (`cmd` on macOS, `ctrl` on non-macOS) | ||
| 38 | - `cmd` / `meta` | ||
| 39 | - `ctrl` / `control` | ||
| 40 | - `shift` | ||
| 41 | - `alt` / `option` | ||
| 42 | |||
| 43 | Examples: | ||
| 44 | |||
| 45 | - `mod+j` | ||
| 46 | - `mod+shift+d` | ||
| 47 | - `ctrl+l` | ||
| 48 | - `cmd+k` | ||
| 49 | |||
| 50 | ## `when` Conditions | ||
| 51 | |||
| 52 | Currently available context keys: | ||
| 53 | |||
| 54 | - `terminalFocus` | ||
| 55 | - `terminalOpen` | ||
| 56 | |||
| 57 | Supported operators: | ||
| 58 | |||
| 59 | - `!` (not) | ||
| 60 | - `&&` (and) | ||
| 61 | - `||` (or) | ||
| 62 | - parentheses: `(` `)` | ||
| 63 | |||
| 64 | Examples: | ||
| 65 | |||
| 66 | - `"when": "terminalFocus"` | ||
| 67 | - `"when": "terminalOpen && !terminalFocus"` | ||
| 68 | - `"when": "terminalFocus || terminalOpen"` | ||
| 69 | |||
| 70 | Unknown condition keys evaluate to `false`. | ||
| 71 | |||
| 72 | ## Defaults | ||
| 73 | |||
| 74 | Built-in defaults: | ||
| 75 | |||
| 76 | - `terminal.toggle`: `mod+j` | ||
| 77 | - `terminal.split`: `mod+d` when `terminalFocus` | ||
| 78 | - `terminal.new`: `mod+shift+d` when `terminalFocus` | ||
| 79 | |||
| 80 | If you define any rules for a command, the default rule for that command is removed. | ||
| 81 | |||
| 82 | ## Precedence | ||
| 83 | |||
| 84 | - Rules are evaluated in array order. | ||
| 85 | - Later matching rules win over earlier matching rules for the same command. | ||
apps/server/src/wsServer.test.ts+145-1| ... | @@ -307,7 +307,151 @@ describe("WebSocket Server", () => { | ... | @@ -307,7 +307,151 @@ describe("WebSocket Server", () => { |
| 307 | 307 | ||
| 308 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 308 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 309 | expect(response.error).toBeUndefined(); | 309 | expect(response.error).toBeUndefined(); |
| 310 | expect(response.result).toEqual({ cwd: "/my/workspace" }); | 310 | expect(response.result).toEqual({ |
| 311 | cwd: "/my/workspace", | ||
| 312 | keybindings: [], | ||
| 313 | }); | ||
| 314 | }); | ||
| 315 | |||
| 316 | it("reads keybindings from ~/.t3/keybindings.json", async () => { | ||
| 317 | const fakeHome = makeTempDir("t3code-home-"); | ||
| 318 | const configDir = path.join(fakeHome, ".t3"); | ||
| 319 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 320 | fs.writeFileSync( | ||
| 321 | path.join(configDir, "keybindings.json"), | ||
| 322 | JSON.stringify([ | ||
| 323 | { key: "cmd+j", command: "terminal.toggle" }, | ||
| 324 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 325 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 326 | ]), | ||
| 327 | "utf8", | ||
| 328 | ); | ||
| 329 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 330 | |||
| 331 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 332 | await server.start(); | ||
| 333 | const addr = server.httpServer.address(); | ||
| 334 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 335 | |||
| 336 | const ws = await connectWs(port); | ||
| 337 | connections.push(ws); | ||
| 338 | |||
| 339 | await waitForMessage(ws); | ||
| 340 | |||
| 341 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 342 | expect(response.error).toBeUndefined(); | ||
| 343 | expect(response.result).toEqual({ | ||
| 344 | cwd: "/my/workspace", | ||
| 345 | keybindings: [ | ||
| 346 | { key: "cmd+j", command: "terminal.toggle" }, | ||
| 347 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 348 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 349 | ], | ||
| 350 | }); | ||
| 351 | }); | ||
| 352 | |||
| 353 | it("warns and ignores invalid keybinding entries", async () => { | ||
| 354 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 355 | const fakeHome = makeTempDir("t3code-home-invalid-entry-"); | ||
| 356 | const configDir = path.join(fakeHome, ".t3"); | ||
| 357 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 358 | fs.writeFileSync( | ||
| 359 | path.join(configDir, "keybindings.json"), | ||
| 360 | JSON.stringify([ | ||
| 361 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 362 | { key: "mod+z", command: "invalid.command", when: "terminalFocus" }, | ||
| 363 | ]), | ||
| 364 | "utf8", | ||
| 365 | ); | ||
| 366 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 367 | |||
| 368 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 369 | await server.start(); | ||
| 370 | const addr = server.httpServer.address(); | ||
| 371 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 372 | |||
| 373 | const ws = await connectWs(port); | ||
| 374 | connections.push(ws); | ||
| 375 | |||
| 376 | await waitForMessage(ws); | ||
| 377 | |||
| 378 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 379 | expect(response.error).toBeUndefined(); | ||
| 380 | expect(response.result).toEqual({ | ||
| 381 | cwd: "/my/workspace", | ||
| 382 | keybindings: [{ key: "mod+j", command: "terminal.toggle" }], | ||
| 383 | }); | ||
| 384 | expect( | ||
| 385 | warnSpy.mock.calls.some(([message]) => | ||
| 386 | String(message).includes("ignoring invalid keybinding entries"), | ||
| 387 | ), | ||
| 388 | ).toBe(true); | ||
| 389 | }); | ||
| 390 | |||
| 391 | it("warns and ignores unsupported keybindings config format", async () => { | ||
| 392 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 393 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); | ||
| 394 | const configDir = path.join(fakeHome, ".t3"); | ||
| 395 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 396 | fs.writeFileSync( | ||
| 397 | path.join(configDir, "keybindings.json"), | ||
| 398 | JSON.stringify({ "terminal.toggle": "mod+j" }), | ||
| 399 | "utf8", | ||
| 400 | ); | ||
| 401 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 402 | |||
| 403 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 404 | await server.start(); | ||
| 405 | const addr = server.httpServer.address(); | ||
| 406 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 407 | |||
| 408 | const ws = await connectWs(port); | ||
| 409 | connections.push(ws); | ||
| 410 | |||
| 411 | await waitForMessage(ws); | ||
| 412 | |||
| 413 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 414 | expect(response.error).toBeUndefined(); | ||
| 415 | expect(response.result).toEqual({ | ||
| 416 | cwd: "/my/workspace", | ||
| 417 | keybindings: [], | ||
| 418 | }); | ||
| 419 | expect( | ||
| 420 | warnSpy.mock.calls.some(([message]) => | ||
| 421 | String(message).includes("unsupported format; expected array"), | ||
| 422 | ), | ||
| 423 | ).toBe(true); | ||
| 424 | }); | ||
| 425 | |||
| 426 | it("warns and ignores malformed keybindings config files", async () => { | ||
| 427 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 428 | const fakeHome = makeTempDir("t3code-home-invalid-"); | ||
| 429 | const configDir = path.join(fakeHome, ".t3"); | ||
| 430 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 431 | fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8"); | ||
| 432 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 433 | |||
| 434 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 435 | await server.start(); | ||
| 436 | const addr = server.httpServer.address(); | ||
| 437 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 438 | |||
| 439 | const ws = await connectWs(port); | ||
| 440 | connections.push(ws); | ||
| 441 | |||
| 442 | await waitForMessage(ws); | ||
| 443 | |||
| 444 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 445 | expect(response.error).toBeUndefined(); | ||
| 446 | expect(response.result).toEqual({ | ||
| 447 | cwd: "/my/workspace", | ||
| 448 | keybindings: [], | ||
| 449 | }); | ||
| 450 | expect( | ||
| 451 | warnSpy.mock.calls.some(([message]) => | ||
| 452 | String(message).includes("ignoring malformed keybindings config"), | ||
| 453 | ), | ||
| 454 | ).toBe(true); | ||
| 311 | }); | 455 | }); |
| 312 | 456 | ||
| 313 | it("returns error for unknown methods", async () => { | 457 | it("returns error for unknown methods", async () => { |
apps/server/src/wsServer.ts+55-1| ... | @@ -9,6 +9,8 @@ import { | ... | @@ -9,6 +9,8 @@ import { |
| 9 | EDITORS, | 9 | EDITORS, |
| 10 | WS_CHANNELS, | 10 | WS_CHANNELS, |
| 11 | WS_METHODS, | 11 | WS_METHODS, |
| 12 | keybindingRuleSchema, | ||
| 13 | type KeybindingsConfig, | ||
| 12 | type TerminalEvent, | 14 | type TerminalEvent, |
| 13 | type WsPush, | 15 | type WsPush, |
| 14 | type WsRequest, | 16 | type WsRequest, |
| ... | @@ -68,6 +70,55 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { | ... | @@ -68,6 +70,55 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 68 | return undefined; | 70 | return undefined; |
| 69 | } | 71 | } |
| 70 | 72 | ||
| 73 | function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): KeybindingsConfig { | ||
| 74 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | ||
| 75 | try { | ||
| 76 | const raw = fs.readFileSync(configPath, "utf8"); | ||
| 77 | const parsed = JSON.parse(raw) as unknown; | ||
| 78 | if (!Array.isArray(parsed)) { | ||
| 79 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | ||
| 80 | path: configPath, | ||
| 81 | }); | ||
| 82 | return []; | ||
| 83 | } | ||
| 84 | |||
| 85 | const sanitized: KeybindingsConfig = []; | ||
| 86 | let invalidEntries = 0; | ||
| 87 | for (const entry of parsed) { | ||
| 88 | const result = keybindingRuleSchema.safeParse(entry); | ||
| 89 | if (result.success) { | ||
| 90 | sanitized.push(result.data); | ||
| 91 | if (sanitized.length >= 256) { | ||
| 92 | logger.warn("truncating keybindings config to max entries", { | ||
| 93 | path: configPath, | ||
| 94 | maxEntries: 256, | ||
| 95 | }); | ||
| 96 | break; | ||
| 97 | } | ||
| 98 | continue; | ||
| 99 | } | ||
| 100 | invalidEntries += 1; | ||
| 101 | } | ||
| 102 | if (invalidEntries > 0) { | ||
| 103 | logger.warn("ignoring invalid keybinding entries", { | ||
| 104 | path: configPath, | ||
| 105 | invalidEntries, | ||
| 106 | totalEntries: parsed.length, | ||
| 107 | }); | ||
| 108 | } | ||
| 109 | return sanitized; | ||
| 110 | } catch (error) { | ||
| 111 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | ||
| 112 | return []; | ||
| 113 | } | ||
| 114 | logger.warn("ignoring malformed keybindings config", { | ||
| 115 | path: configPath, | ||
| 116 | error: error instanceof Error ? error.message : String(error), | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | return []; | ||
| 120 | } | ||
| 121 | |||
| 71 | export function createServer(options: ServerOptions) { | 122 | export function createServer(options: ServerOptions) { |
| 72 | const { | 123 | const { |
| 73 | port, | 124 | port, |
| ... | @@ -410,7 +461,10 @@ export function createServer(options: ServerOptions) { | ... | @@ -410,7 +461,10 @@ export function createServer(options: ServerOptions) { |
| 410 | return undefined; | 461 | return undefined; |
| 411 | 462 | ||
| 412 | case WS_METHODS.serverGetConfig: | 463 | case WS_METHODS.serverGetConfig: |
| 413 | return { cwd }; | 464 | return { |
| 465 | cwd, | ||
| 466 | keybindings: readKeybindingsConfig(logger), | ||
| 467 | }; | ||
| 414 | 468 | ||
| 415 | default: | 469 | default: |
| 416 | throw new Error(`Unknown method: ${request.method}`); | 470 | throw new Error(`Unknown method: ${request.method}`); |
apps/web/src/components/ChatView.tsx+101-8| ... | @@ -1,10 +1,12 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import { | 1 | import { |
| 2 | EDITORS, | ||
| 3 | type EditorId, | ||
| 4 | ModelSlug, | ||
| 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 5 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 6 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 7 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 5 | type ProviderSendTurnAttachmentInput, | ||
| 6 | type ProviderApprovalDecision, | 8 | type ProviderApprovalDecision, |
| 7 | ModelSlug, | 9 | type ProviderSendTurnAttachmentInput, |
| 8 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 9 | import { | 11 | import { |
| 10 | type ClipboardEvent, | 12 | type ClipboardEvent, |
| ... | @@ -25,7 +27,6 @@ import { | ... | @@ -25,7 +27,6 @@ import { |
| 25 | gitCreateWorktreeMutationOptions, | 27 | gitCreateWorktreeMutationOptions, |
| 26 | } from "~/lib/gitReactQuery"; | 28 | } from "~/lib/gitReactQuery"; |
| 27 | 29 | ||
| 28 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | ||
| 29 | import { isElectron } from "../env"; | 30 | import { isElectron } from "../env"; |
| 30 | import { buildBootstrapInput } from "../historyBootstrap"; | 31 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 31 | import { | 32 | import { |
| ... | @@ -50,7 +51,14 @@ import { useStore } from "../store"; | ... | @@ -50,7 +51,14 @@ import { useStore } from "../store"; |
| 50 | import type { ChatImageAttachment } from "../types"; | 51 | import type { ChatImageAttachment } from "../types"; |
| 51 | import BranchToolbar from "./BranchToolbar"; | 52 | import BranchToolbar from "./BranchToolbar"; |
| 52 | import GitActionsControl from "./GitActionsControl"; | 53 | import GitActionsControl from "./GitActionsControl"; |
| 53 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; | 54 | import { |
| 55 | DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 56 | isTerminalNewShortcut, | ||
| 57 | isTerminalSplitShortcut, | ||
| 58 | isTerminalToggleShortcut, | ||
| 59 | resolveTerminalKeybindings, | ||
| 60 | shortcutLabelForCommand, | ||
| 61 | } from "../terminal-shortcuts"; | ||
| 54 | import ChatMarkdown from "./ChatMarkdown"; | 62 | import ChatMarkdown from "./ChatMarkdown"; |
| 55 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 63 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 56 | import { useNativeApi } from "../hooks/useNativeApi"; | 64 | import { useNativeApi } from "../hooks/useNativeApi"; |
| ... | @@ -144,6 +152,7 @@ export default function ChatView() { | ... | @@ -144,6 +152,7 @@ export default function ChatView() { |
| 144 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 152 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 145 | const [nowTick, setNowTick] = useState(() => Date.now()); | 153 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 146 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 154 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 155 | const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS); | ||
| 147 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 156 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 148 | const messagesEndRef = useRef<HTMLDivElement>(null); | 157 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 149 | const shouldAutoScrollRef = useRef(true); | 158 | const shouldAutoScrollRef = useRef(true); |
| ... | @@ -256,12 +265,45 @@ export default function ChatView() { | ... | @@ -256,12 +265,45 @@ export default function ChatView() { |
| 256 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | 265 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 257 | // Default true while loading to avoid toolbar flicker. | 266 | // Default true while loading to avoid toolbar flicker. |
| 258 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | 267 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| 268 | const splitTerminalShortcutLabel = useMemo( | ||
| 269 | () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"), | ||
| 270 | [terminalKeybindings], | ||
| 271 | ); | ||
| 272 | const newTerminalShortcutLabel = useMemo( | ||
| 273 | () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"), | ||
| 274 | [terminalKeybindings], | ||
| 275 | ); | ||
| 259 | 276 | ||
| 260 | const envLocked = Boolean( | 277 | const envLocked = Boolean( |
| 261 | activeThread && | 278 | activeThread && |
| 262 | (activeThread.messages.length > 0 || | 279 | (activeThread.messages.length > 0 || |
| 263 | (activeThread.session !== null && activeThread.session.status !== "closed")), | 280 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 264 | ); | 281 | ); |
| 282 | |||
| 283 | useEffect(() => { | ||
| 284 | if (!api || !api.server || typeof api.server.getConfig !== "function") { | ||
| 285 | setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); | ||
| 286 | return; | ||
| 287 | } | ||
| 288 | |||
| 289 | let disposed = false; | ||
| 290 | |||
| 291 | void api.server | ||
| 292 | .getConfig() | ||
| 293 | .then((config) => { | ||
| 294 | if (disposed) return; | ||
| 295 | setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings)); | ||
| 296 | }) | ||
| 297 | .catch(() => { | ||
| 298 | if (disposed) return; | ||
| 299 | setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS); | ||
| 300 | }); | ||
| 301 | |||
| 302 | return () => { | ||
| 303 | disposed = true; | ||
| 304 | }; | ||
| 305 | }, [api]); | ||
| 306 | |||
| 265 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { | 307 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 266 | for (const image of images) { | 308 | for (const image of images) { |
| 267 | if (!image.previewUrl) continue; | 309 | if (!image.previewUrl) continue; |
| ... | @@ -490,15 +532,64 @@ export default function ChatView() { | ... | @@ -490,15 +532,64 @@ export default function ChatView() { |
| 490 | }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); | 532 | }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); |
| 491 | 533 | ||
| 492 | useEffect(() => { | 534 | useEffect(() => { |
| 535 | const isTerminalFocused = (): boolean => { | ||
| 536 | const activeElement = document.activeElement; | ||
| 537 | if (!(activeElement instanceof HTMLElement)) return false; | ||
| 538 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | ||
| 539 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | ||
| 540 | }; | ||
| 541 | |||
| 493 | const handler = (event: globalThis.KeyboardEvent) => { | 542 | const handler = (event: globalThis.KeyboardEvent) => { |
| 494 | if (!activeThreadId) return; | 543 | if (!activeThreadId || event.defaultPrevented) return; |
| 495 | if (!isTerminalToggleShortcut(event)) return; | 544 | const shortcutContext = { |
| 545 | terminalFocus: isTerminalFocused(), | ||
| 546 | terminalOpen: Boolean(activeThread?.terminalOpen), | ||
| 547 | }; | ||
| 548 | |||
| 549 | if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) { | ||
| 550 | event.preventDefault(); | ||
| 551 | event.stopPropagation(); | ||
| 552 | toggleTerminalVisibility(); | ||
| 553 | return; | ||
| 554 | } | ||
| 555 | |||
| 556 | if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) { | ||
| 557 | event.preventDefault(); | ||
| 558 | event.stopPropagation(); | ||
| 559 | if (!activeThread?.terminalOpen) { | ||
| 560 | dispatch({ | ||
| 561 | type: "SET_THREAD_TERMINAL_OPEN", | ||
| 562 | threadId: activeThreadId, | ||
| 563 | open: true, | ||
| 564 | }); | ||
| 565 | } | ||
| 566 | splitTerminal(); | ||
| 567 | return; | ||
| 568 | } | ||
| 569 | |||
| 570 | if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return; | ||
| 496 | event.preventDefault(); | 571 | event.preventDefault(); |
| 497 | toggleTerminalVisibility(); | 572 | event.stopPropagation(); |
| 573 | if (!activeThread?.terminalOpen) { | ||
| 574 | dispatch({ | ||
| 575 | type: "SET_THREAD_TERMINAL_OPEN", | ||
| 576 | threadId: activeThreadId, | ||
| 577 | open: true, | ||
| 578 | }); | ||
| 579 | } | ||
| 580 | createNewTerminal(); | ||
| 498 | }; | 581 | }; |
| 499 | window.addEventListener("keydown", handler); | 582 | window.addEventListener("keydown", handler); |
| 500 | return () => window.removeEventListener("keydown", handler); | 583 | return () => window.removeEventListener("keydown", handler); |
| 501 | }, [activeThreadId, toggleTerminalVisibility]); | 584 | }, [ |
| 585 | activeThread?.terminalOpen, | ||
| 586 | activeThreadId, | ||
| 587 | createNewTerminal, | ||
| 588 | dispatch, | ||
| 589 | splitTerminal, | ||
| 590 | terminalKeybindings, | ||
| 591 | toggleTerminalVisibility, | ||
| 592 | ]); | ||
| 502 | 593 | ||
| 503 | const setThreadError = (threadId: string | null, error: string | null) => { | 594 | const setThreadError = (threadId: string | null, error: string | null) => { |
| 504 | if (!threadId) return; | 595 | if (!threadId) return; |
| ... | @@ -1350,6 +1441,8 @@ export default function ChatView() { | ... | @@ -1350,6 +1441,8 @@ export default function ChatView() { |
| 1350 | focusRequestId={terminalFocusRequestId} | 1441 | focusRequestId={terminalFocusRequestId} |
| 1351 | onSplitTerminal={splitTerminal} | 1442 | onSplitTerminal={splitTerminal} |
| 1352 | onNewTerminal={createNewTerminal} | 1443 | onNewTerminal={createNewTerminal} |
| 1444 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | ||
| 1445 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | ||
| 1353 | onActiveTerminalChange={activateTerminal} | 1446 | onActiveTerminalChange={activateTerminal} |
| 1354 | onCloseTerminal={closeTerminal} | 1447 | onCloseTerminal={closeTerminal} |
| 1355 | onHeightChange={(height) => | 1448 | onHeightChange={(height) => |
apps/web/src/components/ThreadTerminalDrawer.tsx+14-4| ... | @@ -416,6 +416,8 @@ interface ThreadTerminalDrawerProps { | ... | @@ -416,6 +416,8 @@ interface ThreadTerminalDrawerProps { |
| 416 | focusRequestId: number; | 416 | focusRequestId: number; |
| 417 | onSplitTerminal: () => void; | 417 | onSplitTerminal: () => void; |
| 418 | onNewTerminal: () => void; | 418 | onNewTerminal: () => void; |
| 419 | splitShortcutLabel?: string | undefined; | ||
| 420 | newShortcutLabel?: string | undefined; | ||
| 419 | onActiveTerminalChange: (terminalId: string) => void; | 421 | onActiveTerminalChange: (terminalId: string) => void; |
| 420 | onCloseTerminal: (terminalId: string) => void; | 422 | onCloseTerminal: (terminalId: string) => void; |
| 421 | onHeightChange: (height: number) => void; | 423 | onHeightChange: (height: number) => void; |
| ... | @@ -468,6 +470,8 @@ export default function ThreadTerminalDrawer({ | ... | @@ -468,6 +470,8 @@ export default function ThreadTerminalDrawer({ |
| 468 | focusRequestId, | 470 | focusRequestId, |
| 469 | onSplitTerminal, | 471 | onSplitTerminal, |
| 470 | onNewTerminal, | 472 | onNewTerminal, |
| 473 | splitShortcutLabel, | ||
| 474 | newShortcutLabel, | ||
| 471 | onActiveTerminalChange, | 475 | onActiveTerminalChange, |
| 472 | onCloseTerminal, | 476 | onCloseTerminal, |
| 473 | onHeightChange, | 477 | onHeightChange, |
| ... | @@ -694,7 +698,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -694,7 +698,7 @@ export default function ThreadTerminalDrawer({ |
| 694 | <TerminalActionButton | 698 | <TerminalActionButton |
| 695 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | 699 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 696 | onClick={onSplitTerminal} | 700 | onClick={onSplitTerminal} |
| 697 | label="Split Terminal" | 701 | label={splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"} |
| 698 | > | 702 | > |
| 699 | <SquareSplitHorizontal className="size-3.25" /> | 703 | <SquareSplitHorizontal className="size-3.25" /> |
| 700 | </TerminalActionButton> | 704 | </TerminalActionButton> |
| ... | @@ -702,7 +706,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -702,7 +706,7 @@ export default function ThreadTerminalDrawer({ |
| 702 | <TerminalActionButton | 706 | <TerminalActionButton |
| 703 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | 707 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 704 | onClick={onNewTerminal} | 708 | onClick={onNewTerminal} |
| 705 | label="New Terminal" | 709 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} |
| 706 | > | 710 | > |
| 707 | <Plus className="size-3.25" /> | 711 | <Plus className="size-3.25" /> |
| 708 | </TerminalActionButton> | 712 | </TerminalActionButton> |
| ... | @@ -779,14 +783,20 @@ export default function ThreadTerminalDrawer({ | ... | @@ -779,14 +783,20 @@ export default function ThreadTerminalDrawer({ |
| 779 | <TerminalActionButton | 783 | <TerminalActionButton |
| 780 | className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" | 784 | className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 781 | onClick={onSplitTerminal} | 785 | onClick={onSplitTerminal} |
| 782 | label="Split Terminal" | 786 | label={ |
| 787 | splitShortcutLabel | ||
| 788 | ? `Split Terminal (${splitShortcutLabel})` | ||
| 789 | : "Split Terminal" | ||
| 790 | } | ||
| 783 | > | 791 | > |
| 784 | <SquareSplitHorizontal className="size-3.25" /> | 792 | <SquareSplitHorizontal className="size-3.25" /> |
| 785 | </TerminalActionButton> | 793 | </TerminalActionButton> |
| 786 | <TerminalActionButton | 794 | <TerminalActionButton |
| 787 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | 795 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 788 | onClick={onNewTerminal} | 796 | onClick={onNewTerminal} |
| 789 | label="New Terminal" | 797 | label={ |
| 798 | newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal" | ||
| 799 | } | ||
| 790 | > | 800 | > |
| 791 | <Plus className="size-3.25" /> | 801 | <Plus className="size-3.25" /> |
| 792 | </TerminalActionButton> | 802 | </TerminalActionButton> |
apps/web/src/terminal-shortcuts.test.ts+133-22| ... | @@ -1,8 +1,14 @@ | ... | @@ -1,8 +1,14 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 5 | formatShortcutLabel, | ||
| 4 | isTerminalClearShortcut, | 6 | isTerminalClearShortcut, |
| 7 | isTerminalNewShortcut, | ||
| 8 | isTerminalSplitShortcut, | ||
| 5 | isTerminalToggleShortcut, | 9 | isTerminalToggleShortcut, |
| 10 | resolveTerminalKeybindings, | ||
| 11 | shortcutLabelForCommand, | ||
| 6 | type ShortcutEventLike, | 12 | type ShortcutEventLike, |
| 7 | } from "./terminal-shortcuts"; | 13 | } from "./terminal-shortcuts"; |
| 8 | 14 | ||
| ... | @@ -19,22 +25,140 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | ... | @@ -19,22 +25,140 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { |
| 19 | 25 | ||
| 20 | describe("isTerminalToggleShortcut", () => { | 26 | describe("isTerminalToggleShortcut", () => { |
| 21 | it("matches Cmd+J on macOS", () => { | 27 | it("matches Cmd+J on macOS", () => { |
| 22 | expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); | 28 | expect( |
| 29 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | ||
| 30 | platform: "MacIntel", | ||
| 31 | }), | ||
| 32 | ).toBe(true); | ||
| 23 | }); | 33 | }); |
| 24 | 34 | ||
| 25 | it("matches Ctrl+J on non-macOS", () => { | 35 | it("matches Ctrl+J on non-macOS", () => { |
| 26 | expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32")).toBe(true); | 36 | expect( |
| 37 | isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | ||
| 38 | platform: "Win32", | ||
| 39 | }), | ||
| 40 | ).toBe(true); | ||
| 27 | }); | 41 | }); |
| 42 | }); | ||
| 28 | 43 | ||
| 29 | it("rejects wrong modifiers", () => { | 44 | describe("split/new terminal shortcuts", () => { |
| 30 | expect(isTerminalToggleShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false); | 45 | it("requires terminalFocus for default split/new bindings", () => { |
| 31 | expect(isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel")).toBe( | 46 | expect( |
| 32 | false, | 47 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { |
| 33 | ); | 48 | platform: "MacIntel", |
| 49 | context: { terminalFocus: false }, | ||
| 50 | }), | ||
| 51 | ).toBe(false); | ||
| 52 | expect( | ||
| 53 | isTerminalNewShortcut( | ||
| 54 | event({ key: "d", ctrlKey: true, shiftKey: true }), | ||
| 55 | DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 56 | { | ||
| 57 | platform: "Linux", | ||
| 58 | context: { terminalFocus: false }, | ||
| 59 | }, | ||
| 60 | ), | ||
| 61 | ).toBe(false); | ||
| 34 | }); | 62 | }); |
| 35 | 63 | ||
| 36 | it("rejects non-j keys", () => { | 64 | it("matches split/new when terminalFocus is true", () => { |
| 37 | expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false); | 65 | expect( |
| 66 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, { | ||
| 67 | platform: "MacIntel", | ||
| 68 | context: { terminalFocus: true }, | ||
| 69 | }), | ||
| 70 | ).toBe(true); | ||
| 71 | expect( | ||
| 72 | isTerminalNewShortcut( | ||
| 73 | event({ key: "d", ctrlKey: true, shiftKey: true }), | ||
| 74 | DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 75 | { | ||
| 76 | platform: "Linux", | ||
| 77 | context: { terminalFocus: true }, | ||
| 78 | }, | ||
| 79 | ), | ||
| 80 | ).toBe(true); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("supports configured overrides with when expressions", () => { | ||
| 84 | const keybindings = resolveTerminalKeybindings([ | ||
| 85 | { | ||
| 86 | key: "mod+\\", | ||
| 87 | command: "terminal.split", | ||
| 88 | when: "terminalOpen && !terminalFocus", | ||
| 89 | }, | ||
| 90 | { | ||
| 91 | key: "mod+shift+n", | ||
| 92 | command: "terminal.new", | ||
| 93 | when: "terminalOpen && !terminalFocus", | ||
| 94 | }, | ||
| 95 | ]); | ||
| 96 | expect( | ||
| 97 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | ||
| 98 | platform: "Win32", | ||
| 99 | context: { terminalOpen: true, terminalFocus: false }, | ||
| 100 | }), | ||
| 101 | ).toBe(true); | ||
| 102 | expect( | ||
| 103 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | ||
| 104 | platform: "Win32", | ||
| 105 | context: { terminalOpen: false, terminalFocus: false }, | ||
| 106 | }), | ||
| 107 | ).toBe(false); | ||
| 108 | expect( | ||
| 109 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { | ||
| 110 | platform: "Win32", | ||
| 111 | context: { terminalOpen: true, terminalFocus: false }, | ||
| 112 | }), | ||
| 113 | ).toBe(true); | ||
| 114 | }); | ||
| 115 | }); | ||
| 116 | |||
| 117 | describe("resolveTerminalKeybindings", () => { | ||
| 118 | it("uses defaults when config is empty", () => { | ||
| 119 | expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("replaces defaults for commands configured by user", () => { | ||
| 123 | const resolved = resolveTerminalKeybindings([ | ||
| 124 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | ||
| 125 | ]); | ||
| 126 | |||
| 127 | expect(resolved).toEqual([ | ||
| 128 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 129 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 130 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | ||
| 131 | ]); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("ignores invalid configured values", () => { | ||
| 135 | expect( | ||
| 136 | resolveTerminalKeybindings([ | ||
| 137 | { key: " ", command: "terminal.toggle" }, | ||
| 138 | { key: "mod+d+f", command: "terminal.split" }, | ||
| 139 | ]), | ||
| 140 | ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS); | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | describe("shortcutLabelForCommand", () => { | ||
| 145 | it("returns the most recent binding label", () => { | ||
| 146 | const resolved = resolveTerminalKeybindings([ | ||
| 147 | { key: "mod+\\", command: "terminal.split", when: "terminalFocus" }, | ||
| 148 | { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" }, | ||
| 149 | ]); | ||
| 150 | |||
| 151 | expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\"); | ||
| 152 | }); | ||
| 153 | }); | ||
| 154 | |||
| 155 | describe("formatShortcutLabel", () => { | ||
| 156 | it("formats labels for macOS", () => { | ||
| 157 | expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D"); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("formats labels for non-macOS", () => { | ||
| 161 | expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D"); | ||
| 38 | }); | 162 | }); |
| 39 | }); | 163 | }); |
| 40 | 164 | ||
| ... | @@ -47,17 +171,4 @@ describe("isTerminalClearShortcut", () => { | ... | @@ -47,17 +171,4 @@ describe("isTerminalClearShortcut", () => { |
| 47 | it("matches Cmd+K on macOS", () => { | 171 | it("matches Cmd+K on macOS", () => { |
| 48 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); | 172 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); |
| 49 | }); | 173 | }); |
| 50 | |||
| 51 | it("rejects Cmd+K on non-macOS", () => { | ||
| 52 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32")).toBe(false); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("rejects wrong modifiers", () => { | ||
| 56 | expect( | ||
| 57 | isTerminalClearShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), "Linux"), | ||
| 58 | ).toBe(false); | ||
| 59 | expect( | ||
| 60 | isTerminalClearShortcut(event({ key: "k", metaKey: true, altKey: true }), "MacIntel"), | ||
| 61 | ).toBe(false); | ||
| 62 | }); | ||
| 63 | }); | 174 | }); |
apps/web/src/terminal-shortcuts.ts+422-7| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts"; | ||
| 2 | |||
| 1 | export interface ShortcutEventLike { | 3 | export interface ShortcutEventLike { |
| 2 | key: string; | 4 | key: string; |
| 3 | metaKey: boolean; | 5 | metaKey: boolean; |
| ... | @@ -6,23 +8,436 @@ export interface ShortcutEventLike { | ... | @@ -6,23 +8,436 @@ export interface ShortcutEventLike { |
| 6 | altKey: boolean; | 8 | altKey: boolean; |
| 7 | } | 9 | } |
| 8 | 10 | ||
| 11 | export interface ShortcutMatchContext { | ||
| 12 | terminalFocus: boolean; | ||
| 13 | terminalOpen: boolean; | ||
| 14 | [key: string]: boolean; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface ResolvedTerminalKeybinding { | ||
| 18 | key: string; | ||
| 19 | command: KeybindingCommand; | ||
| 20 | when?: string | undefined; | ||
| 21 | } | ||
| 22 | |||
| 23 | export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[]; | ||
| 24 | |||
| 25 | export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [ | ||
| 26 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 27 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 28 | { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" }, | ||
| 29 | ]; | ||
| 30 | |||
| 31 | interface ParsedShortcut { | ||
| 32 | key: string; | ||
| 33 | metaKey: boolean; | ||
| 34 | ctrlKey: boolean; | ||
| 35 | shiftKey: boolean; | ||
| 36 | altKey: boolean; | ||
| 37 | modKey: boolean; | ||
| 38 | } | ||
| 39 | |||
| 40 | interface ShortcutMatchOptions { | ||
| 41 | platform?: string; | ||
| 42 | context?: Partial<ShortcutMatchContext>; | ||
| 43 | } | ||
| 44 | |||
| 45 | type WhenNode = | ||
| 46 | | { type: "identifier"; name: string } | ||
| 47 | | { type: "not"; node: WhenNode } | ||
| 48 | | { type: "and"; left: WhenNode; right: WhenNode } | ||
| 49 | | { type: "or"; left: WhenNode; right: WhenNode }; | ||
| 50 | |||
| 51 | type WhenToken = | ||
| 52 | | { type: "identifier"; value: string } | ||
| 53 | | { type: "not" } | ||
| 54 | | { type: "and" } | ||
| 55 | | { type: "or" } | ||
| 56 | | { type: "lparen" } | ||
| 57 | | { type: "rparen" }; | ||
| 58 | |||
| 59 | const WHEN_AST_CACHE = new Map<string, WhenNode | null>(); | ||
| 60 | |||
| 9 | function isMacPlatform(platform: string): boolean { | 61 | function isMacPlatform(platform: string): boolean { |
| 10 | return /mac|iphone|ipad|ipod/i.test(platform); | 62 | return /mac|iphone|ipad|ipod/i.test(platform); |
| 11 | } | 63 | } |
| 12 | 64 | ||
| 13 | export function isTerminalToggleShortcut( | 65 | function normalizeKeyToken(token: string): string { |
| 66 | if (token === "space") return " "; | ||
| 67 | if (token === "esc") return "escape"; | ||
| 68 | return token; | ||
| 69 | } | ||
| 70 | |||
| 71 | function parseShortcutValue(value: string): ParsedShortcut | null { | ||
| 72 | const tokens = value | ||
| 73 | .toLowerCase() | ||
| 74 | .split("+") | ||
| 75 | .map((token) => token.trim()) | ||
| 76 | .filter((token) => token.length > 0); | ||
| 77 | if (tokens.length === 0) return null; | ||
| 78 | |||
| 79 | let key: string | null = null; | ||
| 80 | let metaKey = false; | ||
| 81 | let ctrlKey = false; | ||
| 82 | let shiftKey = false; | ||
| 83 | let altKey = false; | ||
| 84 | let modKey = false; | ||
| 85 | |||
| 86 | for (const token of tokens) { | ||
| 87 | switch (token) { | ||
| 88 | case "cmd": | ||
| 89 | case "meta": | ||
| 90 | metaKey = true; | ||
| 91 | break; | ||
| 92 | case "ctrl": | ||
| 93 | case "control": | ||
| 94 | ctrlKey = true; | ||
| 95 | break; | ||
| 96 | case "shift": | ||
| 97 | shiftKey = true; | ||
| 98 | break; | ||
| 99 | case "alt": | ||
| 100 | case "option": | ||
| 101 | altKey = true; | ||
| 102 | break; | ||
| 103 | case "mod": | ||
| 104 | modKey = true; | ||
| 105 | break; | ||
| 106 | default: { | ||
| 107 | if (key !== null) { | ||
| 108 | return null; | ||
| 109 | } | ||
| 110 | key = normalizeKeyToken(token); | ||
| 111 | } | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | if (key === null) return null; | ||
| 116 | |||
| 117 | return { | ||
| 118 | key, | ||
| 119 | metaKey, | ||
| 120 | ctrlKey, | ||
| 121 | shiftKey, | ||
| 122 | altKey, | ||
| 123 | modKey, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | function normalizeEventKey(key: string): string { | ||
| 128 | const normalized = key.toLowerCase(); | ||
| 129 | if (normalized === "esc") return "escape"; | ||
| 130 | return normalized; | ||
| 131 | } | ||
| 132 | |||
| 133 | function matchesShortcutValue( | ||
| 14 | event: ShortcutEventLike, | 134 | event: ShortcutEventLike, |
| 135 | shortcutValue: string, | ||
| 15 | platform = navigator.platform, | 136 | platform = navigator.platform, |
| 16 | ): boolean { | 137 | ): boolean { |
| 17 | const key = event.key.toLowerCase(); | 138 | const parsed = parseShortcutValue(shortcutValue); |
| 18 | if (key !== "j") return false; | 139 | if (!parsed) return false; |
| 19 | if (event.shiftKey || event.altKey) return false; | 140 | |
| 141 | const key = normalizeEventKey(event.key); | ||
| 142 | if (key !== parsed.key) return false; | ||
| 143 | |||
| 144 | const useMetaForMod = isMacPlatform(platform); | ||
| 145 | const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); | ||
| 146 | const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); | ||
| 147 | return ( | ||
| 148 | event.metaKey === expectedMeta && | ||
| 149 | event.ctrlKey === expectedCtrl && | ||
| 150 | event.shiftKey === parsed.shiftKey && | ||
| 151 | event.altKey === parsed.altKey | ||
| 152 | ); | ||
| 153 | } | ||
| 154 | |||
| 155 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | ||
| 156 | const tokens: WhenToken[] = []; | ||
| 157 | let index = 0; | ||
| 20 | 158 | ||
| 21 | if (isMacPlatform(platform)) { | 159 | while (index < expression.length) { |
| 22 | return event.metaKey && !event.ctrlKey; | 160 | const current = expression[index]; |
| 161 | if (!current) break; | ||
| 162 | |||
| 163 | if (/\s/.test(current)) { | ||
| 164 | index += 1; | ||
| 165 | continue; | ||
| 166 | } | ||
| 167 | if (expression.startsWith("&&", index)) { | ||
| 168 | tokens.push({ type: "and" }); | ||
| 169 | index += 2; | ||
| 170 | continue; | ||
| 171 | } | ||
| 172 | if (expression.startsWith("||", index)) { | ||
| 173 | tokens.push({ type: "or" }); | ||
| 174 | index += 2; | ||
| 175 | continue; | ||
| 176 | } | ||
| 177 | if (current === "!") { | ||
| 178 | tokens.push({ type: "not" }); | ||
| 179 | index += 1; | ||
| 180 | continue; | ||
| 181 | } | ||
| 182 | if (current === "(") { | ||
| 183 | tokens.push({ type: "lparen" }); | ||
| 184 | index += 1; | ||
| 185 | continue; | ||
| 186 | } | ||
| 187 | if (current === ")") { | ||
| 188 | tokens.push({ type: "rparen" }); | ||
| 189 | index += 1; | ||
| 190 | continue; | ||
| 191 | } | ||
| 192 | |||
| 193 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | ||
| 194 | if (!identifier) { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | tokens.push({ type: "identifier", value: identifier[0] }); | ||
| 198 | index += identifier[0].length; | ||
| 23 | } | 199 | } |
| 24 | 200 | ||
| 25 | return event.ctrlKey && !event.metaKey; | 201 | return tokens; |
| 202 | } | ||
| 203 | |||
| 204 | function parseWhenExpression(expression: string): WhenNode | null { | ||
| 205 | const tokens = tokenizeWhenExpression(expression); | ||
| 206 | if (!tokens || tokens.length === 0) return null; | ||
| 207 | let index = 0; | ||
| 208 | |||
| 209 | const parsePrimary = (): WhenNode | null => { | ||
| 210 | const token = tokens[index]; | ||
| 211 | if (!token) return null; | ||
| 212 | |||
| 213 | if (token.type === "identifier") { | ||
| 214 | index += 1; | ||
| 215 | return { type: "identifier", name: token.value }; | ||
| 216 | } | ||
| 217 | |||
| 218 | if (token.type === "lparen") { | ||
| 219 | index += 1; | ||
| 220 | const expressionNode = parseOr(); | ||
| 221 | const closeToken = tokens[index]; | ||
| 222 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | ||
| 223 | return null; | ||
| 224 | } | ||
| 225 | index += 1; | ||
| 226 | return expressionNode; | ||
| 227 | } | ||
| 228 | |||
| 229 | return null; | ||
| 230 | }; | ||
| 231 | |||
| 232 | const parseUnary = (): WhenNode | null => { | ||
| 233 | const token = tokens[index]; | ||
| 234 | if (token?.type === "not") { | ||
| 235 | index += 1; | ||
| 236 | const node = parseUnary(); | ||
| 237 | if (!node) return null; | ||
| 238 | return { type: "not", node }; | ||
| 239 | } | ||
| 240 | return parsePrimary(); | ||
| 241 | }; | ||
| 242 | |||
| 243 | const parseAnd = (): WhenNode | null => { | ||
| 244 | let left = parseUnary(); | ||
| 245 | if (!left) return null; | ||
| 246 | |||
| 247 | while (tokens[index]?.type === "and") { | ||
| 248 | index += 1; | ||
| 249 | const right = parseUnary(); | ||
| 250 | if (!right) return null; | ||
| 251 | left = { type: "and", left, right }; | ||
| 252 | } | ||
| 253 | |||
| 254 | return left; | ||
| 255 | }; | ||
| 256 | |||
| 257 | const parseOr = (): WhenNode | null => { | ||
| 258 | let left = parseAnd(); | ||
| 259 | if (!left) return null; | ||
| 260 | |||
| 261 | while (tokens[index]?.type === "or") { | ||
| 262 | index += 1; | ||
| 263 | const right = parseAnd(); | ||
| 264 | if (!right) return null; | ||
| 265 | left = { type: "or", left, right }; | ||
| 266 | } | ||
| 267 | |||
| 268 | return left; | ||
| 269 | }; | ||
| 270 | |||
| 271 | const ast = parseOr(); | ||
| 272 | if (!ast || index !== tokens.length) return null; | ||
| 273 | return ast; | ||
| 274 | } | ||
| 275 | |||
| 276 | function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean { | ||
| 277 | switch (node.type) { | ||
| 278 | case "identifier": | ||
| 279 | return Boolean(context[node.name]); | ||
| 280 | case "not": | ||
| 281 | return !evaluateWhenNode(node.node, context); | ||
| 282 | case "and": | ||
| 283 | return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); | ||
| 284 | case "or": | ||
| 285 | return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); | ||
| 286 | } | ||
| 287 | } | ||
| 288 | |||
| 289 | function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean { | ||
| 290 | if (!when) return true; | ||
| 291 | const normalized = when.trim(); | ||
| 292 | if (normalized.length === 0) return true; | ||
| 293 | |||
| 294 | if (!WHEN_AST_CACHE.has(normalized)) { | ||
| 295 | WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized)); | ||
| 296 | } | ||
| 297 | const ast = WHEN_AST_CACHE.get(normalized); | ||
| 298 | if (!ast) return false; | ||
| 299 | return evaluateWhenNode(ast, context); | ||
| 300 | } | ||
| 301 | |||
| 302 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { | ||
| 303 | return options?.platform ?? navigator.platform; | ||
| 304 | } | ||
| 305 | |||
| 306 | function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { | ||
| 307 | return { | ||
| 308 | terminalFocus: false, | ||
| 309 | terminalOpen: false, | ||
| 310 | ...options?.context, | ||
| 311 | }; | ||
| 312 | } | ||
| 313 | |||
| 314 | function normalizeConfiguredKeybinding( | ||
| 315 | keybinding: KeybindingsConfig[number], | ||
| 316 | ): ResolvedTerminalKeybinding | null { | ||
| 317 | const key = keybinding.key.trim(); | ||
| 318 | if (key.length === 0) return null; | ||
| 319 | if (!parseShortcutValue(key)) return null; | ||
| 320 | |||
| 321 | const when = keybinding.when?.trim(); | ||
| 322 | return { | ||
| 323 | key, | ||
| 324 | command: keybinding.command, | ||
| 325 | ...(when ? { when } : {}), | ||
| 326 | }; | ||
| 327 | } | ||
| 328 | |||
| 329 | function matchesCommandShortcut( | ||
| 330 | event: ShortcutEventLike, | ||
| 331 | keybindings: ResolvedTerminalKeybindings, | ||
| 332 | command: KeybindingCommand, | ||
| 333 | options?: ShortcutMatchOptions, | ||
| 334 | ): boolean { | ||
| 335 | const platform = resolvePlatform(options); | ||
| 336 | const context = resolveContext(options); | ||
| 337 | |||
| 338 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | ||
| 339 | const binding = keybindings[index]; | ||
| 340 | if (!binding || binding.command !== command) continue; | ||
| 341 | if (!matchesWhenExpression(binding.when, context)) continue; | ||
| 342 | if (!matchesShortcutValue(event, binding.key, platform)) continue; | ||
| 343 | return true; | ||
| 344 | } | ||
| 345 | return false; | ||
| 346 | } | ||
| 347 | |||
| 348 | export function resolveTerminalKeybindings( | ||
| 349 | input: KeybindingsConfig | null | undefined, | ||
| 350 | ): ResolvedTerminalKeybindings { | ||
| 351 | if (!input || input.length === 0) { | ||
| 352 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | ||
| 353 | } | ||
| 354 | |||
| 355 | const normalizedInput = input | ||
| 356 | .map((binding) => normalizeConfiguredKeybinding(binding)) | ||
| 357 | .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null); | ||
| 358 | if (normalizedInput.length === 0) { | ||
| 359 | return [...DEFAULT_TERMINAL_KEYBINDINGS]; | ||
| 360 | } | ||
| 361 | |||
| 362 | const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command)); | ||
| 363 | const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter( | ||
| 364 | (binding) => !overriddenCommands.has(binding.command), | ||
| 365 | ); | ||
| 366 | return [...retainedDefaults, ...normalizedInput]; | ||
| 367 | } | ||
| 368 | |||
| 369 | function formatShortcutKeyLabel(key: string): string { | ||
| 370 | if (key === " ") return "Space"; | ||
| 371 | if (key.length === 1) return key.toUpperCase(); | ||
| 372 | if (key === "escape") return "Esc"; | ||
| 373 | if (key === "arrowup") return "Up"; | ||
| 374 | if (key === "arrowdown") return "Down"; | ||
| 375 | if (key === "arrowleft") return "Left"; | ||
| 376 | if (key === "arrowright") return "Right"; | ||
| 377 | return key.slice(0, 1).toUpperCase() + key.slice(1); | ||
| 378 | } | ||
| 379 | |||
| 380 | export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string { | ||
| 381 | const parsed = parseShortcutValue(shortcutValue); | ||
| 382 | if (!parsed) { | ||
| 383 | return shortcutValue; | ||
| 384 | } | ||
| 385 | |||
| 386 | const keyLabel = formatShortcutKeyLabel(parsed.key); | ||
| 387 | const useMetaForMod = isMacPlatform(platform); | ||
| 388 | const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod); | ||
| 389 | const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod); | ||
| 390 | const showAlt = parsed.altKey; | ||
| 391 | const showShift = parsed.shiftKey; | ||
| 392 | |||
| 393 | if (useMetaForMod) { | ||
| 394 | return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; | ||
| 395 | } | ||
| 396 | |||
| 397 | const parts: string[] = []; | ||
| 398 | if (showCtrl) parts.push("Ctrl"); | ||
| 399 | if (showAlt) parts.push("Alt"); | ||
| 400 | if (showShift) parts.push("Shift"); | ||
| 401 | if (showMeta) parts.push("Meta"); | ||
| 402 | parts.push(keyLabel); | ||
| 403 | return parts.join("+"); | ||
| 404 | } | ||
| 405 | |||
| 406 | export function shortcutLabelForCommand( | ||
| 407 | keybindings: ResolvedTerminalKeybindings, | ||
| 408 | command: KeybindingCommand, | ||
| 409 | platform = navigator.platform, | ||
| 410 | ): string | null { | ||
| 411 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | ||
| 412 | const binding = keybindings[index]; | ||
| 413 | if (!binding || binding.command !== command) continue; | ||
| 414 | return formatShortcutLabel(binding.key, platform); | ||
| 415 | } | ||
| 416 | return null; | ||
| 417 | } | ||
| 418 | |||
| 419 | export function isTerminalToggleShortcut( | ||
| 420 | event: ShortcutEventLike, | ||
| 421 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 422 | options?: ShortcutMatchOptions, | ||
| 423 | ): boolean { | ||
| 424 | return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); | ||
| 425 | } | ||
| 426 | |||
| 427 | export function isTerminalSplitShortcut( | ||
| 428 | event: ShortcutEventLike, | ||
| 429 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 430 | options?: ShortcutMatchOptions, | ||
| 431 | ): boolean { | ||
| 432 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); | ||
| 433 | } | ||
| 434 | |||
| 435 | export function isTerminalNewShortcut( | ||
| 436 | event: ShortcutEventLike, | ||
| 437 | keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS, | ||
| 438 | options?: ShortcutMatchOptions, | ||
| 439 | ): boolean { | ||
| 440 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); | ||
| 26 | } | 441 | } |
| 27 | 442 | ||
| 28 | export function isTerminalClearShortcut( | 443 | export function isTerminalClearShortcut( |
apps/web/src/wsNativeApi.ts+3| ... | @@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi { |
| 139 | return showContextMenuFallback(items, position); | 139 | return showContextMenuFallback(items, position); |
| 140 | }, | 140 | }, |
| 141 | }, | 141 | }, |
| 142 | server: { | ||
| 143 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), | ||
| 144 | }, | ||
| 142 | }; | 145 | }; |
| 143 | 146 | ||
| 144 | instance = { api, transport }; | 147 | instance = { api, transport }; |
packages/contracts/src/index.ts+1| ... | @@ -5,5 +5,6 @@ export * from "./terminal"; | ... | @@ -5,5 +5,6 @@ export * from "./terminal"; |
| 5 | export * from "./provider"; | 5 | export * from "./provider"; |
| 6 | export * from "./model"; | 6 | export * from "./model"; |
| 7 | export * from "./ws"; | 7 | export * from "./ws"; |
| 8 | export * from "./server"; | ||
| 8 | export * from "./project"; | 9 | export * from "./project"; |
| 9 | export * from "./git"; | 10 | export * from "./git"; |
packages/contracts/src/ipc.ts+4| ... | @@ -31,6 +31,7 @@ import type { | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | ProjectListResult, | 31 | ProjectListResult, |
| 32 | ProjectRemoveInput, | 32 | ProjectRemoveInput, |
| 33 | } from "./project"; | 33 | } from "./project"; |
| 34 | import type { ServerConfig } from "./server"; | ||
| 34 | import type { | 35 | import type { |
| 35 | TerminalClearInput, | 36 | TerminalClearInput, |
| 36 | TerminalCloseInput, | 37 | TerminalCloseInput, |
| ... | @@ -113,4 +114,7 @@ export interface NativeApi { | ... | @@ -113,4 +114,7 @@ export interface NativeApi { |
| 113 | position?: { x: number; y: number }, | 114 | position?: { x: number; y: number }, |
| 114 | ) => Promise<T | null>; | 115 | ) => Promise<T | null>; |
| 115 | }; | 116 | }; |
| 117 | server: { | ||
| 118 | getConfig: () => Promise<ServerConfig>; | ||
| 119 | }; | ||
| 116 | } | 120 | } |
packages/contracts/src/server.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { z } from "zod"; | ||
| 2 | |||
| 3 | export const keybindingCommandSchema = z.enum([ | ||
| 4 | "terminal.toggle", | ||
| 5 | "terminal.split", | ||
| 6 | "terminal.new", | ||
| 7 | ]); | ||
| 8 | |||
| 9 | const keybindingValueSchema = z.string().trim().min(1).max(64); | ||
| 10 | const keybindingWhenSchema = z.string().trim().min(1).max(256); | ||
| 11 | |||
| 12 | export const keybindingRuleSchema = z.object({ | ||
| 13 | key: keybindingValueSchema, | ||
| 14 | command: keybindingCommandSchema, | ||
| 15 | when: keybindingWhenSchema.optional(), | ||
| 16 | }); | ||
| 17 | |||
| 18 | export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); | ||
| 19 | |||
| 20 | export const serverConfigSchema = z.object({ | ||
| 21 | cwd: z.string().min(1), | ||
| 22 | keybindings: keybindingsConfigSchema.default([]), | ||
| 23 | }); | ||
| 24 | |||
| 25 | export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>; | ||
| 26 | export type KeybindingRule = z.infer<typeof keybindingRuleSchema>; | ||
| 27 | export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>; | ||
| 28 | export type ServerConfig = z.infer<typeof serverConfigSchema>; | ||