feat(KB-058): complete Steps 5 & 6 — xterm.js TerminalModal and styling
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { X, Trash2, Terminal as TerminalIcon } from "lucide-react";
|
import { X, Trash2, Terminal as TerminalIcon, RefreshCw } from "lucide-react";
|
||||||
import { useTerminal } from "../hooks/useTerminal";
|
import { useTerminal } from "../hooks/useTerminal";
|
||||||
|
import { createTerminalSession, killTerminalSession } from "../api";
|
||||||
|
import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm";
|
||||||
|
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
interface TerminalModalProps {
|
interface TerminalModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -9,135 +13,341 @@ interface TerminalModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive terminal modal component.
|
* Interactive terminal modal component using xterm.js and node-pty.
|
||||||
*
|
*
|
||||||
* Provides a fully functional shell terminal where users can execute commands
|
* Provides a fully functional PTY terminal where users can execute commands
|
||||||
* in the project's working directory. Features include:
|
* in the project's working directory. Features include:
|
||||||
* - Real-time command output streaming via SSE
|
* - Real-time bidirectional communication via WebSocket
|
||||||
* - Command history with Up/Down arrow navigation
|
* - xterm.js for proper terminal emulation
|
||||||
* - Keyboard shortcuts (Ctrl+C to kill, Ctrl+L to clear)
|
* - Copy/paste support
|
||||||
* - Persistent session during modal lifetime
|
* - Terminal zoom (Ctrl++/Ctrl+-/Ctrl+0)
|
||||||
* - Scrollable output history
|
* - Auto-resizing to container
|
||||||
|
* - Reconnection support
|
||||||
*
|
*
|
||||||
* The terminal is independent of task state and always available.
|
* The terminal spawns a real shell (bash/zsh/powershell based on platform).
|
||||||
*/
|
*/
|
||||||
export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) {
|
export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) {
|
||||||
const {
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
history,
|
const [error, setError] = useState<string | null>(null);
|
||||||
isRunning,
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
inputValue,
|
const [shellName, setShellName] = useState<string>("");
|
||||||
setInputValue,
|
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||||
executeCommand,
|
|
||||||
clearHistory,
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
killCurrentCommand,
|
const xtermRef = useRef<XTerm | null>(null);
|
||||||
navigateHistoryUp,
|
const fitAddonRef = useRef<ITerminalAddon | null>(null);
|
||||||
navigateHistoryDown,
|
const hasInitialCommandRun = useRef(false);
|
||||||
resetHistoryNavigation,
|
|
||||||
} = useTerminal();
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect } = useTerminal(sessionId);
|
||||||
const outputRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [showWelcome, setShowWelcome] = useState(true);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when output changes
|
// Initialize xterm.js
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (outputRef.current) {
|
if (!isOpen || !terminalRef.current || xtermRef.current) return;
|
||||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [history]);
|
|
||||||
|
|
||||||
// Focus input when modal opens
|
let mounted = true;
|
||||||
|
|
||||||
|
const initTerminal = async () => {
|
||||||
|
// Dynamically import xterm modules
|
||||||
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
import("@xterm/addon-web-links"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!mounted || !terminalRef.current) return;
|
||||||
|
|
||||||
|
// Create terminal instance
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: "block",
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
theme: {
|
||||||
|
background: "#1e1e1e",
|
||||||
|
foreground: "#d4d4d4",
|
||||||
|
cursor: "#d4d4d4",
|
||||||
|
selectionBackground: "#264f78",
|
||||||
|
black: "#1e1e1e",
|
||||||
|
red: "#f48771",
|
||||||
|
green: "#4ec9b0",
|
||||||
|
yellow: "#dcdcaa",
|
||||||
|
blue: "#569cd6",
|
||||||
|
magenta: "#c586c0",
|
||||||
|
cyan: "#9cdcfe",
|
||||||
|
white: "#d4d4d4",
|
||||||
|
},
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load addons
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
// Try to load WebGL addon for better performance
|
||||||
|
try {
|
||||||
|
const { WebglAddon } = await import("@xterm/addon-webgl");
|
||||||
|
const webglAddon = new WebglAddon();
|
||||||
|
webglAddon.onContextLoss(() => {
|
||||||
|
webglAddon.dispose();
|
||||||
|
});
|
||||||
|
terminal.loadAddon(webglAddon);
|
||||||
|
} catch {
|
||||||
|
// WebGL not available, fallback to canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open terminal in container
|
||||||
|
terminal.open(terminalRef.current);
|
||||||
|
|
||||||
|
// Initial fit
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
xtermRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// Handle data from terminal (user input)
|
||||||
|
const dataHandler = terminal.onData((data) => {
|
||||||
|
sendInput(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
const resizeHandler = () => {
|
||||||
|
if (fitAddonRef.current && xtermRef.current) {
|
||||||
|
try {
|
||||||
|
(fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
|
||||||
|
const { cols, rows } = xtermRef.current;
|
||||||
|
resize(cols, rows);
|
||||||
|
} catch {
|
||||||
|
// Ignore fit errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", resizeHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dataHandler.dispose();
|
||||||
|
window.removeEventListener("resize", resizeHandler);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupPromise = initTerminal();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
cleanupPromise.then((cleanup) => cleanup?.());
|
||||||
|
|
||||||
|
if (xtermRef.current) {
|
||||||
|
xtermRef.current.dispose();
|
||||||
|
xtermRef.current = null;
|
||||||
|
}
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
};
|
||||||
|
}, [isOpen, sendInput, resize]);
|
||||||
|
|
||||||
|
// Subscribe to terminal data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && inputRef.current) {
|
if (!xtermRef.current) return;
|
||||||
setTimeout(() => inputRef.current?.focus(), 100);
|
|
||||||
|
const unsubData = onData((data) => {
|
||||||
|
xtermRef.current?.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubScrollback = onScrollback((data) => {
|
||||||
|
xtermRef.current?.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubConnect = onConnect((info) => {
|
||||||
|
setShellName(info.shell.split("/").pop() || info.shell);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubExit = onExit((code) => {
|
||||||
|
setExitCode(code);
|
||||||
|
xtermRef.current?.write(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m\r\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubData();
|
||||||
|
unsubScrollback();
|
||||||
|
unsubConnect();
|
||||||
|
unsubExit();
|
||||||
|
};
|
||||||
|
}, [onData, onScrollback, onConnect, onExit]);
|
||||||
|
|
||||||
|
// Create session when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
// Cleanup session on close
|
||||||
|
if (sessionId) {
|
||||||
|
killTerminalSession(sessionId).catch(() => {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
});
|
||||||
|
setSessionId(null);
|
||||||
|
}
|
||||||
|
hasInitialCommandRun.current = false;
|
||||||
|
setError(null);
|
||||||
|
setExitCode(null);
|
||||||
|
setShellName("");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
const createSession = async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await createTerminalSession();
|
||||||
|
setSessionId(session.sessionId);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to create terminal session");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createSession();
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Execute initial command if provided
|
// Run initial command when connected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && initialCommand && showWelcome) {
|
if (connectionStatus === "connected" && initialCommand && !hasInitialCommandRun.current && sessionId) {
|
||||||
setShowWelcome(false);
|
hasInitialCommandRun.current = true;
|
||||||
executeCommand(initialCommand);
|
// Small delay to let shell initialize
|
||||||
|
setTimeout(() => {
|
||||||
|
sendInput(initialCommand + "\n");
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
}, [isOpen, initialCommand, executeCommand, showWelcome]);
|
}, [connectionStatus, initialCommand, sendInput, sessionId]);
|
||||||
|
|
||||||
// Handle overlay click to close
|
// Handle keyboard shortcuts (zoom)
|
||||||
const handleOverlayClick = useCallback(
|
useEffect(() => {
|
||||||
(e: React.MouseEvent) => {
|
if (!isOpen) return;
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle command submission
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const handleSubmit = useCallback(
|
if (!e.ctrlKey && !e.metaKey) return;
|
||||||
async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!inputValue.trim() || isRunning) return;
|
|
||||||
|
|
||||||
setShowWelcome(false);
|
// Zoom in: Ctrl/Cmd + Plus
|
||||||
resetHistoryNavigation();
|
if (e.code === "Equal" || e.code === "NumpadAdd") {
|
||||||
await executeCommand(inputValue.trim());
|
|
||||||
},
|
|
||||||
[inputValue, isRunning, executeCommand, resetHistoryNavigation],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
// Ctrl+C - Kill running process
|
|
||||||
if (e.key === "c" && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isRunning) {
|
if (xtermRef.current) {
|
||||||
await killCurrentCommand();
|
const currentSize = xtermRef.current.options.fontSize || 14;
|
||||||
|
xtermRef.current.options.fontSize = Math.min(currentSize + 1, 32);
|
||||||
|
fitAddonRef.current && (fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+L - Clear screen
|
// Zoom out: Ctrl/Cmd + Minus
|
||||||
if (e.key === "l" && (e.ctrlKey || e.metaKey)) {
|
if (e.code === "Minus" || e.code === "NumpadSubtract") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearHistory();
|
if (xtermRef.current) {
|
||||||
setShowWelcome(true);
|
const currentSize = xtermRef.current.options.fontSize || 14;
|
||||||
|
xtermRef.current.options.fontSize = Math.max(currentSize - 1, 8);
|
||||||
|
fitAddonRef.current && (fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Up arrow - Navigate history back
|
// Reset zoom: Ctrl/Cmd + 0
|
||||||
if (e.key === "ArrowUp") {
|
if (e.code === "Digit0" || e.code === "Numpad0") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigateHistoryUp();
|
if (xtermRef.current) {
|
||||||
|
xtermRef.current.options.fontSize = 14;
|
||||||
|
fitAddonRef.current && (fitAddonRef.current as InstanceType<typeof FitAddon>).fit();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Down arrow - Navigate history forward
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
if (e.key === "ArrowDown") {
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
e.preventDefault();
|
}, [isOpen]);
|
||||||
navigateHistoryDown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isRunning, killCurrentCommand, clearHistory, navigateHistoryUp, navigateHistoryDown],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle escape key to close
|
// Handle escape key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKey);
|
document.addEventListener("keydown", handleKey);
|
||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
// Handle clear button click
|
// Focus terminal when connected
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionStatus === "connected" && xtermRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
xtermRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [connectionStatus]);
|
||||||
|
|
||||||
|
// Handle overlay click to close
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle clear button
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
clearHistory();
|
xtermRef.current?.clear();
|
||||||
setShowWelcome(true);
|
}, []);
|
||||||
}, [clearHistory]);
|
|
||||||
|
// Handle restart
|
||||||
|
const handleRestart = useCallback(async () => {
|
||||||
|
// Kill current session
|
||||||
|
if (sessionId) {
|
||||||
|
await killTerminalSession(sessionId).catch(() => {
|
||||||
|
// Ignore errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear terminal
|
||||||
|
xtermRef.current?.clear();
|
||||||
|
setExitCode(null);
|
||||||
|
hasInitialCommandRun.current = false;
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await createTerminalSession();
|
||||||
|
setSessionId(session.sessionId);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to create terminal session");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getStatusIndicator = () => {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return <span className="terminal-status connected" title="Connected" />;
|
||||||
|
case "connecting":
|
||||||
|
case "reconnecting":
|
||||||
|
return <span className="terminal-status connecting" title="Connecting..." />;
|
||||||
|
case "disconnected":
|
||||||
|
return <span className="terminal-status disconnected" title="Disconnected" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay open"
|
className="modal-overlay open"
|
||||||
@@ -149,14 +359,40 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
|||||||
<div className="terminal-header">
|
<div className="terminal-header">
|
||||||
<div className="terminal-title" data-testid="terminal-title">
|
<div className="terminal-title" data-testid="terminal-title">
|
||||||
<TerminalIcon size={16} />
|
<TerminalIcon size={16} />
|
||||||
<span>Interactive Terminal</span>
|
<span>Terminal</span>
|
||||||
|
{shellName && (
|
||||||
|
<span className="terminal-shell-name">({shellName})</span>
|
||||||
|
)}
|
||||||
|
{getStatusIndicator()}
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-actions">
|
<div className="terminal-actions">
|
||||||
|
{connectionStatus === "disconnected" && sessionId && (
|
||||||
|
<button
|
||||||
|
className="terminal-reconnect-btn"
|
||||||
|
onClick={reconnect}
|
||||||
|
title="Reconnect"
|
||||||
|
data-testid="terminal-reconnect-btn"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
<span>Reconnect</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{exitCode !== null && (
|
||||||
|
<button
|
||||||
|
className="terminal-restart-btn"
|
||||||
|
onClick={handleRestart}
|
||||||
|
title="New Session"
|
||||||
|
data-testid="terminal-restart-btn"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
<span>New Session</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="terminal-clear-btn"
|
className="terminal-clear-btn"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
data-testid="terminal-clear-btn"
|
data-testid="terminal-clear-btn"
|
||||||
title="Clear terminal (Ctrl+L)"
|
title="Clear terminal"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
<span>Clear</span>
|
<span>Clear</span>
|
||||||
@@ -172,100 +408,45 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output area */}
|
{/* Error message */}
|
||||||
<div
|
{error && (
|
||||||
className="terminal-output"
|
<div className="terminal-error" data-testid="terminal-error">
|
||||||
ref={outputRef}
|
{error}
|
||||||
data-testid="terminal-output"
|
</div>
|
||||||
>
|
)}
|
||||||
{showWelcome && history.length === 0 ? (
|
|
||||||
<div className="terminal-welcome" data-testid="terminal-welcome">
|
{/* Terminal container */}
|
||||||
<div className="terminal-welcome-icon">
|
<div className="terminal-container" data-testid="terminal-container">
|
||||||
<TerminalIcon size={48} />
|
{isCreating ? (
|
||||||
</div>
|
<div className="terminal-loading" data-testid="terminal-loading">
|
||||||
<h3>Interactive Terminal</h3>
|
<div className="terminal-spinner" />
|
||||||
<p>
|
<span>Starting terminal...</span>
|
||||||
Execute shell commands in the project directory. Available commands include:
|
|
||||||
</p>
|
|
||||||
<div className="terminal-commands-list">
|
|
||||||
<span>git</span>
|
|
||||||
<span>npm/pnpm/yarn</span>
|
|
||||||
<span>ls/cat</span>
|
|
||||||
<span>node</span>
|
|
||||||
<span>python</span>
|
|
||||||
<span>curl</span>
|
|
||||||
<span>make</span>
|
|
||||||
<span>ps</span>
|
|
||||||
</div>
|
|
||||||
<p className="terminal-shortcuts">
|
|
||||||
<kbd>↑</kbd> <kbd>↓</kbd> Navigate history •
|
|
||||||
<kbd>Ctrl</kbd>+<kbd>C</kbd> Kill process •
|
|
||||||
<kbd>Ctrl</kbd>+<kbd>L</kbd> Clear
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="terminal-history">
|
<div
|
||||||
{history.map((entry) => (
|
ref={terminalRef}
|
||||||
<div
|
className="terminal-xterm"
|
||||||
key={entry.id}
|
data-testid="terminal-xterm"
|
||||||
className="terminal-entry"
|
/>
|
||||||
data-testid={`terminal-entry-${entry.id}`}
|
|
||||||
>
|
|
||||||
<div className="terminal-prompt-line">
|
|
||||||
<span className="terminal-prompt">$</span>
|
|
||||||
<span className="terminal-command">{entry.command}</span>
|
|
||||||
</div>
|
|
||||||
{entry.output && (
|
|
||||||
<pre
|
|
||||||
className={`terminal-output-text ${
|
|
||||||
entry.exitCode !== 0 && !entry.isRunning
|
|
||||||
? "terminal-output-error"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{entry.output}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
{entry.isRunning && (
|
|
||||||
<div className="terminal-running-indicator">
|
|
||||||
<span className="terminal-spinner" />
|
|
||||||
<span>Running...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Connection status bar */}
|
||||||
<div className="terminal-input-area" data-testid="terminal-input-area">
|
<div className="terminal-status-bar" data-testid="terminal-status-bar">
|
||||||
<form onSubmit={handleSubmit} className="terminal-form">
|
<span className={`terminal-connection-status ${connectionStatus}`}>
|
||||||
<span className="terminal-input-prompt">$</span>
|
{connectionStatus === "connected" && "Connected"}
|
||||||
<input
|
{connectionStatus === "connecting" && "Connecting..."}
|
||||||
ref={inputRef}
|
{connectionStatus === "reconnecting" && "Reconnecting..."}
|
||||||
type="text"
|
{connectionStatus === "disconnected" && "Disconnected"}
|
||||||
className="terminal-input"
|
</span>
|
||||||
value={inputValue}
|
{exitCode !== null && (
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
<span className="terminal-exit-code" data-testid="terminal-exit-code">
|
||||||
onKeyDown={handleKeyDown}
|
Exit: {exitCode}
|
||||||
placeholder={isRunning ? "Command running..." : "Type a command..."}
|
</span>
|
||||||
disabled={isRunning}
|
)}
|
||||||
autoFocus
|
<span className="terminal-shortcuts">
|
||||||
data-testid="terminal-input"
|
Ctrl++/- zoom • Ctrl+L clear • Esc close
|
||||||
/>
|
</span>
|
||||||
{isRunning && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="terminal-kill-btn"
|
|
||||||
onClick={killCurrentCommand}
|
|
||||||
data-testid="terminal-kill-btn"
|
|
||||||
title="Kill process (Ctrl+C)"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3487,6 +3487,152 @@ body {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === PTY Terminal Styles === */
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
background: #1e1e1e;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-xterm {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-xterm .xterm {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-loading .terminal-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--in-progress);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: terminal-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes terminal-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-shell-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status.connected {
|
||||||
|
background: var(--done);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status.connecting,
|
||||||
|
.terminal-status.reconnecting {
|
||||||
|
background: var(--in-progress);
|
||||||
|
animation: terminal-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status.disconnected {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes terminal-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-connection-status {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-connection-status.connected {
|
||||||
|
color: var(--done);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-connection-status.connecting,
|
||||||
|
.terminal-connection-status.reconnecting {
|
||||||
|
color: var(--in-progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-connection-status.disconnected {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-exit-code {
|
||||||
|
color: var(--error);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reconnect-btn,
|
||||||
|
.terminal-restart-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-reconnect-btn:hover,
|
||||||
|
.terminal-restart-btn:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-error {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(179, 38, 38, 0.1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Terminal Modal Mobile Responsive === */
|
/* === Terminal Modal Mobile Responsive === */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.terminal-modal {
|
.terminal-modal {
|
||||||
|
|||||||
Reference in New Issue
Block a user