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...@@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
53- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state53- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
54- `chat.newLocal`: create a new local chat thread for the active project (no worktree context)54- `chat.newLocal`: create a new local chat thread for the active project (no worktree context)
55- `editor.openFavorite`: open current project/worktree in the last-used editor55- `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
57### Key Syntax58### 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 {...@@ -8,11 +8,30 @@ import {
8 type ProjectListResult,8 type ProjectListResult,
9 type ProjectRecord,9 type ProjectRecord,
10 type ProjectRemoveInput,10 type ProjectRemoveInput,
11 type ProjectScript,
12 type ProjectUpdateScriptsInput,
13 type ProjectUpdateScriptsResult,
11 projectAddInputSchema,14 projectAddInputSchema,
12 projectRecordSchema,15 projectRecordSchema,
13 projectRemoveInputSchema,16 projectRemoveInputSchema,
17 projectScriptsSchema,
18 projectUpdateScriptsInputSchema,
14} from "@t3tools/contracts";19} 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
16function normalizeCwd(rawCwd: string): string {35function normalizeCwd(rawCwd: string): string {
17 const resolved = path.resolve(rawCwd.trim());36 const resolved = path.resolve(rawCwd.trim());
18 const normalized = path.normalize(resolved);37 const normalized = path.normalize(resolved);
...@@ -48,7 +67,10 @@ export class ProjectRegistry {...@@ -48,7 +67,10 @@ export class ProjectRegistry {
48 }67 }
4968
50 list(): ProjectListResult {69 list(): ProjectListResult {
51 return this.projects.map((project) => ({ ...project }));70 return this.projects.map((project) => ({
71 ...project,
72 scripts: cloneScripts(project.scripts),
73 }));
52 }74 }
5375
54 add(raw: ProjectAddInput): ProjectAddResult {76 add(raw: ProjectAddInput): ProjectAddResult {
...@@ -60,7 +82,13 @@ export class ProjectRegistry {...@@ -60,7 +82,13 @@ export class ProjectRegistry {
6082
61 const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd);83 const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd);
62 if (existing) {84 if (existing) {
63 return { project: { ...existing }, created: false };85 return {
86 project: {
87 ...existing,
88 scripts: cloneScripts(existing.scripts),
89 },
90 created: false,
91 };
64 }92 }
6593
66 const now = new Date().toISOString();94 const now = new Date().toISOString();
...@@ -68,13 +96,20 @@ export class ProjectRegistry {...@@ -68,13 +96,20 @@ export class ProjectRegistry {
68 id: randomUUID(),96 id: randomUUID(),
69 cwd: normalizedCwd,97 cwd: normalizedCwd,
70 name: inferProjectName(normalizedCwd),98 name: inferProjectName(normalizedCwd),
99 scripts: [],
71 createdAt: now,100 createdAt: now,
72 updatedAt: now,101 updatedAt: now,
73 };102 };
74103
75 this.projects = [project, ...this.projects];104 this.projects = [project, ...this.projects];
76 this.persist();105 this.persist();
77 return { project: { ...project }, created: true };106 return {
107 project: {
108 ...project,
109 scripts: cloneScripts(project.scripts),
110 },
111 created: true,
112 };
78 }113 }
79114
80 remove(raw: ProjectRemoveInput): void {115 remove(raw: ProjectRemoveInput): void {
...@@ -88,6 +123,31 @@ export class ProjectRegistry {...@@ -88,6 +123,31 @@ export class ProjectRegistry {
88 this.persist();123 this.persist();
89 }124 }
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
91 private loadFromDisk(): ProjectRecord[] {151 private loadFromDisk(): ProjectRecord[] {
92 const loaded = this.readPersistedProjects();152 const loaded = this.readPersistedProjects();
93 const deduped = new Map<string, ProjectRecord>();153 const deduped = new Map<string, ProjectRecord>();
...@@ -107,6 +167,7 @@ export class ProjectRegistry {...@@ -107,6 +167,7 @@ export class ProjectRegistry {
107 cwd: normalizedCwd,167 cwd: normalizedCwd,
108 name:168 name:
109 project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd),169 project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd),
170 scripts: dedupeScriptsById(project.scripts),
110 };171 };
111 deduped.set(normalizedCwd, normalizedProject);172 deduped.set(normalizedCwd, normalizedProject);
112 normalizedProjects.push(normalizedProject);173 normalizedProjects.push(normalizedProject);
...@@ -121,6 +182,7 @@ export class ProjectRegistry {...@@ -121,6 +182,7 @@ export class ProjectRegistry {
121 source.id !== project.id ||182 source.id !== project.id ||
122 source.cwd !== project.cwd ||183 source.cwd !== project.cwd ||
123 source.name !== project.name ||184 source.name !== project.name ||
185 JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) ||
124 source.createdAt !== project.createdAt ||186 source.createdAt !== project.createdAt ||
125 source.updatedAt !== project.updatedAt187 source.updatedAt !== project.updatedAt
126 );188 );
...@@ -170,3 +232,35 @@ export class ProjectRegistry {...@@ -170,3 +232,35 @@ export class ProjectRegistry {
170 fs.renameSync(tempFile, this.filePath);232 fs.renameSync(tempFile, this.filePath);
171 }233 }
172}234}
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", () => {...@@ -756,6 +756,42 @@ describe("WebSocket Server", () => {
756 expect(projectId).toBeTruthy();756 expect(projectId).toBeTruthy();
757 if (!projectId) return;757 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
759 const removed = await sendRequest(ws, WS_METHODS.projectsRemove, {795 const removed = await sendRequest(ws, WS_METHODS.projectsRemove, {
760 id: projectId,796 id: projectId,
761 });797 });
apps/server/src/wsServer.ts+2
...@@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) {...@@ -322,6 +322,8 @@ export function createServer(options: ServerOptions) {
322322
323 case WS_METHODS.projectsSearchEntries:323 case WS_METHODS.projectsSearchEntries:
324 return searchWorkspaceEntries(request.params as never);324 return searchWorkspaceEntries(request.params as never);
325 case WS_METHODS.projectsUpdateScripts:
326 return projectRegistry.updateScripts(request.params as never);
325327
326 case WS_METHODS.shellOpenInEditor: {328 case WS_METHODS.shellOpenInEditor: {
327 const params = request.params as {329 const params = request.params as {
apps/web/src/App.tsx+2
...@@ -85,6 +85,7 @@ function AutoProjectBootstrap() {...@@ -85,6 +85,7 @@ function AutoProjectBootstrap() {
85 cwd: payload.cwd,85 cwd: payload.cwd,
86 model: DEFAULT_MODEL,86 model: DEFAULT_MODEL,
87 expanded: true,87 expanded: true,
88 scripts: [],
88 },89 },
89 });90 });
90 dispatch({91 dispatch({
...@@ -146,6 +147,7 @@ function DesktopProjectBootstrap() {...@@ -146,6 +147,7 @@ function DesktopProjectBootstrap() {
146 cwd: project.cwd,147 cwd: project.cwd,
147 model: DEFAULT_MODEL,148 model: DEFAULT_MODEL,
148 expanded: true,149 expanded: true,
150 scripts: project.scripts,
149 })),151 })),
150 });152 });
151 bootstrappedRef.current = true;153 bootstrappedRef.current = true;
apps/web/src/components/ChatView.tsx+184-17
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import {1import {
2 EDITORS,2 EDITORS,
3 type EditorId,3 type EditorId,
4 type NativeApi,
4 type ProjectEntry,5 type ProjectEntry,
6 type ProjectScript,
5 ModelSlug,7 ModelSlug,
6 type NativeApi,
7 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,8 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
8 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
9 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,10 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
...@@ -66,10 +67,7 @@ import BranchToolbar from "./BranchToolbar";...@@ -66,10 +67,7 @@ import BranchToolbar from "./BranchToolbar";
66import GitActionsControl from "./GitActionsControl";67import GitActionsControl from "./GitActionsControl";
67import {68import {
68 isOpenFavoriteEditorShortcut,69 isOpenFavoriteEditorShortcut,
69 isTerminalCloseShortcut,70 resolveShortcutCommand,
70 isTerminalNewShortcut,
71 isTerminalSplitShortcut,
72 isTerminalToggleShortcut,
73 shortcutLabelForCommand,71 shortcutLabelForCommand,
74} from "../keybindings";72} from "../keybindings";
75import ChatMarkdown from "./ChatMarkdown";73import ChatMarkdown from "./ChatMarkdown";
...@@ -97,6 +95,13 @@ import { CursorIcon, Icon } from "./Icons";...@@ -97,6 +95,13 @@ import { CursorIcon, Icon } from "./Icons";
97import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";95import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
98import { Badge } from "./ui/badge";96import { Badge } from "./ui/badge";
99import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";97import { 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
101function formatMessageMeta(createdAt: string, duration: string | null): string {106function formatMessageMeta(createdAt: string, duration: string | null): string {
102 if (!duration) return formatTimestamp(createdAt);107 if (!duration) return formatTimestamp(createdAt);
...@@ -117,6 +122,8 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({...@@ -117,6 +122,8 @@ const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({
117 searchSlug: slug.toLowerCase(),122 searchSlug: slug.toLowerCase(),
118 searchName: name.toLowerCase(),123 searchName: name.toLowerCase(),
119}));124}));
125const SCRIPT_TERMINAL_COLS = 120;
126const SCRIPT_TERMINAL_ROWS = 30;
120127
121function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {128function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
122 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";129 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
...@@ -618,6 +625,125 @@ export default function ChatView() {...@@ -618,6 +625,125 @@ export default function ChatView() {
618 },625 },
619 [activeThread?.terminalIds.length, activeThreadId, api, dispatch],626 [activeThread?.terminalIds.length, activeThreadId, api, dispatch],
620 );627 );
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
622 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {748 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
623 if (mode === state.runtimeMode) return;749 if (mode === state.runtimeMode) return;
...@@ -793,14 +919,17 @@ export default function ChatView() {...@@ -793,14 +919,17 @@ export default function ChatView() {
793 terminalOpen: Boolean(activeThread?.terminalOpen),919 terminalOpen: Boolean(activeThread?.terminalOpen),
794 };920 };
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") {
797 event.preventDefault();926 event.preventDefault();
798 event.stopPropagation();927 event.stopPropagation();
799 toggleTerminalVisibility();928 toggleTerminalVisibility();
800 return;929 return;
801 }930 }
802931
803 if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {932 if (command === "terminal.split") {
804 event.preventDefault();933 event.preventDefault();
805 event.stopPropagation();934 event.stopPropagation();
806 if (!activeThread?.terminalOpen) {935 if (!activeThread?.terminalOpen) {
...@@ -814,7 +943,7 @@ export default function ChatView() {...@@ -814,7 +943,7 @@ export default function ChatView() {
814 return;943 return;
815 }944 }
816945
817 if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {946 if (command === "terminal.close") {
818 event.preventDefault();947 event.preventDefault();
819 event.stopPropagation();948 event.stopPropagation();
820 if (!activeThread?.terminalOpen) return;949 if (!activeThread?.terminalOpen) return;
...@@ -822,27 +951,39 @@ export default function ChatView() {...@@ -822,27 +951,39 @@ export default function ChatView() {
822 return;951 return;
823 }952 }
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;
826 event.preventDefault();972 event.preventDefault();
827 event.stopPropagation();973 event.stopPropagation();
828 if (!activeThread?.terminalOpen) {974 void runProjectScript(script);
829 dispatch({
830 type: "SET_THREAD_TERMINAL_OPEN",
831 threadId: activeThreadId,
832 open: true,
833 });
834 }
835 createNewTerminal();
836 };975 };
837 window.addEventListener("keydown", handler);976 window.addEventListener("keydown", handler);
838 return () => window.removeEventListener("keydown", handler);977 return () => window.removeEventListener("keydown", handler);
839 }, [978 }, [
979 activeProject,
840 activeThread?.terminalOpen,980 activeThread?.terminalOpen,
841 activeThread?.activeTerminalId,981 activeThread?.activeTerminalId,
842 activeThreadId,982 activeThreadId,
843 closeTerminal,983 closeTerminal,
844 createNewTerminal,984 createNewTerminal,
845 dispatch,985 dispatch,
986 runProjectScript,
846 splitTerminal,987 splitTerminal,
847 keybindings,988 keybindings,
848 toggleTerminalVisibility,989 toggleTerminalVisibility,
...@@ -1047,6 +1188,13 @@ export default function ChatView() {...@@ -1047,6 +1188,13 @@ export default function ChatView() {
1047 branch: result.worktree.branch,1188 branch: result.worktree.branch,
1048 worktreePath: result.worktree.path,1189 worktreePath: result.worktree.path,
1049 });1190 });
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 }
1050 } catch (err) {1198 } catch (err) {
1051 dispatch({1199 dispatch({
1052 type: "SET_ERROR",1200 type: "SET_ERROR",
...@@ -1325,10 +1473,15 @@ export default function ChatView() {...@@ -1325,10 +1473,15 @@ export default function ChatView() {
1325 <ChatHeader1473 <ChatHeader
1326 activeThreadTitle={activeThread.title}1474 activeThreadTitle={activeThread.title}
1327 activeProjectName={activeProject?.name}1475 activeProjectName={activeProject?.name}
1476 activeProjectScripts={activeProject?.scripts}
1328 keybindings={keybindings}1477 keybindings={keybindings}
1329 api={api}1478 api={api}
1330 gitCwd={gitCwd}1479 gitCwd={gitCwd}
1331 diffOpen={state.diffOpen}1480 diffOpen={state.diffOpen}
1481 onRunProjectScript={(script) => {
1482 void runProjectScript(script);
1483 }}
1484 onAddProjectScript={saveProjectScript}
1332 onToggleDiff={onToggleDiff}1485 onToggleDiff={onToggleDiff}
1333 />1486 />
1334 </header>1487 </header>
...@@ -1652,20 +1805,26 @@ export default function ChatView() {...@@ -1652,20 +1805,26 @@ export default function ChatView() {
1652interface ChatHeaderProps {1805interface ChatHeaderProps {
1653 activeThreadTitle: string;1806 activeThreadTitle: string;
1654 activeProjectName: string | undefined;1807 activeProjectName: string | undefined;
1808 activeProjectScripts: ProjectScript[] | undefined;
1655 keybindings: ResolvedKeybindingsConfig;1809 keybindings: ResolvedKeybindingsConfig;
1656 api: NativeApi | undefined;1810 api: NativeApi | undefined;
1657 gitCwd: string | null;1811 gitCwd: string | null;
1658 diffOpen: boolean;1812 diffOpen: boolean;
1813 onRunProjectScript: (script: ProjectScript) => void;
1814 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
1659 onToggleDiff: () => void;1815 onToggleDiff: () => void;
1660}1816}
16611817
1662const ChatHeader = memo(function ChatHeader({1818const ChatHeader = memo(function ChatHeader({
1663 activeThreadTitle,1819 activeThreadTitle,
1664 activeProjectName,1820 activeProjectName,
1821 activeProjectScripts,
1665 keybindings,1822 keybindings,
1666 api,1823 api,
1667 gitCwd,1824 gitCwd,
1668 diffOpen,1825 diffOpen,
1826 onRunProjectScript,
1827 onAddProjectScript,
1669 onToggleDiff,1828 onToggleDiff,
1670}: ChatHeaderProps) {1829}: ChatHeaderProps) {
1671 return (1830 return (
...@@ -1677,6 +1836,14 @@ const ChatHeader = memo(function ChatHeader({...@@ -1677,6 +1836,14 @@ const ChatHeader = memo(function ChatHeader({
1677 {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}1836 {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}
1678 </div>1837 </div>
1679 <div className="shrink-0 flex items-center gap-3">1838 <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 )}
1680 {activeProjectName && <OpenInPicker keybindings={keybindings} />}1847 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
1681 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}1848 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
1682 <Button1849 <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() {...@@ -214,6 +214,7 @@ export default function Sidebar() {
214 cwd: result.project.cwd,214 cwd: result.project.cwd,
215 model: DEFAULT_MODEL,215 model: DEFAULT_MODEL,
216 expanded: true,216 expanded: true,
217 scripts: result.project.scripts,
217 };218 };
218 const existingById = state.projects.find((p) => p.id === project.id);219 const existingById = state.projects.find((p) => p.id === project.id);
219 const existingByCwd = state.projects.find((p) => p.cwd === project.cwd);220 const existingByCwd = state.projects.find((p) => p.cwd === project.cwd);
...@@ -241,6 +242,7 @@ export default function Sidebar() {...@@ -241,6 +242,7 @@ export default function Sidebar() {
241 cwd,242 cwd,
242 model: DEFAULT_MODEL,243 model: DEFAULT_MODEL,
243 expanded: true,244 expanded: true,
245 scripts: [],
244 };246 };
245 dispatch({ type: "ADD_PROJECT", project });247 dispatch({ type: "ADD_PROJECT", project });
246 handleNewThread(project.id);248 handleNewThread(project.id);
apps/web/src/keybindings.test.ts+16
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 isTerminalNewShortcut,16 isTerminalNewShortcut,
17 isTerminalSplitShortcut,17 isTerminalSplitShortcut,
18 isTerminalToggleShortcut,18 isTerminalToggleShortcut,
19 resolveShortcutCommand,
19 shortcutLabelForCommand,20 shortcutLabelForCommand,
20 terminalNavigationShortcutData,21 terminalNavigationShortcutData,
21 type ShortcutEventLike,22 type ShortcutEventLike,
...@@ -339,6 +340,21 @@ describe("cross-command precedence", () => {...@@ -339,6 +340,21 @@ describe("cross-command precedence", () => {
339 });340 });
340});341});
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
342describe("formatShortcutLabel", () => {358describe("formatShortcutLabel", () => {
343 it("formats labels for macOS", () => {359 it("formats labels for macOS", () => {
344 assert.strictEqual(360 assert.strictEqual(
apps/web/src/keybindings.ts+2-2
...@@ -100,11 +100,11 @@ function matchesCommandShortcut(...@@ -100,11 +100,11 @@ function matchesCommandShortcut(
100 return resolveShortcutCommand(event, keybindings, options) === command;100 return resolveShortcutCommand(event, keybindings, options) === command;
101}101}
102102
103function resolveShortcutCommand(103export function resolveShortcutCommand(
104 event: ShortcutEventLike,104 event: ShortcutEventLike,
105 keybindings: ResolvedKeybindingsConfig,105 keybindings: ResolvedKeybindingsConfig,
106 options?: ShortcutMatchOptions,106 options?: ShortcutMatchOptions,
107): KeybindingCommand | null {107): string | null {
108 const platform = resolvePlatform(options);108 const platform = resolvePlatform(options);
109 const context = resolveContext(options);109 const context = resolveContext(options);
110110
apps/web/src/persistenceSchema.test.ts+7
...@@ -20,6 +20,7 @@ describe("hydratePersistedState", () => {...@@ -20,6 +20,7 @@ describe("hydratePersistedState", () => {
20 cwd: "/tmp/project",20 cwd: "/tmp/project",
21 model: "gpt-5.2-codex",21 model: "gpt-5.2-codex",
22 expanded: true,22 expanded: true,
23 scripts: [],
23 },24 },
24 ],25 ],
25 threads: [26 threads: [
...@@ -69,6 +70,7 @@ describe("hydratePersistedState", () => {...@@ -69,6 +70,7 @@ describe("hydratePersistedState", () => {
69 cwd: "/tmp/project",70 cwd: "/tmp/project",
70 model: "gpt-5.3-codex",71 model: "gpt-5.3-codex",
71 expanded: false,72 expanded: false,
73 scripts: [],
72 },74 },
73 ],75 ],
74 threads: [76 threads: [
...@@ -119,6 +121,7 @@ describe("hydratePersistedState", () => {...@@ -119,6 +121,7 @@ describe("hydratePersistedState", () => {
119 cwd: "/tmp/project",121 cwd: "/tmp/project",
120 model: "gpt-5.3-codex",122 model: "gpt-5.3-codex",
121 expanded: false,123 expanded: false,
124 scripts: [],
122 },125 },
123 ],126 ],
124 threads: [],127 threads: [],
...@@ -140,6 +143,7 @@ describe("hydratePersistedState", () => {...@@ -140,6 +143,7 @@ describe("hydratePersistedState", () => {
140 cwd: "/tmp/project",143 cwd: "/tmp/project",
141 model: "gpt-5.3-codex",144 model: "gpt-5.3-codex",
142 expanded: true,145 expanded: true,
146 scripts: [],
143 },147 },
144 ],148 ],
145 threads: [149 threads: [
...@@ -187,6 +191,7 @@ describe("hydratePersistedState", () => {...@@ -187,6 +191,7 @@ describe("hydratePersistedState", () => {
187 cwd: "/tmp/project",191 cwd: "/tmp/project",
188 model: "gpt-5.3-codex",192 model: "gpt-5.3-codex",
189 expanded: true,193 expanded: true,
194 scripts: [],
190 },195 },
191 ],196 ],
192 threads: [197 threads: [
...@@ -231,6 +236,7 @@ describe("hydratePersistedState", () => {...@@ -231,6 +236,7 @@ describe("hydratePersistedState", () => {
231 cwd: "/tmp/project",236 cwd: "/tmp/project",
232 model: "gpt-5.3-codex",237 model: "gpt-5.3-codex",
233 expanded: true,238 expanded: true,
239 scripts: [],
234 },240 },
235 ],241 ],
236 threads: [242 threads: [
...@@ -313,6 +319,7 @@ describe("toPersistedState", () => {...@@ -313,6 +319,7 @@ describe("toPersistedState", () => {
313 cwd: "/tmp/project",319 cwd: "/tmp/project",
314 model: "gpt-5.3-codex",320 model: "gpt-5.3-codex",
315 expanded: true,321 expanded: true,
322 scripts: [],
316 },323 },
317 ],324 ],
318 threads: [thread],325 threads: [thread],
apps/web/src/persistenceSchema.ts+3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { z } from "zod";1import { z } from "zod";
2import { projectScriptSchema } from "@t3tools/contracts";
23
3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";4import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import {5import {
...@@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({...@@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({
19 cwd: z.string().min(1),20 cwd: z.string().min(1),
20 model: z.string().min(1),21 model: z.string().min(1),
21 expanded: z.boolean(),22 expanded: z.boolean(),
23 scripts: z.array(projectScriptSchema).default([]),
22});24});
2325
24const persistedMessageSchema = z.object({26const persistedMessageSchema = z.object({
...@@ -140,6 +142,7 @@ function hydrateProject(...@@ -140,6 +142,7 @@ function hydrateProject(
140 return {142 return {
141 ...project,143 ...project,
142 model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),144 model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),
145 scripts: project.scripts,
143 };146 };
144}147}
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 {...@@ -74,6 +74,7 @@ function makeState(thread: Thread): AppState {
74 cwd: "/tmp/project",74 cwd: "/tmp/project",
75 model: "gpt-5.3-codex",75 model: "gpt-5.3-codex",
76 expanded: true,76 expanded: true,
77 scripts: [],
77 },78 },
78 ],79 ],
79 threads: [thread],80 threads: [thread],
...@@ -507,6 +508,7 @@ describe("store reducer thread continuity", () => {...@@ -507,6 +508,7 @@ describe("store reducer thread continuity", () => {
507 cwd: "/tmp/a",508 cwd: "/tmp/a",
508 model: "gpt-5.3-codex",509 model: "gpt-5.3-codex",
509 expanded: false,510 expanded: false,
511 scripts: [],
510 },512 },
511 {513 {
512 id: "project-old-b",514 id: "project-old-b",
...@@ -514,6 +516,7 @@ describe("store reducer thread continuity", () => {...@@ -514,6 +516,7 @@ describe("store reducer thread continuity", () => {
514 cwd: "/tmp/b",516 cwd: "/tmp/b",
515 model: "gpt-5.3-codex",517 model: "gpt-5.3-codex",
516 expanded: true,518 expanded: true,
519 scripts: [],
517 },520 },
518 ],521 ],
519 threads: [522 threads: [
...@@ -540,6 +543,7 @@ describe("store reducer thread continuity", () => {...@@ -540,6 +543,7 @@ describe("store reducer thread continuity", () => {
540 cwd: "/tmp/a",543 cwd: "/tmp/a",
541 model: "gpt-5.3-codex",544 model: "gpt-5.3-codex",
542 expanded: true,545 expanded: true,
546 scripts: [],
543 },547 },
544 ],548 ],
545 });549 });
...@@ -554,6 +558,33 @@ describe("store reducer thread continuity", () => {...@@ -554,6 +558,33 @@ describe("store reducer thread continuity", () => {
554 expect(next.activeThreadId).toBe("thread-a");558 expect(next.activeThreadId).toBe("thread-a");
555 });559 });
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
557 it("deletes a project and all of its threads", () => {588 it("deletes a project and all of its threads", () => {
558 const state: AppState = {589 const state: AppState = {
559 projects: [590 projects: [
...@@ -563,6 +594,7 @@ describe("store reducer thread continuity", () => {...@@ -563,6 +594,7 @@ describe("store reducer thread continuity", () => {
563 cwd: "/tmp/one",594 cwd: "/tmp/one",
564 model: "gpt-5.3-codex",595 model: "gpt-5.3-codex",
565 expanded: true,596 expanded: true,
597 scripts: [],
566 },598 },
567 {599 {
568 id: "project-2",600 id: "project-2",
...@@ -570,6 +602,7 @@ describe("store reducer thread continuity", () => {...@@ -570,6 +602,7 @@ describe("store reducer thread continuity", () => {
570 cwd: "/tmp/two",602 cwd: "/tmp/two",
571 model: "gpt-5.3-codex",603 model: "gpt-5.3-codex",
572 expanded: true,604 expanded: true,
605 scripts: [],
573 },606 },
574 ],607 ],
575 threads: [608 threads: [
apps/web/src/store.ts+50
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 DEFAULT_THREAD_TERMINAL_ID,17 DEFAULT_THREAD_TERMINAL_ID,
18 DEFAULT_RUNTIME_MODE,18 DEFAULT_RUNTIME_MODE,
19 MAX_THREAD_TERMINAL_COUNT,19 MAX_THREAD_TERMINAL_COUNT,
20 type ProjectScript,
20 type Project,21 type Project,
21 type RuntimeMode,22 type RuntimeMode,
22 type Thread,23 type Thread,
...@@ -27,6 +28,7 @@ import {...@@ -27,6 +28,7 @@ import {
2728
28type Action =29type Action =
29 | { type: "ADD_PROJECT"; project: Project }30 | { type: "ADD_PROJECT"; project: Project }
31 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
30 | { type: "SYNC_PROJECTS"; projects: Project[] }32 | { type: "SYNC_PROJECTS"; projects: Project[] }
31 | { type: "TOGGLE_PROJECT"; projectId: string }33 | { type: "TOGGLE_PROJECT"; projectId: string }
32 | { type: "DELETE_PROJECT"; projectId: string }34 | { type: "DELETE_PROJECT"; projectId: string }
...@@ -163,6 +165,38 @@ function normalizeRunningTerminalIds(...@@ -163,6 +165,38 @@ function normalizeRunningTerminalIds(
163 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));165 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
164}166}
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
166function normalizeTerminalGroupIds(terminalIds: string[]): string[] {200function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
167 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];201 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
168}202}
...@@ -426,20 +460,36 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -426,20 +460,36 @@ export function reducer(state: AppState, action: Action): AppState {
426 {460 {
427 ...action.project,461 ...action.project,
428 model: resolveModelSlug(action.project.model),462 model: resolveModelSlug(action.project.model),
463 scripts: normalizeProjectScripts(action.project.scripts),
429 },464 },
430 ],465 ],
431 };466 };
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
433 case "SYNC_PROJECTS": {478 case "SYNC_PROJECTS": {
434 const previousByCwd = new Map(479 const previousByCwd = new Map(
435 state.projects.map((project) => [project.cwd, project] as const),480 state.projects.map((project) => [project.cwd, project] as const),
436 );481 );
437 const nextProjects = action.projects.map((project) => {482 const nextProjects = action.projects.map((project) => {
438 const previous = previousByCwd.get(project.cwd);483 const previous = previousByCwd.get(project.cwd);
484 const scripts =
485 project.scripts.length > 0
486 ? normalizeProjectScripts(project.scripts)
487 : normalizeProjectScripts(previous?.scripts ?? []);
439 return {488 return {
440 ...project,489 ...project,
441 model: resolveModelSlug(previous?.model ?? project.model),490 model: resolveModelSlug(previous?.model ?? project.model),
442 expanded: previous?.expanded ?? project.expanded,491 expanded: previous?.expanded ?? project.expanded,
492 scripts,
443 };493 };
444 });494 });
445 const previousProjectById = new Map(495 const previousProjectById = new Map(
apps/web/src/types.ts+7-1
...@@ -1,4 +1,8 @@...@@ -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
3export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";7export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
4export type RuntimeMode = "approval-required" | "full-access";8export type RuntimeMode = "approval-required" | "full-access";
...@@ -6,6 +10,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";...@@ -6,6 +10,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;10export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
7export const DEFAULT_THREAD_TERMINAL_ID = "default";11export const DEFAULT_THREAD_TERMINAL_ID = "default";
8export const MAX_THREAD_TERMINAL_COUNT = 4;12export const MAX_THREAD_TERMINAL_COUNT = 4;
13export type ProjectScript = ContractProjectScript;
914
10export interface ThreadTerminalGroup {15export interface ThreadTerminalGroup {
11 id: string;16 id: string;
...@@ -38,6 +43,7 @@ export interface Project {...@@ -38,6 +43,7 @@ export interface Project {
38 cwd: string;43 cwd: string;
39 model: string;44 model: string;
40 expanded: boolean;45 expanded: boolean;
46 scripts: ProjectScript[];
41}47}
4248
43export interface Thread {49export interface Thread {
apps/web/src/wsNativeApi.ts+1
...@@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi {...@@ -99,6 +99,7 @@ export function createWsNativeApi(): NativeApi {
99 add: (input) => transport.request(WS_METHODS.projectsAdd, input),99 add: (input) => transport.request(WS_METHODS.projectsAdd, input),
100 remove: (input) => transport.request(WS_METHODS.projectsRemove, input),100 remove: (input) => transport.request(WS_METHODS.projectsRemove, input),
101 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),101 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
102 updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input),
102 },103 },
103 shell: {104 shell: {
104 openInEditor: (cwd, editor) =>105 openInEditor: (cwd, editor) =>
packages/contracts/src/ipc.ts+3
...@@ -32,6 +32,8 @@ import type {...@@ -32,6 +32,8 @@ import type {
32 ProjectSearchEntriesInput,32 ProjectSearchEntriesInput,
33 ProjectSearchEntriesResult,33 ProjectSearchEntriesResult,
34 ProjectRemoveInput,34 ProjectRemoveInput,
35 ProjectUpdateScriptsInput,
36 ProjectUpdateScriptsResult,
35} from "./project";37} from "./project";
36import type { ServerConfig } from "./server";38import type { ServerConfig } from "./server";
37import type {39import type {
...@@ -93,6 +95,7 @@ export interface NativeApi {...@@ -93,6 +95,7 @@ export interface NativeApi {
93 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;95 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
94 remove: (input: ProjectRemoveInput) => Promise<void>;96 remove: (input: ProjectRemoveInput) => Promise<void>;
95 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;97 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
98 updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>;
96 };99 };
97 shell: {100 shell: {
98 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;101 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
packages/contracts/src/keybindings.test.ts+9-1
...@@ -32,11 +32,19 @@ describe("keybindings contracts", () => {...@@ -32,11 +32,19 @@ describe("keybindings contracts", () => {
32 assert.throws(() =>32 assert.throws(() =>
33 keybindingRuleSchema.parse({33 keybindingRuleSchema.parse({
34 key: "mod+j",34 key: "mod+j",
35 command: "invalid.command",35 command: "script.Test.run",
36 }),36 }),
37 );37 );
38 });38 });
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
40 it("parses keybindings array payload", () => {48 it("parses keybindings array payload", () => {
41 const parsed = keybindingsConfigSchema.parse([49 const parsed = keybindingsConfigSchema.parse([
42 { key: "mod+j", command: "terminal.toggle" },50 { key: "mod+j", command: "terminal.toggle" },
packages/contracts/src/keybindings.ts+11-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { z } from "zod";1import { z } from "zod";
22
3export const keybindingCommandSchema = z.enum([3export const STATIC_KEYBINDING_COMMANDS = [
4 "terminal.toggle",4 "terminal.toggle",
5 "terminal.split",5 "terminal.split",
6 "terminal.new",6 "terminal.new",
...@@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([...@@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([
8 "chat.new",8 "chat.new",
9 "chat.newLocal",9 "chat.newLocal",
10 "editor.openFavorite",10 "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$/),
11]);21]);
1222
13const keybindingValueSchema = z.string().trim().min(1).max(64);23const keybindingValueSchema = z.string().trim().min(1).max(64);
packages/contracts/src/project.test.ts+45
...@@ -7,6 +7,8 @@ import {...@@ -7,6 +7,8 @@ import {
7 projectSearchEntriesResultSchema,7 projectSearchEntriesResultSchema,
8 projectListResultSchema,8 projectListResultSchema,
9 projectRemoveInputSchema,9 projectRemoveInputSchema,
10 projectUpdateScriptsInputSchema,
11 projectUpdateScriptsResultSchema,
10} from "./project";12} from "./project";
1113
12describe("project contracts", () => {14describe("project contracts", () => {
...@@ -16,6 +18,7 @@ describe("project contracts", () => {...@@ -16,6 +18,7 @@ describe("project contracts", () => {
16 id: "project-1",18 id: "project-1",
17 cwd: "/tmp/project",19 cwd: "/tmp/project",
18 name: "project",20 name: "project",
21 scripts: [],
19 createdAt: "2026-02-10T00:00:00.000Z",22 createdAt: "2026-02-10T00:00:00.000Z",
20 updatedAt: "2026-02-10T00:00:00.000Z",23 updatedAt: "2026-02-10T00:00:00.000Z",
21 },24 },
...@@ -36,6 +39,7 @@ describe("project contracts", () => {...@@ -36,6 +39,7 @@ describe("project contracts", () => {
36 id: "project-1",39 id: "project-1",
37 cwd: "/tmp/project",40 cwd: "/tmp/project",
38 name: "project",41 name: "project",
42 scripts: [],
39 createdAt: "2026-02-10T00:00:00.000Z",43 createdAt: "2026-02-10T00:00:00.000Z",
40 updatedAt: "2026-02-10T00:00:00.000Z",44 updatedAt: "2026-02-10T00:00:00.000Z",
41 },45 },
...@@ -82,4 +86,45 @@ describe("project contracts", () => {...@@ -82,4 +86,45 @@ describe("project contracts", () => {
82 expect(parsed.entries).toHaveLength(2);86 expect(parsed.entries).toHaveLength(2);
83 expect(parsed.entries[0]?.kind).toBe("directory");87 expect(parsed.entries[0]?.kind).toBe("directory");
84 });88 });
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 });
85});130});
packages/contracts/src/project.ts+35
...@@ -1,9 +1,31 @@...@@ -1,9 +1,31 @@
1import { z } from "zod";1import { 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
3export const projectRecordSchema = z.object({24export const projectRecordSchema = z.object({
4 id: z.string().min(1),25 id: z.string().min(1),
5 cwd: z.string().min(1),26 cwd: z.string().min(1),
6 name: z.string().min(1),27 name: z.string().min(1),
28 scripts: projectScriptsSchema.default([]),
7 createdAt: z.string().datetime(),29 createdAt: z.string().datetime(),
8 updatedAt: z.string().datetime(),30 updatedAt: z.string().datetime(),
9});31});
...@@ -44,6 +66,17 @@ export const projectSearchEntriesResultSchema = z.object({...@@ -44,6 +66,17 @@ export const projectSearchEntriesResultSchema = z.object({
44 truncated: z.boolean(),66 truncated: z.boolean(),
45});67});
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>;
47export type ProjectRecord = z.infer<typeof projectRecordSchema>;80export type ProjectRecord = z.infer<typeof projectRecordSchema>;
48export type ProjectListResult = z.infer<typeof projectListResultSchema>;81export type ProjectListResult = z.infer<typeof projectListResultSchema>;
49export type ProjectAddInput = z.input<typeof projectAddInputSchema>;82export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
...@@ -53,3 +86,5 @@ export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInput...@@ -53,3 +86,5 @@ export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInput
53export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;86export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
54export type ProjectEntry = z.infer<typeof projectEntrySchema>;87export type ProjectEntry = z.infer<typeof projectEntrySchema>;
55export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;88export 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 = {...@@ -16,6 +16,7 @@ export const WS_METHODS = {
16 projectsAdd: "projects.add",16 projectsAdd: "projects.add",
17 projectsRemove: "projects.remove",17 projectsRemove: "projects.remove",
18 projectsSearchEntries: "projects.searchEntries",18 projectsSearchEntries: "projects.searchEntries",
19 projectsUpdateScripts: "projects.updateScripts",
1920
20 // Shell methods21 // Shell methods
21 shellOpenInEditor: "shell.openInEditor",22 shellOpenInEditor: "shell.openInEditor",