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 @@
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
512describe("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
632 it("parses shortcuts including plus key", () => {
733 assert.deepEqual(parseKeybindingShortcut("mod+j"), {
834 key: "j",
......@@ -66,4 +92,56 @@ describe("server keybindings", () => {
6692 }),
6793 );
6894 });
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 });
69147});
apps/server/src/keybindings.ts+80-40
......@@ -274,30 +274,15 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
274274}
275275
276276const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
277const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json");
278const MAX_KEYBINDINGS = 256;
277279
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);
280function resolveKeybindingsConfigPath(): string {
281 return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH);
295282}
296283
297export function loadResolvedKeybindingsConfig(
298 logger: KeybindingsLogger,
299): ResolvedKeybindingsConfig {
300 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
284function loadCustomKeybindingsConfig(logger: KeybindingsLogger): KeybindingsConfig {
285 const configPath = resolveKeybindingsConfigPath();
301286 try {
302287 const raw = fs.readFileSync(configPath, "utf8");
303288 const parsed = JSON.parse(raw) as unknown;
......@@ -305,20 +290,19 @@ export function loadResolvedKeybindingsConfig(
305290 logger.warn("ignoring keybindings config with unsupported format; expected array", {
306291 path: configPath,
307292 });
308 return [...DEFAULT_RESOLVED_KEYBINDINGS];
293 return [];
309294 }
310295
311 const sanitized: ResolvedKeybindingsConfig = [];
296 const sanitized: KeybindingsConfig = [];
312297 let invalidEntries = 0;
313298 for (const entry of parsed) {
314299 const result = keybindingRuleSchema.safeParse(entry);
315300 if (result.success) {
316 const compiled = compileResolvedKeybindingRule(result.data);
317 if (!compiled) {
301 if (!compileResolvedKeybindingRule(result.data)) {
318302 invalidEntries += 1;
319303 continue;
320304 }
321 sanitized.push(compiled);
305 sanitized.push(result.data);
322306 continue;
323307 }
324308 invalidEntries += 1;
......@@ -330,26 +314,82 @@ export function loadResolvedKeybindingsConfig(
330314 totalEntries: parsed.length,
331315 });
332316 }
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;
317 return sanitized;
345318 } catch (error) {
346319 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
347 return [...DEFAULT_RESOLVED_KEYBINDINGS];
320 return [];
348321 }
349322 logger.warn("ignoring malformed keybindings config", {
350323 path: configPath,
351324 error: error instanceof Error ? error.message : String(error),
352325 });
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 });
353388 }
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);
355395}
apps/server/src/wsServer.test.ts+53
......@@ -491,6 +491,59 @@ describe("WebSocket Server", () => {
491491 expect(secondResponse.result).toEqual(firstResponse.result);
492492 });
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
494547 it("warns and ignores unsupported keybindings config format", async () => {
495548 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
496549 const fakeHome = makeTempDir("t3code-home-unsupported-format-");
apps/server/src/wsServer.ts+8-2
......@@ -31,7 +31,7 @@ import {
3131 removeGitWorktree,
3232} from "./git";
3333import { TerminalManager } from "./terminalManager";
34import { loadResolvedKeybindingsConfig } from "./keybindings";
34import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
3535import { searchWorkspaceEntries } from "./workspaceEntries";
3636
3737const MIME_TYPES: Record<string, string> = {
......@@ -92,7 +92,7 @@ export function createServer(options: ServerOptions) {
9292 const logger = createLogger("ws");
9393 const logWebSocketEvents =
9494 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
95 const keybindingsConfig = loadResolvedKeybindingsConfig(logger);
95 let keybindingsConfig = loadResolvedKeybindingsConfig(logger);
9696
9797 function logOutgoingPush(push: WsPush, recipients: number) {
9898 if (!logWebSocketEvents) return;
......@@ -423,6 +423,12 @@ export function createServer(options: ServerOptions) {
423423 keybindings: keybindingsConfig,
424424 };
425425
426 case WS_METHODS.serverUpsertKeybinding:
427 keybindingsConfig = upsertKeybindingRule(logger, request.params);
428 return {
429 keybindings: keybindingsConfig,
430 };
431
426432 default:
427433 throw new Error(`Unknown method: ${request.method}`);
428434 }
apps/web/src/components/ChatView.tsx+176-9
......@@ -31,7 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3131import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
3232import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
3333import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
34import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
34import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3535
3636import { isElectron } from "../env";
3737import { buildBootstrapInput } from "../historyBootstrap";
......@@ -97,8 +97,10 @@ import { Badge } from "./ui/badge";
9797import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
9898import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
9999import {
100 commandForProjectScript,
100101 injectEnvIntoShellCommand,
101102 nextProjectScriptId,
103 projectScriptRuntimeEnv,
102104 projectScriptIdFromCommand,
103105 setupProjectScript,
104106} from "~/projectScripts";
......@@ -109,6 +111,7 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
109111}
110112
111113const LAST_EDITOR_KEY = "t3code:last-editor";
114const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
112115const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
113116const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
114117const IMAGE_ONLY_BOOTSTRAP_PROMPT =
......@@ -125,6 +128,24 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({
125128const SCRIPT_TERMINAL_COLS = 120;
126129const 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
128149function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
129150 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
130151 if (tone === "tool") return "text-muted-foreground/70";
......@@ -330,6 +351,9 @@ export default function ChatView() {
330351 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
331352 const [composerCursor, setComposerCursor] = useState(0);
332353 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
354 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
355 Record<string, string>
356 >(() => readLastInvokedScriptByProjectFromStorage());
333357 const messagesScrollRef = useRef<HTMLDivElement>(null);
334358 const messagesEndRef = useRef<HTMLDivElement>(null);
335359 const shouldAutoScrollRef = useRef(true);
......@@ -635,6 +659,10 @@ export default function ChatView() {
635659 },
636660 ) => {
637661 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 });
638666 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
639667 const baseTerminalId = activeThread.activeTerminalId;
640668 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
......@@ -663,12 +691,20 @@ export default function ChatView() {
663691 }
664692 setTerminalFocusRequestId((value) => value + 1);
665693
666 const scriptEnv =
667 options?.env ??
668 (script.runOnWorktreeCreate ? { T3CODE_PROJECT_ROOT: activeProject.cwd } : undefined);
669 const resolvedCommand = scriptEnv
670 ? injectEnvIntoShellCommand(script.command, scriptEnv)
671 : script.command;
694 const resolvedCommand = injectEnvIntoShellCommand(
695 script.command,
696 projectScriptRuntimeEnv({
697 project: {
698 id: activeProject.id,
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
673709 try {
674710 await api.terminal.open({
......@@ -723,17 +759,124 @@ export default function ChatView() {
723759 });
724760
725761 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;
726842 try {
727843 const updated = await api.projects.updateScripts({
728844 id: activeProject.id,
729845 scripts: nextScripts,
730846 });
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
731866 dispatch({
732867 type: "SET_PROJECT_SCRIPTS",
733868 projectId: updated.project.id,
734869 scripts: updated.project.scripts,
735870 });
736871 } catch (error) {
872 if (scriptsPersisted) {
873 await api.projects
874 .updateScripts({
875 id: activeProject.id,
876 scripts: activeProject.scripts,
877 })
878 .catch(() => undefined);
879 }
737880 dispatch({
738881 type: "SET_PROJECT_SCRIPTS",
739882 projectId: activeProject.id,
......@@ -742,7 +885,7 @@ export default function ChatView() {
742885 throw error;
743886 }
744887 },
745 [activeProject, api, dispatch],
888 [activeProject, api, dispatch, queryClient],
746889 );
747890
748891 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
......@@ -768,6 +911,21 @@ export default function ChatView() {
768911 }
769912 };
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
771929 // Auto-scroll on new messages
772930 const messageCount = activeThread?.messages.length ?? 0;
773931 const workLogCount = workLogEntries.length;
......@@ -1192,7 +1350,6 @@ export default function ChatView() {
11921350 if (setupScript) {
11931351 void runProjectScript(setupScript, {
11941352 cwd: result.worktree.path,
1195 env: { T3CODE_PROJECT_ROOT: activeProject.cwd },
11961353 });
11971354 }
11981355 } catch (err) {
......@@ -1474,6 +1631,9 @@ export default function ChatView() {
14741631 activeThreadTitle={activeThread.title}
14751632 activeProjectName={activeProject?.name}
14761633 activeProjectScripts={activeProject?.scripts}
1634 preferredScriptId={
1635 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
1636 }
14771637 keybindings={keybindings}
14781638 api={api}
14791639 gitCwd={gitCwd}
......@@ -1482,6 +1642,7 @@ export default function ChatView() {
14821642 void runProjectScript(script);
14831643 }}
14841644 onAddProjectScript={saveProjectScript}
1645 onUpdateProjectScript={updateProjectScript}
14851646 onToggleDiff={onToggleDiff}
14861647 />
14871648 </header>
......@@ -1806,12 +1967,14 @@ interface ChatHeaderProps {
18061967 activeThreadTitle: string;
18071968 activeProjectName: string | undefined;
18081969 activeProjectScripts: ProjectScript[] | undefined;
1970 preferredScriptId: string | null;
18091971 keybindings: ResolvedKeybindingsConfig;
18101972 api: NativeApi | undefined;
18111973 gitCwd: string | null;
18121974 diffOpen: boolean;
18131975 onRunProjectScript: (script: ProjectScript) => void;
18141976 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
1977 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
18151978 onToggleDiff: () => void;
18161979}
18171980
......@@ -1819,12 +1982,14 @@ const ChatHeader = memo(function ChatHeader({
18191982 activeThreadTitle,
18201983 activeProjectName,
18211984 activeProjectScripts,
1985 preferredScriptId,
18221986 keybindings,
18231987 api,
18241988 gitCwd,
18251989 diffOpen,
18261990 onRunProjectScript,
18271991 onAddProjectScript,
1992 onUpdateProjectScript,
18281993 onToggleDiff,
18291994}: ChatHeaderProps) {
18301995 return (
......@@ -1840,8 +2005,10 @@ const ChatHeader = memo(function ChatHeader({
18402005 <ProjectScriptsControl
18412006 scripts={activeProjectScripts}
18422007 keybindings={keybindings}
2008 preferredScriptId={preferredScriptId}
18432009 onRunScript={onRunProjectScript}
18442010 onAddScript={onAddProjectScript}
2011 onUpdateScript={onUpdateProjectScript}
18452012 />
18462013 )}
18472014 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
apps/web/src/components/ProjectScriptsControl.tsx+263-72
......@@ -4,7 +4,9 @@ import type {
44 ResolvedKeybindingsConfig,
55} from "@t3tools/contracts";
66import {
7 BugIcon,
78 ChevronDownIcon,
9 CogIcon,
810 FlaskConicalIcon,
911 HammerIcon,
1012 ListChecksIcon,
......@@ -12,14 +14,11 @@ import {
1214 PlusIcon,
1315 WrenchIcon,
1416} from "lucide-react";
15import { type FormEvent, useMemo, useState } from "react";
17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
1618
17import {
18 commandForProjectScript,
19 nextProjectScriptId,
20 primaryProjectScript,
21} from "~/projectScripts";
19import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
2220import { shortcutLabelForCommand } from "~/keybindings";
21import { isMacPlatform } from "~/lib/utils";
2322import { Button } from "./ui/button";
2423import {
2524 Dialog,
......@@ -34,7 +33,7 @@ import { Group, GroupSeparator } from "./ui/group";
3433import { Input } from "./ui/input";
3534import { Label } from "./ui/label";
3635import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
37import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
36import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
3837import { Switch } from "./ui/switch";
3938import { Textarea } from "./ui/textarea";
4039
......@@ -44,6 +43,7 @@ const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
4443 { id: "lint", label: "Lint" },
4544 { id: "configure", label: "Configure" },
4645 { id: "build", label: "Build" },
46 { id: "debug", label: "Debug" },
4747];
4848
4949function ScriptIcon({
......@@ -57,6 +57,7 @@ function ScriptIcon({
5757 if (icon === "lint") return <ListChecksIcon className={className} />;
5858 if (icon === "configure") return <WrenchIcon className={className} />;
5959 if (icon === "build") return <HammerIcon className={className} />;
60 if (icon === "debug") return <BugIcon className={className} />;
6061 return <PlayIcon className={className} />;
6162}
6263
......@@ -65,39 +66,133 @@ export interface NewProjectScriptInput {
6566 command: string;
6667 icon: ProjectScriptIcon;
6768 runOnWorktreeCreate: boolean;
69 keybinding: string | null;
6870}
6971
7072interface ProjectScriptsControlProps {
7173 scripts: ProjectScript[];
7274 keybindings: ResolvedKeybindingsConfig;
75 preferredScriptId?: string | null;
7376 onRunScript: (script: ProjectScript) => void;
7477 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;
75153}
76154
77155export default function ProjectScriptsControl({
78156 scripts,
79157 keybindings,
158 preferredScriptId = null,
80159 onRunScript,
81160 onAddScript,
161 onUpdateScript,
82162}: ProjectScriptsControlProps) {
163 const addScriptFormId = React.useId();
164 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
83165 const [dialogOpen, setDialogOpen] = useState(false);
84166 const [name, setName] = useState("");
85167 const [command, setCommand] = useState("");
86168 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
169 const [iconPickerOpen, setIconPickerOpen] = useState(false);
87170 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
171 const [keybinding, setKeybinding] = useState("");
88172 const [validationError, setValidationError] = useState<string | null>(null);
89173
90 const primaryScript = useMemo(() => primaryProjectScript(scripts), [scripts]);
91 const keybindingCommandPreview = useMemo(
92 () =>
93 commandForProjectScript(
94 nextProjectScriptId(
95 name.length > 0 ? name : "script",
96 scripts.map((script) => script.id),
97 ),
98 ),
99 [name, scripts],
100 );
174 const primaryScript = useMemo(() => {
175 if (preferredScriptId) {
176 const preferred = scripts.find((script) => script.id === preferredScriptId);
177 if (preferred) return preferred;
178 }
179 return primaryProjectScript(scripts);
180 }, [preferredScriptId, scripts]);
181 const isEditing = editingScriptId !== null;
182 const dropdownItemClassName =
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";
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
102197 const submitAddScript = async (event: FormEvent) => {
103198 event.preventDefault();
......@@ -114,23 +209,51 @@ export default function ProjectScriptsControl({
114209
115210 setValidationError(null);
116211 try {
117 await onAddScript({
212 const payload = {
118213 name: trimmedName,
119214 command: trimmedCommand,
120215 icon,
121216 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 }
123224 setDialogOpen(false);
225 setEditingScriptId(null);
124226 setName("");
125227 setCommand("");
126228 setIcon("play");
229 setIconPickerOpen(false);
127230 setRunOnWorktreeCreate(false);
231 setKeybinding("");
128232 } catch (error) {
129233 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
130234 }
131235 };
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)) ?? "");
134257 setValidationError(null);
135258 setDialogOpen(true);
136259 };
......@@ -146,10 +269,10 @@ export default function ProjectScriptsControl({
146269 title={`Run ${primaryScript.name}`}
147270 >
148271 <ScriptIcon icon={primaryScript.icon} />
149 <span>{primaryScript.name}</span>
272 <span className="ml-0.5">{primaryScript.name}</span>
150273 </Button>
151274 <GroupSeparator />
152 <Menu>
275 <Menu highlightItemOnHover={false}>
153276 <MenuTrigger
154277 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
155278 >
......@@ -162,14 +285,44 @@ export default function ProjectScriptsControl({
162285 commandForProjectScript(script.id),
163286 );
164287 return (
165 <MenuItem key={script.id} onClick={() => onRunScript(script)}>
288 <MenuItem
289 key={script.id}
290 className={`group ${dropdownItemClassName}`}
291 onClick={() => onRunScript(script)}
292 >
166293 <ScriptIcon icon={script.icon} className="size-4" />
167 {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name}
168 {shortcutLabel && <MenuShortcut>{shortcutLabel}</MenuShortcut>}
294 <span className="truncate">
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>
169322 </MenuItem>
170323 );
171324 })}
172 <MenuItem onClick={openDialog}>
325 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
173326 <PlusIcon className="size-4" />
174327 Add action
175328 </MenuItem>
......@@ -177,7 +330,7 @@ export default function ProjectScriptsControl({
177330 </Menu>
178331 </Group>
179332 ) : (
180 <Button size="xs" variant="outline" onClick={openDialog}>
333 <Button size="xs" variant="outline" onClick={openAddDialog}>
181334 <PlusIcon className="size-3.5" />
182335 Add action
183336 </Button>
......@@ -185,24 +338,75 @@ export default function ProjectScriptsControl({
185338
186339 <Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
187340 <DialogPopup>
188 <form onSubmit={submitAddScript}>
189 <DialogHeader>
190 <DialogTitle>Add Action</DialogTitle>
191 <DialogDescription>
192 Actions are project-scoped commands you can run from the top bar or keybindings.
193 </DialogDescription>
194 </DialogHeader>
195 <DialogPanel className="space-y-4">
341 <DialogHeader>
342 <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
343 <DialogDescription>
344 Actions are project-scoped commands you can run from the top bar or keybindings.
345 </DialogDescription>
346 </DialogHeader>
347 <DialogPanel>
348 <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}>
196349 <div className="space-y-1.5">
197350 <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>
198401 <Input
199 id="script-name"
200 placeholder="Test"
201 value={name}
202 onChange={(event) => setName(event.target.value)}
402 id="script-keybinding"
403 placeholder="Press shortcut"
404 value={keybinding}
405 readOnly
406 onKeyDown={captureKeybinding}
203407 />
204408 <p className="text-xs text-muted-foreground">
205 Keybinding command: <code>{keybindingCommandPreview}</code>
409 Press a shortcut. Use <code>Backspace</code> to clear.
206410 </p>
207411 </div>
208412 <div className="space-y-1.5">
......@@ -214,44 +418,31 @@ export default function ProjectScriptsControl({
214418 onChange={(event) => setCommand(event.target.value)}
215419 />
216420 </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>
239421 <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>
241423 <Switch
242424 checked={runOnWorktreeCreate}
243425 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
244426 />
245427 </label>
246428 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
247 </DialogPanel>
248 <DialogFooter>
249 <Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
250 Cancel
251 </Button>
252 <Button type="submit">Save action</Button>
253 </DialogFooter>
254 </form>
429 </form>
430 </DialogPanel>
431 <DialogFooter>
432 <Button
433 type="button"
434 variant="outline"
435 onClick={() => {
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>
255446 </DialogPopup>
256447 </Dialog>
257448 </>
apps/web/src/projectScripts.test.ts+49
......@@ -5,6 +5,7 @@ import {
55 injectEnvIntoShellCommand,
66 nextProjectScriptId,
77 primaryProjectScript,
8 projectScriptRuntimeEnv,
89 projectScriptIdFromCommand,
910 setupProjectScript,
1011} from "./projectScripts";
......@@ -54,4 +55,52 @@ describe("projectScripts helpers", () => {
5455 expect(primaryProjectScript(scripts)?.id).toBe("test");
5556 expect(setupProjectScript(scripts)?.id).toBe("setup");
5657 });
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 });
57106});
apps/web/src/projectScripts.ts+32
......@@ -82,6 +82,38 @@ export function injectEnvIntoShellCommand(
8282 return `env ${assignments} ${command}`;
8383}
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
85117export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {
86118 const regular = scripts.find((script) => !script.runOnWorktreeCreate);
87119 return regular ?? scripts[0] ?? null;
apps/web/src/wsNativeApi.ts+1
......@@ -143,6 +143,7 @@ export function createWsNativeApi(): NativeApi {
143143 },
144144 server: {
145145 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
146 upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
146147 },
147148 };
148149
packages/contracts/src/ipc.ts+2
......@@ -46,6 +46,7 @@ import type {
4646 TerminalWriteInput,
4747} from "./terminal";
4848import type { NewTodoInput, Todo } from "./todo";
49import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
4950
5051export const EDITORS = [
5152 { id: "cursor", label: "Cursor", command: "cursor" },
......@@ -122,5 +123,6 @@ export interface NativeApi {
122123 };
123124 server: {
124125 getConfig: () => Promise<ServerConfig>;
126 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
125127 };
126128}
packages/contracts/src/project.test.ts+17
......@@ -105,6 +105,23 @@ describe("project contracts", () => {
105105 expect(parsed.scripts[0]?.id).toBe("test");
106106 });
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
108125 it("parses update scripts result", () => {
109126 const parsed = projectUpdateScriptsResultSchema.parse({
110127 project: {
packages/contracts/src/project.ts+8-1
......@@ -1,6 +1,13 @@
11import { 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
512export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);
613
packages/contracts/src/server.ts+9-1
......@@ -1,9 +1,17 @@
11import { z } from "zod";
2import { resolvedKeybindingsConfigSchema } from "./keybindings";
2import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings";
33
44export const serverConfigSchema = z.object({
55 cwd: z.string().min(1),
66 keybindings: resolvedKeybindingsConfigSchema.default([]),
77});
88
9export const serverUpsertKeybindingInputSchema = keybindingRuleSchema;
10
11export const serverUpsertKeybindingResultSchema = z.object({
12 keybindings: resolvedKeybindingsConfigSchema.default([]),
13});
14
915export 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 = {
4242
4343 // Server meta
4444 serverGetConfig: "server.getConfig",
45 serverUpsertKeybinding: "server.upsertKeybinding",
4546} as const;
4647
4748// ── Push Event Channels ──────────────────────────────────────────────