Open History to see the last commit
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import type { Dispatch } from "react";
6import type { ComposerHandleRef } from "../../composerHandleContext";
7import type { selectThreadTerminalUiState } from "../../terminalUiStateStore";
8import { useStableCallback } from "../../hooks/useStableCallback";
9import { useThreadTerminalActions } from "../terminal/useThreadTerminalActions";
10import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
11import {
12 AuthTerminalReadScope,
13 AuthTerminalOperateScope,
14 type EnvironmentId,
15 type ProjectScript,
16 type ScopedThreadRef,
17 type ThreadId,
18} from "@t3tools/contracts";
19import { projectScriptTerminalInput } from "@t3tools/shared/projectScripts";
20import { type SetStateAction } from "react";
21import {
22 isAtomCommandInterrupted,
23 squashAtomCommandFailure,
24} from "@t3tools/client-runtime/state/runtime";
25import { MAX_TERMINALS_PER_GROUP } from "../../types";
26import {
27 selectThreadRightPanelState,
28 type RightPanelSurface,
29 useRightPanelStore,
30} from "../../rightPanelStore";
31import { openUrlInPreview } from "../../browser/openFileInPreview";
32import { isPreviewAvailableFor } from "../../browser/previewRuntime";
33import { resolveDiscoveredServerUrl } from "../../browser/browserTargetResolver";
34import { stackedThreadToast, toastManager } from "../ui/toast";
35import { confirmTerminalClose } from "../../lib/terminalCloseConfirm";
36import { findReusableProjectScriptTerminal } from "../../lib/projectScriptTerminal";
37import { type TerminalContextSelection } from "../../lib/terminalContext";
38import {
39 focusActiveDrawerTerminal,
40 focusActivePanelTerminal,
41 focusTerminal,
42} from "../../lib/terminalFocus";
43import { useTerminalUiStateStore } from "../../terminalUiStateStore";
44import {
45 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
46 LastInvokedScriptByProjectSchema,
47 makeThreadErrorSetter,
48} from "../ChatView.logic";
49import { useLocalStorage } from "~/hooks/useLocalStorage";
50import { previewEnvironment } from "../../state/preview";
51import { readEnvironmentScope } from "../../state/session";
52import { useAtomCommand } from "../../state/use-atom-command";
53import type { ChatShortcutCommands } from "./useChatShortcuts";
54const SCRIPT_TERMINAL_COLS = 120;
55const SCRIPT_TERMINAL_ROWS = 30;
56
57export interface TerminalLaunchContext {
58 threadId: ThreadId;
59 cwd: string;
60 worktreePath: string | null;
61}
62
63interface ChatTerminalActionsInput {
64 readonly environmentId: EnvironmentId;
65 readonly composerRef: ComposerHandleRef;
66 readonly terminalUiState: ReturnType<typeof selectThreadTerminalUiState>;
67 readonly activeThread: EnvironmentThreadShell | undefined;
68 readonly activeThreadId: ThreadId | null;
69 readonly activeThreadRef: ScopedThreadRef | null;
70 readonly activeProject: EnvironmentProject | null;
71 readonly activeRightPanelSurface: RightPanelSurface | null;
72 readonly runningTerminalIds: ReadonlyArray<string>;
73 readonly activeServerOrderedTerminalIds: ReadonlyArray<string>;
74 readonly activeTerminalLabelsById: ReadonlyMap<string, string>;
75 readonly panelTerminalIds: ReadonlySet<string>;
76 readonly canReuseTerminal: boolean;
77 readonly gitCwd: string | null;
78 readonly setTerminalUiLaunchContext: Dispatch<SetStateAction<TerminalLaunchContext | null>>;
79 readonly setThreadError: ReturnType<typeof makeThreadErrorSetter>;
80}
81
82/**
83 * The thread's terminals: the drawer's and the right panel's, and project scripts and chat
84 * code blocks, which run in a reused or new drawer terminal.
85 */
86export function useChatTerminalActions(input: ChatTerminalActionsInput) {
87 const {
88 environmentId,
89 composerRef,
90 terminalUiState,
91 activeThread,
92 activeThreadId,
93 activeThreadRef,
94 activeProject,
95 activeRightPanelSurface,
96 runningTerminalIds,
97 activeServerOrderedTerminalIds,
98 activeTerminalLabelsById,
99 panelTerminalIds,
100 canReuseTerminal,
101 gitCwd,
102 setTerminalUiLaunchContext,
103 setThreadError,
104 } = input;
105 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
106
107 const hasTerminalWriteAccess = () =>
108 readEnvironmentScope(environmentId, AuthTerminalOperateScope);
109 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
110 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
111 {},
112 LastInvokedScriptByProjectSchema,
113 );
114 const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen);
115 const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal);
116 const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal);
117 const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical);
118 const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);
119 const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal);
120 const terminalActions = useThreadTerminalActions(activeThreadRef);
121
122 const activeTerminalGroup =
123 terminalUiState.terminalGroups.find(
124 (group) => group.id === terminalUiState.activeTerminalGroupId,
125 ) ??
126 terminalUiState.terminalGroups.find((group) =>
127 group.terminalIds.includes(terminalUiState.activeTerminalId),
128 ) ??
129 null;
130 const hasReachedSplitLimit =
131 (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP;
132 /*__HOOK__*/
133 const addTerminalContextToDraft = (selection: TerminalContextSelection) => {
134 composerRef.current?.addTerminalContext(selection);
135 };
136 const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft);
137 const setTerminalOpen = (open: boolean) => {
138 if (!activeThreadRef) return;
139 storeSetTerminalOpen(activeThreadRef, open);
140 focusActiveDrawerTerminal(activeThreadRef);
141 };
142 const setTerminalOpenStable = useStableCallback(setTerminalOpen);
143 const toggleTerminalVisibility = () => {
144 if (!activeThreadRef) return;
145 const nextOpen = !terminalUiState.terminalOpen;
146 if (
147 nextOpen &&
148 !readEnvironmentScope(environmentId, AuthTerminalReadScope) &&
149 !hasTerminalWriteAccess()
150 )
151 return;
152 if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) {
153 // Server-created setup terminals exist before this client's drawer mounts.
154 // Reuse them before allocating a shell from the still-empty UI state.
155 const existingTerminalId =
156 runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ??
157 activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id));
158 if (existingTerminalId !== undefined) {
159 storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true });
160 focusActiveDrawerTerminal(activeThreadRef);
161 return;
162 }
163 void terminalActions.launchTerminal({
164 place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }),
165 })?.result;
166 focusActiveDrawerTerminal(activeThreadRef);
167 return;
168 }
169 setTerminalOpen(nextOpen);
170 };
171 const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility);
172 const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => {
173 if (!activeThreadRef || hasReachedSplitLimit) return;
174 const launched = terminalActions.launchTerminal({
175 place: (terminalId) =>
176 direction === "vertical"
177 ? storeSplitTerminalVertical(activeThreadRef, terminalId)
178 : storeSplitTerminal(activeThreadRef, terminalId),
179 });
180 if (!launched) return;
181 focusTerminal(activeThreadRef, launched.terminalId);
182 void launched.result;
183 };
184 const splitTerminalStable = useStableCallback(splitTerminal);
185 const createNewTerminal = () => {
186 if (!activeThreadRef) return;
187 const launched = terminalActions.launchTerminal({
188 place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId),
189 });
190 if (!launched) return;
191 focusTerminal(activeThreadRef, launched.terminalId);
192 void launched.result;
193 };
194 const createNewTerminalStable = useStableCallback(createNewTerminal);
195 const closeTerminal = (terminalId: string) => {
196 if (!hasTerminalWriteAccess() || !activeThreadRef) return;
197 terminalActions.closeTerminalSession(terminalId, activeThreadRef);
198 storeCloseTerminal(activeThreadRef, terminalId);
199 focusActiveDrawerTerminal(activeThreadRef);
200 };
201 const runProjectScript = async (
202 script: ProjectScript,
203 options?: {
204 cwd?: string;
205 env?: Record<string, string>;
206 worktreePath?: string | null;
207 preferNewTerminal?: boolean;
208 rememberAsLastInvoked?: boolean;
209 shellHistory?: "preserve" | "suppress";
210 },
211 ) => {
212 if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
213 if (options?.rememberAsLastInvoked !== false) {
214 setLastInvokedScriptByProjectId((current) => {
215 if (current[activeProject.id] === script.id) return current;
216 return { ...current, [activeProject.id]: script.id };
217 });
218 }
219 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot;
220 const reusableTerminalId =
221 script.stopOnSettle ||
222 options?.preferNewTerminal ||
223 !canReuseTerminal ||
224 !readEnvironmentScope(environmentId, AuthTerminalReadScope)
225 ? null
226 : findReusableProjectScriptTerminal({
227 // Server sessions cover drawer terminals this client has not opened yet, such as a
228 // worktree setup shell; the helper drops the panel-owned ones.
229 drawerTerminalIds: [
230 ...new Set([...terminalUiState.terminalIds, ...activeServerOrderedTerminalIds]),
231 ],
232 activeTerminalId: terminalUiState.activeTerminalId,
233 panelTerminalIds,
234 runningTerminalIds,
235 });
236 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
237
238 setTerminalUiLaunchContext({
239 threadId: activeThreadId,
240 cwd: targetCwd,
241 worktreePath: targetWorktreePath,
242 });
243 if (!activeThreadRef) {
244 return;
245 }
246
247 const launched = terminalActions.launchTerminal({
248 // The drawer opens with the target already placed: opening it empty would claim
249 // DEFAULT_THREAD_TERMINAL_ID, which a right-panel terminal may already own.
250 place: (terminalId) =>
251 reusableTerminalId === null
252 ? storeNewTerminal(activeThreadRef, terminalId)
253 : storeEnsureTerminal(activeThreadRef, terminalId, { open: true }),
254 ...(reusableTerminalId === null
255 ? { size: { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } }
256 : { terminalId: reusableTerminalId }),
257 location: { cwd: targetCwd, worktreePath: targetWorktreePath },
258 stopOnSettle: script.stopOnSettle ?? false,
259 ...(options?.env ? { extraEnv: options.env } : {}),
260 });
261 if (!launched) return;
262 const targetTerminalId = launched.terminalId;
263 focusTerminal(activeThreadRef, targetTerminalId);
264 const openResult = await launched.result;
265 if (openResult._tag === "Failure") {
266 if (!isAtomCommandInterrupted(openResult)) {
267 const error = squashAtomCommandFailure(openResult);
268 setThreadError(
269 activeThreadId,
270 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
271 );
272 }
273 return;
274 }
275
276 const writeResult = await terminalActions.writeToTerminal(
277 targetTerminalId,
278 options?.shellHistory === "preserve"
279 ? `${script.command}\r`
280 : projectScriptTerminalInput(script.command),
281 );
282 if (!writeResult) return;
283 if (writeResult._tag === "Failure") {
284 if (!isAtomCommandInterrupted(writeResult)) {
285 const error = squashAtomCommandFailure(writeResult);
286 setThreadError(
287 activeThreadId,
288 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
289 );
290 }
291 return;
292 }
293 if (
294 script.autoOpenPreview &&
295 script.previewUrl &&
296 isPreviewAvailableFor(activeThreadRef.environmentId)
297 ) {
298 const previewResult = await openUrlInPreview({
299 threadRef: activeThreadRef,
300 url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl),
301 openPreview,
302 });
303 if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) {
304 const error = squashAtomCommandFailure(previewResult);
305 toastManager.add(
306 stackedThreadToast({
307 type: "error",
308 title: "Could not open preview",
309 description: error instanceof Error ? error.message : "An unexpected error occurred.",
310 }),
311 );
312 }
313 }
314 };
315 const runProjectScriptStable = useStableCallback(runProjectScript);
316
317 const runShellCommand = (command: string) => {
318 void runProjectScript(
319 {
320 id: "chat-code-block",
321 name: "Chat code block",
322 command,
323 icon: "play",
324 runOnWorktreeCreate: false,
325 },
326 { rememberAsLastInvoked: false, shellHistory: "preserve" },
327 );
328 };
329 const runShellCommandStable = useStableCallback(runShellCommand);
330 const addTerminalSurface = () => {
331 if (!activeThreadRef) return;
332 const launched = terminalActions.launchTerminal({
333 place: (terminalId) =>
334 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId),
335 });
336 if (!launched) return;
337 focusTerminal(activeThreadRef, launched.terminalId);
338 void launched.result;
339 };
340 const addTerminalSurfaceStable = useStableCallback(addTerminalSurface);
341 const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => {
342 if (
343 !activeThreadRef ||
344 activeRightPanelSurface?.kind !== "terminal" ||
345 activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP
346 ) {
347 return;
348 }
349 const surfaceId = activeRightPanelSurface.id;
350 const launched = terminalActions.launchTerminal({
351 place: (terminalId) =>
352 useRightPanelStore
353 .getState()
354 .splitTerminal(activeThreadRef, surfaceId, terminalId, direction),
355 });
356 if (!launched) return;
357 focusTerminal(activeThreadRef, launched.terminalId);
358 void launched.result;
359 };
360 const splitPanelTerminalStable = useStableCallback(splitPanelTerminal);
361 const splitPanelTerminalVertical = () => {
362 splitPanelTerminal("vertical");
363 };
364 const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical);
365 const activatePanelTerminal = (terminalId: string) => {
366 if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;
367 useRightPanelStore
368 .getState()
369 .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
370 focusTerminal(activeThreadRef, terminalId);
371 };
372 const activatePanelTerminalStable = useStableCallback(activatePanelTerminal);
373 const closePanelTerminal = (terminalId: string, target = activeThreadRef) => {
374 if (!target || !readEnvironmentScope(target.environmentId, AuthTerminalOperateScope)) return;
375 const store = useRightPanelStore.getState();
376 const surface = selectThreadRightPanelState(store.byThreadKey, target).surfaces.find(
377 (entry) => entry.kind === "terminal" && entry.terminalIds.includes(terminalId),
378 );
379 if (!surface) return;
380 terminalActions.closeTerminalSession(terminalId, target);
381 storeCloseTerminal(target, terminalId);
382 store.closeTerminal(target, surface.id, terminalId);
383 focusActivePanelTerminal(target);
384 };
385 const closePanelTerminalStable = useStableCallback(closePanelTerminal);
386 const requestCloseTerminal = (terminalId: string) => {
387 if (!hasTerminalWriteAccess()) return;
388 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
389 void confirmTerminalClose([label]).then((confirmed) => {
390 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
391 closeTerminal(terminalId);
392 }
393 });
394 };
395 const requestCloseTerminalStable = useStableCallback(requestCloseTerminal);
396 const requestClosePanelTerminal = (terminalId: string) => {
397 if (!hasTerminalWriteAccess()) return;
398 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
399 void confirmTerminalClose([label]).then((confirmed) => {
400 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
401 closePanelTerminal(terminalId);
402 }
403 });
404 };
405 const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal);
406 const openTerminalIfClosed = () => {
407 if (!terminalUiState.terminalOpen) setTerminalOpen(true);
408 };
409 // A focused right-panel terminal takes the split/new/close chords; otherwise they act on the drawer.
410 const shortcuts = {
411 "terminal.toggle": () => {
412 if (
413 !terminalUiState.terminalOpen &&
414 !readEnvironmentScope(environmentId, AuthTerminalReadScope) &&
415 !hasTerminalWriteAccess()
416 )
417 return false;
418 toggleTerminalVisibility();
419 },
420 "terminal.split": ({ terminalFocusOwner }) => {
421 if (!hasTerminalWriteAccess()) return false;
422 if (terminalFocusOwner === "right-panel") return void splitPanelTerminal();
423 openTerminalIfClosed();
424 splitTerminal();
425 },
426 "terminal.splitVertical": ({ terminalFocusOwner }) => {
427 if (!hasTerminalWriteAccess()) return false;
428 if (terminalFocusOwner === "right-panel") return void splitPanelTerminal("vertical");
429 openTerminalIfClosed();
430 splitTerminal("vertical");
431 },
432 "terminal.close": ({ terminalFocusOwner }) => {
433 if (!hasTerminalWriteAccess()) return false;
434 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
435 requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId);
436 return;
437 }
438 if (terminalUiState.terminalOpen) requestCloseTerminal(terminalUiState.activeTerminalId);
439 },
440 "terminal.new": ({ terminalFocusOwner }) => {
441 if (!hasTerminalWriteAccess()) return false;
442 if (terminalFocusOwner === "right-panel") return void addTerminalSurface();
443 openTerminalIfClosed();
444 createNewTerminal();
445 },
446 } satisfies ChatShortcutCommands;
447 return {
448 shortcuts,
449 activatePanelTerminalStable,
450 addTerminalContextToDraftStable,
451 addTerminalSurfaceStable,
452 closePanelTerminalStable,
453 createNewTerminalStable,
454 lastInvokedScriptByProjectId,
455 requestClosePanelTerminalStable,
456 requestCloseTerminalStable,
457 runProjectScriptStable,
458 runShellCommandStable,
459 setTerminalOpenStable,
460 splitPanelTerminalStable,
461 splitPanelTerminalVerticalStable,
462 splitTerminalStable,
463 toggleTerminalVisibilityStable,
464 };
465}