1import { useResizeDrag } from "~/hooks/useResizeDrag";
2import { useAtomValue } from "@effect/atom-react";
3import {
4 isAtomCommandInterrupted,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import {
8 INITIAL_TERMINAL_OUTPUT_CURSOR,
9 readTerminalOutputUpdate,
10 type TerminalOutputCursor,
11 type TerminalOutputUpdate,
12 type TerminalSessionState,
13} from "@t3tools/client-runtime/state/terminal";
14import {
15 Plus,
16 Square,
17 SquareSplitHorizontal,
18 SquareSplitVertical,
19 TerminalSquare,
20 Trash2,
21} from "lucide-react";
22import {
23 AuthPreviewOperateScope,
24 AuthOrchestrationOperateScope,
25 AuthTerminalOperateScope,
26 type ContextMenuItem,
27 type ProviderInstanceId,
28 type ResolvedKeybindingsConfig,
29 type ScopedThreadRef,
30 type ThreadId,
31} from "@t3tools/contracts";
32import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
33import * as Schema from "effect/Schema";
34import {
35 type ReactNode,
36 type SetStateAction,
37 useCallback,
38 useEffect,
39 useEffectEvent,
40 useLayoutEffect,
41 useMemo,
42 useRef,
43 useState,
44} from "react";
45import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
46import { Button } from "~/components/ui/button";
47import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
48import { stackedThreadToast, toastManager } from "~/components/ui/toast";
49import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
50import { cn } from "~/lib/utils";
51import { type TerminalContextSelection } from "~/lib/terminalContext";
52import {
53 TerminalSurface,
54 type TerminalSurfaceOptions,
55 type TerminalTheme,
56} from "~/terminal/TerminalSurface";
57import { useOpenInPreferredEditor } from "../editorPreferences";
58import {
59 formatFilePathPosition,
60 parseFileUrlHref,
61 resolvePathLinkTarget,
62 splitFilePathPosition,
63} from "@t3tools/shared/fileLinks";
64import { safeDecodeURIComponent } from "@t3tools/shared/markdownLinks";
65import { isTerminalUrl } from "../terminal-links";
66import {
67 isDiffToggleShortcut,
68 isTerminalClearShortcut,
69 isTerminalNewShortcut,
70 isTerminalSplitShortcut,
71 isTerminalSplitVerticalShortcut,
72 isTerminalToggleShortcut,
73 terminalDeleteShortcutData,
74 terminalNavigationShortcutData,
75} from "../keybindings";
76import {
77 DEFAULT_THREAD_TERMINAL_HEIGHT,
78 MAX_TERMINALS_PER_GROUP,
79 type ThreadTerminalGroup,
80} from "../types";
81import { readLocalApi } from "~/localApi";
82import { confirmTerminalClose } from "~/lib/terminalCloseConfirm";
83import { useClientSettings } from "../hooks/useSettings";
84import { useLocalStorage } from "../hooks/useLocalStorage";
85import { useAttachedTerminalSession } from "../state/terminalSessions";
86import { serverEnvironment } from "../state/server";
87import { previewEnvironment } from "../state/preview";
88import { readEnvironmentScope } from "../state/session";
89import { terminalEnvironment } from "../state/terminal";
90import { useEnvironmentScope } from "../state/session";
91import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
92import { useAtomCommand } from "../state/use-atom-command";
93import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
94import {
95 resolveTerminalFontPreference,
96 resolveTerminalFontSizePreference,
97 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
98} from "../appearanceFonts";
99
100const MIN_DRAWER_HEIGHT = 180;
101const MAX_DRAWER_HEIGHT_RATIO = 0.75;
102
103function maxDrawerHeight(): number {
104 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
105 return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO));
106}
107
108function clampDrawerHeight(height: number, maxHeight = maxDrawerHeight()): number {
109 const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT;
110 return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);
111}
112
113function writeSystemMessage(terminal: TerminalSurface, message: string): void {
114 terminal.write(`\r\n[terminal] ${message}\r\n`);
115}
116
117export function writeTerminalOutputUpdate(
118 terminal: Pick<TerminalSurface, "resetAndWrite" | "write">,
119 update: TerminalOutputUpdate,
120): void {
121 if (update.type === "reset") {
122 terminal.resetAndWrite(update.data);
123 } else if (update.type === "append") {
124 terminal.write(update.data);
125 }
126}
127
128export function synchronizeTerminalOutput(
129 terminal: Pick<TerminalSurface, "resetAndWrite" | "write">,
130 session: Pick<TerminalSessionState, "output" | "version">,
131 cursor: TerminalOutputCursor,
132): TerminalOutputCursor {
133 if (session.version === 0) return cursor;
134 const update = readTerminalOutputUpdate(session.output, cursor);
135 writeTerminalOutputUpdate(terminal, update);
136 return update.cursor;
137}
138
139// VS Code's default ANSI colors.
140const TERMINAL_PALETTES = {
141 light: [
142 "#000000",
143 "#cd3131",
144 "#107c10",
145 "#949800",
146 "#0451a5",
147 "#bc05bc",
148 "#0598bc",
149 "#555555",
150 "#666666",
151 "#cd3131",
152 "#14ce14",
153 "#b5ba00",
154 "#0451a5",
155 "#bc05bc",
156 "#0598bc",
157 "#a5a5a5",
158 ],
159 dark: [
160 "#000000",
161 "#cd3131",
162 "#0dbc79",
163 "#e5e510",
164 "#2472c8",
165 "#bc3fbc",
166 "#11a8cd",
167 "#e5e5e5",
168 "#666666",
169 "#f14c4c",
170 "#23d18b",
171 "#f5f543",
172 "#3b8eea",
173 "#d670d6",
174 "#29b8db",
175 "#e5e5e5",
176 ],
177};
178
179function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {
180 if (!runtimeEnv) return "";
181 return JSON.stringify(
182 Object.entries(runtimeEnv)
183 .filter(([key, value]) => key.length > 0 && typeof value === "string")
184 .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
185 );
186}
187
188function normalizeComputedColor(value: string | null | undefined, fallback: string): string {
189 const normalizedValue = value?.trim().toLowerCase();
190 if (
191 !normalizedValue ||
192 normalizedValue === "transparent" ||
193 normalizedValue === "rgba(0, 0, 0, 0)" ||
194 normalizedValue === "rgba(0 0 0 / 0)"
195 ) {
196 return fallback;
197 }
198 return value ?? fallback;
199}
200
201function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string {
202 return normalizeComputedColor(styles.getPropertyValue(variable), fallback);
203}
204
205/** The surface treats an omitted family or size as "use the built-in default". */
206function terminalFontOptions(family: string, size: number): { family?: string; size: number } {
207 const trimmed = family.trim();
208 return trimmed.length > 0 ? { family: trimmed, size } : { size };
209}
210
211export function terminalThemeFromApp(mountElement?: HTMLElement | null): TerminalTheme {
212 const drawerSurface =
213 mountElement?.closest("[data-thread-terminal-drawer]") ??
214 document.querySelector("[data-thread-terminal-drawer]") ??
215 document.body;
216 const drawerStyles = getComputedStyle(drawerSurface);
217 const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles;
218 const colorScheme = themeStyles.colorScheme;
219 const isDark =
220 colorScheme === "dark"
221 ? true
222 : colorScheme === "light"
223 ? false
224 : document.documentElement.classList.contains("dark");
225 const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";
226 const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";
227 const bodyStyles = getComputedStyle(document.body);
228 const rootThemeStyles = getComputedStyle(document.documentElement);
229 const background = normalizeComputedColor(
230 drawerStyles.backgroundColor,
231 normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground),
232 );
233 const foreground = normalizeComputedColor(
234 drawerStyles.color,
235 normalizeComputedColor(bodyStyles.color, fallbackForeground),
236 );
237 const terminalBackground = readThemeColor(
238 themeStyles,
239 "--terminal-background",
240 readThemeColor(rootThemeStyles, "--terminal-background", background),
241 );
242 const terminalForeground = readThemeColor(
243 themeStyles,
244 "--terminal-foreground",
245 readThemeColor(rootThemeStyles, "--terminal-foreground", foreground),
246 );
247 const terminalCursor = readThemeColor(
248 themeStyles,
249 "--terminal-cursor",
250 isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)",
251 );
252 const terminalSelection = readThemeColor(
253 themeStyles,
254 "--terminal-selection-background",
255 isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)",
256 );
257 return {
258 background: terminalBackground,
259 foreground: terminalForeground,
260 cursor: terminalCursor,
261 palette: TERMINAL_PALETTES[isDark ? "dark" : "light"],
262 selectionBackground: terminalSelection,
263 };
264}
265
266export function terminalSelectionLineRange(position: {
267 start: { y: number };
268 end: { y: number };
269}): { lineStart: number; lineEnd: number } {
270 const lineStart = position.start.y + 1;
271 return {
272 lineStart,
273 lineEnd: Math.max(lineStart, position.end.y + 1),
274 };
275}
276
277export type TerminalContextMenuAction =
278 | "add-to-chat"
279 | "copy"
280 | "paste"
281 | "select-all"
282 | "scroll-to-bottom";
283
284export function terminalContextMenuItems(options: {
285 hasSelection: boolean;
286 canAddToChat?: boolean;
287 readOnly?: boolean;
288}): ContextMenuItem<TerminalContextMenuAction>[] {
289 const { hasSelection, canAddToChat = true } = options;
290 return [
291 ...(canAddToChat
292 ? [{ id: "add-to-chat" as const, label: "Add to chat", disabled: !hasSelection }]
293 : []),
294 { id: "copy", label: "Copy", disabled: !hasSelection },
295 { id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) },
296 { id: "select-all", label: "Select all" },
297 { id: "scroll-to-bottom", label: "Jump to latest" },
298 ];
299}
300
301export function shouldHandleTerminalExit(
302 current: TerminalSessionState["status"],
303 synchronized: TerminalSessionState["status"],
304 alreadyHandled: boolean,
305 version: number,
306): boolean {
307 return (
308 version > 0 &&
309 (current === "closed" || current === "exited") &&
310 current !== synchronized &&
311 !alreadyHandled
312 );
313}
314
315interface TerminalViewportProps {
316 advancedTypography: boolean;
317 threadRef: ScopedThreadRef;
318 threadId: ThreadId;
319 terminalId: string;
320 terminalLabel: string;
321 cwd: string;
322 worktreePath?: string | null;
323 runtimeEnv?: Record<string, string>;
324 providerInstanceId?: ProviderInstanceId;
325 onSessionExited: () => void;
326 onAddTerminalContext?: (selection: TerminalContextSelection) => void;
327 focusRequestId: number;
328 autoFocus: boolean;
329 visible: boolean;
330 resizeEpoch: number;
331 drawerHeight: number;
332 keybindings: ResolvedKeybindingsConfig;
333}
334
335interface TerminalLaunchLocation {
336 readonly cwd: string;
337 readonly worktreePath?: string | null;
338 readonly runtimeEnv?: Record<string, string>;
339}
340
341export function TerminalViewport({
342 advancedTypography,
343 threadRef,
344 threadId,
345 terminalId,
346 terminalLabel,
347 cwd,
348 worktreePath,
349 runtimeEnv,
350 providerInstanceId,
351 onSessionExited,
352 onAddTerminalContext,
353 focusRequestId,
354 autoFocus,
355 visible,
356 resizeEpoch,
357 drawerHeight,
358 keybindings,
359}: TerminalViewportProps) {
360 const containerRef = useRef<HTMLDivElement>(null);
361 const terminalRef = useRef<TerminalSurface | null>(null);
362 const visibleRef = useRef(visible);
363 const environmentId = threadRef.environmentId;
364 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
365 const hasTerminalWriteAccess = useEffectEvent(() =>
366 readEnvironmentScope(environmentId, AuthTerminalOperateScope),
367 );
368 const canActivateTerminalLink = useEffectEvent(
369 (text: string) =>
370 isTerminalUrl(text) || readEnvironmentScope(environmentId, AuthOrchestrationOperateScope),
371 );
372 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
373 const openInPreferredEditor = useOpenInPreferredEditor(
374 environmentId,
375 serverConfig?.availableEditors ?? [],
376 );
377 const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target));
378 const openPreview = useAtomCommand(previewEnvironment.open, {
379 reportFailure: false,
380 });
381 const runTerminalWrite = useAtomCommand(terminalEnvironment.write, {
382 reportFailure: false,
383 });
384 const runTerminalResize = useAtomCommand(terminalEnvironment.resize, {
385 reportFailure: false,
386 });
387 const hasHandledExitRef = useRef(false);
388 const contextMenuRequestIdRef = useRef(0);
389 const keybindingsRef = useRef(keybindings);
390 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
391 const handleSessionExited = useEffectEvent(() => {
392 if (hasTerminalWriteAccess()) onSessionExited();
393 });
394 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
395 onAddTerminalContext?.(selection);
396 });
397 const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined);
398 const readTerminalLabel = useEffectEvent(() => terminalLabel);
399 const terminalFontFamily = useClientSettings((settings) =>
400 resolveTerminalFontPreference({
401 advanced: advancedTypography,
402 code: settings.fontFamilyCode,
403 terminal: settings.fontFamilyTerminal,
404 }),
405 );
406 const terminalFontSize = useClientSettings((settings) =>
407 resolveTerminalFontSizePreference({
408 advanced: advancedTypography,
409 code: settings.fontSizeCode,
410 terminal: settings.fontSizeTerminal,
411 }),
412 );
413 const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
414 const terminalSession = useAttachedTerminalSession({
415 environmentId,
416 terminal: {
417 threadId,
418 terminalId,
419 cwd,
420 ...(worktreePath !== undefined ? { worktreePath } : {}),
421 ...(runtimeEnv ? { env: runtimeEnv } : {}),
422 ...(providerInstanceId ? { providerInstanceId } : {}),
423 },
424 });
425 const canResizeTerminal =
426 canOperateTerminal && terminalSession.version > 0 && terminalSession.status === "running";
427 const resizeSessionGeneration = canResizeTerminal ? terminalSession.output.generation : null;
428 const writeTerminal = useEffectEvent((data: string) =>
429 runTerminalWrite({
430 environmentId,
431 input: { threadId, terminalId, data },
432 }),
433 );
434 const resizeTerminal = useEffectEvent((cols: number, rows: number) => {
435 if (!canResizeTerminal || !hasTerminalWriteAccess()) return;
436 return runTerminalResize({
437 environmentId,
438 input: { threadId, terminalId, cols, rows },
439 });
440 });
441 const terminalOutput = terminalSession.output;
442 const terminalError = terminalSession.error;
443 const terminalStatus = terminalSession.status;
444 const outputCursorRef = useRef<TerminalOutputCursor>(INITIAL_TERMINAL_OUTPUT_CURSOR);
445 const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed");
446 const synchronizeTerminalStatus = useEffectEvent(
447 (terminal: TerminalSurface, status: TerminalSessionState["status"], version: number) => {
448 const synchronized = synchronizedStatusRef.current;
449 if (version > 0 && status === "running") {
450 hasHandledExitRef.current = false;
451 } else if (
452 shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current, version)
453 ) {
454 hasHandledExitRef.current = true;
455 writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited");
456 window.setTimeout(() => {
457 if (hasHandledExitRef.current) {
458 handleSessionExited();
459 }
460 }, 0);
461 }
462 if (version > 0) synchronizedStatusRef.current = status;
463 },
464 );
465 const terminalVersion = terminalSession.version;
466 const previousSessionRef = useRef({
467 output: terminalOutput,
468 error: terminalError,
469 status: terminalStatus,
470 version: terminalVersion,
471 });
472 const latestSessionRef = useRef(previousSessionRef.current);
473 latestSessionRef.current = {
474 output: terminalOutput,
475 error: terminalError,
476 status: terminalStatus,
477 version: terminalVersion,
478 };
479
480 useEffect(() => {
481 keybindingsRef.current = keybindings;
482 }, [keybindings]);
483
484 useLayoutEffect(() => {
485 if (terminalRef.current) terminalRef.current.input.readOnly = !canOperateTerminal;
486 }, [canOperateTerminal]);
487
488 useEffect(() => {
489 if (resizeSessionGeneration === null) return;
490 // The first fit can finish before authorization or the attach snapshot.
491 // Replay its grid once this writable session exists, including reconnects.
492 terminalRef.current?.resendSize();
493 }, [resizeSessionGeneration]);
494
495 useLayoutEffect(() => {
496 visibleRef.current = visible;
497 terminalRef.current?.setVisible(visible);
498 }, [visible]);
499
500 useEffect(() => {
501 const current = terminalFontRef.current;
502 if (current.family === terminalFontFamily && current.size === terminalFontSize) return;
503 terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize };
504 void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize));
505 }, [terminalFontFamily, terminalFontSize]);
506
507 useEffect(() => {
508 const mount = containerRef.current;
509 if (!mount) return;
510
511 const localApi = readLocalApi();
512 let cancelled = false;
513 let teardown: (() => void) | null = null;
514 let setupTerminal: TerminalSurface | null = null;
515 let setupCleanups: Array<() => void> = [];
516
517 const setup = async (): Promise<(() => void) | null> => {
518 const setupFont = terminalFontRef.current;
519 const terminalOptions: TerminalSurfaceOptions = {
520 theme: terminalThemeFromApp(mount),
521 font: terminalFontOptions(setupFont.family, setupFont.size),
522 get visible() {
523 return visibleRef.current;
524 },
525 onData: (data) => {
526 if (!cancelled) handleData(data);
527 },
528 onResize: (cols, rows) => {
529 if (!cancelled) void resizeTerminal(cols, rows);
530 },
531 beforeKey: (event) => handleBeforeKey(event),
532 onLinkActivate: (text) => handleLinkActivate(text),
533 canActivateLink: (text) => canActivateTerminalLink(text),
534 validatePath: (path) => canActivateTerminalLink(resolvePathLinkTarget(path, cwd)),
535 onContextMenu: (event) => {
536 if (terminalRef.current) void showTerminalContextMenu(event);
537 },
538 };
539 const terminal = await TerminalSurface.create(mount, terminalOptions);
540 if (cancelled) {
541 terminal.dispose();
542 return null;
543 }
544 terminal.setVisible(visibleRef.current);
545 // The theme observer is not installed yet, so re-read the theme in case
546 // the app toggled light/dark while the WASM surface was loading.
547 terminal.setTheme(terminalThemeFromApp(mount));
548 setupTerminal = terminal;
549 terminalRef.current = terminal;
550 terminal.input.readOnly = !hasTerminalWriteAccess();
551 // Client settings hydrate asynchronously; a font preference that landed
552 // while the surface was loading found terminalRef null, so its setFont
553 // was dropped. Re-apply whatever is current once the terminal exists.
554 const currentFont = terminalFontRef.current;
555 if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) {
556 void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size));
557 }
558 const latestSession = latestSessionRef.current;
559 previousSessionRef.current = latestSession;
560 const initialOutput = readTerminalOutputUpdate(
561 latestSession.output,
562 INITIAL_TERMINAL_OUTPUT_CURSOR,
563 );
564 if (initialOutput.type === "reset" && initialOutput.data.length > 0) {
565 writeTerminalOutputUpdate(terminal, initialOutput);
566 }
567 outputCursorRef.current = initialOutput.cursor;
568 if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error);
569 // Attaching to a session that already exited must still run exit handling
570 // once, so mount synchronization starts from the empty "closed" state.
571 // (A session that is "closed" at mount is indistinguishable from one that
572 // never started, so only "exited" triggers the message — as with xterm.)
573 synchronizedStatusRef.current = "closed";
574 synchronizeTerminalStatus(terminal, latestSession.status, latestSession.version);
575 // Startup may finish after the user has returned to the composer.
576 if (
577 hasTerminalWriteAccess() &&
578 visibleRef.current &&
579 mount.contains(document.activeElement)
580 ) {
581 terminal.focus();
582 }
583
584 const readSelectionAction = (): {
585 clipboardText: string;
586 selection: TerminalContextSelection;
587 } | null => {
588 const activeTerminal = terminalRef.current;
589 const mountElement = containerRef.current;
590 if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) {
591 return null;
592 }
593 const selectionText = activeTerminal.getSelection();
594 const selectionPosition = activeTerminal.getSelectionPosition();
595 const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
596 if (!selectionPosition || normalizedText.length === 0) {
597 return null;
598 }
599 const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition);
600 return {
601 clipboardText: selectionText,
602 selection: {
603 terminalId,
604 terminalLabel: readTerminalLabel(),
605 lineStart,
606 lineEnd,
607 text: normalizedText,
608 },
609 };
610 };
611
612 const addSelectionToChat = (selection: TerminalContextSelection) => {
613 handleAddTerminalContext(selection);
614 terminalRef.current?.clearSelection();
615 terminalRef.current?.focus();
616 };
617
618 // A superseded menu must not report errors or reclaim focus.
619 const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => {
620 if (requestId !== contextMenuRequestIdRef.current) return;
621 const activeTerminal = terminalRef.current;
622 if (activeTerminal) {
623 writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallback);
624 }
625 };
626
627 const focusIfCurrent = (requestId: number) => {
628 if (requestId === contextMenuRequestIdRef.current) {
629 terminalRef.current?.focus();
630 }
631 };
632
633 const copySelection = async (text: string, requestId: number) => {
634 try {
635 await writeTextToClipboard(text, "terminal selection");
636 } catch (error) {
637 reportIfCurrent(requestId, error, "Unable to copy terminal selection");
638 }
639 focusIfCurrent(requestId);
640 };
641
642 const pasteFromClipboard = async (requestId: number) => {
643 if (!hasTerminalWriteAccess()) return;
644 const activeTerminal = terminalRef.current;
645 if (!activeTerminal) return;
646 try {
647 const text = await readTextFromClipboard("terminal input");
648 if (requestId === contextMenuRequestIdRef.current && hasTerminalWriteAccess())
649 activeTerminal.paste(text);
650 } catch (error) {
651 reportIfCurrent(requestId, error, "Unable to read the clipboard");
652 return;
653 }
654 focusIfCurrent(requestId);
655 };
656
657 const showTerminalContextMenu = async (event: MouseEvent) => {
658 if (!localApi || !terminalRef.current) return;
659 event.preventDefault();
660 const requestId = ++contextMenuRequestIdRef.current;
661 const selectionAction = readSelectionAction();
662 let clicked: TerminalContextMenuAction | null;
663 try {
664 clicked = await localApi.contextMenu.show(
665 terminalContextMenuItems({
666 hasSelection: selectionAction !== null,
667 canAddToChat: canAddSelectionToChat(),
668 readOnly: !hasTerminalWriteAccess(),
669 }),
670 { x: event.clientX, y: event.clientY },
671 );
672 } catch (error) {
673 reportIfCurrent(requestId, error, "Unable to open the terminal context menu");
674 focusIfCurrent(requestId);
675 return;
676 }
677 if (requestId !== contextMenuRequestIdRef.current || clicked === null) {
678 return;
679 }
680 switch (clicked) {
681 case "add-to-chat":
682 if (selectionAction && canAddSelectionToChat()) {
683 addSelectionToChat(selectionAction.selection);
684 }
685 return;
686 case "copy":
687 if (selectionAction) await copySelection(selectionAction.clipboardText, requestId);
688 return;
689 case "paste":
690 await pasteFromClipboard(requestId);
691 return;
692 case "select-all":
693 terminalRef.current?.selectAll();
694 focusIfCurrent(requestId);
695 return;
696 case "scroll-to-bottom":
697 terminalRef.current?.scrollToBottom();
698 focusIfCurrent(requestId);
699 return;
700 }
701 };
702
703 const sendTerminalInput = async (data: string, fallbackError: string) => {
704 if (!hasTerminalWriteAccess()) return;
705 const activeTerminal = terminalRef.current;
706 if (!activeTerminal) return;
707 const result = await writeTerminal(data);
708 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
709 const error = squashAtomCommandFailure(result);
710 writeSystemMessage(
711 activeTerminal,
712 error instanceof Error ? error.message : fallbackError,
713 );
714 }
715 };
716
717 function handleBeforeKey(event: KeyboardEvent): boolean {
718 const currentKeybindings = keybindingsRef.current;
719 const options = { context: { terminalFocus: true, terminalOpen: true } };
720 if (preventTerminalCloseShortcut(event, currentKeybindings)) {
721 return false;
722 }
723 if (
724 isTerminalToggleShortcut(event, currentKeybindings, options) ||
725 isTerminalSplitShortcut(event, currentKeybindings, options) ||
726 isTerminalSplitVerticalShortcut(event, currentKeybindings, options) ||
727 isTerminalNewShortcut(event, currentKeybindings, options) ||
728 isDiffToggleShortcut(event, currentKeybindings, options)
729 ) {
730 return false;
731 }
732
733 const navigationData = terminalNavigationShortcutData(event);
734 if (navigationData !== null) {
735 event.preventDefault();
736 event.stopPropagation();
737 void sendTerminalInput(navigationData, "Failed to move cursor");
738 return false;
739 }
740
741 const deleteData = terminalDeleteShortcutData(event);
742 if (deleteData !== null) {
743 event.preventDefault();
744 event.stopPropagation();
745 void sendTerminalInput(deleteData, "Failed to delete terminal input");
746 return false;
747 }
748
749 if (!isTerminalClearShortcut(event)) return true;
750 event.preventDefault();
751 event.stopPropagation();
752 void sendTerminalInput("\u000c", "Failed to clear terminal");
753 return false;
754 }
755
756 function handleLinkActivate(text: string): void {
757 if (!canActivateTerminalLink(text)) return;
758 const latestTerminal = terminalRef.current;
759 if (!latestTerminal) return;
760 if (isTerminalUrl(text)) {
761 if (!localApi) {
762 writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser.");
763 return;
764 }
765 const fallbackToBrowser = () => {
766 void localApi.shell.openExternal(text).catch((error: unknown) => {
767 writeSystemMessage(
768 latestTerminal,
769 error instanceof Error ? error.message : "Unable to open link",
770 );
771 });
772 };
773 if (!readEnvironmentScope(environmentId, AuthPreviewOperateScope)) {
774 fallbackToBrowser();
775 return;
776 }
777 void openTerminalLinkInPreview({
778 url: text,
779 threadRef,
780 openPreview,
781 fallbackToBrowser,
782 forceBrowser: false,
783 }).catch((error: unknown) => {
784 toastManager.add(
785 stackedThreadToast({
786 type: "error",
787 title: "Unable to open link",
788 description: error instanceof Error ? error.message : "An error occurred.",
789 }),
790 );
791 });
792 return;
793 }
794 const fileUrl = parseFileUrlHref(text);
795 const path = fileUrl
796 ? formatFilePathPosition(
797 splitFilePathPosition(safeDecodeURIComponent(fileUrl.path), fileUrl.hash),
798 )
799 : text;
800 const target = resolvePathLinkTarget(path, cwd);
801 void (async () => {
802 const result = await openTerminalPath(target);
803 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
804 return;
805 }
806 const error = squashAtomCommandFailure(result);
807 writeSystemMessage(
808 latestTerminal,
809 error instanceof Error ? error.message : "Unable to open path",
810 );
811 })();
812 }
813
814 function handleData(data: string): void {
815 if (!hasTerminalWriteAccess()) return;
816 void (async () => {
817 const result = await writeTerminal(data);
818 if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
819 const error = squashAtomCommandFailure(result);
820 writeSystemMessage(
821 terminal,
822 error instanceof Error ? error.message : "Terminal write failed",
823 );
824 })();
825 }
826
827 const themeObserver = new MutationObserver(() => {
828 const activeTerminal = terminalRef.current;
829 if (!activeTerminal) return;
830 activeTerminal.setTheme(terminalThemeFromApp(containerRef.current));
831 });
832 themeObserver.observe(document.documentElement, {
833 attributes: true,
834 attributeFilter: ["class", "style"],
835 });
836 setupCleanups.push(() => themeObserver.disconnect());
837
838 const fitTimer = window.setTimeout(() => {
839 const activeTerminal = terminalRef.current;
840 if (!activeTerminal) return;
841 const wasAtBottom = activeTerminal.isAtBottom();
842 activeTerminal.fit();
843 if (wasAtBottom) {
844 activeTerminal.scrollToBottom();
845 }
846 }, 30);
847 setupCleanups.push(() => window.clearTimeout(fitTimer));
848
849 const cleanups = setupCleanups;
850 setupCleanups = [];
851 setupTerminal = null;
852 return () => {
853 contextMenuRequestIdRef.current += 1;
854 for (const cleanup of cleanups.toReversed()) cleanup();
855 if (terminalRef.current === terminal) terminalRef.current = null;
856 terminal.dispose();
857 };
858 };
859
860 void setup()
861 .then((nextTeardown) => {
862 if (cancelled) {
863 nextTeardown?.();
864 return;
865 }
866 teardown = nextTeardown;
867 })
868 .catch((error: unknown) => {
869 for (const cleanup of setupCleanups.toReversed()) cleanup();
870 setupCleanups = [];
871 if (terminalRef.current === setupTerminal) terminalRef.current = null;
872 setupTerminal?.dispose();
873 setupTerminal = null;
874 if (cancelled) return;
875 const message =
876 error instanceof Error ? error.message : "Unable to initialize libghostty-vt";
877 mount.textContent = `${message} — close and reopen the terminal to retry.`;
878 });
879
880 return () => {
881 cancelled = true;
882 const hadFocus = mount.contains(document.activeElement);
883 teardown?.();
884 if (hasTerminalWriteAccess() && hadFocus && mount.isConnected) {
885 mount.focus({ preventScroll: true });
886 }
887 };
888 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
889
890 useEffect(() => {
891 const terminal = terminalRef.current;
892 const current = {
893 output: terminalOutput,
894 error: terminalError,
895 status: terminalStatus,
896 version: terminalVersion,
897 };
898 if (!terminal) {
899 previousSessionRef.current = current;
900 return;
901 }
902
903 const previous = previousSessionRef.current;
904 synchronizeTerminalStatus(terminal, current.status, current.version);
905 if (current.version === previous.version && current.output === previous.output) {
906 return;
907 }
908
909 outputCursorRef.current = synchronizeTerminalOutput(terminal, current, outputCursorRef.current);
910
911 if (current.error !== null && current.error !== previous.error) {
912 writeSystemMessage(terminal, current.error);
913 }
914
915 previousSessionRef.current = current;
916 }, [terminalOutput, terminalError, terminalStatus, terminalVersion]);
917
918 useEffect(() => {
919 if (!autoFocus || !canOperateTerminal || !visible) return;
920 // Claim focus when requested, then hand it to the terminal once ready only
921 // if the user has not focused something else in the meantime.
922 (terminalRef.current ?? containerRef.current)?.focus();
923 }, [autoFocus, canOperateTerminal, focusRequestId, visible]);
924
925 useEffect(() => {
926 const terminal = terminalRef.current;
927 if (!terminal || !visibleRef.current) return;
928 const wasAtBottom = terminal.isAtBottom();
929 // The surface reports grid changes through onResize, which is the single
930 // channel for PTY resize RPCs; fitting here only refreshes the layout.
931 const frame = window.requestAnimationFrame(() => {
932 if (!visibleRef.current) return;
933 terminal.fit();
934 if (wasAtBottom) {
935 terminal.scrollToBottom();
936 }
937 });
938 return () => {
939 window.cancelAnimationFrame(frame);
940 };
941 }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]);
942 return (
943 <div
944 ref={containerRef}
945 tabIndex={-1}
946 aria-label={terminalLabel}
947 className="relative h-full w-full overflow-hidden bg-(--terminal-background)"
948 />
949 );
950}
951
952interface ThreadTerminalDrawerProps {
953 mode?: "drawer" | "panel";
954 threadRef: ScopedThreadRef;
955 threadId: ThreadId;
956 cwd: string;
957 worktreePath?: string | null;
958 runtimeEnv?: Record<string, string>;
959 visible?: boolean;
960 height: number;
961 terminalIds: string[];
962 activeTerminalId: string;
963 terminalGroups: ThreadTerminalGroup[];
964 activeTerminalGroupId: string;
965 focusRequestId: number;
966 onSplitTerminal: () => void;
967 onSplitTerminalVertical: () => void;
968 onNewTerminal: () => void;
969 splitShortcutLabel?: string | undefined;
970 splitVerticalShortcutLabel?: string | undefined;
971 newShortcutLabel?: string | undefined;
972 closeShortcutLabel?: string | undefined;
973 onActiveTerminalChange: (terminalId: string) => void;
974 onCloseTerminal: (terminalId: string) => void;
975 onHeightChange: (height: number) => void;
976 onAddTerminalContext: (selection: TerminalContextSelection) => void;
977 keybindings: ResolvedKeybindingsConfig;
978 /** Prefer server-provided tab titles when present (e.g. active subprocess name). */
979 terminalLabelsById?: ReadonlyMap<string, string>;
980 /** Prefer per-session launch locations when the server already knows a terminal. */
981 terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>;
982}
983
984interface TerminalActionButtonProps {
985 label: string;
986 className: string;
987 onClick: () => void;
988 children: ReactNode;
989 disabled?: boolean;
990}
991
992function TerminalActionButton({
993 label,
994 className,
995 onClick,
996 children,
997 disabled,
998}: TerminalActionButtonProps) {
999 return (
1000 <Popover>
1001 <PopoverTrigger
1002 openOnHover
1003 render={
1004 <button
1005 type="button"
1006 className={cn(className, disabled && "opacity-45 cursor-not-allowed")}
1007 onClick={disabled ? undefined : onClick}
1008 aria-label={label}
1009 aria-disabled={disabled}
1010 />
1011 }
1012 >
1013 {children}
1014 </PopoverTrigger>
1015 <PopoverPopup
1016 tooltipStyle
1017 side="bottom"
1018 sideOffset={6}
1019 align="center"
1020 className="pointer-events-none select-none"
1021 >
1022 {label}
1023 </PopoverPopup>
1024 </Popover>
1025 );
1026}
1027
1028export default function ThreadTerminalDrawer({
1029 mode = "drawer",
1030 threadRef,
1031 threadId,
1032 cwd,
1033 worktreePath,
1034 runtimeEnv,
1035 visible = true,
1036 height,
1037 terminalIds,
1038 activeTerminalId,
1039 terminalGroups,
1040 activeTerminalGroupId,
1041 focusRequestId,
1042 onSplitTerminal,
1043 onSplitTerminalVertical,
1044 onNewTerminal,
1045 splitShortcutLabel,
1046 splitVerticalShortcutLabel,
1047 newShortcutLabel,
1048 closeShortcutLabel,
1049 onActiveTerminalChange,
1050 onCloseTerminal,
1051 onHeightChange,
1052 onAddTerminalContext,
1053 keybindings,
1054 terminalLabelsById,
1055 terminalLaunchLocationsById,
1056}: ThreadTerminalDrawerProps) {
1057 const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope);
1058 const isPanel = mode === "panel";
1059 const [advancedTypography] = useLocalStorage(
1060 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
1061 false,
1062 Schema.Boolean,
1063 );
1064 // The stored height is the user's preference; the window only clamps what renders.
1065 const controlledDrawerHeight = height;
1066 const [windowMaxDrawerHeight, setWindowMaxDrawerHeight] = useState(maxDrawerHeight);
1067 const [drawerHeightState, setDrawerHeightState] = useState(() => ({
1068 threadId,
1069 height: controlledDrawerHeight,
1070 }));
1071 const preferredDrawerHeight =
1072 drawerHeightState.threadId === threadId ? drawerHeightState.height : controlledDrawerHeight;
1073 const drawerHeight = clampDrawerHeight(preferredDrawerHeight, windowMaxDrawerHeight);
1074 const setDrawerHeight = useCallback(
1075 (update: SetStateAction<number>) => {
1076 setDrawerHeightState((current) => {
1077 const currentHeight =
1078 current.threadId === threadId ? current.height : controlledDrawerHeight;
1079 const nextHeight = typeof update === "function" ? update(currentHeight) : update;
1080 return nextHeight === currentHeight && current.threadId === threadId
1081 ? current
1082 : { threadId, height: nextHeight };
1083 });
1084 },
1085 [controlledDrawerHeight, threadId],
1086 );
1087 const [resizeEpoch, setResizeEpoch] = useState(0);
1088 const preferredDrawerHeightRef = useRef(preferredDrawerHeight);
1089 const lastSyncedHeightRef = useRef(controlledDrawerHeight);
1090 const onHeightChangeRef = useRef(onHeightChange);
1091
1092 const normalizedTerminalIds = useMemo(() => {
1093 const normalizedIds: string[] = [];
1094 const seen = new Set<string>();
1095 for (const id of terminalIds) {
1096 const trimmedId = id.trim();
1097 if (trimmedId.length === 0 || seen.has(trimmedId)) continue;
1098 seen.add(trimmedId);
1099 normalizedIds.push(trimmedId);
1100 }
1101 return normalizedIds;
1102 }, [terminalIds]);
1103
1104 const resolvedActiveTerminalId =
1105 normalizedTerminalIds.length === 0
1106 ? ""
1107 : normalizedTerminalIds.includes(activeTerminalId)
1108 ? activeTerminalId
1109 : (normalizedTerminalIds[0] ?? "");
1110
1111 const resolvedTerminalGroups = useMemo(() => {
1112 if (normalizedTerminalIds.length === 0) {
1113 return [];
1114 }
1115 const validTerminalIdSet = new Set(normalizedTerminalIds);
1116 const assignedTerminalIds = new Set<string>();
1117 const usedGroupIds = new Set<string>();
1118 const nextGroups: ThreadTerminalGroup[] = [];
1119
1120 const assignUniqueGroupId = (groupId: string): string => {
1121 if (!usedGroupIds.has(groupId)) {
1122 usedGroupIds.add(groupId);
1123 return groupId;
1124 }
1125 let suffix = 2;
1126 while (usedGroupIds.has(`${groupId}-${suffix}`)) {
1127 suffix += 1;
1128 }
1129 const uniqueGroupId = `${groupId}-${suffix}`;
1130 usedGroupIds.add(uniqueGroupId);
1131 return uniqueGroupId;
1132 };
1133
1134 for (const terminalGroup of terminalGroups) {
1135 const nextTerminalIds: string[] = [];
1136 const seenGroupTerminalIds = new Set<string>();
1137 for (const id of terminalGroup.terminalIds) {
1138 const terminalId = id.trim();
1139 if (terminalId.length === 0) continue;
1140 if (seenGroupTerminalIds.has(terminalId)) continue;
1141 seenGroupTerminalIds.add(terminalId);
1142 if (!validTerminalIdSet.has(terminalId)) continue;
1143 if (assignedTerminalIds.has(terminalId)) continue;
1144 nextTerminalIds.push(terminalId);
1145 }
1146 if (nextTerminalIds.length === 0) continue;
1147
1148 for (const terminalId of nextTerminalIds) {
1149 assignedTerminalIds.add(terminalId);
1150 }
1151
1152 const baseGroupId =
1153 terminalGroup.id.trim().length > 0
1154 ? terminalGroup.id.trim()
1155 : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`;
1156 nextGroups.push({
1157 id: assignUniqueGroupId(baseGroupId),
1158 terminalIds: nextTerminalIds,
1159 ...(terminalGroup.splitDirection === "vertical"
1160 ? { splitDirection: "vertical" as const }
1161 : {}),
1162 });
1163 }
1164
1165 for (const terminalId of normalizedTerminalIds) {
1166 if (assignedTerminalIds.has(terminalId)) continue;
1167 nextGroups.push({
1168 id: assignUniqueGroupId(`group-${terminalId}`),
1169 terminalIds: [terminalId],
1170 });
1171 }
1172
1173 const terminalOrderIndex = new Map(
1174 normalizedTerminalIds.map((id, index) => [id, index] as const),
1175 );
1176 nextGroups.sort((left, right) => {
1177 const rank = (ids: readonly string[]) =>
1178 Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY));
1179 return rank(left.terminalIds) - rank(right.terminalIds);
1180 });
1181
1182 return nextGroups;
1183 }, [normalizedTerminalIds, terminalGroups]);
1184
1185 const resolvedActiveGroupIndex = useMemo(() => {
1186 const indexById = resolvedTerminalGroups.findIndex(
1187 (terminalGroup) => terminalGroup.id === activeTerminalGroupId,
1188 );
1189 if (indexById >= 0) return indexById;
1190 const indexByTerminal = resolvedTerminalGroups.findIndex((terminalGroup) =>
1191 terminalGroup.terminalIds.includes(resolvedActiveTerminalId),
1192 );
1193 return indexByTerminal >= 0 ? indexByTerminal : 0;
1194 }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]);
1195
1196 const visibleTerminalIds =
1197 resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ??
1198 (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []);
1199 const splitDirection =
1200 resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal";
1201 const hasTerminalSidebar = normalizedTerminalIds.length > 1;
1202 const isSplitView = visibleTerminalIds.length > 1;
1203 const showGroupHeaders =
1204 resolvedTerminalGroups.length > 1 ||
1205 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
1206 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
1207 const terminalLabelById = useMemo(() => {
1208 const next = new Map<string, string>();
1209 for (const terminalId of normalizedTerminalIds) {
1210 next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId));
1211 }
1212 return next;
1213 }, [normalizedTerminalIds, terminalLabelsById]);
1214 const resolveTerminalLaunchLocation = useCallback(
1215 (terminalId: string): TerminalLaunchLocation => {
1216 return (
1217 terminalLaunchLocationsById?.get(terminalId) ?? {
1218 cwd,
1219 ...(worktreePath !== undefined ? { worktreePath } : {}),
1220 ...(runtimeEnv ? { runtimeEnv } : {}),
1221 }
1222 );
1223 },
1224 [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath],
1225 );
1226 const splitTerminalActionLabel = hasReachedSplitLimit
1227 ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)`
1228 : splitShortcutLabel
1229 ? `Split Terminal Horizontally (${splitShortcutLabel})`
1230 : "Split Terminal Horizontally";
1231 const splitTerminalVerticalActionLabel = hasReachedSplitLimit
1232 ? `Split Terminal Vertically (max ${MAX_TERMINALS_PER_GROUP} per group)`
1233 : splitVerticalShortcutLabel
1234 ? `Split Terminal Vertically (${splitVerticalShortcutLabel})`
1235 : "Split Terminal Vertically";
1236 const newTerminalActionLabel = newShortcutLabel
1237 ? `New Terminal (${newShortcutLabel})`
1238 : "New Terminal";
1239 const closeTerminalActionLabel = closeShortcutLabel
1240 ? `Close Terminal (${closeShortcutLabel})`
1241 : "Close Terminal";
1242 const onSplitTerminalAction = useCallback(() => {
1243 if (!canOperateTerminal || hasReachedSplitLimit) return;
1244 onSplitTerminal();
1245 }, [canOperateTerminal, hasReachedSplitLimit, onSplitTerminal]);
1246 const onSplitTerminalVerticalAction = useCallback(() => {
1247 if (!canOperateTerminal || hasReachedSplitLimit) return;
1248 onSplitTerminalVertical();
1249 }, [canOperateTerminal, hasReachedSplitLimit, onSplitTerminalVertical]);
1250 const onNewTerminalAction = useCallback(() => {
1251 if (!canOperateTerminal) return;
1252 onNewTerminal();
1253 }, [canOperateTerminal, onNewTerminal]);
1254 const confirmCloseTerminal = useCallback(
1255 (terminalId: string) => {
1256 if (!canOperateTerminal) return;
1257 void confirmTerminalClose(threadRef, [terminalId]).then((confirmed) => {
1258 if (confirmed && readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) {
1259 onCloseTerminal(terminalId);
1260 }
1261 });
1262 },
1263 [canOperateTerminal, onCloseTerminal, threadRef],
1264 );
1265
1266 useEffect(() => {
1267 onHeightChangeRef.current = onHeightChange;
1268 }, [onHeightChange]);
1269
1270 useLayoutEffect(() => {
1271 preferredDrawerHeightRef.current = preferredDrawerHeight;
1272 }, [preferredDrawerHeight]);
1273
1274 const syncHeight = useCallback((nextHeight: number) => {
1275 if (lastSyncedHeightRef.current === nextHeight) return;
1276 lastSyncedHeightRef.current = nextHeight;
1277 onHeightChangeRef.current(nextHeight);
1278 }, []);
1279
1280 useEffect(() => {
1281 lastSyncedHeightRef.current = controlledDrawerHeight;
1282 }, [controlledDrawerHeight, threadId]);
1283
1284 const { refresh: refreshDrawerResize, ...drawerResizeHandlers } = useResizeDrag<HTMLDivElement>(
1285 (event) => {
1286 const drawer = event.currentTarget.parentElement;
1287 return {
1288 size: drawerHeight,
1289 edge: "top",
1290 resize(value) {
1291 const height = clampDrawerHeight(value);
1292 drawer?.style.setProperty("height", `${height}px`);
1293 return height;
1294 },
1295 finish(height, moved) {
1296 if (!moved || height === drawerHeight) {
1297 drawer?.style.setProperty(
1298 "height",
1299 `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`,
1300 );
1301 return;
1302 }
1303 setDrawerHeight(height);
1304 syncHeight(height);
1305 setResizeEpoch((value) => value + 1);
1306 },
1307 cleanup(committed) {
1308 if (!committed)
1309 drawer?.style.setProperty(
1310 "height",
1311 `${clampDrawerHeight(preferredDrawerHeightRef.current)}px`,
1312 );
1313 },
1314 };
1315 },
1316 `${threadId}:${visible}:${isPanel}`,
1317 );
1318
1319 useLayoutEffect(refreshDrawerResize, [drawerHeight, refreshDrawerResize]);
1320
1321 const drawerResizeHandle = (
1322 <div
1323 role="separator"
1324 aria-label="Resize terminal"
1325 aria-orientation="horizontal"
1326 aria-valuemin={MIN_DRAWER_HEIGHT}
1327 aria-valuemax={windowMaxDrawerHeight}
1328 aria-valuenow={drawerHeight}
1329 tabIndex={0}
1330 className="group relative h-3 shrink-0 cursor-row-resize touch-none select-none outline-none hover:bg-primary/10 focus-visible:bg-primary/10 active:bg-primary/15"
1331 {...drawerResizeHandlers}
1332 onKeyDown={(event) => {
1333 const step = event.shiftKey ? 40 : 10;
1334 const next =
1335 event.key === "ArrowUp"
1336 ? drawerHeight + step
1337 : event.key === "ArrowDown"
1338 ? drawerHeight - step
1339 : event.key === "Home"
1340 ? MIN_DRAWER_HEIGHT
1341 : event.key === "End"
1342 ? windowMaxDrawerHeight
1343 : null;
1344 if (next === null) return;
1345 event.preventDefault();
1346 const height = clampDrawerHeight(next);
1347 setDrawerHeight(height);
1348 syncHeight(height);
1349 setResizeEpoch((value) => value + 1);
1350 }}
1351 >
1352 <span
1353 aria-hidden
1354 className="pointer-events-none absolute left-1/2 top-1/2 h-0.5 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border group-hover:bg-primary/60 group-focus-visible:bg-primary/60"
1355 />
1356 </div>
1357 );
1358
1359 useEffect(() => {
1360 if (!visible) {
1361 return;
1362 }
1363
1364 const onWindowResize = () => {
1365 refreshDrawerResize();
1366 setWindowMaxDrawerHeight(maxDrawerHeight());
1367 setResizeEpoch((value) => value + 1);
1368 };
1369 window.addEventListener("resize", onWindowResize);
1370 return () => {
1371 window.removeEventListener("resize", onWindowResize);
1372 };
1373 }, [visible, refreshDrawerResize]);
1374
1375 useEffect(() => {
1376 if (!visible) {
1377 return;
1378 }
1379 setWindowMaxDrawerHeight(maxDrawerHeight());
1380 setResizeEpoch((value) => value + 1);
1381 }, [visible]);
1382
1383 useEffect(() => {
1384 return () => {
1385 syncHeight(preferredDrawerHeightRef.current);
1386 };
1387 }, [syncHeight]);
1388
1389 if (normalizedTerminalIds.length === 0) {
1390 return (
1391 <aside
1392 data-thread-terminal-drawer
1393 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
1394 className={cn(
1395 "relative flex min-w-0 flex-col overflow-hidden bg-background",
1396 isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80",
1397 )}
1398 style={isPanel ? undefined : { height: `${drawerHeight}px` }}
1399 >
1400 {!isPanel ? drawerResizeHandle : null}
1401 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground">
1402 <p>No terminal sessions for this thread yet.</p>
1403 <Button
1404 size="xs"
1405 variant="outline"
1406 onClick={onNewTerminalAction}
1407 disabled={!canOperateTerminal}
1408 >
1409 {newTerminalActionLabel}
1410 </Button>
1411 </div>
1412 </aside>
1413 );
1414 }
1415
1416 const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId);
1417
1418 return (
1419 <aside
1420 data-thread-terminal-drawer
1421 data-terminal-owner={isPanel ? "right-panel" : "drawer"}
1422 className={cn(
1423 "relative flex min-w-0 flex-col overflow-hidden bg-background",
1424 isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80",
1425 )}
1426 style={isPanel ? undefined : { height: `${drawerHeight}px` }}
1427 >
1428 {!isPanel ? drawerResizeHandle : null}
1429
1430 {!hasTerminalSidebar && (
1431 <div className="pointer-events-none absolute right-2 top-2 z-20">
1432 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs">
1433 <TerminalActionButton
1434 disabled={!canOperateTerminal}
1435 className={`p-1 text-foreground/90 transition-colors ${
1436 hasReachedSplitLimit
1437 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
1438 : "hover:bg-accent"
1439 }`}
1440 onClick={onSplitTerminalAction}
1441 label={splitTerminalActionLabel}
1442 >
1443 <SquareSplitHorizontal className="size-3.25" />
1444 </TerminalActionButton>
1445 <div className="h-4 w-px bg-border/80" />
1446 <TerminalActionButton
1447 disabled={!canOperateTerminal}
1448 className={`p-1 text-foreground/90 transition-colors ${
1449 hasReachedSplitLimit
1450 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
1451 : "hover:bg-accent"
1452 }`}
1453 onClick={onSplitTerminalVerticalAction}
1454 label={splitTerminalVerticalActionLabel}
1455 >
1456 <SquareSplitVertical className="size-3.25" />
1457 </TerminalActionButton>
1458 <div className="h-4 w-px bg-border/80" />
1459 <TerminalActionButton
1460 disabled={!canOperateTerminal}
1461 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1462 onClick={onNewTerminalAction}
1463 label={newTerminalActionLabel}
1464 >
1465 <Plus className="size-3.25" />
1466 </TerminalActionButton>
1467 <div className="h-4 w-px bg-border/80" />
1468 <TerminalActionButton
1469 disabled={!canOperateTerminal}
1470 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1471 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
1472 label={closeTerminalActionLabel}
1473 >
1474 <Trash2 className="size-3.25" />
1475 </TerminalActionButton>
1476 </div>
1477 </div>
1478 )}
1479
1480 <div className="min-h-0 w-full flex-1">
1481 <div
1482 className={cn(
1483 "flex h-full min-h-0 bg-(--terminal-background)",
1484 hasTerminalSidebar && "gap-1.5",
1485 )}
1486 >
1487 <div className="min-w-0 flex-1">
1488 {isSplitView ? (
1489 <div
1490 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
1491 style={
1492 splitDirection === "vertical"
1493 ? {
1494 gridTemplateRows: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1495 }
1496 : {
1497 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
1498 }
1499 }
1500 >
1501 {visibleTerminalIds.map((terminalId) => {
1502 const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId);
1503 return (
1504 <div
1505 key={terminalId}
1506 className={`min-h-0 min-w-0 ${
1507 splitDirection === "vertical"
1508 ? "border-t first:border-t-0"
1509 : "border-l first:border-l-0"
1510 } ${
1511 terminalId === resolvedActiveTerminalId
1512 ? "border-border"
1513 : "border-border/70"
1514 }`}
1515 onPointerDown={() => {
1516 if (terminalId !== resolvedActiveTerminalId) {
1517 onActiveTerminalChange(terminalId);
1518 }
1519 }}
1520 >
1521 <div className="h-full">
1522 <TerminalViewport
1523 advancedTypography={advancedTypography}
1524 threadRef={threadRef}
1525 threadId={threadId}
1526 terminalId={terminalId}
1527 terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"}
1528 cwd={terminalLaunchLocation.cwd}
1529 {...(terminalLaunchLocation.worktreePath !== undefined
1530 ? { worktreePath: terminalLaunchLocation.worktreePath }
1531 : {})}
1532 {...(terminalLaunchLocation.runtimeEnv
1533 ? { runtimeEnv: terminalLaunchLocation.runtimeEnv }
1534 : {})}
1535 onSessionExited={() => onCloseTerminal(terminalId)}
1536 onAddTerminalContext={onAddTerminalContext}
1537 focusRequestId={focusRequestId}
1538 autoFocus={terminalId === resolvedActiveTerminalId}
1539 visible={visible}
1540 resizeEpoch={resizeEpoch}
1541 drawerHeight={drawerHeight}
1542 keybindings={keybindings}
1543 />
1544 </div>
1545 </div>
1546 );
1547 })}
1548 </div>
1549 ) : (
1550 <div className="h-full">
1551 <TerminalViewport
1552 advancedTypography={advancedTypography}
1553 key={resolvedActiveTerminalId}
1554 threadRef={threadRef}
1555 threadId={threadId}
1556 terminalId={resolvedActiveTerminalId}
1557 terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"}
1558 cwd={activeTerminalLaunchLocation.cwd}
1559 {...(activeTerminalLaunchLocation.worktreePath !== undefined
1560 ? { worktreePath: activeTerminalLaunchLocation.worktreePath }
1561 : {})}
1562 {...(activeTerminalLaunchLocation.runtimeEnv
1563 ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv }
1564 : {})}
1565 onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)}
1566 onAddTerminalContext={onAddTerminalContext}
1567 focusRequestId={focusRequestId}
1568 autoFocus
1569 visible={visible}
1570 resizeEpoch={resizeEpoch}
1571 drawerHeight={drawerHeight}
1572 keybindings={keybindings}
1573 />
1574 </div>
1575 )}
1576 </div>
1577
1578 {hasTerminalSidebar && (
1579 <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10">
1580 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
1581 <div className="inline-flex h-full items-stretch">
1582 <TerminalActionButton
1583 disabled={!canOperateTerminal}
1584 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
1585 hasReachedSplitLimit
1586 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
1587 : "hover:bg-accent/70"
1588 }`}
1589 onClick={onSplitTerminalAction}
1590 label={splitTerminalActionLabel}
1591 >
1592 <SquareSplitHorizontal className="size-3.25" />
1593 </TerminalActionButton>
1594 <TerminalActionButton
1595 disabled={!canOperateTerminal}
1596 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
1597 hasReachedSplitLimit
1598 ? "cursor-not-allowed opacity-64 hover:bg-transparent"
1599 : "hover:bg-accent/70"
1600 }`}
1601 onClick={onSplitTerminalVerticalAction}
1602 label={splitTerminalVerticalActionLabel}
1603 >
1604 <SquareSplitVertical className="size-3.25" />
1605 </TerminalActionButton>
1606 <TerminalActionButton
1607 disabled={!canOperateTerminal}
1608 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1609 onClick={onNewTerminalAction}
1610 label={newTerminalActionLabel}
1611 >
1612 <Plus className="size-3.25" />
1613 </TerminalActionButton>
1614 <TerminalActionButton
1615 disabled={!canOperateTerminal}
1616 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1617 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
1618 label={closeTerminalActionLabel}
1619 >
1620 <Trash2 className="size-3.25" />
1621 </TerminalActionButton>
1622 </div>
1623 </div>
1624
1625 <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
1626 {resolvedTerminalGroups.map((terminalGroup) => {
1627 const isGroupActive =
1628 terminalGroup.terminalIds.includes(resolvedActiveTerminalId);
1629 const groupActiveTerminalId = isGroupActive
1630 ? resolvedActiveTerminalId
1631 : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId);
1632 const terminalCount = terminalGroup.terminalIds.length;
1633 const isSplitGroup = terminalCount > 1;
1634 const groupLabel = !isSplitGroup
1635 ? "Single"
1636 : terminalGroup.splitDirection === "vertical"
1637 ? "Stacked"
1638 : "Side by side";
1639 const GroupIcon = !isSplitGroup
1640 ? Square
1641 : terminalGroup.splitDirection === "vertical"
1642 ? SquareSplitVertical
1643 : SquareSplitHorizontal;
1644
1645 return (
1646 <div key={terminalGroup.id} className="pb-0.5">
1647 {showGroupHeaders && (
1648 <button
1649 type="button"
1650 className={`flex h-[22px] w-full cursor-pointer items-center gap-1 rounded px-1.5 text-2xs ${
1651 isGroupActive
1652 ? "bg-accent/50 text-foreground"
1653 : "text-muted-foreground hover:bg-accent/40 hover:text-foreground"
1654 }`}
1655 onClick={() => onActiveTerminalChange(groupActiveTerminalId)}
1656 >
1657 <GroupIcon className="size-3 shrink-0" />
1658 <span className="min-w-0 flex-1 truncate text-left">{groupLabel}</span>
1659 <span className="text-muted-foreground/70 text-3xs tabular-nums">
1660 {terminalCount}
1661 </span>
1662 </button>
1663 )}
1664
1665 <div className="flex flex-col gap-0.5">
1666 {terminalGroup.terminalIds.map((terminalId) => {
1667 const isActive = terminalId === resolvedActiveTerminalId;
1668 const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal";
1669 const closeTerminalLabel = `Close ${terminalLabel}${
1670 isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""
1671 }`;
1672 return (
1673 <div
1674 key={terminalId}
1675 className={cn(
1676 "group/tab flex h-6 w-full items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
1677 isActive
1678 ? "bg-accent text-foreground"
1679 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
1680 )}
1681 >
1682 {canOperateTerminal ? (
1683 <PanelTabCloseButton
1684 label={closeTerminalLabel}
1685 onClick={() => confirmCloseTerminal(terminalId)}
1686 tooltip={closeTerminalLabel}
1687 >
1688 <TerminalSquare className="size-3 shrink-0" />
1689 </PanelTabCloseButton>
1690 ) : (
1691 <TerminalSquare className="size-3 shrink-0" />
1692 )}
1693 <button
1694 type="button"
1695 className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 text-left"
1696 onClick={() => onActiveTerminalChange(terminalId)}
1697 >
1698 <span className="truncate">{terminalLabel}</span>
1699 </button>
1700 </div>
1701 );
1702 })}
1703 </div>
1704 </div>
1705 );
1706 })}
1707 </div>
1708 </aside>
1709 )}
1710 </div>
1711 </div>
1712 </aside>
1713 );
1714}