History

Add project script actions with run shortcuts and persistence

- add project-scoped script actions in the chat toolbar, including setup-on-worktree-create - support dynamic `script.{id}.run` keybindings and command resolution - persist and sync project scripts across web state, contracts, WebSocket API, and server registry - add backend and frontend tests for script helpers, persistence, and registry behavior

Julius Marminge committed 9 months ago

Commit 3537d471 parent 9e877f525 files · +1068 −25Browse files

Unified · Split

25 files
  1. KEYBINDINGS.md +1 −0
  2. apps/server/src/projectRegistry.test.ts +113 −0
  3. apps/server/src/projectRegistry.ts +97 −3
  4. apps/server/src/wsServer.test.ts +36 −0
  5. apps/server/src/wsServer.ts +2 −0
  6. apps/web/src/App.tsx +2 −0
  7. apps/web/src/components/ChatView.tsx +184 −17
  8. apps/web/src/components/ProjectScriptsControl.tsx +259 −0
  9. apps/web/src/components/Sidebar.tsx +2 −0
  10. apps/web/src/keybindings.test.ts +16 −0
  11. apps/web/src/keybindings.ts +2 −2
  12. apps/web/src/persistenceSchema.test.ts +7 −0
  13. apps/web/src/persistenceSchema.ts +3 −0
  14. apps/web/src/projectScripts.test.ts +57 −0
  15. apps/web/src/projectScripts.ts +92 −0
  16. apps/web/src/store.test.ts +33 −0
  17. apps/web/src/store.ts +50 −0
  18. apps/web/src/types.ts +7 −1
  19. apps/web/src/wsNativeApi.ts +1 −0
  20. packages/contracts/src/ipc.ts +3 −0
  21. packages/contracts/src/keybindings.test.ts +9 −1
  22. packages/contracts/src/keybindings.ts +11 −1
  23. packages/contracts/src/project.test.ts +45 −0
  24. packages/contracts/src/project.ts +35 −0
  25. packages/contracts/src/ws.ts +1 −0
