import { FitAddon } from "@xterm/addon-fit";import { type NativeApi } from "@t3tools/contracts";import { Terminal, type ITheme } from "@xterm/xterm";import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState,} from "react";import { isTerminalClearShortcut } from "../terminal-shortcuts";import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types"; const MIN_DRAWER_HEIGHT = 180;const MAX_DRAWER_HEIGHT_RATIO = 0.75; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.max( MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO), );} function clampDrawerHeight(height: number): number { const safeHeight = Number.isFinite(height) ? height : DEFAULT_THREAD_TERMINAL_HEIGHT; const maxHeight = maxDrawerHeight(); return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);} function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`);} function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); const background = bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); if (isDark) { return { background, foreground, cursor: "rgb(180, 203, 255)", selectionBackground: "rgba(180, 203, 255, 0.25)", scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", black: "rgb(24, 30, 38)", red: "rgb(255, 122, 142)", green: "rgb(134, 231, 149)", yellow: "rgb(244, 205, 114)", blue: "rgb(137, 190, 255)", magenta: "rgb(208, 176, 255)", cyan: "rgb(124, 232, 237)", white: "rgb(210, 218, 230)", brightBlack: "rgb(110, 120, 136)", brightRed: "rgb(255, 168, 180)", brightGreen: "rgb(176, 245, 186)", brightYellow: "rgb(255, 224, 149)", brightBlue: "rgb(174, 210, 255)", brightMagenta: "rgb(229, 203, 255)", brightCyan: "rgb(167, 244, 247)", brightWhite: "rgb(244, 247, 252)", }; } return { background, foreground, cursor: "rgb(38, 56, 78)", selectionBackground: "rgba(37, 63, 99, 0.2)", scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", black: "rgb(44, 53, 66)", red: "rgb(191, 70, 87)", green: "rgb(60, 126, 86)", yellow: "rgb(146, 112, 35)", blue: "rgb(72, 102, 163)", magenta: "rgb(132, 86, 149)", cyan: "rgb(53, 127, 141)", white: "rgb(210, 215, 223)", brightBlack: "rgb(112, 123, 140)", brightRed: "rgb(212, 95, 112)", brightGreen: "rgb(85, 148, 111)", brightYellow: "rgb(173, 133, 45)", brightBlue: "rgb(91, 124, 194)", brightMagenta: "rgb(153, 107, 172)", brightCyan: "rgb(70, 149, 164)", brightWhite: "rgb(236, 240, 246)", };} interface ThreadTerminalDrawerProps { api: NativeApi; threadId: string; cwd: string; height: number; focusRequestId: number; onHeightChange: (height: number) => void; onThreadExited: () => void;} export default function ThreadTerminalDrawer({ api, threadId, cwd, height, focusRequestId, onHeightChange, onThreadExited,}: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const containerRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<Terminal | null>(null); const fitAddonRef = useRef<FitAddon | null>(null); const drawerHeightRef = useRef(drawerHeight); const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); const onHeightChangeRef = useRef(onHeightChange); const onThreadExitedRef = useRef(onThreadExited); const resizeStateRef = useRef<{ pointerId: number; startY: number; startHeight: number; } | null>(null); const didResizeDuringDragRef = useRef(false); useEffect(() => { onHeightChangeRef.current = onHeightChange; }, [onHeightChange]); useEffect(() => { onThreadExitedRef.current = onThreadExited; }, [onThreadExited]); useEffect(() => { drawerHeightRef.current = drawerHeight; }, [drawerHeight]); const syncHeight = useCallback( (nextHeight: number) => { const clampedHeight = clampDrawerHeight(nextHeight); if (lastSyncedHeightRef.current === clampedHeight) return; lastSyncedHeightRef.current = clampedHeight; onHeightChangeRef.current(clampedHeight); }, [], ); const fitAndResizeTerminal = useCallback((preserveBottom = false) => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; const wasAtBottom = preserveBottom && activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; activeFitAddon.fit(); if (wasAtBottom) { activeTerminal.scrollToBottom(); } void api.terminal .resize({ threadId, cols: activeTerminal.cols, rows: activeTerminal.rows, }) .catch(() => undefined); }, [api, threadId]); useEffect(() => { const clampedHeight = clampDrawerHeight(height); setDrawerHeight(clampedHeight); lastSyncedHeightRef.current = clampedHeight; }, [height, threadId]); const handleResizePointerDown = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { if (event.button !== 0) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); didResizeDuringDragRef.current = false; resizeStateRef.current = { pointerId: event.pointerId, startY: event.clientY, startHeight: drawerHeightRef.current, }; }, [], ); const handleResizePointerMove = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; event.preventDefault(); const clampedHeight = clampDrawerHeight( resizeState.startHeight + (resizeState.startY - event.clientY), ); if (clampedHeight === drawerHeightRef.current) { return; } didResizeDuringDragRef.current = true; drawerHeightRef.current = clampedHeight; setDrawerHeight(clampedHeight); }, [], ); const handleResizePointerEnd = useCallback( (event: ReactPointerEvent<HTMLDivElement>) => { const resizeState = resizeStateRef.current; if (!resizeState || resizeState.pointerId !== event.pointerId) return; resizeStateRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } if (!didResizeDuringDragRef.current) { return; } syncHeight(drawerHeightRef.current); fitAndResizeTerminal(true); }, [fitAndResizeTerminal, syncHeight], ); useEffect(() => { const onWindowResize = () => { const clampedHeight = clampDrawerHeight(drawerHeightRef.current); const changed = clampedHeight !== drawerHeightRef.current; if (changed) { setDrawerHeight(clampedHeight); } else { fitAndResizeTerminal(true); } if (!resizeStateRef.current) { syncHeight(clampedHeight); } }; window.addEventListener("resize", onWindowResize); return () => { window.removeEventListener("resize", onWindowResize); }; }, [fitAndResizeTerminal, syncHeight]); useEffect(() => { return () => { syncHeight(drawerHeightRef.current); }; }, [syncHeight]); useEffect(() => { const mount = containerRef.current; if (!mount) return; let disposed = false; const fitAddon = new FitAddon(); const terminal = new Terminal({ cursorBlink: true, lineHeight: 1.2, fontSize: 12, scrollback: 5_000, fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); terminal.open(mount); fitAddon.fit(); terminalRef.current = terminal; fitAddonRef.current = fitAddon; const sendClearShortcut = async () => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { await api.terminal.write({ threadId, data: "\u000c" }); } catch (error) { writeSystemMessage( activeTerminal, error instanceof Error ? error.message : "Failed to clear terminal", ); } }; terminal.attachCustomKeyEventHandler((event) => { if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); void sendClearShortcut(); return false; }); const inputDisposable = terminal.onData((data) => { void api.terminal .write({ threadId, data }) .catch((err) => writeSystemMessage( terminal, err instanceof Error ? err.message : "Terminal write failed", ), ); }); const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; activeTerminal.options.theme = terminalThemeFromApp(); activeTerminal.refresh(0, activeTerminal.rows - 1); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"], }); const openTerminal = async () => { try { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; activeFitAddon.fit(); const snapshot = await api.terminal.open({ threadId, cwd, cols: activeTerminal.cols, rows: activeTerminal.rows, }); if (disposed) return; activeTerminal.write("\u001bc"); if (snapshot.history.length > 0) { activeTerminal.write(snapshot.history); } window.requestAnimationFrame(() => { activeTerminal.focus(); }); } catch (err) { if (disposed) return; writeSystemMessage( terminal, err instanceof Error ? err.message : "Failed to open terminal", ); } }; const unsubscribe = api.terminal.onEvent((event) => { if (event.threadId !== threadId) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; if (event.type === "output") { activeTerminal.write(event.data); return; } if (event.type === "started" || event.type === "restarted") { activeTerminal.write("\u001bc"); if (event.snapshot.history.length > 0) { activeTerminal.write(event.snapshot.history); } return; } if (event.type === "cleared") { activeTerminal.clear(); activeTerminal.write("\u001bc"); return; } if (event.type === "error") { writeSystemMessage(activeTerminal, event.message); return; } if (event.type === "exited") { onThreadExitedRef.current(); } }); const fitTimer = window.setTimeout(() => { fitAndResizeTerminal(true); }, 30); void openTerminal(); return () => { disposed = true; window.clearTimeout(fitTimer); unsubscribe(); inputDisposable.dispose(); themeObserver.disconnect(); terminalRef.current = null; fitAddonRef.current = null; terminal.dispose(); }; }, [api, cwd, fitAndResizeTerminal, threadId]); useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; const frame = window.requestAnimationFrame(() => { terminal.focus(); }); return () => { window.cancelAnimationFrame(frame); }; }, [focusRequestId]); useEffect(() => { const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!terminal || !fitAddon) return; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { fitAddon.fit(); if (wasAtBottom) { terminal.scrollToBottom(); } void api.terminal .resize({ threadId, cols: terminal.cols, rows: terminal.rows, }) .catch(() => undefined); }); return () => { window.cancelAnimationFrame(frame); }; }, [api, cwd, drawerHeight, threadId]); return ( <aside className="thread-terminal-drawer flex shrink-0 flex-col border-t border-border/80 bg-background" style={{ height: `${drawerHeight}px` }} > <div className="flex h-2 cursor-row-resize items-center justify-center" onPointerDown={handleResizePointerDown} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerEnd} onPointerCancel={handleResizePointerEnd} > <div className="h-px w-10 rounded-full bg-border/80" /> </div> <div className="min-h-0 w-full flex-1 px-1.5 pb-1"> <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" /> </div> </aside> );}
import { FitAddon } from "@xterm/addon-fit";
import { type NativeApi } from "@t3tools/contracts";
import { Terminal, type ITheme } from "@xterm/xterm";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { isTerminalClearShortcut } from "../terminal-shortcuts";
import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types";
const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
function maxDrawerHeight(): number {
if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
return Math.max(
MIN_DRAWER_HEIGHT,
Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO),
);
}
function clampDrawerHeight(height: number): number {
const safeHeight = Number.isFinite(height)
? height
: DEFAULT_THREAD_TERMINAL_HEIGHT;
const maxHeight = maxDrawerHeight();
return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);
}
function writeSystemMessage(terminal: Terminal, message: string): void {
terminal.write(`\r\n[terminal] ${message}\r\n`);
}
function terminalThemeFromApp(): ITheme {
const isDark = document.documentElement.classList.contains("dark");
const bodyStyles = getComputedStyle(document.body);
const background =
bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)");
const foreground =
bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)");
if (isDark) {
return {
background,
foreground,
cursor: "rgb(180, 203, 255)",
selectionBackground: "rgba(180, 203, 255, 0.25)",
scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)",
scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)",
scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)",
black: "rgb(24, 30, 38)",
red: "rgb(255, 122, 142)",
green: "rgb(134, 231, 149)",
yellow: "rgb(244, 205, 114)",
blue: "rgb(137, 190, 255)",
magenta: "rgb(208, 176, 255)",
cyan: "rgb(124, 232, 237)",
white: "rgb(210, 218, 230)",
brightBlack: "rgb(110, 120, 136)",
brightRed: "rgb(255, 168, 180)",
brightGreen: "rgb(176, 245, 186)",
brightYellow: "rgb(255, 224, 149)",
brightBlue: "rgb(174, 210, 255)",
brightMagenta: "rgb(229, 203, 255)",
brightCyan: "rgb(167, 244, 247)",
brightWhite: "rgb(244, 247, 252)",
};
}
return {
background,
foreground,
cursor: "rgb(38, 56, 78)",
selectionBackground: "rgba(37, 63, 99, 0.2)",
scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)",
scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)",
scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)",
black: "rgb(44, 53, 66)",
red: "rgb(191, 70, 87)",
green: "rgb(60, 126, 86)",
yellow: "rgb(146, 112, 35)",
blue: "rgb(72, 102, 163)",
magenta: "rgb(132, 86, 149)",
cyan: "rgb(53, 127, 141)",
white: "rgb(210, 215, 223)",
brightBlack: "rgb(112, 123, 140)",
brightRed: "rgb(212, 95, 112)",
brightGreen: "rgb(85, 148, 111)",
brightYellow: "rgb(173, 133, 45)",
brightBlue: "rgb(91, 124, 194)",
brightMagenta: "rgb(153, 107, 172)",
brightCyan: "rgb(70, 149, 164)",
brightWhite: "rgb(236, 240, 246)",
};
}
interface ThreadTerminalDrawerProps {
api: NativeApi;
threadId: string;
cwd: string;
height: number;
focusRequestId: number;
onHeightChange: (height: number) => void;
onThreadExited: () => void;
}
export default function ThreadTerminalDrawer({
api,
threadId,
cwd,
height,
focusRequestId,
onHeightChange,
onThreadExited,
}: ThreadTerminalDrawerProps) {
const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const drawerHeightRef = useRef(drawerHeight);
const lastSyncedHeightRef = useRef(clampDrawerHeight(height));
const onHeightChangeRef = useRef(onHeightChange);
const onThreadExitedRef = useRef(onThreadExited);
const resizeStateRef = useRef<{
pointerId: number;
startY: number;
startHeight: number;
} | null>(null);
const didResizeDuringDragRef = useRef(false);
useEffect(() => {
onHeightChangeRef.current = onHeightChange;
}, [onHeightChange]);
useEffect(() => {
onThreadExitedRef.current = onThreadExited;
}, [onThreadExited]);
useEffect(() => {
drawerHeightRef.current = drawerHeight;
}, [drawerHeight]);
const syncHeight = useCallback(
(nextHeight: number) => {
const clampedHeight = clampDrawerHeight(nextHeight);
if (lastSyncedHeightRef.current === clampedHeight) return;
lastSyncedHeightRef.current = clampedHeight;
onHeightChangeRef.current(clampedHeight);
},
[],
);
const fitAndResizeTerminal = useCallback((preserveBottom = false) => {
const activeTerminal = terminalRef.current;
const activeFitAddon = fitAddonRef.current;
if (!activeTerminal || !activeFitAddon) return;
const wasAtBottom =
preserveBottom &&
activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
activeFitAddon.fit();
if (wasAtBottom) {
activeTerminal.scrollToBottom();
}
void api.terminal
.resize({
threadId,
cols: activeTerminal.cols,
rows: activeTerminal.rows,
})
.catch(() => undefined);
}, [api, threadId]);
useEffect(() => {
const clampedHeight = clampDrawerHeight(height);
setDrawerHeight(clampedHeight);
lastSyncedHeightRef.current = clampedHeight;
}, [height, threadId]);
const handleResizePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
didResizeDuringDragRef.current = false;
resizeStateRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
startHeight: drawerHeightRef.current,
};
},
[],
);
const handleResizePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) return;
event.preventDefault();
const clampedHeight = clampDrawerHeight(
resizeState.startHeight + (resizeState.startY - event.clientY),
);
if (clampedHeight === drawerHeightRef.current) {
return;
}
didResizeDuringDragRef.current = true;
drawerHeightRef.current = clampedHeight;
setDrawerHeight(clampedHeight);
},
[],
);
const handleResizePointerEnd = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) return;
resizeStateRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!didResizeDuringDragRef.current) {
return;
}
syncHeight(drawerHeightRef.current);
fitAndResizeTerminal(true);
},
[fitAndResizeTerminal, syncHeight],
);
useEffect(() => {
const onWindowResize = () => {
const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
const changed = clampedHeight !== drawerHeightRef.current;
if (changed) {
setDrawerHeight(clampedHeight);
} else {
fitAndResizeTerminal(true);
}
if (!resizeStateRef.current) {
syncHeight(clampedHeight);
}
};
window.addEventListener("resize", onWindowResize);
return () => {
window.removeEventListener("resize", onWindowResize);
};
}, [fitAndResizeTerminal, syncHeight]);
useEffect(() => {
return () => {
syncHeight(drawerHeightRef.current);
};
}, [syncHeight]);
useEffect(() => {
const mount = containerRef.current;
if (!mount) return;
let disposed = false;
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: true,
lineHeight: 1.2,
fontSize: 12,
scrollback: 5_000,
fontFamily:
'"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
theme: terminalThemeFromApp(),
});
terminal.loadAddon(fitAddon);
terminal.open(mount);
fitAddon.fit();
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
const sendClearShortcut = async () => {
const activeTerminal = terminalRef.current;
if (!activeTerminal) return;
try {
await api.terminal.write({ threadId, data: "\u000c" });
} catch (error) {
writeSystemMessage(
activeTerminal,
error instanceof Error ? error.message : "Failed to clear terminal",
);
}
};
terminal.attachCustomKeyEventHandler((event) => {
if (!isTerminalClearShortcut(event)) return true;
event.preventDefault();
event.stopPropagation();
void sendClearShortcut();
return false;
});
const inputDisposable = terminal.onData((data) => {
void api.terminal
.write({ threadId, data })
.catch((err) =>
writeSystemMessage(
terminal,
err instanceof Error ? err.message : "Terminal write failed",
),
);
});
const themeObserver = new MutationObserver(() => {
const activeTerminal = terminalRef.current;
if (!activeTerminal) return;
activeTerminal.options.theme = terminalThemeFromApp();
activeTerminal.refresh(0, activeTerminal.rows - 1);
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
});
const openTerminal = async () => {
try {
const activeTerminal = terminalRef.current;
const activeFitAddon = fitAddonRef.current;
if (!activeTerminal || !activeFitAddon) return;
activeFitAddon.fit();
const snapshot = await api.terminal.open({
threadId,
cwd,
cols: activeTerminal.cols,
rows: activeTerminal.rows,
});
if (disposed) return;
activeTerminal.write("\u001bc");
if (snapshot.history.length > 0) {
activeTerminal.write(snapshot.history);
}
window.requestAnimationFrame(() => {
activeTerminal.focus();
});
} catch (err) {
if (disposed) return;
writeSystemMessage(
terminal,
err instanceof Error ? err.message : "Failed to open terminal",
);
}
};
const unsubscribe = api.terminal.onEvent((event) => {
if (event.threadId !== threadId) return;
const activeTerminal = terminalRef.current;
if (!activeTerminal) return;
if (event.type === "output") {
activeTerminal.write(event.data);
return;
}
if (event.type === "started" || event.type === "restarted") {
activeTerminal.write("\u001bc");
if (event.snapshot.history.length > 0) {
activeTerminal.write(event.snapshot.history);
}
return;
}
if (event.type === "cleared") {
activeTerminal.clear();
activeTerminal.write("\u001bc");
return;
}
if (event.type === "error") {
writeSystemMessage(activeTerminal, event.message);
return;
}
if (event.type === "exited") {
onThreadExitedRef.current();
}
});
const fitTimer = window.setTimeout(() => {
fitAndResizeTerminal(true);
}, 30);
void openTerminal();
return () => {
disposed = true;
window.clearTimeout(fitTimer);
unsubscribe();
inputDisposable.dispose();
themeObserver.disconnect();
terminalRef.current = null;
fitAddonRef.current = null;
terminal.dispose();
};
}, [api, cwd, fitAndResizeTerminal, threadId]);
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
const frame = window.requestAnimationFrame(() => {
terminal.focus();
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [focusRequestId]);
useEffect(() => {
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
if (!terminal || !fitAddon) return;
const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
const frame = window.requestAnimationFrame(() => {
fitAddon.fit();
if (wasAtBottom) {
terminal.scrollToBottom();
}
void api.terminal
.resize({
threadId,
cols: terminal.cols,
rows: terminal.rows,
})
.catch(() => undefined);
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [api, cwd, drawerHeight, threadId]);
return (
<aside
className="thread-terminal-drawer flex shrink-0 flex-col border-t border-border/80 bg-background"
style={{ height: `${drawerHeight}px` }}
>
<div
className="flex h-2 cursor-row-resize items-center justify-center"
onPointerDown={handleResizePointerDown}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerEnd}
onPointerCancel={handleResizePointerEnd}
>
<div className="h-px w-10 rounded-full bg-border/80" />
</div>
<div className="min-h-0 w-full flex-1 px-1.5 pb-1">
<div
ref={containerRef}
className="h-full w-full overflow-hidden rounded-[4px]"
/>
</div>
</aside>
);
}