diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 2f8ce1377720..382d3bf4bba5 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -13,6 +13,7 @@ import { nextOpenTerminalId, previousLiveTerminalId, resolveProjectScriptTerminalId, + resolveProjectScriptTerminalIds, type TerminalMenuSession, } from "./terminalMenu"; @@ -226,3 +227,25 @@ describe("resolveProjectScriptTerminalId", () => { ).toBe("term-3"); }); }); + +describe("resolveProjectScriptTerminalIds", () => { + it("reuses the idle default shell for the first command", () => { + expect( + resolveProjectScriptTerminalIds({ + commandCount: 3, + existingTerminalIds: [DEFAULT_TERMINAL_ID], + hasRunningTerminal: false, + }), + ).toEqual([DEFAULT_TERMINAL_ID, "term-2", "term-3"]); + }); + + it("allocates a new shell for every command when one is already running", () => { + expect( + resolveProjectScriptTerminalIds({ + commandCount: 2, + existingTerminalIds: [DEFAULT_TERMINAL_ID], + hasRunningTerminal: true, + }), + ).toEqual(["term-2", "term-3"]); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 06cb74e9467d..bf00ff0bd362 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -154,6 +154,28 @@ export function resolveProjectScriptTerminalId(input: { return nextTerminalId(input.existingTerminalIds); } +export function resolveProjectScriptTerminalIds(input: { + readonly commandCount: number; + readonly existingTerminalIds: ReadonlyArray; + readonly hasRunningTerminal: boolean; +}): string[] { + const terminalIds: string[] = []; + const existingTerminalIds = [...input.existingTerminalIds]; + let hasRunningTerminal = input.hasRunningTerminal; + for (let index = 0; index < input.commandCount; index += 1) { + const terminalId = resolveProjectScriptTerminalId({ + existingTerminalIds, + hasRunningTerminal, + }); + terminalIds.push(terminalId); + if (!existingTerminalIds.includes(terminalId)) { + existingTerminalIds.push(terminalId); + } + hasRunningTerminal = true; + } + return terminalIds; +} + export function projectScriptMenuLabel(script: ProjectScript): string { return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; } diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0ff42a077b04..956f4e5a5ee6 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -19,6 +19,7 @@ import { } from "@t3tools/client-runtime/state/threads"; import { projectScriptCwd, + projectScriptCommands, projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; @@ -54,7 +55,7 @@ import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { buildTerminalMenuSessions, nextOpenTerminalId, - resolveProjectScriptTerminalId, + resolveProjectScriptTerminalIds, } from "../terminal/terminalMenu"; import { resolvePreferredThreadWorktreePath, @@ -74,6 +75,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; +import { terminalEnvironment } from "../../state/terminal"; import { threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -234,6 +236,8 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -576,7 +580,9 @@ function ThreadRouteContent( return; } - const targetTerminalId = resolveProjectScriptTerminalId({ + const scriptCommands = projectScriptCommands(script); + const targetTerminalIds = resolveProjectScriptTerminalIds({ + commandCount: scriptCommands.length, existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), hasRunningTerminal: terminalMenuSessions.some( (session) => session.status === "running" || session.status === "starting", @@ -594,38 +600,86 @@ function ThreadRouteContent( project: { cwd: selectedThreadProject.workspaceRoot }, worktreePath: preferredWorktreePath, }); - stagePendingTerminalLaunch({ - target: { + + for (const [commandIndex, command] of scriptCommands.entries()) { + const targetTerminalId = targetTerminalIds[commandIndex]; + if (!targetTerminalId) continue; + const isLastCommand = commandIndex === scriptCommands.length - 1; + if (isLastCommand) { + stagePendingTerminalLaunch({ + target: { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId: targetTerminalId, + }, + launch: { + cwd, + worktreePath: preferredWorktreePath, + env, + initialInput: `${command}\r`, + }, + }); + terminalDebugLog("project-script:staged", { + scriptId: script.id, + terminalId: targetTerminalId, + cwd, + worktreePath: preferredWorktreePath, + }); + continue; + } + + const openResult = await openTerminal({ environmentId: selectedThread.environmentId, - threadId: selectedThread.id, - terminalId: targetTerminalId, - }, - launch: { - cwd, - worktreePath: preferredWorktreePath, - env, - initialInput: `${script.command}\r`, - }, - }); - terminalDebugLog("project-script:staged", { - scriptId: script.id, - terminalId: targetTerminalId, - cwd, - worktreePath: preferredWorktreePath, - }); + input: { + threadId: selectedThread.id, + terminalId: targetTerminalId, + cwd, + worktreePath: preferredWorktreePath, + env, + cols: 80, + rows: 24, + }, + }); + if (openResult._tag === "Failure") { + terminalDebugLog("project-script:open-failed", { + scriptId: script.id, + terminalId: targetTerminalId, + }); + return; + } + const writeResult = await writeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId: targetTerminalId, + data: `${command}\r`, + }, + }); + if (writeResult._tag === "Failure") { + terminalDebugLog("project-script:write-failed", { + scriptId: script.id, + terminalId: targetTerminalId, + }); + return; + } + } + const focusTerminalId = targetTerminalIds[targetTerminalIds.length - 1]; + if (!focusTerminalId) return; void navigation.navigate("ThreadTerminal", { environmentId: String(selectedThread.environmentId), threadId: String(selectedThread.id), - terminalId: targetTerminalId, + terminalId: focusTerminalId, }); }, [ navigation, + openTerminal, selectedThread, selectedThreadDetailWorktreePath, selectedThreadProject, terminalMenuSessions, + writeTerminal, ], ); const threadGitControlProps = { diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 3a8c3ad71e69..de959f494b70 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -219,6 +219,83 @@ describe("ProjectSetupScriptRunner", () => { }, ); + it.effect("opens a separate terminal for each setup command", () => { + const open = vi.fn((input: { terminalId: string }) => + Effect.succeed({ + threadId: "thread-1", + terminalId: input.terminalId, + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: input.terminalId, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Dev", + command: "npm run api", + commands: ["npm run web"], + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + + expect(result).toEqual({ + status: "started", + scriptId: "setup", + scriptName: "Dev", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(open).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenNthCalledWith(1, { + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(open).toHaveBeenNthCalledWith(2, { + threadId: "thread-1", + terminalId: "setup-setup-2", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(write).toHaveBeenNthCalledWith(1, { + threadId: "thread-1", + terminalId: "setup-setup", + data: "npm run api\r", + }); + expect(write).toHaveBeenNthCalledWith(2, { + threadId: "thread-1", + terminalId: "setup-setup-2", + data: "npm run web\r", + }); + }).pipe(Effect.provide(testLayer(project, { open, write }))); + }); + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { const rootCause = new Error("stat failed"); const terminalError = new TerminalManager.TerminalCwdStatError({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 3bbb0daa7994..02db8629b180 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,6 @@ import { ProjectId } from "@t3tools/contracts"; import { + projectScriptCommands, projectScriptRuntimeEnv, resolveProjectScripts, setupProjectScript, @@ -148,53 +149,58 @@ export const make = Effect.gen(function* () { } as const; } - const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const commands = projectScriptCommands(script); + const baseTerminalId = input.preferredTerminalId ?? `setup-${script.id}`; const cwd = input.worktreePath; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }); - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - ); - yield* terminalManager - .write({ - threadId: input.threadId, - terminalId, - data: `${script.command}\r`, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "writeCommand", - cause, - }), - ), - ); + for (const [commandIndex, command] of commands.entries()) { + const terminalId = + commandIndex === 0 ? baseTerminalId : `${baseTerminalId}-${commandIndex + 1}`; + yield* terminalManager + .open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); + yield* terminalManager + .write({ + threadId: input.threadId, + terminalId, + data: `${command}\r`, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + ); + } return { status: "started", scriptId: script.id, scriptName: script.name, - terminalId, + terminalId: baseTerminalId, cwd, } as const; }); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b754c9408904..5729cc5de068 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -53,6 +53,7 @@ import { resolveDraftPromotionNavigationTarget, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, + resolveProjectScriptTerminalPlacement, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, resolveDraftHeroState, @@ -1958,3 +1959,46 @@ describe("shouldRefocusComposerOnWindowFocus", () => { expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); }); }); + +describe("resolveProjectScriptTerminalPlacement", () => { + it("reuses the current shell when it is idle", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: false, + activeGroupSize: 1, + maxGroupSize: 4, + }), + ).toBe("reuse"); + }); + + it("splits beside a busy shell", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: true, + activeGroupSize: 1, + maxGroupSize: 4, + }), + ).toBe("split"); + }); + + it("keeps stacking in an existing vertical group", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: true, + activeGroupSize: 2, + maxGroupSize: 4, + splitDirection: "vertical", + }), + ).toBe("split-vertical"); + }); + + it("opens a new tab once the split group is full", () => { + expect( + resolveProjectScriptTerminalPlacement({ + needsNewTerminal: true, + activeGroupSize: 4, + maxGroupSize: 4, + }), + ).toBe("new"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c251e8ffba32..837225a0eb82 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1063,3 +1063,17 @@ export function shouldRefocusComposerOnWindowFocus( ) === null ); } + +export type ProjectScriptTerminalPlacement = "reuse" | "split" | "split-vertical" | "new"; + +/** Where a project action should land when the current shell is already busy. */ +export function resolveProjectScriptTerminalPlacement(input: { + readonly needsNewTerminal: boolean; + readonly activeGroupSize: number; + readonly maxGroupSize: number; + readonly splitDirection?: "horizontal" | "vertical"; +}): ProjectScriptTerminalPlacement { + if (!input.needsNewTerminal) return "reuse"; + if (input.activeGroupSize >= input.maxGroupSize) return "new"; + return input.splitDirection === "vertical" ? "split-vertical" : "split"; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..f938ad3d404b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -65,6 +65,7 @@ import { } from "@t3tools/shared/model"; import { projectScriptCwd, + projectScriptCommands, projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; @@ -410,6 +411,7 @@ import { observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, + resolveProjectScriptTerminalPlacement, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -3786,9 +3788,8 @@ export default function ChatView(props: ChatViewProps) { const baseTerminalId = terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; + const scriptCommands = projectScriptCommands(script); setTerminalUiLaunchContext({ threadId: activeThreadId, @@ -3800,6 +3801,15 @@ export default function ChatView(props: ChatViewProps) { return; } setTerminalFocusRequestId((value) => value + 1); + const allocatedTerminalIds = [ + ...new Set([ + ...allocatableActiveTerminalIds, + ...selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + activeThreadRef, + ).terminalIds, + ]), + ]; const runtimeEnv = projectScriptRuntimeEnv({ project: { @@ -3808,59 +3818,96 @@ export default function ChatView(props: ChatViewProps) { worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); - const targetTerminalId = shouldCreateNewTerminal - ? nextTerminalId(allocatableActiveTerminalIds) - : baseTerminalId; - const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - }; - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); - } + for (const [commandIndex, command] of scriptCommands.entries()) { + const currentUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + activeThreadRef, + ); + const currentGroup = + currentUiState.terminalGroups.find( + (group) => group.id === currentUiState.activeTerminalGroupId, + ) ?? + currentUiState.terminalGroups.find((group) => + group.terminalIds.includes(currentUiState.activeTerminalId), + ) ?? + null; + const needsNewTerminal = + commandIndex > 0 || Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const scriptTerminalPlacement = resolveProjectScriptTerminalPlacement({ + needsNewTerminal, + activeGroupSize: currentGroup?.terminalIds.length ?? 0, + maxGroupSize: MAX_TERMINALS_PER_GROUP, + ...(currentGroup?.splitDirection === "vertical" + ? { splitDirection: "vertical" as const } + : {}), + }); + const targetTerminalId = + scriptTerminalPlacement === "reuse" + ? currentUiState.activeTerminalId || baseTerminalId + : nextTerminalId(allocatedTerminalIds); + if (scriptTerminalPlacement !== "reuse") { + allocatedTerminalIds.push(targetTerminalId); + } + const openTerminalInput: TerminalOpenInput = + scriptTerminalPlacement === "reuse" + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + }; + + if (scriptTerminalPlacement === "split-vertical") { + storeSplitTerminalVertical(activeThreadRef, targetTerminalId); + } else if (scriptTerminalPlacement === "split") { + storeSplitTerminal(activeThreadRef, targetTerminalId); + } else if (scriptTerminalPlacement === "new") { + storeNewTerminal(activeThreadRef, targetTerminalId); + } else { + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + } - const openResult = await openTerminal({ environmentId, input: openTerminalInput }); - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + const openResult = await openTerminal({ environmentId, input: openTerminalInput }); + if (openResult._tag === "Failure") { + if (!isAtomCommandInterrupted(openResult)) { + const error = squashAtomCommandFailure(openResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; } - return; - } - const writeResult = await writeTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, - }, - }); - if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); + const writeResult = await writeTerminal({ + environmentId, + input: { + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${command}\r`, + }, + }); + if (writeResult._tag === "Failure") { + if (!isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; + } } }, [ @@ -3873,6 +3920,8 @@ export default function ChatView(props: ChatViewProps) { setThreadError, storeNewTerminal, storeSetActiveTerminal, + storeSplitTerminal, + storeSplitTerminalVertical, setLastInvokedScriptByProjectId, environmentId, openTerminal, diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..d576bc4f0aad 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -111,6 +111,7 @@ export default function ProjectScriptsControl({ const payload: NewProjectScriptInput = { name: fileScript.name, command: fileScript.command, + commands: fileScript.commands ?? [], icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, keybinding: null, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225bdf..ff4556b0f62d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1189,12 +1189,26 @@ export default function ThreadTerminalDrawer({ }); } + const activeGroup = nextGroups.find((group) => group.id === activeTerminalGroupId) ?? null; + let fillGroup = + activeGroup && activeGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP ? activeGroup : null; + const stackIntoNewGroups = activeGroup === null; for (const terminalId of normalizedTerminalIds) { if (assignedTerminalIds.has(terminalId)) continue; - nextGroups.push({ + if (fillGroup && fillGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP) { + fillGroup.terminalIds.push(terminalId); + assignedTerminalIds.add(terminalId); + continue; + } + fillGroup = { id: assignUniqueGroupId(`group-${terminalId}`), terminalIds: [terminalId], - }); + }; + nextGroups.push(fillGroup); + assignedTerminalIds.add(terminalId); + if (!stackIntoNewGroups) { + fillGroup = null; + } } const terminalOrderIndex = new Map( @@ -1207,7 +1221,7 @@ export default function ThreadTerminalDrawer({ }); return nextGroups; - }, [normalizedTerminalIds, terminalGroups]); + }, [activeTerminalGroupId, normalizedTerminalIds, terminalGroups]); const resolvedActiveGroupIndex = useMemo(() => { const indexById = resolvedTerminalGroups.findIndex( diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4ffd453955e9..4ae0b5c6199f 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -1,7 +1,8 @@ -import type { - ProjectScript, - ProjectScriptIcon, - ResolvedKeybindingsConfig, +import { + PROJECT_SCRIPT_MAX_COMMANDS, + type ProjectScript, + type ProjectScriptIcon, + type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { isAtomCommandInterrupted, @@ -14,7 +15,9 @@ import { HammerIcon, ListChecksIcon, PlayIcon, + PlusIcon, WrenchIcon, + XIcon, } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react"; @@ -76,6 +79,8 @@ export function ScriptIcon({ export interface NewProjectScriptInput { name: string; command: string; + /** Extra commands besides `command`. Each runs in its own split terminal. */ + commands: ReadonlyArray; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; keybinding: string | null; @@ -90,6 +95,7 @@ export type ProjectScriptActionResult = AtomCommandResult; export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { name: "", command: "", + commands: [], icon: "play", runOnWorktreeCreate: false, keybinding: null, @@ -114,6 +120,7 @@ export function editorRequestForScript( initial: { name: script.name, command: script.command, + commands: script.commands ?? [], icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), @@ -148,6 +155,7 @@ export function ProjectScriptEditorDialog({ const formId = React.useId(); const [name, setName] = useState(""); const [command, setCommand] = useState(""); + const [extraCommands, setExtraCommands] = useState([]); const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); @@ -165,6 +173,7 @@ export function ProjectScriptEditorDialog({ if (!request) return; setName(request.initial.name); setCommand(request.initial.command); + setExtraCommands([...request.initial.commands]); setIcon(request.initial.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); @@ -217,6 +226,10 @@ export function ProjectScriptEditorDialog({ payload = { name: trimmedName, command: trimmedCommand, + commands: extraCommands + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .slice(0, PROJECT_SCRIPT_MAX_COMMANDS - 1), icon, runOnWorktreeCreate, keybinding: keybindingRule?.key ?? null, @@ -332,6 +345,48 @@ export function ProjectScriptEditorDialog({ value={command} onChange={(event) => setCommand(event.target.value)} /> + {extraCommands.map((entry, index) => ( +
+ { + const next = [...extraCommands]; + next[index] = event.target.value; + setExtraCommands(next); + }} + /> + +
+ ))} + {1 + extraCommands.length < PROJECT_SCRIPT_MAX_COMMANDS ? ( + + ) : null} +

+ Extra commands each run in a split terminal. +

diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx index 1794a5fdaa2e..5be974546820 100644 --- a/apps/web/src/components/settings/ProjectActionsList.tsx +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -1,4 +1,5 @@ import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { projectScriptCommands } from "@t3tools/shared/projectScripts"; import { SettingsIcon } from "lucide-react"; import { shortcutLabelForCommand } from "../../keybindings"; import { commandForProjectScript } from "../../projectScripts"; @@ -45,7 +46,11 @@ export function ProjectActionsList({ ) : null} } - description={{script.command}} + description={ + + {projectScriptCommands(script).join(" ยท ")} + + } control={ <> {shortcutLabel ? ( diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d88644fb7e3c..3969d5c17fb0 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -771,6 +771,7 @@ function ProjectDetail({ const payload: NewProjectScriptInput = { name: fileScript.name, command: fileScript.command, + commands: fileScript.commands ?? [], icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, keybinding: null, diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index a09a18d03b35..d1c2110e9878 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -3,6 +3,7 @@ import { shortcutLabelForCommand } from "./keybindings"; import { describe, expect, it } from "vite-plus/test"; import { projectScriptCwd, + projectScriptCommands, projectScriptRuntimeEnv, setupProjectScript, } from "@t3tools/shared/projectScripts"; @@ -56,6 +57,27 @@ describe("projectScripts helpers", () => { }); }); + it("stores extra commands and drops blanks", () => { + expect( + buildProjectScript("dev", { + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: [" npm run dev ", "", "python -m arq app.worker.WorkerSettings"], + icon: "play", + runOnWorktreeCreate: false, + previewUrl: null, + autoOpenPreview: false, + }), + ).toEqual({ + id: "dev", + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: ["npm run dev", "python -m arq app.worker.WorkerSettings"], + icon: "play", + runOnWorktreeCreate: false, + }); + }); + it("builds and parses script run commands", () => { const command = commandForProjectScript("lint"); expect(command).toBe("script.lint.run"); @@ -109,6 +131,19 @@ describe("projectScripts helpers", () => { expect(setupProjectScript(scripts)?.id).toBe("setup"); }); + it("lists the primary command then extras", () => { + expect( + projectScriptCommands({ + command: "uvicorn app.main:app --reload", + commands: ["npm run dev", " ", "python -m arq app.worker.WorkerSettings"], + }), + ).toEqual([ + "uvicorn app.main:app --reload", + "npm run dev", + "python -m arq app.worker.WorkerSettings", + ]); + }); + it("builds default runtime env for scripts", () => { const env = projectScriptRuntimeEnv({ project: { cwd: "/repo" }, diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index 20d06c9c572a..d7e9b0811e44 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -1,5 +1,6 @@ import { MAX_SCRIPT_ID_LENGTH, + PROJECT_SCRIPT_MAX_COMMANDS, SCRIPT_RUN_COMMAND_PATTERN, type KeybindingCommand, type ProjectScript, @@ -10,6 +11,7 @@ const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); export interface ProjectScriptInput { readonly name: ProjectScript["name"]; readonly command: ProjectScript["command"]; + readonly commands?: ReadonlyArray; readonly icon: ProjectScript["icon"]; readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"]; readonly previewUrl: Exclude | null; @@ -17,10 +19,18 @@ export interface ProjectScriptInput { } export function buildProjectScript(id: string, input: ProjectScriptInput): ProjectScript { + const extraCommands: string[] = []; + for (const extra of input.commands ?? []) { + const trimmed = extra.trim(); + if (trimmed.length === 0) continue; + extraCommands.push(trimmed); + if (extraCommands.length >= PROJECT_SCRIPT_MAX_COMMANDS - 1) break; + } return { id, name: input.name, command: input.command, + ...(extraCommands.length > 0 ? { commands: extraCommands } : {}), icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, ...(input.previewUrl === null diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index b0b1df96e1fe..b67ee7755eae 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -257,8 +257,57 @@ describe("terminalUiStateStore actions", () => { expect(terminalUiState.terminalIds).toEqual(["term-a", "term-b"]); expect(terminalUiState.activeTerminalId).toBe("term-a"); expect(terminalUiState.terminalGroups).toEqual([ - { id: "group-term-a", terminalIds: ["term-a"] }, - { id: "group-term-b", terminalIds: ["term-b"] }, + { id: "group-term-a", terminalIds: ["term-a", "term-b"] }, + ]); + }); + + it("splits newly reconciled terminals into the active group", () => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.reconcileTerminalIds(THREAD_REF, [DEFAULT_THREAD_TERMINAL_ID, "backend", "frontend"]); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "backend", "frontend"], + }, + ]); + }); + + it("opens a new group when the active split group is already full", () => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.splitTerminal(THREAD_REF, "pane-2"); + store.splitTerminal(THREAD_REF, "pane-3"); + store.splitTerminal(THREAD_REF, "pane-4"); + store.newTerminal(THREAD_REF, "other-tab"); + store.setActiveTerminal(THREAD_REF, DEFAULT_THREAD_TERMINAL_ID); + store.reconcileTerminalIds(THREAD_REF, [ + DEFAULT_THREAD_TERMINAL_ID, + "pane-2", + "pane-3", + "pane-4", + "other-tab", + "overflow-a", + "overflow-b", + ]); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "pane-2", "pane-3", "pane-4"], + }, + { id: "group-other-tab", terminalIds: ["other-tab"] }, + { id: "group-overflow-a", terminalIds: ["overflow-a"] }, + { id: "group-overflow-b", terminalIds: ["overflow-b"] }, ]); }); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 290ca8e5954c..ace8e535ba70 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -99,6 +99,7 @@ function normalizeTerminalGroupIds(terminalIds: string[]): string[] { function normalizeTerminalGroups( terminalGroups: ThreadTerminalGroup[], terminalIds: string[], + activeGroupId = "", ): ThreadTerminalGroup[] { if (terminalIds.length === 0) { return []; @@ -130,12 +131,26 @@ function normalizeTerminalGroups( }); } + const activeGroup = nextGroups.find((group) => group.id === activeGroupId) ?? null; + let fillGroup = + activeGroup && activeGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP ? activeGroup : null; + const stackIntoNewGroups = activeGroup === null; for (const terminalId of terminalIds) { if (assignedTerminalIds.has(terminalId)) continue; - nextGroups.push({ + if (fillGroup && fillGroup.terminalIds.length < MAX_TERMINALS_PER_GROUP) { + fillGroup.terminalIds.push(terminalId); + assignedTerminalIds.add(terminalId); + continue; + } + fillGroup = { id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), terminalIds: [terminalId], - }); + }; + nextGroups.push(fillGroup); + assignedTerminalIds.add(terminalId); + if (!stackIntoNewGroups) { + fillGroup = null; + } } return nextGroups; @@ -206,7 +221,11 @@ function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTer const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) ? state.activeTerminalId : (nextTerminalIds[0] ?? ""); - const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); + const terminalGroups = normalizeTerminalGroups( + state.terminalGroups, + nextTerminalIds, + state.activeTerminalGroupId, + ); const activeGroupIdFromState = terminalGroups.some( (group) => group.id === state.activeTerminalGroupId, ) @@ -464,7 +483,11 @@ function reconcileThreadTerminalSessionIds( ? normalized.activeTerminalId : (nextIds[0] ?? ""); - const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds); + const terminalGroups = normalizeTerminalGroups( + normalized.terminalGroups, + nextIds, + normalized.activeTerminalGroupId, + ); const activeGroupIdFromTerminal = terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null; diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index c76c18544df2..11a197c23bcc 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -19,6 +19,15 @@ Reset that list to use shared actions again. Existing project actions are preser Project names, icons, removal, and importing actions from a checkout remain project-specific. When there are several checkouts, the checkout picker selects which actions and grouping to edit. +## Actions + +Add commands under **Actions**. One action can include extra commands; on web and desktop each extra +command starts in its own split terminal until the active split group reaches its terminal limit; +remaining commands open in separate tabs. Use this for a backend, frontend, and worker that should +run together. Mobile starts the extra shells but shows one terminal at a time. + +In a checkout `t3.json`, extra commands go in `commands` next to `command`. + ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4d2f80a1101a..2e1710216cae 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -383,10 +383,23 @@ export const ProjectScriptIcon = Schema.Literals([ ]); export type ProjectScriptIcon = typeof ProjectScriptIcon.Type; +/** One action can run this many shell commands, each in its own terminal. */ +export const PROJECT_SCRIPT_MAX_COMMANDS = 4; +const PROJECT_SCRIPT_MAX_EXTRA_COMMANDS = PROJECT_SCRIPT_MAX_COMMANDS - 1; + export const ProjectScript = Schema.Struct({ id: TrimmedNonEmptyString, name: TrimmedNonEmptyString, command: TrimmedNonEmptyString, + /** + * Extra shell commands besides `command`. Each runs in its own split + * terminal when the user starts the action. + */ + commands: Schema.optional( + Schema.Array(TrimmedNonEmptyString).check( + Schema.isMaxLength(PROJECT_SCRIPT_MAX_EXTRA_COMMANDS), + ), + ), icon: ProjectScriptIcon, runOnWorktreeCreate: Schema.Boolean, /** diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index ed19c6d69887..02d93444d37d 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -43,8 +43,36 @@ describe("T3ProjectFile", () => { expect(decoded.scripts?.[0]).toEqual({ name: "Dev", command: "pnpm dev" }); }); - it("rejects scripts without a command", () => { - expect(() => decode({ scripts: [{ name: "Dev" }] })).toThrow(); + it("decodes extra script commands", () => { + const decoded = decode({ + scripts: [ + { + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: [" npm run dev ", "python -m arq app.worker.WorkerSettings"], + }, + ], + }); + + expect(decoded.scripts?.[0]).toEqual({ + name: "Dev", + command: "uvicorn app.main:app --reload", + commands: ["npm run dev", "python -m arq app.worker.WorkerSettings"], + }); + }); + + it("rejects more extra commands than a split group can show", () => { + expect(() => + decode({ + scripts: [ + { + name: "Dev", + command: "one", + commands: ["two", "three", "four", "five"], + }, + ], + }), + ).toThrow(); }); it("rejects unknown script icons", () => { diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 5062a1a370b5..ef0b5d020245 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ThreadEnvMode } from "./environment.ts"; -import { ProjectScriptIcon } from "./orchestration.ts"; +import { PROJECT_SCRIPT_MAX_COMMANDS, ProjectScriptIcon } from "./orchestration.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ export const T3_PROJECT_FILE_NAME = "t3.json"; @@ -31,6 +31,18 @@ export const T3ProjectFileScript = Schema.Struct({ command: trimmedNonEmpty({ description: "Shell command executed in a T3 Code terminal at the project root.", }), + commands: Schema.optionalKey( + Schema.Array( + trimmedNonEmpty({ + description: "An extra shell command. Each extra command runs in its own split terminal.", + }), + ) + .annotate({ + description: + "Additional commands started with this action. Each runs in its own split terminal.", + }) + .check(Schema.isMaxLength(PROJECT_SCRIPT_MAX_COMMANDS - 1)), + ), icon: Schema.optionalKey( ProjectScriptIcon.annotate({ description: 'Icon shown next to the script in the scripts menu. Defaults to "play".', diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 4d98e36b4d70..ef667cdbdf5e 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,4 +1,9 @@ -import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; +import { + PROJECT_SCRIPT_MAX_COMMANDS, + type ProjectId, + type ProjectScript, + type ServerSettings, +} from "@t3tools/contracts"; /** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ export function resolveProjectScripts( @@ -55,3 +60,17 @@ export function projectScriptRuntimeEnv( export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { return scripts.find((script) => script.runOnWorktreeCreate) ?? null; } + +/** Commands an action should start, in order. `command` is always first. */ +export function projectScriptCommands(script: { + readonly command: string; + readonly commands?: readonly string[]; +}): readonly string[] { + const extras: string[] = []; + for (const extra of script.commands ?? []) { + const trimmed = extra.trim(); + if (trimmed.length === 0) continue; + extras.push(trimmed); + } + return [script.command, ...extras].slice(0, PROJECT_SCRIPT_MAX_COMMANDS); +} diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index a1986ff35f9b..e1c6e74e853b 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -46,6 +46,7 @@ describe("buildT3ProjectFileJsonSchema", () => { expect(Object.keys(script?.properties ?? {}).sort()).toEqual([ "autoOpenPreview", "command", + "commands", "icon", "name", "previewUrl",