From fac40deb0ecdc43ca62ddb9372bcc6860af6608a Mon Sep 17 00:00:00 2001 From: JamesOBrien2 Date: Fri, 23 Oct 2026 15:38:07 +0100 Subject: [PATCH 1/5] feat(project-actions): add agent prompt actions Project actions can now be prompts as well as shell scripts. A prompt runs through the normal message path in the invoking thread, queues behind active work, and can switch the thread to a saved model and reasoning selection first. Prompt actions persist through server settings and t3.json, appear in the web and mobile action menus, and cannot claim setup or settle lifecycle roles. New threads ask for a first message before a prompt action can run. --- .../features/threads/DraftThreadHeader.tsx | 1 + .../features/threads/ThreadGitControls.tsx | 11 +- .../features/threads/ThreadRouteScreen.tsx | 24 +- .../orchestration-v2/CommandPolicy.test.ts | 23 ++ apps/server/src/serverSettings.test.ts | 82 ++++++ apps/web/src/components/ChatView.tsx | 10 +- .../src/components/ProjectScriptsControl.tsx | 2 +- .../components/chat/useChatTerminalActions.ts | 60 +++- .../projectScriptEditor.browser.test.tsx | 39 +++ .../src/components/projectScriptEditor.tsx | 268 ++++++++++++++---- .../settings/ProjectActionsList.tsx | 13 +- .../settings/ProjectActionsSettings.tsx | 2 +- apps/web/src/projectScripts.test.ts | 50 ++++ apps/web/src/projectScripts.ts | 18 +- docs/user/keybindings.md | 2 +- docs/user/project-settings.md | 8 + .../src/operations/commands.test.ts | 172 +++++++++++ .../client-runtime/src/operations/commands.ts | 46 ++- .../src/state/threadCommands.ts | 8 + packages/contracts/src/project.test.ts | 45 +++ packages/contracts/src/project.ts | 23 +- packages/shared/src/projectScripts.ts | 17 +- 22 files changed, 840 insertions(+), 84 deletions(-) diff --git a/apps/mobile/src/features/threads/DraftThreadHeader.tsx b/apps/mobile/src/features/threads/DraftThreadHeader.tsx index 509545b58c8bb..429a50fe63d9f 100644 --- a/apps/mobile/src/features/threads/DraftThreadHeader.tsx +++ b/apps/mobile/src/features/threads/DraftThreadHeader.tsx @@ -71,6 +71,7 @@ export function DraftThreadHeader(props: { canOperateTerminal: false, canOpenFiles: canReadFiles && props.cwd !== null, projectScripts: [], + canRunPrompt: false, terminalSessions: [], onOpenFilesInspector: openFiles, onOpenGitInspector: requiresThread, diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index e49f4caadf8fa..c731fc408cb4f 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -103,6 +103,7 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { }; readonly canOpenTerminal: boolean; readonly canOperateTerminal: boolean; + readonly canRunPrompt: boolean; readonly canOpenFiles: boolean; readonly projectScripts: ReadonlyArray; readonly terminalSessions: ReadonlyArray; @@ -281,8 +282,8 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit menu: { items: [ ...props.projectScripts.map((script) => ({ - description: script.command, - disabled: !props.canOperateTerminal, + description: script.type === "prompt" ? script.prompt : script.command, + disabled: script.type === "prompt" ? !props.canRunPrompt : !props.canOperateTerminal, icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const }, label: projectScriptMenuLabel(script), onPress: () => void props.onRunProjectScript(script), @@ -450,9 +451,11 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { void props.onRunProjectScript(script)} - subtitle={script.command} + subtitle={script.type === "prompt" ? script.prompt : script.command} > {projectScriptMenuLabel(script)} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index eedfa6e1d8210..2427a319a171f 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -637,7 +637,27 @@ function ThreadRouteContent( }); }; + const runPrompt = useAtomCommand(threadEnvironment.runProjectPrompt, "run prompt action"); const handleRunProjectScript = async (script: ProjectScript) => { + if (script.type === "prompt") { + if ( + !selectedThread || + !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) + ) + return; + await runPrompt({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + action: script, + creationSource: "mobile", + ...(composer.modelSelection ? { modelSelection: composer.modelSelection } : {}), + ...(composer.runtimeMode ? { runtimeMode: composer.runtimeMode } : {}), + ...(composer.interactionMode ? { interactionMode: composer.interactionMode } : {}), + }, + }); + return; + } terminalDebugLog("project-script:press", { scriptId: script.id, command: script.command, @@ -726,8 +746,10 @@ function ThreadRouteContent( gitStatus: gitStatus.data, gitOperationLabel: gitState.gitOperationLabel, canOpenTerminal: - Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal), + Boolean(selectedThreadProject?.workspaceRoot) && + (canReadTerminal || canOperateTerminal || canOperateThread), canOperateTerminal, + canRunPrompt: canOperateThread, canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), projectScripts: selectedThreadProject ? resolveProjectScripts( diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 1bf294641a01c..719945b4a306b 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { CommandId, MessageId, + type ModelSelection, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ThreadProjection, ProviderInstanceId, @@ -200,6 +201,28 @@ it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) = const layer = it.layer(CommandPolicy.layer); layer("CommandPolicyV2", (it) => { + it.effect("starts an idle queued prompt with the thread's arbitrary model and options", () => + Effect.gen(function* () { + const policy = yield* CommandPolicy.CommandPolicyV2; + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make("custom-runtime"), + model: "deepseek/deepseek-v4.1-flash:free", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const projection = { + ...dispatchProjection(), + thread: { id: threadId, modelSelection }, + } as OrchestrationV2ThreadProjection; + const decision = yield* policy.decideMessageDispatch({ + commandId, + projection, + requestedMode: { type: "queue_after_active" }, + capabilities: baseCapabilities, + }); + assert.deepEqual(decision, { type: "start_run", modelSelection }); + }), + ); + it.effect("falls back to portable context when Cursor cannot fork natively", () => Effect.gen(function* () { const policy = yield* CommandPolicy.CommandPolicyV2; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ac4e0d394284a..8b9832e62bdde 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1722,6 +1722,88 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, ); + it.effect( + "persists prompt actions beside legacy shells and preserves unrelated settings on later saves", + () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const projectId = ProjectId.make("prompt-project"); + const shell: ProjectScript = { + id: "test", + name: "Test", + command: "npm test", + icon: "test", + runOnWorktreeCreate: true, + async: false, + runOnSettle: true, + }; + const prompt: ProjectScript = { + id: "status", + name: "Status", + type: "prompt", + prompt: "Read project instructions.\nReport completed results without changes.", + icon: "play", + runOnWorktreeCreate: false, + }; + yield* service.updateSettings({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, defaultProjectScripts: [shell, prompt] }, + }, + }); + const saved = yield* decodeServerSettingsJson( + yield* fs.readFileString(config.settingsPath), + ); + assert.deepEqual(saved.projectSettingsOverrides[projectId], { + defaultAutoPull: true, + defaultProjectScripts: [shell, prompt], + }); + const reloaded = yield* Effect.gen(function* () { + const fresh = yield* ServerSettingsModule.ServerSettingsService; + return yield* fresh.getSettings; + }).pipe( + Effect.provide( + Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)), + ), + ); + assert.deepEqual( + reloaded.projectSettingsOverrides[projectId], + saved.projectSettingsOverrides[projectId], + ); + yield* service.updateSettings({ + projectSettingsOverrides: { + [projectId]: { + ...saved.projectSettingsOverrides[projectId], + defaultProjectScripts: [shell, { ...prompt, prompt: "Inspect new results." }], + }, + }, + }); + const edited = yield* decodeServerSettingsJson( + yield* fs.readFileString(config.settingsPath), + ); + assert.deepEqual(edited.projectSettingsOverrides[projectId]?.defaultProjectScripts, [ + shell, + { ...prompt, prompt: "Inspect new results." }, + ]); + yield* service.updateSettings({ + projectSettingsOverrides: { + [projectId]: { + ...edited.projectSettingsOverrides[projectId], + defaultProjectScripts: [shell], + }, + }, + }); + const deleted = yield* decodeServerSettingsJson( + yield* fs.readFileString(config.settingsPath), + ); + assert.deepEqual(deleted.projectSettingsOverrides[projectId], { + defaultAutoPull: true, + defaultProjectScripts: [shell], + }); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("folds legacy project overrides into projectSettingsOverrides once", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92707af8ca119..f6a48957c18d9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1859,8 +1859,12 @@ export default function ChatView(props: ChatViewProps) { composerRef, terminalUiState, activeThread, + modelSelection: composerSelectedModelSelection ?? activeThread?.modelSelection, + runtimeMode, + interactionMode, activeThreadId, activeThreadRef, + isServerThread, activeProject, activeRightPanelSurface, runningTerminalIds, @@ -2745,9 +2749,11 @@ export default function ChatView(props: ChatViewProps) { }, fallback: (command) => { const scriptId = projectScriptIdFromCommand(command); - if (!scriptId || !activeProject || !canOperateTerminal) return undefined; + if (!scriptId || !activeProject) return undefined; const script = activeProjectScripts.find((entry) => entry.id === scriptId); - return script ? () => void runProjectScriptStable(script) : undefined; + if (!script || (script.type === "prompt" ? !canOperateThread : !canOperateTerminal)) + return undefined; + return () => void runProjectScriptStable(script); }, }); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index b22da09463562..5d9ab2a34ac93 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -111,7 +111,7 @@ export default function ProjectScriptsControl({ (fileScript) => !scripts.some( (script) => - script.command === fileScript.command || + (script.type !== "prompt" && script.command === fileScript.command) || script.name.toLowerCase() === fileScript.name.toLowerCase(), ), ); diff --git a/apps/web/src/components/chat/useChatTerminalActions.ts b/apps/web/src/components/chat/useChatTerminalActions.ts index 02b6fcd9851c8..b30a14a195e90 100644 --- a/apps/web/src/components/chat/useChatTerminalActions.ts +++ b/apps/web/src/components/chat/useChatTerminalActions.ts @@ -9,6 +9,7 @@ import { useStableCallback } from "../../hooks/useStableCallback"; import { useThreadTerminalActions } from "../terminal/useThreadTerminalActions"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { + AuthOrchestrationOperateScope, AuthTerminalReadScope, AuthTerminalOperateScope, type EnvironmentId, @@ -38,8 +39,10 @@ import { makeThreadErrorSetter, } from "../ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { previewEnvironment } from "../../state/preview"; import { readEnvironmentScope } from "../../state/session"; +import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import type { ChatShortcutCommands } from "./useChatShortcuts"; const SCRIPT_TERMINAL_COLS = 120; @@ -56,8 +59,12 @@ interface ChatTerminalActionsInput { readonly composerRef: ComposerHandleRef; readonly terminalUiState: ReturnType; readonly activeThread: EnvironmentThreadShell | undefined; + readonly modelSelection: EnvironmentThreadShell["modelSelection"] | undefined; + readonly runtimeMode: EnvironmentThreadShell["runtimeMode"] | undefined; + readonly interactionMode: EnvironmentThreadShell["interactionMode"] | undefined; readonly activeThreadId: ThreadId | null; readonly activeThreadRef: ScopedThreadRef | null; + readonly isServerThread: boolean; readonly activeProject: EnvironmentProject | null; readonly activeRightPanelSurface: RightPanelSurface | null; readonly runningTerminalIds: ReadonlyArray; @@ -81,8 +88,12 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { composerRef, terminalUiState, activeThread, + modelSelection, + runtimeMode, + interactionMode, activeThreadId, activeThreadRef, + isServerThread, activeProject, activeRightPanelSurface, runningTerminalIds, @@ -95,6 +106,8 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { setTerminalUiLaunchContext, setThreadError, } = input; + const runPrompt = useAtomCommand(threadEnvironment.runProjectPrompt, { reportFailure: false }); + const setComposerModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const hasTerminalWriteAccess = () => @@ -199,13 +212,58 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { shellHistory?: "preserve" | "suppress"; }, ) => { - if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; + if (!activeThreadId || !activeProject || !activeThread) return; + if (script.type !== "prompt" && !hasTerminalWriteAccess()) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; return { ...current, [activeProject.id]: script.id }; }); } + if (script.type === "prompt") { + if (!isServerThread) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Start this thread first", + description: "Send a message before running a prompt action.", + }), + ); + return; + } + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { + setThreadError(activeThreadId, "This connection cannot run prompt actions."); + return; + } + const result = await runPrompt({ + environmentId, + input: { + threadId: activeThreadId, + action: script, + ...(modelSelection ? { modelSelection } : {}), + ...(runtimeMode ? { runtimeMode } : {}), + ...(interactionMode ? { interactionMode } : {}), + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error + ? error.message + : `Could not run prompt action "${script.name}". Check that the thread's model and provider are available.`, + ); + } + return; + } + if (script.modelSelection && activeThreadRef) { + setComposerModelSelection(activeThreadRef, script.modelSelection, { + replaceOptions: true, + }); + } + return; + } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; const reusableTerminalId = options?.preferNewTerminal || diff --git a/apps/web/src/components/projectScriptEditor.browser.test.tsx b/apps/web/src/components/projectScriptEditor.browser.test.tsx index a9ea6f03ad185..cba857a7c86c9 100644 --- a/apps/web/src/components/projectScriptEditor.browser.test.tsx +++ b/apps/web/src/components/projectScriptEditor.browser.test.tsx @@ -1,6 +1,7 @@ import { act, render, type Screen } from "../../test/render"; import { EnvironmentId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; +import { page, userEvent } from "vite-plus/test/browser"; import { StrictMode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -86,3 +87,41 @@ describe("project action editor save lifecycle", () => { expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]); }); }); + +it("switches from shell to prompt, records a shortcut, and saves the prompt editor", async () => { + setup(); + onSubmit.mockResolvedValue(AsyncResult.success(undefined)); + await using screen = await render(null); + await open(screen, { + scriptId: null, + initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name: "Research status" }, + }); + await expect.element(page.getByLabelText("Command", { exact: true })).toBeVisible(); + await userEvent.click(page.getByRole("combobox", { name: "Action type" })); + await userEvent.click(page.getByRole("option", { name: "Prompt", exact: true })); + await expect.element(page.getByLabelText("Command", { exact: true })).not.toBeInTheDocument(); + await expect + .element(page.getByText("Run automatically on worktree creation")) + .not.toBeInTheDocument(); + await userEvent.type( + page.getByLabelText("Prompt", { exact: true }), + "Read instructions. Report results without edits.", + ); + await userEvent.click(page.getByLabelText("Keybinding", { exact: true })); + await userEvent.keyboard("{Control>}{Shift>}p{/Shift}{/Control}"); + await expect + .element(page.getByLabelText("Keybinding", { exact: true })) + .toHaveValue("ctrl+shift+p"); + await act(async () => submit()); + await vi.waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + null, + expect.objectContaining({ + type: "prompt", + prompt: "Read instructions. Report results without edits.", + name: "Research status", + keybinding: "ctrl+shift+p", + }), + ), + ); +}); diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index b3ea5301be830..ac27ec76c58db 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -2,6 +2,7 @@ import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, type EnvironmentId, + type ModelSelection, type ProjectScript, type ProjectScriptIcon, type ResolvedKeybindingsConfig, @@ -11,6 +12,8 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; import { BugIcon, FlaskConicalIcon, @@ -33,7 +36,18 @@ import { } from "~/lib/projectScriptKeybindings"; import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "~/providerInstances"; +import { getCustomModelOptionsByInstance } from "~/modelSelection"; +import { useEnvironmentSettings } from "~/hooks/useSettings"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "~/state/server"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { ProviderModelPicker } from "./chat/ProviderModelPicker"; +import { TraitsPicker } from "./chat/TraitsPicker"; import { useComposerMenuState } from "./chat/useComposerMenuState"; import { AlertDialog, @@ -57,6 +71,7 @@ import { import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select"; import { Switch } from "./ui/switch"; import { Textarea } from "./ui/textarea"; @@ -86,6 +101,9 @@ export function ScriptIcon({ export interface NewProjectScriptInput { name: string; + type?: "shell" | "prompt"; + prompt?: string; + modelSelection?: ModelSelection | null; command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; @@ -125,7 +143,10 @@ export function editorRequestForScript( scriptId: script.id, initial: { name: script.name, - command: script.command, + type: script.type ?? "shell", + prompt: script.type === "prompt" ? script.prompt : "", + modelSelection: script.type === "prompt" ? (script.modelSelection ?? null) : null, + command: script.type === "prompt" ? "" : script.command, icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, waitForSetup: script.runOnWorktreeCreate && script.async === false, @@ -262,6 +283,8 @@ function ProjectScriptEditorForm({ const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); const formId = React.useId(); const [name, setName] = useState(""); + const [actionType, setActionType] = useState<"shell" | "prompt">("shell"); + const [prompt, setPrompt] = useState(""); const [command, setCommand] = useState(""); const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions); @@ -271,7 +294,30 @@ function ProjectScriptEditorForm({ const [keybinding, setKeybinding] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); + const [modelSelection, setModelSelection] = useState(null); const [savingRequest, setSavingRequest] = useState(null); + const settings = useEnvironmentSettings(environmentId); + const providers = + useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const instanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, null), + ); + const fallbackModelSelection = + resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection) ?? + instanceEntries.reduce((selection, entry) => { + if (selection || !entry.enabled || !entry.models[0]) return selection; + return createModelSelection(entry.instanceId, entry.models[0].slug); + }, null); + const effectiveModelSelection = modelSelection ?? fallbackModelSelection; + const activeEntry = + instanceEntries.find((entry) => entry.instanceId === effectiveModelSelection?.instanceId) ?? + null; + const modelOptionsByInstance = getCustomModelOptionsByInstance( + settings, + providers, + effectiveModelSelection?.instanceId, + effectiveModelSelection?.model, + ); const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null); const isEditing = request?.scriptId != null; @@ -291,6 +337,8 @@ function ProjectScriptEditorForm({ setFormRequest(request); if (request) { setName(request.initial.name); + setActionType(request.initial.type ?? "shell"); + setPrompt(request.initial.prompt ?? ""); setCommand(request.initial.command); setIcon(request.initial.icon); setIconPickerOpen(false); @@ -300,6 +348,7 @@ function ProjectScriptEditorForm({ setKeybinding(request.initial.keybinding ?? ""); setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); + setModelSelection(request.initial.modelSelection ?? null); setSavingRequest(null); } } @@ -345,8 +394,8 @@ function ProjectScriptEditorForm({ setValidationError("Name is required."); return; } - if (trimmedCommand.length === 0) { - setValidationError("Command is required."); + if ((actionType === "prompt" ? prompt.trim() : trimmedCommand).length === 0) { + setValidationError(actionType === "prompt" ? "Prompt is required." : "Command is required."); return; } @@ -365,6 +414,9 @@ function ProjectScriptEditorForm({ }); const trimmedPreviewUrl = previewUrl.trim(); payload = { + type: actionType, + prompt: prompt.trim(), + ...(actionType === "prompt" && modelSelection ? { modelSelection } : {}), name: trimmedName, command: trimmedCommand, icon, @@ -414,7 +466,8 @@ function ProjectScriptEditorForm({ {isEditing ? "Edit Action" : "Add Action"} - Actions are project-scoped commands you can run from the top bar or keybindings. + Actions are project-scoped shell scripts or prompts you can run from the top bar or + keybindings. @@ -487,64 +540,157 @@ function ProjectScriptEditorForm({

- -