diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md new file mode 100644 index 0000000000000000000000000000000000000000..a44f1f5f4ff997d13508a4ed7d28a4993d7b9d19 --- /dev/null +++ b/KEYBINDINGS.md @@ -0,0 +1,100 @@ +# Keybindings + +T3 Code reads keybindings from: + +- `~/.t3/keybindings.json` + +The file must be a JSON array of rules: + +```json +[ + { "key": "mod+g", "command": "terminal.toggle" }, + { "key": "mod+shift+g", "command": "terminal.new", "when": "terminalFocus" } +] +``` + +See the full schema for more details: [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) + +## Defaults + +```json +[ + { "key": "mod+j", "command": "terminal.toggle" }, + { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, + { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, + { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, + { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, + { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, + { "key": "mod+o", "command": "editor.openFavorite" } +] +``` + +For most up to date defaults, see [`DEFAULT_KEYBINDINGS` in `apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts) + +## Configuration + +### 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) +- `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state +- `chat.newLocal`: create a new local chat thread for the active project (no worktree context) +- `editor.openFavorite`: open current project/worktree in the last-used editor + +### 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`. + +### Precedence + +- Rules are evaluated in array order. +- For a key event, the last rule where both `key` matches and `when` evaluates to `true` wins. +- That means precedence is across commands, not only within the same command. diff --git a/apps/server/src/gitManager.test.ts b/apps/server/src/gitManager.test.ts index 67aaaf978b5a6ef39ab5bc9c14d94d47850f4aa8..e1c089df878824228f133679d24d42ab35af58f9 100644 --- a/apps/server/src/gitManager.test.ts +++ b/apps/server/src/gitManager.test.ts @@ -190,15 +190,15 @@ describe("GitManager", () => { const status = await manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-open-pr"); - expect(status.openPr).toEqual({ + expect(status.pr).toEqual({ number: 13, title: "Existing PR", url: "https://github.com/pingdotgg/codething-mvp/pull/13", baseBranch: "main", headBranch: "feature/status-open-pr", + state: "open", }); - expect(status.mergedPr).toBeNull(); - expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false); + expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); }); it("status includes merged PR metadata when there is no open PR", async () => { @@ -211,7 +211,6 @@ describe("GitManager", () => { const { runner, ghCalls } = createRunnerWithFakeGh({ prListSequence: [ - "[]", JSON.stringify([ { number: 26, @@ -219,6 +218,9 @@ describe("GitManager", () => { url: "https://github.com/pingdotgg/codething-mvp/pull/26", baseRefName: "main", headRefName: "feature/status-merged-pr", + state: "CLOSED", + mergedAt: "2026-02-14T10:00:00Z", + updatedAt: "2026-02-14T10:00:00Z", }, ]), ], @@ -231,18 +233,18 @@ describe("GitManager", () => { const status = await manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-merged-pr"); - expect(status.openPr).toBeNull(); - expect(status.mergedPr).toEqual({ + expect(status.pr).toEqual({ number: 26, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/26", baseBranch: "main", headBranch: "feature/status-merged-pr", + state: "merged", }); - expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); + expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); }); - it("status skips open PR lookup for branches without upstream", async () => { + it("status checks all PR states for branches without upstream", async () => { const repoDir = makeTempDir("t3code-git-manager-"); await initRepo(repoDir); await runGit(repoDir, ["checkout", "-b", "feature/local-only"]); @@ -258,13 +260,11 @@ describe("GitManager", () => { const status = await manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/local-only"); - expect(status.openPr).toBeNull(); - expect(status.mergedPr).toBeNull(); - expect(ghCalls.some((call) => call.includes("--state open"))).toBe(false); - expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); + expect(status.pr).toBeNull(); + expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); }); - it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => { + it("status is resilient to gh lookup failures and returns pr null", async () => { const repoDir = makeTempDir("t3code-git-manager-"); await initRepo(repoDir); await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); @@ -282,8 +282,7 @@ describe("GitManager", () => { const status = await manager.status({ cwd: repoDir }); expect(status.branch).toBe("feature/status-no-gh"); - expect(status.openPr).toBeNull(); - expect(status.mergedPr).toBeNull(); + expect(status.pr).toBeNull(); }); it("creates a commit when working tree is dirty", async () => { diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index d1ca7b0b57f4f99506811a960bfe65f2892254f4..8aaf529115350a13a4a2fea8321d2d0cb33fd069 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -67,8 +67,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { typeof title !== "string" || typeof url !== "string" || typeof baseRefName !== "string" || - typeof headRefName !== "string" || - typeof state !== "string" + typeof headRefName !== "string" ) { continue; } @@ -76,7 +75,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { let normalizedState: GitStatusPrState; if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { normalizedState = "merged"; - } else if (state === "OPEN") { + } else if (state === "OPEN" || state === undefined || state === null) { normalizedState = "open"; } else if (state === "CLOSED") { normalizedState = "closed"; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d761588e93872b3fa084d7cefde627f87a3728b5 --- /dev/null +++ b/apps/server/src/keybindings.test.ts @@ -0,0 +1,69 @@ +import { assert, describe, it } from "vitest"; + +import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; + +describe("server keybindings", () => { + it("parses shortcuts including plus key", () => { + assert.deepEqual(parseKeybindingShortcut("mod+j"), { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }); + assert.deepEqual(parseKeybindingShortcut("mod++"), { + key: "+", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }); + }); + + it("compiles valid rule with parsed when AST", () => { + const compiled = compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: "terminalOpen && !terminalFocus", + }); + + assert.deepEqual(compiled, { + command: "terminal.split", + shortcut: { + key: "d", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "and", + left: { type: "identifier", name: "terminalOpen" }, + right: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + }); + }); + + it("rejects invalid rules", () => { + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+shift+d+o", + command: "terminal.new", + }), + ); + + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: "terminalFocus && (", + }), + ); + }); +}); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..97e2de438ff79666a16cdbf60844a2fbbbaabd97 --- /dev/null +++ b/apps/server/src/keybindings.ts @@ -0,0 +1,355 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { + KeybindingRule, + KeybindingsConfig, + KeybindingShortcut, + KeybindingWhenNode, + ResolvedKeybindingRule, + ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { keybindingRuleSchema } from "@t3tools/contracts"; + +interface KeybindingsLogger { + warn(message: string, context?: Record): void; +} + +type WhenToken = + | { type: "identifier"; value: string } + | { type: "not" } + | { type: "and" } + | { type: "or" } + | { type: "lparen" } + | { type: "rparen" }; + +export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, + { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, + { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+o", command: "editor.openFavorite" }, +]; + +function normalizeKeyToken(token: string): string { + if (token === "space") return " "; + if (token === "esc") return "escape"; + return token; +} + +export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { + const rawTokens = value + .toLowerCase() + .split("+") + .map((token) => token.trim()); + const tokens = [...rawTokens]; + let trailingEmptyCount = 0; + while (tokens[tokens.length - 1] === "") { + trailingEmptyCount += 1; + tokens.pop(); + } + if (trailingEmptyCount > 0) { + tokens.push("+"); + } + if (tokens.some((token) => token.length === 0)) { + return null; + } + 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 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; +} + +export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { + const tokens = tokenizeWhenExpression(expression); + if (!tokens || tokens.length === 0) return null; + let index = 0; + + const parsePrimary = (): KeybindingWhenNode | 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 = (): KeybindingWhenNode | 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 = (): KeybindingWhenNode | 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 = (): KeybindingWhenNode | 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; +} + +export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { + const key = rule.key.trim(); + if (key.length === 0) return null; + const shortcut = parseKeybindingShortcut(key); + if (!shortcut) return null; + + const when = rule.when?.trim(); + if (when && when.length > 0) { + const whenAst = parseKeybindingWhenExpression(when); + if (!whenAst) return null; + return { + command: rule.command, + shortcut, + whenAst, + }; + } + + return { + command: rule.command, + shortcut, + }; +} + +export function compileResolvedKeybindingsConfig( + config: KeybindingsConfig, +): ResolvedKeybindingsConfig { + const compiled: ResolvedKeybindingsConfig = []; + for (const rule of config) { + const resolved = compileResolvedKeybindingRule(rule); + if (!resolved) continue; + compiled.push(resolved); + } + return compiled; +} + +function compileDefaultKeybindings(): ResolvedKeybindingsConfig { + const resolved: ResolvedKeybindingsConfig = []; + for (const rule of DEFAULT_KEYBINDINGS) { + const compiled = compileResolvedKeybindingRule(rule); + if (!compiled) { + throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); + } + resolved.push(compiled); + } + return resolved; +} + +const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); + +function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { + if (custom.length === 0) { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + const merged = [...retainedDefaults, ...custom]; + + if (merged.length <= 256) { + return merged; + } + + // Keep the latest rules when the config exceeds max size; later rules have higher precedence. + return merged.slice(-256); +} + +export function loadResolvedKeybindingsConfig( + logger: KeybindingsLogger, +): ResolvedKeybindingsConfig { + 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 [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const sanitized: ResolvedKeybindingsConfig = []; + let invalidEntries = 0; + for (const entry of parsed) { + const result = keybindingRuleSchema.safeParse(entry); + if (result.success) { + const compiled = compileResolvedKeybindingRule(result.data); + if (!compiled) { + invalidEntries += 1; + continue; + } + sanitized.push(compiled); + continue; + } + invalidEntries += 1; + } + if (invalidEntries > 0) { + logger.warn("ignoring invalid keybinding entries", { + path: configPath, + invalidEntries, + totalEntries: parsed.length, + }); + } + const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); + const mergedBeforeCapLength = + DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) + .length + sanitized.length; + const merged = mergeWithDefaultKeybindings(sanitized); + if (mergedBeforeCapLength > 256) { + logger.warn("truncating merged keybindings config to max entries", { + path: configPath, + maxEntries: 256, + }); + } + return merged; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + logger.warn("ignoring malformed keybindings config", { + path: configPath, + error: error instanceof Error ? error.message : String(error), + }); + } + return [...DEFAULT_RESOLVED_KEYBINDINGS]; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index d9bee192f7a5e8fb5d15faf21317e80b2d576c68..d7ab19ce7f55d0adbc12c61111b97d36ceecb38f 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -11,10 +11,13 @@ import { DEFAULT_TERMINAL_ID, WS_CHANNELS, WS_METHODS, + type KeybindingsConfig, + type ResolvedKeybindingsConfig, type WsPush, type WsResponse, } from "@t3tools/contracts"; import { ProjectRegistry } from "./projectRegistry"; +import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; import type { TerminalClearInput, TerminalCloseInput, @@ -192,6 +195,32 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro } } +function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { + const resolved: ResolvedKeybindingsConfig = []; + for (const binding of bindings) { + const compiled = compileResolvedKeybindingRule(binding); + if (!compiled) { + throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); + } + resolved.push(compiled); + } + return resolved; +} + +const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); + +function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig { + if (custom.length === 0) { + return DEFAULT_RESOLVED_KEYBINDINGS; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + return compileKeybindings([...retainedDefaults, ...custom].slice(-256)); +} + describe("WebSocket Server", () => { let server: ReturnType | null = null; const connections: WebSocket[] = []; @@ -307,7 +336,225 @@ 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: DEFAULT_RESOLVED_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+n", 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: mergeWithDefaultsForTest([ + { key: "cmd+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+n", 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: mergeWithDefaultsForTest([{ 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 keybindings with malformed when expressions", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fakeHome = makeTempDir("t3code-home-invalid-when-"); + 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", 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: DEFAULT_RESOLVED_KEYBINDINGS, + }); + expect( + warnSpy.mock.calls.some(([message]) => + String(message).includes("ignoring invalid keybinding entries"), + ), + ).toBe(true); + }); + + it("reads keybindings once at startup and caches the resolved config", async () => { + const fakeHome = makeTempDir("t3code-home-cached-config-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const keybindingsPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync( + keybindingsPath, + JSON.stringify([{ key: "cmd+j", command: "terminal.toggle" }]), + "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 firstResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(firstResponse.error).toBeUndefined(); + expect(firstResponse.result).toEqual({ + cwd: "/my/workspace", + keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]), + }); + + fs.writeFileSync( + keybindingsPath, + JSON.stringify([{ key: "cmd+k", command: "terminal.toggle" }]), + "utf8", + ); + + const secondResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(secondResponse.error).toBeUndefined(); + expect(secondResponse.result).toEqual(firstResponse.result); + }); + + 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: DEFAULT_RESOLVED_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: DEFAULT_RESOLVED_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..a8cbbba51e572a2efd94aa8a6548719136c36991 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -31,6 +31,7 @@ import { removeGitWorktree, } from "./git"; import { TerminalManager } from "./terminalManager"; +import { loadResolvedKeybindingsConfig } from "./keybindings"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -90,6 +91,7 @@ export function createServer(options: ServerOptions) { const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); + const keybindingsConfig = loadResolvedKeybindingsConfig(logger); function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -410,7 +412,10 @@ export function createServer(options: ServerOptions) { return undefined; case WS_METHODS.serverGetConfig: - return { cwd }; + return { + cwd, + keybindings: keybindingsConfig, + }; default: throw new Error(`Unknown method: ${request.method}`); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index f5b670cecf0aa3a02543c0ce800b7e5535eb14b6..1e6dd9e923c3f22153196bd54f5fabc61a5d1e33 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -25,9 +25,15 @@ interface BranchToolbarProps { envMode: "local" | "worktree"; onEnvModeChange: (mode: "local" | "worktree") => void; envLocked: boolean; + onComposerFocusRequest?: () => void; } -export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) { +export default function BranchToolbar({ + envMode, + onEnvModeChange, + envLocked, + onComposerFocusRequest, +}: BranchToolbarProps) { const { state, dispatch } = useStore(); const api = useNativeApi(); const queryClient = useQueryClient(); @@ -123,6 +129,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B setThreadError(null); setThreadBranch(branch.name, null); setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); return; } @@ -135,6 +142,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B // Secondary worktree → point the thread at that worktree path. setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); return; } @@ -143,6 +151,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B setThreadError(null); setThreadBranch(branch.name, activeWorktreePath); setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); }, }); }; @@ -156,6 +165,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B setThreadBranch(name, activeWorktreePath); setBranchQuery(""); setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); }, }); }; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5104ddced5e66cfd9c8d77207f41406f36bdf69d..e16d09e2a3705389cb0008ad5727f25ff598f088 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeHighlight from "rehype-highlight"; @@ -13,7 +14,7 @@ const markdownComponents: Components = { }, }; -export default function ChatMarkdown({ text }: ChatMarkdownProps) { +function ChatMarkdown({ text }: ChatMarkdownProps) { return (
); } + +export default memo(ChatMarkdown); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f9649d2f9f7221cd08f994ce774fdc43e28a3e82..382d30b53664880cbf5e3d48dc15a82606088085 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,10 +1,14 @@ import { + EDITORS, + type EditorId, + ModelSlug, + type NativeApi, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, - type ProviderSendTurnAttachmentInput, + type ResolvedKeybindingsConfig, type ProviderApprovalDecision, - ModelSlug, + type ProviderSendTurnAttachmentInput, } from "@t3tools/contracts"; import { type ClipboardEvent, @@ -12,6 +16,8 @@ import { type FormEvent, Fragment, type KeyboardEvent, + memo, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -20,12 +26,9 @@ import { useState, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - gitBranchesQueryOptions, - gitCreateWorktreeMutationOptions, -} from "~/lib/gitReactQuery"; +import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; -import { EDITORS, type EditorId } from "@t3tools/contracts"; import { isElectron } from "../env"; import { buildBootstrapInput } from "../historyBootstrap"; import { @@ -40,6 +43,7 @@ import { derivePendingApprovals, derivePhase, deriveTimelineEntries, + type PendingApproval, deriveWorkLogEntries, formatDuration, formatElapsed, @@ -50,7 +54,14 @@ import { useStore } from "../store"; import type { ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; -import { isTerminalToggleShortcut } from "../terminal-shortcuts"; +import { + isOpenFavoriteEditorShortcut, + isTerminalCloseShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, + shortcutLabelForCommand, +} from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; @@ -70,7 +81,7 @@ import { Separator } from "./ui/separator"; import { Group, GroupSeparator } from "./ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; import { CursorIcon, Icon } from "./Icons"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -83,6 +94,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; @@ -130,7 +142,9 @@ export default function ChatView() { const { state, dispatch } = useStore(); const api = useNativeApi(); const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ api, queryClient })); + const createWorktreeMutation = useMutation( + gitCreateWorktreeMutationOptions({ api, queryClient }), + ); const [prompt, setPrompt] = useState(""); const [composerImages, setComposerImages] = useState([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); @@ -154,6 +168,7 @@ export default function ChatView() { const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeThreadId = activeThread?.id ?? null; + const activeSessionId = activeThread?.session?.sessionId; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, @@ -254,14 +269,32 @@ export default function ChatView() { } as const); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); + const keybindingsQuery = useQuery({ + ...serverConfigQueryOptions(api), + select: (config) => config.keybindings, + }); + const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; + const splitTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.split"), + [keybindings], + ); + const newTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.new"), + [keybindings], + ); + const closeTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.close"), + [keybindings], + ); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); + const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { if (!image.previewUrl) continue; @@ -275,6 +308,11 @@ export default function ChatView() { const cursor = textarea.value.length; textarea.setSelectionRange(cursor, cursor); }, []); + const scheduleComposerFocus = useCallback(() => { + window.requestAnimationFrame(() => { + focusComposer(); + }); + }, [focusComposer]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; dispatch({ @@ -346,6 +384,7 @@ export default function ChatView() { const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; dispatch({ type: "SET_RUNTIME_MODE", mode }); + scheduleComposerFocus(); if (!api) return; const sessionIds = state.threads @@ -463,7 +502,7 @@ export default function ChatView() { if (phase !== "running") return; const timer = window.setInterval(() => { setNowTick(Date.now()); - }, 250); + }, 1000); return () => { window.clearInterval(timer); }; @@ -490,15 +529,74 @@ 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, keybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + toggleTerminalVisibility(); + return; + } + + if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) { + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open: true, + }); + } + splitTerminal(); + return; + } + + if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) return; + closeTerminal(activeThread.activeTerminalId); + return; + } + + if (!isTerminalNewShortcut(event, keybindings, { 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, + activeThread?.activeTerminalId, + activeThreadId, + closeTerminal, + createNewTerminal, + dispatch, + splitTerminal, + keybindings, + toggleTerminalVisibility, + ]); const setThreadError = (threadId: string | null, error: string | null) => { if (!threadId) return; @@ -796,37 +894,58 @@ export default function ChatView() { }); }; - const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { - if (!api || !activeThread?.session) return; + const onRespondToApproval = useCallback( + async (requestId: string, decision: ProviderApprovalDecision) => { + if (!api || !activeSessionId || !activeThreadId) return; - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - try { - await api.providers.respondToRequest({ - sessionId: activeThread.session.sessionId, - requestId, - decision, - }); - } catch (err) { + setRespondingRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + try { + await api.providers.respondToRequest({ + sessionId: activeSessionId, + requestId, + decision, + }); + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThreadId, + error: err instanceof Error ? err.message : "Failed to submit approval decision.", + }); + } finally { + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + } + }, + [activeSessionId, activeThreadId, api, dispatch], + ); + + const onModelSelect = useCallback( + (model: ModelSlug) => { + if (!activeThread) return; dispatch({ - type: "SET_ERROR", + type: "SET_THREAD_MODEL", threadId: activeThread.id, - error: err instanceof Error ? err.message : "Failed to submit approval decision.", + model: resolveModelSlug(model), }); - } finally { - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); - } - }; - - const onModelSelect = (model: ModelSlug) => { - if (!activeThread) return; - dispatch({ - type: "SET_THREAD_MODEL", - threadId: activeThread.id, - model: resolveModelSlug(model), - }); - }; + scheduleComposerFocus(); + }, + [activeThread, dispatch, scheduleComposerFocus], + ); + const onEffortSelect = useCallback( + (effort: ReasoningEffort) => { + setSelectedEffort(effort); + scheduleComposerFocus(); + }, + [scheduleComposerFocus], + ); + const onEnvModeChange = useCallback( + (mode: "local" | "worktree") => { + setEnvMode(mode); + scheduleComposerFocus(); + }, + [scheduleComposerFocus], + ); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -834,6 +953,18 @@ export default function ChatView() { void onSend(e as unknown as FormEvent); } }; + const onToggleWorkGroup = useCallback((groupId: string) => { + setExpandedWorkGroups((existing) => ({ + ...existing, + [groupId]: !existing[groupId], + })); + }, []); + const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { + setExpandedImage(image); + }, []); + const onToggleDiff = useCallback(() => { + dispatch({ type: "TOGGLE_DIFF" }); + }, [dispatch]); // Empty state: no active thread if (!activeThread) { @@ -859,101 +990,24 @@ export default function ChatView() {
-
-

