authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-16 00:30:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 00:59:06-08:00
log648a6ac372bb32e7f6a35b73eb23e444f3c99ef3
treeb6098dfb978a188fe0e4b29f1035ac3994c2f4b6
parent3537d477027565cd0d2c1661ff2e41d8f20ea166

Support script editing and persist project keybindings

- add server `upsertKeybinding` flow to write `~/.t3/keybindings.json` and refresh cached config - wire project script create/edit to save optional keybindings via the WS API - improve script action UI with edit controls, icon picker, and preferred-script recall - add coverage for keybinding upsert behavior in server and websocket tests

14 files changed, 779 insertions(+), 127 deletions(-)

apps/server/src/keybindings.test.ts+80-2
...@@ -1,8 +1,34 @@...@@ -1,8 +1,34 @@
1import { assert, describe, it } from "vitest";1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { assert, afterEach, describe, expect, it, vi } from "vitest";
25
3import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";6import {
7 compileResolvedKeybindingRule,
8 parseKeybindingShortcut,
9 upsertKeybindingRule,
10} from "./keybindings";
411
5describe("server keybindings", () => {12describe("server keybindings", () => {
13 const tempDirs: string[] = [];
14 const logger = {
15 warn: vi.fn(),
16 };
17
18 function makeTempDir(prefix: string): string {
19 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
20 tempDirs.push(dir);
21 return dir;
22 }
23
24 afterEach(() => {
25 vi.restoreAllMocks();
26 logger.warn.mockReset();
27 for (const dir of tempDirs.splice(0, tempDirs.length)) {
28 fs.rmSync(dir, { recursive: true, force: true });
29 }
30 });
31
6 it("parses shortcuts including plus key", () => {32 it("parses shortcuts including plus key", () => {
7 assert.deepEqual(parseKeybindingShortcut("mod+j"), {33 assert.deepEqual(parseKeybindingShortcut("mod+j"), {
8 key: "j",34 key: "j",
...@@ -66,4 +92,56 @@ describe("server keybindings", () => {...@@ -66,4 +92,56 @@ describe("server keybindings", () => {
66 }),92 }),
67 );93 );
68 });94 });
95
96 it("upserts custom keybindings to ~/.t3/keybindings.json", () => {
97 const fakeHome = makeTempDir("t3code-keybindings-upsert-");
98 const configDir = path.join(fakeHome, ".t3");
99 fs.mkdirSync(configDir, { recursive: true });
100 const configPath = path.join(configDir, "keybindings.json");
101 fs.writeFileSync(
102 configPath,
103 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
104 "utf8",
105 );
106 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
107
108 const resolved = upsertKeybindingRule(logger, {
109 key: "mod+shift+r",
110 command: "script.run-tests.run",
111 });
112 const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{
113 key: string;
114 command: string;
115 }>;
116
117 expect(persisted).toEqual([
118 { key: "mod+j", command: "terminal.toggle" },
119 { key: "mod+shift+r", command: "script.run-tests.run" },
120 ]);
121 expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true);
122 });
123
124 it("replaces existing custom keybinding for the same command", () => {
125 const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-");
126 const configDir = path.join(fakeHome, ".t3");
127 fs.mkdirSync(configDir, { recursive: true });
128 const configPath = path.join(configDir, "keybindings.json");
129 fs.writeFileSync(
130 configPath,
131 JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]),
132 "utf8",
133 );
134 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
135
136 upsertKeybindingRule(logger, {
137 key: "mod+shift+r",
138 command: "script.run-tests.run",
139 });
140
141 const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{
142 key: string;
143 command: string;
144 }>;
145 expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]);
146 });
69});147});
apps/server/src/keybindings.ts+80-40
...@@ -274,30 +274,15 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig {...@@ -274,30 +274,15 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
274}274}
275275
276const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();276const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
277const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json");
278const MAX_KEYBINDINGS = 256;
277279
278function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {280function resolveKeybindingsConfigPath(): string {
279 if (custom.length === 0) {281 return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH);
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}282}
296283
297export function loadResolvedKeybindingsConfig(284function loadCustomKeybindingsConfig(logger: KeybindingsLogger): KeybindingsConfig {
298 logger: KeybindingsLogger,285 const configPath = resolveKeybindingsConfigPath();
299): ResolvedKeybindingsConfig {
300 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
301 try {286 try {
302 const raw = fs.readFileSync(configPath, "utf8");287 const raw = fs.readFileSync(configPath, "utf8");
303 const parsed = JSON.parse(raw) as unknown;288 const parsed = JSON.parse(raw) as unknown;
...@@ -305,20 +290,19 @@ export function loadResolvedKeybindingsConfig(...@@ -305,20 +290,19 @@ export function loadResolvedKeybindingsConfig(
305 logger.warn("ignoring keybindings config with unsupported format; expected array", {290 logger.warn("ignoring keybindings config with unsupported format; expected array", {
306 path: configPath,291 path: configPath,
307 });292 });
308 return [...DEFAULT_RESOLVED_KEYBINDINGS];293 return [];
309 }294 }
310295
311 const sanitized: ResolvedKeybindingsConfig = [];296 const sanitized: KeybindingsConfig = [];
312 let invalidEntries = 0;297 let invalidEntries = 0;
313 for (const entry of parsed) {298 for (const entry of parsed) {
314 const result = keybindingRuleSchema.safeParse(entry);299 const result = keybindingRuleSchema.safeParse(entry);
315 if (result.success) {300 if (result.success) {
316 const compiled = compileResolvedKeybindingRule(result.data);301 if (!compileResolvedKeybindingRule(result.data)) {
317 if (!compiled) {
318 invalidEntries += 1;302 invalidEntries += 1;
319 continue;303 continue;
320 }304 }
321 sanitized.push(compiled);305 sanitized.push(result.data);
322 continue;306 continue;
323 }307 }
324 invalidEntries += 1;308 invalidEntries += 1;
...@@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig(...@@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig(
330 totalEntries: parsed.length,314 totalEntries: parsed.length,
331 });315 });
332 }316 }
333 const overriddenCommands = new Set(sanitized.map((entry) => entry.command));317 return sanitized;
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) {318 } catch (error) {
346 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {319 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
347 return [...DEFAULT_RESOLVED_KEYBINDINGS];320 return [];
348 }321 }
349 logger.warn("ignoring malformed keybindings config", {322 logger.warn("ignoring malformed keybindings config", {
350 path: configPath,323 path: configPath,
351 error: error instanceof Error ? error.message : String(error),324 error: error instanceof Error ? error.message : String(error),
352 });325 });
326 return [];
327 }
328}
329
330function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
331 if (custom.length === 0) {
332 return [...DEFAULT_RESOLVED_KEYBINDINGS];
333 }
334
335 const overriddenCommands = new Set(custom.map((binding) => binding.command));
336 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
337 (binding) => !overriddenCommands.has(binding.command),
338 );
339 const merged = [...retainedDefaults, ...custom];
340
341 if (merged.length <= MAX_KEYBINDINGS) {
342 return merged;
343 }
344
345 // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
346 return merged.slice(-MAX_KEYBINDINGS);
347}
348
349export function loadResolvedKeybindingsConfig(
350 logger: KeybindingsLogger,
351): ResolvedKeybindingsConfig {
352 const customConfig = loadCustomKeybindingsConfig(logger);
353 const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig);
354 const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command));
355 const mergedBeforeCapLength =
356 DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
357 .length + compiledCustomConfig.length;
358 const merged = mergeWithDefaultKeybindings(compiledCustomConfig);
359 if (mergedBeforeCapLength > MAX_KEYBINDINGS) {
360 logger.warn("truncating merged keybindings config to max entries", {
361 path: resolveKeybindingsConfigPath(),
362 maxEntries: MAX_KEYBINDINGS,
363 });
364 }
365 return merged;
366}
367
368export function upsertKeybindingRule(
369 logger: KeybindingsLogger,
370 rawRule: unknown,
371): ResolvedKeybindingsConfig {
372 const rule = keybindingRuleSchema.parse(rawRule);
373 if (!compileResolvedKeybindingRule(rule)) {
374 throw new Error(`Invalid keybinding shortcut: "${rule.key}"`);
375 }
376
377 const configPath = resolveKeybindingsConfigPath();
378 const customConfig = loadCustomKeybindingsConfig(logger);
379 const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule];
380 const cappedConfig =
381 nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig;
382
383 if (nextConfig.length > MAX_KEYBINDINGS) {
384 logger.warn("truncating keybindings config to max entries", {
385 path: configPath,
386 maxEntries: MAX_KEYBINDINGS,
387 });
353 }388 }
354 return [...DEFAULT_RESOLVED_KEYBINDINGS];389
390 fs.mkdirSync(path.dirname(configPath), { recursive: true });
391 fs.writeFileSync(configPath, `${JSON.stringify(cappedConfig, null, 2)}\n`, "utf8");
392
393 const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig);
394 return mergeWithDefaultKeybindings(compiledCustomConfig);
355}395}
apps/server/src/wsServer.test.ts+53
...@@ -491,6 +491,59 @@ describe("WebSocket Server", () => {...@@ -491,6 +491,59 @@ describe("WebSocket Server", () => {
491 expect(secondResponse.result).toEqual(firstResponse.result);491 expect(secondResponse.result).toEqual(firstResponse.result);
492 });492 });
493493
494 it("upserts keybinding rules and updates cached server config", async () => {
495 const fakeHome = makeTempDir("t3code-home-upsert-keybinding-");
496 const configDir = path.join(fakeHome, ".t3");
497 fs.mkdirSync(configDir, { recursive: true });
498 const keybindingsPath = path.join(configDir, "keybindings.json");
499 fs.writeFileSync(
500 keybindingsPath,
501 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
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 await waitForMessage(ws);
514
515 const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, {
516 key: "mod+shift+r",
517 command: "script.run-tests.run",
518 });
519 expect(upsertResponse.error).toBeUndefined();
520 expect(upsertResponse.result).toEqual({
521 keybindings: mergeWithDefaultsForTest([
522 { key: "mod+j", command: "terminal.toggle" },
523 { key: "mod+shift+r", command: "script.run-tests.run" },
524 ]),
525 });
526
527 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{
528 key: string;
529 command: string;
530 }>;
531 expect(persistedConfig).toEqual([
532 { key: "mod+j", command: "terminal.toggle" },
533 { key: "mod+shift+r", command: "script.run-tests.run" },
534 ]);
535
536 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
537 expect(configResponse.error).toBeUndefined();
538 expect(configResponse.result).toEqual({
539 cwd: "/my/workspace",
540 keybindings: mergeWithDefaultsForTest([
541 { key: "mod+j", command: "terminal.toggle" },
542 { key: "mod+shift+r", command: "script.run-tests.run" },
543 ]),
544 });
545 });
546
494 it("warns and ignores unsupported keybindings config format", async () => {547 it("warns and ignores unsupported keybindings config format", async () => {
495 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});548 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
496 const fakeHome = makeTempDir("t3code-home-unsupported-format-");549 const fakeHome = makeTempDir("t3code-home-unsupported-format-");
apps/server/src/wsServer.ts+8-2
...@@ -31,7 +31,7 @@ import {...@@ -31,7 +31,7 @@ import {
31 removeGitWorktree,31 removeGitWorktree,
32} from "./git";32} from "./git";
33import { TerminalManager } from "./terminalManager";33import { TerminalManager } from "./terminalManager";
34import { loadResolvedKeybindingsConfig } from "./keybindings";34import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
35import { searchWorkspaceEntries } from "./workspaceEntries";35import { searchWorkspaceEntries } from "./workspaceEntries";
3636
37const MIME_TYPES: Record<string, string> = {37const MIME_TYPES: Record<string, string> = {
...@@ -92,7 +92,7 @@ export function createServer(options: ServerOptions) {...@@ -92,7 +92,7 @@ export function createServer(options: ServerOptions) {
92 const logger = createLogger("ws");92 const logger = createLogger("ws");
93 const logWebSocketEvents =93 const logWebSocketEvents =
94 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);94 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
95 const keybindingsConfig = loadResolvedKeybindingsConfig(logger);95 let keybindingsConfig = loadResolvedKeybindingsConfig(logger);
9696
97 function logOutgoingPush(push: WsPush, recipients: number) {97 function logOutgoingPush(push: WsPush, recipients: number) {
98 if (!logWebSocketEvents) return;98 if (!logWebSocketEvents) return;
...@@ -423,6 +423,12 @@ export function createServer(options: ServerOptions) {...@@ -423,6 +423,12 @@ export function createServer(options: ServerOptions) {
423 keybindings: keybindingsConfig,423 keybindings: keybindingsConfig,
424 };424 };
425425
426 case WS_METHODS.serverUpsertKeybinding:
427 keybindingsConfig = upsertKeybindingRule(logger, request.params);
428 return {
429 keybindings: keybindingsConfig,
430 };
431
426 default:432 default:
427 throw new Error(`Unknown method: ${request.method}`);433 throw new Error(`Unknown method: ${request.method}`);
428 }434 }
apps/web/src/components/ChatView.tsx+176-9
...@@ -31,7 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";...@@ -31,7 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
31import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";31import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
33import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";33import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
34import { serverConfigQueryOptions } from "~/lib/serverReactQuery";34import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3535
36import { isElectron } from "../env";36import { isElectron } from "../env";
37import { buildBootstrapInput } from "../historyBootstrap";37import { buildBootstrapInput } from "../historyBootstrap";
...@@ -97,8 +97,10 @@ import { Badge } from "./ui/badge";...@@ -97,8 +97,10 @@ import { Badge } from "./ui/badge";
97import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";97import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
98import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";98import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
99import {99import {
100 commandForProjectScript,
100 injectEnvIntoShellCommand,101 injectEnvIntoShellCommand,
101 nextProjectScriptId,102 nextProjectScriptId,
103 projectScriptRuntimeEnv,
102 projectScriptIdFromCommand,104 projectScriptIdFromCommand,
103 setupProjectScript,105 setupProjectScript,
104} from "~/projectScripts";106} from "~/projectScripts";
...@@ -109,6 +111,7 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {...@@ -109,6 +111,7 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
109}111}
110112
111const LAST_EDITOR_KEY = "t3code:last-editor";113const LAST_EDITOR_KEY = "t3code:last-editor";
114const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
112const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;115const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
113const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;116const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
114const IMAGE_ONLY_BOOTSTRAP_PROMPT =117const IMAGE_ONLY_BOOTSTRAP_PROMPT =
...@@ -125,6 +128,24 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({...@@ -125,6 +128,24 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({
125const SCRIPT_TERMINAL_COLS = 120;128const SCRIPT_TERMINAL_COLS = 120;
126const SCRIPT_TERMINAL_ROWS = 30;129const SCRIPT_TERMINAL_ROWS = 30;
127130
131function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
132 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
133 if (!stored) return {};
134
135 try {
136 const parsed: unknown = JSON.parse(stored);
137 if (!parsed || typeof parsed !== "object") return {};
138 return Object.fromEntries(
139 Object.entries(parsed).filter(
140 (entry): entry is [string, string] =>
141 typeof entry[0] === "string" && typeof entry[1] === "string",
142 ),
143 );
144 } catch {
145 return {};
146 }
147}
148
128function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {149function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
129 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";150 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
130 if (tone === "tool") return "text-muted-foreground/70";151 if (tone === "tool") return "text-muted-foreground/70";
...@@ -330,6 +351,9 @@ export default function ChatView() {...@@ -330,6 +351,9 @@ export default function ChatView() {
330 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);351 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
331 const [composerCursor, setComposerCursor] = useState(0);352 const [composerCursor, setComposerCursor] = useState(0);
332 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);353 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
354 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
355 Record<string, string>
356 >(() => readLastInvokedScriptByProjectFromStorage());
333 const messagesScrollRef = useRef<HTMLDivElement>(null);357 const messagesScrollRef = useRef<HTMLDivElement>(null);
334 const messagesEndRef = useRef<HTMLDivElement>(null);358 const messagesEndRef = useRef<HTMLDivElement>(null);
335 const shouldAutoScrollRef = useRef(true);359 const shouldAutoScrollRef = useRef(true);
...@@ -635,6 +659,10 @@ export default function ChatView() {...@@ -635,6 +659,10 @@ export default function ChatView() {
635 },659 },
636 ) => {660 ) => {
637 if (!api || !activeThreadId || !activeProject || !activeThread) return;661 if (!api || !activeThreadId || !activeProject || !activeThread) return;
662 setLastInvokedScriptByProjectId((current) => {
663 if (current[activeProject.id] === script.id) return current;
664 return { ...current, [activeProject.id]: script.id };
665 });
638 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;666 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
639 const baseTerminalId = activeThread.activeTerminalId;667 const baseTerminalId = activeThread.activeTerminalId;
640 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);668 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
...@@ -663,12 +691,20 @@ export default function ChatView() {...@@ -663,12 +691,20 @@ export default function ChatView() {
663 }691 }
664 setTerminalFocusRequestId((value) => value + 1);692 setTerminalFocusRequestId((value) => value + 1);
665693
666 const scriptEnv =694 const resolvedCommand = injectEnvIntoShellCommand(
667 options?.env ??695 script.command,
668 (script.runOnWorktreeCreate ? { T3CODE_PROJECT_ROOT: activeProject.cwd } : undefined);696 projectScriptRuntimeEnv({
669 const resolvedCommand = scriptEnv697 project: {
670 ? injectEnvIntoShellCommand(script.command, scriptEnv)698 id: activeProject.id,
671 : script.command;699 name: activeProject.name,
700 cwd: activeProject.cwd,
701 },
702 script,
703 threadId: activeThreadId,
704 worktreePath: activeThread.worktreePath ?? null,
705 extraEnv: options?.env,
706 }),
707 );
672708
673 try {709 try {
674 await api.terminal.open({710 await api.terminal.open({
...@@ -723,17 +759,124 @@ export default function ChatView() {...@@ -723,17 +759,124 @@ export default function ChatView() {
723 });759 });
724760
725 if (!isElectron || !api) return;761 if (!isElectron || !api) return;
762 let scriptsPersisted = false;
763 try {
764 const updated = await api.projects.updateScripts({
765 id: activeProject.id,
766 scripts: nextScripts,
767 });
768 scriptsPersisted = true;
769
770 if (input.keybinding) {
771 const keybindingUpdate = await api.server.upsertKeybinding({
772 key: input.keybinding,
773 command: commandForProjectScript(nextId),
774 });
775 queryClient.setQueryData(
776 serverQueryKeys.config(),
777 (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) =>
778 current
779 ? { ...current, keybindings: keybindingUpdate.keybindings }
780 : {
781 cwd: activeProject.cwd,
782 keybindings: keybindingUpdate.keybindings,
783 },
784 );
785 }
786
787 dispatch({
788 type: "SET_PROJECT_SCRIPTS",
789 projectId: updated.project.id,
790 scripts: updated.project.scripts,
791 });
792 } catch (error) {
793 if (scriptsPersisted) {
794 await api.projects
795 .updateScripts({
796 id: activeProject.id,
797 scripts: activeProject.scripts,
798 })
799 .catch(() => undefined);
800 }
801 dispatch({
802 type: "SET_PROJECT_SCRIPTS",
803 projectId: activeProject.id,
804 scripts: activeProject.scripts,
805 });
806 throw error;
807 }
808 },
809 [activeProject, api, dispatch, queryClient],
810 );
811 const updateProjectScript = useCallback(
812 async (scriptId: string, input: NewProjectScriptInput) => {
813 if (!activeProject) return;
814 const existingScript = activeProject.scripts.find((script) => script.id === scriptId);
815 if (!existingScript) {
816 throw new Error("Script not found.");
817 }
818
819 const updatedScript: ProjectScript = {
820 ...existingScript,
821 name: input.name,
822 command: input.command,
823 icon: input.icon,
824 runOnWorktreeCreate: input.runOnWorktreeCreate,
825 };
826 const nextScripts = activeProject.scripts.map((script) =>
827 script.id === scriptId
828 ? updatedScript
829 : input.runOnWorktreeCreate
830 ? { ...script, runOnWorktreeCreate: false }
831 : script,
832 );
833
834 dispatch({
835 type: "SET_PROJECT_SCRIPTS",
836 projectId: activeProject.id,
837 scripts: nextScripts,
838 });
839
840 if (!isElectron || !api) return;
841 let scriptsPersisted = false;
726 try {842 try {
727 const updated = await api.projects.updateScripts({843 const updated = await api.projects.updateScripts({
728 id: activeProject.id,844 id: activeProject.id,
729 scripts: nextScripts,845 scripts: nextScripts,
730 });846 });
847 scriptsPersisted = true;
848
849 if (input.keybinding) {
850 const keybindingUpdate = await api.server.upsertKeybinding({
851 key: input.keybinding,
852 command: commandForProjectScript(scriptId),
853 });
854 queryClient.setQueryData(
855 serverQueryKeys.config(),
856 (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) =>
857 current
858 ? { ...current, keybindings: keybindingUpdate.keybindings }
859 : {
860 cwd: activeProject.cwd,
861 keybindings: keybindingUpdate.keybindings,
862 },
863 );
864 }
865
731 dispatch({866 dispatch({
732 type: "SET_PROJECT_SCRIPTS",867 type: "SET_PROJECT_SCRIPTS",
733 projectId: updated.project.id,868 projectId: updated.project.id,
734 scripts: updated.project.scripts,869 scripts: updated.project.scripts,
735 });870 });
736 } catch (error) {871 } catch (error) {
872 if (scriptsPersisted) {
873 await api.projects
874 .updateScripts({
875 id: activeProject.id,
876 scripts: activeProject.scripts,
877 })
878 .catch(() => undefined);
879 }
737 dispatch({880 dispatch({
738 type: "SET_PROJECT_SCRIPTS",881 type: "SET_PROJECT_SCRIPTS",
739 projectId: activeProject.id,882 projectId: activeProject.id,
...@@ -742,7 +885,7 @@ export default function ChatView() {...@@ -742,7 +885,7 @@ export default function ChatView() {
742 throw error;885 throw error;
743 }886 }
744 },887 },
745 [activeProject, api, dispatch],888 [activeProject, api, dispatch, queryClient],
746 );889 );
747890
748 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {891 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
...@@ -768,6 +911,21 @@ export default function ChatView() {...@@ -768,6 +911,21 @@ export default function ChatView() {
768 }911 }
769 };912 };
770913
914 useEffect(() => {
915 try {
916 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {
917 localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
918 return;
919 }
920 localStorage.setItem(
921 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
922 JSON.stringify(lastInvokedScriptByProjectId),
923 );
924 } catch {
925 // Ignore storage write failures (private mode, quota exceeded, etc.)
926 }
927 }, [lastInvokedScriptByProjectId]);
928
771 // Auto-scroll on new messages929 // Auto-scroll on new messages
772 const messageCount = activeThread?.messages.length ?? 0;930 const messageCount = activeThread?.messages.length ?? 0;
773 const workLogCount = workLogEntries.length;931 const workLogCount = workLogEntries.length;
...@@ -1192,7 +1350,6 @@ export default function ChatView() {...@@ -1192,7 +1350,6 @@ export default function ChatView() {
1192 if (setupScript) {1350 if (setupScript) {
1193 void runProjectScript(setupScript, {1351 void runProjectScript(setupScript, {
1194 cwd: result.worktree.path,1352 cwd: result.worktree.path,
1195 env: { T3CODE_PROJECT_ROOT: activeProject.cwd },
1196 });1353 });
1197 }1354 }
1198 } catch (err) {1355 } catch (err) {
...@@ -1474,6 +1631,9 @@ export default function ChatView() {...@@ -1474,6 +1631,9 @@ export default function ChatView() {
1474 activeThreadTitle={activeThread.title}1631 activeThreadTitle={activeThread.title}
1475 activeProjectName={activeProject?.name}1632 activeProjectName={activeProject?.name}
1476 activeProjectScripts={activeProject?.scripts}1633 activeProjectScripts={activeProject?.scripts}
1634 preferredScriptId={
1635 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
1636 }
1477 keybindings={keybindings}1637 keybindings={keybindings}
1478 api={api}1638 api={api}
1479 gitCwd={gitCwd}1639 gitCwd={gitCwd}
...@@ -1482,6 +1642,7 @@ export default function ChatView() {...@@ -1482,6 +1642,7 @@ export default function ChatView() {
1482 void runProjectScript(script);1642 void runProjectScript(script);
1483 }}1643 }}
1484 onAddProjectScript={saveProjectScript}1644 onAddProjectScript={saveProjectScript}
1645 onUpdateProjectScript={updateProjectScript}
1485 onToggleDiff={onToggleDiff}1646 onToggleDiff={onToggleDiff}
1486 />1647 />
1487 </header>1648 </header>
...@@ -1806,12 +1967,14 @@ interface ChatHeaderProps {...@@ -1806,12 +1967,14 @@ interface ChatHeaderProps {
1806 activeThreadTitle: string;1967 activeThreadTitle: string;
1807 activeProjectName: string | undefined;1968 activeProjectName: string | undefined;
1808 activeProjectScripts: ProjectScript[] | undefined;1969 activeProjectScripts: ProjectScript[] | undefined;
1970 preferredScriptId: string | null;
1809 keybindings: ResolvedKeybindingsConfig;1971 keybindings: ResolvedKeybindingsConfig;
1810 api: NativeApi | undefined;1972 api: NativeApi | undefined;
1811 gitCwd: string | null;1973 gitCwd: string | null;
1812 diffOpen: boolean;1974 diffOpen: boolean;
1813 onRunProjectScript: (script: ProjectScript) => void;1975 onRunProjectScript: (script: ProjectScript) => void;
1814 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;1976 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
1977 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
1815 onToggleDiff: () => void;1978 onToggleDiff: () => void;
1816}1979}
18171980
...@@ -1819,12 +1982,14 @@ const ChatHeader = memo(function ChatHeader({...@@ -1819,12 +1982,14 @@ const ChatHeader = memo(function ChatHeader({
1819 activeThreadTitle,1982 activeThreadTitle,
1820 activeProjectName,1983 activeProjectName,
1821 activeProjectScripts,1984 activeProjectScripts,
1985 preferredScriptId,
1822 keybindings,1986 keybindings,
1823 api,1987 api,
1824 gitCwd,1988 gitCwd,
1825 diffOpen,1989 diffOpen,
1826 onRunProjectScript,1990 onRunProjectScript,
1827 onAddProjectScript,1991 onAddProjectScript,
1992 onUpdateProjectScript,
1828 onToggleDiff,1993 onToggleDiff,
1829}: ChatHeaderProps) {1994}: ChatHeaderProps) {
1830 return (1995 return (
...@@ -1840,8 +2005,10 @@ const ChatHeader = memo(function ChatHeader({...@@ -1840,8 +2005,10 @@ const ChatHeader = memo(function ChatHeader({
1840 <ProjectScriptsControl2005 <ProjectScriptsControl
1841 scripts={activeProjectScripts}2006 scripts={activeProjectScripts}
1842 keybindings={keybindings}2007 keybindings={keybindings}
2008 preferredScriptId={preferredScriptId}
1843 onRunScript={onRunProjectScript}2009 onRunScript={onRunProjectScript}
1844 onAddScript={onAddProjectScript}2010 onAddScript={onAddProjectScript}
2011 onUpdateScript={onUpdateProjectScript}
1845 />2012 />
1846 )}2013 )}
1847 {activeProjectName && <OpenInPicker keybindings={keybindings} />}2014 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
apps/web/src/components/ProjectScriptsControl.tsx+263-72
...@@ -4,7 +4,9 @@ import type {...@@ -4,7 +4,9 @@ import type {
4 ResolvedKeybindingsConfig,4 ResolvedKeybindingsConfig,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import {6import {
7 BugIcon,
7 ChevronDownIcon,8 ChevronDownIcon,
9 CogIcon,
8 FlaskConicalIcon,10 FlaskConicalIcon,
9 HammerIcon,11 HammerIcon,
10 ListChecksIcon,12 ListChecksIcon,
...@@ -12,14 +14,11 @@ import {...@@ -12,14 +14,11 @@ import {
12 PlusIcon,14 PlusIcon,
13 WrenchIcon,15 WrenchIcon,
14} from "lucide-react";16} from "lucide-react";
15import { type FormEvent, useMemo, useState } from "react";17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
1618
17import {19import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
18 commandForProjectScript,
19 nextProjectScriptId,
20 primaryProjectScript,
21} from "~/projectScripts";
22import { shortcutLabelForCommand } from "~/keybindings";20import { shortcutLabelForCommand } from "~/keybindings";
21import { isMacPlatform } from "~/lib/utils";
23import { Button } from "./ui/button";22import { Button } from "./ui/button";
24import {23import {
25 Dialog,24 Dialog,
...@@ -34,7 +33,7 @@ import { Group, GroupSeparator } from "./ui/group";...@@ -34,7 +33,7 @@ import { Group, GroupSeparator } from "./ui/group";
34import { Input } from "./ui/input";33import { Input } from "./ui/input";
35import { Label } from "./ui/label";34import { Label } from "./ui/label";
36import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";35import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
37import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";36import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
38import { Switch } from "./ui/switch";37import { Switch } from "./ui/switch";
39import { Textarea } from "./ui/textarea";38import { Textarea } from "./ui/textarea";
4039
...@@ -44,6 +43,7 @@ const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [...@@ -44,6 +43,7 @@ const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
44 { id: "lint", label: "Lint" },43 { id: "lint", label: "Lint" },
45 { id: "configure", label: "Configure" },44 { id: "configure", label: "Configure" },
46 { id: "build", label: "Build" },45 { id: "build", label: "Build" },
46 { id: "debug", label: "Debug" },
47];47];
4848
49function ScriptIcon({49function ScriptIcon({
...@@ -57,6 +57,7 @@ function ScriptIcon({...@@ -57,6 +57,7 @@ function ScriptIcon({
57 if (icon === "lint") return <ListChecksIcon className={className} />;57 if (icon === "lint") return <ListChecksIcon className={className} />;
58 if (icon === "configure") return <WrenchIcon className={className} />;58 if (icon === "configure") return <WrenchIcon className={className} />;
59 if (icon === "build") return <HammerIcon className={className} />;59 if (icon === "build") return <HammerIcon className={className} />;
60 if (icon === "debug") return <BugIcon className={className} />;
60 return <PlayIcon className={className} />;61 return <PlayIcon className={className} />;
61}62}
6263
...@@ -65,39 +66,133 @@ export interface NewProjectScriptInput {...@@ -65,39 +66,133 @@ export interface NewProjectScriptInput {
65 command: string;66 command: string;
66 icon: ProjectScriptIcon;67 icon: ProjectScriptIcon;
67 runOnWorktreeCreate: boolean;68 runOnWorktreeCreate: boolean;
69 keybinding: string | null;
68}70}
6971
70interface ProjectScriptsControlProps {72interface ProjectScriptsControlProps {
71 scripts: ProjectScript[];73 scripts: ProjectScript[];
72 keybindings: ResolvedKeybindingsConfig;74 keybindings: ResolvedKeybindingsConfig;
75 preferredScriptId?: string | null;
73 onRunScript: (script: ProjectScript) => void;76 onRunScript: (script: ProjectScript) => void;
74 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;77 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
78 onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;
79}
80
81function normalizeShortcutKeyToken(key: string): string | null {
82 const normalized = key.toLowerCase();
83 if (
84 normalized === "meta" ||
85 normalized === "control" ||
86 normalized === "ctrl" ||
87 normalized === "shift" ||
88 normalized === "alt" ||
89 normalized === "option"
90 ) {
91 return null;
92 }
93 if (normalized === " ") return "space";
94 if (normalized === "escape") return "esc";
95 if (normalized === "arrowup") return "arrowup";
96 if (normalized === "arrowdown") return "arrowdown";
97 if (normalized === "arrowleft") return "arrowleft";
98 if (normalized === "arrowright") return "arrowright";
99 if (normalized.length === 1) return normalized;
100 if (normalized.startsWith("f") && normalized.length <= 3) return normalized;
101 if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
102 return normalized;
103 }
104 if (normalized === "delete" || normalized === "home" || normalized === "end") {
105 return normalized;
106 }
107 if (normalized === "pageup" || normalized === "pagedown") return normalized;
108 return null;
109}
110
111function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null {
112 const keyToken = normalizeShortcutKeyToken(event.key);
113 if (!keyToken) return null;
114
115 const parts: string[] = [];
116 if (isMacPlatform(navigator.platform)) {
117 if (event.metaKey) parts.push("mod");
118 if (event.ctrlKey) parts.push("ctrl");
119 } else {
120 if (event.ctrlKey) parts.push("mod");
121 if (event.metaKey) parts.push("meta");
122 }
123 if (event.altKey) parts.push("alt");
124 if (event.shiftKey) parts.push("shift");
125 parts.push(keyToken);
126 return parts.join("+");
127}
128
129function keybindingValueForCommand(
130 keybindings: ResolvedKeybindingsConfig,
131 command: string,
132): string | null {
133 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
134 const binding = keybindings[index];
135 if (!binding || binding.command !== command) continue;
136
137 const parts: string[] = [];
138 if (binding.shortcut.modKey) parts.push("mod");
139 if (binding.shortcut.ctrlKey) parts.push("ctrl");
140 if (binding.shortcut.metaKey) parts.push("meta");
141 if (binding.shortcut.altKey) parts.push("alt");
142 if (binding.shortcut.shiftKey) parts.push("shift");
143 const keyToken =
144 binding.shortcut.key === " "
145 ? "space"
146 : binding.shortcut.key === "escape"
147 ? "esc"
148 : binding.shortcut.key;
149 parts.push(keyToken);
150 return parts.join("+");
151 }
152 return null;
75}153}
76154
77export default function ProjectScriptsControl({155export default function ProjectScriptsControl({
78 scripts,156 scripts,
79 keybindings,157 keybindings,
158 preferredScriptId = null,
80 onRunScript,159 onRunScript,
81 onAddScript,160 onAddScript,
161 onUpdateScript,
82}: ProjectScriptsControlProps) {162}: ProjectScriptsControlProps) {
163 const addScriptFormId = React.useId();
164 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
83 const [dialogOpen, setDialogOpen] = useState(false);165 const [dialogOpen, setDialogOpen] = useState(false);
84 const [name, setName] = useState("");166 const [name, setName] = useState("");
85 const [command, setCommand] = useState("");167 const [command, setCommand] = useState("");
86 const [icon, setIcon] = useState<ProjectScriptIcon>("play");168 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
169 const [iconPickerOpen, setIconPickerOpen] = useState(false);
87 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);170 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
171 const [keybinding, setKeybinding] = useState("");
88 const [validationError, setValidationError] = useState<string | null>(null);172 const [validationError, setValidationError] = useState<string | null>(null);
89173
90 const primaryScript = useMemo(() => primaryProjectScript(scripts), [scripts]);174 const primaryScript = useMemo(() => {
91 const keybindingCommandPreview = useMemo(175 if (preferredScriptId) {
92 () =>176 const preferred = scripts.find((script) => script.id === preferredScriptId);
93 commandForProjectScript(177 if (preferred) return preferred;
94 nextProjectScriptId(178 }
95 name.length > 0 ? name : "script",179 return primaryProjectScript(scripts);
96 scripts.map((script) => script.id),180 }, [preferredScriptId, scripts]);
97 ),181 const isEditing = editingScriptId !== null;
98 ),182 const dropdownItemClassName =
99 [name, scripts],183 "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground";
100 );184
185 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
186 if (event.key === "Tab") return;
187 event.preventDefault();
188 if (event.key === "Backspace" || event.key === "Delete") {
189 setKeybinding("");
190 return;
191 }
192 const next = keybindingFromEvent(event);
193 if (!next) return;
194 setKeybinding(next);
195 };
101196
102 const submitAddScript = async (event: FormEvent) => {197 const submitAddScript = async (event: FormEvent) => {
103 event.preventDefault();198 event.preventDefault();
...@@ -114,23 +209,51 @@ export default function ProjectScriptsControl({...@@ -114,23 +209,51 @@ export default function ProjectScriptsControl({
114209
115 setValidationError(null);210 setValidationError(null);
116 try {211 try {
117 await onAddScript({212 const payload = {
118 name: trimmedName,213 name: trimmedName,
119 command: trimmedCommand,214 command: trimmedCommand,
120 icon,215 icon,
121 runOnWorktreeCreate,216 runOnWorktreeCreate,
122 });217 keybinding: keybinding.trim() || null,
218 } satisfies NewProjectScriptInput;
219 if (editingScriptId) {
220 await onUpdateScript(editingScriptId, payload);
221 } else {
222 await onAddScript(payload);
223 }
123 setDialogOpen(false);224 setDialogOpen(false);
225 setEditingScriptId(null);
124 setName("");226 setName("");
125 setCommand("");227 setCommand("");
126 setIcon("play");228 setIcon("play");
229 setIconPickerOpen(false);
127 setRunOnWorktreeCreate(false);230 setRunOnWorktreeCreate(false);
231 setKeybinding("");
128 } catch (error) {232 } catch (error) {
129 setValidationError(error instanceof Error ? error.message : "Failed to save action.");233 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
130 }234 }
131 };235 };
132236
133 const openDialog = () => {237 const openAddDialog = () => {
238 setEditingScriptId(null);
239 setName("");
240 setCommand("");
241 setIcon("play");
242 setIconPickerOpen(false);
243 setRunOnWorktreeCreate(false);
244 setKeybinding("");
245 setValidationError(null);
246 setDialogOpen(true);
247 };
248
249 const openEditDialog = (script: ProjectScript) => {
250 setEditingScriptId(script.id);
251 setName(script.name);
252 setCommand(script.command);
253 setIcon(script.icon);
254 setIconPickerOpen(false);
255 setRunOnWorktreeCreate(script.runOnWorktreeCreate);
256 setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
134 setValidationError(null);257 setValidationError(null);
135 setDialogOpen(true);258 setDialogOpen(true);
136 };259 };
...@@ -146,10 +269,10 @@ export default function ProjectScriptsControl({...@@ -146,10 +269,10 @@ export default function ProjectScriptsControl({
146 title={`Run ${primaryScript.name}`}269 title={`Run ${primaryScript.name}`}
147 >270 >
148 <ScriptIcon icon={primaryScript.icon} />271 <ScriptIcon icon={primaryScript.icon} />
149 <span>{primaryScript.name}</span>272 <span className="ml-0.5">{primaryScript.name}</span>
150 </Button>273 </Button>
151 <GroupSeparator />274 <GroupSeparator />
152 <Menu>275 <Menu highlightItemOnHover={false}>
153 <MenuTrigger276 <MenuTrigger
154 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}277 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
155 >278 >
...@@ -162,14 +285,44 @@ export default function ProjectScriptsControl({...@@ -162,14 +285,44 @@ export default function ProjectScriptsControl({
162 commandForProjectScript(script.id),285 commandForProjectScript(script.id),
163 );286 );
164 return (287 return (
165 <MenuItem key={script.id} onClick={() => onRunScript(script)}>288 <MenuItem
289 key={script.id}
290 className={`group ${dropdownItemClassName}`}
291 onClick={() => onRunScript(script)}
292 >
166 <ScriptIcon icon={script.icon} className="size-4" />293 <ScriptIcon icon={script.icon} className="size-4" />
167 {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name}294 <span className="truncate">
168 {shortcutLabel && <MenuShortcut>{shortcutLabel}</MenuShortcut>}295 {script.runOnWorktreeCreate ? `${script.name}` : script.name}
296 </span>
297 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
298 {shortcutLabel && (
299 <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0">
300 {shortcutLabel}
301 </MenuShortcut>
302 )}
303 <Button
304 type="button"
305 variant="ghost"
306 size="icon-xs"
307 className="absolute right-0 top-1/2 size-6 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto"
308 aria-label={`Edit ${script.name}`}
309 onPointerDown={(event) => {
310 event.preventDefault();
311 event.stopPropagation();
312 }}
313 onClick={(event) => {
314 event.preventDefault();
315 event.stopPropagation();
316 openEditDialog(script);
317 }}
318 >
319 <CogIcon className="size-3.5" />
320 </Button>
321 </span>
169 </MenuItem>322 </MenuItem>
170 );323 );
171 })}324 })}
172 <MenuItem onClick={openDialog}>325 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
173 <PlusIcon className="size-4" />326 <PlusIcon className="size-4" />
174 Add action327 Add action
175 </MenuItem>328 </MenuItem>
...@@ -177,7 +330,7 @@ export default function ProjectScriptsControl({...@@ -177,7 +330,7 @@ export default function ProjectScriptsControl({
177 </Menu>330 </Menu>
178 </Group>331 </Group>
179 ) : (332 ) : (
180 <Button size="xs" variant="outline" onClick={openDialog}>333 <Button size="xs" variant="outline" onClick={openAddDialog}>
181 <PlusIcon className="size-3.5" />334 <PlusIcon className="size-3.5" />
182 Add action335 Add action
183 </Button>336 </Button>
...@@ -185,24 +338,75 @@ export default function ProjectScriptsControl({...@@ -185,24 +338,75 @@ export default function ProjectScriptsControl({
185338
186 <Dialog onOpenChange={setDialogOpen} open={dialogOpen}>339 <Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
187 <DialogPopup>340 <DialogPopup>
188 <form onSubmit={submitAddScript}>341 <DialogHeader>
189 <DialogHeader>342 <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
190 <DialogTitle>Add Action</DialogTitle>343 <DialogDescription>
191 <DialogDescription>344 Actions are project-scoped commands you can run from the top bar or keybindings.
192 Actions are project-scoped commands you can run from the top bar or keybindings.345 </DialogDescription>
193 </DialogDescription>346 </DialogHeader>
194 </DialogHeader>347 <DialogPanel>
195 <DialogPanel className="space-y-4">348 <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}>
196 <div className="space-y-1.5">349 <div className="space-y-1.5">
197 <Label htmlFor="script-name">Name</Label>350 <Label htmlFor="script-name">Name</Label>
351 <div className="flex items-center gap-2">
352 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
353 <PopoverTrigger
354 render={
355 <Button
356 type="button"
357 variant="outline"
358 className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)]"
359 aria-label="Choose icon"
360 />
361 }
362 >
363 <ScriptIcon icon={icon} className="size-4.5" />
364 </PopoverTrigger>
365 <PopoverPopup align="start">
366 <div className="grid grid-cols-3 gap-2">
367 {SCRIPT_ICONS.map((entry) => {
368 const isSelected = entry.id === icon;
369 return (
370 <button
371 key={entry.id}
372 type="button"
373 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs ${
374 isSelected
375 ? "border-primary/70 bg-primary/10"
376 : "border-border/70 hover:bg-accent/60"
377 }`}
378 onClick={() => {
379 setIcon(entry.id);
380 setIconPickerOpen(false);
381 }}
382 >
383 <ScriptIcon icon={entry.id} className="size-4" />
384 <span>{entry.label}</span>
385 </button>
386 );
387 })}
388 </div>
389 </PopoverPopup>
390 </Popover>
391 <Input
392 id="script-name"
393 placeholder="Test"
394 value={name}
395 onChange={(event) => setName(event.target.value)}
396 />
397 </div>
398 </div>
399 <div className="space-y-1.5">
400 <Label htmlFor="script-keybinding">Keybinding</Label>
198 <Input401 <Input
199 id="script-name"402 id="script-keybinding"
200 placeholder="Test"403 placeholder="Press shortcut"
201 value={name}404 value={keybinding}
202 onChange={(event) => setName(event.target.value)}405 readOnly
406 onKeyDown={captureKeybinding}
203 />407 />
204 <p className="text-xs text-muted-foreground">408 <p className="text-xs text-muted-foreground">
205 Keybinding command: <code>{keybindingCommandPreview}</code>409 Press a shortcut. Use <code>Backspace</code> to clear.
206 </p>410 </p>
207 </div>411 </div>
208 <div className="space-y-1.5">412 <div className="space-y-1.5">
...@@ -214,44 +418,31 @@ export default function ProjectScriptsControl({...@@ -214,44 +418,31 @@ export default function ProjectScriptsControl({
214 onChange={(event) => setCommand(event.target.value)}418 onChange={(event) => setCommand(event.target.value)}
215 />419 />
216 </div>420 </div>
217 <div className="space-y-1.5">
218 <Label>Icon</Label>
219 <Select
220 items={SCRIPT_ICONS.map((entry) => ({ label: entry.label, value: entry.id }))}
221 value={icon}
222 onValueChange={(value) => {
223 if (!value) return;
224 setIcon(value as ProjectScriptIcon);
225 }}
226 >
227 <SelectTrigger>
228 <SelectValue />
229 </SelectTrigger>
230 <SelectPopup>
231 {SCRIPT_ICONS.map((entry) => (
232 <SelectItem key={entry.id} value={entry.id}>
233 {entry.label}
234 </SelectItem>
235 ))}
236 </SelectPopup>
237 </Select>
238 </div>
239 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">421 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">
240 <span>Run automatically on worktree creation (Setup)</span>422 <span>Run automatically on worktree creation</span>
241 <Switch423 <Switch
242 checked={runOnWorktreeCreate}424 checked={runOnWorktreeCreate}
243 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}425 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
244 />426 />
245 </label>427 </label>
246 {validationError && <p className="text-sm text-destructive">{validationError}</p>}428 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
247 </DialogPanel>429 </form>
248 <DialogFooter>430 </DialogPanel>
249 <Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>431 <DialogFooter>
250 Cancel432 <Button
251 </Button>433 type="button"
252 <Button type="submit">Save action</Button>434 variant="outline"
253 </DialogFooter>435 onClick={() => {
254 </form>436 setDialogOpen(false);
437 setEditingScriptId(null);
438 }}
439 >
440 Cancel
441 </Button>
442 <Button form={addScriptFormId} type="submit">
443 {isEditing ? "Save changes" : "Save action"}
444 </Button>
445 </DialogFooter>
255 </DialogPopup>446 </DialogPopup>
256 </Dialog>447 </Dialog>
257 </>448 </>
apps/web/src/projectScripts.test.ts+49
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 injectEnvIntoShellCommand,5 injectEnvIntoShellCommand,
6 nextProjectScriptId,6 nextProjectScriptId,
7 primaryProjectScript,7 primaryProjectScript,
8 projectScriptRuntimeEnv,
8 projectScriptIdFromCommand,9 projectScriptIdFromCommand,
9 setupProjectScript,10 setupProjectScript,
10} from "./projectScripts";11} from "./projectScripts";
...@@ -54,4 +55,52 @@ describe("projectScripts helpers", () => {...@@ -54,4 +55,52 @@ describe("projectScripts helpers", () => {
54 expect(primaryProjectScript(scripts)?.id).toBe("test");55 expect(primaryProjectScript(scripts)?.id).toBe("test");
55 expect(setupProjectScript(scripts)?.id).toBe("setup");56 expect(setupProjectScript(scripts)?.id).toBe("setup");
56 });57 });
58
59 it("builds default runtime env for scripts", () => {
60 const env = projectScriptRuntimeEnv({
61 project: { id: "project-1", name: "acme-web", cwd: "/repo" },
62 script: {
63 id: "test",
64 name: "Test",
65 icon: "test",
66 runOnWorktreeCreate: false,
67 },
68 threadId: "thread-1",
69 worktreePath: "/repo/worktree-a",
70 });
71
72 expect(env).toMatchObject({
73 T3CODE_PROJECT_ROOT: "/repo",
74 T3CODE_PROJECT_ID: "project-1",
75 T3CODE_PROJECT_NAME: "acme-web",
76 T3CODE_THREAD_ID: "thread-1",
77 T3CODE_SCRIPT_ID: "test",
78 T3CODE_SCRIPT_NAME: "Test",
79 T3CODE_SCRIPT_ICON: "test",
80 T3CODE_SCRIPT_IS_SETUP: "0",
81 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
82 });
83 });
84
85 it("allows overriding runtime env values", () => {
86 const env = projectScriptRuntimeEnv({
87 project: { id: "project-1", name: "acme-web", cwd: "/repo" },
88 script: {
89 id: "setup",
90 name: "Setup",
91 icon: "configure",
92 runOnWorktreeCreate: true,
93 },
94 threadId: "thread-1",
95 extraEnv: {
96 T3CODE_PROJECT_ROOT: "/custom-root",
97 CUSTOM_FLAG: "1",
98 },
99 });
100
101 expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root");
102 expect(env.T3CODE_SCRIPT_IS_SETUP).toBe("1");
103 expect(env.CUSTOM_FLAG).toBe("1");
104 expect(env.T3CODE_WORKTREE_PATH).toBeUndefined();
105 });
57});106});
apps/web/src/projectScripts.ts+32
...@@ -82,6 +82,38 @@ export function injectEnvIntoShellCommand(...@@ -82,6 +82,38 @@ export function injectEnvIntoShellCommand(
82 return `env ${assignments} ${command}`;82 return `env ${assignments} ${command}`;
83}83}
8484
85interface ProjectScriptRuntimeEnvInput {
86 project: {
87 id: string;
88 name: string;
89 cwd: string;
90 };
91 script: Pick<ProjectScript, "id" | "name" | "icon" | "runOnWorktreeCreate">;
92 threadId: string;
93 worktreePath?: string | null;
94 extraEnv?: Record<string, string>;
95}
96
97export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> {
98 const env: Record<string, string> = {
99 T3CODE_PROJECT_ROOT: input.project.cwd,
100 T3CODE_PROJECT_ID: input.project.id,
101 T3CODE_PROJECT_NAME: input.project.name,
102 T3CODE_THREAD_ID: input.threadId,
103 T3CODE_SCRIPT_ID: input.script.id,
104 T3CODE_SCRIPT_NAME: input.script.name,
105 T3CODE_SCRIPT_ICON: input.script.icon,
106 T3CODE_SCRIPT_IS_SETUP: input.script.runOnWorktreeCreate ? "1" : "0",
107 };
108 if (input.worktreePath) {
109 env.T3CODE_WORKTREE_PATH = input.worktreePath;
110 }
111 if (input.extraEnv) {
112 return { ...env, ...input.extraEnv };
113 }
114 return env;
115}
116
85export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {117export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {
86 const regular = scripts.find((script) => !script.runOnWorktreeCreate);118 const regular = scripts.find((script) => !script.runOnWorktreeCreate);
87 return regular ?? scripts[0] ?? null;119 return regular ?? scripts[0] ?? null;
apps/web/src/wsNativeApi.ts+1
...@@ -143,6 +143,7 @@ export function createWsNativeApi(): NativeApi {...@@ -143,6 +143,7 @@ export function createWsNativeApi(): NativeApi {
143 },143 },
144 server: {144 server: {
145 getConfig: () => transport.request(WS_METHODS.serverGetConfig),145 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
146 upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
146 },147 },
147 };148 };
148149
packages/contracts/src/ipc.ts+2
...@@ -46,6 +46,7 @@ import type {...@@ -46,6 +46,7 @@ import type {
46 TerminalWriteInput,46 TerminalWriteInput,
47} from "./terminal";47} from "./terminal";
48import type { NewTodoInput, Todo } from "./todo";48import type { NewTodoInput, Todo } from "./todo";
49import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
4950
50export const EDITORS = [51export const EDITORS = [
51 { id: "cursor", label: "Cursor", command: "cursor" },52 { id: "cursor", label: "Cursor", command: "cursor" },
...@@ -122,5 +123,6 @@ export interface NativeApi {...@@ -122,5 +123,6 @@ export interface NativeApi {
122 };123 };
123 server: {124 server: {
124 getConfig: () => Promise<ServerConfig>;125 getConfig: () => Promise<ServerConfig>;
126 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
125 };127 };
126}128}
packages/contracts/src/project.test.ts+17
...@@ -105,6 +105,23 @@ describe("project contracts", () => {...@@ -105,6 +105,23 @@ describe("project contracts", () => {
105 expect(parsed.scripts[0]?.id).toBe("test");105 expect(parsed.scripts[0]?.id).toBe("test");
106 });106 });
107107
108 it("parses debug icon in scripts", () => {
109 const parsed = projectUpdateScriptsInputSchema.parse({
110 id: "project-1",
111 scripts: [
112 {
113 id: "debug",
114 name: "Debug",
115 command: "bun --inspect test",
116 icon: "debug",
117 runOnWorktreeCreate: false,
118 },
119 ],
120 });
121
122 expect(parsed.scripts[0]?.icon).toBe("debug");
123 });
124
108 it("parses update scripts result", () => {125 it("parses update scripts result", () => {
109 const parsed = projectUpdateScriptsResultSchema.parse({126 const parsed = projectUpdateScriptsResultSchema.parse({
110 project: {127 project: {
packages/contracts/src/project.ts+8-1
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import { z } from "zod";1import { z } from "zod";
22
3export const PROJECT_SCRIPT_ICON_VALUES = ["play", "test", "lint", "configure", "build"] as const;3export const PROJECT_SCRIPT_ICON_VALUES = [
4 "play",
5 "test",
6 "lint",
7 "configure",
8 "build",
9 "debug",
10] as const;
411
5export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);12export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);
613
packages/contracts/src/server.ts+9-1
...@@ -1,9 +1,17 @@...@@ -1,9 +1,17 @@
1import { z } from "zod";1import { z } from "zod";
2import { resolvedKeybindingsConfigSchema } from "./keybindings";2import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings";
33
4export const serverConfigSchema = z.object({4export const serverConfigSchema = z.object({
5 cwd: z.string().min(1),5 cwd: z.string().min(1),
6 keybindings: resolvedKeybindingsConfigSchema.default([]),6 keybindings: resolvedKeybindingsConfigSchema.default([]),
7});7});
88
9export const serverUpsertKeybindingInputSchema = keybindingRuleSchema;
10
11export const serverUpsertKeybindingResultSchema = z.object({
12 keybindings: resolvedKeybindingsConfigSchema.default([]),
13});
14
9export type ServerConfig = z.infer<typeof serverConfigSchema>;15export type ServerConfig = z.infer<typeof serverConfigSchema>;
16export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>;
17export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>;
packages/contracts/src/ws.ts+1
...@@ -42,6 +42,7 @@ export const WS_METHODS = {...@@ -42,6 +42,7 @@ export const WS_METHODS = {
4242
43 // Server meta43 // Server meta
44 serverGetConfig: "server.getConfig",44 serverGetConfig: "server.getConfig",
45 serverUpsertKeybinding: "server.upsertKeybinding",
45} as const;46} as const;
4647
47// ── Push Event Channels ──────────────────────────────────────────────48// ── Push Event Channels ──────────────────────────────────────────────