// ======================================== // IssueTerminalTab // ======================================== // Embedded xterm.js terminal for PTY-backed CLI sessions. import { useEffect, useMemo, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { Copy, Plus, RefreshCw, Share2, XCircle } from 'lucide-react'; import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { Button } from '@/components/ui/Button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'; import { Input } from '@/components/ui/Input'; import { cn } from '@/lib/utils'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { closeCliSession, createCliSession, createCliSessionShareToken, fetchCliSessionShares, revokeCliSessionShareToken, executeInCliSession, fetchCliSessionBuffer, fetchCliSessions, resizeCliSession, sendCliSessionText, type CliSession, } from '@/lib/api'; import { useCliSessionStore } from '@/stores/cliSessionStore'; type ToolName = 'claude' | 'codex' | 'gemini'; type ResumeStrategy = 'nativeResume' | 'promptConcat'; export function IssueTerminalTab({ issueId }: { issueId: string }) { const { formatMessage } = useIntl(); const projectPath = useWorkflowStore(selectProjectPath); const sessionsByKey = useCliSessionStore((s) => s.sessions); const outputChunks = useCliSessionStore((s) => s.outputChunks); const setSessions = useCliSessionStore((s) => s.setSessions); const upsertSession = useCliSessionStore((s) => s.upsertSession); const setBuffer = useCliSessionStore((s) => s.setBuffer); const clearOutput = useCliSessionStore((s) => s.clearOutput); const sessions = useMemo(() => Object.values(sessionsByKey).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), [sessionsByKey]); const [selectedSessionKey, setSelectedSessionKey] = useState(''); const [isLoadingSessions, setIsLoadingSessions] = useState(false); const [isCreating, setIsCreating] = useState(false); const [isClosing, setIsClosing] = useState(false); const [error, setError] = useState(null); const [tool, setTool] = useState('claude'); const [mode, setMode] = useState<'analysis' | 'write'>('analysis'); const [resumeKey, setResumeKey] = useState(issueId); const [resumeStrategy, setResumeStrategy] = useState('nativeResume'); const [prompt, setPrompt] = useState(''); const [isExecuting, setIsExecuting] = useState(false); const [shareUrl, setShareUrl] = useState(''); const [shareToken, setShareToken] = useState(''); const [shareExpiresAt, setShareExpiresAt] = useState(''); const [shareRecords, setShareRecords] = useState>([]); const [isLoadingShares, setIsLoadingShares] = useState(false); const [isRevokingShare, setIsRevokingShare] = useState(false); const terminalHostRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const lastChunkIndexRef = useRef(0); const pendingInputRef = useRef(''); const flushTimerRef = useRef(null); const flushInput = async () => { const sessionKey = selectedSessionKey; if (!sessionKey) return; const pending = pendingInputRef.current; pendingInputRef.current = ''; if (!pending) return; try { await sendCliSessionText(sessionKey, { text: pending, appendNewline: false }, projectPath || undefined); } catch (e) { // Ignore transient failures (WS output still shows process state) } }; const scheduleFlush = () => { if (flushTimerRef.current !== null) return; flushTimerRef.current = window.setTimeout(async () => { flushTimerRef.current = null; await flushInput(); }, 30); }; useEffect(() => { setIsLoadingSessions(true); setError(null); fetchCliSessions(projectPath || undefined) .then((r) => { setSessions(r.sessions as unknown as CliSession[]); }) .catch((e) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setIsLoadingSessions(false)); }, [projectPath, setSessions]); // Auto-select a session if none selected yet useEffect(() => { if (selectedSessionKey) return; if (sessions.length === 0) return; setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? ''); }, [sessions, selectedSessionKey]); const buildShareLink = (sessionKey: string, token: string): string => { const url = new URL(window.location.href); const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); url.pathname = `${base}/cli-sessions/share`; url.search = `sessionKey=${encodeURIComponent(sessionKey)}&shareToken=${encodeURIComponent(token)}`; return url.toString(); }; const refreshShares = async (sessionKey: string) => { if (!sessionKey) { setShareRecords([]); return; } setIsLoadingShares(true); try { const r = await fetchCliSessionShares(sessionKey, projectPath || undefined); setShareRecords(r.shares || []); } catch { setShareRecords([]); } finally { setIsLoadingShares(false); } }; // Refresh share tokens when session changes useEffect(() => { setShareUrl(''); setShareToken(''); setShareExpiresAt(''); void refreshShares(selectedSessionKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSessionKey, projectPath]); // Init xterm useEffect(() => { if (!terminalHostRef.current) return; if (xtermRef.current) return; const term = new XTerm({ convertEol: true, cursorBlink: true, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontSize: 12, scrollback: 5000, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(terminalHostRef.current); fitAddon.fit(); // Forward keystrokes to backend (batched) term.onData((data) => { if (!selectedSessionKey) return; pendingInputRef.current += data; scheduleFlush(); }); xtermRef.current = term; fitAddonRef.current = fitAddon; return () => { try { term.dispose(); } finally { xtermRef.current = null; fitAddonRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Attach to selected session: clear terminal and load buffer useEffect(() => { const term = xtermRef.current; const fitAddon = fitAddonRef.current; if (!term || !fitAddon) return; lastChunkIndexRef.current = 0; term.reset(); term.clear(); if (!selectedSessionKey) return; clearOutput(selectedSessionKey); fetchCliSessionBuffer(selectedSessionKey, projectPath || undefined) .then(({ buffer }) => { setBuffer(selectedSessionKey, buffer || ''); }) .catch(() => { // ignore }) .finally(() => { fitAddon.fit(); }); }, [selectedSessionKey, projectPath, setBuffer, clearOutput]); // Stream new output chunks into xterm useEffect(() => { const term = xtermRef.current; if (!term) return; if (!selectedSessionKey) return; const chunks = outputChunks[selectedSessionKey] ?? []; const start = lastChunkIndexRef.current; if (start >= chunks.length) return; for (let i = start; i < chunks.length; i++) { term.write(chunks[i].data); } lastChunkIndexRef.current = chunks.length; }, [outputChunks, selectedSessionKey]); // Resize observer -> fit + resize backend useEffect(() => { const host = terminalHostRef.current; const term = xtermRef.current; const fitAddon = fitAddonRef.current; if (!host || !term || !fitAddon) return; const resize = () => { fitAddon.fit(); if (selectedSessionKey) { void (async () => { try { await resizeCliSession(selectedSessionKey, { cols: term.cols, rows: term.rows }, projectPath || undefined); } catch { // ignore } })(); } }; const ro = new ResizeObserver(resize); ro.observe(host); return () => ro.disconnect(); }, [selectedSessionKey, projectPath]); const handleCreateSession = async () => { setIsCreating(true); setError(null); try { const created = await createCliSession({ workingDir: projectPath || undefined, preferredShell: 'bash', cols: xtermRef.current?.cols, rows: xtermRef.current?.rows, tool, model: undefined, resumeKey, }, projectPath || undefined); upsertSession(created.session as unknown as CliSession); setSelectedSessionKey(created.session.sessionKey); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsCreating(false); } }; const handleCloseSession = async () => { if (!selectedSessionKey) return; setIsClosing(true); setError(null); try { await closeCliSession(selectedSessionKey, projectPath || undefined); setSelectedSessionKey(''); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsClosing(false); } }; const handleExecute = async () => { if (!selectedSessionKey) return; if (!prompt.trim()) return; setIsExecuting(true); setError(null); try { await executeInCliSession(selectedSessionKey, { tool, prompt: prompt.trim(), mode, resumeKey: resumeKey.trim() || undefined, resumeStrategy, category: 'user', }, projectPath || undefined); setPrompt(''); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsExecuting(false); } }; const handleRefreshSessions = async () => { setIsLoadingSessions(true); setError(null); try { const r = await fetchCliSessions(projectPath || undefined); setSessions(r.sessions as unknown as CliSession[]); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoadingSessions(false); } }; const handleCreateShareLink = async () => { if (!selectedSessionKey) return; setError(null); setShareUrl(''); setShareToken(''); setShareExpiresAt(''); try { const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined); setShareUrl(buildShareLink(selectedSessionKey, r.shareToken)); setShareToken(r.shareToken); setShareExpiresAt(r.expiresAt); void refreshShares(selectedSessionKey); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }; const handleRevokeShareLink = async (token: string) => { if (!selectedSessionKey || !token) return; setIsRevokingShare(true); setError(null); try { await revokeCliSessionShareToken(selectedSessionKey, { shareToken: token }, projectPath || undefined); if (token === shareToken) { setShareUrl(''); setShareToken(''); setShareExpiresAt(''); } void refreshShares(selectedSessionKey); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsRevokingShare(false); } }; const handleCopyShareLink = async () => { if (!shareUrl) return; try { await navigator.clipboard.writeText(shareUrl); } catch { // ignore } }; return (
{shareUrl && (
{shareExpiresAt && (
{formatMessage({ id: 'issues.terminal.session.expiresAt' })}: {shareExpiresAt}
)}
)} {selectedSessionKey && shareRecords.length > 0 && (
{formatMessage({ id: 'issues.terminal.session.activeShares' })} {isLoadingShares ? '…' : ''}
{shareRecords.map((s) => (
{s.shareToken.slice(0, 6)}…{s.shareToken.slice(-6)}
{s.mode}
{s.expiresAt}
))}
)}
{formatMessage({ id: 'issues.terminal.exec.tool' })}
{formatMessage({ id: 'issues.terminal.exec.mode' })}
{formatMessage({ id: 'issues.terminal.exec.resumeKey' })}
setResumeKey(e.target.value)} placeholder={issueId} />
{formatMessage({ id: 'issues.terminal.exec.resumeStrategy' })}
{formatMessage({ id: 'issues.terminal.exec.prompt.label' })}