authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-16 01:01:13-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-16 01:01:13-08:00
logfe86836b43dbcb49598d8e7a728c7febb9584b3c
treef7266f24c8d482c96db5f97bd334b89b2ed53f64
parent973e62373e5b442ac9c13e0e65f771132c5e66d4
parent73fa2d66c79eb1351fbae55a81c61e1f7b88c619

Merge origin/main and resolve PR status/sidebar conflicts

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
3T3 Code reads keybindings from:
4
5- `~/.t3/keybindings.json`
6
7The 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
16See 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
33For 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
39Each 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
45Invalid 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
59Supported modifiers:
60
61- `mod` (`cmd` on macOS, `ctrl` on non-macOS)
62- `cmd` / `meta`
63- `ctrl` / `control`
64- `shift`
65- `alt` / `option`
66
67Examples:
68
69- `mod+j`
70- `mod+shift+d`
71- `ctrl+l`
72- `cmd+k`
73
74### `when` Conditions
75
76Currently available context keys:
77
78- `terminalFocus`
79- `terminalOpen`
80
81Supported operators:
82
83- `!` (not)
84- `&&` (and)
85- `||` (or)
86- parentheses: `(` `)`
87
88Examples:
89
90- `"when": "terminalFocus"`
91- `"when": "terminalOpen && !terminalFocus"`
92- `"when": "terminalFocus || terminalOpen"`
93
94Unknown 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", () => {
190190
191191 const status = await manager.status({ cwd: repoDir });
192192 expect(status.branch).toBe("feature/status-open-pr");
193 expect(status.openPr).toEqual({
193 expect(status.pr).toEqual({
194194 number: 13,
195195 title: "Existing PR",
196196 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
197197 baseBranch: "main",
198198 headBranch: "feature/status-open-pr",
199 state: "open",
199200 });
200 expect(status.mergedPr).toBeNull();
201 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false);
201 expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true);
202202 });
203203
204204 it("status includes merged PR metadata when there is no open PR", async () => {
......@@ -211,7 +211,6 @@ describe("GitManager", () => {
211211
212212 const { runner, ghCalls } = createRunnerWithFakeGh({
213213 prListSequence: [
214 "[]",
215214 JSON.stringify([
216215 {
217216 number: 26,
......@@ -219,6 +218,9 @@ describe("GitManager", () => {
219218 url: "https://github.com/pingdotgg/codething-mvp/pull/26",
220219 baseRefName: "main",
221220 headRefName: "feature/status-merged-pr",
221 state: "CLOSED",
222 mergedAt: "2026-02-14T10:00:00Z",
223 updatedAt: "2026-02-14T10:00:00Z",
222224 },
223225 ]),
224226 ],
......@@ -231,18 +233,18 @@ describe("GitManager", () => {
231233
232234 const status = await manager.status({ cwd: repoDir });
233235 expect(status.branch).toBe("feature/status-merged-pr");
234 expect(status.openPr).toBeNull();
235 expect(status.mergedPr).toEqual({
236 expect(status.pr).toEqual({
236237 number: 26,
237238 title: "Merged PR",
238239 url: "https://github.com/pingdotgg/codething-mvp/pull/26",
239240 baseBranch: "main",
240241 headBranch: "feature/status-merged-pr",
242 state: "merged",
241243 });
242 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);
244 expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true);
243245 });
244246
245 it("status skips open PR lookup for branches without upstream", async () => {
247 it("status checks all PR states for branches without upstream", async () => {
246248 const repoDir = makeTempDir("t3code-git-manager-");
247249 await initRepo(repoDir);
248250 await runGit(repoDir, ["checkout", "-b", "feature/local-only"]);
......@@ -258,13 +260,11 @@ describe("GitManager", () => {
258260
259261 const status = await manager.status({ cwd: repoDir });
260262 expect(status.branch).toBe("feature/local-only");
261 expect(status.openPr).toBeNull();
262 expect(status.mergedPr).toBeNull();
263 expect(ghCalls.some((call) => call.includes("--state open"))).toBe(false);
264 expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true);
263 expect(status.pr).toBeNull();
264 expect(ghCalls.some((call) => call.includes("--state all"))).toBe(true);
265265 });
266266
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 () => {
268268 const repoDir = makeTempDir("t3code-git-manager-");
269269 await initRepo(repoDir);
270270 await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
......@@ -282,8 +282,7 @@ describe("GitManager", () => {
282282
283283 const status = await manager.status({ cwd: repoDir });
284284 expect(status.branch).toBe("feature/status-no-gh");
285 expect(status.openPr).toBeNull();
286 expect(status.mergedPr).toBeNull();
285 expect(status.pr).toBeNull();
287286 });
288287
289288 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[] {
6767 typeof title !== "string" ||
6868 typeof url !== "string" ||
6969 typeof baseRefName !== "string" ||
70 typeof headRefName !== "string" ||
71 typeof state !== "string"
70 typeof headRefName !== "string"
7271 ) {
7372 continue;
7473 }
......@@ -76,7 +75,7 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] {
7675 let normalizedState: GitStatusPrState;
7776 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
7877 normalizedState = "merged";
79 } else if (state === "OPEN") {
78 } else if (state === "OPEN" || state === undefined || state === null) {
8079 normalizedState = "open";
8180 } else if (state === "CLOSED") {
8281 normalizedState = "closed";
apps/server/src/keybindings.test.ts created+69
......@@ -0,0 +1,69 @@
1import { assert, describe, it } from "vitest";
2
3import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";
4
5describe("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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import type {
6 KeybindingRule,
7 KeybindingsConfig,
8 KeybindingShortcut,
9 KeybindingWhenNode,
10 ResolvedKeybindingRule,
11 ResolvedKeybindingsConfig,
12} from "@t3tools/contracts";
13import { keybindingRuleSchema } from "@t3tools/contracts";
14
15interface KeybindingsLogger {
16 warn(message: string, context?: Record<string, unknown>): void;
17}
18
19type WhenToken =
20 | { type: "identifier"; value: string }
21 | { type: "not" }
22 | { type: "and" }
23 | { type: "or" }
24 | { type: "lparen" }
25 | { type: "rparen" };
26
27export 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
38function normalizeKeyToken(token: string): string {
39 if (token === "space") return " ";
40 if (token === "esc") return "escape";
41 return token;
42}
43
44export 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
108function 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
157export 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
229export 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
252export 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
264function 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
276const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
277
278function 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
297export 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 {
1111 DEFAULT_TERMINAL_ID,
1212 WS_CHANNELS,
1313 WS_METHODS,
14 type KeybindingsConfig,
15 type ResolvedKeybindingsConfig,
1416 type WsPush,
1517 type WsResponse,
1618} from "@t3tools/contracts";
1719import { ProjectRegistry } from "./projectRegistry";
20import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
1821import type {
1922 TerminalClearInput,
2023 TerminalCloseInput,
......@@ -192,6 +195,32 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro
192195 }
193196}
194197
198function 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
210const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
211
212function 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
195224describe("WebSocket Server", () => {
196225 let server: ReturnType<typeof createServer> | null = null;
197226 const connections: WebSocket[] = [];
......@@ -307,7 +336,225 @@ describe("WebSocket Server", () => {
307336
308337 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
309338 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);
311558 });
312559
313560 it("returns error for unknown methods", async () => {
apps/server/src/wsServer.ts+6-1
......@@ -31,6 +31,7 @@ import {
3131 removeGitWorktree,
3232} from "./git";
3333import { TerminalManager } from "./terminalManager";
34import { loadResolvedKeybindingsConfig } from "./keybindings";
3435
3536const MIME_TYPES: Record<string, string> = {
3637 ".html": "text/html; charset=utf-8",
......@@ -90,6 +91,7 @@ export function createServer(options: ServerOptions) {
9091 const logger = createLogger("ws");
9192 const logWebSocketEvents =
9293 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
94 const keybindingsConfig = loadResolvedKeybindingsConfig(logger);
9395
9496 function logOutgoingPush(push: WsPush, recipients: number) {
9597 if (!logWebSocketEvents) return;
......@@ -410,7 +412,10 @@ export function createServer(options: ServerOptions) {
410412 return undefined;
411413
412414 case WS_METHODS.serverGetConfig:
413 return { cwd };
415 return {
416 cwd,
417 keybindings: keybindingsConfig,
418 };
414419
415420 default:
416421 throw new Error(`Unknown method: ${request.method}`);
apps/web/src/components/BranchToolbar.tsx+11-1
......@@ -25,9 +25,15 @@ interface BranchToolbarProps {
2525 envMode: "local" | "worktree";
2626 onEnvModeChange: (mode: "local" | "worktree") => void;
2727 envLocked: boolean;
28 onComposerFocusRequest?: () => void;
2829}
2930
30export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
31export default function BranchToolbar({
32 envMode,
33 onEnvModeChange,
34 envLocked,
35 onComposerFocusRequest,
36}: BranchToolbarProps) {
3137 const { state, dispatch } = useStore();
3238 const api = useNativeApi();
3339 const queryClient = useQueryClient();
......@@ -123,6 +129,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
123129 setThreadError(null);
124130 setThreadBranch(branch.name, null);
125131 setIsBranchMenuOpen(false);
132 onComposerFocusRequest?.();
126133 return;
127134 }
128135
......@@ -135,6 +142,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
135142 // Secondary worktree → point the thread at that worktree path.
136143 setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath);
137144 setIsBranchMenuOpen(false);
145 onComposerFocusRequest?.();
138146 return;
139147 }
140148
......@@ -143,6 +151,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
143151 setThreadError(null);
144152 setThreadBranch(branch.name, activeWorktreePath);
145153 setIsBranchMenuOpen(false);
154 onComposerFocusRequest?.();
146155 },
147156 });
148157 };
......@@ -156,6 +165,7 @@ export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: B
156165 setThreadBranch(name, activeWorktreePath);
157166 setBranchQuery("");
158167 setIsBranchMenuOpen(false);
168 onComposerFocusRequest?.();
159169 },
160170 });
161171 };
apps/web/src/components/ChatMarkdown.tsx+4-1
......@@ -1,3 +1,4 @@
1import { memo } from "react";
12import type { Components } from "react-markdown";
23import ReactMarkdown from "react-markdown";
34import rehypeHighlight from "rehype-highlight";
......@@ -13,7 +14,7 @@ const markdownComponents: Components = {
1314 },
1415};
1516
16export default function ChatMarkdown({ text }: ChatMarkdownProps) {
17function ChatMarkdown({ text }: ChatMarkdownProps) {
1718 return (
1819 <div className="chat-markdown text-sm leading-relaxed text-foreground/80">
1920 <ReactMarkdown
......@@ -26,3 +27,5 @@ export default function ChatMarkdown({ text }: ChatMarkdownProps) {
2627 </div>
2728 );
2829}
30
31export default memo(ChatMarkdown);
apps/web/src/components/ChatView.tsx+585-349
......@@ -1,10 +1,14 @@
11import {
2 EDITORS,
3 type EditorId,
4 ModelSlug,
5 type NativeApi,
26 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
37 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
48 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5 type ProviderSendTurnAttachmentInput,
9 type ResolvedKeybindingsConfig,
610 type ProviderApprovalDecision,
7 ModelSlug,
11 type ProviderSendTurnAttachmentInput,
812} from "@t3tools/contracts";
913import {
1014 type ClipboardEvent,
......@@ -12,6 +16,8 @@ import {
1216 type FormEvent,
1317 Fragment,
1418 type KeyboardEvent,
19 memo,
20 type RefObject,
1521 useCallback,
1622 useEffect,
1723 useLayoutEffect,
......@@ -20,12 +26,9 @@ import {
2026 useState,
2127} from "react";
2228import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
23import {
24 gitBranchesQueryOptions,
25 gitCreateWorktreeMutationOptions,
26} from "~/lib/gitReactQuery";
29import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
30import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
2731
28import { EDITORS, type EditorId } from "@t3tools/contracts";
2932import { isElectron } from "../env";
3033import { buildBootstrapInput } from "../historyBootstrap";
3134import {
......@@ -40,6 +43,7 @@ import {
4043 derivePendingApprovals,
4144 derivePhase,
4245 deriveTimelineEntries,
46 type PendingApproval,
4347 deriveWorkLogEntries,
4448 formatDuration,
4549 formatElapsed,
......@@ -50,7 +54,14 @@ import { useStore } from "../store";
5054import type { ChatImageAttachment } from "../types";
5155import BranchToolbar from "./BranchToolbar";
5256import GitActionsControl from "./GitActionsControl";
53import { isTerminalToggleShortcut } from "../terminal-shortcuts";
57import {
58 isOpenFavoriteEditorShortcut,
59 isTerminalCloseShortcut,
60 isTerminalNewShortcut,
61 isTerminalSplitShortcut,
62 isTerminalToggleShortcut,
63 shortcutLabelForCommand,
64} from "../keybindings";
5465import ChatMarkdown from "./ChatMarkdown";
5566import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
5667import { useNativeApi } from "../hooks/useNativeApi";
......@@ -70,7 +81,7 @@ import { Separator } from "./ui/separator";
7081import { Group, GroupSeparator } from "./ui/group";
7182import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
7283import { CursorIcon, Icon } from "./Icons";
73import { cn } from "~/lib/utils";
84import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
7485import { Badge } from "./ui/badge";
7586
7687function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -83,6 +94,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
8394const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
8495const IMAGE_ONLY_BOOTSTRAP_PROMPT =
8596 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
97const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
8698
8799function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
88100 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
......@@ -130,7 +142,9 @@ export default function ChatView() {
130142 const { state, dispatch } = useStore();
131143 const api = useNativeApi();
132144 const queryClient = useQueryClient();
133 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ api, queryClient }));
145 const createWorktreeMutation = useMutation(
146 gitCreateWorktreeMutationOptions({ api, queryClient }),
147 );
134148 const [prompt, setPrompt] = useState("");
135149 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
136150 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
......@@ -154,6 +168,7 @@ export default function ChatView() {
154168
155169 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
156170 const activeThreadId = activeThread?.id ?? null;
171 const activeSessionId = activeThread?.session?.sessionId;
157172 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
158173 const selectedModel = resolveModelSlug(
159174 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
......@@ -254,14 +269,32 @@ export default function ChatView() {
254269 } as const);
255270 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
256271 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;
257277 // Default true while loading to avoid toolbar flicker.
258278 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 );
259291
260292 const envLocked = Boolean(
261293 activeThread &&
262294 (activeThread.messages.length > 0 ||
263295 (activeThread.session !== null && activeThread.session.status !== "closed")),
264296 );
297
265298 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
266299 for (const image of images) {
267300 if (!image.previewUrl) continue;
......@@ -275,6 +308,11 @@ export default function ChatView() {
275308 const cursor = textarea.value.length;
276309 textarea.setSelectionRange(cursor, cursor);
277310 }, []);
311 const scheduleComposerFocus = useCallback(() => {
312 window.requestAnimationFrame(() => {
313 focusComposer();
314 });
315 }, [focusComposer]);
278316 const toggleTerminalVisibility = useCallback(() => {
279317 if (!activeThreadId) return;
280318 dispatch({
......@@ -346,6 +384,7 @@ export default function ChatView() {
346384 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
347385 if (mode === state.runtimeMode) return;
348386 dispatch({ type: "SET_RUNTIME_MODE", mode });
387 scheduleComposerFocus();
349388 if (!api) return;
350389
351390 const sessionIds = state.threads
......@@ -463,7 +502,7 @@ export default function ChatView() {
463502 if (phase !== "running") return;
464503 const timer = window.setInterval(() => {
465504 setNowTick(Date.now());
466 }, 250);
505 }, 1000);
467506 return () => {
468507 window.clearInterval(timer);
469508 };
......@@ -490,15 +529,74 @@ export default function ChatView() {
490529 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);
491530
492531 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
493539 const handler = (event: globalThis.KeyboardEvent) => {
494 if (!activeThreadId) return;
495 if (!isTerminalToggleShortcut(event)) return;
540 if (!activeThreadId || event.defaultPrevented) 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;
496576 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();
498586 };
499587 window.addEventListener("keydown", handler);
500588 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 ]);
502600
503601 const setThreadError = (threadId: string | null, error: string | null) => {
504602 if (!threadId) return;
......@@ -796,37 +894,58 @@ export default function ChatView() {
796894 });
797895 };
798896
799 const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => {
800 if (!api || !activeThread?.session) return;
897 const onRespondToApproval = useCallback(
898 async (requestId: string, decision: ProviderApprovalDecision) => {
899 if (!api || !activeSessionId || !activeThreadId) return;
801900
802 setRespondingRequestIds((existing) =>
803 existing.includes(requestId) ? existing : [...existing, requestId],
804 );
805 try {
806 await api.providers.respondToRequest({
807 sessionId: activeThread.session.sessionId,
808 requestId,
809 decision,
810 });
811 } catch (err) {
901 setRespondingRequestIds((existing) =>
902 existing.includes(requestId) ? existing : [...existing, requestId],
903 );
904 try {
905 await api.providers.respondToRequest({
906 sessionId: activeSessionId,
907 requestId,
908 decision,
909 });
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;
812926 dispatch({
813 type: "SET_ERROR",
927 type: "SET_THREAD_MODEL",
814928 threadId: activeThread.id,
815 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
929 model: resolveModelSlug(model),
816930 });
817 } finally {
818 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
819 }
820 };
821
822 const onModelSelect = (model: ModelSlug) => {
823 if (!activeThread) return;
824 dispatch({
825 type: "SET_THREAD_MODEL",
826 threadId: activeThread.id,
827 model: resolveModelSlug(model),
828 });
829 };
931 scheduleComposerFocus();
932 },
933 [activeThread, dispatch, scheduleComposerFocus],
934 );
935 const onEffortSelect = useCallback(
936 (effort: ReasoningEffort) => {
937 setSelectedEffort(effort);
938 scheduleComposerFocus();
939 },
940 [scheduleComposerFocus],
941 );
942 const onEnvModeChange = useCallback(
943 (mode: "local" | "worktree") => {
944 setEnvMode(mode);
945 scheduleComposerFocus();
946 },
947 [scheduleComposerFocus],
948 );
830949
831950 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
832951 if (e.key === "Enter" && !e.shiftKey) {
......@@ -834,6 +953,18 @@ export default function ChatView() {
834953 void onSend(e as unknown as FormEvent);
835954 }
836955 };
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]);
837968
838969 // Empty state: no active thread
839970 if (!activeThread) {
......@@ -859,101 +990,24 @@ export default function ChatView() {
859990 <header
860991 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
861992 >
862 <div className="flex items-center gap-3">
863 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>
864 {activeProject && <Badge variant="outline">{activeProject.name}</Badge>}
865 </div>
866 <div className="flex items-center gap-3">
867 {/* Open in editor */}
868 {activeProject && <OpenInPicker />}
869 {/* Git actions */}
870 {activeProject && <GitActionsControl api={api} gitCwd={gitCwd} />}
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>
993 <ChatHeader
994 activeThreadTitle={activeThread.title}
995 activeProjectName={activeProject?.name}
996 keybindings={keybindings}
997 api={api}
998 gitCwd={gitCwd}
999 diffOpen={state.diffOpen}
1000 onToggleDiff={onToggleDiff}
1001 />
8851002 </header>
8861003
8871004 {/* Error banner */}
888 {activeThread.error && (
889 <div className="pt-3 mx-auto max-w-3xl">
890 <Alert variant="error">
891 <CircleAlertIcon />
892 <AlertDescription className="line-clamp-3" title={activeThread.error}>
893 {activeThread.error}
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 )}
1005 <ThreadErrorBanner error={activeThread.error} />
1006 <PendingApprovalsPanel
1007 pendingApprovals={pendingApprovals}
1008 respondingRequestIds={respondingRequestIds}
1009 onRespondToApproval={onRespondToApproval}
1010 />
9571011
9581012 {/* Messages */}
9591013 <div
......@@ -961,198 +1015,19 @@ export default function ChatView() {
9611015 className="min-h-0 flex-1 overflow-y-auto px-5 py-4"
9621016 onScroll={onMessagesScroll}
9631017 >
964 {activeThread.messages.length === 0 && !isWorking ? (
965 <div className="flex h-full items-center justify-center">
966 <p className="text-sm text-muted-foreground/30">
967 Send a message to start the conversation.
968 </p>
969 </div>
970 ) : (
971 <div className="mx-auto max-w-3xl space-y-4">
972 {timelineEntries.map((timelineEntry, index) => {
973 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
974 return null;
975 }
976
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 )}
1018 <MessagesTimeline
1019 hasMessages={activeThread.messages.length > 0}
1020 isWorking={isWorking}
1021 timelineEntries={timelineEntries}
1022 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
1023 completionSummary={completionSummary}
1024 assistantCompletionByItemId={assistantCompletionByItemId}
1025 nowIso={nowIso}
1026 expandedWorkGroups={expandedWorkGroups}
1027 onToggleWorkGroup={onToggleWorkGroup}
1028 onImageExpand={onExpandTimelineImage}
1029 messagesEndRef={messagesEndRef}
1030 />
11561031 </div>
11571032
11581033 {/* Input bar */}
......@@ -1230,7 +1105,7 @@ export default function ChatView() {
12301105 <Separator orientation="vertical" className="mx-0.5 h-4" />
12311106
12321107 {/* Reasoning effort */}
1233 <ReasoningEffortPicker effort={selectedEffort} onEffortChange={setSelectedEffort} />
1108 <ReasoningEffortPicker effort={selectedEffort} onEffortChange={onEffortSelect} />
12341109
12351110 {/* Divider */}
12361111 <Separator orientation="vertical" className="mx-0.5 h-4" />
......@@ -1333,7 +1208,12 @@ export default function ChatView() {
13331208 </div>
13341209
13351210 {isGitRepo && (
1336 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
1211 <BranchToolbar
1212 envMode={envMode}
1213 onEnvModeChange={onEnvModeChange}
1214 envLocked={envLocked}
1215 onComposerFocusRequest={scheduleComposerFocus}
1216 />
13371217 )}
13381218
13391219 {activeThread.terminalOpen && api && activeProject && (
......@@ -1350,6 +1230,9 @@ export default function ChatView() {
13501230 focusRequestId={terminalFocusRequestId}
13511231 onSplitTerminal={splitTerminal}
13521232 onNewTerminal={createNewTerminal}
1233 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
1234 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
1235 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
13531236 onActiveTerminalChange={activateTerminal}
13541237 onCloseTerminal={closeTerminal}
13551238 onHeightChange={(height) =>
......@@ -1400,7 +1283,355 @@ export default function ChatView() {
14001283 );
14011284}
14021285
1403function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug) => void }) {
1286interface 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
1296const 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
1330const 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
1344interface PendingApprovalsPanelProps {
1345 pendingApprovals: PendingApproval[];
1346 respondingRequestIds: string[];
1347 onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>;
1348}
1349
1350const 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
1416interface 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
1430const 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
1631const ModelPicker = memo(function ModelPicker(props: {
1632 model: ModelSlug;
1633 onModelChange: (model: ModelSlug) => void;
1634}) {
14041635 return (
14051636 <Select
14061637 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}
......@@ -1419,9 +1650,9 @@ function ModelPicker(props: { model: ModelSlug; onModelChange: (model: ModelSlug
14191650 </SelectPopup>
14201651 </Select>
14211652 );
1422}
1653});
14231654
1424function ReasoningEffortPicker(props: {
1655const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
14251656 effort: ReasoningEffort;
14261657 onEffortChange: (effort: ReasoningEffort) => void;
14271658}) {
......@@ -1443,9 +1674,13 @@ function ReasoningEffortPicker(props: {
14431674 </SelectPopup>
14441675 </Select>
14451676 );
1446}
1677});
14471678
1448function OpenInPicker() {
1679const OpenInPicker = memo(function OpenInPicker({
1680 keybindings,
1681}: {
1682 keybindings: ResolvedKeybindingsConfig;
1683}) {
14491684 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
14501685 const stored = localStorage.getItem(LAST_EDITOR_KEY);
14511686 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
......@@ -1458,9 +1693,9 @@ function OpenInPicker() {
14581693 value: "cursor",
14591694 },
14601695 {
1461 label: navigator.platform.includes("Mac")
1696 label: isMacPlatform(navigator.platform)
14621697 ? "Finder"
1463 : navigator.platform.includes("Win")
1698 : isWindowsPlatform(navigator.platform)
14641699 ? "Explorer"
14651700 : "Files",
14661701 Icon: FolderClosedIcon,
......@@ -1486,20 +1721,23 @@ function OpenInPicker() {
14861721 [api, activeProject, activeThread, lastEditor, setLastEditor],
14871722 );
14881723
1489 // Cmd+O / Ctrl+O to open in last-used editor
1724 const openFavoriteEditorShortcutLabel = useMemo(
1725 () => shortcutLabelForCommand(keybindings, "editor.openFavorite"),
1726 [keybindings],
1727 );
1728
14901729 useEffect(() => {
14911730 const handler = (e: globalThis.KeyboardEvent) => {
1492 if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
1493 if (api && activeProject) {
1494 e.preventDefault();
1495 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1496 void api.shell.openInEditor(cwd, lastEditor);
1497 }
1498 }
1731 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
1732 if (!api || !activeProject) return;
1733
1734 e.preventDefault();
1735 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
1736 void api.shell.openInEditor(cwd, lastEditor);
14991737 };
15001738 window.addEventListener("keydown", handler);
15011739 return () => window.removeEventListener("keydown", handler);
1502 }, [api, activeProject, activeThread, lastEditor]);
1740 }, [api, activeProject, activeThread, keybindings, lastEditor]);
15031741
15041742 return (
15051743 <Group aria-label="Subscription actions">
......@@ -1517,10 +1755,8 @@ function OpenInPicker() {
15171755 <MenuItem key={value} onClick={() => openInEditor(value)}>
15181756 <Icon aria-hidden="true" className="text-muted-foreground" />
15191757 {label}
1520 {value === lastEditor && (
1521 <MenuShortcut>
1522 {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
1523 </MenuShortcut>
1758 {value === lastEditor && openFavoriteEditorShortcutLabel && (
1759 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
15241760 )}
15251761 </MenuItem>
15261762 ))}
......@@ -1528,4 +1764,4 @@ function OpenInPicker() {
15281764 </Menu>
15291765 </Group>
15301766 );
1531}
1767});
apps/web/src/components/GitActionsControl.logic.test.ts+23-17
......@@ -20,8 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
2020 hasUpstream: true,
2121 aheadCount: 0,
2222 behindCount: 0,
23 openPr: null,
24 mergedPr: null,
23 pr: null,
2524 ...overrides,
2625 };
2726}
......@@ -30,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => {
3029 it("resolveQuickAction opens the existing PR", () => {
3130 const quick = resolveQuickAction(
3231 status({
33 openPr: {
32 pr: {
3433 number: 10,
3534 title: "Open PR",
3635 url: "https://example.com/pr/10",
3736 baseBranch: "main",
3837 headBranch: "feature/test",
38 state: "open",
3939 },
4040 }),
4141 false,
......@@ -46,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => {
4646 it("buildMenuItems disables commit/push and enables open PR", () => {
4747 const items = buildMenuItems(
4848 status({
49 openPr: {
49 pr: {
5050 number: 11,
5151 title: "Existing PR",
5252 url: "https://example.com/pr/11",
5353 baseBranch: "main",
5454 headBranch: "feature/test",
55 state: "open",
5556 },
5657 }),
5758 false,
......@@ -148,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
148149 const quick = resolveQuickAction(
149150 status({
150151 aheadCount: 3,
151 openPr: {
152 pr: {
152153 number: 13,
153154 title: "Open PR",
154155 url: "https://example.com/pr/13",
155156 baseBranch: "main",
156157 headBranch: "feature/test",
158 state: "open",
157159 },
158160 }),
159161 false,
......@@ -165,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
165167 const items = buildMenuItems(
166168 status({
167169 aheadCount: 2,
168 openPr: {
170 pr: {
169171 number: 12,
170172 title: "Existing PR",
171173 url: "https://example.com/pr/12",
172174 baseBranch: "main",
173175 headBranch: "feature/test",
176 state: "open",
174177 },
175178 }),
176179 false,
......@@ -205,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
205208
206209describe("when: branch is clean, ahead, and has no open PR", () => {
207210 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);
209212 assert.deepInclude(quick, {
210213 kind: "run_action",
211214 action: "commit_push_pr",
......@@ -214,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
214217 });
215218
216219 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);
218221 assert.deepEqual(items, [
219222 {
220223 id: "commit",
......@@ -247,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
247250describe("when: branch is clean, up to date, and has no open PR", () => {
248251 it("resolveQuickAction returns disabled no-action state", () => {
249252 const quick = resolveQuickAction(
250 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }),
253 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
251254 false,
252255 );
253256 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
254257 });
255258
256259 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);
258261 assert.deepEqual(items, [
259262 {
260263 id: "commit",
......@@ -291,7 +294,7 @@ describe("when: branch is behind upstream", () => {
291294 });
292295
293296 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);
295298 assert.deepEqual(items, [
296299 {
297300 id: "commit",
......@@ -347,12 +350,13 @@ describe("when: working tree has local changes", () => {
347350 const quick = resolveQuickAction(
348351 status({
349352 hasWorkingTreeChanges: true,
350 openPr: {
353 pr: {
351354 number: 16,
352355 title: "Existing PR",
353356 url: "https://example.com/pr/16",
354357 baseBranch: "main",
355358 headBranch: "feature/test",
359 state: "open",
356360 },
357361 }),
358362 false,
......@@ -412,7 +416,7 @@ describe("when: on default branch without open PR", () => {
412416
413417 it("resolveQuickAction returns push when branch is ahead", () => {
414418 const quick = resolveQuickAction(
415 status({ branch: "main", aheadCount: 2, openPr: null }),
419 status({ branch: "main", aheadCount: 2, pr: null }),
416420 false,
417421 true,
418422 );
......@@ -512,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
512516describe("when: branch has no upstream configured", () => {
513517 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {
514518 const quick = resolveQuickAction(
515 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),
519 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
516520 false,
517521 );
518522 assert.deepInclude(quick, {
......@@ -527,12 +531,13 @@ describe("when: branch has no upstream configured", () => {
527531 status({
528532 hasUpstream: false,
529533 aheadCount: 0,
530 openPr: {
534 pr: {
531535 number: 14,
532536 title: "Existing PR",
533537 url: "https://example.com/pr/14",
534538 baseBranch: "main",
535539 headBranch: "feature/test",
540 state: "open",
536541 },
537542 }),
538543 false,
......@@ -549,12 +554,13 @@ describe("when: branch has no upstream configured", () => {
549554 status({
550555 hasUpstream: false,
551556 aheadCount: 1,
552 openPr: {
557 pr: {
553558 number: 15,
554559 title: "Existing PR",
555560 url: "https://example.com/pr/15",
556561 baseBranch: "main",
557562 headBranch: "feature/test",
563 state: "open",
558564 },
559565 }),
560566 false,
......@@ -569,7 +575,7 @@ describe("when: branch has no upstream configured", () => {
569575
570576 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
571577 const items = buildMenuItems(
572 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),
578 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
573579 false,
574580 );
575581 assert.deepEqual(items, [
apps/web/src/components/GitActionsControl.logic.ts+2-2
......@@ -108,7 +108,7 @@ export function buildMenuItems(
108108
109109 const hasBranch = gitStatus.branch !== null;
110110 const hasChanges = gitStatus.hasWorkingTreeChanges;
111 const hasOpenPr = gitStatus.openPr !== null;
111 const hasOpenPr = gitStatus.pr?.state === "open";
112112 const isBehind = gitStatus.behindCount > 0;
113113 const canCommit = !isBusy && hasChanges;
114114 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;
......@@ -178,7 +178,7 @@ export function resolveQuickAction(
178178
179179 const hasBranch = gitStatus.branch !== null;
180180 const hasChanges = gitStatus.hasWorkingTreeChanges;
181 const hasOpenPr = gitStatus.openPr !== null;
181 const hasOpenPr = gitStatus.pr?.state === "open";
182182 const isAhead = gitStatus.aheadCount > 0;
183183 const isBehind = gitStatus.behindCount > 0;
184184 const isDiverged = isAhead && isBehind;
apps/web/src/components/GitActionsControl.tsx+11-6
......@@ -57,7 +57,7 @@ function getMenuActionDisabledReason(
5757
5858 const hasBranch = gitStatus.branch !== null;
5959 const hasChanges = gitStatus.hasWorkingTreeChanges;
60 const hasOpenPr = gitStatus.openPr !== null;
60 const hasOpenPr = gitStatus.pr?.state === "open";
6161 const isAhead = gitStatus.aheadCount > 0;
6262 const isBehind = gitStatus.behindCount > 0;
6363
......@@ -217,7 +217,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
217217 });
218218 return;
219219 }
220 const prUrl = gitStatusForActions?.openPr?.url ?? null;
220 const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null;
221221 if (!prUrl) {
222222 toastManager.add({
223223 type: "error",
......@@ -237,7 +237,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
237237 }),
238238 });
239239 void promise.catch(() => undefined);
240 }, [api, gitStatusForActions?.openPr?.url, threadToastData]);
240 }, [api, gitStatusForActions?.pr, threadToastData]);
241241
242242 const runGitActionWithToast = useCallback(
243243 async ({
......@@ -297,7 +297,9 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
297297 stopProgressUpdates();
298298 const resultToast = summarizeGitResult(result);
299299
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;
301303 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
302304 const shouldOfferOpenPrCta =
303305 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;
......@@ -312,7 +314,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
312314 title: resultToast.title,
313315 description: resultToast.description,
314316 timeout: 0,
315 data: threadToastData,
317 data: {
318 ...threadToastData,
319 dismissAfterVisibleMs: 10_000,
320 },
316321 ...(shouldOfferPushCta
317322 ? {
318323 actionProps: {
......@@ -362,7 +367,7 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
362367 api,
363368 gitStatusForActions?.branch,
364369 gitStatusForActions?.hasWorkingTreeChanges,
365 gitStatusForActions?.openPr?.url,
370 gitStatusForActions?.pr,
366371 isDefaultBranch,
367372 maybeConfirmPushToDefaultBranch,
368373 runImmediateGitActionMutation,
apps/web/src/components/Sidebar.tsx+140-32
......@@ -1,12 +1,20 @@
1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
1import {
2 GitPullRequestIcon,
3 MonitorIcon,
4 MoonIcon,
5 SunIcon,
6 TerminalIcon,
7} from "lucide-react";
28import { useCallback, useEffect, useMemo, useState } from "react";
3import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
9import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
10import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
411import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts";
512import { isElectron } from "../env";
613import { useTheme } from "../hooks/useTheme";
714import { DEFAULT_MODEL } from "../model-logic";
815import { derivePendingApprovals } from "../session-logic";
916import { useStore } from "../store";
17import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
1018import {
1119 DEFAULT_THREAD_TERMINAL_HEIGHT,
1220 DEFAULT_THREAD_TERMINAL_ID,
......@@ -15,9 +23,13 @@ import {
1523} from "../types";
1624import { useNativeApi } from "../hooks/useNativeApi";
1725import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
26import { serverConfigQueryOptions } from "../lib/serverReactQuery";
27import { toastManager } from "./ui/toast";
1828import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1929
2030const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
31const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
32
2133function formatRelativeTime(iso: string): string {
2234 const diff = Date.now() - new Date(iso).getTime();
2335 const minutes = Math.floor(diff / 60_000);
......@@ -36,7 +48,7 @@ function inferProjectName(cwd: string): string {
3648}
3749
3850interface ThreadStatusPill {
39 label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged";
51 label: "Working" | "Connecting" | "Completed" | "Awaiting response";
4052 colorClass: string;
4153 dotClass: string;
4254 pulse: boolean;
......@@ -48,6 +60,13 @@ interface TerminalStatusIndicator {
4860 pulse: boolean;
4961}
5062
63interface PrStatusIndicator {
64 label: "PR open" | "PR closed" | "PR merged";
65 colorClass: string;
66}
67
68type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null;
69
5170function hasUnseenCompletion(thread: Thread): boolean {
5271 if (!thread.latestTurnCompletedAt) return false;
5372 const completedAt = Date.parse(thread.latestTurnCompletedAt);
......@@ -62,7 +81,6 @@ function hasUnseenCompletion(thread: Thread): boolean {
6281function threadStatusPill(
6382 thread: Thread,
6483 hasPendingApprovals: boolean,
65 hasMergedPr: boolean,
6684): ThreadStatusPill | null {
6785 if (hasPendingApprovals) {
6886 return {
......@@ -91,15 +109,6 @@ function threadStatusPill(
91109 };
92110 }
93111
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
103112 if (hasUnseenCompletion(thread)) {
104113 return {
105114 label: "Completed",
......@@ -112,6 +121,28 @@ function threadStatusPill(
112121 return null;
113122}
114123
124function 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
115146function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null {
116147 if (thread.runningTerminalIds.length === 0) {
117148 return null;
......@@ -126,8 +157,14 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
126157export default function Sidebar() {
127158 const { state, dispatch } = useStore();
128159 const api = useNativeApi();
160 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
161 ...serverConfigQueryOptions(api),
162 select: (config) => config.keybindings,
163 });
129164 const queryClient = useQueryClient();
130 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ api, queryClient }));
165 const removeWorktreeMutation = useMutation(
166 gitRemoveWorktreeMutationOptions({ api, queryClient }),
167 );
131168 const { theme, setTheme } = useTheme();
132169 const [addingProject, setAddingProject] = useState(false);
133170 const [newCwd, setNewCwd] = useState("");
......@@ -176,7 +213,7 @@ export default function Sidebar() {
176213 };
177214 }),
178215 });
179 const mergedPrByThreadId = useMemo(() => {
216 const prStateByThreadId = useMemo(() => {
180217 const statusByCwd = new Map<string, GitStatusResult>();
181218 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
182219 const cwd = threadGitStatusCwds[index];
......@@ -187,18 +224,24 @@ export default function Sidebar() {
187224 }
188225 }
189226
190 const map = new Map<string, boolean>();
227 const map = new Map<string, ThreadPrState>();
191228 for (const target of threadGitTargets) {
192229 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
193230 const branchMatches =
194231 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);
196233 }
197234 return map;
198235 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
199236
200237 const handleNewThread = useCallback(
201 (projectId: string) => {
238 (
239 projectId: string,
240 options?: {
241 branch?: string | null;
242 worktreePath?: string | null;
243 },
244 ) => {
202245 dispatch({
203246 type: "ADD_THREAD",
204247 thread: {
......@@ -224,8 +267,8 @@ export default function Sidebar() {
224267 events: [],
225268 error: null,
226269 createdAt: new Date().toISOString(),
227 branch: null,
228 worktreePath: null,
270 branch: options?.branch ?? null,
271 worktreePath: options?.worktreePath ?? null,
229272 },
230273 });
231274 },
......@@ -392,29 +435,77 @@ export default function Sidebar() {
392435 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
393436 );
394437
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
395483 useEffect(() => {
396484 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
405485 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;
406495 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
407496 if (!projectId) return;
408
409497 event.preventDefault();
410 handleNewThread(projectId);
498 handleNewThread(projectId, {
499 branch: activeThread?.branch ?? null,
500 worktreePath: activeThread?.worktreePath ?? null,
501 });
411502 };
412503
413504 window.addEventListener("keydown", onWindowKeyDown);
414505 return () => {
415506 window.removeEventListener("keydown", onWindowKeyDown);
416507 };
417 }, [handleNewThread, state.activeThreadId, state.projects, state.threads]);
508 }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
418509
419510 return (
420511 <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() {
478569 type="button"
479570 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent"
480571 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 }}
481579 >
482580 <span className="text-[10px] text-muted-foreground/70">
483581 {project.expanded ? "â–¼" : "â–¶"}
......@@ -496,9 +594,9 @@ export default function Sidebar() {
496594 const threadStatus = threadStatusPill(
497595 thread,
498596 pendingApprovalByThreadId.get(thread.id) === true,
499 mergedPrByThreadId.get(thread.id) === true,
500597 );
501598 const terminalStatus = terminalStatusIndicator(thread);
599 const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null);
502600 return (
503601 <button
504602 key={thread.id}
......@@ -538,6 +636,16 @@ export default function Sidebar() {
538636 <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
539637 </div>
540638 <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 )}
541649 {terminalStatus && (
542650 <span
543651 role="img"
apps/web/src/components/ThreadTerminalDrawer.tsx+65-36
......@@ -1,5 +1,5 @@
11import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
33import { type NativeApi } from "@t3tools/contracts";
44import { Terminal, type ITheme } from "@xterm/xterm";
55import {
......@@ -18,7 +18,7 @@ import {
1818 preferredTerminalEditor,
1919 resolvePathLinkTarget,
2020} from "../terminal-links";
21import { isTerminalClearShortcut } from "../terminal-shortcuts";
21import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
2222import {
2323 DEFAULT_THREAD_TERMINAL_HEIGHT,
2424 DEFAULT_THREAD_TERMINAL_ID,
......@@ -152,24 +152,29 @@ function TerminalViewport({
152152 terminalRef.current = terminal;
153153 fitAddonRef.current = fitAddon;
154154
155 const sendClearShortcut = async () => {
155 const sendTerminalInput = async (data: string, fallbackError: string) => {
156156 const activeTerminal = terminalRef.current;
157157 if (!activeTerminal) return;
158158 try {
159 await api.terminal.write({ threadId, terminalId, data: "\u000c" });
159 await api.terminal.write({ threadId, terminalId, data });
160160 } catch (error) {
161 writeSystemMessage(
162 activeTerminal,
163 error instanceof Error ? error.message : "Failed to clear terminal",
164 );
161 writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError);
165162 }
166163 };
167164
168165 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
169174 if (!isTerminalClearShortcut(event)) return true;
170175 event.preventDefault();
171176 event.stopPropagation();
172 void sendClearShortcut();
177 void sendTerminalInput("\u000c", "Failed to clear terminal");
173178 return false;
174179 });
175180
......@@ -416,6 +421,9 @@ interface ThreadTerminalDrawerProps {
416421 focusRequestId: number;
417422 onSplitTerminal: () => void;
418423 onNewTerminal: () => void;
424 splitShortcutLabel?: string | undefined;
425 newShortcutLabel?: string | undefined;
426 closeShortcutLabel?: string | undefined;
419427 onActiveTerminalChange: (terminalId: string) => void;
420428 onCloseTerminal: (terminalId: string) => void;
421429 onHeightChange: (height: number) => void;
......@@ -431,15 +439,9 @@ interface TerminalActionButtonProps {
431439function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) {
432440 return (
433441 <Popover>
434 <PopoverTrigger openOnHover
435 render={
436 <button
437 type="button"
438 className={className}
439 onClick={onClick}
440 aria-label={label}
441 />
442 }
442 <PopoverTrigger
443 openOnHover
444 render={<button type="button" className={className} onClick={onClick} aria-label={label} />}
443445 >
444446 {children}
445447 </PopoverTrigger>
......@@ -468,6 +470,9 @@ export default function ThreadTerminalDrawer({
468470 focusRequestId,
469471 onSplitTerminal,
470472 onNewTerminal,
473 splitShortcutLabel,
474 newShortcutLabel,
475 closeShortcutLabel,
471476 onActiveTerminalChange,
472477 onCloseTerminal,
473478 onHeightChange,
......@@ -583,6 +588,9 @@ export default function ThreadTerminalDrawer({
583588 ),
584589 [normalizedTerminalIds],
585590 );
591 const closeTerminalActionLabel = closeShortcutLabel
592 ? `Close Terminal (${closeShortcutLabel})`
593 : "Close Terminal";
586594
587595 useEffect(() => {
588596 onHeightChangeRef.current = onHeightChange;
......@@ -694,7 +702,9 @@ export default function ThreadTerminalDrawer({
694702 <TerminalActionButton
695703 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
696704 onClick={onSplitTerminal}
697 label="Split Terminal"
705 label={
706 splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"
707 }
698708 >
699709 <SquareSplitHorizontal className="size-3.25" />
700710 </TerminalActionButton>
......@@ -702,7 +712,7 @@ export default function ThreadTerminalDrawer({
702712 <TerminalActionButton
703713 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
704714 onClick={onNewTerminal}
705 label="New Terminal"
715 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
706716 >
707717 <Plus className="size-3.25" />
708718 </TerminalActionButton>
......@@ -710,7 +720,7 @@ export default function ThreadTerminalDrawer({
710720 <TerminalActionButton
711721 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
712722 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
713 label="Close Terminal"
723 label={closeTerminalActionLabel}
714724 >
715725 <Trash2 className="size-3.25" />
716726 </TerminalActionButton>
......@@ -779,21 +789,25 @@ export default function ThreadTerminalDrawer({
779789 <TerminalActionButton
780790 className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"
781791 onClick={onSplitTerminal}
782 label="Split Terminal"
792 label={
793 splitShortcutLabel
794 ? `Split Terminal (${splitShortcutLabel})`
795 : "Split Terminal"
796 }
783797 >
784798 <SquareSplitHorizontal className="size-3.25" />
785799 </TerminalActionButton>
786800 <TerminalActionButton
787801 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
788802 onClick={onNewTerminal}
789 label="New Terminal"
803 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
790804 >
791805 <Plus className="size-3.25" />
792806 </TerminalActionButton>
793807 <TerminalActionButton
794808 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
795809 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
796 label="Close Terminal"
810 label={closeTerminalActionLabel}
797811 >
798812 <Trash2 className="size-3.25" />
799813 </TerminalActionButton>
......@@ -831,6 +845,9 @@ export default function ThreadTerminalDrawer({
831845 >
832846 {terminalGroup.terminalIds.map((terminalId) => {
833847 const isActive = terminalId === resolvedActiveTerminalId;
848 const closeTerminalLabel = `Close ${
849 terminalLabelById.get(terminalId) ?? "terminal"
850 }${isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""}`;
834851 return (
835852 <div
836853 key={terminalId}
......@@ -854,18 +871,30 @@ export default function ThreadTerminalDrawer({
854871 </span>
855872 </button>
856873 {normalizedTerminalIds.length > 1 && (
857 <button
858 type="button"
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"
860 onClick={(event) => {
861 event.stopPropagation();
862 onCloseTerminal(terminalId);
863 }}
864 aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
865 title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
866 >
867 ×
868 </button>
874 <Popover>
875 <PopoverTrigger
876 openOnHover
877 render={
878 <button
879 type="button"
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"
881 onClick={() => onCloseTerminal(terminalId)}
882 aria-label={closeTerminalLabel}
883 />
884 }
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>
869898 )}
870899 </div>
871900 );
apps/web/src/components/ui/toast.logic.test.ts created+16
......@@ -0,0 +1,16 @@
1import { assert, describe, it } from "vitest";
2import { shouldHideCollapsedToastContent } from "./toast.logic";
3
4describe("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 @@
1export 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 @@
11"use client";
22
33import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";
45import {
56 CircleAlertIcon,
67 CircleCheckIcon,
......@@ -12,14 +13,18 @@ import {
1213import { cn } from "~/lib/utils";
1314import { buttonVariants } from "~/components/ui/button";
1415import { useStore } from "~/store";
16import { shouldHideCollapsedToastContent } from "./toast.logic";
1517
1618type ThreadToastData = {
1719 threadId?: string | null;
1820 tooltipStyle?: boolean;
21 dismissAfterVisibleMs?: number;
1922};
2023
2124const toastManager = Toast.createToastManager<ThreadToastData>();
2225const anchoredToastManager = Toast.createToastManager<ThreadToastData>();
26type ToastId = ReturnType<typeof toastManager.add>;
27const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>();
2328
2429const TOAST_ICONS = {
2530 error: CircleAlertIcon,
......@@ -50,6 +55,84 @@ function shouldRenderForActiveThread(
5055 return toastThreadId === activeThreadId;
5156}
5257
58function 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
53136function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) {
54137 return (
55138 <Toast.Provider toastManager={toastManager} {...props}>
......@@ -64,6 +147,18 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
64147 const { toasts } = Toast.useToastManager<ThreadToastData>();
65148 const activeThreadId = state.activeThreadId;
66149 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]);
67162
68163 return (
69164 <Toast.Portal data-slot="toast-portal">
......@@ -81,96 +176,107 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
81176 data-position={position}
82177 data-slot="toast-viewport"
83178 >
84 {toasts
85 .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId))
86 .map((toast) => {
87 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
179 {visibleToasts.map((toast, visibleIndex) => {
180 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
181 const hideCollapsedContent = shouldHideCollapsedToastContent(
182 visibleIndex,
183 visibleToasts.length,
184 );
88185
89 return (
90 <Toast.Root
186 return (
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
91247 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%)]",
93 // Base positioning using data-position
94 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
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)))]",
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",
249 hideCollapsedContent &&
250 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
131251 )}
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}
142252 >
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">
144 <div className="flex gap-2">
145 {Icon && (
146 <div
147 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
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"
253 <div className="flex gap-2">
254 {Icon && (
255 <div
256 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
257 data-slot="toast-icon"
166258 >
167 {toast.actionProps.children}
168 </Toast.Action>
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" />
260 </div>
169261 )}
170 </Toast.Content>
171 </Toast.Root>
172 );
173 })}
262
263 <div className="flex flex-col gap-0.5">
264 <Toast.Title className="font-medium" data-slot="toast-title" />
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 })}
174280 </Toast.Viewport>
175281 </Toast.Portal>
176282 );
apps/web/src/keybindings.test.ts created+444
......@@ -0,0 +1,444 @@
1import { assert, describe, it } from "vitest";
2
3import {
4 type KeybindingCommand,
5 type KeybindingShortcut,
6 type KeybindingWhenNode,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9import {
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
24function 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
35function 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
50function whenIdentifier(name: string): KeybindingWhenNode {
51 return { type: "identifier", name };
52}
53
54function whenNot(node: KeybindingWhenNode): KeybindingWhenNode {
55 return { type: "not", node };
56}
57
58function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): KeybindingWhenNode {
59 return { type: "and", left, right };
60}
61
62interface TestBinding {
63 shortcut: KeybindingShortcut;
64 command: KeybindingCommand;
65 whenAst?: KeybindingWhenNode;
66}
67
68function 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
76const 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
98describe("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
112describe("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
208describe("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
234describe("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
283describe("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
345describe("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
360describe("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
375describe("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
430describe("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 @@
1import {
2 type KeybindingCommand,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";
7import { isMacPlatform } from "./lib/utils";
8
9export interface ShortcutEventLike {
10 type?: string;
11 key: string;
12 metaKey: boolean;
13 ctrlKey: boolean;
14 shiftKey: boolean;
15 altKey: boolean;
16}
17
18export interface ShortcutMatchContext {
19 terminalFocus: boolean;
20 terminalOpen: boolean;
21 [key: string]: boolean;
22}
23
24interface ShortcutMatchOptions {
25 platform?: string;
26 context?: Partial<ShortcutMatchContext>;
27}
28
29const TERMINAL_WORD_BACKWARD = "\u001bb";
30const TERMINAL_WORD_FORWARD = "\u001bf";
31const TERMINAL_LINE_START = "\u0001";
32const TERMINAL_LINE_END = "\u0005";
33
34function normalizeEventKey(key: string): string {
35 const normalized = key.toLowerCase();
36 if (normalized === "esc") return "escape";
37 return normalized;
38}
39
40function 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
59function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
60 return options?.platform ?? navigator.platform;
61}
62
63function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
64 return {
65 terminalFocus: false,
66 terminalOpen: false,
67 ...options?.context,
68 };
69}
70
71function 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
86function matchesWhenClause(
87 whenAst: KeybindingWhenNode | undefined,
88 context: ShortcutMatchContext,
89): boolean {
90 if (!whenAst) return true;
91 return evaluateWhenNode(whenAst, context);
92}
93
94function matchesCommandShortcut(
95 event: ShortcutEventLike,
96 keybindings: ResolvedKeybindingsConfig,
97 command: KeybindingCommand,
98 options?: ShortcutMatchOptions,
99): boolean {
100 return resolveShortcutCommand(event, keybindings, options) === command;
101}
102
103function 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
121function 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
132export 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
156export 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
169export function isTerminalToggleShortcut(
170 event: ShortcutEventLike,
171 keybindings: ResolvedKeybindingsConfig,
172 options?: ShortcutMatchOptions,
173): boolean {
174 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
175}
176
177export function isTerminalSplitShortcut(
178 event: ShortcutEventLike,
179 keybindings: ResolvedKeybindingsConfig,
180 options?: ShortcutMatchOptions,
181): boolean {
182 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
183}
184
185export function isTerminalNewShortcut(
186 event: ShortcutEventLike,
187 keybindings: ResolvedKeybindingsConfig,
188 options?: ShortcutMatchOptions,
189): boolean {
190 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
191}
192
193export function isTerminalCloseShortcut(
194 event: ShortcutEventLike,
195 keybindings: ResolvedKeybindingsConfig,
196 options?: ShortcutMatchOptions,
197): boolean {
198 return matchesCommandShortcut(event, keybindings, "terminal.close", options);
199}
200
201export function isChatNewShortcut(
202 event: ShortcutEventLike,
203 keybindings: ResolvedKeybindingsConfig,
204 options?: ShortcutMatchOptions,
205): boolean {
206 return matchesCommandShortcut(event, keybindings, "chat.new", options);
207}
208
209export function isChatNewLocalShortcut(
210 event: ShortcutEventLike,
211 keybindings: ResolvedKeybindingsConfig,
212 options?: ShortcutMatchOptions,
213): boolean {
214 return matchesCommandShortcut(event, keybindings, "chat.newLocal", options);
215}
216
217export function isOpenFavoriteEditorShortcut(
218 event: ShortcutEventLike,
219 keybindings: ResolvedKeybindingsConfig,
220 options?: ShortcutMatchOptions,
221): boolean {
222 return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options);
223}
224
225export 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
249export 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 @@
1import type { NativeApi } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3
4export const serverQueryKeys = {
5 all: ["server"] as const,
6 config: () => ["server", "config"] as const,
7};
8
9export 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 @@
1import { assert, describe, it } from "vitest";
2
3import { isWindowsPlatform } from "./utils";
4
5describe("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";
44export function cn(...inputs: CxOptions) {
55 return twMerge(cx(inputs));
66}
7
8export function isMacPlatform(platform: string): boolean {
9 return /mac|iphone|ipad|ipod/i.test(platform);
10}
11
12export 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", () => {
486486 expect(next.activeThreadId).toBe("thread-a");
487487 });
488488
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
489534 it("marks the active thread as visited when selected", () => {
490535 const state = makeState(
491536 makeThread({
apps/web/src/store.ts+20
......@@ -28,6 +28,7 @@ type Action =
2828 | { type: "ADD_PROJECT"; project: Project }
2929 | { type: "SYNC_PROJECTS"; projects: Project[] }
3030 | { type: "TOGGLE_PROJECT"; projectId: string }
31 | { type: "DELETE_PROJECT"; projectId: string }
3132 | { type: "ADD_THREAD"; thread: Thread }
3233 | { type: "SET_ACTIVE_THREAD"; threadId: string }
3334 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
......@@ -473,6 +474,25 @@ export function reducer(state: AppState, action: Action): AppState {
473474 ),
474475 };
475476
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
476496 case "ADD_THREAD": {
477497 const nextThread = normalizeThreadTerminals({
478498 ...action.thread,
apps/web/src/terminal-links.ts+1-4
......@@ -1,4 +1,5 @@
11import { EDITORS, type EditorId } from "@t3tools/contracts";
2import { isMacPlatform } from "./lib/utils";
23
34export type TerminalLinkKind = "url" | "path";
45
......@@ -15,10 +16,6 @@ const FILE_PATH_PATTERN =
1516const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
1617const LAST_EDITOR_KEY = "t3code:last-editor";
1718
18function isMacPlatform(platform: string): boolean {
19 return /mac|iphone|ipad|ipod/i.test(platform);
20}
21
2219function trimClosingDelimiters(value: string): string {
2320 let output = value.replace(TRAILING_PUNCTUATION_PATTERN, "");
2421 if (output.length === 0) return output;
apps/web/src/terminal-shortcuts.test.ts deleted-63
......@@ -1,63 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 isTerminalClearShortcut,
5 isTerminalToggleShortcut,
6 type ShortcutEventLike,
7} from "./terminal-shortcuts";
8
9function 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
20describe("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
41describe("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 @@
1export interface ShortcutEventLike {
2 key: string;
3 metaKey: boolean;
4 ctrlKey: boolean;
5 shiftKey: boolean;
6 altKey: boolean;
7}
8
9function isMacPlatform(platform: string): boolean {
10 return /mac|iphone|ipad|ipod/i.test(platform);
11}
12
13export 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
28export 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 {
139139 return showContextMenuFallback(items, position);
140140 },
141141 },
142 server: {
143 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
144 },
142145 };
143146
144147 instance = { api, transport };
packages/contracts/src/index.ts+2
......@@ -5,5 +5,7 @@ export * from "./terminal";
55export * from "./provider";
66export * from "./model";
77export * from "./ws";
8export * from "./keybindings";
9export * from "./server";
810export * from "./project";
911export * from "./git";
packages/contracts/src/ipc.ts+4
......@@ -31,6 +31,7 @@ import type {
3131 ProjectListResult,
3232 ProjectRemoveInput,
3333} from "./project";
34import type { ServerConfig } from "./server";
3435import type {
3536 TerminalClearInput,
3637 TerminalCloseInput,
......@@ -113,4 +114,7 @@ export interface NativeApi {
113114 position?: { x: number; y: number },
114115 ) => Promise<T | null>;
115116 };
117 server: {
118 getConfig: () => Promise<ServerConfig>;
119 };
116120}
packages/contracts/src/keybindings.test.ts created+104
......@@ -0,0 +1,104 @@
1import { assert, describe, it } from "vitest";
2
3import {
4 keybindingRuleSchema,
5 keybindingsConfigSchema,
6 resolvedKeybindingRuleSchema,
7 resolvedKeybindingsConfigSchema,
8} from "./keybindings";
9
10describe("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 @@
1import { z } from "zod";
2
3export 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
13const keybindingValueSchema = z.string().trim().min(1).max(64);
14const keybindingWhenSchema = z.string().trim().min(1).max(256);
15
16export const keybindingRuleSchema = z.object({
17 key: keybindingValueSchema,
18 command: keybindingCommandSchema,
19 when: keybindingWhenSchema.optional(),
20});
21
22export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256);
23
24export 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
33export 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
39export 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
62export const resolvedKeybindingRuleSchema = z.object({
63 command: keybindingCommandSchema,
64 shortcut: keybindingShortcutSchema,
65 whenAst: keybindingWhenNodeSchema.optional(),
66}).strict();
67
68export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
69
70export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>;
71export type KeybindingRule = z.infer<typeof keybindingRuleSchema>;
72export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>;
73export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>;
74export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>;
75export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>;
packages/contracts/src/server.ts created+9
......@@ -0,0 +1,9 @@
1import { z } from "zod";
2import { resolvedKeybindingsConfigSchema } from "./keybindings";
3
4export const serverConfigSchema = z.object({
5 cwd: z.string().min(1),
6 keybindings: resolvedKeybindingsConfigSchema.default([]),
7});
8
9export type ServerConfig = z.infer<typeof serverConfigSchema>;