'use client'; import { useEffect, useRef, useCallback } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useSession } from '@/hooks/domains/session/use-session'; import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl'; import { getTerminalTheme } from '@/lib/terminal-theme'; type ShellTerminalProps = { // Interactive shell mode - requires sessionId sessionId?: string; // Read-only process output mode - requires processOutput processOutput?: string; processId?: string | null; isStopping?: boolean; }; export function ShellTerminal({ sessionId: propSessionId, processOutput, processId, isStopping = false, }: ShellTerminalProps) { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const lastOutputLengthRef = useRef(0); const subscriptionIdRef = useRef(0); const onDataDisposableRef = useRef<{ dispose: () => void } | null>(null); const retryTimeoutRef = useRef | null>(null); const processIdRef = useRef(null); const outputRef = useRef(processOutput ?? ''); const storeApi = useAppStoreApi(); // Determine mode: if processOutput is provided, use read-only mode const isReadOnlyMode = processOutput !== undefined; // For interactive mode, get sessionId from store if not provided as prop const storeSessionId = useAppStore((state) => state.tasks.activeSessionId); const sessionId = propSessionId ?? storeSessionId; const { session, isActive, isFailed, errorMessage } = useSession(isReadOnlyMode ? null : sessionId); // Hook also subscribes to session channel for real-time updates useSessionAgentctl(isReadOnlyMode ? null : sessionId); const taskId = session?.task_id ?? null; const isSessionFailed = !isReadOnlyMode && isFailed; const shellOutput = useAppStore((state) => sessionId && !isReadOnlyMode ? state.shell.outputs[sessionId] || '' : '' ); // Don't gate on isAgentctlReady - shell works as long as session is active const canSubscribe = Boolean(sessionId && isActive && !isReadOnlyMode); // Update output ref when processOutput changes useEffect(() => { if (isReadOnlyMode) { outputRef.current = processOutput ?? ''; } }, [processOutput, isReadOnlyMode]); const send = useCallback( (action: string, payload: Record) => { const client = getWebSocketClient(); if (client) { client.send({ type: 'request', action, payload }); } }, [] ); // Initialize terminal useEffect(() => { if (!terminalRef.current || xtermRef.current) return; const terminal = new Terminal({ cursorBlink: !isReadOnlyMode, disableStdin: isReadOnlyMode, convertEol: isReadOnlyMode, fontSize: isReadOnlyMode ? 12 : 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', theme: getTerminalTheme(terminalRef.current), }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(terminalRef.current); fitAddon.fit(); xtermRef.current = terminal; fitAddonRef.current = fitAddon; // For read-only mode, write initial output if (isReadOnlyMode && outputRef.current) { terminal.write(outputRef.current); lastOutputLengthRef.current = outputRef.current.length; } // Ensure terminal is properly sized after initial render const initialFitTimeout = setTimeout(() => { fitAddon.fit(); }, 100); // Handle resize const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); }); resizeObserver.observe(terminalRef.current); // Handle visibility changes (e.g., when tab becomes visible) const intersectionObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting && fitAddonRef.current) { requestAnimationFrame(() => { fitAddonRef.current?.fit(); }); } }); }, { threshold: 0.1 } ); intersectionObserver.observe(terminalRef.current); // Reset output tracking when session changes (interactive mode only) // For read-only mode, we preserve the length set by the initial write above if (!isReadOnlyMode) { lastOutputLengthRef.current = 0; } return () => { clearTimeout(initialFitTimeout); resizeObserver.disconnect(); intersectionObserver.disconnect(); terminal.dispose(); xtermRef.current = null; fitAddonRef.current = null; }; }, [taskId, sessionId, send, isReadOnlyMode]); // Handle user input (interactive mode only) useEffect(() => { if (!xtermRef.current || isReadOnlyMode) return; onDataDisposableRef.current?.dispose(); onDataDisposableRef.current = null; if (!taskId || !sessionId) return; onDataDisposableRef.current = xtermRef.current.onData((data) => { // Filter out terminal query responses (e.g., cursor position reports) // These are responses to DSR queries like \x1b[6n and look like \x1b[row;colR // They should NOT be sent to the shell as input if (/^\x1b\[\d+;\d+R$/.test(data) || /^\x1b\[\d+R$/.test(data)) { return; } send('shell.input', { session_id: sessionId, data }); }); return () => { onDataDisposableRef.current?.dispose(); onDataDisposableRef.current = null; }; }, [taskId, sessionId, send, isReadOnlyMode]); // Handle processId changes in read-only mode (clear terminal and reset output) // Skip on initial mount (processIdRef.current === null) since initialization effect handles it useEffect(() => { if (!xtermRef.current || !isReadOnlyMode) return; if (processIdRef.current === null) { // Initial mount - just track the processId, don't write (initialization effect handles it) processIdRef.current = processId ?? null; return; } if (processIdRef.current !== processId) { processIdRef.current = processId ?? null; lastOutputLengthRef.current = 0; xtermRef.current.clear(); if (outputRef.current) { xtermRef.current.write(outputRef.current); lastOutputLengthRef.current = outputRef.current.length; } } }, [processId, isReadOnlyMode]); // Write new output to terminal useEffect(() => { if (!xtermRef.current) return; const output = isReadOnlyMode ? (processOutput ?? '') : shellOutput; // Only write new data since last update const newData = output.slice(lastOutputLengthRef.current); if (newData) { xtermRef.current.write(newData); lastOutputLengthRef.current = output.length; } }, [shellOutput, processOutput, isReadOnlyMode]); // Subscribe to shell once agentctl is ready. useEffect(() => { if (!taskId || !sessionId || !canSubscribe) return; const currentSubscriptionId = ++subscriptionIdRef.current; // Clear any stale output before subscribing to get fresh buffer storeApi.getState().clearShellOutput(sessionId); lastOutputLengthRef.current = 0; // Also clear the terminal display if (xtermRef.current) { xtermRef.current.clear(); } const client = getWebSocketClient(); if (!client) return; if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } let cancelled = false; const attemptSubscribe = () => { client .request<{ success: boolean; buffer?: string }>('shell.subscribe', { session_id: sessionId }) .then((response) => { if (cancelled || subscriptionIdRef.current !== currentSubscriptionId) return; if (response.buffer) { storeApi.getState().appendShellOutput(sessionId, response.buffer); } // Send Ctrl+L to clear screen and redraw prompt // This ensures a clean state after loading the buffer which may contain garbage escape sequences setTimeout(() => { if (!cancelled && subscriptionIdRef.current === currentSubscriptionId) { send('shell.input', { session_id: sessionId, data: '\x0c' }); } }, 100); }) .catch((err) => { if (cancelled || subscriptionIdRef.current !== currentSubscriptionId) return; const message = err instanceof Error ? err.message : String(err); if (message.includes('no agent running')) { retryTimeoutRef.current = setTimeout(() => { if (!cancelled && subscriptionIdRef.current === currentSubscriptionId) { attemptSubscribe(); } }, 1000); return; } console.error('Failed to subscribe to shell:', err); }); }; attemptSubscribe(); return () => { subscriptionIdRef.current += 1; cancelled = true; if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } }; }, [taskId, sessionId, storeApi, canSubscribe, send]); if (isReadOnlyMode) { return (
{isStopping ? (
Stopping…
) : null}
); } if (isSessionFailed) { return (
Session failed
{errorMessage && (
{errorMessage}
)}
); } return (
); }