authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 23:07:33-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 00:59:06-08:00
log3537d477027565cd0d2c1661ff2e41d8f20ea166
tree11cf922eefa567dd5486af4236947b5b6c4c0d15
parent9e877f592bad913a5d6e9168becab35a5d78605d

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

25 files changed, 1068 insertions(+), 25 deletions(-)

KEYBINDINGS.md+1
......@@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
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 created+113
......@@ -0,0 +1,113 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { ProjectRegistry } from "./projectRegistry";
8
9const tempDirs: string[] = [];
10
11function makeTempDir(prefix: string): string {
12 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13 tempDirs.push(dir);
14 return dir;
15}
16
17afterEach(() => {
18 for (const dir of tempDirs.splice(0, tempDirs.length)) {
19 fs.rmSync(dir, { recursive: true, force: true });
20 }
21});
22
23describe("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 {
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
21function 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
1635function normalizeCwd(rawCwd: string): string {
1736 const resolved = path.resolve(rawCwd.trim());
1837 const normalized = path.normalize(resolved);
......@@ -48,7 +67,10 @@ export class ProjectRegistry {
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 {
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 {
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 {
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 {
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 {
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 {
170232 fs.renameSync(tempFile, this.filePath);
171233 }
172234}
235
236function 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
......@@ -756,6 +756,42 @@ describe("WebSocket Server", () => {
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
......@@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) {
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
......@@ -85,6 +85,7 @@ function AutoProjectBootstrap() {
8585 cwd: payload.cwd,
8686 model: DEFAULT_MODEL,
8787 expanded: true,
88 scripts: [],
8889 },
8990 });
9091 dispatch({
......@@ -146,6 +147,7 @@ function DesktopProjectBootstrap() {
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 @@
11import {
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";
6667import GitActionsControl from "./GitActionsControl";
6768import {
6869 isOpenFavoriteEditorShortcut,
69 isTerminalCloseShortcut,
70 isTerminalNewShortcut,
71 isTerminalSplitShortcut,
72 isTerminalToggleShortcut,
70 resolveShortcutCommand,
7371 shortcutLabelForCommand,
7472} from "../keybindings";
7573import ChatMarkdown from "./ChatMarkdown";
......@@ -97,6 +95,13 @@ import { CursorIcon, Icon } from "./Icons";
9795import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
9896import { Badge } from "./ui/badge";
9997import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
98import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
99import {
100 injectEnvIntoShellCommand,
101 nextProjectScriptId,
102 projectScriptIdFromCommand,
103 setupProjectScript,
104} from "~/projectScripts";
100105
101106function 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 }) => ({
117122 searchSlug: slug.toLowerCase(),
118123 searchName: name.toLowerCase(),
119124}));
125const SCRIPT_TERMINAL_COLS = 120;
126const SCRIPT_TERMINAL_ROWS = 30;
120127
121128function 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() {
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() {
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() {
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() {
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() {
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() {
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() {
16521805interface 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
16621818const 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({
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 created+259
......@@ -0,0 +1,259 @@
1import type {
2 ProjectScript,
3 ProjectScriptIcon,
4 ResolvedKeybindingsConfig,
5} from "@t3tools/contracts";
6import {
7 ChevronDownIcon,
8 FlaskConicalIcon,
9 HammerIcon,
10 ListChecksIcon,
11 PlayIcon,
12 PlusIcon,
13 WrenchIcon,
14} from "lucide-react";
15import { type FormEvent, useMemo, useState } from "react";
16
17import {
18 commandForProjectScript,
19 nextProjectScriptId,
20 primaryProjectScript,
21} from "~/projectScripts";
22import { shortcutLabelForCommand } from "~/keybindings";
23import { Button } from "./ui/button";
24import {
25 Dialog,
26 DialogDescription,
27 DialogFooter,
28 DialogHeader,
29 DialogPanel,
30 DialogPopup,
31 DialogTitle,
32} from "./ui/dialog";
33import { Group, GroupSeparator } from "./ui/group";
34import { Input } from "./ui/input";
35import { Label } from "./ui/label";
36import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
37import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
38import { Switch } from "./ui/switch";
39import { Textarea } from "./ui/textarea";
40
41const 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
49function 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
63export interface NewProjectScriptInput {
64 name: string;
65 command: string;
66 icon: ProjectScriptIcon;
67 runOnWorktreeCreate: boolean;
68}
69
70interface ProjectScriptsControlProps {
71 scripts: ProjectScript[];
72 keybindings: ResolvedKeybindingsConfig;
73 onRunScript: (script: ProjectScript) => void;
74 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
75}
76
77export 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
......@@ -214,6 +214,7 @@ export default function Sidebar() {
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() {
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
......@@ -16,6 +16,7 @@ import {
1616 isTerminalNewShortcut,
1717 isTerminalSplitShortcut,
1818 isTerminalToggleShortcut,
19 resolveShortcutCommand,
1920 shortcutLabelForCommand,
2021 terminalNavigationShortcutData,
2122 type ShortcutEventLike,
......@@ -339,6 +340,21 @@ describe("cross-command precedence", () => {
339340 });
340341});
341342
343describe("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
342358describe("formatShortcutLabel", () => {
343359 it("formats labels for macOS", () => {
344360 assert.strictEqual(
apps/web/src/keybindings.ts+2-2
......@@ -100,11 +100,11 @@ function matchesCommandShortcut(
100100 return resolveShortcutCommand(event, keybindings, options) === command;
101101}
102102
103function resolveShortcutCommand(
103export 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
......@@ -20,6 +20,7 @@ describe("hydratePersistedState", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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
......@@ -1,4 +1,5 @@
11import { z } from "zod";
2import { projectScriptSchema } from "@t3tools/contracts";
23
34import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
45import {
......@@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({
1920 cwd: z.string().min(1),
2021 model: z.string().min(1),
2122 expanded: z.boolean(),
23 scripts: z.array(projectScriptSchema).default([]),
2224});
2325
2426const persistedMessageSchema = z.object({
......@@ -140,6 +142,7 @@ function hydrateProject(
140142 return {
141143 ...project,
142144 model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),
145 scripts: project.scripts,
143146 };
144147}
145148
apps/web/src/projectScripts.test.ts created+57
......@@ -0,0 +1,57 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 commandForProjectScript,
5 injectEnvIntoShellCommand,
6 nextProjectScriptId,
7 primaryProjectScript,
8 projectScriptIdFromCommand,
9 setupProjectScript,
10} from "./projectScripts";
11
12describe("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 created+92
......@@ -0,0 +1,92 @@
1import type { ProjectScript } from "@t3tools/contracts";
2
3import { isWindowsPlatform } from "./lib/utils";
4
5const SCRIPT_RUN_COMMAND_PATTERN = /^script\.([a-z0-9][a-z0-9-]*)\.run$/;
6const MAX_SCRIPT_ID_LENGTH = 64;
7const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
9function 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
24export function commandForProjectScript(scriptId: string): string {
25 return `script.${scriptId}.run`;
26}
27
28export function projectScriptIdFromCommand(command: string): string | null {
29 const match = SCRIPT_RUN_COMMAND_PATTERN.exec(command.trim());
30 return match?.[1] ?? null;
31}
32
33export 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
54function shellEscapePosix(value: string): string {
55 return `'${value.replace(/'/g, `'\\''`)}'`;
56}
57
58function shellEscapeWindowsSetValue(value: string): string {
59 return value.replace(/"/g, '""');
60}
61
62export 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
85export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {
86 const regular = scripts.find((script) => !script.runOnWorktreeCreate);
87 return regular ?? scripts[0] ?? null;
88}
89
90export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null {
91 return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
92}
apps/web/src/store.test.ts+33
......@@ -74,6 +74,7 @@ function makeState(thread: Thread): AppState {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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
......@@ -17,6 +17,7 @@ import {
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 {
2728
2829type 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(
163165 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
164166}
165167
168function 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
166200function 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 {
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 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
1import type {
2 ProjectScript as ContractProjectScript,
3 ProviderEvent,
4 ProviderSession,
5} from "@t3tools/contracts";
26
37export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
48export type RuntimeMode = "approval-required" | "full-access";
......@@ -6,6 +10,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
610export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
711export const DEFAULT_THREAD_TERMINAL_ID = "default";
812export const MAX_THREAD_TERMINAL_COUNT = 4;
13export type ProjectScript = ContractProjectScript;
914
1015export interface ThreadTerminalGroup {
1116 id: string;
......@@ -38,6 +43,7 @@ export interface Project {
3843 cwd: string;
3944 model: string;
4045 expanded: boolean;
46 scripts: ProjectScript[];
4147}
4248
4349export interface Thread {
apps/web/src/wsNativeApi.ts+1
......@@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi {
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
......@@ -32,6 +32,8 @@ import type {
3232 ProjectSearchEntriesInput,
3333 ProjectSearchEntriesResult,
3434 ProjectRemoveInput,
35 ProjectUpdateScriptsInput,
36 ProjectUpdateScriptsResult,
3537} from "./project";
3638import type { ServerConfig } from "./server";
3739import type {
......@@ -93,6 +95,7 @@ export interface NativeApi {
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", () => {
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 @@
11import { z } from "zod";
22
3export const keybindingCommandSchema = z.enum([
3export const STATIC_KEYBINDING_COMMANDS = [
44 "terminal.toggle",
55 "terminal.split",
66 "terminal.new",
......@@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([
88 "chat.new",
99 "chat.newLocal",
1010 "editor.openFavorite",
11] as const;
12
13export 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
1323const keybindingValueSchema = z.string().trim().min(1).max(64);
packages/contracts/src/project.test.ts+45
......@@ -7,6 +7,8 @@ import {
77 projectSearchEntriesResultSchema,
88 projectListResultSchema,
99 projectRemoveInputSchema,
10 projectUpdateScriptsInputSchema,
11 projectUpdateScriptsResultSchema,
1012} from "./project";
1113
1214describe("project contracts", () => {
......@@ -16,6 +18,7 @@ describe("project contracts", () => {
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", () => {
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", () => {
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
......@@ -1,9 +1,31 @@
11import { z } from "zod";
22
3export const PROJECT_SCRIPT_ICON_VALUES = ["play", "test", "lint", "configure", "build"] as const;
4
5export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);
6
7export 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
22export const projectScriptsSchema = z.array(projectScriptSchema).max(64);
23
324export 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({
4466 truncated: z.boolean(),
4567});
4668
69export const projectUpdateScriptsInputSchema = z.object({
70 id: z.string().min(1),
71 scripts: projectScriptsSchema,
72});
73
74export const projectUpdateScriptsResultSchema = z.object({
75 project: projectRecordSchema,
76});
77
78export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>;
79export type ProjectScript = z.infer<typeof projectScriptSchema>;
4780export type ProjectRecord = z.infer<typeof projectRecordSchema>;
4881export type ProjectListResult = z.infer<typeof projectListResultSchema>;
4982export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
......@@ -53,3 +86,5 @@ export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInput
5386export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
5487export type ProjectEntry = z.infer<typeof projectEntrySchema>;
5588export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;
89export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>;
90export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>;
packages/contracts/src/ws.ts+1
......@@ -16,6 +16,7 @@ export const WS_METHODS = {
1616 projectsAdd: "projects.add",
1717 projectsRemove: "projects.remove",
1818 projectsSearchEntries: "projects.searchEntries",
19 projectsUpdateScripts: "projects.updateScripts",
1920
2021 // Shell methods
2122 shellOpenInEditor: "shell.openInEditor",