{activeThread.title}

- {activeProject && {activeProject.name}} -
-
- {/* Open in editor */} - {activeProject && } - {/* Git actions */} - {activeProject && } - - {/* Diff toggle */} - -
+
{/* Error banner */} - {activeThread.error && ( -
- - - - {activeThread.error} - - -
- )} - - {pendingApprovals.length > 0 && ( -
- {pendingApprovals.map((approval) => { - const isResponding = respondingRequestIds.includes(approval.requestId); - - return ( - - - - {approval.requestKind === "command" - ? "Command approval requested" - : "File-change approval requested"} - - - {approval.detail} - - - - - - - - - ); - })} -
- )} + + {/* Messages */}
- {activeThread.messages.length === 0 && !isWorking ? ( -
-

- Send a message to start the conversation. -

-
- ) : ( -
- {timelineEntries.map((timelineEntry, index) => { - if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { - return null; - } - - const showCompletionDivider = - timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id; - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - - const groupId = timelineEntry.id; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; - - return ( - -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - <> - {workEntry.label} - - {workEntry.detail} - - - ) : ( - workEntry.label - )} -

-
- ))} -
-
-
- ); - } - - if (timelineEntry.message.role === "user") { - const userImages = timelineEntry.message.attachments ?? []; - return ( - -
-
- {userImages.length > 0 && ( -
- {userImages.map((image) => ( -
- {image.previewUrl ? ( - {image.name} - setExpandedImage({ src: image.previewUrl!, name: image.name }) - } - /> - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {timelineEntry.message.text && ( -
-                            {timelineEntry.message.text}
-                          
- )} -

- {formatTimestamp(timelineEntry.message.createdAt)} -

-
-
-
- ); - } - - return ( - - {showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed(timelineEntry.message.createdAt, nowIso) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get(timelineEntry.message.id), - ), - )} -

-
-
- ); - })} - {isWorking && ( -
- -
- - - - - -
-
- )} -
-
- )} + 0} + isWorking={isWorking} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + assistantCompletionByItemId={assistantCompletionByItemId} + nowIso={nowIso} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + onImageExpand={onExpandTimelineImage} + messagesEndRef={messagesEndRef} + />
{/* Input bar */} @@ -1230,7 +1105,7 @@ export default function ChatView() { {/* Reasoning effort */} - + {/* Divider */} @@ -1333,7 +1208,12 @@ export default function ChatView() {
{isGitRepo && ( - + )} {activeThread.terminalOpen && api && activeProject && ( @@ -1350,6 +1230,9 @@ export default function ChatView() { focusRequestId={terminalFocusRequestId} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} + splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} + newShortcutLabel={newTerminalShortcutLabel ?? undefined} + closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} onActiveTerminalChange={activateTerminal} onCloseTerminal={closeTerminal} onHeightChange={(height) => @@ -1400,7 +1283,355 @@ export default function ChatView() { ); } -function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) { +interface ChatHeaderProps { + activeThreadTitle: string; + activeProjectName: string | undefined; + keybindings: ResolvedKeybindingsConfig; + api: NativeApi | undefined; + gitCwd: string | null; + diffOpen: boolean; + onToggleDiff: () => void; +} + +const ChatHeader = memo(function ChatHeader({ + activeThreadTitle, + activeProjectName, + keybindings, + api, + gitCwd, + diffOpen, + onToggleDiff, +}: ChatHeaderProps) { + return ( + <> +
+

{activeThreadTitle}

+ {activeProjectName && {activeProjectName}} +
+
+ {activeProjectName && } + {activeProjectName && } + +
+ + ); +}); + +const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { + if (!error) return null; + return ( +
+ + + + {error} + + +
+ ); +}); + +interface PendingApprovalsPanelProps { + pendingApprovals: PendingApproval[]; + respondingRequestIds: string[]; + onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise; +} + +const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ + pendingApprovals, + respondingRequestIds, + onRespondToApproval, +}: PendingApprovalsPanelProps) { + if (pendingApprovals.length === 0) return null; + return ( +
+ {pendingApprovals.map((approval) => { + const isResponding = respondingRequestIds.includes(approval.requestId); + + return ( + + + + {approval.requestKind === "command" + ? "Command approval requested" + : "File-change approval requested"} + + + {approval.detail} + + + + + + + + + ); + })} +
+ ); +}); + +interface MessagesTimelineProps { + hasMessages: boolean; + isWorking: boolean; + timelineEntries: ReturnType; + completionDividerBeforeEntryId: string | null; + completionSummary: string | null; + assistantCompletionByItemId: Map; + nowIso: string; + expandedWorkGroups: Record; + onToggleWorkGroup: (groupId: string) => void; + onImageExpand: (image: ExpandedImagePreview) => void; + messagesEndRef: RefObject; +} + +const MessagesTimeline = memo(function MessagesTimeline({ + hasMessages, + isWorking, + timelineEntries, + completionDividerBeforeEntryId, + completionSummary, + assistantCompletionByItemId, + nowIso, + expandedWorkGroups, + onToggleWorkGroup, + onImageExpand, + messagesEndRef, +}: MessagesTimelineProps) { + if (!hasMessages && !isWorking) { + return ( +
+

Send a message to start the conversation.

+
+ ); + } + + return ( +
+ {timelineEntries.map((timelineEntry, index) => { + if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { + return null; + } + + const showCompletionDivider = + timelineEntry.kind === "message" && + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id; + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + + const groupId = timelineEntry.id; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; + + return ( + +
+
+

+ {groupLabel} +

+ {hasOverflow && ( + + )} +
+
+ {visibleEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( + <> + {workEntry.label} + + {workEntry.detail} + + + ) : ( + workEntry.label + )} +

+
+ ))} +
+
+
+ ); + } + + if (timelineEntry.message.role === "user") { + const userImages = timelineEntry.message.attachments ?? []; + return ( + +
+
+ {userImages.length > 0 && ( +
+ {userImages.map((image) => ( +
+ {image.previewUrl ? ( + {image.name} + onImageExpand({ src: image.previewUrl!, name: image.name }) + } + /> + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ )} + {timelineEntry.message.text && ( +
+                      {timelineEntry.message.text}
+                    
+ )} +

+ {formatTimestamp(timelineEntry.message.createdAt)} +

+
+
+
+ ); + } + + return ( + + {showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ +

+ {formatMessageMeta( + timelineEntry.message.createdAt, + timelineEntry.message.streaming + ? formatElapsed(timelineEntry.message.createdAt, nowIso) + : formatElapsed( + timelineEntry.message.createdAt, + assistantCompletionByItemId.get(timelineEntry.message.id), + ), + )} +

+
+
+ ); + })} + {isWorking && ( +
+ +
+ + + + + +
+
+ )} +
+
+ ); +}); + +const ModelPicker = memo(function ModelPicker(props: { + model: ModelSlug; + onModelChange: (model: ModelSlug) => void; +}) { return ( ); -} +}); -function ReasoningEffortPicker(props: { +const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { effort: ReasoningEffort; onEffortChange: (effort: ReasoningEffort) => void; }) { @@ -1443,9 +1674,13 @@ function ReasoningEffortPicker(props: { ); -} +}); -function OpenInPicker() { +const OpenInPicker = memo(function OpenInPicker({ + keybindings, +}: { + keybindings: ResolvedKeybindingsConfig; +}) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; @@ -1458,9 +1693,9 @@ function OpenInPicker() { value: "cursor", }, { - label: navigator.platform.includes("Mac") + label: isMacPlatform(navigator.platform) ? "Finder" - : navigator.platform.includes("Win") + : isWindowsPlatform(navigator.platform) ? "Explorer" : "Files", Icon: FolderClosedIcon, @@ -1486,20 +1721,23 @@ function OpenInPicker() { [api, activeProject, activeThread, lastEditor, setLastEditor], ); - // Cmd+O / Ctrl+O to open in last-used editor + const openFavoriteEditorShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), + [keybindings], + ); + useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - if (api && activeProject) { - e.preventDefault(); - const cwd = activeThread?.worktreePath ?? activeProject.cwd; - void api.shell.openInEditor(cwd, lastEditor); - } - } + if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; + if (!api || !activeProject) return; + + e.preventDefault(); + const cwd = activeThread?.worktreePath ?? activeProject.cwd; + void api.shell.openInEditor(cwd, lastEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [api, activeProject, activeThread, lastEditor]); + }, [api, activeProject, activeThread, keybindings, lastEditor]); return ( @@ -1517,10 +1755,8 @@ function OpenInPicker() { openInEditor(value)}> ))} @@ -1528,4 +1764,4 @@ function OpenInPicker() { ); -} +}); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index feac3a689bd2ee9ab9b229a65f1d53a66f80b1af..ba8cbdad4485a9b7967075bfbddef6ac4cbf297a 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -20,8 +20,7 @@ function status(overrides: Partial = {}): GitStatusResult { hasUpstream: true, aheadCount: 0, behindCount: 0, - openPr: null, - mergedPr: null, + pr: null, ...overrides, }; } @@ -30,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => { it("resolveQuickAction opens the existing PR", () => { const quick = resolveQuickAction( status({ - openPr: { + pr: { number: 10, title: "Open PR", url: "https://example.com/pr/10", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -46,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => { it("buildMenuItems disables commit/push and enables open PR", () => { const items = buildMenuItems( status({ - openPr: { + pr: { number: 11, title: "Existing PR", url: "https://example.com/pr/11", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -148,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { const quick = resolveQuickAction( status({ aheadCount: 3, - openPr: { + pr: { number: 13, title: "Open PR", url: "https://example.com/pr/13", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -165,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { const items = buildMenuItems( status({ aheadCount: 2, - openPr: { + pr: { number: 12, title: "Existing PR", url: "https://example.com/pr/12", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -205,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { describe("when: branch is clean, ahead, and has no open PR", () => { it("resolveQuickAction pushes and creates a PR", () => { - const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false); + const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { kind: "run_action", action: "commit_push_pr", @@ -214,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { }); it("buildMenuItems enables push and create PR, with commit disabled", () => { - const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false); + const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); assert.deepEqual(items, [ { id: "commit", @@ -247,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => { describe("when: branch is clean, up to date, and has no open PR", () => { it("resolveQuickAction returns disabled no-action state", () => { const quick = resolveQuickAction( - status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }), + status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), false, ); assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); it("buildMenuItems disables commit, push, and create PR", () => { - const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false); + const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); assert.deepEqual(items, [ { id: "commit", @@ -291,7 +294,7 @@ describe("when: branch is behind upstream", () => { }); it("buildMenuItems disables push and create PR", () => { - const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false); + const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); assert.deepEqual(items, [ { id: "commit", @@ -347,12 +350,13 @@ describe("when: working tree has local changes", () => { const quick = resolveQuickAction( status({ hasWorkingTreeChanges: true, - openPr: { + pr: { number: 16, title: "Existing PR", url: "https://example.com/pr/16", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -412,7 +416,7 @@ describe("when: on default branch without open PR", () => { it("resolveQuickAction returns push when branch is ahead", () => { const quick = resolveQuickAction( - status({ branch: "main", aheadCount: 2, openPr: null }), + status({ branch: "main", aheadCount: 2, pr: null }), false, true, ); @@ -512,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => { describe("when: branch has no upstream configured", () => { it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { const quick = resolveQuickAction( - status({ hasUpstream: false, openPr: null, aheadCount: 0 }), + status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, ); assert.deepInclude(quick, { @@ -527,12 +531,13 @@ describe("when: branch has no upstream configured", () => { status({ hasUpstream: false, aheadCount: 0, - openPr: { + pr: { number: 14, title: "Existing PR", url: "https://example.com/pr/14", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -549,12 +554,13 @@ describe("when: branch has no upstream configured", () => { status({ hasUpstream: false, aheadCount: 1, - openPr: { + pr: { number: 15, title: "Existing PR", url: "https://example.com/pr/15", baseBranch: "main", headBranch: "feature/test", + state: "open", }, }), false, @@ -569,7 +575,7 @@ describe("when: branch has no upstream configured", () => { it("buildMenuItems disables push and create PR when no commits are ahead", () => { const items = buildMenuItems( - status({ hasUpstream: false, openPr: null, aheadCount: 0 }), + status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, ); assert.deepEqual(items, [ diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 50de7b6c5751294e9e3a05b6c8de7402b9589151..44497f48fe5e57c547a816369eac18059f2f7a6d 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -108,7 +108,7 @@ export function buildMenuItems( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.openPr !== null; + const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; const canCommit = !isBusy && hasChanges; const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; @@ -178,7 +178,7 @@ export function resolveQuickAction( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.openPr !== null; + const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 83c74e5f5807715970002a1ecfb30f2fb6d2adc7..438c0ac63e58a46dc7c18a0340b084327c994a9e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -57,7 +57,7 @@ function getMenuActionDisabledReason( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.openPr !== null; + const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; @@ -217,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }); return; } - const prUrl = gitStatusForActions?.openPr?.url ?? null; + const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", @@ -237,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }), }); void promise.catch(() => undefined); - }, [api, gitStatusForActions?.openPr?.url, threadToastData]); + }, [api, gitStatusForActions?.pr, threadToastData]); const runGitActionWithToast = useCallback( async ({ @@ -297,7 +297,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp stopProgressUpdates(); const resultToast = summarizeGitResult(result); - const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url; + const existingOpenPrUrl = + gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined; + const prUrl = result.pr.url ?? existingOpenPrUrl; const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; const shouldOfferOpenPrCta = (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; @@ -312,7 +314,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp title: resultToast.title, description: resultToast.description, timeout: 0, - data: threadToastData, + data: { + ...threadToastData, + dismissAfterVisibleMs: 10_000, + }, ...(shouldOfferPushCta ? { actionProps: { @@ -362,7 +367,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp api, gitStatusForActions?.branch, gitStatusForActions?.hasWorkingTreeChanges, - gitStatusForActions?.openPr?.url, + gitStatusForActions?.pr, isDefaultBranch, maybeConfirmPushToDefaultBranch, runImmediateGitActionMutation, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5f18ccb42eee41b554ea38e1b0e5b610ce7a9cc6..01bbcbafa299af2142fe438bfebb3ee3e6e3e79f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,12 +1,20 @@ -import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; +import { + GitPullRequestIcon, + MonitorIcon, + MoonIcon, + SunIcon, + TerminalIcon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; +import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -15,9 +23,13 @@ import { } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; + function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -36,7 +48,7 @@ function inferProjectName(cwd: string): string { } interface ThreadStatusPill { - label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged"; + label: "Working" | "Connecting" | "Completed" | "Awaiting response"; colorClass: string; dotClass: string; pulse: boolean; @@ -48,6 +60,13 @@ interface TerminalStatusIndicator { pulse: boolean; } +interface PrStatusIndicator { + label: "PR open" | "PR closed" | "PR merged"; + colorClass: string; +} + +type ThreadPrState = NonNullable["state"] | null; + function hasUnseenCompletion(thread: Thread): boolean { if (!thread.latestTurnCompletedAt) return false; const completedAt = Date.parse(thread.latestTurnCompletedAt); @@ -62,7 +81,6 @@ function hasUnseenCompletion(thread: Thread): boolean { function threadStatusPill( thread: Thread, hasPendingApprovals: boolean, - hasMergedPr: boolean, ): ThreadStatusPill | null { if (hasPendingApprovals) { return { @@ -91,15 +109,6 @@ function threadStatusPill( }; } - if (hasMergedPr) { - return { - label: "Merged", - colorClass: "text-violet-600 dark:text-violet-300/90", - dotClass: "bg-violet-500 dark:bg-violet-300/90", - pulse: false, - }; - } - if (hasUnseenCompletion(thread)) { return { label: "Completed", @@ -112,6 +121,28 @@ function threadStatusPill( return null; } +function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { + if (prState === "open") { + return { + label: "PR open", + colorClass: "text-emerald-600 dark:text-emerald-300/90", + }; + } + if (prState === "closed") { + return { + label: "PR closed", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + }; + } + if (prState === "merged") { + return { + label: "PR merged", + colorClass: "text-violet-600 dark:text-violet-300/90", + }; + } + return null; +} + function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { if (thread.runningTerminalIds.length === 0) { return null; @@ -126,8 +157,14 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); + const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ + ...serverConfigQueryOptions(api), + select: (config) => config.keybindings, + }); const queryClient = useQueryClient(); - const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient })); + const removeWorktreeMutation = useMutation( + gitRemoveWorktreeMutationOptions({ api, queryClient }), + ); const { theme, setTheme } = useTheme(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); @@ -176,7 +213,7 @@ export default function Sidebar() { }; }), }); - const mergedPrByThreadId = useMemo(() => { + const prStateByThreadId = useMemo(() => { const statusByCwd = new Map(); for (let index = 0; index < threadGitStatusCwds.length; index += 1) { const cwd = threadGitStatusCwds[index]; @@ -187,18 +224,24 @@ export default function Sidebar() { } } - const map = new Map(); + const map = new Map(); for (const target of threadGitTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null); + map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null); } return map; }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); const handleNewThread = useCallback( - (projectId: string) => { + ( + projectId: string, + options?: { + branch?: string | null; + worktreePath?: string | null; + }, + ) => { dispatch({ type: "ADD_THREAD", thread: { @@ -224,8 +267,8 @@ export default function Sidebar() { events: [], error: null, createdAt: new Date().toISOString(), - branch: null, - worktreePath: null, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, }, }); }, @@ -392,29 +435,77 @@ export default function Sidebar() { [api, dispatch, removeWorktreeMutation, state.projects, state.threads], ); + const handleProjectContextMenu = useCallback( + async (projectId: string, position: { x: number; y: number }) => { + if (!api) return; + const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); + if (clicked !== "delete") return; + + const project = state.projects.find((entry) => entry.id === projectId); + if (!project) return; + + const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); + if (projectThreads.length > 0) { + toastManager.add({ + type: "warning", + title: "Project is not empty", + description: "Delete all threads in this project before deleting it.", + }); + return; + } + + const confirmed = await api.dialogs.confirm( + [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"), + ); + if (!confirmed) return; + + if (isElectron) { + try { + await api.projects.remove({ id: projectId }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error deleting project."; + console.error("Failed to remove project", { projectId, error }); + toastManager.add({ + type: "error", + title: `Failed to delete "${project.name}"`, + description: message, + }); + return; + } + } + + dispatch({ type: "DELETE_PROJECT", projectId }); + }, + [api, dispatch, state.projects, state.threads], + ); + useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - const isNewThreadShortcut = - event.metaKey && - event.shiftKey && - !event.ctrlKey && - !event.altKey && - event.key.toLowerCase() === "o"; - if (!isNewThreadShortcut) return; - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + if (isChatNewLocalShortcut(event, keybindings)) { + const projectId = activeThread?.projectId ?? state.projects[0]?.id; + if (!projectId) return; + event.preventDefault(); + handleNewThread(projectId); + return; + } + + if (!isChatNewShortcut(event, keybindings)) return; const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; - event.preventDefault(); - handleNewThread(projectId); + handleNewThread(projectId, { + branch: activeThread?.branch ?? null, + worktreePath: activeThread?.worktreePath ?? null, + }); }; window.addEventListener("keydown", onWindowKeyDown); return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); + }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); return (
+ {prStatus && ( + + + + )} {terminalStatus && ( { + const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { - await api.terminal.write({ threadId, terminalId, data: "\u000c" }); + await api.terminal.write({ threadId, terminalId, data }); } catch (error) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Failed to clear terminal", - ); + writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } }; terminal.attachCustomKeyEventHandler((event) => { + const navigationData = terminalNavigationShortcutData(event); + if (navigationData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(navigationData, "Failed to move cursor"); + return false; + } + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); - void sendClearShortcut(); + void sendTerminalInput("\u000c", "Failed to clear terminal"); return false; }); @@ -416,6 +421,9 @@ interface ThreadTerminalDrawerProps { focusRequestId: number; onSplitTerminal: () => void; onNewTerminal: () => void; + splitShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; @@ -431,15 +439,9 @@ interface TerminalActionButtonProps { function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { return ( - - } + } > {children} @@ -468,6 +470,9 @@ export default function ThreadTerminalDrawer({ focusRequestId, onSplitTerminal, onNewTerminal, + splitShortcutLabel, + newShortcutLabel, + closeShortcutLabel, onActiveTerminalChange, onCloseTerminal, onHeightChange, @@ -583,6 +588,9 @@ export default function ThreadTerminalDrawer({ ), [normalizedTerminalIds], ); + const closeTerminalActionLabel = closeShortcutLabel + ? `Close Terminal (${closeShortcutLabel})` + : "Close Terminal"; useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -694,7 +702,9 @@ export default function ThreadTerminalDrawer({ @@ -702,7 +712,7 @@ export default function ThreadTerminalDrawer({ @@ -710,7 +720,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(resolvedActiveTerminalId)} - label="Close Terminal" + label={closeTerminalActionLabel} > @@ -779,21 +789,25 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(resolvedActiveTerminalId)} - label="Close Terminal" + label={closeTerminalActionLabel} > @@ -831,6 +845,9 @@ export default function ThreadTerminalDrawer({ > {terminalGroup.terminalIds.map((terminalId) => { const isActive = terminalId === resolvedActiveTerminalId; + const closeTerminalLabel = `Close ${ + terminalLabelById.get(terminalId) ?? "terminal" + }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; return (
{normalizedTerminalIds.length > 1 && ( - + + onCloseTerminal(terminalId)} + aria-label={closeTerminalLabel} + /> + } + > + + + + {closeTerminalLabel} + + )}
); diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..def62c884fef72acc2fa2c3ba65e5339fdfeb012 --- /dev/null +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "vitest"; +import { shouldHideCollapsedToastContent } from "./toast.logic"; + +describe("shouldHideCollapsedToastContent", () => { + it("keeps a single visible toast readable", () => { + assert.equal(shouldHideCollapsedToastContent(0, 1), false); + }); + + it("keeps the front-most toast readable in a visible stack", () => { + assert.equal(shouldHideCollapsedToastContent(0, 3), false); + }); + + it("hides non-front toasts until the stack is expanded", () => { + assert.equal(shouldHideCollapsedToastContent(1, 3), true); + }); +}); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..500203033abba5f276bda21175cc8b9240dbd9d9 --- /dev/null +++ b/apps/web/src/components/ui/toast.logic.ts @@ -0,0 +1,9 @@ +export function shouldHideCollapsedToastContent( + visibleToastIndex: number, + visibleToastCount: number, +): boolean { + // Keep the front-most toast readable even if Base UI marks it as "behind" + // due to toasts hidden by thread filtering. + if (visibleToastCount <= 1) return false; + return visibleToastIndex > 0; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 6c9614a91a0034a10f764b873e5fd6de64b6dd67..7b4879e9f029b074bca0e4b887017c379f7a25c4 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,6 +1,7 @@ "use client"; import { Toast } from "@base-ui/react/toast"; +import { useEffect } from "react"; import { CircleAlertIcon, CircleCheckIcon, @@ -12,14 +13,18 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; import { useStore } from "~/store"; +import { shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { threadId?: string | null; tooltipStyle?: boolean; + dismissAfterVisibleMs?: number; }; const toastManager = Toast.createToastManager(); const anchoredToastManager = Toast.createToastManager(); +type ToastId = ReturnType; +const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, @@ -50,6 +55,84 @@ function shouldRenderForActiveThread( return toastThreadId === activeThreadId; } +function ThreadToastVisibleAutoDismiss({ + toastId, + dismissAfterVisibleMs, +}: { + toastId: ToastId; + dismissAfterVisibleMs: number | undefined; +}) { + useEffect(() => { + if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; + if (typeof window === "undefined" || typeof document === "undefined") return; + + let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; + let startedAtMs: number | null = null; + let timeoutId: number | null = null; + let closed = false; + + const clearTimer = () => { + if (timeoutId === null) return; + window.clearTimeout(timeoutId); + timeoutId = null; + }; + + const closeToast = () => { + if (closed) return; + closed = true; + threadToastVisibleTimeoutRemainingMs.delete(toastId); + toastManager.close(toastId); + }; + + const pause = () => { + if (startedAtMs === null) return; + remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); + startedAtMs = null; + clearTimer(); + threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); + }; + + const start = () => { + if (closed || startedAtMs !== null) return; + if (remainingMs <= 0) { + closeToast(); + return; + } + startedAtMs = Date.now(); + clearTimer(); + timeoutId = window.setTimeout(() => { + remainingMs = 0; + startedAtMs = null; + closeToast(); + }, remainingMs); + }; + + const syncTimer = () => { + const shouldRun = document.visibilityState === "visible" && document.hasFocus(); + if (shouldRun) { + start(); + return; + } + pause(); + }; + + syncTimer(); + document.addEventListener("visibilitychange", syncTimer); + window.addEventListener("focus", syncTimer); + window.addEventListener("blur", syncTimer); + + return () => { + document.removeEventListener("visibilitychange", syncTimer); + window.removeEventListener("focus", syncTimer); + window.removeEventListener("blur", syncTimer); + pause(); + clearTimer(); + }; + }, [dismissAfterVisibleMs, toastId]); + + return null; +} + function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { return ( @@ -64,6 +147,18 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { const { toasts } = Toast.useToastManager(); const activeThreadId = state.activeThreadId; const isTop = position.startsWith("top"); + const visibleToasts = toasts.filter((toast) => + shouldRenderForActiveThread(toast.data, activeThreadId), + ); + + useEffect(() => { + const activeToastIds = new Set(toasts.map((toast) => toast.id)); + for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { + if (!activeToastIds.has(toastId)) { + threadToastVisibleTimeoutRemainingMs.delete(toastId); + } + } + }, [toasts]); return ( @@ -81,96 +176,107 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { data-position={position} data-slot="toast-viewport" > - {toasts - .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId)) - .map((toast) => { - const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; + {visibleToasts.map((toast, visibleIndex) => { + const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; + const hideCollapsedContent = shouldHideCollapsedToastContent( + visibleIndex, + visibleToasts.length, + ); - return ( - + + - -
- {Icon && ( -
- -
- )} - -
- - +
+ {Icon && ( +
+
+ )} + +
+ +
- {toast.actionProps && ( - - {toast.actionProps.children} - - )} - - - ); - })} +
+ {toast.actionProps && ( + + {toast.actionProps.children} + + )} + + + ); + })} ); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a3b9c3244e4dbba13dc5252971c884c056e8324 --- /dev/null +++ b/apps/web/src/keybindings.test.ts @@ -0,0 +1,444 @@ +import { assert, describe, it } from "vitest"; + +import { + type KeybindingCommand, + type KeybindingShortcut, + type KeybindingWhenNode, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { + formatShortcutLabel, + isChatNewShortcut, + isChatNewLocalShortcut, + isOpenFavoriteEditorShortcut, + isTerminalClearShortcut, + isTerminalCloseShortcut, + isTerminalNewShortcut, + isTerminalSplitShortcut, + isTerminalToggleShortcut, + shortcutLabelForCommand, + terminalNavigationShortcutData, + type ShortcutEventLike, +} from "./keybindings"; + +function event(overrides: Partial = {}): ShortcutEventLike { + return { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides, + }; +} + +function modShortcut( + key: string, + overrides: Partial> = {}, +): KeybindingShortcut { + return { + key, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + ...overrides, + }; +} + +function whenIdentifier(name: string): KeybindingWhenNode { + return { type: "identifier", name }; +} + +function whenNot(node: KeybindingWhenNode): KeybindingWhenNode { + return { type: "not", node }; +} + +function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode { + return { type: "and", left, right }; +} + +interface TestBinding { + shortcut: KeybindingShortcut; + command: KeybindingCommand; + whenAst?: KeybindingWhenNode; +} + +function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { + return bindings.map((binding) => ({ + command: binding.command, + shortcut: binding.shortcut, + ...(binding.whenAst ? { whenAst: binding.whenAst } : {}), + })); +} + +const DEFAULT_BINDINGS = compile([ + { shortcut: modShortcut("j"), command: "terminal.toggle" }, + { + shortcut: modShortcut("d"), + command: "terminal.split", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("d", { shiftKey: true }), + command: "terminal.new", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("w"), + command: "terminal.close", + whenAst: whenIdentifier("terminalFocus"), + }, + { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, + { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, + { shortcut: modShortcut("o"), command: "editor.openFavorite" }, +]); + +describe("isTerminalToggleShortcut", () => { + it("matches Cmd+J on macOS", () => { + assert.isTrue( + isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" }), + ); + }); + + it("matches Ctrl+J on non-macOS", () => { + assert.isTrue( + isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" }), + ); + }); +}); + +describe("split/new/close terminal shortcuts", () => { + it("requires terminalFocus for default split/new/close bindings", () => { + assert.isFalse( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + assert.isFalse( + isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + ); + assert.isFalse( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: false }, + }), + ); + }); + + it("matches split/new when terminalFocus is true", () => { + assert.isTrue( + isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + assert.isTrue( + isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + assert.isTrue( + isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); + + it("supports when expressions", () => { + const keybindings = compile([ + { + shortcut: modShortcut("\\"), + command: "terminal.split", + whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), + }, + { + shortcut: modShortcut("n", { shiftKey: true }), + command: "terminal.new", + whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), + }, + { shortcut: modShortcut("j"), command: "terminal.toggle" }, + ]); + assert.isTrue( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ); + assert.isFalse( + isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: false, terminalFocus: false }, + }), + ); + assert.isTrue( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { + platform: "Win32", + context: { terminalOpen: true, terminalFocus: false }, + }), + ); + }); + + it("supports when boolean literals", () => { + const keybindings = compile([ + { shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("true") }, + { shortcut: modShortcut("m"), command: "terminal.new", whenAst: whenIdentifier("false") }, + ]); + + assert.isTrue( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + platform: "Linux", + }), + ); + assert.isFalse( + isTerminalNewShortcut(event({ key: "m", ctrlKey: true }), keybindings, { + platform: "Linux", + }), + ); + }); +}); + +describe("shortcutLabelForCommand", () => { + it("returns the most recent binding label", () => { + const bindings = compile([ + { + shortcut: modShortcut("\\"), + command: "terminal.split", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("\\", { shiftKey: true }), + command: "terminal.split", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + ]); + assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); + }); + + it("returns labels for non-terminal commands", () => { + assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), + "Ctrl+O", + ); + }); +}); + +describe("chat/editor shortcuts", () => { + it("matches chat.new shortcut", () => { + assert.isTrue( + isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + ); + assert.isTrue( + isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + ); + }); + + it("matches chat.newLocal shortcut", () => { + assert.isTrue( + isChatNewLocalShortcut( + event({ key: "n", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), + ); + assert.isTrue( + isChatNewLocalShortcut( + event({ key: "n", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "Linux", + }, + ), + ); + }); + + it("matches editor.openFavorite shortcut", () => { + assert.isTrue( + isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + ); + assert.isTrue( + isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + ); + }); +}); + +describe("cross-command precedence", () => { + it("uses when + order so a later focused rule overrides a global rule", () => { + const keybindings = compile([ + { shortcut: modShortcut("n"), command: "chat.new" }, + { + shortcut: modShortcut("n"), + command: "terminal.new", + whenAst: whenIdentifier("terminalFocus"), + }, + ]); + + assert.isTrue( + isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + assert.isFalse( + isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + assert.isFalse( + isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + assert.isTrue( + isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + }); + + it("still lets a later global rule win when both rules match", () => { + const keybindings = compile([ + { + shortcut: modShortcut("n"), + command: "terminal.new", + whenAst: whenIdentifier("terminalFocus"), + }, + { shortcut: modShortcut("n"), command: "chat.new" }, + ]); + + assert.isFalse( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + assert.isTrue( + isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + }); +}); + +describe("formatShortcutLabel", () => { + it("formats labels for macOS", () => { + assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D"); + }); + + it("formats labels for non-macOS", () => { + assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D"); + }); + + it("formats labels for plus key", () => { + assert.strictEqual(formatShortcutLabel(modShortcut("+"), "MacIntel"), "⌘+"); + assert.strictEqual(formatShortcutLabel(modShortcut("+"), "Linux"), "Ctrl++"); + }); +}); + +describe("isTerminalClearShortcut", () => { + it("matches Ctrl+L on all platforms", () => { + assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")); + assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")); + }); + + it("matches Cmd+K on macOS", () => { + assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")); + }); + + it("ignores non-keydown events", () => { + assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux")); + }); +}); + +describe("terminalNavigationShortcutData", () => { + it("maps Option+Arrow on macOS to word movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"), + "\u001bb", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"), + "\u001bf", + ); + }); + + it("maps Cmd+Arrow on macOS to line movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"), + "\u0001", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"), + "\u0005", + ); + }); + + it("maps Ctrl+Arrow on non-macOS to word movement", () => { + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"), + "\u001bb", + ); + assert.strictEqual( + terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"), + "\u001bf", + ); + }); + + it("rejects unsupported combinations", () => { + assert.isNull( + terminalNavigationShortcutData( + event({ key: "ArrowLeft", shiftKey: true, altKey: true }), + "MacIntel", + ), + ); + assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); + assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); + }); + + it("ignores non-keydown events", () => { + assert.isNull( + terminalNavigationShortcutData( + event({ type: "keyup", key: "ArrowLeft", altKey: true }), + "MacIntel", + ), + ); + }); +}); + +describe("plus key parsing", () => { + it("matches the plus key shortcut", () => { + const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]); + assert.isTrue( + isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, { + platform: "MacIntel", + }), + ); + assert.isTrue( + isTerminalToggleShortcut(event({ key: "+", ctrlKey: true }), plusBindings, { + platform: "Linux", + }), + ); + }); +}); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..859602e97cb6f9afb8e5a65b555cd8ebb1003f5d --- /dev/null +++ b/apps/web/src/keybindings.ts @@ -0,0 +1,286 @@ +import { + type KeybindingCommand, + type KeybindingShortcut, + type KeybindingWhenNode, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { isMacPlatform } from "./lib/utils"; + +export interface ShortcutEventLike { + type?: string; + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +export interface ShortcutMatchContext { + terminalFocus: boolean; + terminalOpen: boolean; + [key: string]: boolean; +} + +interface ShortcutMatchOptions { + platform?: string; + context?: Partial; +} + +const TERMINAL_WORD_BACKWARD = "\u001bb"; +const TERMINAL_WORD_FORWARD = "\u001bf"; +const TERMINAL_LINE_START = "\u0001"; +const TERMINAL_LINE_END = "\u0005"; + +function normalizeEventKey(key: string): string { + const normalized = key.toLowerCase(); + if (normalized === "esc") return "escape"; + return normalized; +} + +function matchesShortcut( + event: ShortcutEventLike, + shortcut: KeybindingShortcut, + platform = navigator.platform, +): boolean { + const key = normalizeEventKey(event.key); + if (key !== shortcut.key) return false; + + const useMetaForMod = isMacPlatform(platform); + const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); + const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); + return ( + event.metaKey === expectedMeta && + event.ctrlKey === expectedCtrl && + event.shiftKey === shortcut.shiftKey && + event.altKey === shortcut.altKey + ); +} + +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 evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean { + switch (node.type) { + case "identifier": + if (node.name === "true") return true; + if (node.name === "false") return false; + 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 matchesWhenClause( + whenAst: KeybindingWhenNode | undefined, + context: ShortcutMatchContext, +): boolean { + if (!whenAst) return true; + return evaluateWhenNode(whenAst, context); +} + +function matchesCommandShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): boolean { + return resolveShortcutCommand(event, keybindings, options) === command; +} + +function resolveShortcutCommand( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): KeybindingCommand | null { + const platform = resolvePlatform(options); + const context = resolveContext(options); + + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding) continue; + if (!matchesWhenClause(binding.whenAst, context)) continue; + if (!matchesShortcut(event, binding.shortcut, platform)) continue; + return binding.command; + } + return null; +} + +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( + shortcut: KeybindingShortcut, + platform = navigator.platform, +): string { + const keyLabel = formatShortcutKeyLabel(shortcut.key); + const useMetaForMod = isMacPlatform(platform); + const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); + const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); + const showAlt = shortcut.altKey; + const showShift = shortcut.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: ResolvedKeybindingsConfig, + 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.shortcut, platform); + } + return null; +} + +export function isTerminalToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); +} + +export function isTerminalSplitShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.split", options); +} + +export function isTerminalNewShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.new", options); +} + +export function isTerminalCloseShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.close", options); +} + +export function isChatNewShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "chat.new", options); +} + +export function isChatNewLocalShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); +} + +export function isOpenFavoriteEditorShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); +} + +export function isTerminalClearShortcut( + event: ShortcutEventLike, + platform = navigator.platform, +): boolean { + if (event.type !== undefined && event.type !== "keydown") { + return false; + } + + const key = event.key.toLowerCase(); + + if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + return true; + } + + return ( + isMacPlatform(platform) && + key === "k" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ); +} + +export function terminalNavigationShortcutData( + event: ShortcutEventLike, + platform = navigator.platform, +): string | null { + if (event.type !== undefined && event.type !== "keydown") { + return null; + } + + if (event.shiftKey) return null; + + const key = normalizeEventKey(event.key); + if (key !== "arrowleft" && key !== "arrowright") { + return null; + } + + const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD; + const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END; + + if (isMacPlatform(platform)) { + if (event.altKey && !event.metaKey && !event.ctrlKey) { + return moveWord; + } + if (event.metaKey && !event.altKey && !event.ctrlKey) { + return moveLine; + } + return null; + } + + if (event.ctrlKey && !event.metaKey && !event.altKey) { + return moveWord; + } + + if (event.altKey && !event.metaKey && !event.ctrlKey) { + return moveWord; + } + + return null; +} diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..8324cd33ef6bfdf0993a3eb9f078b0cc42d9a317 --- /dev/null +++ b/apps/web/src/lib/serverReactQuery.ts @@ -0,0 +1,21 @@ +import type { NativeApi } from "@t3tools/contracts"; +import { queryOptions } from "@tanstack/react-query"; + +export const serverQueryKeys = { + all: ["server"] as const, + config: () => ["server", "config"] as const, +}; + +export function serverConfigQueryOptions(api: NativeApi | undefined) { + return queryOptions({ + queryKey: serverQueryKeys.config(), + queryFn: async () => { + if (!api || !api.server || typeof api.server.getConfig !== "function") { + throw new Error("Server config is unavailable."); + } + return api.server.getConfig(); + }, + enabled: !!api && !!api.server && typeof api.server.getConfig === "function", + staleTime: Infinity, + }); +} diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..017b6bee07246739af2790b958b6932d9816d561 --- /dev/null +++ b/apps/web/src/lib/utils.test.ts @@ -0,0 +1,15 @@ +import { assert, describe, it } from "vitest"; + +import { isWindowsPlatform } from "./utils"; + +describe("isWindowsPlatform", () => { + it("matches Windows platform identifiers", () => { + assert.isTrue(isWindowsPlatform("Win32")); + assert.isTrue(isWindowsPlatform("Windows")); + assert.isTrue(isWindowsPlatform("windows_nt")); + }); + + it("does not match darwin", () => { + assert.isFalse(isWindowsPlatform("darwin")); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index d27850a379532fb32ef895464f6572e570463c16..d9d5575a5db50332532ee09e77b9781e8e97670b 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); } + +export function isMacPlatform(platform: string): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + +export function isWindowsPlatform(platform: string): boolean { + return /^win(dows)?/i.test(platform); +} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 176a6a73fc0d93a11d81b8586c58abe5750291b0..9bc2c3de6695057fdcf34f96e25d5d624b981b9b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { expect(next.activeThreadId).toBe("thread-a"); }); + it("deletes a project and all of its threads", () => { + const state: AppState = { + projects: [ + { + id: "project-1", + name: "One", + cwd: "/tmp/one", + model: "gpt-5.3-codex", + expanded: true, + }, + { + id: "project-2", + name: "Two", + cwd: "/tmp/two", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [ + makeThread({ + id: "thread-a", + projectId: "project-1", + }), + makeThread({ + id: "thread-b", + projectId: "project-2", + }), + ], + activeThreadId: "thread-a", + runtimeMode: "full-access", + diffOpen: false, + }; + + const next = reducer(state, { + type: "DELETE_PROJECT", + projectId: "project-1", + }); + + expect(next.projects).toHaveLength(1); + expect(next.projects[0]?.id).toBe("project-2"); + expect(next.threads).toHaveLength(1); + expect(next.threads[0]?.id).toBe("thread-b"); + expect(next.activeThreadId).toBe("thread-b"); + }); + it("marks the active thread as visited when selected", () => { const state = makeState( makeThread({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 3f944601b1cb61f923e671cfca63f1d3be033f09..2f0ad22b6f0d9e058b3d2420073e99a20f300ea3 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -28,6 +28,7 @@ type Action = | { type: "ADD_PROJECT"; project: Project } | { type: "SYNC_PROJECTS"; projects: Project[] } | { type: "TOGGLE_PROJECT"; projectId: string } + | { type: "DELETE_PROJECT"; projectId: string } | { type: "ADD_THREAD"; thread: Thread } | { type: "SET_ACTIVE_THREAD"; threadId: string } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } @@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { ), }; + case "DELETE_PROJECT": { + const projects = state.projects.filter((project) => project.id !== action.projectId); + if (projects.length === state.projects.length) { + return state; + } + + const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); + const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) + ? state.activeThreadId + : (threads[0]?.id ?? null); + + return { + ...state, + projects, + threads, + activeThreadId, + }; + } + case "ADD_THREAD": { const nextThread = normalizeThreadTerminals({ ...action.thread, diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index f46cc6563bf9e040e13d018efae0f362ece86d79..64e18670fadb082435678023467d1f3eb6af0354 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -1,4 +1,5 @@ import { EDITORS, type EditorId } from "@t3tools/contracts"; +import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; @@ -15,10 +16,6 @@ const FILE_PATH_PATTERN = const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; const LAST_EDITOR_KEY = "t3code:last-editor"; -function isMacPlatform(platform: string): boolean { - return /mac|iphone|ipad|ipod/i.test(platform); -} - function trimClosingDelimiters(value: string): string { let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); if (output.length === 0) return output; diff --git a/apps/web/src/terminal-shortcuts.test.ts b/apps/web/src/terminal-shortcuts.test.ts deleted file mode 100644 index bbc13f068d5a12aeebc1619875421abf31d27708..0000000000000000000000000000000000000000 --- a/apps/web/src/terminal-shortcuts.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - isTerminalClearShortcut, - isTerminalToggleShortcut, - type ShortcutEventLike, -} from "./terminal-shortcuts"; - -function event(overrides: Partial = {}): ShortcutEventLike { - return { - key: "j", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - ...overrides, - }; -} - -describe("isTerminalToggleShortcut", () => { - it("matches Cmd+J on macOS", () => { - expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); - }); - - it("matches Ctrl+J on non-macOS", () => { - expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "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, - ); - }); - - it("rejects non-j keys", () => { - expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false); - }); -}); - -describe("isTerminalClearShortcut", () => { - it("matches Ctrl+L on all platforms", () => { - expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true); - expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true); - }); - - 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 deleted file mode 100644 index bc76a145ffc4a0fb8fd5bbb5c6edcef210a9f067..0000000000000000000000000000000000000000 --- a/apps/web/src/terminal-shortcuts.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface ShortcutEventLike { - key: string; - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; -} - -function isMacPlatform(platform: string): boolean { - return /mac|iphone|ipad|ipod/i.test(platform); -} - -export function isTerminalToggleShortcut( - event: ShortcutEventLike, - platform = navigator.platform, -): boolean { - const key = event.key.toLowerCase(); - if (key !== "j") return false; - if (event.shiftKey || event.altKey) return false; - - if (isMacPlatform(platform)) { - return event.metaKey && !event.ctrlKey; - } - - return event.ctrlKey && !event.metaKey; -} - -export function isTerminalClearShortcut( - event: ShortcutEventLike, - platform = navigator.platform, -): boolean { - const key = event.key.toLowerCase(); - - if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { - return true; - } - - return ( - isMacPlatform(platform) && - key === "k" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey - ); -} 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..5e705e418f4a6cb350162409f0308cc90d62ce4f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,5 +5,7 @@ export * from "./terminal"; export * from "./provider"; export * from "./model"; export * from "./ws"; +export * from "./keybindings"; +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/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..12726ea720b8f7e0e3cad46e9f50d49f53ea6baf --- /dev/null +++ b/packages/contracts/src/keybindings.test.ts @@ -0,0 +1,104 @@ +import { assert, describe, it } from "vitest"; + +import { + keybindingRuleSchema, + keybindingsConfigSchema, + resolvedKeybindingRuleSchema, + resolvedKeybindingsConfigSchema, +} from "./keybindings"; + +describe("keybindings contracts", () => { + it("parses keybinding rules", () => { + const parsed = keybindingRuleSchema.parse({ + key: "mod+j", + command: "terminal.toggle", + }); + assert.strictEqual(parsed.command, "terminal.toggle"); + + const parsedClose = keybindingRuleSchema.parse({ + key: "mod+w", + command: "terminal.close", + }); + assert.strictEqual(parsedClose.command, "terminal.close"); + + const parsedLocal = keybindingRuleSchema.parse({ + key: "mod+shift+n", + command: "chat.newLocal", + }); + assert.strictEqual(parsedLocal.command, "chat.newLocal"); + }); + + it("rejects invalid command values", () => { + assert.throws(() => + keybindingRuleSchema.parse({ + key: "mod+j", + command: "invalid.command", + }), + ); + }); + + it("parses keybindings array payload", () => { + const parsed = keybindingsConfigSchema.parse([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + ]); + assert.lengthOf(parsed, 2); + }); + + it("parses resolved keybinding rules", () => { + const parsed = resolvedKeybindingRuleSchema.parse({ + command: "terminal.split", + shortcut: { + key: "d", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "and", + left: { type: "identifier", name: "terminalOpen" }, + right: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + }); + assert.strictEqual(parsed.shortcut.key, "d"); + }); + + it("parses resolved keybindings arrays", () => { + const parsed = resolvedKeybindingsConfigSchema.parse([ + { + command: "terminal.toggle", + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ]); + assert.lengthOf(parsed, 1); + }); + + it("rejects unknown fields in resolved keybinding rules", () => { + assert.throws(() => + resolvedKeybindingRuleSchema.parse({ + command: "terminal.toggle", + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + key: "mod+j", + }), + ); + }); +}); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9d8bfbf9eefd4228dfa9e8fdc1907d315c39f52 --- /dev/null +++ b/packages/contracts/src/keybindings.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +export const keybindingCommandSchema = z.enum([ + "terminal.toggle", + "terminal.split", + "terminal.new", + "terminal.close", + "chat.new", + "chat.newLocal", + "editor.openFavorite", +]); + +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 keybindingShortcutSchema = z.object({ + key: z.string().trim().min(1).max(32), + metaKey: z.boolean(), + ctrlKey: z.boolean(), + shiftKey: z.boolean(), + altKey: z.boolean(), + modKey: z.boolean(), +}).strict(); + +export type KeybindingWhenNode = + | { type: "identifier"; name: string } + | { type: "not"; node: KeybindingWhenNode } + | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } + | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; + +export const keybindingWhenNodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ + type: z.literal("identifier"), + name: z.string().min(1), + }), + z.object({ + type: z.literal("not"), + node: keybindingWhenNodeSchema, + }), + z.object({ + type: z.literal("and"), + left: keybindingWhenNodeSchema, + right: keybindingWhenNodeSchema, + }), + z.object({ + type: z.literal("or"), + left: keybindingWhenNodeSchema, + right: keybindingWhenNodeSchema, + }), + ]), +); + +export const resolvedKeybindingRuleSchema = z.object({ + command: keybindingCommandSchema, + shortcut: keybindingShortcutSchema, + whenAst: keybindingWhenNodeSchema.optional(), +}).strict(); + +export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); + +export type KeybindingCommand = z.infer; +export type KeybindingRule = z.infer; +export type KeybindingsConfig = z.infer; +export type KeybindingShortcut = z.infer; +export type ResolvedKeybindingRule = z.infer; +export type ResolvedKeybindingsConfig = z.infer; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebf3f1334e41441a8f8dc5eb7b8cc5ad5f1ba8bf --- /dev/null +++ b/packages/contracts/src/server.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { resolvedKeybindingsConfigSchema } from "./keybindings"; + +export const serverConfigSchema = z.object({ + cwd: z.string().min(1), + keybindings: resolvedKeybindingsConfigSchema.default([]), +}); + +export type ServerConfig = z.infer;