KEYBINDINGS.md+1−0
@@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged · 52 unmodified lines
5353 - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
5454 - `chat.newLocal`: create a new local chat thread for the active project (no worktree context)
5555 - `editor.openFavorite`: open current project/worktree in the last-used editor
56+- `script.{id}.run`: run a project script by id (for example `script.test.run`)
5657
5758 ### Key Syntax
5859
apps/server/src/projectRegistry.test.ts+113−0
@@ -0,0 +1,113 @@
1+import fs from "node:fs";
2+import os from "node:os";
3+import path from "node:path";
4+
5+import { afterEach, describe, expect, it } from "vitest";
6+
7+import { ProjectRegistry } from "./projectRegistry";
8+
9+const tempDirs: string[] = [];
10+
11+function makeTempDir(prefix: string): string {
12+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13+ tempDirs.push(dir);
14+ return dir;
15+}
16+
17+afterEach(() => {
18+ for (const dir of tempDirs.splice(0, tempDirs.length)) {
19+ fs.rmSync(dir, { recursive: true, force: true });
20+ }
21+});
22+
23+describe("ProjectRegistry scripts", () => {
24+ it("stores and reloads project scripts", () => {
25+ const stateDir = makeTempDir("t3code-project-registry-state-");
26+ const projectDir = makeTempDir("t3code-project-registry-project-");
27+ const registry = new ProjectRegistry(stateDir);
28+
29+ const created = registry.add({ cwd: projectDir }).project;
30+ const updated = registry.updateScripts({
31+ id: created.id,
32+ scripts: [
33+ {
34+ id: "run",
35+ name: "Run",
36+ command: "bun run dev",
37+ icon: "play",
38+ runOnWorktreeCreate: false,
39+ },
40+ {
41+ id: "setup",
42+ name: "Setup",
43+ command: "bun install",
44+ icon: "configure",
45+ runOnWorktreeCreate: true,
46+ },
47+ ],
48+ });
49+
50+ expect(updated.project.scripts).toHaveLength(2);
51+ expect(updated.project.scripts[1]?.id).toBe("setup");
52+ expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true);
53+
54+ const reloaded = new ProjectRegistry(stateDir);
55+ const listed = reloaded.list();
56+ expect(listed).toHaveLength(1);
57+ expect(listed[0]?.scripts).toHaveLength(2);
58+ expect(listed[0]?.scripts[0]?.id).toBe("run");
59+ expect(listed[0]?.scripts[1]?.id).toBe("setup");
60+ });
61+
62+ it("deduplicates script ids and allows only one setup script", () => {
63+ const stateDir = makeTempDir("t3code-project-registry-state-");
64+ const projectDir = makeTempDir("t3code-project-registry-project-");
65+ const registry = new ProjectRegistry(stateDir);
66+ const created = registry.add({ cwd: projectDir }).project;
67+
68+ const updated = registry.updateScripts({
69+ id: created.id,
70+ scripts: [
71+ {
72+ id: "setup",
73+ name: "Setup",
74+ command: "echo setup",
75+ icon: "configure",
76+ runOnWorktreeCreate: true,
77+ },
78+ {
79+ id: "setup",
80+ name: "Setup Duplicate",
81+ command: "echo duplicate",
82+ icon: "configure",
83+ runOnWorktreeCreate: true,
84+ },
85+ {
86+ id: "another",
87+ name: "Another Setup",
88+ command: "echo another",
89+ icon: "play",
90+ runOnWorktreeCreate: true,
91+ },
92+ ],
93+ });
94+
95+ expect(updated.project.scripts).toHaveLength(2);
96+ expect(updated.project.scripts[0]?.id).toBe("setup");
97+ expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true);
98+ expect(updated.project.scripts[1]?.id).toBe("another");
99+ expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false);
100+ });
101+
102+ it("throws when updating scripts for an unknown project id", () => {
103+ const stateDir = makeTempDir("t3code-project-registry-state-");
104+ const registry = new ProjectRegistry(stateDir);
105+
106+ expect(() =>
107+ registry.updateScripts({
108+ id: "missing-project",
109+ scripts: [],
110+ }),
111+ ).toThrowError("Project not found");
112+ });
113+});
apps/server/src/projectRegistry.ts+97−3
@@ -8,11 +8,30 @@ import { · 7 unmodified lines
88 type ProjectListResult,
99 type ProjectRecord,
1010 type ProjectRemoveInput,
11+ type ProjectScript,
12+ type ProjectUpdateScriptsInput,
13+ type ProjectUpdateScriptsResult,
1114 projectAddInputSchema,
1215 projectRecordSchema,
1316 projectRemoveInputSchema,
17+ projectScriptsSchema,
18+ projectUpdateScriptsInputSchema,
1419 } from "@t3tools/contracts";
1520
21+function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] {
22+ const cloned: ProjectScript[] = [];
23+ for (const script of scripts) {
24+ cloned.push({
25+ id: script.id,
26+ name: script.name,
27+ command: script.command,
28+ icon: script.icon,
29+ runOnWorktreeCreate: script.runOnWorktreeCreate,
30+ });
31+ }
32+ return cloned;
33+}
34+
1635 function normalizeCwd(rawCwd: string): string {
1736 const resolved = path.resolve(rawCwd.trim());
1837 const normalized = path.normalize(resolved);
@@ -48,7 +67,10 @@ export class ProjectRegistry { · 29 unmodified lines
4867 }
4968
5069 list(): ProjectListResult {
51− return this.projects.map((project) => ({ ...project }));
70+ return this.projects.map((project) => ({
71+ ...project,
72+ scripts: cloneScripts(project.scripts),
73+ }));
5274 }
5375
5476 add(raw: ProjectAddInput): ProjectAddResult {
@@ -60,7 +82,13 @@ export class ProjectRegistry { · 5 unmodified lines
6082
6183 const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd);
6284 if (existing) {
63− return { project: { ...existing }, created: false };
85+ return {
86+ project: {
87+ ...existing,
88+ scripts: cloneScripts(existing.scripts),
89+ },
90+ created: false,
91+ };
6492 }
6593
6694 const now = new Date().toISOString();
@@ -68,13 +96,20 @@ export class ProjectRegistry { · 1 unmodified line
6896 id: randomUUID(),
6997 cwd: normalizedCwd,
7098 name: inferProjectName(normalizedCwd),
99+ scripts: [],
71100 createdAt: now,
72101 updatedAt: now,
73102 };
74103
75104 this.projects = [project, ...this.projects];
76105 this.persist();
77− return { project: { ...project }, created: true };
106+ return {
107+ project: {
108+ ...project,
109+ scripts: cloneScripts(project.scripts),
110+ },
111+ created: true,
112+ };
78113 }
79114
80115 remove(raw: ProjectRemoveInput): void {
@@ -88,6 +123,31 @@ export class ProjectRegistry { · 7 unmodified lines
88123 this.persist();
89124 }
90125
126+ updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult {
127+ const input = projectUpdateScriptsInputSchema.parse(raw);
128+ const index = this.projects.findIndex((project) => project.id === input.id);
129+ if (index < 0) {
130+ throw new Error(`Project not found: ${input.id}`);
131+ }
132+
133+ const nextScripts = dedupeScriptsById(projectScriptsSchema.parse(input.scripts));
134+ const nextUpdatedAt = new Date().toISOString();
135+ const nextProject: ProjectRecord = {
136+ ...this.projects[index]!,
137+ scripts: nextScripts,
138+ updatedAt: nextUpdatedAt,
139+ };
140+
141+ const nextProjects = [...this.projects];
142+ nextProjects[index] = nextProject;
143+ this.projects = nextProjects;
144+ this.persist();
145+
146+ return {
147+ project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) },
148+ };
149+ }
150+
91151 private loadFromDisk(): ProjectRecord[] {
92152 const loaded = this.readPersistedProjects();
93153 const deduped = new Map<string, ProjectRecord>();
@@ -107,6 +167,7 @@ export class ProjectRegistry { · 13 unmodified lines
107167 cwd: normalizedCwd,
108168 name:
109169 project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd),
170+ scripts: dedupeScriptsById(project.scripts),
110171 };
111172 deduped.set(normalizedCwd, normalizedProject);
112173 normalizedProjects.push(normalizedProject);
@@ -121,6 +182,7 @@ export class ProjectRegistry { · 8 unmodified lines
121182 source.id !== project.id ||
122183 source.cwd !== project.cwd ||
123184 source.name !== project.name ||
185+ JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) ||
124186 source.createdAt !== project.createdAt ||
125187 source.updatedAt !== project.updatedAt
126188 );
@@ -170,3 +232,35 @@ export class ProjectRegistry { · 43 unmodified lines
170232 fs.renameSync(tempFile, this.filePath);
171233 }
172234 }
235+
236+function dedupeScriptsById(scripts: readonly ProjectScript[]): ProjectScript[] {
237+ const seenIds = new Set<string>();
238+ const deduped: ProjectScript[] = [];
239+ for (const script of scripts) {
240+ if (seenIds.has(script.id)) continue;
241+ seenIds.add(script.id);
242+ deduped.push({
243+ id: script.id,
244+ name: script.name,
245+ command: script.command,
246+ icon: script.icon,
247+ runOnWorktreeCreate: script.runOnWorktreeCreate,
248+ });
249+ }
250+
251+ let setupAssigned = false;
252+ const normalized: ProjectScript[] = [];
253+ for (const script of deduped) {
254+ if (!script.runOnWorktreeCreate) {
255+ normalized.push(script);
256+ continue;
257+ }
258+ if (!setupAssigned) {
259+ setupAssigned = true;
260+ normalized.push(script);
261+ continue;
262+ }
263+ normalized.push({ ...script, runOnWorktreeCreate: false });
264+ }
265+ return normalized;
266+}
apps/server/src/wsServer.test.ts+36−0
@@ -756,6 +756,42 @@ describe("WebSocket Server", () => { · 755 unmodified lines
756756 expect(projectId).toBeTruthy();
757757 if (!projectId) return;
758758
759+ const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, {
760+ id: projectId,
761+ scripts: [
762+ {
763+ id: "setup",
764+ name: "Setup",
765+ command: "bun install",
766+ icon: "configure",
767+ runOnWorktreeCreate: true,
768+ },
769+ ],
770+ });
771+ expect(updatedScripts.error).toBeUndefined();
772+ const scriptPayload = (
773+ updatedScripts.result as {
774+ project: {
775+ scripts: Array<{
776+ id: string;
777+ name: string;
778+ command: string;
779+ icon: string;
780+ runOnWorktreeCreate: boolean;
781+ }>;
782+ };
783+ }
784+ ).project.scripts;
785+ expect(scriptPayload).toEqual([
786+ {
787+ id: "setup",
788+ name: "Setup",
789+ command: "bun install",
790+ icon: "configure",
791+ runOnWorktreeCreate: true,
792+ },
793+ ]);
794+
759795 const removed = await sendRequest(ws, WS_METHODS.projectsRemove, {
760796 id: projectId,
761797 });
apps/server/src/wsServer.ts+2−0
@@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) { · 321 unmodified lines
322322
323323 case WS_METHODS.projectsSearchEntries:
324324 return searchWorkspaceEntries(request.params as never);
325+ case WS_METHODS.projectsUpdateScripts:
326+ return projectRegistry.updateScripts(request.params as never);
325327
326328 case WS_METHODS.shellOpenInEditor: {
327329 const params = request.params as {
apps/web/src/App.tsx+2−0
@@ -85,6 +85,7 @@ function AutoProjectBootstrap() { · 84 unmodified lines
8585 cwd: payload.cwd,
8686 model: DEFAULT_MODEL,
8787 expanded: true,
88+ scripts: [],
8889 },
8990 });
9091 dispatch({
@@ -146,6 +147,7 @@ function DesktopProjectBootstrap() { · 55 unmodified lines
146147 cwd: project.cwd,
147148 model: DEFAULT_MODEL,
148149 expanded: true,
150+ scripts: project.scripts,
149151 })),
150152 });
151153 bootstrappedRef.current = true;
apps/web/src/components/ChatView.tsx+184−17
@@ -1,9 +1,10 @@
11 import {
22 EDITORS,
33 type EditorId,
4+ type NativeApi,
45 type ProjectEntry,
6+ type ProjectScript,
57 ModelSlug,
6− type NativeApi,
78 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
89 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
910 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
@@ -66,10 +67,7 @@ import BranchToolbar from "./BranchToolbar"; · 56 unmodified lines
6667 import GitActionsControl from "./GitActionsControl";
6768 import {
6869 isOpenFavoriteEditorShortcut,
69− isTerminalCloseShortcut,
70− isTerminalNewShortcut,
71− isTerminalSplitShortcut,
72− isTerminalToggleShortcut,
70+ resolveShortcutCommand,
7371 shortcutLabelForCommand,
7472 } from "../keybindings";
7573 import ChatMarkdown from "./ChatMarkdown";
@@ -97,6 +95,13 @@ import { CursorIcon, Icon } from "./Icons"; · 21 unmodified lines
9795 import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
9896 import { Badge } from "./ui/badge";
9997 import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
98+import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
99+import {
100+ injectEnvIntoShellCommand,
101+ nextProjectScriptId,
102+ projectScriptIdFromCommand,
103+ setupProjectScript,
104+} from "~/projectScripts";
100105
101106 function formatMessageMeta(createdAt: string, duration: string | null): string {
102107 if (!duration) return formatTimestamp(createdAt);
@@ -117,6 +122,8 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ · 14 unmodified lines
117122 searchSlug: slug.toLowerCase(),
118123 searchName: name.toLowerCase(),
119124 }));
125+const SCRIPT_TERMINAL_COLS = 120;
126+const SCRIPT_TERMINAL_ROWS = 30;
120127
121128 function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
122129 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
@@ -618,6 +625,125 @@ export default function ChatView() { · 495 unmodified lines
618625 },
619626 [activeThread?.terminalIds.length, activeThreadId, api, dispatch],
620627 );
628+ const runProjectScript = useCallback(
629+ async (
630+ script: ProjectScript,
631+ options?: {
632+ cwd?: string;
633+ env?: Record<string, string>;
634+ preferNewTerminal?: boolean;
635+ },
636+ ) => {
637+ if (!api || !activeThreadId || !activeProject || !activeThread) return;
638+ const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
639+ const baseTerminalId = activeThread.activeTerminalId;
640+ const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
641+ const shouldCreateNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
642+ const targetTerminalId = shouldCreateNewTerminal
643+ ? `terminal-${crypto.randomUUID()}`
644+ : baseTerminalId;
645+
646+ dispatch({
647+ type: "SET_THREAD_TERMINAL_OPEN",
648+ threadId: activeThreadId,
649+ open: true,
650+ });
651+ if (shouldCreateNewTerminal) {
652+ dispatch({
653+ type: "NEW_THREAD_TERMINAL",
654+ threadId: activeThreadId,
655+ terminalId: targetTerminalId,
656+ });
657+ } else {
658+ dispatch({
659+ type: "SET_THREAD_ACTIVE_TERMINAL",
660+ threadId: activeThreadId,
661+ terminalId: targetTerminalId,
662+ });
663+ }
664+ setTerminalFocusRequestId((value) => value + 1);
665+
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;
672+
673+ try {
674+ await api.terminal.open({
675+ threadId: activeThreadId,
676+ terminalId: targetTerminalId,
677+ cwd: targetCwd,
678+ cols: SCRIPT_TERMINAL_COLS,
679+ rows: SCRIPT_TERMINAL_ROWS,
680+ });
681+ await api.terminal.write({
682+ threadId: activeThreadId,
683+ terminalId: targetTerminalId,
684+ data: `${resolvedCommand}\n`,
685+ });
686+ } catch (error) {
687+ dispatch({
688+ type: "SET_ERROR",
689+ threadId: activeThreadId,
690+ error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
691+ });
692+ }
693+ },
694+ [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd],
695+ );
696+ const saveProjectScript = useCallback(
697+ async (input: NewProjectScriptInput) => {
698+ if (!activeProject) return;
699+ const nextId = nextProjectScriptId(
700+ input.name,
701+ activeProject.scripts.map((script) => script.id),
702+ );
703+ const nextScript: ProjectScript = {
704+ id: nextId,
705+ name: input.name,
706+ command: input.command,
707+ icon: input.icon,
708+ runOnWorktreeCreate: input.runOnWorktreeCreate,
709+ };
710+ const nextScripts = input.runOnWorktreeCreate
711+ ? [
712+ ...activeProject.scripts.map((script) =>
713+ script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
714+ ),
715+ nextScript,
716+ ]
717+ : [...activeProject.scripts, nextScript];
718+
719+ dispatch({
720+ type: "SET_PROJECT_SCRIPTS",
721+ projectId: activeProject.id,
722+ scripts: nextScripts,
723+ });
724+
725+ if (!isElectron || !api) return;
726+ try {
727+ const updated = await api.projects.updateScripts({
728+ id: activeProject.id,
729+ scripts: nextScripts,
730+ });
731+ dispatch({
732+ type: "SET_PROJECT_SCRIPTS",
733+ projectId: updated.project.id,
734+ scripts: updated.project.scripts,
735+ });
736+ } catch (error) {
737+ dispatch({
738+ type: "SET_PROJECT_SCRIPTS",
739+ projectId: activeProject.id,
740+ scripts: activeProject.scripts,
741+ });
742+ throw error;
743+ }
744+ },
745+ [activeProject, api, dispatch],
746+ );
621747
622748 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
623749 if (mode === state.runtimeMode) return;
@@ -793,14 +919,17 @@ export default function ChatView() { · 169 unmodified lines
793919 terminalOpen: Boolean(activeThread?.terminalOpen),
794920 };
795921
796− if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
922+ const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext });
923+ if (!command) return;
924+
925+ if (command === "terminal.toggle") {
797926 event.preventDefault();
798927 event.stopPropagation();
799928 toggleTerminalVisibility();
800929 return;
801930 }
802931
803− if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
932+ if (command === "terminal.split") {
804933 event.preventDefault();
805934 event.stopPropagation();
806935 if (!activeThread?.terminalOpen) {
@@ -814,7 +943,7 @@ export default function ChatView() { · 7 unmodified lines
814943 return;
815944 }
816945
817− if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
946+ if (command === "terminal.close") {
818947 event.preventDefault();
819948 event.stopPropagation();
820949 if (!activeThread?.terminalOpen) return;
@@ -822,27 +951,39 @@ export default function ChatView() { · 1 unmodified line
822951 return;
823952 }
824953
825− if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
954+ if (command === "terminal.new") {
955+ event.preventDefault();
956+ event.stopPropagation();
957+ if (!activeThread?.terminalOpen) {
958+ dispatch({
959+ type: "SET_THREAD_TERMINAL_OPEN",
960+ threadId: activeThreadId,
961+ open: true,
962+ });
963+ }
964+ createNewTerminal();
965+ return;
966+ }
967+
968+ const scriptId = projectScriptIdFromCommand(command);
969+ if (!scriptId || !activeProject) return;
970+ const script = activeProject.scripts.find((entry) => entry.id === scriptId);
971+ if (!script) return;
826972 event.preventDefault();
827973 event.stopPropagation();
828− if (!activeThread?.terminalOpen) {
829− dispatch({
830− type: "SET_THREAD_TERMINAL_OPEN",
831− threadId: activeThreadId,
832− open: true,
833− });
834− }
835− createNewTerminal();
974+ void runProjectScript(script);
836975 };
837976 window.addEventListener("keydown", handler);
838977 return () => window.removeEventListener("keydown", handler);
839978 }, [
979+ activeProject,
840980 activeThread?.terminalOpen,
841981 activeThread?.activeTerminalId,
842982 activeThreadId,
843983 closeTerminal,
844984 createNewTerminal,
845985 dispatch,
986+ runProjectScript,
846987 splitTerminal,
847988 keybindings,
848989 toggleTerminalVisibility,
@@ -1047,6 +1188,13 @@ export default function ChatView() { · 198 unmodified lines
10471188 branch: result.worktree.branch,
10481189 worktreePath: result.worktree.path,
10491190 });
1191+ const setupScript = setupProjectScript(activeProject.scripts);
1192+ if (setupScript) {
1193+ void runProjectScript(setupScript, {
1194+ cwd: result.worktree.path,
1195+ env: { T3CODE_PROJECT_ROOT: activeProject.cwd },
1196+ });
1197+ }
10501198 } catch (err) {
10511199 dispatch({
10521200 type: "SET_ERROR",
@@ -1325,10 +1473,15 @@ export default function ChatView() { · 272 unmodified lines
13251473 <ChatHeader
13261474 activeThreadTitle={activeThread.title}
13271475 activeProjectName={activeProject?.name}
1476+ activeProjectScripts={activeProject?.scripts}
13281477 keybindings={keybindings}
13291478 api={api}
13301479 gitCwd={gitCwd}
13311480 diffOpen={state.diffOpen}
1481+ onRunProjectScript={(script) => {
1482+ void runProjectScript(script);
1483+ }}
1484+ onAddProjectScript={saveProjectScript}
13321485 onToggleDiff={onToggleDiff}
13331486 />
13341487 </header>
@@ -1652,20 +1805,26 @@ export default function ChatView() { · 317 unmodified lines
16521805 interface ChatHeaderProps {
16531806 activeThreadTitle: string;
16541807 activeProjectName: string | undefined;
1808+ activeProjectScripts: ProjectScript[] | undefined;
16551809 keybindings: ResolvedKeybindingsConfig;
16561810 api: NativeApi | undefined;
16571811 gitCwd: string | null;
16581812 diffOpen: boolean;
1813+ onRunProjectScript: (script: ProjectScript) => void;
1814+ onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
16591815 onToggleDiff: () => void;
16601816 }
16611817
16621818 const ChatHeader = memo(function ChatHeader({
16631819 activeThreadTitle,
16641820 activeProjectName,
1821+ activeProjectScripts,
16651822 keybindings,
16661823 api,
16671824 gitCwd,
16681825 diffOpen,
1826+ onRunProjectScript,
1827+ onAddProjectScript,
16691828 onToggleDiff,
16701829 }: ChatHeaderProps) {
16711830 return (
@@ -1677,6 +1836,14 @@ const ChatHeader = memo(function ChatHeader({ · 5 unmodified lines
16771836 {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}
16781837 </div>
16791838 <div className="shrink-0 flex items-center gap-3">
1839+ {activeProjectScripts && (
1840+ <ProjectScriptsControl
1841+ scripts={activeProjectScripts}
1842+ keybindings={keybindings}
1843+ onRunScript={onRunProjectScript}
1844+ onAddScript={onAddProjectScript}
1845+ />
1846+ )}
16801847 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
16811848 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
16821849 <Button
apps/web/src/components/ProjectScriptsControl.tsx+259−0
@@ -0,0 +1,259 @@
1+import type {
2+ ProjectScript,
3+ ProjectScriptIcon,
4+ ResolvedKeybindingsConfig,
5+} from "@t3tools/contracts";
6+import {
7+ ChevronDownIcon,
8+ FlaskConicalIcon,
9+ HammerIcon,
10+ ListChecksIcon,
11+ PlayIcon,
12+ PlusIcon,
13+ WrenchIcon,
14+} from "lucide-react";
15+import { type FormEvent, useMemo, useState } from "react";
16+
17+import {
18+ commandForProjectScript,
19+ nextProjectScriptId,
20+ primaryProjectScript,
21+} from "~/projectScripts";
22+import { shortcutLabelForCommand } from "~/keybindings";
23+import { Button } from "./ui/button";
24+import {
25+ Dialog,
26+ DialogDescription,
27+ DialogFooter,
28+ DialogHeader,
29+ DialogPanel,
30+ DialogPopup,
31+ DialogTitle,
32+} from "./ui/dialog";
33+import { Group, GroupSeparator } from "./ui/group";
34+import { Input } from "./ui/input";
35+import { Label } from "./ui/label";
36+import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
37+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
38+import { Switch } from "./ui/switch";
39+import { Textarea } from "./ui/textarea";
40+
41+const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
42+ { id: "play", label: "Play" },
43+ { id: "test", label: "Test" },
44+ { id: "lint", label: "Lint" },
45+ { id: "configure", label: "Configure" },
46+ { id: "build", label: "Build" },
47+];
48+
49+function ScriptIcon({
50+ icon,
51+ className = "size-3.5",
52+}: {
53+ icon: ProjectScriptIcon;
54+ className?: string;
55+}) {
56+ if (icon === "test") return <FlaskConicalIcon className={className} />;
57+ if (icon === "lint") return <ListChecksIcon className={className} />;
58+ if (icon === "configure") return <WrenchIcon className={className} />;
59+ if (icon === "build") return <HammerIcon className={className} />;
60+ return <PlayIcon className={className} />;
61+}
62+
63+export interface NewProjectScriptInput {
64+ name: string;
65+ command: string;
66+ icon: ProjectScriptIcon;
67+ runOnWorktreeCreate: boolean;
68+}
69+
70+interface ProjectScriptsControlProps {
71+ scripts: ProjectScript[];
72+ keybindings: ResolvedKeybindingsConfig;
73+ onRunScript: (script: ProjectScript) => void;
74+ onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
75+}
76+
77+export default function ProjectScriptsControl({
78+ scripts,
79+ keybindings,
80+ onRunScript,
81+ onAddScript,
82+}: ProjectScriptsControlProps) {
83+ const [dialogOpen, setDialogOpen] = useState(false);
84+ const [name, setName] = useState("");
85+ const [command, setCommand] = useState("");
86+ const [icon, setIcon] = useState<ProjectScriptIcon>("play");
87+ const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
88+ const [validationError, setValidationError] = useState<string | null>(null);
89+
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+ );
101+
102+ const submitAddScript = async (event: FormEvent) => {
103+ event.preventDefault();
104+ const trimmedName = name.trim();
105+ const trimmedCommand = command.trim();
106+ if (trimmedName.length === 0) {
107+ setValidationError("Name is required.");
108+ return;
109+ }
110+ if (trimmedCommand.length === 0) {
111+ setValidationError("Command is required.");
112+ return;
113+ }
114+
115+ setValidationError(null);
116+ try {
117+ await onAddScript({
118+ name: trimmedName,
119+ command: trimmedCommand,
120+ icon,
121+ runOnWorktreeCreate,
122+ });
123+ setDialogOpen(false);
124+ setName("");
125+ setCommand("");
126+ setIcon("play");
127+ setRunOnWorktreeCreate(false);
128+ } catch (error) {
129+ setValidationError(error instanceof Error ? error.message : "Failed to save action.");
130+ }
131+ };
132+
133+ const openDialog = () => {
134+ setValidationError(null);
135+ setDialogOpen(true);
136+ };
137+
138+ return (
139+ <>
140+ {primaryScript ? (
141+ <Group aria-label="Project scripts">
142+ <Button
143+ size="xs"
144+ variant="outline"
145+ onClick={() => onRunScript(primaryScript)}
146+ title={`Run ${primaryScript.name}`}
147+ >
148+ <ScriptIcon icon={primaryScript.icon} />
149+ <span>{primaryScript.name}</span>
150+ </Button>
151+ <GroupSeparator />
152+ <Menu>
153+ <MenuTrigger
154+ render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
155+ >
156+ <ChevronDownIcon className="size-4" />
157+ </MenuTrigger>
158+ <MenuPopup align="end">
159+ {scripts.map((script) => {
160+ const shortcutLabel = shortcutLabelForCommand(
161+ keybindings,
162+ commandForProjectScript(script.id),
163+ );
164+ return (
165+ <MenuItem key={script.id} onClick={() => onRunScript(script)}>
166+ <ScriptIcon icon={script.icon} className="size-4" />
167+ {script.runOnWorktreeCreate ? `${script.name} (Setup)` : script.name}
168+ {shortcutLabel && <MenuShortcut>{shortcutLabel}</MenuShortcut>}
169+ </MenuItem>
170+ );
171+ })}
172+ <MenuItem onClick={openDialog}>
173+ <PlusIcon className="size-4" />
174+ Add action
175+ </MenuItem>
176+ </MenuPopup>
177+ </Menu>
178+ </Group>
179+ ) : (
180+ <Button size="xs" variant="outline" onClick={openDialog}>
181+ <PlusIcon className="size-3.5" />
182+ Add action
183+ </Button>
184+ )}
185+
186+ <Dialog onOpenChange={setDialogOpen} open={dialogOpen}>
187+ <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">
196+ <div className="space-y-1.5">
197+ <Label htmlFor="script-name">Name</Label>
198+ <Input
199+ id="script-name"
200+ placeholder="Test"
201+ value={name}
202+ onChange={(event) => setName(event.target.value)}
203+ />
204+ <p className="text-xs text-muted-foreground">
205+ Keybinding command: <code>{keybindingCommandPreview}</code>
206+ </p>
207+ </div>
208+ <div className="space-y-1.5">
209+ <Label htmlFor="script-command">Command</Label>
210+ <Textarea
211+ id="script-command"
212+ placeholder="bun test"
213+ value={command}
214+ onChange={(event) => setCommand(event.target.value)}
215+ />
216+ </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">
240+ <span>Run automatically on worktree creation (Setup)</span>
241+ <Switch
242+ checked={runOnWorktreeCreate}
243+ onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
244+ />
245+ </label>
246+ {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>
255+ </DialogPopup>
256+ </Dialog>
257+ </>
258+ );
259+}
apps/web/src/components/Sidebar.tsx+2−0
@@ -214,6 +214,7 @@ export default function Sidebar() { · 213 unmodified lines
214214 cwd: result.project.cwd,
215215 model: DEFAULT_MODEL,
216216 expanded: true,
217+ scripts: result.project.scripts,
217218 };
218219 const existingById = state.projects.find((p) => p.id === project.id);
219220 const existingByCwd = state.projects.find((p) => p.cwd === project.cwd);
@@ -241,6 +242,7 @@ export default function Sidebar() { · 21 unmodified lines
241242 cwd,
242243 model: DEFAULT_MODEL,
243244 expanded: true,
245+ scripts: [],
244246 };
245247 dispatch({ type: "ADD_PROJECT", project });
246248 handleNewThread(project.id);
apps/web/src/keybindings.test.ts+16−0
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 isTerminalNewShortcut,
1717 isTerminalSplitShortcut,
1818 isTerminalToggleShortcut,
19+ resolveShortcutCommand,
1920 shortcutLabelForCommand,
2021 terminalNavigationShortcutData,
2122 type ShortcutEventLike,
@@ -339,6 +340,21 @@ describe("cross-command precedence", () => { · 317 unmodified lines
339340 });
340341 });
341342
343+describe("resolveShortcutCommand", () => {
344+ it("returns dynamic script commands", () => {
345+ const keybindings = compile([
346+ { shortcut: modShortcut("r"), command: "script.setup.run" },
347+ ]);
348+
349+ assert.strictEqual(
350+ resolveShortcutCommand(event({ key: "r", ctrlKey: true }), keybindings, {
351+ platform: "Linux",
352+ }),
353+ "script.setup.run",
354+ );
355+ });
356+});
357+
342358 describe("formatShortcutLabel", () => {
343359 it("formats labels for macOS", () => {
344360 assert.strictEqual(
apps/web/src/keybindings.ts+2−2
@@ -100,11 +100,11 @@ function matchesCommandShortcut( · 99 unmodified lines
100100 return resolveShortcutCommand(event, keybindings, options) === command;
101101 }
102102
103−function resolveShortcutCommand(
103+export function resolveShortcutCommand(
104104 event: ShortcutEventLike,
105105 keybindings: ResolvedKeybindingsConfig,
106106 options?: ShortcutMatchOptions,
107−): KeybindingCommand | null {
107+): string | null {
108108 const platform = resolvePlatform(options);
109109 const context = resolveContext(options);
110110
apps/web/src/persistenceSchema.test.ts+7−0
@@ -20,6 +20,7 @@ describe("hydratePersistedState", () => { · 19 unmodified lines
2020 cwd: "/tmp/project",
2121 model: "gpt-5.2-codex",
2222 expanded: true,
23+ scripts: [],
2324 },
2425 ],
2526 threads: [
@@ -69,6 +70,7 @@ describe("hydratePersistedState", () => { · 43 unmodified lines
6970 cwd: "/tmp/project",
7071 model: "gpt-5.3-codex",
7172 expanded: false,
73+ scripts: [],
7274 },
7375 ],
7476 threads: [
@@ -119,6 +121,7 @@ describe("hydratePersistedState", () => { · 44 unmodified lines
119121 cwd: "/tmp/project",
120122 model: "gpt-5.3-codex",
121123 expanded: false,
124+ scripts: [],
122125 },
123126 ],
124127 threads: [],
@@ -140,6 +143,7 @@ describe("hydratePersistedState", () => { · 15 unmodified lines
140143 cwd: "/tmp/project",
141144 model: "gpt-5.3-codex",
142145 expanded: true,
146+ scripts: [],
143147 },
144148 ],
145149 threads: [
@@ -187,6 +191,7 @@ describe("hydratePersistedState", () => { · 41 unmodified lines
187191 cwd: "/tmp/project",
188192 model: "gpt-5.3-codex",
189193 expanded: true,
194+ scripts: [],
190195 },
191196 ],
192197 threads: [
@@ -231,6 +236,7 @@ describe("hydratePersistedState", () => { · 38 unmodified lines
231236 cwd: "/tmp/project",
232237 model: "gpt-5.3-codex",
233238 expanded: true,
239+ scripts: [],
234240 },
235241 ],
236242 threads: [
@@ -313,6 +319,7 @@ describe("toPersistedState", () => { · 76 unmodified lines
313319 cwd: "/tmp/project",
314320 model: "gpt-5.3-codex",
315321 expanded: true,
322+ scripts: [],
316323 },
317324 ],
318325 threads: [thread],
apps/web/src/persistenceSchema.ts+3−0
@@ -1,4 +1,5 @@
11 import { z } from "zod";
2+import { projectScriptSchema } from "@t3tools/contracts";
23
34 import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
45 import {
@@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({ · 14 unmodified lines
1920 cwd: z.string().min(1),
2021 model: z.string().min(1),
2122 expanded: z.boolean(),
23+ scripts: z.array(projectScriptSchema).default([]),
2224 });
2325
2426 const persistedMessageSchema = z.object({
@@ -140,6 +142,7 @@ function hydrateProject( · 115 unmodified lines
140142 return {
141143 ...project,
142144 model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),
145+ scripts: project.scripts,
143146 };
144147 }
145148
apps/web/src/projectScripts.test.ts+57−0
@@ -0,0 +1,57 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ commandForProjectScript,
5+ injectEnvIntoShellCommand,
6+ nextProjectScriptId,
7+ primaryProjectScript,
8+ projectScriptIdFromCommand,
9+ setupProjectScript,
10+} from "./projectScripts";
11+
12+describe("projectScripts helpers", () => {
13+ it("builds and parses script run commands", () => {
14+ const command = commandForProjectScript("lint");
15+ expect(command).toBe("script.lint.run");
16+ expect(projectScriptIdFromCommand(command)).toBe("lint");
17+ expect(projectScriptIdFromCommand("terminal.toggle")).toBeNull();
18+ });
19+
20+ it("slugifies and dedupes project script ids", () => {
21+ expect(nextProjectScriptId("Run Tests", [])).toBe("run-tests");
22+ expect(nextProjectScriptId("Run Tests", ["run-tests"])).toBe("run-tests-2");
23+ expect(nextProjectScriptId("!!!", [])).toBe("script");
24+ });
25+
26+ it("injects environment variables for posix shells", () => {
27+ const command = injectEnvIntoShellCommand("bun install", { T3CODE_PROJECT_ROOT: "/tmp/project" }, "MacIntel");
28+ expect(command).toBe("env T3CODE_PROJECT_ROOT='/tmp/project' bun install");
29+ });
30+
31+ it("injects environment variables for windows shells", () => {
32+ const command = injectEnvIntoShellCommand("bun install", { T3CODE_PROJECT_ROOT: "C:\\\\repo path" }, "Win32");
33+ expect(command).toBe('set "T3CODE_PROJECT_ROOT=C:\\\\repo path" && bun install');
34+ });
35+
36+ it("resolves primary and setup scripts", () => {
37+ const scripts = [
38+ {
39+ id: "setup",
40+ name: "Setup",
41+ command: "bun install",
42+ icon: "configure" as const,
43+ runOnWorktreeCreate: true,
44+ },
45+ {
46+ id: "test",
47+ name: "Test",
48+ command: "bun test",
49+ icon: "test" as const,
50+ runOnWorktreeCreate: false,
51+ },
52+ ];
53+
54+ expect(primaryProjectScript(scripts)?.id).toBe("test");
55+ expect(setupProjectScript(scripts)?.id).toBe("setup");
56+ });
57+});
apps/web/src/projectScripts.ts+92−0
@@ -0,0 +1,92 @@
1+import type { ProjectScript } from "@t3tools/contracts";
2+
3+import { isWindowsPlatform } from "./lib/utils";
4+
5+const SCRIPT_RUN_COMMAND_PATTERN = /^script\.([a-z0-9][a-z0-9-]*)\.run$/;
6+const MAX_SCRIPT_ID_LENGTH = 64;
7+const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8+
9+function normalizeScriptId(value: string): string {
10+ const cleaned = value
11+ .trim()
12+ .toLowerCase()
13+ .replace(/[^a-z0-9]+/g, "-")
14+ .replace(/^-+|-+$/g, "");
15+ if (cleaned.length === 0) {
16+ return "script";
17+ }
18+ if (cleaned.length <= MAX_SCRIPT_ID_LENGTH) {
19+ return cleaned;
20+ }
21+ return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script";
22+}
23+
24+export function commandForProjectScript(scriptId: string): string {
25+ return `script.${scriptId}.run`;
26+}
27+
28+export function projectScriptIdFromCommand(command: string): string | null {
29+ const match = SCRIPT_RUN_COMMAND_PATTERN.exec(command.trim());
30+ return match?.[1] ?? null;
31+}
32+
33+export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string {
34+ const taken = new Set(Array.from(existingIds));
35+ const baseId = normalizeScriptId(name);
36+ if (!taken.has(baseId)) return baseId;
37+
38+ let suffix = 2;
39+ while (suffix < 10_000) {
40+ const candidate = `${baseId}-${suffix}`;
41+ const safeCandidate =
42+ candidate.length <= MAX_SCRIPT_ID_LENGTH
43+ ? candidate
44+ : `${baseId.slice(0, Math.max(1, MAX_SCRIPT_ID_LENGTH - String(suffix).length - 1))}-${suffix}`;
45+ if (!taken.has(safeCandidate)) {
46+ return safeCandidate;
47+ }
48+ suffix += 1;
49+ }
50+
51+ return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);
52+}
53+
54+function shellEscapePosix(value: string): string {
55+ return `'${value.replace(/'/g, `'\\''`)}'`;
56+}
57+
58+function shellEscapeWindowsSetValue(value: string): string {
59+ return value.replace(/"/g, '""');
60+}
61+
62+export function injectEnvIntoShellCommand(
63+ command: string,
64+ env: Record<string, string>,
65+ platform = typeof navigator === "undefined" ? "" : navigator.platform,
66+): string {
67+ const entries = Object.entries(env).filter(
68+ ([key, value]) => ENV_KEY_PATTERN.test(key) && value.length > 0,
69+ );
70+ if (entries.length === 0) {
71+ return command;
72+ }
73+
74+ if (isWindowsPlatform(platform)) {
75+ const prefixes = entries.map(
76+ ([key, value]) => `set "${key}=${shellEscapeWindowsSetValue(value)}"`,
77+ );
78+ return `${prefixes.join(" && ")} && ${command}`;
79+ }
80+
81+ const assignments = entries.map(([key, value]) => `${key}=${shellEscapePosix(value)}`).join(" ");
82+ return `env ${assignments} ${command}`;
83+}
84+
85+export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {
86+ const regular = scripts.find((script) => !script.runOnWorktreeCreate);
87+ return regular ?? scripts[0] ?? null;
88+}
89+
90+export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null {
91+ return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
92+}
apps/web/src/store.test.ts+33−0
@@ -74,6 +74,7 @@ function makeState(thread: Thread): AppState { · 73 unmodified lines
7474 cwd: "/tmp/project",
7575 model: "gpt-5.3-codex",
7676 expanded: true,
77+ scripts: [],
7778 },
7879 ],
7980 threads: [thread],
@@ -507,6 +508,7 @@ describe("store reducer thread continuity", () => { · 427 unmodified lines
507508 cwd: "/tmp/a",
508509 model: "gpt-5.3-codex",
509510 expanded: false,
511+ scripts: [],
510512 },
511513 {
512514 id: "project-old-b",
@@ -514,6 +516,7 @@ describe("store reducer thread continuity", () => { · 1 unmodified line
514516 cwd: "/tmp/b",
515517 model: "gpt-5.3-codex",
516518 expanded: true,
519+ scripts: [],
517520 },
518521 ],
519522 threads: [
@@ -540,6 +543,7 @@ describe("store reducer thread continuity", () => { · 20 unmodified lines
540543 cwd: "/tmp/a",
541544 model: "gpt-5.3-codex",
542545 expanded: true,
546+ scripts: [],
543547 },
544548 ],
545549 });
@@ -554,6 +558,33 @@ describe("store reducer thread continuity", () => { · 8 unmodified lines
554558 expect(next.activeThreadId).toBe("thread-a");
555559 });
556560
561+ it("updates project scripts", () => {
562+ const state = makeState(makeThread());
563+ const next = reducer(state, {
564+ type: "SET_PROJECT_SCRIPTS",
565+ projectId: "project-1",
566+ scripts: [
567+ {
568+ id: "test",
569+ name: "Test",
570+ command: "bun test",
571+ icon: "test",
572+ runOnWorktreeCreate: false,
573+ },
574+ ],
575+ });
576+
577+ expect(next.projects[0]?.scripts).toEqual([
578+ {
579+ id: "test",
580+ name: "Test",
581+ command: "bun test",
582+ icon: "test",
583+ runOnWorktreeCreate: false,
584+ },
585+ ]);
586+ });
587+
557588 it("deletes a project and all of its threads", () => {
558589 const state: AppState = {
559590 projects: [
@@ -563,6 +594,7 @@ describe("store reducer thread continuity", () => { · 3 unmodified lines
563594 cwd: "/tmp/one",
564595 model: "gpt-5.3-codex",
565596 expanded: true,
597+ scripts: [],
566598 },
567599 {
568600 id: "project-2",
@@ -570,6 +602,7 @@ describe("store reducer thread continuity", () => { · 1 unmodified line
570602 cwd: "/tmp/two",
571603 model: "gpt-5.3-codex",
572604 expanded: true,
605+ scripts: [],
573606 },
574607 ],
575608 threads: [
apps/web/src/store.ts+50−0
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 DEFAULT_THREAD_TERMINAL_ID,
1818 DEFAULT_RUNTIME_MODE,
1919 MAX_THREAD_TERMINAL_COUNT,
20+ type ProjectScript,
2021 type Project,
2122 type RuntimeMode,
2223 type Thread,
@@ -27,6 +28,7 @@ import { · 4 unmodified lines
2728
2829 type Action =
2930 | { type: "ADD_PROJECT"; project: Project }
31+ | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
3032 | { type: "SYNC_PROJECTS"; projects: Project[] }
3133 | { type: "TOGGLE_PROJECT"; projectId: string }
3234 | { type: "DELETE_PROJECT"; projectId: string }
@@ -163,6 +165,38 @@ function normalizeRunningTerminalIds( · 130 unmodified lines
163165 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
164166 }
165167
168+function normalizeProjectScripts(scripts: ProjectScript[]): ProjectScript[] {
169+ const deduped: ProjectScript[] = [];
170+ const seenIds = new Set<string>();
171+ for (const script of scripts) {
172+ const id = script.id.trim();
173+ if (id.length === 0 || seenIds.has(id)) continue;
174+ seenIds.add(id);
175+ deduped.push({
176+ ...script,
177+ id,
178+ name: script.name.trim(),
179+ command: script.command.trim(),
180+ });
181+ }
182+
183+ let setupAssigned = false;
184+ const normalized: ProjectScript[] = [];
185+ for (const script of deduped) {
186+ if (!script.runOnWorktreeCreate) {
187+ normalized.push(script);
188+ continue;
189+ }
190+ if (!setupAssigned) {
191+ setupAssigned = true;
192+ normalized.push(script);
193+ continue;
194+ }
195+ normalized.push({ ...script, runOnWorktreeCreate: false });
196+ }
197+ return normalized;
198+}
199+
166200 function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
167201 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
168202 }
@@ -426,20 +460,36 @@ export function reducer(state: AppState, action: Action): AppState { · 257 unmodified lines
426460 {
427461 ...action.project,
428462 model: resolveModelSlug(action.project.model),
463+ scripts: normalizeProjectScripts(action.project.scripts),
429464 },
430465 ],
431466 };
432467
468+ case "SET_PROJECT_SCRIPTS":
469+ return {
470+ ...state,
471+ projects: state.projects.map((project) =>
472+ project.id === action.projectId
473+ ? { ...project, scripts: normalizeProjectScripts(action.scripts) }
474+ : project,
475+ ),
476+ };
477+
433478 case "SYNC_PROJECTS": {
434479 const previousByCwd = new Map(
435480 state.projects.map((project) => [project.cwd, project] as const),
436481 );
437482 const nextProjects = action.projects.map((project) => {
438483 const previous = previousByCwd.get(project.cwd);
484+ const scripts =
485+ project.scripts.length > 0
486+ ? normalizeProjectScripts(project.scripts)
487+ : normalizeProjectScripts(previous?.scripts ?? []);
439488 return {
440489 ...project,
441490 model: resolveModelSlug(previous?.model ?? project.model),
442491 expanded: previous?.expanded ?? project.expanded,
492+ scripts,
443493 };
444494 });
445495 const previousProjectById = new Map(
apps/web/src/types.ts+7−1
@@ -1,4 +1,8 @@
1−import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
1+import type {
2+ ProjectScript as ContractProjectScript,
3+ ProviderEvent,
4+ ProviderSession,
5+} from "@t3tools/contracts";
26
37 export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
48 export type RuntimeMode = "approval-required" | "full-access";
@@ -6,6 +10,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; · 1 unmodified line
610 export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
711 export const DEFAULT_THREAD_TERMINAL_ID = "default";
812 export const MAX_THREAD_TERMINAL_COUNT = 4;
13+export type ProjectScript = ContractProjectScript;
914
1015 export interface ThreadTerminalGroup {
1116 id: string;
@@ -38,6 +43,7 @@ export interface Project { · 26 unmodified lines
3843 cwd: string;
3944 model: string;
4045 expanded: boolean;
46+ scripts: ProjectScript[];
4147 }
4248
4349 export interface Thread {
apps/web/src/wsNativeApi.ts+1−0
@@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi { · 98 unmodified lines
9999 add: (input) => transport.request(WS_METHODS.projectsAdd, input),
100100 remove: (input) => transport.request(WS_METHODS.projectsRemove, input),
101101 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
102+ updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input),
102103 },
103104 shell: {
104105 openInEditor: (cwd, editor) =>
packages/contracts/src/ipc.ts+3−0
@@ -32,6 +32,8 @@ import type { · 31 unmodified lines
3232 ProjectSearchEntriesInput,
3333 ProjectSearchEntriesResult,
3434 ProjectRemoveInput,
35+ ProjectUpdateScriptsInput,
36+ ProjectUpdateScriptsResult,
3537 } from "./project";
3638 import type { ServerConfig } from "./server";
3739 import type {
@@ -93,6 +95,7 @@ export interface NativeApi { · 55 unmodified lines
9395 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
9496 remove: (input: ProjectRemoveInput) => Promise<void>;
9597 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
98+ updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>;
9699 };
97100 shell: {
98101 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
packages/contracts/src/keybindings.test.ts+9−1
@@ -32,11 +32,19 @@ describe("keybindings contracts", () => { · 31 unmodified lines
3232 assert.throws(() =>
3333 keybindingRuleSchema.parse({
3434 key: "mod+j",
35− command: "invalid.command",
35+ command: "script.Test.run",
3636 }),
3737 );
3838 });
3939
40+ it("accepts dynamic script run commands", () => {
41+ const parsed = keybindingRuleSchema.parse({
42+ key: "mod+r",
43+ command: "script.setup.run",
44+ });
45+ assert.strictEqual(parsed.command, "script.setup.run");
46+ });
47+
4048 it("parses keybindings array payload", () => {
4149 const parsed = keybindingsConfigSchema.parse([
4250 { key: "mod+j", command: "terminal.toggle" },
packages/contracts/src/keybindings.ts+11−1
@@ -1,6 +1,6 @@
11 import { z } from "zod";
22
3−export const keybindingCommandSchema = z.enum([
3+export const STATIC_KEYBINDING_COMMANDS = [
44 "terminal.toggle",
55 "terminal.split",
66 "terminal.new",
@@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([ · 1 unmodified line
88 "chat.new",
99 "chat.newLocal",
1010 "editor.openFavorite",
11+] as const;
12+
13+export const keybindingCommandSchema = z.union([
14+ z.enum(STATIC_KEYBINDING_COMMANDS),
15+ z
16+ .string()
17+ .trim()
18+ .min(1)
19+ .max(96)
20+ .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/),
1121 ]);
1222
1323 const keybindingValueSchema = z.string().trim().min(1).max(64);
packages/contracts/src/project.test.ts+45−0
@@ -7,6 +7,8 @@ import { · 6 unmodified lines
77 projectSearchEntriesResultSchema,
88 projectListResultSchema,
99 projectRemoveInputSchema,
10+ projectUpdateScriptsInputSchema,
11+ projectUpdateScriptsResultSchema,
1012 } from "./project";
1113
1214 describe("project contracts", () => {
@@ -16,6 +18,7 @@ describe("project contracts", () => { · 3 unmodified lines
1618 id: "project-1",
1719 cwd: "/tmp/project",
1820 name: "project",
21+ scripts: [],
1922 createdAt: "2026-02-10T00:00:00.000Z",
2023 updatedAt: "2026-02-10T00:00:00.000Z",
2124 },
@@ -36,6 +39,7 @@ describe("project contracts", () => { · 14 unmodified lines
3639 id: "project-1",
3740 cwd: "/tmp/project",
3841 name: "project",
42+ scripts: [],
3943 createdAt: "2026-02-10T00:00:00.000Z",
4044 updatedAt: "2026-02-10T00:00:00.000Z",
4145 },
@@ -82,4 +86,45 @@ describe("project contracts", () => { · 40 unmodified lines
8286 expect(parsed.entries).toHaveLength(2);
8387 expect(parsed.entries[0]?.kind).toBe("directory");
8488 });
89+
90+ it("parses update scripts input", () => {
91+ const parsed = projectUpdateScriptsInputSchema.parse({
92+ id: "project-1",
93+ scripts: [
94+ {
95+ id: "test",
96+ name: "Test",
97+ command: "bun test",
98+ icon: "test",
99+ runOnWorktreeCreate: false,
100+ },
101+ ],
102+ });
103+
104+ expect(parsed.scripts).toHaveLength(1);
105+ expect(parsed.scripts[0]?.id).toBe("test");
106+ });
107+
108+ it("parses update scripts result", () => {
109+ const parsed = projectUpdateScriptsResultSchema.parse({
110+ project: {
111+ id: "project-1",
112+ cwd: "/tmp/project",
113+ name: "project",
114+ scripts: [
115+ {
116+ id: "setup",
117+ name: "Setup",
118+ command: "bun install",
119+ icon: "configure",
120+ runOnWorktreeCreate: true,
121+ },
122+ ],
123+ createdAt: "2026-02-10T00:00:00.000Z",
124+ updatedAt: "2026-02-10T00:00:00.000Z",
125+ },
126+ });
127+
128+ expect(parsed.project.scripts[0]?.id).toBe("setup");
129+ });
85130 });
packages/contracts/src/project.ts+35−0
@@ -1,9 +1,31 @@
11 import { z } from "zod";
22
3+export const PROJECT_SCRIPT_ICON_VALUES = ["play", "test", "lint", "configure", "build"] as const;
4+
5+export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);
6+
7+export const projectScriptSchema = z
8+ .object({
9+ id: z
10+ .string()
11+ .trim()
12+ .min(1)
13+ .max(64)
14+ .regex(/^[a-z0-9][a-z0-9-]*$/),
15+ name: z.string().trim().min(1).max(64),
16+ command: z.string().trim().min(1).max(10_000),
17+ icon: projectScriptIconSchema,
18+ runOnWorktreeCreate: z.boolean().default(false),
19+ })
20+ .strict();
21+
22+export const projectScriptsSchema = z.array(projectScriptSchema).max(64);
23+
324 export const projectRecordSchema = z.object({
425 id: z.string().min(1),
526 cwd: z.string().min(1),
627 name: z.string().min(1),
28+ scripts: projectScriptsSchema.default([]),
729 createdAt: z.string().datetime(),
830 updatedAt: z.string().datetime(),
931 });
@@ -44,6 +66,17 @@ export const projectSearchEntriesResultSchema = z.object({ · 34 unmodified lines
4466 truncated: z.boolean(),
4567 });
4668
69+export const projectUpdateScriptsInputSchema = z.object({
70+ id: z.string().min(1),
71+ scripts: projectScriptsSchema,
72+});
73+
74+export const projectUpdateScriptsResultSchema = z.object({
75+ project: projectRecordSchema,
76+});
77+
78+export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>;
79+export type ProjectScript = z.infer<typeof projectScriptSchema>;
4780 export type ProjectRecord = z.infer<typeof projectRecordSchema>;
4881 export type ProjectListResult = z.infer<typeof projectListResultSchema>;
4982 export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
@@ -53,3 +86,5 @@ export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInput · 3 unmodified lines
5386 export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
5487 export type ProjectEntry = z.infer<typeof projectEntrySchema>;
5588 export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;
89+export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>;
90+export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>;
packages/contracts/src/ws.ts+1−0
@@ -16,6 +16,7 @@ export const WS_METHODS = { · 15 unmodified lines
1616 projectsAdd: "projects.add",
1717 projectsRemove: "projects.remove",
1818 projectsSearchEntries: "projects.searchEntries",
19+ projectsUpdateScripts: "projects.updateScripts",
1920
2021 // Shell methods
2122 shellOpenInEditor: "shell.openInEditor",