mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-14 02:42:04 +08:00
- 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.
303 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|