| author | |
| committer | |
| log | fe86836b43dbcb49598d8e7a728c7febb9584b3c |
| tree | f7266f24c8d482c96db5f97bd334b89b2ed53f64 |
| parent | 973e62373e5b442ac9c13e0e65f771132c5e66d4 |
| parent | 73fa2d66c79eb1351fbae55a81c61e1f7b88c619 |
Co-authored-by: codex <codex@users.noreply.github.com>34 files changed, 2887 insertions(+), 661 deletions(-)
KEYBINDINGS.md created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | # Keybindings | ||
| 2 | |||
| 3 | T3 Code reads keybindings from: | ||
| 4 | |||
| 5 | - `~/.t3/keybindings.json` | ||
| 6 | |||
| 7 | The file must be a JSON array of rules: | ||
| 8 | |||
| 9 | ```json | ||
| 10 | [ | ||
| 11 | { "key": "mod+g", "command": "terminal.toggle" }, | ||
| 12 | { "key": "mod+shift+g", "command": "terminal.new", "when": "terminalFocus" } | ||
| 13 | ] | ||
| 14 | ``` | ||
| 15 | |||
| 16 | See the full schema for more details: [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts) | ||
| 17 | |||
| 18 | ## Defaults | ||
| 19 | |||
| 20 | ```json | ||
| 21 | [ | ||
| 22 | { "key": "mod+j", "command": "terminal.toggle" }, | ||
| 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, | ||
| 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, | ||
| 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, | ||
| 26 | { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, | ||
| 27 | { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, | ||
| 28 | { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, | ||
| 29 | { "key": "mod+o", "command": "editor.openFavorite" } | ||
| 30 | ] | ||
| 31 | ``` | ||
| 32 | |||
| 33 | For most up to date defaults, see [`DEFAULT_KEYBINDINGS` in `apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts) | ||
| 34 | |||
| 35 | ## Configuration | ||
| 36 | |||
| 37 | ### Rule Shape | ||
| 38 | |||
| 39 | Each entry supports: | ||
| 40 | |||
| 41 | - `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d` | ||
| 42 | - `command` (required): action ID | ||
| 43 | - `when` (optional): boolean expression controlling when the shortcut is active | ||
| 44 | |||
| 45 | Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server. | ||
| 46 | |||
| 47 | ### Available Commands | ||
| 48 | |||
| 49 | - `terminal.toggle`: open/close terminal drawer | ||
| 50 | - `terminal.split`: split terminal (in focused terminal context by default) | ||
| 51 | - `terminal.new`: create new terminal (in focused terminal context by default) | ||
| 52 | - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) | ||
| 53 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state | ||
| 54 | - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) | ||
| 55 | - `editor.openFavorite`: open current project/worktree in the last-used editor | ||
| 56 | |||
| 57 | ### Key Syntax | ||
| 58 | |||
| 59 | Supported modifiers: | ||
| 60 | |||
| 61 | - `mod` (`cmd` on macOS, `ctrl` on non-macOS) | ||
| 62 | - `cmd` / `meta` | ||
| 63 | - `ctrl` / `control` | ||
| 64 | - `shift` | ||
| 65 | - `alt` / `option` | ||
| 66 | |||
| 67 | Examples: | ||
| 68 | |||
| 69 | - `mod+j` | ||
| 70 | - `mod+shift+d` | ||
| 71 | - `ctrl+l` | ||
| 72 | - `cmd+k` | ||
| 73 | |||
| 74 | ### `when` Conditions | ||
| 75 | |||
| 76 | Currently available context keys: | ||
| 77 | |||
| 78 | - `terminalFocus` | ||
| 79 | - `terminalOpen` | ||
| 80 | |||
| 81 | Supported operators: | ||
| 82 | |||
| 83 | - `!` (not) | ||
| 84 | - `&&` (and) | ||
| 85 | - `||` (or) | ||
| 86 | - parentheses: `(` `)` | ||
| 87 | |||
| 88 | Examples: | ||
| 89 | |||
| 90 | - `"when": "terminalFocus"` | ||
| 91 | - `"when": "terminalOpen && !terminalFocus"` | ||
| 92 | - `"when": "terminalFocus || terminalOpen"` | ||
| 93 | |||
| 94 | Unknown condition keys evaluate to `false`. | ||
| 95 | |||
| 96 | ### Precedence | ||
| 97 | |||
| 98 | - Rules are evaluated in array order. | ||
| 99 | - For a key event, the last rule where both `key` matches and `when` evaluates to `true` wins. | ||
| 100 | - That means precedence is across commands, not only within the same command. | ||
apps/server/src/gitManager.test.ts+14-15| ... | @@ -190,15 +190,15 @@ describe("GitManager", () => { | ... | @@ -190,15 +190,15 @@ describe("GitManager", () => { |
| 190 | 190 | ||
| 191 | const status = await manager.status({ cwd: repoDir }); | 191 | const status = await manager.status({ cwd: repoDir }); |
| 192 | expect(status.branch).toBe("feature/status-open-pr"); | 192 | expect(status.branch).toBe("feature/status-open-pr"); |
| 193 | expect(status.openPr).toEqual({ | 193 | expect(status.pr).toEqual({ |
| 194 | number: 13, | 194 | number: 13, |
| 195 | title: "Existing PR", | 195 | title: "Existing PR", |
| 196 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | 196 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", |
| 197 | baseBranch: "main", | 197 | baseBranch: "main", |
| 198 | headBranch: "feature/status-open-pr", | 198 | headBranch: "feature/status-open-pr", |
| 199 | state: "open", | ||
| 199 | }); | 200 | }); |
| 200 | expect(status.mergedPr).toBeNull(); | 201 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); |
| 201 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false); | ||
| 202 | }); | 202 | }); |
| 203 | 203 | ||
| 204 | it("status includes merged PR metadata when there is no open PR", async () => { | 204 | it("status includes merged PR metadata when there is no open PR", async () => { |
| ... | @@ -211,7 +211,6 @@ describe("GitManager", () => { | ... | @@ -211,7 +211,6 @@ describe("GitManager", () => { |
| 211 | 211 | ||
| 212 | const { runner, ghCalls } = createRunnerWithFakeGh({ | 212 | const { runner, ghCalls } = createRunnerWithFakeGh({ |
| 213 | prListSequence: [ | 213 | prListSequence: [ |
| 214 | "[]", | ||
| 215 | JSON.stringify([ | 214 | JSON.stringify([ |
| 216 | { | 215 | { |
| 217 | number: 26, | 216 | number: 26, |
| ... | @@ -219,6 +218,9 @@ describe("GitManager", () => { | ... | @@ -219,6 +218,9 @@ describe("GitManager", () => { |
| 219 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", | 218 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", |
| 220 | baseRefName: "main", | 219 | baseRefName: "main", |
| 221 | headRefName: "feature/status-merged-pr", | 220 | headRefName: "feature/status-merged-pr", |
| 221 | state: "CLOSED", | ||
| 222 | mergedAt: "2026-02-14T10:00:00Z", | ||
| 223 | updatedAt: "2026-02-14T10:00:00Z", | ||
| 222 | }, | 224 | }, |
| 223 | ]), | 225 | ]), |
| 224 | ], | 226 | ], |
| ... | @@ -231,18 +233,18 @@ describe("GitManager", () => { | ... | @@ -231,18 +233,18 @@ describe("GitManager", () => { |
| 231 | 233 | ||
| 232 | const status = await manager.status({ cwd: repoDir }); | 234 | const status = await manager.status({ cwd: repoDir }); |
| 233 | expect(status.branch).toBe("feature/status-merged-pr"); | 235 | expect(status.branch).toBe("feature/status-merged-pr"); |
| 234 | expect(status.openPr).toBeNull(); | 236 | expect(status.pr).toEqual({ |
| 235 | expect(status.mergedPr).toEqual({ | ||
| 236 | number: 26, | 237 | number: 26, |
| 237 | title: "Merged PR", | 238 | title: "Merged PR", |
| 238 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", | 239 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", |
| 239 | baseBranch: "main", | 240 | baseBranch: "main", |
| 240 | headBranch: "feature/status-merged-pr", | 241 | headBranch: "feature/status-merged-pr", |
| 242 | state: "merged", | ||
| 241 | }); | 243 | }); |
| 242 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); | 244 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); |
| 243 | }); | 245 | }); |
| 244 | 246 | ||
| 245 | it("status skips open PR lookup for branches without upstream", async () => { | 247 | it("status checks all PR states for branches without upstream", async () => { |
| 246 | const repoDir = makeTempDir("t3code-git-manager-"); | 248 | const repoDir = makeTempDir("t3code-git-manager-"); |
| 247 | await initRepo(repoDir); | 249 | await initRepo(repoDir); |
| 248 | await runGit(repoDir, ["checkout", "-b", "feature/local-only"]); | 250 | await runGit(repoDir, ["checkout", "-b", "feature/local-only"]); |
| ... | @@ -258,13 +260,11 @@ describe("GitManager", () => { | ... | @@ -258,13 +260,11 @@ describe("GitManager", () => { |
| 258 | 260 | ||
| 259 | const status = await manager.status({ cwd: repoDir }); | 261 | const status = await manager.status({ cwd: repoDir }); |
| 260 | expect(status.branch).toBe("feature/local-only"); | 262 | expect(status.branch).toBe("feature/local-only"); |
| 261 | expect(status.openPr).toBeNull(); | 263 | expect(status.pr).toBeNull(); |
| 262 | expect(status.mergedPr).toBeNull(); | 264 | expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true); |
| 263 | expect(ghCalls.some((call) => call.includes("--state open"))).toBe(false); | ||
| 264 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); | ||
| 265 | }); | 265 | }); |
| 266 | 266 | ||
| 267 | it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => { | 267 | it("status is resilient to gh lookup failures and returns pr null", async () => { |
| 268 | const repoDir = makeTempDir("t3code-git-manager-"); | 268 | const repoDir = makeTempDir("t3code-git-manager-"); |
| 269 | await initRepo(repoDir); | 269 | await initRepo(repoDir); |
| 270 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | 270 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); |
| ... | @@ -282,8 +282,7 @@ describe("GitManager", () => { | ... | @@ -282,8 +282,7 @@ describe("GitManager", () => { |
| 282 | 282 | ||
| 283 | const status = await manager.status({ cwd: repoDir }); | 283 | const status = await manager.status({ cwd: repoDir }); |
| 284 | expect(status.branch).toBe("feature/status-no-gh"); | 284 | expect(status.branch).toBe("feature/status-no-gh"); |
| 285 | expect(status.openPr).toBeNull(); | 285 | expect(status.pr).toBeNull(); |
| 286 | expect(status.mergedPr).toBeNull(); | ||
| 287 | }); | 286 | }); |
| 288 | 287 | ||
| 289 | it("creates a commit when working tree is dirty", async () => { | 288 | it("creates a commit when working tree is dirty", async () => { |
apps/server/src/gitManager.ts+2-3| ... | @@ -67,8 +67,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { | ... | @@ -67,8 +67,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 67 | typeof title !== "string" || | 67 | typeof title !== "string" || |
| 68 | typeof url !== "string" || | 68 | typeof url !== "string" || |
| 69 | typeof baseRefName !== "string" || | 69 | typeof baseRefName !== "string" || |
| 70 | typeof headRefName !== "string" || | 70 | typeof headRefName !== "string" |
| 71 | typeof state !== "string" | ||
| 72 | ) { | 71 | ) { |
| 73 | continue; | 72 | continue; |
| 74 | } | 73 | } |
| ... | @@ -76,7 +75,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { | ... | @@ -76,7 +75,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 76 | let normalizedState: GitStatusPrState; | 75 | let normalizedState: GitStatusPrState; |
| 77 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { | 76 | if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { |
| 78 | normalizedState = "merged"; | 77 | normalizedState = "merged"; |
| 79 | } else if (state === "OPEN") { | 78 | } else if (state === "OPEN" || state === undefined || state === null) { |
| 80 | normalizedState = "open"; | 79 | normalizedState = "open"; |
| 81 | } else if (state === "CLOSED") { | 80 | } else if (state === "CLOSED") { |
| 82 | normalizedState = "closed"; | 81 | normalizedState = "closed"; |
apps/server/src/keybindings.test.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | import { assert, describe, it } from "vitest"; | ||
| 2 | |||
| 3 | import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; | ||
| 4 | |||
| 5 | describe("server keybindings", () => { | ||
| 6 | it("parses shortcuts including plus key", () => { | ||
| 7 | assert.deepEqual(parseKeybindingShortcut("mod+j"), { | ||
| 8 | key: "j", | ||
| 9 | metaKey: false, | ||
| 10 | ctrlKey: false, | ||
| 11 | shiftKey: false, | ||
| 12 | altKey: false, | ||
| 13 | modKey: true, | ||
| 14 | }); | ||
| 15 | assert.deepEqual(parseKeybindingShortcut("mod++"), { | ||
| 16 | key: "+", | ||
| 17 | metaKey: false, | ||
| 18 | ctrlKey: false, | ||
| 19 | shiftKey: false, | ||
| 20 | altKey: false, | ||
| 21 | modKey: true, | ||
| 22 | }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("compiles valid rule with parsed when AST", () => { | ||
| 26 | const compiled = compileResolvedKeybindingRule({ | ||
| 27 | key: "mod+d", | ||
| 28 | command: "terminal.split", | ||
| 29 | when: "terminalOpen && !terminalFocus", | ||
| 30 | }); | ||
| 31 | |||
| 32 | assert.deepEqual(compiled, { | ||
| 33 | command: "terminal.split", | ||
| 34 | shortcut: { | ||
| 35 | key: "d", | ||
| 36 | metaKey: false, | ||
| 37 | ctrlKey: false, | ||
| 38 | shiftKey: false, | ||
| 39 | altKey: false, | ||
| 40 | modKey: true, | ||
| 41 | }, | ||
| 42 | whenAst: { | ||
| 43 | type: "and", | ||
| 44 | left: { type: "identifier", name: "terminalOpen" }, | ||
| 45 | right: { | ||
| 46 | type: "not", | ||
| 47 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 48 | }, | ||
| 49 | }, | ||
| 50 | }); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("rejects invalid rules", () => { | ||
| 54 | assert.isNull( | ||
| 55 | compileResolvedKeybindingRule({ | ||
| 56 | key: "mod+shift+d+o", | ||
| 57 | command: "terminal.new", | ||
| 58 | }), | ||
| 59 | ); | ||
| 60 | |||
| 61 | assert.isNull( | ||
| 62 | compileResolvedKeybindingRule({ | ||
| 63 | key: "mod+d", | ||
| 64 | command: "terminal.split", | ||
| 65 | when: "terminalFocus && (", | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | }); | ||
| 69 | }); | ||
apps/server/src/keybindings.ts created+355| ... | @@ -0,0 +1,355 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import type { | ||
| 6 | KeybindingRule, | ||
| 7 | KeybindingsConfig, | ||
| 8 | KeybindingShortcut, | ||
| 9 | KeybindingWhenNode, | ||
| 10 | ResolvedKeybindingRule, | ||
| 11 | ResolvedKeybindingsConfig, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { keybindingRuleSchema } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | interface KeybindingsLogger { | ||
| 16 | warn(message: string, context?: Record<string, unknown>): void; | ||
| 17 | } | ||
| 18 | |||
| 19 | type WhenToken = | ||
| 20 | | { type: "identifier"; value: string } | ||
| 21 | | { type: "not" } | ||
| 22 | | { type: "and" } | ||
| 23 | | { type: "or" } | ||
| 24 | | { type: "lparen" } | ||
| 25 | | { type: "rparen" }; | ||
| 26 | |||
| 27 | export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ||
| 28 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 29 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 30 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | ||
| 31 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, | ||
| 32 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | ||
| 33 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | ||
| 34 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | ||
| 35 | { key: "mod+o", command: "editor.openFavorite" }, | ||
| 36 | ]; | ||
| 37 | |||
| 38 | function normalizeKeyToken(token: string): string { | ||
| 39 | if (token === "space") return " "; | ||
| 40 | if (token === "esc") return "escape"; | ||
| 41 | return token; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { | ||
| 45 | const rawTokens = value | ||
| 46 | .toLowerCase() | ||
| 47 | .split("+") | ||
| 48 | .map((token) => token.trim()); | ||
| 49 | const tokens = [...rawTokens]; | ||
| 50 | let trailingEmptyCount = 0; | ||
| 51 | while (tokens[tokens.length - 1] === "") { | ||
| 52 | trailingEmptyCount += 1; | ||
| 53 | tokens.pop(); | ||
| 54 | } | ||
| 55 | if (trailingEmptyCount > 0) { | ||
| 56 | tokens.push("+"); | ||
| 57 | } | ||
| 58 | if (tokens.some((token) => token.length === 0)) { | ||
| 59 | return null; | ||
| 60 | } | ||
| 61 | if (tokens.length === 0) return null; | ||
| 62 | |||
| 63 | let key: string | null = null; | ||
| 64 | let metaKey = false; | ||
| 65 | let ctrlKey = false; | ||
| 66 | let shiftKey = false; | ||
| 67 | let altKey = false; | ||
| 68 | let modKey = false; | ||
| 69 | |||
| 70 | for (const token of tokens) { | ||
| 71 | switch (token) { | ||
| 72 | case "cmd": | ||
| 73 | case "meta": | ||
| 74 | metaKey = true; | ||
| 75 | break; | ||
| 76 | case "ctrl": | ||
| 77 | case "control": | ||
| 78 | ctrlKey = true; | ||
| 79 | break; | ||
| 80 | case "shift": | ||
| 81 | shiftKey = true; | ||
| 82 | break; | ||
| 83 | case "alt": | ||
| 84 | case "option": | ||
| 85 | altKey = true; | ||
| 86 | break; | ||
| 87 | case "mod": | ||
| 88 | modKey = true; | ||
| 89 | break; | ||
| 90 | default: { | ||
| 91 | if (key !== null) return null; | ||
| 92 | key = normalizeKeyToken(token); | ||
| 93 | } | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | if (key === null) return null; | ||
| 98 | return { | ||
| 99 | key, | ||
| 100 | metaKey, | ||
| 101 | ctrlKey, | ||
| 102 | shiftKey, | ||
| 103 | altKey, | ||
| 104 | modKey, | ||
| 105 | }; | ||
| 106 | } | ||
| 107 | |||
| 108 | function tokenizeWhenExpression(expression: string): WhenToken[] | null { | ||
| 109 | const tokens: WhenToken[] = []; | ||
| 110 | let index = 0; | ||
| 111 | |||
| 112 | while (index < expression.length) { | ||
| 113 | const current = expression[index]; | ||
| 114 | if (!current) break; | ||
| 115 | |||
| 116 | if (/\s/.test(current)) { | ||
| 117 | index += 1; | ||
| 118 | continue; | ||
| 119 | } | ||
| 120 | if (expression.startsWith("&&", index)) { | ||
| 121 | tokens.push({ type: "and" }); | ||
| 122 | index += 2; | ||
| 123 | continue; | ||
| 124 | } | ||
| 125 | if (expression.startsWith("||", index)) { | ||
| 126 | tokens.push({ type: "or" }); | ||
| 127 | index += 2; | ||
| 128 | continue; | ||
| 129 | } | ||
| 130 | if (current === "!") { | ||
| 131 | tokens.push({ type: "not" }); | ||
| 132 | index += 1; | ||
| 133 | continue; | ||
| 134 | } | ||
| 135 | if (current === "(") { | ||
| 136 | tokens.push({ type: "lparen" }); | ||
| 137 | index += 1; | ||
| 138 | continue; | ||
| 139 | } | ||
| 140 | if (current === ")") { | ||
| 141 | tokens.push({ type: "rparen" }); | ||
| 142 | index += 1; | ||
| 143 | continue; | ||
| 144 | } | ||
| 145 | |||
| 146 | const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); | ||
| 147 | if (!identifier) { | ||
| 148 | return null; | ||
| 149 | } | ||
| 150 | tokens.push({ type: "identifier", value: identifier[0] }); | ||
| 151 | index += identifier[0].length; | ||
| 152 | } | ||
| 153 | |||
| 154 | return tokens; | ||
| 155 | } | ||
| 156 | |||
| 157 | export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { | ||
| 158 | const tokens = tokenizeWhenExpression(expression); | ||
| 159 | if (!tokens || tokens.length === 0) return null; | ||
| 160 | let index = 0; | ||
| 161 | |||
| 162 | const parsePrimary = (): KeybindingWhenNode | null => { | ||
| 163 | const token = tokens[index]; | ||
| 164 | if (!token) return null; | ||
| 165 | |||
| 166 | if (token.type === "identifier") { | ||
| 167 | index += 1; | ||
| 168 | return { type: "identifier", name: token.value }; | ||
| 169 | } | ||
| 170 | |||
| 171 | if (token.type === "lparen") { | ||
| 172 | index += 1; | ||
| 173 | const expressionNode = parseOr(); | ||
| 174 | const closeToken = tokens[index]; | ||
| 175 | if (!expressionNode || !closeToken || closeToken.type !== "rparen") { | ||
| 176 | return null; | ||
| 177 | } | ||
| 178 | index += 1; | ||
| 179 | return expressionNode; | ||
| 180 | } | ||
| 181 | |||
| 182 | return null; | ||
| 183 | }; | ||
| 184 | |||
| 185 | const parseUnary = (): KeybindingWhenNode | null => { | ||
| 186 | const token = tokens[index]; | ||
| 187 | if (token?.type === "not") { | ||
| 188 | index += 1; | ||
| 189 | const node = parseUnary(); | ||
| 190 | if (!node) return null; | ||
| 191 | return { type: "not", node }; | ||
| 192 | } | ||
| 193 | return parsePrimary(); | ||
| 194 | }; | ||
| 195 | |||
| 196 | const parseAnd = (): KeybindingWhenNode | null => { | ||
| 197 | let left = parseUnary(); | ||
| 198 | if (!left) return null; | ||
| 199 | |||
| 200 | while (tokens[index]?.type === "and") { | ||
| 201 | index += 1; | ||
| 202 | const right = parseUnary(); | ||
| 203 | if (!right) return null; | ||
| 204 | left = { type: "and", left, right }; | ||
| 205 | } | ||
| 206 | |||
| 207 | return left; | ||
| 208 | }; | ||
| 209 | |||
| 210 | const parseOr = (): KeybindingWhenNode | null => { | ||
| 211 | let left = parseAnd(); | ||
| 212 | if (!left) return null; | ||
| 213 | |||
| 214 | while (tokens[index]?.type === "or") { | ||
| 215 | index += 1; | ||
| 216 | const right = parseAnd(); | ||
| 217 | if (!right) return null; | ||
| 218 | left = { type: "or", left, right }; | ||
| 219 | } | ||
| 220 | |||
| 221 | return left; | ||
| 222 | }; | ||
| 223 | |||
| 224 | const ast = parseOr(); | ||
| 225 | if (!ast || index !== tokens.length) return null; | ||
| 226 | return ast; | ||
| 227 | } | ||
| 228 | |||
| 229 | export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { | ||
| 230 | const key = rule.key.trim(); | ||
| 231 | if (key.length === 0) return null; | ||
| 232 | const shortcut = parseKeybindingShortcut(key); | ||
| 233 | if (!shortcut) return null; | ||
| 234 | |||
| 235 | const when = rule.when?.trim(); | ||
| 236 | if (when && when.length > 0) { | ||
| 237 | const whenAst = parseKeybindingWhenExpression(when); | ||
| 238 | if (!whenAst) return null; | ||
| 239 | return { | ||
| 240 | command: rule.command, | ||
| 241 | shortcut, | ||
| 242 | whenAst, | ||
| 243 | }; | ||
| 244 | } | ||
| 245 | |||
| 246 | return { | ||
| 247 | command: rule.command, | ||
| 248 | shortcut, | ||
| 249 | }; | ||
| 250 | } | ||
| 251 | |||
| 252 | export function compileResolvedKeybindingsConfig( | ||
| 253 | config: KeybindingsConfig, | ||
| 254 | ): ResolvedKeybindingsConfig { | ||
| 255 | const compiled: ResolvedKeybindingsConfig = []; | ||
| 256 | for (const rule of config) { | ||
| 257 | const resolved = compileResolvedKeybindingRule(rule); | ||
| 258 | if (!resolved) continue; | ||
| 259 | compiled.push(resolved); | ||
| 260 | } | ||
| 261 | return compiled; | ||
| 262 | } | ||
| 263 | |||
| 264 | function compileDefaultKeybindings(): ResolvedKeybindingsConfig { | ||
| 265 | const resolved: ResolvedKeybindingsConfig = []; | ||
| 266 | for (const rule of DEFAULT_KEYBINDINGS) { | ||
| 267 | const compiled = compileResolvedKeybindingRule(rule); | ||
| 268 | if (!compiled) { | ||
| 269 | throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); | ||
| 270 | } | ||
| 271 | resolved.push(compiled); | ||
| 272 | } | ||
| 273 | return resolved; | ||
| 274 | } | ||
| 275 | |||
| 276 | const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); | ||
| 277 | |||
| 278 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | ||
| 279 | if (custom.length === 0) { | ||
| 280 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 281 | } | ||
| 282 | |||
| 283 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 284 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | ||
| 285 | (binding) => !overriddenCommands.has(binding.command), | ||
| 286 | ); | ||
| 287 | const merged = [...retainedDefaults, ...custom]; | ||
| 288 | |||
| 289 | if (merged.length <= 256) { | ||
| 290 | return merged; | ||
| 291 | } | ||
| 292 | |||
| 293 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | ||
| 294 | return merged.slice(-256); | ||
| 295 | } | ||
| 296 | |||
| 297 | export function loadResolvedKeybindingsConfig( | ||
| 298 | logger: KeybindingsLogger, | ||
| 299 | ): ResolvedKeybindingsConfig { | ||
| 300 | const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); | ||
| 301 | try { | ||
| 302 | const raw = fs.readFileSync(configPath, "utf8"); | ||
| 303 | const parsed = JSON.parse(raw) as unknown; | ||
| 304 | if (!Array.isArray(parsed)) { | ||
| 305 | logger.warn("ignoring keybindings config with unsupported format; expected array", { | ||
| 306 | path: configPath, | ||
| 307 | }); | ||
| 308 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 309 | } | ||
| 310 | |||
| 311 | const sanitized: ResolvedKeybindingsConfig = []; | ||
| 312 | let invalidEntries = 0; | ||
| 313 | for (const entry of parsed) { | ||
| 314 | const result = keybindingRuleSchema.safeParse(entry); | ||
| 315 | if (result.success) { | ||
| 316 | const compiled = compileResolvedKeybindingRule(result.data); | ||
| 317 | if (!compiled) { | ||
| 318 | invalidEntries += 1; | ||
| 319 | continue; | ||
| 320 | } | ||
| 321 | sanitized.push(compiled); | ||
| 322 | continue; | ||
| 323 | } | ||
| 324 | invalidEntries += 1; | ||
| 325 | } | ||
| 326 | if (invalidEntries > 0) { | ||
| 327 | logger.warn("ignoring invalid keybinding entries", { | ||
| 328 | path: configPath, | ||
| 329 | invalidEntries, | ||
| 330 | totalEntries: parsed.length, | ||
| 331 | }); | ||
| 332 | } | ||
| 333 | const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); | ||
| 334 | const mergedBeforeCapLength = | ||
| 335 | DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) | ||
| 336 | .length + sanitized.length; | ||
| 337 | const merged = mergeWithDefaultKeybindings(sanitized); | ||
| 338 | if (mergedBeforeCapLength > 256) { | ||
| 339 | logger.warn("truncating merged keybindings config to max entries", { | ||
| 340 | path: configPath, | ||
| 341 | maxEntries: 256, | ||
| 342 | }); | ||
| 343 | } | ||
| 344 | return merged; | ||
| 345 | } catch (error) { | ||
| 346 | if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { | ||
| 347 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 348 | } | ||
| 349 | logger.warn("ignoring malformed keybindings config", { | ||
| 350 | path: configPath, | ||
| 351 | error: error instanceof Error ? error.message : String(error), | ||
| 352 | }); | ||
| 353 | } | ||
| 354 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 355 | } | ||
apps/server/src/wsServer.test.ts+248-1| ... | @@ -11,10 +11,13 @@ import { | ... | @@ -11,10 +11,13 @@ import { |
| 11 | DEFAULT_TERMINAL_ID, | 11 | DEFAULT_TERMINAL_ID, |
| 12 | WS_CHANNELS, | 12 | WS_CHANNELS, |
| 13 | WS_METHODS, | 13 | WS_METHODS, |
| 14 | type KeybindingsConfig, | ||
| 15 | type ResolvedKeybindingsConfig, | ||
| 14 | type WsPush, | 16 | type WsPush, |
| 15 | type WsResponse, | 17 | type WsResponse, |
| 16 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 17 | import { ProjectRegistry } from "./projectRegistry"; | 19 | import { ProjectRegistry } from "./projectRegistry"; |
| 20 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; | ||
| 18 | import type { | 21 | import type { |
| 19 | TerminalClearInput, | 22 | TerminalClearInput, |
| 20 | TerminalCloseInput, | 23 | TerminalCloseInput, |
| ... | @@ -192,6 +195,32 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro | ... | @@ -192,6 +195,32 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro |
| 192 | } | 195 | } |
| 193 | } | 196 | } |
| 194 | 197 | ||
| 198 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { | ||
| 199 | const resolved: ResolvedKeybindingsConfig = []; | ||
| 200 | for (const binding of bindings) { | ||
| 201 | const compiled = compileResolvedKeybindingRule(binding); | ||
| 202 | if (!compiled) { | ||
| 203 | throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); | ||
| 204 | } | ||
| 205 | resolved.push(compiled); | ||
| 206 | } | ||
| 207 | return resolved; | ||
| 208 | } | ||
| 209 | |||
| 210 | const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); | ||
| 211 | |||
| 212 | function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig { | ||
| 213 | if (custom.length === 0) { | ||
| 214 | return DEFAULT_RESOLVED_KEYBINDINGS; | ||
| 215 | } | ||
| 216 | |||
| 217 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 218 | const retainedDefaults = DEFAULT_KEYBINDINGS.filter( | ||
| 219 | (binding) => !overriddenCommands.has(binding.command), | ||
| 220 | ); | ||
| 221 | return compileKeybindings([...retainedDefaults, ...custom].slice(-256)); | ||
| 222 | } | ||
| 223 | |||
| 195 | describe("WebSocket Server", () => { | 224 | describe("WebSocket Server", () => { |
| 196 | let server: ReturnType<typeof createServer> | null = null; | 225 | let server: ReturnType<typeof createServer> | null = null; |
| 197 | const connections: WebSocket[] = []; | 226 | const connections: WebSocket[] = []; |
| ... | @@ -307,7 +336,225 @@ describe("WebSocket Server", () => { | ... | @@ -307,7 +336,225 @@ describe("WebSocket Server", () => { |
| 307 | 336 | ||
| 308 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 337 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 309 | expect(response.error).toBeUndefined(); | 338 | expect(response.error).toBeUndefined(); |
| 310 | expect(response.result).toEqual({ cwd: "/my/workspace" }); | 339 | expect(response.result).toEqual({ |
| 340 | cwd: "/my/workspace", | ||
| 341 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 342 | }); | ||
| 343 | }); | ||
| 344 | |||
| 345 | it("reads keybindings from ~/.t3/keybindings.json", async () => { | ||
| 346 | const fakeHome = makeTempDir("t3code-home-"); | ||
| 347 | const configDir = path.join(fakeHome, ".t3"); | ||
| 348 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 349 | fs.writeFileSync( | ||
| 350 | path.join(configDir, "keybindings.json"), | ||
| 351 | JSON.stringify([ | ||
| 352 | { key: "cmd+j", command: "terminal.toggle" }, | ||
| 353 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 354 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | ||
| 355 | ]), | ||
| 356 | "utf8", | ||
| 357 | ); | ||
| 358 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 359 | |||
| 360 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 361 | await server.start(); | ||
| 362 | const addr = server.httpServer.address(); | ||
| 363 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 364 | |||
| 365 | const ws = await connectWs(port); | ||
| 366 | connections.push(ws); | ||
| 367 | |||
| 368 | await waitForMessage(ws); | ||
| 369 | |||
| 370 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 371 | expect(response.error).toBeUndefined(); | ||
| 372 | expect(response.result).toEqual({ | ||
| 373 | cwd: "/my/workspace", | ||
| 374 | keybindings: mergeWithDefaultsForTest([ | ||
| 375 | { key: "cmd+j", command: "terminal.toggle" }, | ||
| 376 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 377 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, | ||
| 378 | ]), | ||
| 379 | }); | ||
| 380 | }); | ||
| 381 | |||
| 382 | it("warns and ignores invalid keybinding entries", async () => { | ||
| 383 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 384 | const fakeHome = makeTempDir("t3code-home-invalid-entry-"); | ||
| 385 | const configDir = path.join(fakeHome, ".t3"); | ||
| 386 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 387 | fs.writeFileSync( | ||
| 388 | path.join(configDir, "keybindings.json"), | ||
| 389 | JSON.stringify([ | ||
| 390 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 391 | { key: "mod+z", command: "invalid.command", when: "terminalFocus" }, | ||
| 392 | ]), | ||
| 393 | "utf8", | ||
| 394 | ); | ||
| 395 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 396 | |||
| 397 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 398 | await server.start(); | ||
| 399 | const addr = server.httpServer.address(); | ||
| 400 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 401 | |||
| 402 | const ws = await connectWs(port); | ||
| 403 | connections.push(ws); | ||
| 404 | |||
| 405 | await waitForMessage(ws); | ||
| 406 | |||
| 407 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 408 | expect(response.error).toBeUndefined(); | ||
| 409 | expect(response.result).toEqual({ | ||
| 410 | cwd: "/my/workspace", | ||
| 411 | keybindings: mergeWithDefaultsForTest([{ key: "mod+j", command: "terminal.toggle" }]), | ||
| 412 | }); | ||
| 413 | expect( | ||
| 414 | warnSpy.mock.calls.some(([message]) => | ||
| 415 | String(message).includes("ignoring invalid keybinding entries"), | ||
| 416 | ), | ||
| 417 | ).toBe(true); | ||
| 418 | }); | ||
| 419 | |||
| 420 | it("warns and ignores keybindings with malformed when expressions", async () => { | ||
| 421 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 422 | const fakeHome = makeTempDir("t3code-home-invalid-when-"); | ||
| 423 | const configDir = path.join(fakeHome, ".t3"); | ||
| 424 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 425 | fs.writeFileSync( | ||
| 426 | path.join(configDir, "keybindings.json"), | ||
| 427 | JSON.stringify([{ key: "mod+j", command: "terminal.toggle", when: "terminalFocus && (" }]), | ||
| 428 | "utf8", | ||
| 429 | ); | ||
| 430 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 431 | |||
| 432 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 433 | await server.start(); | ||
| 434 | const addr = server.httpServer.address(); | ||
| 435 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 436 | |||
| 437 | const ws = await connectWs(port); | ||
| 438 | connections.push(ws); | ||
| 439 | |||
| 440 | await waitForMessage(ws); | ||
| 441 | |||
| 442 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 443 | expect(response.error).toBeUndefined(); | ||
| 444 | expect(response.result).toEqual({ | ||
| 445 | cwd: "/my/workspace", | ||
| 446 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 447 | }); | ||
| 448 | expect( | ||
| 449 | warnSpy.mock.calls.some(([message]) => | ||
| 450 | String(message).includes("ignoring invalid keybinding entries"), | ||
| 451 | ), | ||
| 452 | ).toBe(true); | ||
| 453 | }); | ||
| 454 | |||
| 455 | it("reads keybindings once at startup and caches the resolved config", async () => { | ||
| 456 | const fakeHome = makeTempDir("t3code-home-cached-config-"); | ||
| 457 | const configDir = path.join(fakeHome, ".t3"); | ||
| 458 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 459 | const keybindingsPath = path.join(configDir, "keybindings.json"); | ||
| 460 | fs.writeFileSync( | ||
| 461 | keybindingsPath, | ||
| 462 | JSON.stringify([{ key: "cmd+j", command: "terminal.toggle" }]), | ||
| 463 | "utf8", | ||
| 464 | ); | ||
| 465 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 466 | |||
| 467 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 468 | await server.start(); | ||
| 469 | const addr = server.httpServer.address(); | ||
| 470 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 471 | |||
| 472 | const ws = await connectWs(port); | ||
| 473 | connections.push(ws); | ||
| 474 | await waitForMessage(ws); | ||
| 475 | |||
| 476 | const firstResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 477 | expect(firstResponse.error).toBeUndefined(); | ||
| 478 | expect(firstResponse.result).toEqual({ | ||
| 479 | cwd: "/my/workspace", | ||
| 480 | keybindings: mergeWithDefaultsForTest([{ key: "cmd+j", command: "terminal.toggle" }]), | ||
| 481 | }); | ||
| 482 | |||
| 483 | fs.writeFileSync( | ||
| 484 | keybindingsPath, | ||
| 485 | JSON.stringify([{ key: "cmd+k", command: "terminal.toggle" }]), | ||
| 486 | "utf8", | ||
| 487 | ); | ||
| 488 | |||
| 489 | const secondResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 490 | expect(secondResponse.error).toBeUndefined(); | ||
| 491 | expect(secondResponse.result).toEqual(firstResponse.result); | ||
| 492 | }); | ||
| 493 | |||
| 494 | it("warns and ignores unsupported keybindings config format", async () => { | ||
| 495 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 496 | const fakeHome = makeTempDir("t3code-home-unsupported-format-"); | ||
| 497 | const configDir = path.join(fakeHome, ".t3"); | ||
| 498 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 499 | fs.writeFileSync( | ||
| 500 | path.join(configDir, "keybindings.json"), | ||
| 501 | JSON.stringify({ "terminal.toggle": "mod+j" }), | ||
| 502 | "utf8", | ||
| 503 | ); | ||
| 504 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 505 | |||
| 506 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 507 | await server.start(); | ||
| 508 | const addr = server.httpServer.address(); | ||
| 509 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 510 | |||
| 511 | const ws = await connectWs(port); | ||
| 512 | connections.push(ws); | ||
| 513 | |||
| 514 | await waitForMessage(ws); | ||
| 515 | |||
| 516 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 517 | expect(response.error).toBeUndefined(); | ||
| 518 | expect(response.result).toEqual({ | ||
| 519 | cwd: "/my/workspace", | ||
| 520 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 521 | }); | ||
| 522 | expect( | ||
| 523 | warnSpy.mock.calls.some(([message]) => | ||
| 524 | String(message).includes("unsupported format; expected array"), | ||
| 525 | ), | ||
| 526 | ).toBe(true); | ||
| 527 | }); | ||
| 528 | |||
| 529 | it("warns and ignores malformed keybindings config files", async () => { | ||
| 530 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 531 | const fakeHome = makeTempDir("t3code-home-invalid-"); | ||
| 532 | const configDir = path.join(fakeHome, ".t3"); | ||
| 533 | fs.mkdirSync(configDir, { recursive: true }); | ||
| 534 | fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8"); | ||
| 535 | vi.spyOn(os, "homedir").mockReturnValue(fakeHome); | ||
| 536 | |||
| 537 | server = createTestServer({ cwd: "/my/workspace" }); | ||
| 538 | await server.start(); | ||
| 539 | const addr = server.httpServer.address(); | ||
| 540 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 541 | |||
| 542 | const ws = await connectWs(port); | ||
| 543 | connections.push(ws); | ||
| 544 | |||
| 545 | await waitForMessage(ws); | ||
| 546 | |||
| 547 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | ||
| 548 | expect(response.error).toBeUndefined(); | ||
| 549 | expect(response.result).toEqual({ | ||
| 550 | cwd: "/my/workspace", | ||
| 551 | keybindings: DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 552 | }); | ||
| 553 | expect( | ||
| 554 | warnSpy.mock.calls.some(([message]) => | ||
| 555 | String(message).includes("ignoring malformed keybindings config"), | ||
| 556 | ), | ||
| 557 | ).toBe(true); | ||
| 311 | }); | 558 | }); |
| 312 | 559 | ||
| 313 | it("returns error for unknown methods", async () => { | 560 | it("returns error for unknown methods", async () => { |
apps/server/src/wsServer.ts+6-1| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | removeGitWorktree, | 31 | removeGitWorktree, |
| 32 | } from "./git"; | 32 | } from "./git"; |
| 33 | import { TerminalManager } from "./terminalManager"; | 33 | import { TerminalManager } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig } from "./keybindings"; | ||
| 34 | 35 | ||
| 35 | const MIME_TYPES: Record<string, string> = { | 36 | const MIME_TYPES: Record<string, string> = { |
| 36 | ".html": "text/html; charset=utf-8", | 37 | ".html": "text/html; charset=utf-8", |
| ... | @@ -90,6 +91,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -90,6 +91,7 @@ export function createServer(options: ServerOptions) { |
| 90 | const logger = createLogger("ws"); | 91 | const logger = createLogger("ws"); |
| 91 | const logWebSocketEvents = | 92 | const logWebSocketEvents = |
| 92 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); | 93 | explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); |
| 94 | const keybindingsConfig = loadResolvedKeybindingsConfig(logger); | ||
| 93 | 95 | ||
| 94 | function logOutgoingPush(push: WsPush, recipients: number) { | 96 | function logOutgoingPush(push: WsPush, recipients: number) { |
| 95 | if (!logWebSocketEvents) return; | 97 | if (!logWebSocketEvents) return; |
| ... | @@ -410,7 +412,10 @@ export function createServer(options: ServerOptions) { | ... | @@ -410,7 +412,10 @@ export function createServer(options: ServerOptions) { |
| 410 | return undefined; | 412 | return undefined; |
| 411 | 413 | ||
| 412 | case WS_METHODS.serverGetConfig: | 414 | case WS_METHODS.serverGetConfig: |
| 413 | return { cwd }; | 415 | return { |
| 416 | cwd, | ||
| 417 | keybindings: keybindingsConfig, | ||
| 418 | }; | ||
| 414 | 419 | ||
| 415 | default: | 420 | default: |
| 416 | throw new Error(`Unknown method: ${request.method}`); | 421 | throw new Error(`Unknown method: ${request.method}`); |
apps/web/src/components/BranchToolbar.tsx+11-1| ... | @@ -25,9 +25,15 @@ interface BranchToolbarProps { | ... | @@ -25,9 +25,15 @@ interface BranchToolbarProps { |
| 25 | envMode: "local" | "worktree"; | 25 | envMode: "local" | "worktree"; |
| 26 | onEnvModeChange: (mode: "local" | "worktree") => void; | 26 | onEnvModeChange: (mode: "local" | "worktree") => void; |
| 27 | envLocked: boolean; | 27 | envLocked: boolean; |
| 28 | onComposerFocusRequest?: () => void; | ||
| 28 | } | 29 | } |
| 29 | 30 | ||
| 30 | export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) { | 31 | export default function BranchToolbar({ |
| 32 | envMode, | ||
| 33 | onEnvModeChange, | ||
| 34 | envLocked, | ||
| 35 | onComposerFocusRequest, | ||
| 36 | }: BranchToolbarProps) { | ||
| 31 | const { state, dispatch } = useStore(); | 37 | const { state, dispatch } = useStore(); |
| 32 | const api = useNativeApi(); | 38 | const api = useNativeApi(); |
| 33 | const queryClient = useQueryClient(); | 39 | const queryClient = useQueryClient(); |
| ... | @@ -123,6 +129,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B | ... | @@ -123,6 +129,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 123 | setThreadError(null); | 129 | setThreadError(null); |
| 124 | setThreadBranch(branch.name, null); | 130 | setThreadBranch(branch.name, null); |
| 125 | setIsBranchMenuOpen(false); | 131 | setIsBranchMenuOpen(false); |
| 132 | onComposerFocusRequest?.(); | ||
| 126 | return; | 133 | return; |
| 127 | } | 134 | } |
| 128 | 135 | ||
| ... | @@ -135,6 +142,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B | ... | @@ -135,6 +142,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 135 | // Secondary worktree → point the thread at that worktree path. | 142 | // Secondary worktree → point the thread at that worktree path. |
| 136 | setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); | 143 | setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); |
| 137 | setIsBranchMenuOpen(false); | 144 | setIsBranchMenuOpen(false); |
| 145 | onComposerFocusRequest?.(); | ||
| 138 | return; | 146 | return; |
| 139 | } | 147 | } |
| 140 | 148 | ||
| ... | @@ -143,6 +151,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B | ... | @@ -143,6 +151,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 143 | setThreadError(null); | 151 | setThreadError(null); |
| 144 | setThreadBranch(branch.name, activeWorktreePath); | 152 | setThreadBranch(branch.name, activeWorktreePath); |
| 145 | setIsBranchMenuOpen(false); | 153 | setIsBranchMenuOpen(false); |
| 154 | onComposerFocusRequest?.(); | ||
| 146 | }, | 155 | }, |
| 147 | }); | 156 | }); |
| 148 | }; | 157 | }; |
| ... | @@ -156,6 +165,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B | ... | @@ -156,6 +165,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B |
| 156 | setThreadBranch(name, activeWorktreePath); | 165 | setThreadBranch(name, activeWorktreePath); |
| 157 | setBranchQuery(""); | 166 | setBranchQuery(""); |
| 158 | setIsBranchMenuOpen(false); | 167 | setIsBranchMenuOpen(false); |
| 168 | onComposerFocusRequest?.(); | ||
| 159 | }, | 169 | }, |
| 160 | }); | 170 | }); |
| 161 | }; | 171 | }; |
apps/web/src/components/ChatMarkdown.tsx+4-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { memo } from "react"; | ||
| 1 | import type { Components } from "react-markdown"; | 2 | import type { Components } from "react-markdown"; |
| 2 | import ReactMarkdown from "react-markdown"; | 3 | import ReactMarkdown from "react-markdown"; |
| 3 | import rehypeHighlight from "rehype-highlight"; | 4 | import rehypeHighlight from "rehype-highlight"; |
| ... | @@ -13,7 +14,7 @@ const markdownComponents: Components = { | ... | @@ -13,7 +14,7 @@ const markdownComponents: Components = { |
| 13 | }, | 14 | }, |
| 14 | }; | 15 | }; |
| 15 | 16 | ||
| 16 | export default function ChatMarkdown({ text }: ChatMarkdownProps) { | 17 | function ChatMarkdown({ text }: ChatMarkdownProps) { |
| 17 | return ( | 18 | return ( |
| 18 | <div className="chat-markdown text-sm leading-relaxed text-foreground/80"> | 19 | <div className="chat-markdown text-sm leading-relaxed text-foreground/80"> |
| 19 | <ReactMarkdown | 20 | <ReactMarkdown |
| ... | @@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) { | ... | @@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) { |
| 26 | </div> | 27 | </div> |
| 27 | ); | 28 | ); |
| 28 | } | 29 | } |
| 30 | |||
| 31 | export default memo(ChatMarkdown); |
apps/web/src/components/ChatView.tsx+585-349| ... | @@ -1,10 +1,14 @@ | ... | @@ -1,10 +1,14 @@ |
| 1 | import { | 1 | import { |
| 2 | EDITORS, | ||
| 3 | type EditorId, | ||
| 4 | ModelSlug, | ||
| 5 | type NativeApi, | ||
| 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 6 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 7 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 8 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 5 | type ProviderSendTurnAttachmentInput, | 9 | type ResolvedKeybindingsConfig, |
| 6 | type ProviderApprovalDecision, | 10 | type ProviderApprovalDecision, |
| 7 | ModelSlug, | 11 | type ProviderSendTurnAttachmentInput, |
| 8 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 9 | import { | 13 | import { |
| 10 | type ClipboardEvent, | 14 | type ClipboardEvent, |
| ... | @@ -12,6 +16,8 @@ import { | ... | @@ -12,6 +16,8 @@ import { |
| 12 | type FormEvent, | 16 | type FormEvent, |
| 13 | Fragment, | 17 | Fragment, |
| 14 | type KeyboardEvent, | 18 | type KeyboardEvent, |
| 19 | memo, | ||
| 20 | type RefObject, | ||
| 15 | useCallback, | 21 | useCallback, |
| 16 | useEffect, | 22 | useEffect, |
| 17 | useLayoutEffect, | 23 | useLayoutEffect, |
| ... | @@ -20,12 +26,9 @@ import { | ... | @@ -20,12 +26,9 @@ import { |
| 20 | useState, | 26 | useState, |
| 21 | } from "react"; | 27 | } from "react"; |
| 22 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 28 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 23 | import { | 29 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 24 | gitBranchesQueryOptions, | 30 | import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; |
| 25 | gitCreateWorktreeMutationOptions, | ||
| 26 | } from "~/lib/gitReactQuery"; | ||
| 27 | 31 | ||
| 28 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | ||
| 29 | import { isElectron } from "../env"; | 32 | import { isElectron } from "../env"; |
| 30 | import { buildBootstrapInput } from "../historyBootstrap"; | 33 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 31 | import { | 34 | import { |
| ... | @@ -40,6 +43,7 @@ import { | ... | @@ -40,6 +43,7 @@ import { |
| 40 | derivePendingApprovals, | 43 | derivePendingApprovals, |
| 41 | derivePhase, | 44 | derivePhase, |
| 42 | deriveTimelineEntries, | 45 | deriveTimelineEntries, |
| 46 | type PendingApproval, | ||
| 43 | deriveWorkLogEntries, | 47 | deriveWorkLogEntries, |
| 44 | formatDuration, | 48 | formatDuration, |
| 45 | formatElapsed, | 49 | formatElapsed, |
| ... | @@ -50,7 +54,14 @@ import { useStore } from "../store"; | ... | @@ -50,7 +54,14 @@ import { useStore } from "../store"; |
| 50 | import type { ChatImageAttachment } from "../types"; | 54 | import type { ChatImageAttachment } from "../types"; |
| 51 | import BranchToolbar from "./BranchToolbar"; | 55 | import BranchToolbar from "./BranchToolbar"; |
| 52 | import GitActionsControl from "./GitActionsControl"; | 56 | import GitActionsControl from "./GitActionsControl"; |
| 53 | import { isTerminalToggleShortcut } from "../terminal-shortcuts"; | 57 | import { |
| 58 | isOpenFavoriteEditorShortcut, | ||
| 59 | isTerminalCloseShortcut, | ||
| 60 | isTerminalNewShortcut, | ||
| 61 | isTerminalSplitShortcut, | ||
| 62 | isTerminalToggleShortcut, | ||
| 63 | shortcutLabelForCommand, | ||
| 64 | } from "../keybindings"; | ||
| 54 | import ChatMarkdown from "./ChatMarkdown"; | 65 | import ChatMarkdown from "./ChatMarkdown"; |
| 55 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 66 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 56 | import { useNativeApi } from "../hooks/useNativeApi"; | 67 | import { useNativeApi } from "../hooks/useNativeApi"; |
| ... | @@ -70,7 +81,7 @@ import { Separator } from "./ui/separator"; | ... | @@ -70,7 +81,7 @@ import { Separator } from "./ui/separator"; |
| 70 | import { Group, GroupSeparator } from "./ui/group"; | 81 | import { Group, GroupSeparator } from "./ui/group"; |
| 71 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; | 82 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; |
| 72 | import { CursorIcon, Icon } from "./Icons"; | 83 | import { CursorIcon, Icon } from "./Icons"; |
| 73 | import { cn } from "~/lib/utils"; | 84 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 74 | import { Badge } from "./ui/badge"; | 85 | import { Badge } from "./ui/badge"; |
| 75 | 86 | ||
| 76 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 87 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | @@ -83,6 +94,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | ... | @@ -83,6 +94,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 83 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | 94 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| 84 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = | 95 | const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 85 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; | 96 | "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; |
| 97 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | ||
| 86 | 98 | ||
| 87 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 99 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 88 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | 100 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | @@ -130,7 +142,9 @@ export default function ChatView() { | ... | @@ -130,7 +142,9 @@ export default function ChatView() { |
| 130 | const { state, dispatch } = useStore(); | 142 | const { state, dispatch } = useStore(); |
| 131 | const api = useNativeApi(); | 143 | const api = useNativeApi(); |
| 132 | const queryClient = useQueryClient(); | 144 | const queryClient = useQueryClient(); |
| 133 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ api, queryClient })); | 145 | const createWorktreeMutation = useMutation( |
| 146 | gitCreateWorktreeMutationOptions({ api, queryClient }), | ||
| 147 | ); | ||
| 134 | const [prompt, setPrompt] = useState(""); | 148 | const [prompt, setPrompt] = useState(""); |
| 135 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); | 149 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 136 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 150 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| ... | @@ -154,6 +168,7 @@ export default function ChatView() { | ... | @@ -154,6 +168,7 @@ export default function ChatView() { |
| 154 | 168 | ||
| 155 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 169 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 156 | const activeThreadId = activeThread?.id ?? null; | 170 | const activeThreadId = activeThread?.id ?? null; |
| 171 | const activeSessionId = activeThread?.session?.sessionId; | ||
| 157 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 172 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 158 | const selectedModel = resolveModelSlug( | 173 | const selectedModel = resolveModelSlug( |
| 159 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | 174 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| ... | @@ -254,14 +269,32 @@ export default function ChatView() { | ... | @@ -254,14 +269,32 @@ export default function ChatView() { |
| 254 | } as const); | 269 | } as const); |
| 255 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | 270 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 256 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | 271 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); |
| 272 | const keybindingsQuery = useQuery({ | ||
| 273 | ...serverConfigQueryOptions(api), | ||
| 274 | select: (config) => config.keybindings, | ||
| 275 | }); | ||
| 276 | const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; | ||
| 257 | // Default true while loading to avoid toolbar flicker. | 277 | // Default true while loading to avoid toolbar flicker. |
| 258 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | 278 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| 279 | const splitTerminalShortcutLabel = useMemo( | ||
| 280 | () => shortcutLabelForCommand(keybindings, "terminal.split"), | ||
| 281 | [keybindings], | ||
| 282 | ); | ||
| 283 | const newTerminalShortcutLabel = useMemo( | ||
| 284 | () => shortcutLabelForCommand(keybindings, "terminal.new"), | ||
| 285 | [keybindings], | ||
| 286 | ); | ||
| 287 | const closeTerminalShortcutLabel = useMemo( | ||
| 288 | () => shortcutLabelForCommand(keybindings, "terminal.close"), | ||
| 289 | [keybindings], | ||
| 290 | ); | ||
| 259 | 291 | ||
| 260 | const envLocked = Boolean( | 292 | const envLocked = Boolean( |
| 261 | activeThread && | 293 | activeThread && |
| 262 | (activeThread.messages.length > 0 || | 294 | (activeThread.messages.length > 0 || |
| 263 | (activeThread.session !== null && activeThread.session.status !== "closed")), | 295 | (activeThread.session !== null && activeThread.session.status !== "closed")), |
| 264 | ); | 296 | ); |
| 297 | |||
| 265 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { | 298 | const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { |
| 266 | for (const image of images) { | 299 | for (const image of images) { |
| 267 | if (!image.previewUrl) continue; | 300 | if (!image.previewUrl) continue; |
| ... | @@ -275,6 +308,11 @@ export default function ChatView() { | ... | @@ -275,6 +308,11 @@ export default function ChatView() { |
| 275 | const cursor = textarea.value.length; | 308 | const cursor = textarea.value.length; |
| 276 | textarea.setSelectionRange(cursor, cursor); | 309 | textarea.setSelectionRange(cursor, cursor); |
| 277 | }, []); | 310 | }, []); |
| 311 | const scheduleComposerFocus = useCallback(() => { | ||
| 312 | window.requestAnimationFrame(() => { | ||
| 313 | focusComposer(); | ||
| 314 | }); | ||
| 315 | }, [focusComposer]); | ||
| 278 | const toggleTerminalVisibility = useCallback(() => { | 316 | const toggleTerminalVisibility = useCallback(() => { |
| 279 | if (!activeThreadId) return; | 317 | if (!activeThreadId) return; |
| 280 | dispatch({ | 318 | dispatch({ |
| ... | @@ -346,6 +384,7 @@ export default function ChatView() { | ... | @@ -346,6 +384,7 @@ export default function ChatView() { |
| 346 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { | 384 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 347 | if (mode === state.runtimeMode) return; | 385 | if (mode === state.runtimeMode) return; |
| 348 | dispatch({ type: "SET_RUNTIME_MODE", mode }); | 386 | dispatch({ type: "SET_RUNTIME_MODE", mode }); |
| 387 | scheduleComposerFocus(); | ||
| 349 | if (!api) return; | 388 | if (!api) return; |
| 350 | 389 | ||
| 351 | const sessionIds = state.threads | 390 | const sessionIds = state.threads |
| ... | @@ -463,7 +502,7 @@ export default function ChatView() { | ... | @@ -463,7 +502,7 @@ export default function ChatView() { |
| 463 | if (phase !== "running") return; | 502 | if (phase !== "running") return; |
| 464 | const timer = window.setInterval(() => { | 503 | const timer = window.setInterval(() => { |
| 465 | setNowTick(Date.now()); | 504 | setNowTick(Date.now()); |
| 466 | }, 250); | 505 | }, 1000); |
| 467 | return () => { | 506 | return () => { |
| 468 | window.clearInterval(timer); | 507 | window.clearInterval(timer); |
| 469 | }; | 508 | }; |
| ... | @@ -490,15 +529,74 @@ export default function ChatView() { | ... | @@ -490,15 +529,74 @@ export default function ChatView() { |
| 490 | }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); | 529 | }, [activeThread?.terminalOpen, activeThreadId, focusComposer]); |
| 491 | 530 | ||
| 492 | useEffect(() => { | 531 | useEffect(() => { |
| 532 | const isTerminalFocused = (): boolean => { | ||
| 533 | const activeElement = document.activeElement; | ||
| 534 | if (!(activeElement instanceof HTMLElement)) return false; | ||
| 535 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | ||
| 536 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | ||
| 537 | }; | ||
| 538 | |||
| 493 | const handler = (event: globalThis.KeyboardEvent) => { | 539 | const handler = (event: globalThis.KeyboardEvent) => { |
| 494 | if (!activeThreadId) return; | 540 | if (!activeThreadId || event.defaultPrevented) return; |
| 495 | if (!isTerminalToggleShortcut(event)) return; | 541 | const shortcutContext = { |
| 542 | terminalFocus: isTerminalFocused(), | ||
| 543 | terminalOpen: Boolean(activeThread?.terminalOpen), | ||
| 544 | }; | ||
| 545 | |||
| 546 | if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) { | ||
| 547 | event.preventDefault(); | ||
| 548 | event.stopPropagation(); | ||
| 549 | toggleTerminalVisibility(); | ||
| 550 | return; | ||
| 551 | } | ||
| 552 | |||
| 553 | if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { | ||
| 554 | event.preventDefault(); | ||
| 555 | event.stopPropagation(); | ||
| 556 | if (!activeThread?.terminalOpen) { | ||
| 557 | dispatch({ | ||
| 558 | type: "SET_THREAD_TERMINAL_OPEN", | ||
| 559 | threadId: activeThreadId, | ||
| 560 | open: true, | ||
| 561 | }); | ||
| 562 | } | ||
| 563 | splitTerminal(); | ||
| 564 | return; | ||
| 565 | } | ||
| 566 | |||
| 567 | if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { | ||
| 568 | event.preventDefault(); | ||
| 569 | event.stopPropagation(); | ||
| 570 | if (!activeThread?.terminalOpen) return; | ||
| 571 | closeTerminal(activeThread.activeTerminalId); | ||
| 572 | return; | ||
| 573 | } | ||
| 574 | |||
| 575 | if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; | ||
| 496 | event.preventDefault(); | 576 | event.preventDefault(); |
| 497 | toggleTerminalVisibility(); | 577 | event.stopPropagation(); |
| 578 | if (!activeThread?.terminalOpen) { | ||
| 579 | dispatch({ | ||
| 580 | type: "SET_THREAD_TERMINAL_OPEN", | ||
| 581 | threadId: activeThreadId, | ||
| 582 | open: true, | ||
| 583 | }); | ||
| 584 | } | ||
| 585 | createNewTerminal(); | ||
| 498 | }; | 586 | }; |
| 499 | window.addEventListener("keydown", handler); | 587 | window.addEventListener("keydown", handler); |
| 500 | return () => window.removeEventListener("keydown", handler); | 588 | return () => window.removeEventListener("keydown", handler); |
| 501 | }, [activeThreadId, toggleTerminalVisibility]); | 589 | }, [ |
| 590 | activeThread?.terminalOpen, | ||
| 591 | activeThread?.activeTerminalId, | ||
| 592 | activeThreadId, | ||
| 593 | closeTerminal, | ||
| 594 | createNewTerminal, | ||
| 595 | dispatch, | ||
| 596 | splitTerminal, | ||
| 597 | keybindings, | ||
| 598 | toggleTerminalVisibility, | ||
| 599 | ]); | ||
| 502 | 600 | ||
| 503 | const setThreadError = (threadId: string | null, error: string | null) => { | 601 | const setThreadError = (threadId: string | null, error: string | null) => { |
| 504 | if (!threadId) return; | 602 | if (!threadId) return; |
| ... | @@ -796,37 +894,58 @@ export default function ChatView() { | ... | @@ -796,37 +894,58 @@ export default function ChatView() { |
| 796 | }); | 894 | }); |
| 797 | }; | 895 | }; |
| 798 | 896 | ||
| 799 | const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => { | 897 | const onRespondToApproval = useCallback( |
| 800 | if (!api || !activeThread?.session) return; | 898 | async (requestId: string, decision: ProviderApprovalDecision) => { |
| 899 | if (!api || !activeSessionId || !activeThreadId) return; | ||
| 801 | 900 | ||
| 802 | setRespondingRequestIds((existing) => | 901 | setRespondingRequestIds((existing) => |
| 803 | existing.includes(requestId) ? existing : [...existing, requestId], | 902 | existing.includes(requestId) ? existing : [...existing, requestId], |
| 804 | ); | 903 | ); |
| 805 | try { | 904 | try { |
| 806 | await api.providers.respondToRequest({ | 905 | await api.providers.respondToRequest({ |
| 807 | sessionId: activeThread.session.sessionId, | 906 | sessionId: activeSessionId, |
| 808 | requestId, | 907 | requestId, |
| 809 | decision, | 908 | decision, |
| 810 | }); | 909 | }); |
| 811 | } catch (err) { | 910 | } catch (err) { |
| 911 | dispatch({ | ||
| 912 | type: "SET_ERROR", | ||
| 913 | threadId: activeThreadId, | ||
| 914 | error: err instanceof Error ? err.message : "Failed to submit approval decision.", | ||
| 915 | }); | ||
| 916 | } finally { | ||
| 917 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | ||
| 918 | } | ||
| 919 | }, | ||
| 920 | [activeSessionId, activeThreadId, api, dispatch], | ||
| 921 | ); | ||
| 922 | |||
| 923 | const onModelSelect = useCallback( | ||
| 924 | (model: ModelSlug) => { | ||
| 925 | if (!activeThread) return; | ||
| 812 | dispatch({ | 926 | dispatch({ |
| 813 | type: "SET_ERROR", | 927 | type: "SET_THREAD_MODEL", |
| 814 | threadId: activeThread.id, | 928 | threadId: activeThread.id, |
| 815 | error: err instanceof Error ? err.message : "Failed to submit approval decision.", | 929 | model: resolveModelSlug(model), |
| 816 | }); | 930 | }); |
| 817 | } finally { | 931 | scheduleComposerFocus(); |
| 818 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 932 | }, |
| 819 | } | 933 | [activeThread, dispatch, scheduleComposerFocus], |
| 820 | }; | 934 | ); |
| 821 | 935 | const onEffortSelect = useCallback( | |
| 822 | const onModelSelect = (model: ModelSlug) => { | 936 | (effort: ReasoningEffort) => { |
| 823 | if (!activeThread) return; | 937 | setSelectedEffort(effort); |
| 824 | dispatch({ | 938 | scheduleComposerFocus(); |
| 825 | type: "SET_THREAD_MODEL", | 939 | }, |
| 826 | threadId: activeThread.id, | 940 | [scheduleComposerFocus], |
| 827 | model: resolveModelSlug(model), | 941 | ); |
| 828 | }); | 942 | const onEnvModeChange = useCallback( |
| 829 | }; | 943 | (mode: "local" | "worktree") => { |
| 944 | setEnvMode(mode); | ||
| 945 | scheduleComposerFocus(); | ||
| 946 | }, | ||
| 947 | [scheduleComposerFocus], | ||
| 948 | ); | ||
| 830 | 949 | ||
| 831 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | 950 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { |
| 832 | if (e.key === "Enter" && !e.shiftKey) { | 951 | if (e.key === "Enter" && !e.shiftKey) { |
| ... | @@ -834,6 +953,18 @@ export default function ChatView() { | ... | @@ -834,6 +953,18 @@ export default function ChatView() { |
| 834 | void onSend(e as unknown as FormEvent); | 953 | void onSend(e as unknown as FormEvent); |
| 835 | } | 954 | } |
| 836 | }; | 955 | }; |
| 956 | const onToggleWorkGroup = useCallback((groupId: string) => { | ||
| 957 | setExpandedWorkGroups((existing) => ({ | ||
| 958 | ...existing, | ||
| 959 | [groupId]: !existing[groupId], | ||
| 960 | })); | ||
| 961 | }, []); | ||
| 962 | const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { | ||
| 963 | setExpandedImage(image); | ||
| 964 | }, []); | ||
| 965 | const onToggleDiff = useCallback(() => { | ||
| 966 | dispatch({ type: "TOGGLE_DIFF" }); | ||
| 967 | }, [dispatch]); | ||
| 837 | 968 | ||
| 838 | // Empty state: no active thread | 969 | // Empty state: no active thread |
| 839 | if (!activeThread) { | 970 | if (!activeThread) { |
| ... | @@ -859,101 +990,24 @@ export default function ChatView() { | ... | @@ -859,101 +990,24 @@ export default function ChatView() { |
| 859 | <header | 990 | <header |
| 860 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} | 991 | className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} |
| 861 | > | 992 | > |
| 862 | <div className="flex items-center gap-3"> | 993 | <ChatHeader |
| 863 | <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2> | 994 | activeThreadTitle={activeThread.title} |
| 864 | {activeProject && <Badge variant="outline">{activeProject.name}</Badge>} | 995 | activeProjectName={activeProject?.name} |
| 865 | </div> | 996 | keybindings={keybindings} |
| 866 | <div className="flex items-center gap-3"> | 997 | api={api} |
| 867 | {/* Open in editor */} | 998 | gitCwd={gitCwd} |
| 868 | {activeProject && <OpenInPicker />} | 999 | diffOpen={state.diffOpen} |
| 869 | {/* Git actions */} | 1000 | onToggleDiff={onToggleDiff} |
| 870 | {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />} | 1001 | /> |
| 871 | |||
| 872 | {/* Diff toggle */} | ||
| 873 | <Button | ||
| 874 | size="xs" | ||
| 875 | variant="ghost" | ||
| 876 | className={cn( | ||
| 877 | "text-muted-foreground/70 hover:text-foreground/80", | ||
| 878 | state.diffOpen && "bg-accent text-accent-foreground", | ||
| 879 | )} | ||
| 880 | onClick={() => dispatch({ type: "TOGGLE_DIFF" })} | ||
| 881 | > | ||
| 882 | Diff | ||
| 883 | </Button> | ||
| 884 | </div> | ||
| 885 | </header> | 1002 | </header> |
| 886 | 1003 | ||
| 887 | {/* Error banner */} | 1004 | {/* Error banner */} |
| 888 | {activeThread.error && ( | 1005 | <ThreadErrorBanner error={activeThread.error} /> |
| 889 | <div className="pt-3 mx-auto max-w-3xl"> | 1006 | <PendingApprovalsPanel |
| 890 | <Alert variant="error"> | 1007 | pendingApprovals={pendingApprovals} |
| 891 | <CircleAlertIcon /> | 1008 | respondingRequestIds={respondingRequestIds} |
| 892 | <AlertDescription className="line-clamp-3" title={activeThread.error}> | 1009 | onRespondToApproval={onRespondToApproval} |
| 893 | {activeThread.error} | 1010 | /> |
| 894 | </AlertDescription> | ||
| 895 | </Alert> | ||
| 896 | </div> | ||
| 897 | )} | ||
| 898 | |||
| 899 | {pendingApprovals.length > 0 && ( | ||
| 900 | <div className="pt-3 mx-auto max-w-3xl space-y-2"> | ||
| 901 | {pendingApprovals.map((approval) => { | ||
| 902 | const isResponding = respondingRequestIds.includes(approval.requestId); | ||
| 903 | |||
| 904 | return ( | ||
| 905 | <Alert variant="warning" key={approval.requestId}> | ||
| 906 | <InfoIcon /> | ||
| 907 | <AlertTitle className="text-xs"> | ||
| 908 | {approval.requestKind === "command" | ||
| 909 | ? "Command approval requested" | ||
| 910 | : "File-change approval requested"} | ||
| 911 | </AlertTitle> | ||
| 912 | <AlertDescription | ||
| 913 | className="truncate block font-mono text-[11px]" | ||
| 914 | title={approval.detail} | ||
| 915 | > | ||
| 916 | {approval.detail} | ||
| 917 | </AlertDescription> | ||
| 918 | <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto"> | ||
| 919 | <Button | ||
| 920 | size="xs" | ||
| 921 | variant="default" | ||
| 922 | disabled={isResponding} | ||
| 923 | onClick={() => void onRespondToApproval(approval.requestId, "accept")} | ||
| 924 | > | ||
| 925 | Approve once | ||
| 926 | </Button> | ||
| 927 | <Button | ||
| 928 | size="xs" | ||
| 929 | variant="outline" | ||
| 930 | disabled={isResponding} | ||
| 931 | onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")} | ||
| 932 | > | ||
| 933 | Always allow this session | ||
| 934 | </Button> | ||
| 935 | <Button | ||
| 936 | size="xs" | ||
| 937 | variant="destructive-outline" | ||
| 938 | disabled={isResponding} | ||
| 939 | onClick={() => void onRespondToApproval(approval.requestId, "decline")} | ||
| 940 | > | ||
| 941 | Decline | ||
| 942 | </Button> | ||
| 943 | <Button | ||
| 944 | size="xs" | ||
| 945 | variant="ghost" | ||
| 946 | disabled={isResponding} | ||
| 947 | onClick={() => void onRespondToApproval(approval.requestId, "cancel")} | ||
| 948 | > | ||
| 949 | Cancel turn | ||
| 950 | </Button> | ||
| 951 | </AlertAction> | ||
| 952 | </Alert> | ||
| 953 | ); | ||
| 954 | })} | ||
| 955 | </div> | ||
| 956 | )} | ||
| 957 | 1011 | ||
| 958 | {/* Messages */} | 1012 | {/* Messages */} |
| 959 | <div | 1013 | <div |
| ... | @@ -961,198 +1015,19 @@ export default function ChatView() { | ... | @@ -961,198 +1015,19 @@ export default function ChatView() { |
| 961 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" | 1015 | className="min-h-0 flex-1 overflow-y-auto px-5 py-4" |
| 962 | onScroll={onMessagesScroll} | 1016 | onScroll={onMessagesScroll} |
| 963 | > | 1017 | > |
| 964 | {activeThread.messages.length === 0 && !isWorking ? ( | 1018 | <MessagesTimeline |
| 965 | <div className="flex h-full items-center justify-center"> | 1019 | hasMessages={activeThread.messages.length > 0} |
| 966 | <p className="text-sm text-muted-foreground/30"> | 1020 | isWorking={isWorking} |
| 967 | Send a message to start the conversation. | 1021 | timelineEntries={timelineEntries} |
| 968 | </p> | 1022 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| 969 | </div> | 1023 | completionSummary={completionSummary} |
| 970 | ) : ( | 1024 | assistantCompletionByItemId={assistantCompletionByItemId} |
| 971 | <div className="mx-auto max-w-3xl space-y-4"> | 1025 | nowIso={nowIso} |
| 972 | {timelineEntries.map((timelineEntry, index) => { | 1026 | expandedWorkGroups={expandedWorkGroups} |
| 973 | if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { | 1027 | onToggleWorkGroup={onToggleWorkGroup} |
| 974 | return null; | 1028 | onImageExpand={onExpandTimelineImage} |
| 975 | } | 1029 | messagesEndRef={messagesEndRef} |
| 976 | 1030 | /> | |
| 977 | const showCompletionDivider = | ||
| 978 | timelineEntry.kind === "message" && | ||
| 979 | timelineEntry.message.role === "assistant" && | ||
| 980 | completionDividerBeforeEntryId === timelineEntry.id; | ||
| 981 | |||
| 982 | if (timelineEntry.kind === "work") { | ||
| 983 | const groupedEntries = [timelineEntry.entry]; | ||
| 984 | let cursor = index + 1; | ||
| 985 | while (cursor < timelineEntries.length) { | ||
| 986 | const nextEntry = timelineEntries[cursor]; | ||
| 987 | if (!nextEntry || nextEntry.kind !== "work") break; | ||
| 988 | groupedEntries.push(nextEntry.entry); | ||
| 989 | cursor += 1; | ||
| 990 | } | ||
| 991 | |||
| 992 | const groupId = timelineEntry.id; | ||
| 993 | const isExpanded = expandedWorkGroups[groupId] ?? false; | ||
| 994 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 995 | const visibleEntries = | ||
| 996 | hasOverflow && !isExpanded | ||
| 997 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | ||
| 998 | : groupedEntries; | ||
| 999 | const hiddenCount = groupedEntries.length - visibleEntries.length; | ||
| 1000 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | ||
| 1001 | const groupLabel = onlyToolEntries | ||
| 1002 | ? groupedEntries.length === 1 | ||
| 1003 | ? "Tool call" | ||
| 1004 | : `Tool calls (${groupedEntries.length})` | ||
| 1005 | : groupedEntries.length === 1 | ||
| 1006 | ? "Work event" | ||
| 1007 | : `Work log (${groupedEntries.length})`; | ||
| 1008 | |||
| 1009 | return ( | ||
| 1010 | <Fragment key={timelineEntry.id}> | ||
| 1011 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | ||
| 1012 | <div className="mb-1.5 flex items-center justify-between gap-3"> | ||
| 1013 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | ||
| 1014 | {groupLabel} | ||
| 1015 | </p> | ||
| 1016 | {hasOverflow && ( | ||
| 1017 | <button | ||
| 1018 | type="button" | ||
| 1019 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | ||
| 1020 | onClick={() => | ||
| 1021 | setExpandedWorkGroups((existing) => ({ | ||
| 1022 | ...existing, | ||
| 1023 | [groupId]: !existing[groupId], | ||
| 1024 | })) | ||
| 1025 | } | ||
| 1026 | > | ||
| 1027 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | ||
| 1028 | </button> | ||
| 1029 | )} | ||
| 1030 | </div> | ||
| 1031 | <div className="space-y-1"> | ||
| 1032 | {visibleEntries.map((workEntry) => ( | ||
| 1033 | <div | ||
| 1034 | key={`work-row:${workEntry.id}`} | ||
| 1035 | className="flex items-start gap-2 py-0.5" | ||
| 1036 | > | ||
| 1037 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 1038 | <p | ||
| 1039 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | ||
| 1040 | > | ||
| 1041 | {workEntry.detail ? ( | ||
| 1042 | <> | ||
| 1043 | {workEntry.label} | ||
| 1044 | <span | ||
| 1045 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | ||
| 1046 | title={workEntry.detail} | ||
| 1047 | > | ||
| 1048 | {workEntry.detail} | ||
| 1049 | </span> | ||
| 1050 | </> | ||
| 1051 | ) : ( | ||
| 1052 | workEntry.label | ||
| 1053 | )} | ||
| 1054 | </p> | ||
| 1055 | </div> | ||
| 1056 | ))} | ||
| 1057 | </div> | ||
| 1058 | </div> | ||
| 1059 | </Fragment> | ||
| 1060 | ); | ||
| 1061 | } | ||
| 1062 | |||
| 1063 | if (timelineEntry.message.role === "user") { | ||
| 1064 | const userImages = timelineEntry.message.attachments ?? []; | ||
| 1065 | return ( | ||
| 1066 | <Fragment key={timelineEntry.id}> | ||
| 1067 | <div className="flex justify-end"> | ||
| 1068 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | ||
| 1069 | {userImages.length > 0 && ( | ||
| 1070 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | ||
| 1071 | {userImages.map((image) => ( | ||
| 1072 | <div | ||
| 1073 | key={image.id} | ||
| 1074 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | ||
| 1075 | > | ||
| 1076 | {image.previewUrl ? ( | ||
| 1077 | <img | ||
| 1078 | src={image.previewUrl} | ||
| 1079 | alt={image.name} | ||
| 1080 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | ||
| 1081 | onClick={() => | ||
| 1082 | setExpandedImage({ src: image.previewUrl!, name: image.name }) | ||
| 1083 | } | ||
| 1084 | /> | ||
| 1085 | ) : ( | ||
| 1086 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | ||
| 1087 | {image.name} | ||
| 1088 | </div> | ||
| 1089 | )} | ||
| 1090 | </div> | ||
| 1091 | ))} | ||
| 1092 | </div> | ||
| 1093 | )} | ||
| 1094 | {timelineEntry.message.text && ( | ||
| 1095 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | ||
| 1096 | {timelineEntry.message.text} | ||
| 1097 | </pre> | ||
| 1098 | )} | ||
| 1099 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | ||
| 1100 | {formatTimestamp(timelineEntry.message.createdAt)} | ||
| 1101 | </p> | ||
| 1102 | </div> | ||
| 1103 | </div> | ||
| 1104 | </Fragment> | ||
| 1105 | ); | ||
| 1106 | } | ||
| 1107 | |||
| 1108 | return ( | ||
| 1109 | <Fragment key={timelineEntry.id}> | ||
| 1110 | {showCompletionDivider && ( | ||
| 1111 | <div className="my-3 flex items-center gap-3"> | ||
| 1112 | <span className="h-px flex-1 bg-border" /> | ||
| 1113 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | ||
| 1114 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | ||
| 1115 | </span> | ||
| 1116 | <span className="h-px flex-1 bg-border" /> | ||
| 1117 | </div> | ||
| 1118 | )} | ||
| 1119 | <div className="px-1 py-0.5"> | ||
| 1120 | <ChatMarkdown | ||
| 1121 | text={ | ||
| 1122 | timelineEntry.message.text || | ||
| 1123 | (timelineEntry.message.streaming ? "" : "(empty response)") | ||
| 1124 | } | ||
| 1125 | /> | ||
| 1126 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | ||
| 1127 | {formatMessageMeta( | ||
| 1128 | timelineEntry.message.createdAt, | ||
| 1129 | timelineEntry.message.streaming | ||
| 1130 | ? formatElapsed(timelineEntry.message.createdAt, nowIso) | ||
| 1131 | : formatElapsed( | ||
| 1132 | timelineEntry.message.createdAt, | ||
| 1133 | assistantCompletionByItemId.get(timelineEntry.message.id), | ||
| 1134 | ), | ||
| 1135 | )} | ||
| 1136 | </p> | ||
| 1137 | </div> | ||
| 1138 | </Fragment> | ||
| 1139 | ); | ||
| 1140 | })} | ||
| 1141 | {isWorking && ( | ||
| 1142 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | ||
| 1143 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 1144 | <div className="flex items-center pt-1"> | ||
| 1145 | <span className="inline-flex items-center gap-[3px]"> | ||
| 1146 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | ||
| 1147 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | ||
| 1148 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | ||
| 1149 | </span> | ||
| 1150 | </div> | ||
| 1151 | </div> | ||
| 1152 | )} | ||
| 1153 | <div ref={messagesEndRef} /> | ||
| 1154 | </div> | ||
| 1155 | )} | ||
| 1156 | </div> | 1031 | </div> |
| 1157 | 1032 | ||
| 1158 | {/* Input bar */} | 1033 | {/* Input bar */} |
| ... | @@ -1230,7 +1105,7 @@ export default function ChatView() { | ... | @@ -1230,7 +1105,7 @@ export default function ChatView() { |
| 1230 | <Separator orientation="vertical" className="mx-0.5 h-4" /> | 1105 | <Separator orientation="vertical" className="mx-0.5 h-4" /> |
| 1231 | 1106 | ||
| 1232 | {/* Reasoning effort */} | 1107 | {/* Reasoning effort */} |
| 1233 | <ReasoningEffortPicker effort={selectedEffort} onEffortChange={setSelectedEffort} /> | 1108 | <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} /> |
| 1234 | 1109 | ||
| 1235 | {/* Divider */} | 1110 | {/* Divider */} |
| 1236 | <Separator orientation="vertical" className="mx-0.5 h-4" /> | 1111 | <Separator orientation="vertical" className="mx-0.5 h-4" /> |
| ... | @@ -1333,7 +1208,12 @@ export default function ChatView() { | ... | @@ -1333,7 +1208,12 @@ export default function ChatView() { |
| 1333 | </div> | 1208 | </div> |
| 1334 | 1209 | ||
| 1335 | {isGitRepo && ( | 1210 | {isGitRepo && ( |
| 1336 | <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} /> | 1211 | <BranchToolbar |
| 1212 | envMode={envMode} | ||
| 1213 | onEnvModeChange={onEnvModeChange} | ||
| 1214 | envLocked={envLocked} | ||
| 1215 | onComposerFocusRequest={scheduleComposerFocus} | ||
| 1216 | /> | ||
| 1337 | )} | 1217 | )} |
| 1338 | 1218 | ||
| 1339 | {activeThread.terminalOpen && api && activeProject && ( | 1219 | {activeThread.terminalOpen && api && activeProject && ( |
| ... | @@ -1350,6 +1230,9 @@ export default function ChatView() { | ... | @@ -1350,6 +1230,9 @@ export default function ChatView() { |
| 1350 | focusRequestId={terminalFocusRequestId} | 1230 | focusRequestId={terminalFocusRequestId} |
| 1351 | onSplitTerminal={splitTerminal} | 1231 | onSplitTerminal={splitTerminal} |
| 1352 | onNewTerminal={createNewTerminal} | 1232 | onNewTerminal={createNewTerminal} |
| 1233 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | ||
| 1234 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | ||
| 1235 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | ||
| 1353 | onActiveTerminalChange={activateTerminal} | 1236 | onActiveTerminalChange={activateTerminal} |
| 1354 | onCloseTerminal={closeTerminal} | 1237 | onCloseTerminal={closeTerminal} |
| 1355 | onHeightChange={(height) => | 1238 | onHeightChange={(height) => |
| ... | @@ -1400,7 +1283,355 @@ export default function ChatView() { | ... | @@ -1400,7 +1283,355 @@ export default function ChatView() { |
| 1400 | ); | 1283 | ); |
| 1401 | } | 1284 | } |
| 1402 | 1285 | ||
| 1403 | function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) { | 1286 | interface ChatHeaderProps { |
| 1287 | activeThreadTitle: string; | ||
| 1288 | activeProjectName: string | undefined; | ||
| 1289 | keybindings: ResolvedKeybindingsConfig; | ||
| 1290 | api: NativeApi | undefined; | ||
| 1291 | gitCwd: string | null; | ||
| 1292 | diffOpen: boolean; | ||
| 1293 | onToggleDiff: () => void; | ||
| 1294 | } | ||
| 1295 | |||
| 1296 | const ChatHeader = memo(function ChatHeader({ | ||
| 1297 | activeThreadTitle, | ||
| 1298 | activeProjectName, | ||
| 1299 | keybindings, | ||
| 1300 | api, | ||
| 1301 | gitCwd, | ||
| 1302 | diffOpen, | ||
| 1303 | onToggleDiff, | ||
| 1304 | }: ChatHeaderProps) { | ||
| 1305 | return ( | ||
| 1306 | <> | ||
| 1307 | <div className="flex items-center gap-3"> | ||
| 1308 | <h2 className="text-sm font-medium text-foreground">{activeThreadTitle}</h2> | ||
| 1309 | {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>} | ||
| 1310 | </div> | ||
| 1311 | <div className="flex items-center gap-3"> | ||
| 1312 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | ||
| 1313 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} | ||
| 1314 | <Button | ||
| 1315 | size="xs" | ||
| 1316 | variant="ghost" | ||
| 1317 | className={cn( | ||
| 1318 | "text-muted-foreground/70 hover:text-foreground/80", | ||
| 1319 | diffOpen && "bg-accent text-accent-foreground", | ||
| 1320 | )} | ||
| 1321 | onClick={onToggleDiff} | ||
| 1322 | > | ||
| 1323 | Diff | ||
| 1324 | </Button> | ||
| 1325 | </div> | ||
| 1326 | </> | ||
| 1327 | ); | ||
| 1328 | }); | ||
| 1329 | |||
| 1330 | const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: string | null }) { | ||
| 1331 | if (!error) return null; | ||
| 1332 | return ( | ||
| 1333 | <div className="pt-3 mx-auto max-w-3xl"> | ||
| 1334 | <Alert variant="error"> | ||
| 1335 | <CircleAlertIcon /> | ||
| 1336 | <AlertDescription className="line-clamp-3" title={error}> | ||
| 1337 | {error} | ||
| 1338 | </AlertDescription> | ||
| 1339 | </Alert> | ||
| 1340 | </div> | ||
| 1341 | ); | ||
| 1342 | }); | ||
| 1343 | |||
| 1344 | interface PendingApprovalsPanelProps { | ||
| 1345 | pendingApprovals: PendingApproval[]; | ||
| 1346 | respondingRequestIds: string[]; | ||
| 1347 | onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>; | ||
| 1348 | } | ||
| 1349 | |||
| 1350 | const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ | ||
| 1351 | pendingApprovals, | ||
| 1352 | respondingRequestIds, | ||
| 1353 | onRespondToApproval, | ||
| 1354 | }: PendingApprovalsPanelProps) { | ||
| 1355 | if (pendingApprovals.length === 0) return null; | ||
| 1356 | return ( | ||
| 1357 | <div className="pt-3 mx-auto max-w-3xl space-y-2"> | ||
| 1358 | {pendingApprovals.map((approval) => { | ||
| 1359 | const isResponding = respondingRequestIds.includes(approval.requestId); | ||
| 1360 | |||
| 1361 | return ( | ||
| 1362 | <Alert variant="warning" key={approval.requestId}> | ||
| 1363 | <InfoIcon /> | ||
| 1364 | <AlertTitle className="text-xs"> | ||
| 1365 | {approval.requestKind === "command" | ||
| 1366 | ? "Command approval requested" | ||
| 1367 | : "File-change approval requested"} | ||
| 1368 | </AlertTitle> | ||
| 1369 | <AlertDescription | ||
| 1370 | className="truncate block font-mono text-[11px]" | ||
| 1371 | title={approval.detail} | ||
| 1372 | > | ||
| 1373 | {approval.detail} | ||
| 1374 | </AlertDescription> | ||
| 1375 | <AlertAction className="col-start-2! -col-end-1! mt-1.5 sm:row-start-auto sm:row-end-auto"> | ||
| 1376 | <Button | ||
| 1377 | size="xs" | ||
| 1378 | variant="default" | ||
| 1379 | disabled={isResponding} | ||
| 1380 | onClick={() => void onRespondToApproval(approval.requestId, "accept")} | ||
| 1381 | > | ||
| 1382 | Approve once | ||
| 1383 | </Button> | ||
| 1384 | <Button | ||
| 1385 | size="xs" | ||
| 1386 | variant="outline" | ||
| 1387 | disabled={isResponding} | ||
| 1388 | onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")} | ||
| 1389 | > | ||
| 1390 | Always allow this session | ||
| 1391 | </Button> | ||
| 1392 | <Button | ||
| 1393 | size="xs" | ||
| 1394 | variant="destructive-outline" | ||
| 1395 | disabled={isResponding} | ||
| 1396 | onClick={() => void onRespondToApproval(approval.requestId, "decline")} | ||
| 1397 | > | ||
| 1398 | Decline | ||
| 1399 | </Button> | ||
| 1400 | <Button | ||
| 1401 | size="xs" | ||
| 1402 | variant="ghost" | ||
| 1403 | disabled={isResponding} | ||
| 1404 | onClick={() => void onRespondToApproval(approval.requestId, "cancel")} | ||
| 1405 | > | ||
| 1406 | Cancel turn | ||
| 1407 | </Button> | ||
| 1408 | </AlertAction> | ||
| 1409 | </Alert> | ||
| 1410 | ); | ||
| 1411 | })} | ||
| 1412 | </div> | ||
| 1413 | ); | ||
| 1414 | }); | ||
| 1415 | |||
| 1416 | interface MessagesTimelineProps { | ||
| 1417 | hasMessages: boolean; | ||
| 1418 | isWorking: boolean; | ||
| 1419 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | ||
| 1420 | completionDividerBeforeEntryId: string | null; | ||
| 1421 | completionSummary: string | null; | ||
| 1422 | assistantCompletionByItemId: Map<string, string>; | ||
| 1423 | nowIso: string; | ||
| 1424 | expandedWorkGroups: Record<string, boolean>; | ||
| 1425 | onToggleWorkGroup: (groupId: string) => void; | ||
| 1426 | onImageExpand: (image: ExpandedImagePreview) => void; | ||
| 1427 | messagesEndRef: RefObject<HTMLDivElement | null>; | ||
| 1428 | } | ||
| 1429 | |||
| 1430 | const MessagesTimeline = memo(function MessagesTimeline({ | ||
| 1431 | hasMessages, | ||
| 1432 | isWorking, | ||
| 1433 | timelineEntries, | ||
| 1434 | completionDividerBeforeEntryId, | ||
| 1435 | completionSummary, | ||
| 1436 | assistantCompletionByItemId, | ||
| 1437 | nowIso, | ||
| 1438 | expandedWorkGroups, | ||
| 1439 | onToggleWorkGroup, | ||
| 1440 | onImageExpand, | ||
| 1441 | messagesEndRef, | ||
| 1442 | }: MessagesTimelineProps) { | ||
| 1443 | if (!hasMessages && !isWorking) { | ||
| 1444 | return ( | ||
| 1445 | <div className="flex h-full items-center justify-center"> | ||
| 1446 | <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p> | ||
| 1447 | </div> | ||
| 1448 | ); | ||
| 1449 | } | ||
| 1450 | |||
| 1451 | return ( | ||
| 1452 | <div className="mx-auto max-w-3xl space-y-4"> | ||
| 1453 | {timelineEntries.map((timelineEntry, index) => { | ||
| 1454 | if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { | ||
| 1455 | return null; | ||
| 1456 | } | ||
| 1457 | |||
| 1458 | const showCompletionDivider = | ||
| 1459 | timelineEntry.kind === "message" && | ||
| 1460 | timelineEntry.message.role === "assistant" && | ||
| 1461 | completionDividerBeforeEntryId === timelineEntry.id; | ||
| 1462 | |||
| 1463 | if (timelineEntry.kind === "work") { | ||
| 1464 | const groupedEntries = [timelineEntry.entry]; | ||
| 1465 | let cursor = index + 1; | ||
| 1466 | while (cursor < timelineEntries.length) { | ||
| 1467 | const nextEntry = timelineEntries[cursor]; | ||
| 1468 | if (!nextEntry || nextEntry.kind !== "work") break; | ||
| 1469 | groupedEntries.push(nextEntry.entry); | ||
| 1470 | cursor += 1; | ||
| 1471 | } | ||
| 1472 | |||
| 1473 | const groupId = timelineEntry.id; | ||
| 1474 | const isExpanded = expandedWorkGroups[groupId] ?? false; | ||
| 1475 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 1476 | const visibleEntries = | ||
| 1477 | hasOverflow && !isExpanded | ||
| 1478 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | ||
| 1479 | : groupedEntries; | ||
| 1480 | const hiddenCount = groupedEntries.length - visibleEntries.length; | ||
| 1481 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | ||
| 1482 | const groupLabel = onlyToolEntries | ||
| 1483 | ? groupedEntries.length === 1 | ||
| 1484 | ? "Tool call" | ||
| 1485 | : `Tool calls (${groupedEntries.length})` | ||
| 1486 | : groupedEntries.length === 1 | ||
| 1487 | ? "Work event" | ||
| 1488 | : `Work log (${groupedEntries.length})`; | ||
| 1489 | |||
| 1490 | return ( | ||
| 1491 | <Fragment key={timelineEntry.id}> | ||
| 1492 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | ||
| 1493 | <div className="mb-1.5 flex items-center justify-between gap-3"> | ||
| 1494 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | ||
| 1495 | {groupLabel} | ||
| 1496 | </p> | ||
| 1497 | {hasOverflow && ( | ||
| 1498 | <button | ||
| 1499 | type="button" | ||
| 1500 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | ||
| 1501 | onClick={() => onToggleWorkGroup(groupId)} | ||
| 1502 | > | ||
| 1503 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | ||
| 1504 | </button> | ||
| 1505 | )} | ||
| 1506 | </div> | ||
| 1507 | <div className="space-y-1"> | ||
| 1508 | {visibleEntries.map((workEntry) => ( | ||
| 1509 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> | ||
| 1510 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 1511 | <p | ||
| 1512 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | ||
| 1513 | > | ||
| 1514 | {workEntry.detail ? ( | ||
| 1515 | <> | ||
| 1516 | {workEntry.label} | ||
| 1517 | <span | ||
| 1518 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | ||
| 1519 | title={workEntry.detail} | ||
| 1520 | > | ||
| 1521 | {workEntry.detail} | ||
| 1522 | </span> | ||
| 1523 | </> | ||
| 1524 | ) : ( | ||
| 1525 | workEntry.label | ||
| 1526 | )} | ||
| 1527 | </p> | ||
| 1528 | </div> | ||
| 1529 | ))} | ||
| 1530 | </div> | ||
| 1531 | </div> | ||
| 1532 | </Fragment> | ||
| 1533 | ); | ||
| 1534 | } | ||
| 1535 | |||
| 1536 | if (timelineEntry.message.role === "user") { | ||
| 1537 | const userImages = timelineEntry.message.attachments ?? []; | ||
| 1538 | return ( | ||
| 1539 | <Fragment key={timelineEntry.id}> | ||
| 1540 | <div className="flex justify-end"> | ||
| 1541 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | ||
| 1542 | {userImages.length > 0 && ( | ||
| 1543 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | ||
| 1544 | {userImages.map((image) => ( | ||
| 1545 | <div | ||
| 1546 | key={image.id} | ||
| 1547 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | ||
| 1548 | > | ||
| 1549 | {image.previewUrl ? ( | ||
| 1550 | <img | ||
| 1551 | src={image.previewUrl} | ||
| 1552 | alt={image.name} | ||
| 1553 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | ||
| 1554 | onClick={() => | ||
| 1555 | onImageExpand({ src: image.previewUrl!, name: image.name }) | ||
| 1556 | } | ||
| 1557 | /> | ||
| 1558 | ) : ( | ||
| 1559 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | ||
| 1560 | {image.name} | ||
| 1561 | </div> | ||
| 1562 | )} | ||
| 1563 | </div> | ||
| 1564 | ))} | ||
| 1565 | </div> | ||
| 1566 | )} | ||
| 1567 | {timelineEntry.message.text && ( | ||
| 1568 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | ||
| 1569 | {timelineEntry.message.text} | ||
| 1570 | </pre> | ||
| 1571 | )} | ||
| 1572 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | ||
| 1573 | {formatTimestamp(timelineEntry.message.createdAt)} | ||
| 1574 | </p> | ||
| 1575 | </div> | ||
| 1576 | </div> | ||
| 1577 | </Fragment> | ||
| 1578 | ); | ||
| 1579 | } | ||
| 1580 | |||
| 1581 | return ( | ||
| 1582 | <Fragment key={timelineEntry.id}> | ||
| 1583 | {showCompletionDivider && ( | ||
| 1584 | <div className="my-3 flex items-center gap-3"> | ||
| 1585 | <span className="h-px flex-1 bg-border" /> | ||
| 1586 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | ||
| 1587 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | ||
| 1588 | </span> | ||
| 1589 | <span className="h-px flex-1 bg-border" /> | ||
| 1590 | </div> | ||
| 1591 | )} | ||
| 1592 | <div className="px-1 py-0.5"> | ||
| 1593 | <ChatMarkdown | ||
| 1594 | text={ | ||
| 1595 | timelineEntry.message.text || | ||
| 1596 | (timelineEntry.message.streaming ? "" : "(empty response)") | ||
| 1597 | } | ||
| 1598 | /> | ||
| 1599 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | ||
| 1600 | {formatMessageMeta( | ||
| 1601 | timelineEntry.message.createdAt, | ||
| 1602 | timelineEntry.message.streaming | ||
| 1603 | ? formatElapsed(timelineEntry.message.createdAt, nowIso) | ||
| 1604 | : formatElapsed( | ||
| 1605 | timelineEntry.message.createdAt, | ||
| 1606 | assistantCompletionByItemId.get(timelineEntry.message.id), | ||
| 1607 | ), | ||
| 1608 | )} | ||
| 1609 | </p> | ||
| 1610 | </div> | ||
| 1611 | </Fragment> | ||
| 1612 | ); | ||
| 1613 | })} | ||
| 1614 | {isWorking && ( | ||
| 1615 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | ||
| 1616 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 1617 | <div className="flex items-center pt-1"> | ||
| 1618 | <span className="inline-flex items-center gap-[3px]"> | ||
| 1619 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | ||
| 1620 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | ||
| 1621 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | ||
| 1622 | </span> | ||
| 1623 | </div> | ||
| 1624 | </div> | ||
| 1625 | )} | ||
| 1626 | <div ref={messagesEndRef} /> | ||
| 1627 | </div> | ||
| 1628 | ); | ||
| 1629 | }); | ||
| 1630 | |||
| 1631 | const ModelPicker = memo(function ModelPicker(props: { | ||
| 1632 | model: ModelSlug; | ||
| 1633 | onModelChange: (model: ModelSlug) => void; | ||
| 1634 | }) { | ||
| 1404 | return ( | 1635 | return ( |
| 1405 | <Select | 1636 | <Select |
| 1406 | items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))} | 1637 | items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))} |
| ... | @@ -1419,9 +1650,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug | ... | @@ -1419,9 +1650,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug |
| 1419 | </SelectPopup> | 1650 | </SelectPopup> |
| 1420 | </Select> | 1651 | </Select> |
| 1421 | ); | 1652 | ); |
| 1422 | } | 1653 | }); |
| 1423 | 1654 | ||
| 1424 | function ReasoningEffortPicker(props: { | 1655 | const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { |
| 1425 | effort: ReasoningEffort; | 1656 | effort: ReasoningEffort; |
| 1426 | onEffortChange: (effort: ReasoningEffort) => void; | 1657 | onEffortChange: (effort: ReasoningEffort) => void; |
| 1427 | }) { | 1658 | }) { |
| ... | @@ -1443,9 +1674,13 @@ function ReasoningEffortPicker(props: { | ... | @@ -1443,9 +1674,13 @@ function ReasoningEffortPicker(props: { |
| 1443 | </SelectPopup> | 1674 | </SelectPopup> |
| 1444 | </Select> | 1675 | </Select> |
| 1445 | ); | 1676 | ); |
| 1446 | } | 1677 | }); |
| 1447 | 1678 | ||
| 1448 | function OpenInPicker() { | 1679 | const OpenInPicker = memo(function OpenInPicker({ |
| 1680 | keybindings, | ||
| 1681 | }: { | ||
| 1682 | keybindings: ResolvedKeybindingsConfig; | ||
| 1683 | }) { | ||
| 1449 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | 1684 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 1450 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | 1685 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| 1451 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; | 1686 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; |
| ... | @@ -1458,9 +1693,9 @@ function OpenInPicker() { | ... | @@ -1458,9 +1693,9 @@ function OpenInPicker() { |
| 1458 | value: "cursor", | 1693 | value: "cursor", |
| 1459 | }, | 1694 | }, |
| 1460 | { | 1695 | { |
| 1461 | label: navigator.platform.includes("Mac") | 1696 | label: isMacPlatform(navigator.platform) |
| 1462 | ? "Finder" | 1697 | ? "Finder" |
| 1463 | : navigator.platform.includes("Win") | 1698 | : isWindowsPlatform(navigator.platform) |
| 1464 | ? "Explorer" | 1699 | ? "Explorer" |
| 1465 | : "Files", | 1700 | : "Files", |
| 1466 | Icon: FolderClosedIcon, | 1701 | Icon: FolderClosedIcon, |
| ... | @@ -1486,20 +1721,23 @@ function OpenInPicker() { | ... | @@ -1486,20 +1721,23 @@ function OpenInPicker() { |
| 1486 | [api, activeProject, activeThread, lastEditor, setLastEditor], | 1721 | [api, activeProject, activeThread, lastEditor, setLastEditor], |
| 1487 | ); | 1722 | ); |
| 1488 | 1723 | ||
| 1489 | // Cmd+O / Ctrl+O to open in last-used editor | 1724 | const openFavoriteEditorShortcutLabel = useMemo( |
| 1725 | () => shortcutLabelForCommand(keybindings, "editor.openFavorite"), | ||
| 1726 | [keybindings], | ||
| 1727 | ); | ||
| 1728 | |||
| 1490 | useEffect(() => { | 1729 | useEffect(() => { |
| 1491 | const handler = (e: globalThis.KeyboardEvent) => { | 1730 | const handler = (e: globalThis.KeyboardEvent) => { |
| 1492 | if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { | 1731 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 1493 | if (api && activeProject) { | 1732 | if (!api || !activeProject) return; |
| 1494 | e.preventDefault(); | 1733 | |
| 1495 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; | 1734 | e.preventDefault(); |
| 1496 | void api.shell.openInEditor(cwd, lastEditor); | 1735 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; |
| 1497 | } | 1736 | void api.shell.openInEditor(cwd, lastEditor); |
| 1498 | } | ||
| 1499 | }; | 1737 | }; |
| 1500 | window.addEventListener("keydown", handler); | 1738 | window.addEventListener("keydown", handler); |
| 1501 | return () => window.removeEventListener("keydown", handler); | 1739 | return () => window.removeEventListener("keydown", handler); |
| 1502 | }, [api, activeProject, activeThread, lastEditor]); | 1740 | }, [api, activeProject, activeThread, keybindings, lastEditor]); |
| 1503 | 1741 | ||
| 1504 | return ( | 1742 | return ( |
| 1505 | <Group aria-label="Subscription actions"> | 1743 | <Group aria-label="Subscription actions"> |
| ... | @@ -1517,10 +1755,8 @@ function OpenInPicker() { | ... | @@ -1517,10 +1755,8 @@ function OpenInPicker() { |
| 1517 | <MenuItem key={value} onClick={() => openInEditor(value)}> | 1755 | <MenuItem key={value} onClick={() => openInEditor(value)}> |
| 1518 | <Icon aria-hidden="true" className="text-muted-foreground" /> | 1756 | <Icon aria-hidden="true" className="text-muted-foreground" /> |
| 1519 | {label} | 1757 | {label} |
| 1520 | {value === lastEditor && ( | 1758 | {value === lastEditor && openFavoriteEditorShortcutLabel && ( |
| 1521 | <MenuShortcut> | 1759 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> |
| 1522 | {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"} | ||
| 1523 | </MenuShortcut> | ||
| 1524 | )} | 1760 | )} |
| 1525 | </MenuItem> | 1761 | </MenuItem> |
| 1526 | ))} | 1762 | ))} |
| ... | @@ -1528,4 +1764,4 @@ function OpenInPicker() { | ... | @@ -1528,4 +1764,4 @@ function OpenInPicker() { |
| 1528 | </Menu> | 1764 | </Menu> |
| 1529 | </Group> | 1765 | </Group> |
| 1530 | ); | 1766 | ); |
| 1531 | } | 1767 | }); |
apps/web/src/components/GitActionsControl.logic.test.ts+23-17| ... | @@ -20,8 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { | ... | @@ -20,8 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 20 | hasUpstream: true, | 20 | hasUpstream: true, |
| 21 | aheadCount: 0, | 21 | aheadCount: 0, |
| 22 | behindCount: 0, | 22 | behindCount: 0, |
| 23 | openPr: null, | 23 | pr: null, |
| 24 | mergedPr: null, | ||
| 25 | ...overrides, | 24 | ...overrides, |
| 26 | }; | 25 | }; |
| 27 | } | 26 | } |
| ... | @@ -30,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => { | ... | @@ -30,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => { |
| 30 | it("resolveQuickAction opens the existing PR", () => { | 29 | it("resolveQuickAction opens the existing PR", () => { |
| 31 | const quick = resolveQuickAction( | 30 | const quick = resolveQuickAction( |
| 32 | status({ | 31 | status({ |
| 33 | openPr: { | 32 | pr: { |
| 34 | number: 10, | 33 | number: 10, |
| 35 | title: "Open PR", | 34 | title: "Open PR", |
| 36 | url: "https://example.com/pr/10", | 35 | url: "https://example.com/pr/10", |
| 37 | baseBranch: "main", | 36 | baseBranch: "main", |
| 38 | headBranch: "feature/test", | 37 | headBranch: "feature/test", |
| 38 | state: "open", | ||
| 39 | }, | 39 | }, |
| 40 | }), | 40 | }), |
| 41 | false, | 41 | false, |
| ... | @@ -46,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => { | ... | @@ -46,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => { |
| 46 | it("buildMenuItems disables commit/push and enables open PR", () => { | 46 | it("buildMenuItems disables commit/push and enables open PR", () => { |
| 47 | const items = buildMenuItems( | 47 | const items = buildMenuItems( |
| 48 | status({ | 48 | status({ |
| 49 | openPr: { | 49 | pr: { |
| 50 | number: 11, | 50 | number: 11, |
| 51 | title: "Existing PR", | 51 | title: "Existing PR", |
| 52 | url: "https://example.com/pr/11", | 52 | url: "https://example.com/pr/11", |
| 53 | baseBranch: "main", | 53 | baseBranch: "main", |
| 54 | headBranch: "feature/test", | 54 | headBranch: "feature/test", |
| 55 | state: "open", | ||
| 55 | }, | 56 | }, |
| 56 | }), | 57 | }), |
| 57 | false, | 58 | false, |
| ... | @@ -148,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { | ... | @@ -148,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 148 | const quick = resolveQuickAction( | 149 | const quick = resolveQuickAction( |
| 149 | status({ | 150 | status({ |
| 150 | aheadCount: 3, | 151 | aheadCount: 3, |
| 151 | openPr: { | 152 | pr: { |
| 152 | number: 13, | 153 | number: 13, |
| 153 | title: "Open PR", | 154 | title: "Open PR", |
| 154 | url: "https://example.com/pr/13", | 155 | url: "https://example.com/pr/13", |
| 155 | baseBranch: "main", | 156 | baseBranch: "main", |
| 156 | headBranch: "feature/test", | 157 | headBranch: "feature/test", |
| 158 | state: "open", | ||
| 157 | }, | 159 | }, |
| 158 | }), | 160 | }), |
| 159 | false, | 161 | false, |
| ... | @@ -165,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { | ... | @@ -165,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 165 | const items = buildMenuItems( | 167 | const items = buildMenuItems( |
| 166 | status({ | 168 | status({ |
| 167 | aheadCount: 2, | 169 | aheadCount: 2, |
| 168 | openPr: { | 170 | pr: { |
| 169 | number: 12, | 171 | number: 12, |
| 170 | title: "Existing PR", | 172 | title: "Existing PR", |
| 171 | url: "https://example.com/pr/12", | 173 | url: "https://example.com/pr/12", |
| 172 | baseBranch: "main", | 174 | baseBranch: "main", |
| 173 | headBranch: "feature/test", | 175 | headBranch: "feature/test", |
| 176 | state: "open", | ||
| 174 | }, | 177 | }, |
| 175 | }), | 178 | }), |
| 176 | false, | 179 | false, |
| ... | @@ -205,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { | ... | @@ -205,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 205 | 208 | ||
| 206 | describe("when: branch is clean, ahead, and has no open PR", () => { | 209 | describe("when: branch is clean, ahead, and has no open PR", () => { |
| 207 | it("resolveQuickAction pushes and creates a PR", () => { | 210 | it("resolveQuickAction pushes and creates a PR", () => { |
| 208 | const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false); | 211 | const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); |
| 209 | assert.deepInclude(quick, { | 212 | assert.deepInclude(quick, { |
| 210 | kind: "run_action", | 213 | kind: "run_action", |
| 211 | action: "commit_push_pr", | 214 | action: "commit_push_pr", |
| ... | @@ -214,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { | ... | @@ -214,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { |
| 214 | }); | 217 | }); |
| 215 | 218 | ||
| 216 | it("buildMenuItems enables push and create PR, with commit disabled", () => { | 219 | it("buildMenuItems enables push and create PR, with commit disabled", () => { |
| 217 | const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false); | 220 | const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false); |
| 218 | assert.deepEqual(items, [ | 221 | assert.deepEqual(items, [ |
| 219 | { | 222 | { |
| 220 | id: "commit", | 223 | id: "commit", |
| ... | @@ -247,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => { | ... | @@ -247,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => { |
| 247 | describe("when: branch is clean, up to date, and has no open PR", () => { | 250 | describe("when: branch is clean, up to date, and has no open PR", () => { |
| 248 | it("resolveQuickAction returns disabled no-action state", () => { | 251 | it("resolveQuickAction returns disabled no-action state", () => { |
| 249 | const quick = resolveQuickAction( | 252 | const quick = resolveQuickAction( |
| 250 | status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }), | 253 | status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), |
| 251 | false, | 254 | false, |
| 252 | ); | 255 | ); |
| 253 | assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); | 256 | assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); |
| 254 | }); | 257 | }); |
| 255 | 258 | ||
| 256 | it("buildMenuItems disables commit, push, and create PR", () => { | 259 | it("buildMenuItems disables commit, push, and create PR", () => { |
| 257 | const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false); | 260 | const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false); |
| 258 | assert.deepEqual(items, [ | 261 | assert.deepEqual(items, [ |
| 259 | { | 262 | { |
| 260 | id: "commit", | 263 | id: "commit", |
| ... | @@ -291,7 +294,7 @@ describe("when: branch is behind upstream", () => { | ... | @@ -291,7 +294,7 @@ describe("when: branch is behind upstream", () => { |
| 291 | }); | 294 | }); |
| 292 | 295 | ||
| 293 | it("buildMenuItems disables push and create PR", () => { | 296 | it("buildMenuItems disables push and create PR", () => { |
| 294 | const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false); | 297 | const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); |
| 295 | assert.deepEqual(items, [ | 298 | assert.deepEqual(items, [ |
| 296 | { | 299 | { |
| 297 | id: "commit", | 300 | id: "commit", |
| ... | @@ -347,12 +350,13 @@ describe("when: working tree has local changes", () => { | ... | @@ -347,12 +350,13 @@ describe("when: working tree has local changes", () => { |
| 347 | const quick = resolveQuickAction( | 350 | const quick = resolveQuickAction( |
| 348 | status({ | 351 | status({ |
| 349 | hasWorkingTreeChanges: true, | 352 | hasWorkingTreeChanges: true, |
| 350 | openPr: { | 353 | pr: { |
| 351 | number: 16, | 354 | number: 16, |
| 352 | title: "Existing PR", | 355 | title: "Existing PR", |
| 353 | url: "https://example.com/pr/16", | 356 | url: "https://example.com/pr/16", |
| 354 | baseBranch: "main", | 357 | baseBranch: "main", |
| 355 | headBranch: "feature/test", | 358 | headBranch: "feature/test", |
| 359 | state: "open", | ||
| 356 | }, | 360 | }, |
| 357 | }), | 361 | }), |
| 358 | false, | 362 | false, |
| ... | @@ -412,7 +416,7 @@ describe("when: on default branch without open PR", () => { | ... | @@ -412,7 +416,7 @@ describe("when: on default branch without open PR", () => { |
| 412 | 416 | ||
| 413 | it("resolveQuickAction returns push when branch is ahead", () => { | 417 | it("resolveQuickAction returns push when branch is ahead", () => { |
| 414 | const quick = resolveQuickAction( | 418 | const quick = resolveQuickAction( |
| 415 | status({ branch: "main", aheadCount: 2, openPr: null }), | 419 | status({ branch: "main", aheadCount: 2, pr: null }), |
| 416 | false, | 420 | false, |
| 417 | true, | 421 | true, |
| 418 | ); | 422 | ); |
| ... | @@ -512,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => { | ... | @@ -512,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 512 | describe("when: branch has no upstream configured", () => { | 516 | describe("when: branch has no upstream configured", () => { |
| 513 | it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { | 517 | it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { |
| 514 | const quick = resolveQuickAction( | 518 | const quick = resolveQuickAction( |
| 515 | status({ hasUpstream: false, openPr: null, aheadCount: 0 }), | 519 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), |
| 516 | false, | 520 | false, |
| 517 | ); | 521 | ); |
| 518 | assert.deepInclude(quick, { | 522 | assert.deepInclude(quick, { |
| ... | @@ -527,12 +531,13 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -527,12 +531,13 @@ describe("when: branch has no upstream configured", () => { |
| 527 | status({ | 531 | status({ |
| 528 | hasUpstream: false, | 532 | hasUpstream: false, |
| 529 | aheadCount: 0, | 533 | aheadCount: 0, |
| 530 | openPr: { | 534 | pr: { |
| 531 | number: 14, | 535 | number: 14, |
| 532 | title: "Existing PR", | 536 | title: "Existing PR", |
| 533 | url: "https://example.com/pr/14", | 537 | url: "https://example.com/pr/14", |
| 534 | baseBranch: "main", | 538 | baseBranch: "main", |
| 535 | headBranch: "feature/test", | 539 | headBranch: "feature/test", |
| 540 | state: "open", | ||
| 536 | }, | 541 | }, |
| 537 | }), | 542 | }), |
| 538 | false, | 543 | false, |
| ... | @@ -549,12 +554,13 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -549,12 +554,13 @@ describe("when: branch has no upstream configured", () => { |
| 549 | status({ | 554 | status({ |
| 550 | hasUpstream: false, | 555 | hasUpstream: false, |
| 551 | aheadCount: 1, | 556 | aheadCount: 1, |
| 552 | openPr: { | 557 | pr: { |
| 553 | number: 15, | 558 | number: 15, |
| 554 | title: "Existing PR", | 559 | title: "Existing PR", |
| 555 | url: "https://example.com/pr/15", | 560 | url: "https://example.com/pr/15", |
| 556 | baseBranch: "main", | 561 | baseBranch: "main", |
| 557 | headBranch: "feature/test", | 562 | headBranch: "feature/test", |
| 563 | state: "open", | ||
| 558 | }, | 564 | }, |
| 559 | }), | 565 | }), |
| 560 | false, | 566 | false, |
| ... | @@ -569,7 +575,7 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -569,7 +575,7 @@ describe("when: branch has no upstream configured", () => { |
| 569 | 575 | ||
| 570 | it("buildMenuItems disables push and create PR when no commits are ahead", () => { | 576 | it("buildMenuItems disables push and create PR when no commits are ahead", () => { |
| 571 | const items = buildMenuItems( | 577 | const items = buildMenuItems( |
| 572 | status({ hasUpstream: false, openPr: null, aheadCount: 0 }), | 578 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), |
| 573 | false, | 579 | false, |
| 574 | ); | 580 | ); |
| 575 | assert.deepEqual(items, [ | 581 | assert.deepEqual(items, [ |
apps/web/src/components/GitActionsControl.logic.ts+2-2| ... | @@ -108,7 +108,7 @@ export function buildMenuItems( | ... | @@ -108,7 +108,7 @@ export function buildMenuItems( |
| 108 | 108 | ||
| 109 | const hasBranch = gitStatus.branch !== null; | 109 | const hasBranch = gitStatus.branch !== null; |
| 110 | const hasChanges = gitStatus.hasWorkingTreeChanges; | 110 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 111 | const hasOpenPr = gitStatus.openPr !== null; | 111 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 112 | const isBehind = gitStatus.behindCount > 0; | 112 | const isBehind = gitStatus.behindCount > 0; |
| 113 | const canCommit = !isBusy && hasChanges; | 113 | const canCommit = !isBusy && hasChanges; |
| 114 | const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; | 114 | const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; |
| ... | @@ -178,7 +178,7 @@ export function resolveQuickAction( | ... | @@ -178,7 +178,7 @@ export function resolveQuickAction( |
| 178 | 178 | ||
| 179 | const hasBranch = gitStatus.branch !== null; | 179 | const hasBranch = gitStatus.branch !== null; |
| 180 | const hasChanges = gitStatus.hasWorkingTreeChanges; | 180 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 181 | const hasOpenPr = gitStatus.openPr !== null; | 181 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 182 | const isAhead = gitStatus.aheadCount > 0; | 182 | const isAhead = gitStatus.aheadCount > 0; |
| 183 | const isBehind = gitStatus.behindCount > 0; | 183 | const isBehind = gitStatus.behindCount > 0; |
| 184 | const isDiverged = isAhead && isBehind; | 184 | const isDiverged = isAhead && isBehind; |
apps/web/src/components/GitActionsControl.tsx+11-6| ... | @@ -57,7 +57,7 @@ function getMenuActionDisabledReason( | ... | @@ -57,7 +57,7 @@ function getMenuActionDisabledReason( |
| 57 | 57 | ||
| 58 | const hasBranch = gitStatus.branch !== null; | 58 | const hasBranch = gitStatus.branch !== null; |
| 59 | const hasChanges = gitStatus.hasWorkingTreeChanges; | 59 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 60 | const hasOpenPr = gitStatus.openPr !== null; | 60 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 61 | const isAhead = gitStatus.aheadCount > 0; | 61 | const isAhead = gitStatus.aheadCount > 0; |
| 62 | const isBehind = gitStatus.behindCount > 0; | 62 | const isBehind = gitStatus.behindCount > 0; |
| 63 | 63 | ||
| ... | @@ -217,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -217,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 217 | }); | 217 | }); |
| 218 | return; | 218 | return; |
| 219 | } | 219 | } |
| 220 | const prUrl = gitStatusForActions?.openPr?.url ?? null; | 220 | const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; |
| 221 | if (!prUrl) { | 221 | if (!prUrl) { |
| 222 | toastManager.add({ | 222 | toastManager.add({ |
| 223 | type: "error", | 223 | type: "error", |
| ... | @@ -237,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -237,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 237 | }), | 237 | }), |
| 238 | }); | 238 | }); |
| 239 | void promise.catch(() => undefined); | 239 | void promise.catch(() => undefined); |
| 240 | }, [api, gitStatusForActions?.openPr?.url, threadToastData]); | 240 | }, [api, gitStatusForActions?.pr, threadToastData]); |
| 241 | 241 | ||
| 242 | const runGitActionWithToast = useCallback( | 242 | const runGitActionWithToast = useCallback( |
| 243 | async ({ | 243 | async ({ |
| ... | @@ -297,7 +297,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -297,7 +297,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 297 | stopProgressUpdates(); | 297 | stopProgressUpdates(); |
| 298 | const resultToast = summarizeGitResult(result); | 298 | const resultToast = summarizeGitResult(result); |
| 299 | 299 | ||
| 300 | const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url; | 300 | const existingOpenPrUrl = |
| 301 | gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined; | ||
| 302 | const prUrl = result.pr.url ?? existingOpenPrUrl; | ||
| 301 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; | 303 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 302 | const shouldOfferOpenPrCta = | 304 | const shouldOfferOpenPrCta = |
| 303 | (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; | 305 | (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; |
| ... | @@ -312,7 +314,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -312,7 +314,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 312 | title: resultToast.title, | 314 | title: resultToast.title, |
| 313 | description: resultToast.description, | 315 | description: resultToast.description, |
| 314 | timeout: 0, | 316 | timeout: 0, |
| 315 | data: threadToastData, | 317 | data: { |
| 318 | ...threadToastData, | ||
| 319 | dismissAfterVisibleMs: 10_000, | ||
| 320 | }, | ||
| 316 | ...(shouldOfferPushCta | 321 | ...(shouldOfferPushCta |
| 317 | ? { | 322 | ? { |
| 318 | actionProps: { | 323 | actionProps: { |
| ... | @@ -362,7 +367,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp | ... | @@ -362,7 +367,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp |
| 362 | api, | 367 | api, |
| 363 | gitStatusForActions?.branch, | 368 | gitStatusForActions?.branch, |
| 364 | gitStatusForActions?.hasWorkingTreeChanges, | 369 | gitStatusForActions?.hasWorkingTreeChanges, |
| 365 | gitStatusForActions?.openPr?.url, | 370 | gitStatusForActions?.pr, |
| 366 | isDefaultBranch, | 371 | isDefaultBranch, |
| 367 | maybeConfirmPushToDefaultBranch, | 372 | maybeConfirmPushToDefaultBranch, |
| 368 | runImmediateGitActionMutation, | 373 | runImmediateGitActionMutation, |
apps/web/src/components/Sidebar.tsx+140-32| ... | @@ -1,12 +1,20 @@ | ... | @@ -1,12 +1,20 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; | 1 | import { |
| 2 | GitPullRequestIcon, | ||
| 3 | MonitorIcon, | ||
| 4 | MoonIcon, | ||
| 5 | SunIcon, | ||
| 6 | TerminalIcon, | ||
| 7 | } from "lucide-react"; | ||
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 8 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; | 9 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 10 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| 4 | import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts"; | 11 | import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts"; |
| 5 | import { isElectron } from "../env"; | 12 | import { isElectron } from "../env"; |
| 6 | import { useTheme } from "../hooks/useTheme"; | 13 | import { useTheme } from "../hooks/useTheme"; |
| 7 | import { DEFAULT_MODEL } from "../model-logic"; | 14 | import { DEFAULT_MODEL } from "../model-logic"; |
| 8 | import { derivePendingApprovals } from "../session-logic"; | 15 | import { derivePendingApprovals } from "../session-logic"; |
| 9 | import { useStore } from "../store"; | 16 | import { useStore } from "../store"; |
| 17 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; | ||
| 10 | import { | 18 | import { |
| 11 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 19 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 12 | DEFAULT_THREAD_TERMINAL_ID, | 20 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | @@ -15,9 +23,13 @@ import { | ... | @@ -15,9 +23,13 @@ import { |
| 15 | } from "../types"; | 23 | } from "../types"; |
| 16 | import { useNativeApi } from "../hooks/useNativeApi"; | 24 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 17 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | 25 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 26 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | ||
| 27 | import { toastManager } from "./ui/toast"; | ||
| 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 28 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 19 | 29 | ||
| 20 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | 30 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| 31 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | ||
| 32 | |||
| 21 | function formatRelativeTime(iso: string): string { | 33 | function formatRelativeTime(iso: string): string { |
| 22 | const diff = Date.now() - new Date(iso).getTime(); | 34 | const diff = Date.now() - new Date(iso).getTime(); |
| 23 | const minutes = Math.floor(diff / 60_000); | 35 | const minutes = Math.floor(diff / 60_000); |
| ... | @@ -36,7 +48,7 @@ function inferProjectName(cwd: string): string { | ... | @@ -36,7 +48,7 @@ function inferProjectName(cwd: string): string { |
| 36 | } | 48 | } |
| 37 | 49 | ||
| 38 | interface ThreadStatusPill { | 50 | interface ThreadStatusPill { |
| 39 | label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged"; | 51 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; |
| 40 | colorClass: string; | 52 | colorClass: string; |
| 41 | dotClass: string; | 53 | dotClass: string; |
| 42 | pulse: boolean; | 54 | pulse: boolean; |
| ... | @@ -48,6 +60,13 @@ interface TerminalStatusIndicator { | ... | @@ -48,6 +60,13 @@ interface TerminalStatusIndicator { |
| 48 | pulse: boolean; | 60 | pulse: boolean; |
| 49 | } | 61 | } |
| 50 | 62 | ||
| 63 | interface PrStatusIndicator { | ||
| 64 | label: "PR open" | "PR closed" | "PR merged"; | ||
| 65 | colorClass: string; | ||
| 66 | } | ||
| 67 | |||
| 68 | type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null; | ||
| 69 | |||
| 51 | function hasUnseenCompletion(thread: Thread): boolean { | 70 | function hasUnseenCompletion(thread: Thread): boolean { |
| 52 | if (!thread.latestTurnCompletedAt) return false; | 71 | if (!thread.latestTurnCompletedAt) return false; |
| 53 | const completedAt = Date.parse(thread.latestTurnCompletedAt); | 72 | const completedAt = Date.parse(thread.latestTurnCompletedAt); |
| ... | @@ -62,7 +81,6 @@ function hasUnseenCompletion(thread: Thread): boolean { | ... | @@ -62,7 +81,6 @@ function hasUnseenCompletion(thread: Thread): boolean { |
| 62 | function threadStatusPill( | 81 | function threadStatusPill( |
| 63 | thread: Thread, | 82 | thread: Thread, |
| 64 | hasPendingApprovals: boolean, | 83 | hasPendingApprovals: boolean, |
| 65 | hasMergedPr: boolean, | ||
| 66 | ): ThreadStatusPill | null { | 84 | ): ThreadStatusPill | null { |
| 67 | if (hasPendingApprovals) { | 85 | if (hasPendingApprovals) { |
| 68 | return { | 86 | return { |
| ... | @@ -91,15 +109,6 @@ function threadStatusPill( | ... | @@ -91,15 +109,6 @@ function threadStatusPill( |
| 91 | }; | 109 | }; |
| 92 | } | 110 | } |
| 93 | 111 | ||
| 94 | if (hasMergedPr) { | ||
| 95 | return { | ||
| 96 | label: "Merged", | ||
| 97 | colorClass: "text-violet-600 dark:text-violet-300/90", | ||
| 98 | dotClass: "bg-violet-500 dark:bg-violet-300/90", | ||
| 99 | pulse: false, | ||
| 100 | }; | ||
| 101 | } | ||
| 102 | |||
| 103 | if (hasUnseenCompletion(thread)) { | 112 | if (hasUnseenCompletion(thread)) { |
| 104 | return { | 113 | return { |
| 105 | label: "Completed", | 114 | label: "Completed", |
| ... | @@ -112,6 +121,28 @@ function threadStatusPill( | ... | @@ -112,6 +121,28 @@ function threadStatusPill( |
| 112 | return null; | 121 | return null; |
| 113 | } | 122 | } |
| 114 | 123 | ||
| 124 | function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null { | ||
| 125 | if (prState === "open") { | ||
| 126 | return { | ||
| 127 | label: "PR open", | ||
| 128 | colorClass: "text-emerald-600 dark:text-emerald-300/90", | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | if (prState === "closed") { | ||
| 132 | return { | ||
| 133 | label: "PR closed", | ||
| 134 | colorClass: "text-zinc-500 dark:text-zinc-400/80", | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | if (prState === "merged") { | ||
| 138 | return { | ||
| 139 | label: "PR merged", | ||
| 140 | colorClass: "text-violet-600 dark:text-violet-300/90", | ||
| 141 | }; | ||
| 142 | } | ||
| 143 | return null; | ||
| 144 | } | ||
| 145 | |||
| 115 | function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { | 146 | function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null { |
| 116 | if (thread.runningTerminalIds.length === 0) { | 147 | if (thread.runningTerminalIds.length === 0) { |
| 117 | return null; | 148 | return null; |
| ... | @@ -126,8 +157,14 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null | ... | @@ -126,8 +157,14 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null |
| 126 | export default function Sidebar() { | 157 | export default function Sidebar() { |
| 127 | const { state, dispatch } = useStore(); | 158 | const { state, dispatch } = useStore(); |
| 128 | const api = useNativeApi(); | 159 | const api = useNativeApi(); |
| 160 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ | ||
| 161 | ...serverConfigQueryOptions(api), | ||
| 162 | select: (config) => config.keybindings, | ||
| 163 | }); | ||
| 129 | const queryClient = useQueryClient(); | 164 | const queryClient = useQueryClient(); |
| 130 | const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient })); | 165 | const removeWorktreeMutation = useMutation( |
| 166 | gitRemoveWorktreeMutationOptions({ api, queryClient }), | ||
| 167 | ); | ||
| 131 | const { theme, setTheme } = useTheme(); | 168 | const { theme, setTheme } = useTheme(); |
| 132 | const [addingProject, setAddingProject] = useState(false); | 169 | const [addingProject, setAddingProject] = useState(false); |
| 133 | const [newCwd, setNewCwd] = useState(""); | 170 | const [newCwd, setNewCwd] = useState(""); |
| ... | @@ -176,7 +213,7 @@ export default function Sidebar() { | ... | @@ -176,7 +213,7 @@ export default function Sidebar() { |
| 176 | }; | 213 | }; |
| 177 | }), | 214 | }), |
| 178 | }); | 215 | }); |
| 179 | const mergedPrByThreadId = useMemo(() => { | 216 | const prStateByThreadId = useMemo(() => { |
| 180 | const statusByCwd = new Map<string, GitStatusResult>(); | 217 | const statusByCwd = new Map<string, GitStatusResult>(); |
| 181 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { | 218 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { |
| 182 | const cwd = threadGitStatusCwds[index]; | 219 | const cwd = threadGitStatusCwds[index]; |
| ... | @@ -187,18 +224,24 @@ export default function Sidebar() { | ... | @@ -187,18 +224,24 @@ export default function Sidebar() { |
| 187 | } | 224 | } |
| 188 | } | 225 | } |
| 189 | 226 | ||
| 190 | const map = new Map<string, boolean>(); | 227 | const map = new Map<string, ThreadPrState>(); |
| 191 | for (const target of threadGitTargets) { | 228 | for (const target of threadGitTargets) { |
| 192 | const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; | 229 | const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; |
| 193 | const branchMatches = | 230 | const branchMatches = |
| 194 | target.branch !== null && status?.branch !== null && status?.branch === target.branch; | 231 | target.branch !== null && status?.branch !== null && status?.branch === target.branch; |
| 195 | map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null); | 232 | map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null); |
| 196 | } | 233 | } |
| 197 | return map; | 234 | return map; |
| 198 | }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); | 235 | }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); |
| 199 | 236 | ||
| 200 | const handleNewThread = useCallback( | 237 | const handleNewThread = useCallback( |
| 201 | (projectId: string) => { | 238 | ( |
| 239 | projectId: string, | ||
| 240 | options?: { | ||
| 241 | branch?: string | null; | ||
| 242 | worktreePath?: string | null; | ||
| 243 | }, | ||
| 244 | ) => { | ||
| 202 | dispatch({ | 245 | dispatch({ |
| 203 | type: "ADD_THREAD", | 246 | type: "ADD_THREAD", |
| 204 | thread: { | 247 | thread: { |
| ... | @@ -224,8 +267,8 @@ export default function Sidebar() { | ... | @@ -224,8 +267,8 @@ export default function Sidebar() { |
| 224 | events: [], | 267 | events: [], |
| 225 | error: null, | 268 | error: null, |
| 226 | createdAt: new Date().toISOString(), | 269 | createdAt: new Date().toISOString(), |
| 227 | branch: null, | 270 | branch: options?.branch ?? null, |
| 228 | worktreePath: null, | 271 | worktreePath: options?.worktreePath ?? null, |
| 229 | }, | 272 | }, |
| 230 | }); | 273 | }); |
| 231 | }, | 274 | }, |
| ... | @@ -392,29 +435,77 @@ export default function Sidebar() { | ... | @@ -392,29 +435,77 @@ export default function Sidebar() { |
| 392 | [api, dispatch, removeWorktreeMutation, state.projects, state.threads], | 435 | [api, dispatch, removeWorktreeMutation, state.projects, state.threads], |
| 393 | ); | 436 | ); |
| 394 | 437 | ||
| 438 | const handleProjectContextMenu = useCallback( | ||
| 439 | async (projectId: string, position: { x: number; y: number }) => { | ||
| 440 | if (!api) return; | ||
| 441 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | ||
| 442 | if (clicked !== "delete") return; | ||
| 443 | |||
| 444 | const project = state.projects.find((entry) => entry.id === projectId); | ||
| 445 | if (!project) return; | ||
| 446 | |||
| 447 | const projectThreads = state.threads.filter((thread) => thread.projectId === projectId); | ||
| 448 | if (projectThreads.length > 0) { | ||
| 449 | toastManager.add({ | ||
| 450 | type: "warning", | ||
| 451 | title: "Project is not empty", | ||
| 452 | description: "Delete all threads in this project before deleting it.", | ||
| 453 | }); | ||
| 454 | return; | ||
| 455 | } | ||
| 456 | |||
| 457 | const confirmed = await api.dialogs.confirm( | ||
| 458 | [`Delete project "${project.name}"?`, "This action cannot be undone."].join("\n"), | ||
| 459 | ); | ||
| 460 | if (!confirmed) return; | ||
| 461 | |||
| 462 | if (isElectron) { | ||
| 463 | try { | ||
| 464 | await api.projects.remove({ id: projectId }); | ||
| 465 | } catch (error) { | ||
| 466 | const message = | ||
| 467 | error instanceof Error ? error.message : "Unknown error deleting project."; | ||
| 468 | console.error("Failed to remove project", { projectId, error }); | ||
| 469 | toastManager.add({ | ||
| 470 | type: "error", | ||
| 471 | title: `Failed to delete "${project.name}"`, | ||
| 472 | description: message, | ||
| 473 | }); | ||
| 474 | return; | ||
| 475 | } | ||
| 476 | } | ||
| 477 | |||
| 478 | dispatch({ type: "DELETE_PROJECT", projectId }); | ||
| 479 | }, | ||
| 480 | [api, dispatch, state.projects, state.threads], | ||
| 481 | ); | ||
| 482 | |||
| 395 | useEffect(() => { | 483 | useEffect(() => { |
| 396 | const onWindowKeyDown = (event: KeyboardEvent) => { | 484 | const onWindowKeyDown = (event: KeyboardEvent) => { |
| 397 | const isNewThreadShortcut = | ||
| 398 | event.metaKey && | ||
| 399 | event.shiftKey && | ||
| 400 | !event.ctrlKey && | ||
| 401 | !event.altKey && | ||
| 402 | event.key.toLowerCase() === "o"; | ||
| 403 | if (!isNewThreadShortcut) return; | ||
| 404 | |||
| 405 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 485 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 486 | if (isChatNewLocalShortcut(event, keybindings)) { | ||
| 487 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | ||
| 488 | if (!projectId) return; | ||
| 489 | event.preventDefault(); | ||
| 490 | handleNewThread(projectId); | ||
| 491 | return; | ||
| 492 | } | ||
| 493 | |||
| 494 | if (!isChatNewShortcut(event, keybindings)) return; | ||
| 406 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | 495 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; |
| 407 | if (!projectId) return; | 496 | if (!projectId) return; |
| 408 | |||
| 409 | event.preventDefault(); | 497 | event.preventDefault(); |
| 410 | handleNewThread(projectId); | 498 | handleNewThread(projectId, { |
| 499 | branch: activeThread?.branch ?? null, | ||
| 500 | worktreePath: activeThread?.worktreePath ?? null, | ||
| 501 | }); | ||
| 411 | }; | 502 | }; |
| 412 | 503 | ||
| 413 | window.addEventListener("keydown", onWindowKeyDown); | 504 | window.addEventListener("keydown", onWindowKeyDown); |
| 414 | return () => { | 505 | return () => { |
| 415 | window.removeEventListener("keydown", onWindowKeyDown); | 506 | window.removeEventListener("keydown", onWindowKeyDown); |
| 416 | }; | 507 | }; |
| 417 | }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); | 508 | }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); |
| 418 | 509 | ||
| 419 | return ( | 510 | return ( |
| 420 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> | 511 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> |
| ... | @@ -478,6 +569,13 @@ export default function Sidebar() { | ... | @@ -478,6 +569,13 @@ export default function Sidebar() { |
| 478 | type="button" | 569 | type="button" |
| 479 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" | 570 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" |
| 480 | onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} | 571 | onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} |
| 572 | onContextMenu={(e) => { | ||
| 573 | e.preventDefault(); | ||
| 574 | void handleProjectContextMenu(project.id, { | ||
| 575 | x: e.clientX, | ||
| 576 | y: e.clientY, | ||
| 577 | }); | ||
| 578 | }} | ||
| 481 | > | 579 | > |
| 482 | <span className="text-[10px] text-muted-foreground/70"> | 580 | <span className="text-[10px] text-muted-foreground/70"> |
| 483 | {project.expanded ? "â–¼" : "â–¶"} | 581 | {project.expanded ? "â–¼" : "â–¶"} |
| ... | @@ -496,9 +594,9 @@ export default function Sidebar() { | ... | @@ -496,9 +594,9 @@ export default function Sidebar() { |
| 496 | const threadStatus = threadStatusPill( | 594 | const threadStatus = threadStatusPill( |
| 497 | thread, | 595 | thread, |
| 498 | pendingApprovalByThreadId.get(thread.id) === true, | 596 | pendingApprovalByThreadId.get(thread.id) === true, |
| 499 | mergedPrByThreadId.get(thread.id) === true, | ||
| 500 | ); | 597 | ); |
| 501 | const terminalStatus = terminalStatusIndicator(thread); | 598 | const terminalStatus = terminalStatusIndicator(thread); |
| 599 | const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null); | ||
| 502 | return ( | 600 | return ( |
| 503 | <button | 601 | <button |
| 504 | key={thread.id} | 602 | key={thread.id} |
| ... | @@ -538,6 +636,16 @@ export default function Sidebar() { | ... | @@ -538,6 +636,16 @@ export default function Sidebar() { |
| 538 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | 636 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> |
| 539 | </div> | 637 | </div> |
| 540 | <div className="ml-2 flex shrink-0 items-center gap-1.5"> | 638 | <div className="ml-2 flex shrink-0 items-center gap-1.5"> |
| 639 | {prStatus && ( | ||
| 640 | <span | ||
| 641 | role="img" | ||
| 642 | aria-label={prStatus.label} | ||
| 643 | title={prStatus.label} | ||
| 644 | className={`inline-flex items-center justify-center ${prStatus.colorClass}`} | ||
| 645 | > | ||
| 646 | <GitPullRequestIcon className="size-3" /> | ||
| 647 | </span> | ||
| 648 | )} | ||
| 541 | {terminalStatus && ( | 649 | {terminalStatus && ( |
| 542 | <span | 650 | <span |
| 543 | role="img" | 651 | role="img" |
apps/web/src/components/ThreadTerminalDrawer.tsx+65-36| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { FitAddon } from "@xterm/addon-fit"; | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; | 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; |
| 3 | import { type NativeApi } from "@t3tools/contracts"; | 3 | import { type NativeApi } from "@t3tools/contracts"; |
| 4 | import { Terminal, type ITheme } from "@xterm/xterm"; | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 5 | import { | 5 | import { |
| ... | @@ -18,7 +18,7 @@ import { | ... | @@ -18,7 +18,7 @@ import { |
| 18 | preferredTerminalEditor, | 18 | preferredTerminalEditor, |
| 19 | resolvePathLinkTarget, | 19 | resolvePathLinkTarget, |
| 20 | } from "../terminal-links"; | 20 | } from "../terminal-links"; |
| 21 | import { isTerminalClearShortcut } from "../terminal-shortcuts"; | 21 | import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; |
| 22 | import { | 22 | import { |
| 23 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 23 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 24 | DEFAULT_THREAD_TERMINAL_ID, | 24 | DEFAULT_THREAD_TERMINAL_ID, |
| ... | @@ -152,24 +152,29 @@ function TerminalViewport({ | ... | @@ -152,24 +152,29 @@ function TerminalViewport({ |
| 152 | terminalRef.current = terminal; | 152 | terminalRef.current = terminal; |
| 153 | fitAddonRef.current = fitAddon; | 153 | fitAddonRef.current = fitAddon; |
| 154 | 154 | ||
| 155 | const sendClearShortcut = async () => { | 155 | const sendTerminalInput = async (data: string, fallbackError: string) => { |
| 156 | const activeTerminal = terminalRef.current; | 156 | const activeTerminal = terminalRef.current; |
| 157 | if (!activeTerminal) return; | 157 | if (!activeTerminal) return; |
| 158 | try { | 158 | try { |
| 159 | await api.terminal.write({ threadId, terminalId, data: "\u000c" }); | 159 | await api.terminal.write({ threadId, terminalId, data }); |
| 160 | } catch (error) { | 160 | } catch (error) { |
| 161 | writeSystemMessage( | 161 | writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); |
| 162 | activeTerminal, | ||
| 163 | error instanceof Error ? error.message : "Failed to clear terminal", | ||
| 164 | ); | ||
| 165 | } | 162 | } |
| 166 | }; | 163 | }; |
| 167 | 164 | ||
| 168 | terminal.attachCustomKeyEventHandler((event) => { | 165 | terminal.attachCustomKeyEventHandler((event) => { |
| 166 | const navigationData = terminalNavigationShortcutData(event); | ||
| 167 | if (navigationData !== null) { | ||
| 168 | event.preventDefault(); | ||
| 169 | event.stopPropagation(); | ||
| 170 | void sendTerminalInput(navigationData, "Failed to move cursor"); | ||
| 171 | return false; | ||
| 172 | } | ||
| 173 | |||
| 169 | if (!isTerminalClearShortcut(event)) return true; | 174 | if (!isTerminalClearShortcut(event)) return true; |
| 170 | event.preventDefault(); | 175 | event.preventDefault(); |
| 171 | event.stopPropagation(); | 176 | event.stopPropagation(); |
| 172 | void sendClearShortcut(); | 177 | void sendTerminalInput("\u000c", "Failed to clear terminal"); |
| 173 | return false; | 178 | return false; |
| 174 | }); | 179 | }); |
| 175 | 180 | ||
| ... | @@ -416,6 +421,9 @@ interface ThreadTerminalDrawerProps { | ... | @@ -416,6 +421,9 @@ interface ThreadTerminalDrawerProps { |
| 416 | focusRequestId: number; | 421 | focusRequestId: number; |
| 417 | onSplitTerminal: () => void; | 422 | onSplitTerminal: () => void; |
| 418 | onNewTerminal: () => void; | 423 | onNewTerminal: () => void; |
| 424 | splitShortcutLabel?: string | undefined; | ||
| 425 | newShortcutLabel?: string | undefined; | ||
| 426 | closeShortcutLabel?: string | undefined; | ||
| 419 | onActiveTerminalChange: (terminalId: string) => void; | 427 | onActiveTerminalChange: (terminalId: string) => void; |
| 420 | onCloseTerminal: (terminalId: string) => void; | 428 | onCloseTerminal: (terminalId: string) => void; |
| 421 | onHeightChange: (height: number) => void; | 429 | onHeightChange: (height: number) => void; |
| ... | @@ -431,15 +439,9 @@ interface TerminalActionButtonProps { | ... | @@ -431,15 +439,9 @@ interface TerminalActionButtonProps { |
| 431 | function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { | 439 | function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { |
| 432 | return ( | 440 | return ( |
| 433 | <Popover> | 441 | <Popover> |
| 434 | <PopoverTrigger openOnHover | 442 | <PopoverTrigger |
| 435 | render={ | 443 | openOnHover |
| 436 | <button | 444 | render={<button type="button" className={className} onClick={onClick} aria-label={label} />} |
| 437 | type="button" | ||
| 438 | className={className} | ||
| 439 | onClick={onClick} | ||
| 440 | aria-label={label} | ||
| 441 | /> | ||
| 442 | } | ||
| 443 | > | 445 | > |
| 444 | {children} | 446 | {children} |
| 445 | </PopoverTrigger> | 447 | </PopoverTrigger> |
| ... | @@ -468,6 +470,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -468,6 +470,9 @@ export default function ThreadTerminalDrawer({ |
| 468 | focusRequestId, | 470 | focusRequestId, |
| 469 | onSplitTerminal, | 471 | onSplitTerminal, |
| 470 | onNewTerminal, | 472 | onNewTerminal, |
| 473 | splitShortcutLabel, | ||
| 474 | newShortcutLabel, | ||
| 475 | closeShortcutLabel, | ||
| 471 | onActiveTerminalChange, | 476 | onActiveTerminalChange, |
| 472 | onCloseTerminal, | 477 | onCloseTerminal, |
| 473 | onHeightChange, | 478 | onHeightChange, |
| ... | @@ -583,6 +588,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -583,6 +588,9 @@ export default function ThreadTerminalDrawer({ |
| 583 | ), | 588 | ), |
| 584 | [normalizedTerminalIds], | 589 | [normalizedTerminalIds], |
| 585 | ); | 590 | ); |
| 591 | const closeTerminalActionLabel = closeShortcutLabel | ||
| 592 | ? `Close Terminal (${closeShortcutLabel})` | ||
| 593 | : "Close Terminal"; | ||
| 586 | 594 | ||
| 587 | useEffect(() => { | 595 | useEffect(() => { |
| 588 | onHeightChangeRef.current = onHeightChange; | 596 | onHeightChangeRef.current = onHeightChange; |
| ... | @@ -694,7 +702,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -694,7 +702,9 @@ export default function ThreadTerminalDrawer({ |
| 694 | <TerminalActionButton | 702 | <TerminalActionButton |
| 695 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | 703 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 696 | onClick={onSplitTerminal} | 704 | onClick={onSplitTerminal} |
| 697 | label="Split Terminal" | 705 | label={ |
| 706 | splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal" | ||
| 707 | } | ||
| 698 | > | 708 | > |
| 699 | <SquareSplitHorizontal className="size-3.25" /> | 709 | <SquareSplitHorizontal className="size-3.25" /> |
| 700 | </TerminalActionButton> | 710 | </TerminalActionButton> |
| ... | @@ -702,7 +712,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -702,7 +712,7 @@ export default function ThreadTerminalDrawer({ |
| 702 | <TerminalActionButton | 712 | <TerminalActionButton |
| 703 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | 713 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 704 | onClick={onNewTerminal} | 714 | onClick={onNewTerminal} |
| 705 | label="New Terminal" | 715 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} |
| 706 | > | 716 | > |
| 707 | <Plus className="size-3.25" /> | 717 | <Plus className="size-3.25" /> |
| 708 | </TerminalActionButton> | 718 | </TerminalActionButton> |
| ... | @@ -710,7 +720,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -710,7 +720,7 @@ export default function ThreadTerminalDrawer({ |
| 710 | <TerminalActionButton | 720 | <TerminalActionButton |
| 711 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" | 721 | className="p-1 text-foreground/90 transition-colors hover:bg-accent" |
| 712 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | 722 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 713 | label="Close Terminal" | 723 | label={closeTerminalActionLabel} |
| 714 | > | 724 | > |
| 715 | <Trash2 className="size-3.25" /> | 725 | <Trash2 className="size-3.25" /> |
| 716 | </TerminalActionButton> | 726 | </TerminalActionButton> |
| ... | @@ -779,21 +789,25 @@ export default function ThreadTerminalDrawer({ | ... | @@ -779,21 +789,25 @@ export default function ThreadTerminalDrawer({ |
| 779 | <TerminalActionButton | 789 | <TerminalActionButton |
| 780 | className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" | 790 | className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 781 | onClick={onSplitTerminal} | 791 | onClick={onSplitTerminal} |
| 782 | label="Split Terminal" | 792 | label={ |
| 793 | splitShortcutLabel | ||
| 794 | ? `Split Terminal (${splitShortcutLabel})` | ||
| 795 | : "Split Terminal" | ||
| 796 | } | ||
| 783 | > | 797 | > |
| 784 | <SquareSplitHorizontal className="size-3.25" /> | 798 | <SquareSplitHorizontal className="size-3.25" /> |
| 785 | </TerminalActionButton> | 799 | </TerminalActionButton> |
| 786 | <TerminalActionButton | 800 | <TerminalActionButton |
| 787 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | 801 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 788 | onClick={onNewTerminal} | 802 | onClick={onNewTerminal} |
| 789 | label="New Terminal" | 803 | label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"} |
| 790 | > | 804 | > |
| 791 | <Plus className="size-3.25" /> | 805 | <Plus className="size-3.25" /> |
| 792 | </TerminalActionButton> | 806 | </TerminalActionButton> |
| 793 | <TerminalActionButton | 807 | <TerminalActionButton |
| 794 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" | 808 | className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70" |
| 795 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} | 809 | onClick={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 796 | label="Close Terminal" | 810 | label={closeTerminalActionLabel} |
| 797 | > | 811 | > |
| 798 | <Trash2 className="size-3.25" /> | 812 | <Trash2 className="size-3.25" /> |
| 799 | </TerminalActionButton> | 813 | </TerminalActionButton> |
| ... | @@ -831,6 +845,9 @@ export default function ThreadTerminalDrawer({ | ... | @@ -831,6 +845,9 @@ export default function ThreadTerminalDrawer({ |
| 831 | > | 845 | > |
| 832 | {terminalGroup.terminalIds.map((terminalId) => { | 846 | {terminalGroup.terminalIds.map((terminalId) => { |
| 833 | const isActive = terminalId === resolvedActiveTerminalId; | 847 | const isActive = terminalId === resolvedActiveTerminalId; |
| 848 | const closeTerminalLabel = `Close ${ | ||
| 849 | terminalLabelById.get(terminalId) ?? "terminal" | ||
| 850 | }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`; | ||
| 834 | return ( | 851 | return ( |
| 835 | <div | 852 | <div |
| 836 | key={terminalId} | 853 | key={terminalId} |
| ... | @@ -854,18 +871,30 @@ export default function ThreadTerminalDrawer({ | ... | @@ -854,18 +871,30 @@ export default function ThreadTerminalDrawer({ |
| 854 | </span> | 871 | </span> |
| 855 | </button> | 872 | </button> |
| 856 | {normalizedTerminalIds.length > 1 && ( | 873 | {normalizedTerminalIds.length > 1 && ( |
| 857 | <button | 874 | <Popover> |
| 858 | type="button" | 875 | <PopoverTrigger |
| 859 | className="rounded px-1 text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" | 876 | openOnHover |
| 860 | onClick={(event) => { | 877 | render={ |
| 861 | event.stopPropagation(); | 878 | <button |
| 862 | onCloseTerminal(terminalId); | 879 | type="button" |
| 863 | }} | 880 | className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100" |
| 864 | aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | 881 | onClick={() => onCloseTerminal(terminalId)} |
| 865 | title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`} | 882 | aria-label={closeTerminalLabel} |
| 866 | > | 883 | /> |
| 867 | × | 884 | } |
| 868 | </button> | 885 | > |
| 886 | <XIcon className="size-2.5" /> | ||
| 887 | </PopoverTrigger> | ||
| 888 | <PopoverPopup | ||
| 889 | tooltipStyle | ||
| 890 | side="bottom" | ||
| 891 | sideOffset={6} | ||
| 892 | align="center" | ||
| 893 | className="pointer-events-none select-none" | ||
| 894 | > | ||
| 895 | {closeTerminalLabel} | ||
| 896 | </PopoverPopup> | ||
| 897 | </Popover> | ||
| 869 | )} | 898 | )} |
| 870 | </div> | 899 | </div> |
| 871 | ); | 900 | ); |
apps/web/src/components/ui/toast.logic.test.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { assert, describe, it } from "vitest"; | ||
| 2 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | ||
| 3 | |||
| 4 | describe("shouldHideCollapsedToastContent", () => { | ||
| 5 | it("keeps a single visible toast readable", () => { | ||
| 6 | assert.equal(shouldHideCollapsedToastContent(0, 1), false); | ||
| 7 | }); | ||
| 8 | |||
| 9 | it("keeps the front-most toast readable in a visible stack", () => { | ||
| 10 | assert.equal(shouldHideCollapsedToastContent(0, 3), false); | ||
| 11 | }); | ||
| 12 | |||
| 13 | it("hides non-front toasts until the stack is expanded", () => { | ||
| 14 | assert.equal(shouldHideCollapsedToastContent(1, 3), true); | ||
| 15 | }); | ||
| 16 | }); | ||
apps/web/src/components/ui/toast.logic.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | export function shouldHideCollapsedToastContent( | ||
| 2 | visibleToastIndex: number, | ||
| 3 | visibleToastCount: number, | ||
| 4 | ): boolean { | ||
| 5 | // Keep the front-most toast readable even if Base UI marks it as "behind" | ||
| 6 | // due to toasts hidden by thread filtering. | ||
| 7 | if (visibleToastCount <= 1) return false; | ||
| 8 | return visibleToastIndex > 0; | ||
| 9 | } | ||
apps/web/src/components/ui/toast.tsx+190-84| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { Toast } from "@base-ui/react/toast"; | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; | ||
| 4 | import { | 5 | import { |
| 5 | CircleAlertIcon, | 6 | CircleAlertIcon, |
| 6 | CircleCheckIcon, | 7 | CircleCheckIcon, |
| ... | @@ -12,14 +13,18 @@ import { | ... | @@ -12,14 +13,18 @@ import { |
| 12 | import { cn } from "~/lib/utils"; | 13 | import { cn } from "~/lib/utils"; |
| 13 | import { buttonVariants } from "~/components/ui/button"; | 14 | import { buttonVariants } from "~/components/ui/button"; |
| 14 | import { useStore } from "~/store"; | 15 | import { useStore } from "~/store"; |
| 16 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | ||
| 15 | 17 | ||
| 16 | type ThreadToastData = { | 18 | type ThreadToastData = { |
| 17 | threadId?: string | null; | 19 | threadId?: string | null; |
| 18 | tooltipStyle?: boolean; | 20 | tooltipStyle?: boolean; |
| 21 | dismissAfterVisibleMs?: number; | ||
| 19 | }; | 22 | }; |
| 20 | 23 | ||
| 21 | const toastManager = Toast.createToastManager<ThreadToastData>(); | 24 | const toastManager = Toast.createToastManager<ThreadToastData>(); |
| 22 | const anchoredToastManager = Toast.createToastManager<ThreadToastData>(); | 25 | const anchoredToastManager = Toast.createToastManager<ThreadToastData>(); |
| 26 | type ToastId = ReturnType<typeof toastManager.add>; | ||
| 27 | const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>(); | ||
| 23 | 28 | ||
| 24 | const TOAST_ICONS = { | 29 | const TOAST_ICONS = { |
| 25 | error: CircleAlertIcon, | 30 | error: CircleAlertIcon, |
| ... | @@ -50,6 +55,84 @@ function shouldRenderForActiveThread( | ... | @@ -50,6 +55,84 @@ function shouldRenderForActiveThread( |
| 50 | return toastThreadId === activeThreadId; | 55 | return toastThreadId === activeThreadId; |
| 51 | } | 56 | } |
| 52 | 57 | ||
| 58 | function ThreadToastVisibleAutoDismiss({ | ||
| 59 | toastId, | ||
| 60 | dismissAfterVisibleMs, | ||
| 61 | }: { | ||
| 62 | toastId: ToastId; | ||
| 63 | dismissAfterVisibleMs: number | undefined; | ||
| 64 | }) { | ||
| 65 | useEffect(() => { | ||
| 66 | if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; | ||
| 67 | if (typeof window === "undefined" || typeof document === "undefined") return; | ||
| 68 | |||
| 69 | let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; | ||
| 70 | let startedAtMs: number | null = null; | ||
| 71 | let timeoutId: number | null = null; | ||
| 72 | let closed = false; | ||
| 73 | |||
| 74 | const clearTimer = () => { | ||
| 75 | if (timeoutId === null) return; | ||
| 76 | window.clearTimeout(timeoutId); | ||
| 77 | timeoutId = null; | ||
| 78 | }; | ||
| 79 | |||
| 80 | const closeToast = () => { | ||
| 81 | if (closed) return; | ||
| 82 | closed = true; | ||
| 83 | threadToastVisibleTimeoutRemainingMs.delete(toastId); | ||
| 84 | toastManager.close(toastId); | ||
| 85 | }; | ||
| 86 | |||
| 87 | const pause = () => { | ||
| 88 | if (startedAtMs === null) return; | ||
| 89 | remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); | ||
| 90 | startedAtMs = null; | ||
| 91 | clearTimer(); | ||
| 92 | threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); | ||
| 93 | }; | ||
| 94 | |||
| 95 | const start = () => { | ||
| 96 | if (closed || startedAtMs !== null) return; | ||
| 97 | if (remainingMs <= 0) { | ||
| 98 | closeToast(); | ||
| 99 | return; | ||
| 100 | } | ||
| 101 | startedAtMs = Date.now(); | ||
| 102 | clearTimer(); | ||
| 103 | timeoutId = window.setTimeout(() => { | ||
| 104 | remainingMs = 0; | ||
| 105 | startedAtMs = null; | ||
| 106 | closeToast(); | ||
| 107 | }, remainingMs); | ||
| 108 | }; | ||
| 109 | |||
| 110 | const syncTimer = () => { | ||
| 111 | const shouldRun = document.visibilityState === "visible" && document.hasFocus(); | ||
| 112 | if (shouldRun) { | ||
| 113 | start(); | ||
| 114 | return; | ||
| 115 | } | ||
| 116 | pause(); | ||
| 117 | }; | ||
| 118 | |||
| 119 | syncTimer(); | ||
| 120 | document.addEventListener("visibilitychange", syncTimer); | ||
| 121 | window.addEventListener("focus", syncTimer); | ||
| 122 | window.addEventListener("blur", syncTimer); | ||
| 123 | |||
| 124 | return () => { | ||
| 125 | document.removeEventListener("visibilitychange", syncTimer); | ||
| 126 | window.removeEventListener("focus", syncTimer); | ||
| 127 | window.removeEventListener("blur", syncTimer); | ||
| 128 | pause(); | ||
| 129 | clearTimer(); | ||
| 130 | }; | ||
| 131 | }, [dismissAfterVisibleMs, toastId]); | ||
| 132 | |||
| 133 | return null; | ||
| 134 | } | ||
| 135 | |||
| 53 | function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { | 136 | function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { |
| 54 | return ( | 137 | return ( |
| 55 | <Toast.Provider toastManager={toastManager} {...props}> | 138 | <Toast.Provider toastManager={toastManager} {...props}> |
| ... | @@ -64,6 +147,18 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -64,6 +147,18 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 64 | const { toasts } = Toast.useToastManager<ThreadToastData>(); | 147 | const { toasts } = Toast.useToastManager<ThreadToastData>(); |
| 65 | const activeThreadId = state.activeThreadId; | 148 | const activeThreadId = state.activeThreadId; |
| 66 | const isTop = position.startsWith("top"); | 149 | const isTop = position.startsWith("top"); |
| 150 | const visibleToasts = toasts.filter((toast) => | ||
| 151 | shouldRenderForActiveThread(toast.data, activeThreadId), | ||
| 152 | ); | ||
| 153 | |||
| 154 | useEffect(() => { | ||
| 155 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); | ||
| 156 | for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { | ||
| 157 | if (!activeToastIds.has(toastId)) { | ||
| 158 | threadToastVisibleTimeoutRemainingMs.delete(toastId); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | }, [toasts]); | ||
| 67 | 162 | ||
| 68 | return ( | 163 | return ( |
| 69 | <Toast.Portal data-slot="toast-portal"> | 164 | <Toast.Portal data-slot="toast-portal"> |
| ... | @@ -81,96 +176,107 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -81,96 +176,107 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 81 | data-position={position} | 176 | data-position={position} |
| 82 | data-slot="toast-viewport" | 177 | data-slot="toast-viewport" |
| 83 | > | 178 | > |
| 84 | {toasts | 179 | {visibleToasts.map((toast, visibleIndex) => { |
| 85 | .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId)) | 180 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 86 | .map((toast) => { | 181 | const hideCollapsedContent = shouldHideCollapsedToastContent( |
| 87 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | 182 | visibleIndex, |
| 183 | visibleToasts.length, | ||
| 184 | ); | ||
| 88 | 185 | ||
| 89 | return ( | 186 | return ( |
| 90 | <Toast.Root | 187 | <Toast.Root |
| 188 | className={cn( | ||
| 189 | "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | ||
| 190 | // Base positioning using data-position | ||
| 191 | "data-[position*=right]:right-0 data-[position*=right]:left-auto", | ||
| 192 | "data-[position*=left]:right-auto data-[position*=left]:left-0", | ||
| 193 | "data-[position*=center]:right-0 data-[position*=center]:left-0", | ||
| 194 | "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top", | ||
| 195 | "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom", | ||
| 196 | // Gap fill for hover | ||
| 197 | "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full", | ||
| 198 | "data-[position*=top]:after:top-full", | ||
| 199 | "data-[position*=bottom]:after:bottom-full", | ||
| 200 | // Define some variables | ||
| 201 | "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]", | ||
| 202 | // Define offset-y variable | ||
| 203 | "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]", | ||
| 204 | "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]", | ||
| 205 | // Default state transform | ||
| 206 | "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | ||
| 207 | "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | ||
| 208 | // Limited state | ||
| 209 | "data-limited:opacity-0", | ||
| 210 | // Expanded state | ||
| 211 | "data-expanded:h-(--toast-height)", | ||
| 212 | "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]", | ||
| 213 | // Starting and ending animations | ||
| 214 | "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]", | ||
| 215 | "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]", | ||
| 216 | "data-[position*=top]:data-[position*=right]:data-starting-style:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 217 | "data-ending-style:opacity-0", | ||
| 218 | // Ending animations (direction-aware) | ||
| 219 | "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]", | ||
| 220 | "data-[position*=top]:data-[position*=right]:data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 221 | "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 222 | "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 223 | "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | ||
| 224 | "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | ||
| 225 | // Ending animations (expanded) | ||
| 226 | "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 227 | "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 228 | "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | ||
| 229 | "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | ||
| 230 | )} | ||
| 231 | data-position={position} | ||
| 232 | key={toast.id} | ||
| 233 | swipeDirection={ | ||
| 234 | position.includes("center") | ||
| 235 | ? [isTop ? "up" : "down"] | ||
| 236 | : position.includes("left") | ||
| 237 | ? ["left", isTop ? "up" : "down"] | ||
| 238 | : ["right", isTop ? "up" : "down"] | ||
| 239 | } | ||
| 240 | toast={toast} | ||
| 241 | > | ||
| 242 | <ThreadToastVisibleAutoDismiss | ||
| 243 | dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} | ||
| 244 | toastId={toast.id} | ||
| 245 | /> | ||
| 246 | <Toast.Content | ||
| 91 | className={cn( | 247 | className={cn( |
| 92 | "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | 248 | "pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-expanded:opacity-100", |
| 93 | // Base positioning using data-position | 249 | hideCollapsedContent && |
| 94 | "data-[position*=right]:right-0 data-[position*=right]:left-auto", | 250 | "not-data-expanded:pointer-events-none not-data-expanded:opacity-0", |
| 95 | "data-[position*=left]:right-auto data-[position*=left]:left-0", | ||
| 96 | "data-[position*=center]:right-0 data-[position*=center]:left-0", | ||
| 97 | "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top", | ||
| 98 | "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom", | ||
| 99 | // Gap fill for hover | ||
| 100 | "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full", | ||
| 101 | "data-[position*=top]:after:top-full", | ||
| 102 | "data-[position*=bottom]:after:bottom-full", | ||
| 103 | // Define some variables | ||
| 104 | "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]", | ||
| 105 | // Define offset-y variable | ||
| 106 | "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]", | ||
| 107 | "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]", | ||
| 108 | // Default state transform | ||
| 109 | "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | ||
| 110 | "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", | ||
| 111 | // Limited state | ||
| 112 | "data-limited:opacity-0", | ||
| 113 | // Expanded state | ||
| 114 | "data-expanded:h-(--toast-height)", | ||
| 115 | "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]", | ||
| 116 | // Starting and ending animations | ||
| 117 | "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]", | ||
| 118 | "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]", | ||
| 119 | "data-ending-style:opacity-0", | ||
| 120 | // Ending animations (direction-aware) | ||
| 121 | "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]", | ||
| 122 | "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 123 | "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 124 | "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | ||
| 125 | "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | ||
| 126 | // Ending animations (expanded) | ||
| 127 | "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 128 | "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", | ||
| 129 | "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", | ||
| 130 | "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", | ||
| 131 | )} | 251 | )} |
| 132 | data-position={position} | ||
| 133 | key={toast.id} | ||
| 134 | swipeDirection={ | ||
| 135 | position.includes("center") | ||
| 136 | ? [isTop ? "up" : "down"] | ||
| 137 | : position.includes("left") | ||
| 138 | ? ["left", isTop ? "up" : "down"] | ||
| 139 | : ["right", isTop ? "up" : "down"] | ||
| 140 | } | ||
| 141 | toast={toast} | ||
| 142 | > | 252 | > |
| 143 | <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100"> | 253 | <div className="flex gap-2"> |
| 144 | <div className="flex gap-2"> | 254 | {Icon && ( |
| 145 | {Icon && ( | 255 | <div |
| 146 | <div | 256 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" |
| 147 | className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" | 257 | data-slot="toast-icon" |
| 148 | data-slot="toast-icon" | ||
| 149 | > | ||
| 150 | <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" /> | ||
| 151 | </div> | ||
| 152 | )} | ||
| 153 | |||
| 154 | <div className="flex flex-col gap-0.5"> | ||
| 155 | <Toast.Title className="font-medium" data-slot="toast-title" /> | ||
| 156 | <Toast.Description | ||
| 157 | className="text-muted-foreground" | ||
| 158 | data-slot="toast-description" | ||
| 159 | /> | ||
| 160 | </div> | ||
| 161 | </div> | ||
| 162 | {toast.actionProps && ( | ||
| 163 | <Toast.Action | ||
| 164 | className={buttonVariants({ size: "xs" })} | ||
| 165 | data-slot="toast-action" | ||
| 166 | > | 258 | > |
| 167 | {toast.actionProps.children} | 259 | <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" /> |
| 168 | </Toast.Action> | 260 | </div> |
| 169 | )} | 261 | )} |
| 170 | </Toast.Content> | 262 | |
| 171 | </Toast.Root> | 263 | <div className="flex flex-col gap-0.5"> |
| 172 | ); | 264 | <Toast.Title className="font-medium" data-slot="toast-title" /> |
| 173 | })} | 265 | <Toast.Description |
| 266 | className="text-muted-foreground" | ||
| 267 | data-slot="toast-description" | ||
| 268 | /> | ||
| 269 | </div> | ||
| 270 | </div> | ||
| 271 | {toast.actionProps && ( | ||
| 272 | <Toast.Action className={buttonVariants({ size: "xs" })} data-slot="toast-action"> | ||
| 273 | {toast.actionProps.children} | ||
| 274 | </Toast.Action> | ||
| 275 | )} | ||
| 276 | </Toast.Content> | ||
| 277 | </Toast.Root> | ||
| 278 | ); | ||
| 279 | })} | ||
| 174 | </Toast.Viewport> | 280 | </Toast.Viewport> |
| 175 | </Toast.Portal> | 281 | </Toast.Portal> |
| 176 | ); | 282 | ); |
apps/web/src/keybindings.test.ts created+444| ... | @@ -0,0 +1,444 @@ | ||
| 1 | import { assert, describe, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | type KeybindingCommand, | ||
| 5 | type KeybindingShortcut, | ||
| 6 | type KeybindingWhenNode, | ||
| 7 | type ResolvedKeybindingsConfig, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { | ||
| 10 | formatShortcutLabel, | ||
| 11 | isChatNewShortcut, | ||
| 12 | isChatNewLocalShortcut, | ||
| 13 | isOpenFavoriteEditorShortcut, | ||
| 14 | isTerminalClearShortcut, | ||
| 15 | isTerminalCloseShortcut, | ||
| 16 | isTerminalNewShortcut, | ||
| 17 | isTerminalSplitShortcut, | ||
| 18 | isTerminalToggleShortcut, | ||
| 19 | shortcutLabelForCommand, | ||
| 20 | terminalNavigationShortcutData, | ||
| 21 | type ShortcutEventLike, | ||
| 22 | } from "./keybindings"; | ||
| 23 | |||
| 24 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | ||
| 25 | return { | ||
| 26 | key: "j", | ||
| 27 | metaKey: false, | ||
| 28 | ctrlKey: false, | ||
| 29 | shiftKey: false, | ||
| 30 | altKey: false, | ||
| 31 | ...overrides, | ||
| 32 | }; | ||
| 33 | } | ||
| 34 | |||
| 35 | function modShortcut( | ||
| 36 | key: string, | ||
| 37 | overrides: Partial<Omit<KeybindingShortcut, "key">> = {}, | ||
| 38 | ): KeybindingShortcut { | ||
| 39 | return { | ||
| 40 | key, | ||
| 41 | metaKey: false, | ||
| 42 | ctrlKey: false, | ||
| 43 | shiftKey: false, | ||
| 44 | altKey: false, | ||
| 45 | modKey: true, | ||
| 46 | ...overrides, | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | function whenIdentifier(name: string): KeybindingWhenNode { | ||
| 51 | return { type: "identifier", name }; | ||
| 52 | } | ||
| 53 | |||
| 54 | function whenNot(node: KeybindingWhenNode): KeybindingWhenNode { | ||
| 55 | return { type: "not", node }; | ||
| 56 | } | ||
| 57 | |||
| 58 | function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode { | ||
| 59 | return { type: "and", left, right }; | ||
| 60 | } | ||
| 61 | |||
| 62 | interface TestBinding { | ||
| 63 | shortcut: KeybindingShortcut; | ||
| 64 | command: KeybindingCommand; | ||
| 65 | whenAst?: KeybindingWhenNode; | ||
| 66 | } | ||
| 67 | |||
| 68 | function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { | ||
| 69 | return bindings.map((binding) => ({ | ||
| 70 | command: binding.command, | ||
| 71 | shortcut: binding.shortcut, | ||
| 72 | ...(binding.whenAst ? { whenAst: binding.whenAst } : {}), | ||
| 73 | })); | ||
| 74 | } | ||
| 75 | |||
| 76 | const DEFAULT_BINDINGS = compile([ | ||
| 77 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, | ||
| 78 | { | ||
| 79 | shortcut: modShortcut("d"), | ||
| 80 | command: "terminal.split", | ||
| 81 | whenAst: whenIdentifier("terminalFocus"), | ||
| 82 | }, | ||
| 83 | { | ||
| 84 | shortcut: modShortcut("d", { shiftKey: true }), | ||
| 85 | command: "terminal.new", | ||
| 86 | whenAst: whenIdentifier("terminalFocus"), | ||
| 87 | }, | ||
| 88 | { | ||
| 89 | shortcut: modShortcut("w"), | ||
| 90 | command: "terminal.close", | ||
| 91 | whenAst: whenIdentifier("terminalFocus"), | ||
| 92 | }, | ||
| 93 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, | ||
| 94 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, | ||
| 95 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, | ||
| 96 | ]); | ||
| 97 | |||
| 98 | describe("isTerminalToggleShortcut", () => { | ||
| 99 | it("matches Cmd+J on macOS", () => { | ||
| 100 | assert.isTrue( | ||
| 101 | isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" }), | ||
| 102 | ); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("matches Ctrl+J on non-macOS", () => { | ||
| 106 | assert.isTrue( | ||
| 107 | isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" }), | ||
| 108 | ); | ||
| 109 | }); | ||
| 110 | }); | ||
| 111 | |||
| 112 | describe("split/new/close terminal shortcuts", () => { | ||
| 113 | it("requires terminalFocus for default split/new/close bindings", () => { | ||
| 114 | assert.isFalse( | ||
| 115 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | ||
| 116 | platform: "MacIntel", | ||
| 117 | context: { terminalFocus: false }, | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | assert.isFalse( | ||
| 121 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | ||
| 122 | platform: "Linux", | ||
| 123 | context: { terminalFocus: false }, | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | assert.isFalse( | ||
| 127 | isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { | ||
| 128 | platform: "Linux", | ||
| 129 | context: { terminalFocus: false }, | ||
| 130 | }), | ||
| 131 | ); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("matches split/new when terminalFocus is true", () => { | ||
| 135 | assert.isTrue( | ||
| 136 | isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { | ||
| 137 | platform: "MacIntel", | ||
| 138 | context: { terminalFocus: true }, | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | assert.isTrue( | ||
| 142 | isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | ||
| 143 | platform: "Linux", | ||
| 144 | context: { terminalFocus: true }, | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | assert.isTrue( | ||
| 148 | isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, { | ||
| 149 | platform: "Linux", | ||
| 150 | context: { terminalFocus: true }, | ||
| 151 | }), | ||
| 152 | ); | ||
| 153 | }); | ||
| 154 | |||
| 155 | it("supports when expressions", () => { | ||
| 156 | const keybindings = compile([ | ||
| 157 | { | ||
| 158 | shortcut: modShortcut("\\"), | ||
| 159 | command: "terminal.split", | ||
| 160 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), | ||
| 161 | }, | ||
| 162 | { | ||
| 163 | shortcut: modShortcut("n", { shiftKey: true }), | ||
| 164 | command: "terminal.new", | ||
| 165 | whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))), | ||
| 166 | }, | ||
| 167 | { shortcut: modShortcut("j"), command: "terminal.toggle" }, | ||
| 168 | ]); | ||
| 169 | assert.isTrue( | ||
| 170 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | ||
| 171 | platform: "Win32", | ||
| 172 | context: { terminalOpen: true, terminalFocus: false }, | ||
| 173 | }), | ||
| 174 | ); | ||
| 175 | assert.isFalse( | ||
| 176 | isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, { | ||
| 177 | platform: "Win32", | ||
| 178 | context: { terminalOpen: false, terminalFocus: false }, | ||
| 179 | }), | ||
| 180 | ); | ||
| 181 | assert.isTrue( | ||
| 182 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, { | ||
| 183 | platform: "Win32", | ||
| 184 | context: { terminalOpen: true, terminalFocus: false }, | ||
| 185 | }), | ||
| 186 | ); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("supports when boolean literals", () => { | ||
| 190 | const keybindings = compile([ | ||
| 191 | { shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("true") }, | ||
| 192 | { shortcut: modShortcut("m"), command: "terminal.new", whenAst: whenIdentifier("false") }, | ||
| 193 | ]); | ||
| 194 | |||
| 195 | assert.isTrue( | ||
| 196 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { | ||
| 197 | platform: "Linux", | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | assert.isFalse( | ||
| 201 | isTerminalNewShortcut(event({ key: "m", ctrlKey: true }), keybindings, { | ||
| 202 | platform: "Linux", | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | }); | ||
| 206 | }); | ||
| 207 | |||
| 208 | describe("shortcutLabelForCommand", () => { | ||
| 209 | it("returns the most recent binding label", () => { | ||
| 210 | const bindings = compile([ | ||
| 211 | { | ||
| 212 | shortcut: modShortcut("\\"), | ||
| 213 | command: "terminal.split", | ||
| 214 | whenAst: whenIdentifier("terminalFocus"), | ||
| 215 | }, | ||
| 216 | { | ||
| 217 | shortcut: modShortcut("\\", { shiftKey: true }), | ||
| 218 | command: "terminal.split", | ||
| 219 | whenAst: whenNot(whenIdentifier("terminalFocus")), | ||
| 220 | }, | ||
| 221 | ]); | ||
| 222 | assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\"); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("returns labels for non-terminal commands", () => { | ||
| 226 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); | ||
| 227 | assert.strictEqual( | ||
| 228 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), | ||
| 229 | "Ctrl+O", | ||
| 230 | ); | ||
| 231 | }); | ||
| 232 | }); | ||
| 233 | |||
| 234 | describe("chat/editor shortcuts", () => { | ||
| 235 | it("matches chat.new shortcut", () => { | ||
| 236 | assert.isTrue( | ||
| 237 | isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | ||
| 238 | platform: "MacIntel", | ||
| 239 | }), | ||
| 240 | ); | ||
| 241 | assert.isTrue( | ||
| 242 | isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | ||
| 243 | platform: "Linux", | ||
| 244 | }), | ||
| 245 | ); | ||
| 246 | }); | ||
| 247 | |||
| 248 | it("matches chat.newLocal shortcut", () => { | ||
| 249 | assert.isTrue( | ||
| 250 | isChatNewLocalShortcut( | ||
| 251 | event({ key: "n", metaKey: true, shiftKey: true }), | ||
| 252 | DEFAULT_BINDINGS, | ||
| 253 | { | ||
| 254 | platform: "MacIntel", | ||
| 255 | }, | ||
| 256 | ), | ||
| 257 | ); | ||
| 258 | assert.isTrue( | ||
| 259 | isChatNewLocalShortcut( | ||
| 260 | event({ key: "n", ctrlKey: true, shiftKey: true }), | ||
| 261 | DEFAULT_BINDINGS, | ||
| 262 | { | ||
| 263 | platform: "Linux", | ||
| 264 | }, | ||
| 265 | ), | ||
| 266 | ); | ||
| 267 | }); | ||
| 268 | |||
| 269 | it("matches editor.openFavorite shortcut", () => { | ||
| 270 | assert.isTrue( | ||
| 271 | isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, { | ||
| 272 | platform: "MacIntel", | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | assert.isTrue( | ||
| 276 | isOpenFavoriteEditorShortcut(event({ key: "o", ctrlKey: true }), DEFAULT_BINDINGS, { | ||
| 277 | platform: "Linux", | ||
| 278 | }), | ||
| 279 | ); | ||
| 280 | }); | ||
| 281 | }); | ||
| 282 | |||
| 283 | describe("cross-command precedence", () => { | ||
| 284 | it("uses when + order so a later focused rule overrides a global rule", () => { | ||
| 285 | const keybindings = compile([ | ||
| 286 | { shortcut: modShortcut("n"), command: "chat.new" }, | ||
| 287 | { | ||
| 288 | shortcut: modShortcut("n"), | ||
| 289 | command: "terminal.new", | ||
| 290 | whenAst: whenIdentifier("terminalFocus"), | ||
| 291 | }, | ||
| 292 | ]); | ||
| 293 | |||
| 294 | assert.isTrue( | ||
| 295 | isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | ||
| 296 | platform: "MacIntel", | ||
| 297 | context: { terminalFocus: true }, | ||
| 298 | }), | ||
| 299 | ); | ||
| 300 | assert.isFalse( | ||
| 301 | isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | ||
| 302 | platform: "MacIntel", | ||
| 303 | context: { terminalFocus: true }, | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | assert.isFalse( | ||
| 307 | isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | ||
| 308 | platform: "MacIntel", | ||
| 309 | context: { terminalFocus: false }, | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | assert.isTrue( | ||
| 313 | isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { | ||
| 314 | platform: "MacIntel", | ||
| 315 | context: { terminalFocus: false }, | ||
| 316 | }), | ||
| 317 | ); | ||
| 318 | }); | ||
| 319 | |||
| 320 | it("still lets a later global rule win when both rules match", () => { | ||
| 321 | const keybindings = compile([ | ||
| 322 | { | ||
| 323 | shortcut: modShortcut("n"), | ||
| 324 | command: "terminal.new", | ||
| 325 | whenAst: whenIdentifier("terminalFocus"), | ||
| 326 | }, | ||
| 327 | { shortcut: modShortcut("n"), command: "chat.new" }, | ||
| 328 | ]); | ||
| 329 | |||
| 330 | assert.isFalse( | ||
| 331 | isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { | ||
| 332 | platform: "Linux", | ||
| 333 | context: { terminalFocus: true }, | ||
| 334 | }), | ||
| 335 | ); | ||
| 336 | assert.isTrue( | ||
| 337 | isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { | ||
| 338 | platform: "Linux", | ||
| 339 | context: { terminalFocus: true }, | ||
| 340 | }), | ||
| 341 | ); | ||
| 342 | }); | ||
| 343 | }); | ||
| 344 | |||
| 345 | describe("formatShortcutLabel", () => { | ||
| 346 | it("formats labels for macOS", () => { | ||
| 347 | assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D"); | ||
| 348 | }); | ||
| 349 | |||
| 350 | it("formats labels for non-macOS", () => { | ||
| 351 | assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D"); | ||
| 352 | }); | ||
| 353 | |||
| 354 | it("formats labels for plus key", () => { | ||
| 355 | assert.strictEqual(formatShortcutLabel(modShortcut("+"), "MacIntel"), "⌘+"); | ||
| 356 | assert.strictEqual(formatShortcutLabel(modShortcut("+"), "Linux"), "Ctrl++"); | ||
| 357 | }); | ||
| 358 | }); | ||
| 359 | |||
| 360 | describe("isTerminalClearShortcut", () => { | ||
| 361 | it("matches Ctrl+L on all platforms", () => { | ||
| 362 | assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")); | ||
| 363 | assert.isTrue(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")); | ||
| 364 | }); | ||
| 365 | |||
| 366 | it("matches Cmd+K on macOS", () => { | ||
| 367 | assert.isTrue(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")); | ||
| 368 | }); | ||
| 369 | |||
| 370 | it("ignores non-keydown events", () => { | ||
| 371 | assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux")); | ||
| 372 | }); | ||
| 373 | }); | ||
| 374 | |||
| 375 | describe("terminalNavigationShortcutData", () => { | ||
| 376 | it("maps Option+Arrow on macOS to word movement", () => { | ||
| 377 | assert.strictEqual( | ||
| 378 | terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), "MacIntel"), | ||
| 379 | "\u001bb", | ||
| 380 | ); | ||
| 381 | assert.strictEqual( | ||
| 382 | terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), "MacIntel"), | ||
| 383 | "\u001bf", | ||
| 384 | ); | ||
| 385 | }); | ||
| 386 | |||
| 387 | it("maps Cmd+Arrow on macOS to line movement", () => { | ||
| 388 | assert.strictEqual( | ||
| 389 | terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "MacIntel"), | ||
| 390 | "\u0001", | ||
| 391 | ); | ||
| 392 | assert.strictEqual( | ||
| 393 | terminalNavigationShortcutData(event({ key: "ArrowRight", metaKey: true }), "MacIntel"), | ||
| 394 | "\u0005", | ||
| 395 | ); | ||
| 396 | }); | ||
| 397 | |||
| 398 | it("maps Ctrl+Arrow on non-macOS to word movement", () => { | ||
| 399 | assert.strictEqual( | ||
| 400 | terminalNavigationShortcutData(event({ key: "ArrowLeft", ctrlKey: true }), "Win32"), | ||
| 401 | "\u001bb", | ||
| 402 | ); | ||
| 403 | assert.strictEqual( | ||
| 404 | terminalNavigationShortcutData(event({ key: "ArrowRight", ctrlKey: true }), "Linux"), | ||
| 405 | "\u001bf", | ||
| 406 | ); | ||
| 407 | }); | ||
| 408 | |||
| 409 | it("rejects unsupported combinations", () => { | ||
| 410 | assert.isNull( | ||
| 411 | terminalNavigationShortcutData( | ||
| 412 | event({ key: "ArrowLeft", shiftKey: true, altKey: true }), | ||
| 413 | "MacIntel", | ||
| 414 | ), | ||
| 415 | ); | ||
| 416 | assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux")); | ||
| 417 | assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel")); | ||
| 418 | }); | ||
| 419 | |||
| 420 | it("ignores non-keydown events", () => { | ||
| 421 | assert.isNull( | ||
| 422 | terminalNavigationShortcutData( | ||
| 423 | event({ type: "keyup", key: "ArrowLeft", altKey: true }), | ||
| 424 | "MacIntel", | ||
| 425 | ), | ||
| 426 | ); | ||
| 427 | }); | ||
| 428 | }); | ||
| 429 | |||
| 430 | describe("plus key parsing", () => { | ||
| 431 | it("matches the plus key shortcut", () => { | ||
| 432 | const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]); | ||
| 433 | assert.isTrue( | ||
| 434 | isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, { | ||
| 435 | platform: "MacIntel", | ||
| 436 | }), | ||
| 437 | ); | ||
| 438 | assert.isTrue( | ||
| 439 | isTerminalToggleShortcut(event({ key: "+", ctrlKey: true }), plusBindings, { | ||
| 440 | platform: "Linux", | ||
| 441 | }), | ||
| 442 | ); | ||
| 443 | }); | ||
| 444 | }); | ||
apps/web/src/keybindings.ts created+286| ... | @@ -0,0 +1,286 @@ | ||
| 1 | import { | ||
| 2 | type KeybindingCommand, | ||
| 3 | type KeybindingShortcut, | ||
| 4 | type KeybindingWhenNode, | ||
| 5 | type ResolvedKeybindingsConfig, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { isMacPlatform } from "./lib/utils"; | ||
| 8 | |||
| 9 | export interface ShortcutEventLike { | ||
| 10 | type?: string; | ||
| 11 | key: string; | ||
| 12 | metaKey: boolean; | ||
| 13 | ctrlKey: boolean; | ||
| 14 | shiftKey: boolean; | ||
| 15 | altKey: boolean; | ||
| 16 | } | ||
| 17 | |||
| 18 | export interface ShortcutMatchContext { | ||
| 19 | terminalFocus: boolean; | ||
| 20 | terminalOpen: boolean; | ||
| 21 | [key: string]: boolean; | ||
| 22 | } | ||
| 23 | |||
| 24 | interface ShortcutMatchOptions { | ||
| 25 | platform?: string; | ||
| 26 | context?: Partial<ShortcutMatchContext>; | ||
| 27 | } | ||
| 28 | |||
| 29 | const TERMINAL_WORD_BACKWARD = "\u001bb"; | ||
| 30 | const TERMINAL_WORD_FORWARD = "\u001bf"; | ||
| 31 | const TERMINAL_LINE_START = "\u0001"; | ||
| 32 | const TERMINAL_LINE_END = "\u0005"; | ||
| 33 | |||
| 34 | function normalizeEventKey(key: string): string { | ||
| 35 | const normalized = key.toLowerCase(); | ||
| 36 | if (normalized === "esc") return "escape"; | ||
| 37 | return normalized; | ||
| 38 | } | ||
| 39 | |||
| 40 | function matchesShortcut( | ||
| 41 | event: ShortcutEventLike, | ||
| 42 | shortcut: KeybindingShortcut, | ||
| 43 | platform = navigator.platform, | ||
| 44 | ): boolean { | ||
| 45 | const key = normalizeEventKey(event.key); | ||
| 46 | if (key !== shortcut.key) return false; | ||
| 47 | |||
| 48 | const useMetaForMod = isMacPlatform(platform); | ||
| 49 | const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); | ||
| 50 | const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); | ||
| 51 | return ( | ||
| 52 | event.metaKey === expectedMeta && | ||
| 53 | event.ctrlKey === expectedCtrl && | ||
| 54 | event.shiftKey === shortcut.shiftKey && | ||
| 55 | event.altKey === shortcut.altKey | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { | ||
| 60 | return options?.platform ?? navigator.platform; | ||
| 61 | } | ||
| 62 | |||
| 63 | function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext { | ||
| 64 | return { | ||
| 65 | terminalFocus: false, | ||
| 66 | terminalOpen: false, | ||
| 67 | ...options?.context, | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | function evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean { | ||
| 72 | switch (node.type) { | ||
| 73 | case "identifier": | ||
| 74 | if (node.name === "true") return true; | ||
| 75 | if (node.name === "false") return false; | ||
| 76 | return Boolean(context[node.name]); | ||
| 77 | case "not": | ||
| 78 | return !evaluateWhenNode(node.node, context); | ||
| 79 | case "and": | ||
| 80 | return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context); | ||
| 81 | case "or": | ||
| 82 | return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context); | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | function matchesWhenClause( | ||
| 87 | whenAst: KeybindingWhenNode | undefined, | ||
| 88 | context: ShortcutMatchContext, | ||
| 89 | ): boolean { | ||
| 90 | if (!whenAst) return true; | ||
| 91 | return evaluateWhenNode(whenAst, context); | ||
| 92 | } | ||
| 93 | |||
| 94 | function matchesCommandShortcut( | ||
| 95 | event: ShortcutEventLike, | ||
| 96 | keybindings: ResolvedKeybindingsConfig, | ||
| 97 | command: KeybindingCommand, | ||
| 98 | options?: ShortcutMatchOptions, | ||
| 99 | ): boolean { | ||
| 100 | return resolveShortcutCommand(event, keybindings, options) === command; | ||
| 101 | } | ||
| 102 | |||
| 103 | function resolveShortcutCommand( | ||
| 104 | event: ShortcutEventLike, | ||
| 105 | keybindings: ResolvedKeybindingsConfig, | ||
| 106 | options?: ShortcutMatchOptions, | ||
| 107 | ): KeybindingCommand | null { | ||
| 108 | const platform = resolvePlatform(options); | ||
| 109 | const context = resolveContext(options); | ||
| 110 | |||
| 111 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | ||
| 112 | const binding = keybindings[index]; | ||
| 113 | if (!binding) continue; | ||
| 114 | if (!matchesWhenClause(binding.whenAst, context)) continue; | ||
| 115 | if (!matchesShortcut(event, binding.shortcut, platform)) continue; | ||
| 116 | return binding.command; | ||
| 117 | } | ||
| 118 | return null; | ||
| 119 | } | ||
| 120 | |||
| 121 | function formatShortcutKeyLabel(key: string): string { | ||
| 122 | if (key === " ") return "Space"; | ||
| 123 | if (key.length === 1) return key.toUpperCase(); | ||
| 124 | if (key === "escape") return "Esc"; | ||
| 125 | if (key === "arrowup") return "Up"; | ||
| 126 | if (key === "arrowdown") return "Down"; | ||
| 127 | if (key === "arrowleft") return "Left"; | ||
| 128 | if (key === "arrowright") return "Right"; | ||
| 129 | return key.slice(0, 1).toUpperCase() + key.slice(1); | ||
| 130 | } | ||
| 131 | |||
| 132 | export function formatShortcutLabel( | ||
| 133 | shortcut: KeybindingShortcut, | ||
| 134 | platform = navigator.platform, | ||
| 135 | ): string { | ||
| 136 | const keyLabel = formatShortcutKeyLabel(shortcut.key); | ||
| 137 | const useMetaForMod = isMacPlatform(platform); | ||
| 138 | const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); | ||
| 139 | const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); | ||
| 140 | const showAlt = shortcut.altKey; | ||
| 141 | const showShift = shortcut.shiftKey; | ||
| 142 | |||
| 143 | if (useMetaForMod) { | ||
| 144 | return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`; | ||
| 145 | } | ||
| 146 | |||
| 147 | const parts: string[] = []; | ||
| 148 | if (showCtrl) parts.push("Ctrl"); | ||
| 149 | if (showAlt) parts.push("Alt"); | ||
| 150 | if (showShift) parts.push("Shift"); | ||
| 151 | if (showMeta) parts.push("Meta"); | ||
| 152 | parts.push(keyLabel); | ||
| 153 | return parts.join("+"); | ||
| 154 | } | ||
| 155 | |||
| 156 | export function shortcutLabelForCommand( | ||
| 157 | keybindings: ResolvedKeybindingsConfig, | ||
| 158 | command: KeybindingCommand, | ||
| 159 | platform = navigator.platform, | ||
| 160 | ): string | null { | ||
| 161 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | ||
| 162 | const binding = keybindings[index]; | ||
| 163 | if (!binding || binding.command !== command) continue; | ||
| 164 | return formatShortcutLabel(binding.shortcut, platform); | ||
| 165 | } | ||
| 166 | return null; | ||
| 167 | } | ||
| 168 | |||
| 169 | export function isTerminalToggleShortcut( | ||
| 170 | event: ShortcutEventLike, | ||
| 171 | keybindings: ResolvedKeybindingsConfig, | ||
| 172 | options?: ShortcutMatchOptions, | ||
| 173 | ): boolean { | ||
| 174 | return matchesCommandShortcut(event, keybindings, "terminal.toggle", options); | ||
| 175 | } | ||
| 176 | |||
| 177 | export function isTerminalSplitShortcut( | ||
| 178 | event: ShortcutEventLike, | ||
| 179 | keybindings: ResolvedKeybindingsConfig, | ||
| 180 | options?: ShortcutMatchOptions, | ||
| 181 | ): boolean { | ||
| 182 | return matchesCommandShortcut(event, keybindings, "terminal.split", options); | ||
| 183 | } | ||
| 184 | |||
| 185 | export function isTerminalNewShortcut( | ||
| 186 | event: ShortcutEventLike, | ||
| 187 | keybindings: ResolvedKeybindingsConfig, | ||
| 188 | options?: ShortcutMatchOptions, | ||
| 189 | ): boolean { | ||
| 190 | return matchesCommandShortcut(event, keybindings, "terminal.new", options); | ||
| 191 | } | ||
| 192 | |||
| 193 | export function isTerminalCloseShortcut( | ||
| 194 | event: ShortcutEventLike, | ||
| 195 | keybindings: ResolvedKeybindingsConfig, | ||
| 196 | options?: ShortcutMatchOptions, | ||
| 197 | ): boolean { | ||
| 198 | return matchesCommandShortcut(event, keybindings, "terminal.close", options); | ||
| 199 | } | ||
| 200 | |||
| 201 | export function isChatNewShortcut( | ||
| 202 | event: ShortcutEventLike, | ||
| 203 | keybindings: ResolvedKeybindingsConfig, | ||
| 204 | options?: ShortcutMatchOptions, | ||
| 205 | ): boolean { | ||
| 206 | return matchesCommandShortcut(event, keybindings, "chat.new", options); | ||
| 207 | } | ||
| 208 | |||
| 209 | export function isChatNewLocalShortcut( | ||
| 210 | event: ShortcutEventLike, | ||
| 211 | keybindings: ResolvedKeybindingsConfig, | ||
| 212 | options?: ShortcutMatchOptions, | ||
| 213 | ): boolean { | ||
| 214 | return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); | ||
| 215 | } | ||
| 216 | |||
| 217 | export function isOpenFavoriteEditorShortcut( | ||
| 218 | event: ShortcutEventLike, | ||
| 219 | keybindings: ResolvedKeybindingsConfig, | ||
| 220 | options?: ShortcutMatchOptions, | ||
| 221 | ): boolean { | ||
| 222 | return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); | ||
| 223 | } | ||
| 224 | |||
| 225 | export function isTerminalClearShortcut( | ||
| 226 | event: ShortcutEventLike, | ||
| 227 | platform = navigator.platform, | ||
| 228 | ): boolean { | ||
| 229 | if (event.type !== undefined && event.type !== "keydown") { | ||
| 230 | return false; | ||
| 231 | } | ||
| 232 | |||
| 233 | const key = event.key.toLowerCase(); | ||
| 234 | |||
| 235 | if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { | ||
| 236 | return true; | ||
| 237 | } | ||
| 238 | |||
| 239 | return ( | ||
| 240 | isMacPlatform(platform) && | ||
| 241 | key === "k" && | ||
| 242 | event.metaKey && | ||
| 243 | !event.ctrlKey && | ||
| 244 | !event.altKey && | ||
| 245 | !event.shiftKey | ||
| 246 | ); | ||
| 247 | } | ||
| 248 | |||
| 249 | export function terminalNavigationShortcutData( | ||
| 250 | event: ShortcutEventLike, | ||
| 251 | platform = navigator.platform, | ||
| 252 | ): string | null { | ||
| 253 | if (event.type !== undefined && event.type !== "keydown") { | ||
| 254 | return null; | ||
| 255 | } | ||
| 256 | |||
| 257 | if (event.shiftKey) return null; | ||
| 258 | |||
| 259 | const key = normalizeEventKey(event.key); | ||
| 260 | if (key !== "arrowleft" && key !== "arrowright") { | ||
| 261 | return null; | ||
| 262 | } | ||
| 263 | |||
| 264 | const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD; | ||
| 265 | const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END; | ||
| 266 | |||
| 267 | if (isMacPlatform(platform)) { | ||
| 268 | if (event.altKey && !event.metaKey && !event.ctrlKey) { | ||
| 269 | return moveWord; | ||
| 270 | } | ||
| 271 | if (event.metaKey && !event.altKey && !event.ctrlKey) { | ||
| 272 | return moveLine; | ||
| 273 | } | ||
| 274 | return null; | ||
| 275 | } | ||
| 276 | |||
| 277 | if (event.ctrlKey && !event.metaKey && !event.altKey) { | ||
| 278 | return moveWord; | ||
| 279 | } | ||
| 280 | |||
| 281 | if (event.altKey && !event.metaKey && !event.ctrlKey) { | ||
| 282 | return moveWord; | ||
| 283 | } | ||
| 284 | |||
| 285 | return null; | ||
| 286 | } | ||
apps/web/src/lib/serverReactQuery.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import type { NativeApi } from "@t3tools/contracts"; | ||
| 2 | import { queryOptions } from "@tanstack/react-query"; | ||
| 3 | |||
| 4 | export const serverQueryKeys = { | ||
| 5 | all: ["server"] as const, | ||
| 6 | config: () => ["server", "config"] as const, | ||
| 7 | }; | ||
| 8 | |||
| 9 | export function serverConfigQueryOptions(api: NativeApi | undefined) { | ||
| 10 | return queryOptions({ | ||
| 11 | queryKey: serverQueryKeys.config(), | ||
| 12 | queryFn: async () => { | ||
| 13 | if (!api || !api.server || typeof api.server.getConfig !== "function") { | ||
| 14 | throw new Error("Server config is unavailable."); | ||
| 15 | } | ||
| 16 | return api.server.getConfig(); | ||
| 17 | }, | ||
| 18 | enabled: !!api && !!api.server && typeof api.server.getConfig === "function", | ||
| 19 | staleTime: Infinity, | ||
| 20 | }); | ||
| 21 | } | ||
apps/web/src/lib/utils.test.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | import { assert, describe, it } from "vitest"; | ||
| 2 | |||
| 3 | import { isWindowsPlatform } from "./utils"; | ||
| 4 | |||
| 5 | describe("isWindowsPlatform", () => { | ||
| 6 | it("matches Windows platform identifiers", () => { | ||
| 7 | assert.isTrue(isWindowsPlatform("Win32")); | ||
| 8 | assert.isTrue(isWindowsPlatform("Windows")); | ||
| 9 | assert.isTrue(isWindowsPlatform("windows_nt")); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it("does not match darwin", () => { | ||
| 13 | assert.isFalse(isWindowsPlatform("darwin")); | ||
| 14 | }); | ||
| 15 | }); | ||
apps/web/src/lib/utils.ts+8| ... | @@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge"; | ... | @@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge"; |
| 4 | export function cn(...inputs: CxOptions) { | 4 | export function cn(...inputs: CxOptions) { |
| 5 | return twMerge(cx(inputs)); | 5 | return twMerge(cx(inputs)); |
| 6 | } | 6 | } |
| 7 | |||
| 8 | export function isMacPlatform(platform: string): boolean { | ||
| 9 | return /mac|iphone|ipad|ipod/i.test(platform); | ||
| 10 | } | ||
| 11 | |||
| 12 | export function isWindowsPlatform(platform: string): boolean { | ||
| 13 | return /^win(dows)?/i.test(platform); | ||
| 14 | } |
apps/web/src/store.test.ts+45| ... | @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { | ... | @@ -486,6 +486,51 @@ describe("store reducer thread continuity", () => { |
| 486 | expect(next.activeThreadId).toBe("thread-a"); | 486 | expect(next.activeThreadId).toBe("thread-a"); |
| 487 | }); | 487 | }); |
| 488 | 488 | ||
| 489 | it("deletes a project and all of its threads", () => { | ||
| 490 | const state: AppState = { | ||
| 491 | projects: [ | ||
| 492 | { | ||
| 493 | id: "project-1", | ||
| 494 | name: "One", | ||
| 495 | cwd: "/tmp/one", | ||
| 496 | model: "gpt-5.3-codex", | ||
| 497 | expanded: true, | ||
| 498 | }, | ||
| 499 | { | ||
| 500 | id: "project-2", | ||
| 501 | name: "Two", | ||
| 502 | cwd: "/tmp/two", | ||
| 503 | model: "gpt-5.3-codex", | ||
| 504 | expanded: true, | ||
| 505 | }, | ||
| 506 | ], | ||
| 507 | threads: [ | ||
| 508 | makeThread({ | ||
| 509 | id: "thread-a", | ||
| 510 | projectId: "project-1", | ||
| 511 | }), | ||
| 512 | makeThread({ | ||
| 513 | id: "thread-b", | ||
| 514 | projectId: "project-2", | ||
| 515 | }), | ||
| 516 | ], | ||
| 517 | activeThreadId: "thread-a", | ||
| 518 | runtimeMode: "full-access", | ||
| 519 | diffOpen: false, | ||
| 520 | }; | ||
| 521 | |||
| 522 | const next = reducer(state, { | ||
| 523 | type: "DELETE_PROJECT", | ||
| 524 | projectId: "project-1", | ||
| 525 | }); | ||
| 526 | |||
| 527 | expect(next.projects).toHaveLength(1); | ||
| 528 | expect(next.projects[0]?.id).toBe("project-2"); | ||
| 529 | expect(next.threads).toHaveLength(1); | ||
| 530 | expect(next.threads[0]?.id).toBe("thread-b"); | ||
| 531 | expect(next.activeThreadId).toBe("thread-b"); | ||
| 532 | }); | ||
| 533 | |||
| 489 | it("marks the active thread as visited when selected", () => { | 534 | it("marks the active thread as visited when selected", () => { |
| 490 | const state = makeState( | 535 | const state = makeState( |
| 491 | makeThread({ | 536 | makeThread({ |
apps/web/src/store.ts+20| ... | @@ -28,6 +28,7 @@ type Action = | ... | @@ -28,6 +28,7 @@ type Action = |
| 28 | | { type: "ADD_PROJECT"; project: Project } | 28 | | { type: "ADD_PROJECT"; project: Project } |
| 29 | | { type: "SYNC_PROJECTS"; projects: Project[] } | 29 | | { type: "SYNC_PROJECTS"; projects: Project[] } |
| 30 | | { type: "TOGGLE_PROJECT"; projectId: string } | 30 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 31 | | { type: "DELETE_PROJECT"; projectId: string } | ||
| 31 | | { type: "ADD_THREAD"; thread: Thread } | 32 | | { type: "ADD_THREAD"; thread: Thread } |
| 32 | | { type: "SET_ACTIVE_THREAD"; threadId: string } | 33 | | { type: "SET_ACTIVE_THREAD"; threadId: string } |
| 33 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | 34 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| ... | @@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState { |
| 473 | ), | 474 | ), |
| 474 | }; | 475 | }; |
| 475 | 476 | ||
| 477 | case "DELETE_PROJECT": { | ||
| 478 | const projects = state.projects.filter((project) => project.id !== action.projectId); | ||
| 479 | if (projects.length === state.projects.length) { | ||
| 480 | return state; | ||
| 481 | } | ||
| 482 | |||
| 483 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); | ||
| 484 | const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId) | ||
| 485 | ? state.activeThreadId | ||
| 486 | : (threads[0]?.id ?? null); | ||
| 487 | |||
| 488 | return { | ||
| 489 | ...state, | ||
| 490 | projects, | ||
| 491 | threads, | ||
| 492 | activeThreadId, | ||
| 493 | }; | ||
| 494 | } | ||
| 495 | |||
| 476 | case "ADD_THREAD": { | 496 | case "ADD_THREAD": { |
| 477 | const nextThread = normalizeThreadTerminals({ | 497 | const nextThread = normalizeThreadTerminals({ |
| 478 | ...action.thread, | 498 | ...action.thread, |
apps/web/src/terminal-links.ts+1-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | 1 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 2 | import { isMacPlatform } from "./lib/utils"; | ||
| 2 | 3 | ||
| 3 | export type TerminalLinkKind = "url" | "path"; | 4 | export type TerminalLinkKind = "url" | "path"; |
| 4 | 5 | ||
| ... | @@ -15,10 +16,6 @@ const FILE_PATH_PATTERN = | ... | @@ -15,10 +16,6 @@ const FILE_PATH_PATTERN = |
| 15 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; | 16 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; |
| 16 | const LAST_EDITOR_KEY = "t3code:last-editor"; | 17 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 17 | 18 | ||
| 18 | function isMacPlatform(platform: string): boolean { | ||
| 19 | return /mac|iphone|ipad|ipod/i.test(platform); | ||
| 20 | } | ||
| 21 | |||
| 22 | function trimClosingDelimiters(value: string): string { | 19 | function trimClosingDelimiters(value: string): string { |
| 23 | let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); | 20 | let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); |
| 24 | if (output.length === 0) return output; | 21 | if (output.length === 0) return output; |
apps/web/src/terminal-shortcuts.test.ts deleted-63| ... | @@ -1,63 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | isTerminalClearShortcut, | ||
| 5 | isTerminalToggleShortcut, | ||
| 6 | type ShortcutEventLike, | ||
| 7 | } from "./terminal-shortcuts"; | ||
| 8 | |||
| 9 | function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike { | ||
| 10 | return { | ||
| 11 | key: "j", | ||
| 12 | metaKey: false, | ||
| 13 | ctrlKey: false, | ||
| 14 | shiftKey: false, | ||
| 15 | altKey: false, | ||
| 16 | ...overrides, | ||
| 17 | }; | ||
| 18 | } | ||
| 19 | |||
| 20 | describe("isTerminalToggleShortcut", () => { | ||
| 21 | it("matches Cmd+J on macOS", () => { | ||
| 22 | expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("matches Ctrl+J on non-macOS", () => { | ||
| 26 | expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32")).toBe(true); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("rejects wrong modifiers", () => { | ||
| 30 | expect(isTerminalToggleShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false); | ||
| 31 | expect(isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel")).toBe( | ||
| 32 | false, | ||
| 33 | ); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("rejects non-j keys", () => { | ||
| 37 | expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false); | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | describe("isTerminalClearShortcut", () => { | ||
| 42 | it("matches Ctrl+L on all platforms", () => { | ||
| 43 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux")).toBe(true); | ||
| 44 | expect(isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel")).toBe(true); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("matches Cmd+K on macOS", () => { | ||
| 48 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("rejects Cmd+K on non-macOS", () => { | ||
| 52 | expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32")).toBe(false); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("rejects wrong modifiers", () => { | ||
| 56 | expect( | ||
| 57 | isTerminalClearShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), "Linux"), | ||
| 58 | ).toBe(false); | ||
| 59 | expect( | ||
| 60 | isTerminalClearShortcut(event({ key: "k", metaKey: true, altKey: true }), "MacIntel"), | ||
| 61 | ).toBe(false); | ||
| 62 | }); | ||
| 63 | }); | ||
apps/web/src/terminal-shortcuts.ts deleted-46| ... | @@ -1,46 +0,0 @@ | ||
| 1 | export interface ShortcutEventLike { | ||
| 2 | key: string; | ||
| 3 | metaKey: boolean; | ||
| 4 | ctrlKey: boolean; | ||
| 5 | shiftKey: boolean; | ||
| 6 | altKey: boolean; | ||
| 7 | } | ||
| 8 | |||
| 9 | function isMacPlatform(platform: string): boolean { | ||
| 10 | return /mac|iphone|ipad|ipod/i.test(platform); | ||
| 11 | } | ||
| 12 | |||
| 13 | export function isTerminalToggleShortcut( | ||
| 14 | event: ShortcutEventLike, | ||
| 15 | platform = navigator.platform, | ||
| 16 | ): boolean { | ||
| 17 | const key = event.key.toLowerCase(); | ||
| 18 | if (key !== "j") return false; | ||
| 19 | if (event.shiftKey || event.altKey) return false; | ||
| 20 | |||
| 21 | if (isMacPlatform(platform)) { | ||
| 22 | return event.metaKey && !event.ctrlKey; | ||
| 23 | } | ||
| 24 | |||
| 25 | return event.ctrlKey && !event.metaKey; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function isTerminalClearShortcut( | ||
| 29 | event: ShortcutEventLike, | ||
| 30 | platform = navigator.platform, | ||
| 31 | ): boolean { | ||
| 32 | const key = event.key.toLowerCase(); | ||
| 33 | |||
| 34 | if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { | ||
| 35 | return true; | ||
| 36 | } | ||
| 37 | |||
| 38 | return ( | ||
| 39 | isMacPlatform(platform) && | ||
| 40 | key === "k" && | ||
| 41 | event.metaKey && | ||
| 42 | !event.ctrlKey && | ||
| 43 | !event.altKey && | ||
| 44 | !event.shiftKey | ||
| 45 | ); | ||
| 46 | } | ||
apps/web/src/wsNativeApi.ts+3| ... | @@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi { |
| 139 | return showContextMenuFallback(items, position); | 139 | return showContextMenuFallback(items, position); |
| 140 | }, | 140 | }, |
| 141 | }, | 141 | }, |
| 142 | server: { | ||
| 143 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), | ||
| 144 | }, | ||
| 142 | }; | 145 | }; |
| 143 | 146 | ||
| 144 | instance = { api, transport }; | 147 | instance = { api, transport }; |
packages/contracts/src/index.ts+2| ... | @@ -5,5 +5,7 @@ export * from "./terminal"; | ... | @@ -5,5 +5,7 @@ export * from "./terminal"; |
| 5 | export * from "./provider"; | 5 | export * from "./provider"; |
| 6 | export * from "./model"; | 6 | export * from "./model"; |
| 7 | export * from "./ws"; | 7 | export * from "./ws"; |
| 8 | export * from "./keybindings"; | ||
| 9 | export * from "./server"; | ||
| 8 | export * from "./project"; | 10 | export * from "./project"; |
| 9 | export * from "./git"; | 11 | export * from "./git"; |
packages/contracts/src/ipc.ts+4| ... | @@ -31,6 +31,7 @@ import type { | ... | @@ -31,6 +31,7 @@ import type { |
| 31 | ProjectListResult, | 31 | ProjectListResult, |
| 32 | ProjectRemoveInput, | 32 | ProjectRemoveInput, |
| 33 | } from "./project"; | 33 | } from "./project"; |
| 34 | import type { ServerConfig } from "./server"; | ||
| 34 | import type { | 35 | import type { |
| 35 | TerminalClearInput, | 36 | TerminalClearInput, |
| 36 | TerminalCloseInput, | 37 | TerminalCloseInput, |
| ... | @@ -113,4 +114,7 @@ export interface NativeApi { | ... | @@ -113,4 +114,7 @@ export interface NativeApi { |
| 113 | position?: { x: number; y: number }, | 114 | position?: { x: number; y: number }, |
| 114 | ) => Promise<T | null>; | 115 | ) => Promise<T | null>; |
| 115 | }; | 116 | }; |
| 117 | server: { | ||
| 118 | getConfig: () => Promise<ServerConfig>; | ||
| 119 | }; | ||
| 116 | } | 120 | } |
packages/contracts/src/keybindings.test.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import { assert, describe, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | keybindingRuleSchema, | ||
| 5 | keybindingsConfigSchema, | ||
| 6 | resolvedKeybindingRuleSchema, | ||
| 7 | resolvedKeybindingsConfigSchema, | ||
| 8 | } from "./keybindings"; | ||
| 9 | |||
| 10 | describe("keybindings contracts", () => { | ||
| 11 | it("parses keybinding rules", () => { | ||
| 12 | const parsed = keybindingRuleSchema.parse({ | ||
| 13 | key: "mod+j", | ||
| 14 | command: "terminal.toggle", | ||
| 15 | }); | ||
| 16 | assert.strictEqual(parsed.command, "terminal.toggle"); | ||
| 17 | |||
| 18 | const parsedClose = keybindingRuleSchema.parse({ | ||
| 19 | key: "mod+w", | ||
| 20 | command: "terminal.close", | ||
| 21 | }); | ||
| 22 | assert.strictEqual(parsedClose.command, "terminal.close"); | ||
| 23 | |||
| 24 | const parsedLocal = keybindingRuleSchema.parse({ | ||
| 25 | key: "mod+shift+n", | ||
| 26 | command: "chat.newLocal", | ||
| 27 | }); | ||
| 28 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("rejects invalid command values", () => { | ||
| 32 | assert.throws(() => | ||
| 33 | keybindingRuleSchema.parse({ | ||
| 34 | key: "mod+j", | ||
| 35 | command: "invalid.command", | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("parses keybindings array payload", () => { | ||
| 41 | const parsed = keybindingsConfigSchema.parse([ | ||
| 42 | { key: "mod+j", command: "terminal.toggle" }, | ||
| 43 | { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, | ||
| 44 | ]); | ||
| 45 | assert.lengthOf(parsed, 2); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("parses resolved keybinding rules", () => { | ||
| 49 | const parsed = resolvedKeybindingRuleSchema.parse({ | ||
| 50 | command: "terminal.split", | ||
| 51 | shortcut: { | ||
| 52 | key: "d", | ||
| 53 | metaKey: false, | ||
| 54 | ctrlKey: false, | ||
| 55 | shiftKey: false, | ||
| 56 | altKey: false, | ||
| 57 | modKey: true, | ||
| 58 | }, | ||
| 59 | whenAst: { | ||
| 60 | type: "and", | ||
| 61 | left: { type: "identifier", name: "terminalOpen" }, | ||
| 62 | right: { | ||
| 63 | type: "not", | ||
| 64 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 65 | }, | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | assert.strictEqual(parsed.shortcut.key, "d"); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("parses resolved keybindings arrays", () => { | ||
| 72 | const parsed = resolvedKeybindingsConfigSchema.parse([ | ||
| 73 | { | ||
| 74 | command: "terminal.toggle", | ||
| 75 | shortcut: { | ||
| 76 | key: "j", | ||
| 77 | metaKey: false, | ||
| 78 | ctrlKey: false, | ||
| 79 | shiftKey: false, | ||
| 80 | altKey: false, | ||
| 81 | modKey: true, | ||
| 82 | }, | ||
| 83 | }, | ||
| 84 | ]); | ||
| 85 | assert.lengthOf(parsed, 1); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("rejects unknown fields in resolved keybinding rules", () => { | ||
| 89 | assert.throws(() => | ||
| 90 | resolvedKeybindingRuleSchema.parse({ | ||
| 91 | command: "terminal.toggle", | ||
| 92 | shortcut: { | ||
| 93 | key: "j", | ||
| 94 | metaKey: false, | ||
| 95 | ctrlKey: false, | ||
| 96 | shiftKey: false, | ||
| 97 | altKey: false, | ||
| 98 | modKey: true, | ||
| 99 | }, | ||
| 100 | key: "mod+j", | ||
| 101 | }), | ||
| 102 | ); | ||
| 103 | }); | ||
| 104 | }); | ||
packages/contracts/src/keybindings.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | import { z } from "zod"; | ||
| 2 | |||
| 3 | export const keybindingCommandSchema = z.enum([ | ||
| 4 | "terminal.toggle", | ||
| 5 | "terminal.split", | ||
| 6 | "terminal.new", | ||
| 7 | "terminal.close", | ||
| 8 | "chat.new", | ||
| 9 | "chat.newLocal", | ||
| 10 | "editor.openFavorite", | ||
| 11 | ]); | ||
| 12 | |||
| 13 | const keybindingValueSchema = z.string().trim().min(1).max(64); | ||
| 14 | const keybindingWhenSchema = z.string().trim().min(1).max(256); | ||
| 15 | |||
| 16 | export const keybindingRuleSchema = z.object({ | ||
| 17 | key: keybindingValueSchema, | ||
| 18 | command: keybindingCommandSchema, | ||
| 19 | when: keybindingWhenSchema.optional(), | ||
| 20 | }); | ||
| 21 | |||
| 22 | export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256); | ||
| 23 | |||
| 24 | export const keybindingShortcutSchema = z.object({ | ||
| 25 | key: z.string().trim().min(1).max(32), | ||
| 26 | metaKey: z.boolean(), | ||
| 27 | ctrlKey: z.boolean(), | ||
| 28 | shiftKey: z.boolean(), | ||
| 29 | altKey: z.boolean(), | ||
| 30 | modKey: z.boolean(), | ||
| 31 | }).strict(); | ||
| 32 | |||
| 33 | export type KeybindingWhenNode = | ||
| 34 | | { type: "identifier"; name: string } | ||
| 35 | | { type: "not"; node: KeybindingWhenNode } | ||
| 36 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } | ||
| 37 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; | ||
| 38 | |||
| 39 | export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() => | ||
| 40 | z.union([ | ||
| 41 | z.object({ | ||
| 42 | type: z.literal("identifier"), | ||
| 43 | name: z.string().min(1), | ||
| 44 | }), | ||
| 45 | z.object({ | ||
| 46 | type: z.literal("not"), | ||
| 47 | node: keybindingWhenNodeSchema, | ||
| 48 | }), | ||
| 49 | z.object({ | ||
| 50 | type: z.literal("and"), | ||
| 51 | left: keybindingWhenNodeSchema, | ||
| 52 | right: keybindingWhenNodeSchema, | ||
| 53 | }), | ||
| 54 | z.object({ | ||
| 55 | type: z.literal("or"), | ||
| 56 | left: keybindingWhenNodeSchema, | ||
| 57 | right: keybindingWhenNodeSchema, | ||
| 58 | }), | ||
| 59 | ]), | ||
| 60 | ); | ||
| 61 | |||
| 62 | export const resolvedKeybindingRuleSchema = z.object({ | ||
| 63 | command: keybindingCommandSchema, | ||
| 64 | shortcut: keybindingShortcutSchema, | ||
| 65 | whenAst: keybindingWhenNodeSchema.optional(), | ||
| 66 | }).strict(); | ||
| 67 | |||
| 68 | export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256); | ||
| 69 | |||
| 70 | export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>; | ||
| 71 | export type KeybindingRule = z.infer<typeof keybindingRuleSchema>; | ||
| 72 | export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>; | ||
| 73 | export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>; | ||
| 74 | export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>; | ||
| 75 | export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>; | ||
packages/contracts/src/server.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | import { z } from "zod"; | ||
| 2 | import { resolvedKeybindingsConfigSchema } from "./keybindings"; | ||
| 3 | |||
| 4 | export const serverConfigSchema = z.object({ | ||
| 5 | cwd: z.string().min(1), | ||
| 6 | keybindings: resolvedKeybindingsConfigSchema.default([]), | ||
| 7 | }); | ||
| 8 | |||
| 9 | export type ServerConfig = z.infer<typeof serverConfigSchema>; | ||