import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { Dispatch } from "react"; import type { ComposerHandleRef } from "../../composerHandleContext"; import type { selectThreadTerminalUiState } from "../../terminalUiStateStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { useThreadTerminalActions } from "../terminal/useThreadTerminalActions"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { AuthTerminalReadScope, AuthTerminalOperateScope, type EnvironmentId, type ProjectScript, type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import { projectScriptTerminalInput } from "@t3tools/shared/projectScripts"; import { type SetStateAction } from "react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { MAX_TERMINALS_PER_GROUP } from "../../types"; import { selectThreadRightPanelState, type RightPanelSurface, useRightPanelStore, } from "../../rightPanelStore"; import { isPreviewSupportedInRuntime } from "../../previewStateStore"; import { openUrlInPreview } from "../../browser/openFileInPreview"; import { resolveDiscoveredServerUrl } from "../../browser/browserTargetResolver"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { confirmTerminalClose } from "../../lib/terminalCloseConfirm"; import { findReusableProjectScriptTerminal } from "../../lib/projectScriptTerminal"; import { type TerminalContextSelection } from "../../lib/terminalContext"; import { focusActiveDrawerTerminal, focusActivePanelTerminal, focusTerminal, } from "../../lib/terminalFocus"; import { useTerminalUiStateStore } from "../../terminalUiStateStore"; import { LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, makeThreadErrorSetter, } from "../ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { previewEnvironment } from "../../state/preview"; import { readEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import type { ChatShortcutCommands } from "./useChatShortcuts"; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; export interface TerminalLaunchContext { threadId: ThreadId; cwd: string; worktreePath: string | null; } interface ChatTerminalActionsInput { readonly environmentId: EnvironmentId; readonly composerRef: ComposerHandleRef; readonly terminalUiState: ReturnType; readonly activeThread: EnvironmentThreadShell | undefined; readonly activeThreadId: ThreadId | null; readonly activeThreadRef: ScopedThreadRef | null; readonly activeProject: EnvironmentProject | null; readonly activeRightPanelSurface: RightPanelSurface | null; readonly runningTerminalIds: ReadonlyArray; readonly activeServerOrderedTerminalIds: ReadonlyArray; readonly activeTerminalLabelsById: ReadonlyMap; readonly panelTerminalIds: ReadonlySet; readonly canReuseTerminal: boolean; readonly gitCwd: string | null; readonly setTerminalUiLaunchContext: Dispatch>; readonly setThreadError: ReturnType; } /** * The thread's terminals: the drawer's and the right panel's, and project scripts and chat * code blocks, which run in a reused or new drawer terminal. */ export function useChatTerminalActions(input: ChatTerminalActionsInput) { const { environmentId, composerRef, terminalUiState, activeThread, activeThreadId, activeThreadRef, activeProject, activeRightPanelSurface, runningTerminalIds, activeServerOrderedTerminalIds, activeTerminalLabelsById, panelTerminalIds, canReuseTerminal, gitCwd, setTerminalUiLaunchContext, setThreadError, } = input; const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const hasTerminalWriteAccess = () => readEnvironmentScope(environmentId, AuthTerminalOperateScope); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); const terminalActions = useThreadTerminalActions(activeThreadRef); const activeTerminalGroup = terminalUiState.terminalGroups.find( (group) => group.id === terminalUiState.activeTerminalGroupId, ) ?? terminalUiState.terminalGroups.find((group) => group.terminalIds.includes(terminalUiState.activeTerminalId), ) ?? null; const hasReachedSplitLimit = (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; /*__HOOK__*/ const addTerminalContextToDraft = (selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); }; const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft); const setTerminalOpen = (open: boolean) => { if (!activeThreadRef) return; storeSetTerminalOpen(activeThreadRef, open); focusActiveDrawerTerminal(activeThreadRef); }; const setTerminalOpenStable = useStableCallback(setTerminalOpen); const toggleTerminalVisibility = () => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; if ( nextOpen && !readEnvironmentScope(environmentId, AuthTerminalReadScope) && !hasTerminalWriteAccess() ) return; if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { // Server-created setup terminals exist before this client's drawer mounts. // Reuse them before allocating a shell from the still-empty UI state. const existingTerminalId = runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ?? activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); if (existingTerminalId !== undefined) { storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); focusActiveDrawerTerminal(activeThreadRef); return; } void terminalActions.launchTerminal({ place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), })?.result; focusActiveDrawerTerminal(activeThreadRef); return; } setTerminalOpen(nextOpen); }; const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility); const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { if (!activeThreadRef || hasReachedSplitLimit) return; const launched = terminalActions.launchTerminal({ place: (terminalId) => direction === "vertical" ? storeSplitTerminalVertical(activeThreadRef, terminalId) : storeSplitTerminal(activeThreadRef, terminalId), }); if (!launched) return; focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const splitTerminalStable = useStableCallback(splitTerminal); const createNewTerminal = () => { if (!activeThreadRef) return; const launched = terminalActions.launchTerminal({ place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId), }); if (!launched) return; focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const createNewTerminalStable = useStableCallback(createNewTerminal); const closeTerminal = (terminalId: string) => { if (!hasTerminalWriteAccess() || !activeThreadRef) return; terminalActions.closeTerminalSession(terminalId, activeThreadRef); storeCloseTerminal(activeThreadRef, terminalId); focusActiveDrawerTerminal(activeThreadRef); }; const runProjectScript = async ( script: ProjectScript, options?: { cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; shellHistory?: "preserve" | "suppress"; }, ) => { if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; return { ...current, [activeProject.id]: script.id }; }); } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; const reusableTerminalId = options?.preferNewTerminal || !canReuseTerminal || !readEnvironmentScope(environmentId, AuthTerminalReadScope) ? null : findReusableProjectScriptTerminal({ // Server sessions cover drawer terminals this client has not opened yet, such as a // worktree setup shell; the helper drops the panel-owned ones. drawerTerminalIds: [ ...new Set([...terminalUiState.terminalIds, ...activeServerOrderedTerminalIds]), ], activeTerminalId: terminalUiState.activeTerminalId, panelTerminalIds, runningTerminalIds, }); const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; setTerminalUiLaunchContext({ threadId: activeThreadId, cwd: targetCwd, worktreePath: targetWorktreePath, }); if (!activeThreadRef) { return; } const launched = terminalActions.launchTerminal({ // The drawer opens with the target already placed: opening it empty would claim // DEFAULT_THREAD_TERMINAL_ID, which a right-panel terminal may already own. place: (terminalId) => reusableTerminalId === null ? storeNewTerminal(activeThreadRef, terminalId) : storeEnsureTerminal(activeThreadRef, terminalId, { open: true }), ...(reusableTerminalId === null ? { size: { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } } : { terminalId: reusableTerminalId }), location: { cwd: targetCwd, worktreePath: targetWorktreePath }, ...(options?.env ? { extraEnv: options.env } : {}), }); if (!launched) return; const targetTerminalId = launched.terminalId; focusTerminal(activeThreadRef, targetTerminalId); const openResult = await launched.result; 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; } const writeResult = await terminalActions.writeToTerminal( targetTerminalId, options?.shellHistory === "preserve" ? `${script.command}\r` : projectScriptTerminalInput(script.command), ); if (!writeResult) return; 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; } if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { const previewResult = await openUrlInPreview({ threadRef: activeThreadRef, url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), openPreview, }); if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { const error = squashAtomCommandFailure(previewResult); toastManager.add( stackedThreadToast({ type: "error", title: "Could not open preview", description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); } } }; const runProjectScriptStable = useStableCallback(runProjectScript); const runShellCommand = (command: string) => { void runProjectScript( { id: "chat-code-block", name: "Chat code block", command, icon: "play", runOnWorktreeCreate: false, }, { rememberAsLastInvoked: false, shellHistory: "preserve" }, ); }; const runShellCommandStable = useStableCallback(runShellCommand); const addTerminalSurface = () => { if (!activeThreadRef) return; const launched = terminalActions.launchTerminal({ place: (terminalId) => useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId), }); if (!launched) return; focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { if ( !activeThreadRef || activeRightPanelSurface?.kind !== "terminal" || activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return; } const surfaceId = activeRightPanelSurface.id; const launched = terminalActions.launchTerminal({ place: (terminalId) => useRightPanelStore .getState() .splitTerminal(activeThreadRef, surfaceId, terminalId, direction), }); if (!launched) return; focusTerminal(activeThreadRef, launched.terminalId); void launched.result; }; const splitPanelTerminalStable = useStableCallback(splitPanelTerminal); const splitPanelTerminalVertical = () => { splitPanelTerminal("vertical"); }; const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical); const activatePanelTerminal = (terminalId: string) => { if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; useRightPanelStore .getState() .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); focusTerminal(activeThreadRef, terminalId); }; const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); const closePanelTerminal = (terminalId: string, target = activeThreadRef) => { if (!target || !readEnvironmentScope(target.environmentId, AuthTerminalOperateScope)) return; const store = useRightPanelStore.getState(); const surface = selectThreadRightPanelState(store.byThreadKey, target).surfaces.find( (entry) => entry.kind === "terminal" && entry.terminalIds.includes(terminalId), ); if (!surface) return; terminalActions.closeTerminalSession(terminalId, target); storeCloseTerminal(target, terminalId); store.closeTerminal(target, surface.id, terminalId); focusActivePanelTerminal(target); }; const closePanelTerminalStable = useStableCallback(closePanelTerminal); const requestCloseTerminal = (terminalId: string) => { if (!hasTerminalWriteAccess()) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { closeTerminal(terminalId); } }); }; const requestCloseTerminalStable = useStableCallback(requestCloseTerminal); const requestClosePanelTerminal = (terminalId: string) => { if (!hasTerminalWriteAccess()) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { closePanelTerminal(terminalId); } }); }; const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal); const openTerminalIfClosed = () => { if (!terminalUiState.terminalOpen) setTerminalOpen(true); }; // A focused right-panel terminal takes the split/new/close chords; otherwise they act on the drawer. const shortcuts = { "terminal.toggle": () => { if ( !terminalUiState.terminalOpen && !readEnvironmentScope(environmentId, AuthTerminalReadScope) && !hasTerminalWriteAccess() ) return false; toggleTerminalVisibility(); }, "terminal.split": ({ terminalFocusOwner }) => { if (!hasTerminalWriteAccess()) return false; if (terminalFocusOwner === "right-panel") return void splitPanelTerminal(); openTerminalIfClosed(); splitTerminal(); }, "terminal.splitVertical": ({ terminalFocusOwner }) => { if (!hasTerminalWriteAccess()) return false; if (terminalFocusOwner === "right-panel") return void splitPanelTerminal("vertical"); openTerminalIfClosed(); splitTerminal("vertical"); }, "terminal.close": ({ terminalFocusOwner }) => { if (!hasTerminalWriteAccess()) return false; if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); return; } if (terminalUiState.terminalOpen) requestCloseTerminal(terminalUiState.activeTerminalId); }, "terminal.new": ({ terminalFocusOwner }) => { if (!hasTerminalWriteAccess()) return false; if (terminalFocusOwner === "right-panel") return void addTerminalSurface(); openTerminalIfClosed(); createNewTerminal(); }, } satisfies ChatShortcutCommands; return { shortcuts, activatePanelTerminalStable, addTerminalContextToDraftStable, addTerminalSurfaceStable, closePanelTerminalStable, createNewTerminalStable, lastInvokedScriptByProjectId, requestClosePanelTerminalStable, requestCloseTerminalStable, runProjectScriptStable, runShellCommandStable, setTerminalOpenStable, splitPanelTerminalStable, splitPanelTerminalVerticalStable, splitTerminalStable, toggleTerminalVisibilityStable, }; }