Files
Claude-Code-Workflow/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx
catlog22 a77c965e89 feat: Implement terminal panel for command execution and monitoring
- Added TerminalPanel component with navigation and main area for terminal interactions.
- Integrated terminal session management with CLI execution output display.
- Enhanced SolutionDrawer to open terminal panel on command execution.
- Updated localization files for terminal panel strings in English and Chinese.
- Introduced hooks for terminal panel state management.
- Created JSON schemas for plan overview and fix plan types.
2026-02-13 00:22:16 +08:00

303 lines
9.9 KiB
TypeScript

// ========================================
// TerminalMainArea Component
// ========================================
// Main content area inside TerminalPanel.
// Renders terminal output (xterm.js) or queue view based on panelView.
import { useEffect, useRef, useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { X, Terminal as TerminalIcon } from 'lucide-react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
import { useCliSessionStore, type CliSessionMeta } from '@/stores/cliSessionStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
fetchCliSessionBuffer,
sendCliSessionText,
resizeCliSession,
executeInCliSession,
} from '@/lib/api';
// ========== Types ==========
interface TerminalMainAreaProps {
onClose: () => void;
}
// ========== Component ==========
export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const { formatMessage } = useIntl();
const panelView = useTerminalPanelStore((s) => s.panelView);
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
const sessions = useCliSessionStore((s) => s.sessions);
const outputChunks = useCliSessionStore((s) => s.outputChunks);
const setBuffer = useCliSessionStore((s) => s.setBuffer);
const clearOutput = useCliSessionStore((s) => s.clearOutput);
const projectPath = useWorkflowStore(selectProjectPath);
const activeSession: CliSessionMeta | undefined = activeTerminalId
? sessions[activeTerminalId]
: undefined;
// ========== xterm State ==========
const terminalHostRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const lastChunkIndexRef = useRef<number>(0);
// PTY input batching
const pendingInputRef = useRef<string>('');
const flushTimerRef = useRef<number | null>(null);
// Command execution
const [prompt, setPrompt] = useState('');
const [isExecuting, setIsExecuting] = useState(false);
const flushInput = useCallback(async () => {
const sessionKey = activeTerminalId;
if (!sessionKey) return;
const pending = pendingInputRef.current;
pendingInputRef.current = '';
if (!pending) return;
try {
await sendCliSessionText(sessionKey, { text: pending, appendNewline: false }, projectPath || undefined);
} catch {
// Ignore transient failures
}
}, [activeTerminalId, projectPath]);
const scheduleFlush = useCallback(() => {
if (flushTimerRef.current !== null) return;
flushTimerRef.current = window.setTimeout(async () => {
flushTimerRef.current = null;
await flushInput();
}, 30);
}, [flushInput]);
// ========== xterm Lifecycle ==========
// Init xterm instance
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 (!activeTerminalId) 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 (!activeTerminalId) return;
clearOutput(activeTerminalId);
fetchCliSessionBuffer(activeTerminalId, projectPath || undefined)
.then(({ buffer }) => {
setBuffer(activeTerminalId, buffer || '');
})
.catch(() => {
// ignore
})
.finally(() => {
fitAddon.fit();
});
}, [activeTerminalId, projectPath, setBuffer, clearOutput]);
// Stream new output chunks into xterm
useEffect(() => {
const term = xtermRef.current;
if (!term) return;
if (!activeTerminalId) return;
const chunks = outputChunks[activeTerminalId] ?? [];
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, activeTerminalId]);
// 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 (activeTerminalId) {
void (async () => {
try {
await resizeCliSession(activeTerminalId, { cols: term.cols, rows: term.rows }, projectPath || undefined);
} catch {
// ignore
}
})();
}
};
const ro = new ResizeObserver(resize);
ro.observe(host);
return () => ro.disconnect();
}, [activeTerminalId, projectPath]);
// ========== Command Execution ==========
const handleExecute = async () => {
if (!activeTerminalId || !prompt.trim()) return;
setIsExecuting(true);
const sessionTool = (activeSession?.tool || 'claude') as 'claude' | 'codex' | 'gemini';
try {
await executeInCliSession(activeTerminalId, {
tool: sessionTool,
prompt: prompt.trim(),
mode: 'analysis',
category: 'user',
}, projectPath || undefined);
setPrompt('');
} catch {
// Error shown in terminal output
} finally {
setIsExecuting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
void handleExecute();
}
};
// ========== Render ==========
return (
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
<div className="flex items-center gap-2 min-w-0">
<TerminalIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-semibold text-foreground truncate">
{panelView === 'queue'
? formatMessage({ id: 'home.terminalPanel.executionQueue' })
: activeSession
? `${activeSession.tool || 'cli'} - ${activeSession.sessionKey}`
: formatMessage({ id: 'home.terminalPanel.title' })}
</span>
{activeSession?.workingDir && panelView === 'terminal' && (
<span className="text-xs text-muted-foreground truncate hidden sm:inline">
{activeSession.workingDir}
</span>
)}
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
<X className="h-5 w-5" />
</Button>
</div>
{/* Content */}
{panelView === 'queue' ? (
/* Queue View - Placeholder */
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.executionQueueDesc' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.executionQueuePhase2' })}</p>
</div>
</div>
) : activeTerminalId ? (
/* Terminal View */
<div className="flex-1 flex flex-col min-h-0">
{/* xterm container */}
<div className="flex-1 min-h-0">
<div
ref={terminalHostRef}
className="h-full w-full bg-black/90 rounded-none"
/>
</div>
{/* Command Input */}
<div className="border-t border-border p-3 bg-card">
<div className="space-y-2">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={formatMessage({ id: 'home.terminalPanel.commandPlaceholder' })}
className={cn(
'w-full min-h-[60px] p-2 bg-background border border-input rounded-md text-sm resize-none',
'focus:outline-none focus:ring-2 focus:ring-primary'
)}
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleExecute}
disabled={!activeTerminalId || isExecuting || !prompt.trim()}
>
{formatMessage({ id: 'home.terminalPanel.execute' })}
</Button>
</div>
</div>
</div>
</div>
) : (
/* Empty State */
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.noTerminalSelected' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
</div>
</div>
)}
</div>
);
}