feat(KB-057): add interactive terminal to dashboard
- Add backend terminal API with SSE streaming for real-time output - Create useTerminal hook with command history and navigation - Refactor TerminalModal into interactive shell component - Update App and Header for standalone terminal button - Add CSS styles for terminal UI (prompts, output, spinner, welcome screen) - Update tests for new terminal behavior
This commit is contained in:
@@ -142,15 +142,12 @@ function AppInner() {
|
||||
setTerminalOpen(false);
|
||||
}, []);
|
||||
|
||||
// Filter tasks to get only in-progress tasks for terminal
|
||||
const inProgressTasks = tasks.filter((t) => t.column === "in-progress");
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
inProgressCount={inProgressTasks.length}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
onToggleGlobalPause={handleToggleGlobalPause}
|
||||
|
||||
@@ -477,3 +477,58 @@ export function pushBranch(): Promise<GitPushResult> {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Terminal API ---
|
||||
|
||||
/** Terminal exec response - returns sessionId for streaming output via SSE */
|
||||
export interface TerminalExecResponse {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/** Terminal session status and output */
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
command: string;
|
||||
running: boolean;
|
||||
exitCode: number | null;
|
||||
output: string;
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
/** Terminal SSE event types */
|
||||
export interface TerminalOutputEvent {
|
||||
type: "stdout" | "stderr";
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** Terminal exit event from SSE */
|
||||
export interface TerminalExitEvent {
|
||||
type: "exit";
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/** Execute a shell command and get a session ID for streaming output */
|
||||
export function execTerminalCommand(command: string): Promise<TerminalExecResponse> {
|
||||
return api<TerminalExecResponse>("/terminal/exec", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Get terminal session status and accumulated output */
|
||||
export function getTerminalSession(sessionId: string): Promise<TerminalSession> {
|
||||
return api<TerminalSession>(`/terminal/sessions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
|
||||
/** Kill a running terminal session */
|
||||
export function killTerminalSession(sessionId: string, signal?: "SIGTERM" | "SIGKILL" | "SIGINT"): Promise<{ killed: boolean; sessionId: string }> {
|
||||
return api<{ killed: boolean; sessionId: string }>(`/terminal/sessions/${encodeURIComponent(sessionId)}/kill`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ signal: signal ?? "SIGTERM" }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the SSE stream URL for a terminal session */
|
||||
export function getTerminalStreamUrl(sessionId: string): string {
|
||||
return `/api/terminal/sessions/${encodeURIComponent(sessionId)}/stream`;
|
||||
}
|
||||
|
||||
@@ -105,45 +105,14 @@ describe("Header", () => {
|
||||
|
||||
it("calls onToggleTerminal when terminal button is clicked", () => {
|
||||
const onToggleTerminal = vi.fn();
|
||||
renderHeader({ onToggleTerminal, inProgressCount: 1 });
|
||||
renderHeader({ onToggleTerminal });
|
||||
fireEvent.click(screen.getByTitle("Open Terminal"));
|
||||
expect(onToggleTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows badge with count when in-progress tasks exist", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 3 });
|
||||
expect(screen.getByTestId("terminal-badge")).toBeDefined();
|
||||
expect(screen.getByTestId("terminal-badge").textContent).toBe("3");
|
||||
});
|
||||
|
||||
it("shows badge with 9+ when count exceeds 9", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 15 });
|
||||
expect(screen.getByTestId("terminal-badge")).toBeDefined();
|
||||
expect(screen.getByTestId("terminal-badge").textContent).toBe("9+");
|
||||
});
|
||||
|
||||
it("does not show badge when no in-progress tasks", () => {
|
||||
renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
|
||||
expect(screen.queryByTestId("terminal-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("is always enabled (interactive shell feature)", () => {
|
||||
// Terminal is now always accessible regardless of task state
|
||||
const { rerender } = renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
|
||||
let btn = screen.getByTitle("Open Terminal");
|
||||
expect(btn.hasAttribute("disabled")).toBe(false);
|
||||
|
||||
rerender(<Header
|
||||
onOpenSettings={noop}
|
||||
onOpenGitHubImport={noop}
|
||||
globalPaused={false}
|
||||
enginePaused={false}
|
||||
onToggleGlobalPause={noop}
|
||||
onToggleEnginePause={noop}
|
||||
onToggleTerminal={noop}
|
||||
inProgressCount={2}
|
||||
/>);
|
||||
btn = screen.getByTitle("Open Terminal");
|
||||
it("is always enabled regardless of task state", () => {
|
||||
renderHeader({ onToggleTerminal: noop });
|
||||
const btn = screen.getByTitle("Open Terminal");
|
||||
expect(btn.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Moon, Sun, Monitor } from "lucide-react";
|
||||
import type { ThemeMode } from "@kb/core";
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
inProgressCount?: number;
|
||||
globalPaused?: boolean;
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list";
|
||||
onChangeView?: (view: "board" | "list") => void;
|
||||
themeMode?: ThemeMode;
|
||||
onToggleTheme?: () => void;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
onOpenSettings,
|
||||
onOpenGitHubImport,
|
||||
onToggleTerminal,
|
||||
inProgressCount = 0,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
onToggleGlobalPause,
|
||||
onToggleEnginePause,
|
||||
view = "board",
|
||||
onChangeView,
|
||||
themeMode = "dark",
|
||||
onToggleTheme,
|
||||
}: HeaderProps) {
|
||||
const hasInProgressTasks = inProgressCount > 0;
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
@@ -63,41 +54,18 @@ export function Header({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Theme Toggle */}
|
||||
{onToggleTheme && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onToggleTheme}
|
||||
title={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
|
||||
aria-label={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
|
||||
data-testid="theme-toggle-btn"
|
||||
>
|
||||
{themeMode === "dark" ? (
|
||||
<Moon size={16} />
|
||||
) : themeMode === "light" ? (
|
||||
<Sun size={16} />
|
||||
) : (
|
||||
<Monitor size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Import from GitHub */}
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{/* Terminal button - always enabled */}
|
||||
{/* Terminal button - always available for interactive shell access */}
|
||||
<button
|
||||
className={`btn-icon btn-icon--terminal${hasInProgressTasks ? " has-badge" : ""}`}
|
||||
className="btn-icon btn-icon--terminal"
|
||||
onClick={onToggleTerminal}
|
||||
title="Open Terminal"
|
||||
data-testid="terminal-toggle-btn"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
{hasInProgressTasks && (
|
||||
<span className="btn-badge" data-testid="terminal-badge">
|
||||
{inProgressCount > 9 ? "9+" : inProgressCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Pause button (soft pause): stops new work, lets agents finish */}
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, Trash2, Terminal as TerminalIcon } from "lucide-react";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
|
||||
@@ -8,24 +8,38 @@ interface TerminalModalProps {
|
||||
initialCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive terminal modal component.
|
||||
*
|
||||
* Provides a fully functional shell terminal where users can execute commands
|
||||
* in the project's working directory. Features include:
|
||||
* - Real-time command output streaming via SSE
|
||||
* - Command history with Up/Down arrow navigation
|
||||
* - Keyboard shortcuts (Ctrl+C to kill, Ctrl+L to clear)
|
||||
* - Persistent session during modal lifetime
|
||||
* - Scrollable output history
|
||||
*
|
||||
* The terminal is independent of task state and always available.
|
||||
*/
|
||||
export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) {
|
||||
const {
|
||||
history,
|
||||
input,
|
||||
isRunning,
|
||||
currentDirectory,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
executeCommand,
|
||||
clearHistory,
|
||||
setInput,
|
||||
killCurrentCommand,
|
||||
navigateHistory,
|
||||
navigateHistoryUp,
|
||||
navigateHistoryDown,
|
||||
resetHistoryNavigation,
|
||||
} = useTerminal();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const [historyOffset, setHistoryOffset] = useState(-1);
|
||||
const [showWelcome, setShowWelcome] = useState(true);
|
||||
|
||||
// Auto-scroll to bottom when history changes
|
||||
// Auto-scroll to bottom when output changes
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
@@ -35,77 +49,19 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Execute initial command if provided
|
||||
useEffect(() => {
|
||||
if (isOpen && initialCommand && !isRunning && history.length === 0) {
|
||||
if (isOpen && initialCommand && showWelcome) {
|
||||
setShowWelcome(false);
|
||||
executeCommand(initialCommand);
|
||||
}
|
||||
}, [isOpen, initialCommand, isRunning, history.length, executeCommand]);
|
||||
}, [isOpen, initialCommand, executeCommand, showWelcome]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent) => {
|
||||
// Ctrl+C - Kill running command
|
||||
if (e.ctrlKey && e.key === "c" && isRunning) {
|
||||
e.preventDefault();
|
||||
await killCurrentCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+L - Clear screen
|
||||
if (e.ctrlKey && e.key === "l") {
|
||||
e.preventDefault();
|
||||
clearHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter - Execute command
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const command = input.trim();
|
||||
if (command) {
|
||||
await executeCommand(command);
|
||||
setHistoryOffset(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Up arrow - Navigate history backward (older)
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const historyCmd = navigateHistory("up", input);
|
||||
if (historyCmd !== null) {
|
||||
setInput(historyCmd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Down arrow - Navigate history forward (newer)
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const historyCmd = navigateHistory("down", input);
|
||||
if (historyCmd !== null) {
|
||||
setInput(historyCmd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
[input, isRunning, executeCommand, killCurrentCommand, clearHistory, navigateHistory, setInput]
|
||||
);
|
||||
|
||||
// Handle overlay click to close
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
// Handle escape key
|
||||
// Handle escape key to close modal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -116,24 +72,91 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle overlay click to close
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Handle command submission
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputValue.trim() || isRunning) return;
|
||||
|
||||
setShowWelcome(false);
|
||||
resetHistoryNavigation();
|
||||
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();
|
||||
if (isRunning) {
|
||||
await killCurrentCommand();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+L - Clear screen
|
||||
if (e.key === "l" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
clearHistory();
|
||||
setShowWelcome(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Up arrow - Navigate history back
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
navigateHistoryUp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Down arrow - Navigate history forward
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
navigateHistoryDown();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[isRunning, killCurrentCommand, clearHistory, navigateHistoryUp, navigateHistoryDown],
|
||||
);
|
||||
|
||||
// Handle clear button click
|
||||
const handleClear = useCallback(() => {
|
||||
clearHistory();
|
||||
setShowWelcome(true);
|
||||
}, [clearHistory]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="terminal-modal-overlay">
|
||||
<div className="modal terminal-modal interactive" data-testid="terminal-modal">
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={handleOverlayClick}
|
||||
data-testid="terminal-modal-overlay"
|
||||
>
|
||||
<div className="modal terminal-modal" data-testid="terminal-modal">
|
||||
{/* Header */}
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-title">
|
||||
<div className="terminal-title" data-testid="terminal-title">
|
||||
<TerminalIcon size={16} />
|
||||
<span>Terminal</span>
|
||||
<span>Interactive Terminal</span>
|
||||
</div>
|
||||
<div className="terminal-actions">
|
||||
<button
|
||||
className="terminal-clear-btn"
|
||||
onClick={clearHistory}
|
||||
disabled={history.length === 0}
|
||||
title="Clear history (Ctrl+L)"
|
||||
onClick={handleClear}
|
||||
data-testid="terminal-clear-btn"
|
||||
title="Clear terminal (Ctrl+L)"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Clear</span>
|
||||
@@ -142,7 +165,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
className="terminal-close"
|
||||
onClick={onClose}
|
||||
data-testid="terminal-close-btn"
|
||||
title="Close terminal (Esc)"
|
||||
title="Close terminal"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -150,38 +173,63 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
</div>
|
||||
|
||||
{/* Output area */}
|
||||
<div className="terminal-content" ref={outputRef} data-testid="terminal-content">
|
||||
{history.length === 0 ? (
|
||||
<div
|
||||
className="terminal-output"
|
||||
ref={outputRef}
|
||||
data-testid="terminal-output"
|
||||
>
|
||||
{showWelcome && history.length === 0 ? (
|
||||
<div className="terminal-welcome" data-testid="terminal-welcome">
|
||||
<p>Interactive Terminal</p>
|
||||
<p>Type commands and press Enter to execute.</p>
|
||||
<div className="terminal-shortcuts">
|
||||
<span>Ctrl+C</span> Kill process
|
||||
<span>Ctrl+L</span> Clear screen
|
||||
<span>↑/↓</span> Command history
|
||||
<span>Esc</span> Close
|
||||
<div className="terminal-welcome-icon">
|
||||
<TerminalIcon size={48} />
|
||||
</div>
|
||||
<h3>Interactive Terminal</h3>
|
||||
<p>
|
||||
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 className="terminal-output" data-testid="terminal-output">
|
||||
{history.map((entry, index) => (
|
||||
<div key={index} className="terminal-entry" data-testid={`terminal-entry-${index}`}>
|
||||
<div className="terminal-history">
|
||||
{history.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
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>
|
||||
{entry.isRunning && <span className="terminal-running-indicator">●</span>}
|
||||
</div>
|
||||
{entry.output && (
|
||||
<pre className="terminal-output-text" data-testid={`terminal-output-${index}`}>
|
||||
<pre
|
||||
className={`terminal-output-text ${
|
||||
entry.exitCode !== 0 && !entry.isRunning
|
||||
? "terminal-output-error"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{entry.output}
|
||||
</pre>
|
||||
)}
|
||||
{!entry.isRunning && entry.exitCode !== null && (
|
||||
<div
|
||||
className={`terminal-exit-code ${entry.exitCode !== 0 ? "error" : ""}`}
|
||||
data-testid={`terminal-exit-${index}`}
|
||||
>
|
||||
{entry.exitCode === 0 ? "✓" : `✗ Exit code: ${entry.exitCode}`}
|
||||
{entry.isRunning && (
|
||||
<div className="terminal-running-indicator">
|
||||
<span className="terminal-spinner" />
|
||||
<span>Running...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,38 +240,32 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
|
||||
{/* Input area */}
|
||||
<div className="terminal-input-area" data-testid="terminal-input-area">
|
||||
<div className="terminal-input-line">
|
||||
<span className="terminal-prompt">$</span>
|
||||
<form onSubmit={handleSubmit} className="terminal-form">
|
||||
<span className="terminal-input-prompt">$</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="terminal-input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a command..."
|
||||
placeholder={isRunning ? "Command running..." : "Type a command..."}
|
||||
disabled={isRunning}
|
||||
data-testid="terminal-input"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
data-testid="terminal-input"
|
||||
/>
|
||||
{isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-kill-btn"
|
||||
onClick={killCurrentCommand}
|
||||
title="Kill process (Ctrl+C)"
|
||||
data-testid="terminal-kill-btn"
|
||||
title="Kill process (Ctrl+C)"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="terminal-status">
|
||||
{currentDirectory}
|
||||
{isRunning && <span className="terminal-status-running">Running...</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import { TerminalModal } from "../TerminalModal";
|
||||
import * as useTerminalModule from "../../hooks/useTerminal";
|
||||
|
||||
@@ -14,21 +14,26 @@ describe("TerminalModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockExecuteCommand = vi.fn();
|
||||
const mockClearHistory = vi.fn();
|
||||
const mockSetInput = vi.fn();
|
||||
const mockKillCurrentCommand = vi.fn();
|
||||
const mockNavigateHistory = vi.fn();
|
||||
const mockSetInputValue = vi.fn();
|
||||
const mockNavigateHistoryUp = vi.fn();
|
||||
const mockNavigateHistoryDown = vi.fn();
|
||||
const mockResetHistoryNavigation = vi.fn();
|
||||
|
||||
const createMockTerminalState = (overrides = {}) => ({
|
||||
history: [],
|
||||
input: "",
|
||||
isRunning: false,
|
||||
currentSessionId: null,
|
||||
currentDirectory: "~/project",
|
||||
isRunning: false,
|
||||
inputValue: "",
|
||||
historyIndex: -1,
|
||||
error: null,
|
||||
executeCommand: mockExecuteCommand,
|
||||
clearHistory: mockClearHistory,
|
||||
setInput: mockSetInput,
|
||||
killCurrentCommand: mockKillCurrentCommand,
|
||||
navigateHistory: mockNavigateHistory,
|
||||
setInputValue: mockSetInputValue,
|
||||
navigateHistoryUp: mockNavigateHistoryUp,
|
||||
navigateHistoryDown: mockNavigateHistoryDown,
|
||||
resetHistoryNavigation: mockResetHistoryNavigation,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -36,9 +41,12 @@ describe("TerminalModal", () => {
|
||||
mockOnClose.mockClear();
|
||||
mockExecuteCommand.mockClear();
|
||||
mockClearHistory.mockClear();
|
||||
mockSetInput.mockClear();
|
||||
mockKillCurrentCommand.mockClear();
|
||||
mockNavigateHistory.mockClear();
|
||||
mockSetInputValue.mockClear();
|
||||
mockNavigateHistoryUp.mockClear();
|
||||
mockNavigateHistoryDown.mockClear();
|
||||
mockResetHistoryNavigation.mockClear();
|
||||
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||||
});
|
||||
|
||||
@@ -46,138 +54,156 @@ describe("TerminalModal", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders without crashing when open", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
const { container } = render(
|
||||
<TerminalModal isOpen={false} onClose={mockOnClose} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders when open", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-content")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows welcome message when history is empty", () => {
|
||||
it("shows welcome message when empty", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-welcome")).toBeTruthy();
|
||||
expect(screen.getByText("Interactive Terminal")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Interactive Terminal" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays command history", () => {
|
||||
it("executes command on form submit", async () => {
|
||||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
// Update mock to provide a value
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState({ inputValue: "ls -la" }));
|
||||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const form = screen.getByTestId("terminal-input").closest("form");
|
||||
fireEvent.submit(form!);
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("ls -la");
|
||||
});
|
||||
|
||||
it("executes initial command when provided", async () => {
|
||||
mockUseTerminal.mockReturnValue(createMockTerminalState({ inputValue: "" }));
|
||||
|
||||
render(
|
||||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="git status" />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("git status");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears history when clear button clicked", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-clear-btn"));
|
||||
|
||||
expect(mockClearHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("kills process when kill button clicked while running", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({ isRunning: true, currentSessionId: "session-123" })
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-kill-btn"));
|
||||
|
||||
expect(mockKillCurrentCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows running indicator when command is executing", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
isRunning: true,
|
||||
currentSessionId: "session-123",
|
||||
history: [
|
||||
{ command: "ls -la", output: "file1\nfile2", exitCode: 0, isRunning: false, timestamp: new Date() },
|
||||
{
|
||||
id: "entry-1",
|
||||
command: "sleep 10",
|
||||
output: "",
|
||||
exitCode: null,
|
||||
timestamp: new Date(),
|
||||
isRunning: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-output")).toBeTruthy();
|
||||
expect(screen.getByText("ls -la")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-output-0").textContent).toContain("file1");
|
||||
expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
|
||||
expect(screen.getByText("Running...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onClose when clicking overlay", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-modal-overlay"));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when clicking close button", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-close-btn"));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes on escape key", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates input value on type", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.change(input, { target: { value: "ls" } });
|
||||
|
||||
expect(mockSetInput).toHaveBeenCalledWith("ls");
|
||||
});
|
||||
|
||||
it("executes command on Enter key", async () => {
|
||||
it("displays command history with output", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
input: "ls -la",
|
||||
history: [
|
||||
{
|
||||
id: "entry-1",
|
||||
command: "echo hello",
|
||||
output: "hello\n",
|
||||
exitCode: 0,
|
||||
timestamp: new Date(),
|
||||
isRunning: false,
|
||||
},
|
||||
{
|
||||
id: "entry-2",
|
||||
command: "ls",
|
||||
output: "file1.txt\nfile2.txt\n",
|
||||
exitCode: 0,
|
||||
timestamp: new Date(),
|
||||
isRunning: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("ls -la");
|
||||
});
|
||||
expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
|
||||
expect(screen.getByTestId("terminal-entry-entry-2")).toBeTruthy();
|
||||
expect(screen.getByText("echo hello")).toBeTruthy();
|
||||
expect(screen.getByText("ls")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not execute empty command on Enter", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates history on up arrow", () => {
|
||||
it("disables input while command is running", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
input: "current",
|
||||
})
|
||||
createMockTerminalState({ isRunning: true })
|
||||
);
|
||||
mockNavigateHistory.mockReturnValue("previous");
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
|
||||
expect(mockNavigateHistory).toHaveBeenCalledWith("up", "current");
|
||||
expect(mockSetInput).toHaveBeenCalledWith("previous");
|
||||
expect(input).toBeDisabled();
|
||||
expect(input).toHaveAttribute("placeholder", "Command running...");
|
||||
});
|
||||
|
||||
it("navigates history on down arrow", () => {
|
||||
it("handles Ctrl+C to kill running process", () => {
|
||||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
input: "current",
|
||||
})
|
||||
createMockTerminalState({ isRunning: true })
|
||||
);
|
||||
mockNavigateHistory.mockReturnValue("next");
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
|
||||
expect(mockNavigateHistory).toHaveBeenCalledWith("down", "current");
|
||||
expect(mockSetInput).toHaveBeenCalledWith("next");
|
||||
fireEvent.keyDown(input, { key: "c", ctrlKey: true });
|
||||
|
||||
expect(mockKillCurrentCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears history on Ctrl+L", () => {
|
||||
it("handles Ctrl+L to clear history", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
@@ -186,134 +212,95 @@ describe("TerminalModal", () => {
|
||||
expect(mockClearHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("kills running command on Ctrl+C when running", async () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
isRunning: true,
|
||||
})
|
||||
);
|
||||
|
||||
it("handles Up arrow to navigate history", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.keyDown(input, { key: "c", ctrlKey: true });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
|
||||
expect(mockNavigateHistoryUp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles Down arrow to navigate history", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
|
||||
expect(mockNavigateHistoryDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal closes on Escape key press", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal closes on overlay click", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
||||
fireEvent.click(overlay);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal does not close when clicking inside modal content", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const modal = screen.getByTestId("terminal-modal");
|
||||
fireEvent.click(modal);
|
||||
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("modal closes on close button click", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-close-btn"));
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates input value on change", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
fireEvent.change(input, { target: { value: "git status" } });
|
||||
|
||||
expect(mockSetInputValue).toHaveBeenCalledWith("git status");
|
||||
});
|
||||
|
||||
it("marks error output with error class", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
history: [
|
||||
{
|
||||
id: "entry-1",
|
||||
command: "exit 1",
|
||||
output: "Error occurred",
|
||||
exitCode: 1,
|
||||
timestamp: new Date(),
|
||||
isRunning: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const output = screen.getByText("Error occurred");
|
||||
expect(output.className).toContain("terminal-output-error");
|
||||
});
|
||||
|
||||
it("focuses input when modal opens", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKillCurrentCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows kill button when command is running", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
isRunning: true,
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-kill-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides kill button when not running", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
isRunning: false,
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.queryByTestId("terminal-kill-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables input when command is running", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
isRunning: true,
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-input")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows running indicator for running commands", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
history: [
|
||||
{ command: "sleep 10", output: "", exitCode: null, isRunning: true, timestamp: new Date() },
|
||||
],
|
||||
isRunning: true,
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const entry = screen.getByTestId("terminal-entry-0");
|
||||
expect(entry.textContent).toContain("●");
|
||||
});
|
||||
|
||||
it("shows exit code for completed commands", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
history: [
|
||||
{ command: "ls", output: "", exitCode: 0, isRunning: false, timestamp: new Date() },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-exit-0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error exit code for failed commands", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
history: [
|
||||
{ command: "false", output: "", exitCode: 1, isRunning: false, timestamp: new Date() },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const exitCode = screen.getByTestId("terminal-exit-0");
|
||||
expect(exitCode.textContent).toContain("1");
|
||||
});
|
||||
|
||||
it("disables clear button when history is empty", () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByTestId("terminal-clear-btn")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows current directory in status bar", () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
currentDirectory: "/home/user/project",
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
expect(screen.getByText("/home/user/project")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("executes initial command on mount when provided", async () => {
|
||||
mockUseTerminal.mockReturnValue(
|
||||
createMockTerminalState({
|
||||
history: [],
|
||||
isRunning: false,
|
||||
})
|
||||
);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm install" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith("npm install");
|
||||
const input = screen.getByTestId("terminal-input");
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,16 +23,12 @@ export interface TerminalState {
|
||||
currentSessionId: string | null;
|
||||
/** Whether a command is currently executing */
|
||||
isRunning: boolean;
|
||||
/** Current input value in the terminal (alias for inputValue) */
|
||||
input: string;
|
||||
/** Current input value in the terminal */
|
||||
inputValue: string;
|
||||
/** Index for navigating command history with up/down arrows (-1 means not navigating) */
|
||||
historyIndex: number;
|
||||
/** Error message if something went wrong */
|
||||
error: string | null;
|
||||
/** Current working directory */
|
||||
currentDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +43,6 @@ export interface TerminalActions {
|
||||
killCurrentCommand: () => Promise<void>;
|
||||
/** Set the input value */
|
||||
setInputValue: (value: string) => void;
|
||||
/** Set the input value (alias for setInputValue) */
|
||||
setInput: (value: string) => void;
|
||||
/** Navigate command history with direction and current input */
|
||||
navigateHistory: (direction: "up" | "down", currentInput?: string) => string | null;
|
||||
/** Navigate to previous command in history (for up arrow) */
|
||||
navigateHistoryUp: () => string | null;
|
||||
/** Navigate to next command in history (for down arrow) */
|
||||
@@ -73,16 +65,16 @@ export interface TerminalActions {
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { history, isRunning, input, setInput, executeCommand, clearHistory } = useTerminal();
|
||||
* const { history, isRunning, inputValue, setInputValue, executeCommand, clearHistory } = useTerminal();
|
||||
*
|
||||
* // In your component:
|
||||
* <input
|
||||
* value={input}
|
||||
* onChange={(e) => setInput(e.target.value)}
|
||||
* value={inputValue}
|
||||
* onChange={(e) => setInputValue(e.target.value)}
|
||||
* onKeyDown={(e) => {
|
||||
* if (e.key === 'Enter') executeCommand(input);
|
||||
* if (e.key === 'ArrowUp') navigateHistory('up', input);
|
||||
* if (e.key === 'ArrowDown') navigateHistory('down', input);
|
||||
* if (e.key === 'Enter') executeCommand(inputValue);
|
||||
* if (e.key === 'ArrowUp') navigateHistoryUp();
|
||||
* if (e.key === 'ArrowDown') navigateHistoryDown();
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
@@ -98,109 +90,24 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
// Input state
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [originalInput, setOriginalInput] = useState(""); // Store original input when navigating history
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Current directory state (tracked locally for cd commands)
|
||||
const [currentDirectory, setCurrentDirectory] = useState("~");
|
||||
|
||||
// Refs for managing SSE and abort controllers
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const currentEntryRef = useRef<TerminalHistoryEntry | null>(null);
|
||||
const historyRef = useRef(history);
|
||||
const currentDirRef = useRef(currentDirectory);
|
||||
const historyIndexRef = useRef(historyIndex); // Track historyIndex in ref for navigation
|
||||
|
||||
// Keep history ref in sync for access in event handlers
|
||||
useEffect(() => {
|
||||
historyRef.current = history;
|
||||
}, [history]);
|
||||
|
||||
// Keep current dir ref in sync
|
||||
useEffect(() => {
|
||||
currentDirRef.current = currentDirectory;
|
||||
}, [currentDirectory]);
|
||||
|
||||
// Keep historyIndex ref in sync
|
||||
useEffect(() => {
|
||||
historyIndexRef.current = historyIndex;
|
||||
}, [historyIndex]);
|
||||
|
||||
/**
|
||||
* Handle local commands (cd, clear, cls) without API call.
|
||||
* Returns true if command was handled locally.
|
||||
*/
|
||||
const handleLocalCommand = useCallback((command: string): boolean => {
|
||||
const trimmed = command.trim();
|
||||
|
||||
// Handle clear/cls commands locally - just clear history, don't add command
|
||||
if (trimmed === "clear" || trimmed === "cls") {
|
||||
setHistory([]);
|
||||
setInputValue("");
|
||||
setHistoryIndex(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle cd command locally
|
||||
if (trimmed === "cd" || trimmed.startsWith("cd ")) {
|
||||
const args = trimmed.slice(2).trim();
|
||||
|
||||
if (!args || args === "~" || args === "~/") {
|
||||
setCurrentDirectory("~");
|
||||
} else if (args.startsWith("/")) {
|
||||
setCurrentDirectory(args);
|
||||
} else if (args === "..") {
|
||||
// Simple parent directory handling
|
||||
if (currentDirRef.current === "~") {
|
||||
setCurrentDirectory("~");
|
||||
} else {
|
||||
const parts = currentDirRef.current.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
setCurrentDirectory(parts.length === 0 ? "~" : "/" + parts.join("/"));
|
||||
}
|
||||
} else {
|
||||
// Relative path
|
||||
if (currentDirRef.current === "~") {
|
||||
setCurrentDirectory("~/" + args);
|
||||
} else if (currentDirRef.current === "/") {
|
||||
setCurrentDirectory("/" + args);
|
||||
} else {
|
||||
setCurrentDirectory(currentDirRef.current + "/" + args);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to history with success
|
||||
const entry: TerminalHistoryEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
command: trimmed,
|
||||
output: "",
|
||||
exitCode: 0,
|
||||
timestamp: new Date(),
|
||||
isRunning: false,
|
||||
};
|
||||
setHistory((prev) => [...prev, entry]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Execute a shell command in the terminal.
|
||||
* Creates a new session and streams output via SSE.
|
||||
*/
|
||||
const executeCommand = useCallback(async (command: string) => {
|
||||
if (!command.trim()) return;
|
||||
|
||||
// Try to handle local commands first (these can run even while another command is running)
|
||||
if (handleLocalCommand(command)) {
|
||||
setInputValue("");
|
||||
setHistoryIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
// For API commands, check if one is already running
|
||||
if (isRunning) return;
|
||||
if (!command.trim() || isRunning) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
@@ -219,7 +126,6 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
setHistory((prev) => [...prev, entry]);
|
||||
setIsRunning(true);
|
||||
setInputValue("");
|
||||
setOriginalInput(""); // Clear original input on new command
|
||||
setHistoryIndex(-1);
|
||||
|
||||
// Execute command via API
|
||||
@@ -309,14 +215,14 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to execute command");
|
||||
|
||||
// Mark entry as failed with exit code 1 (as expected by tests)
|
||||
// Mark entry as failed
|
||||
setHistory((prev) => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
if (!lastEntry || !lastEntry.isRunning) return prev;
|
||||
|
||||
const updatedEntry = {
|
||||
...lastEntry,
|
||||
exitCode: 1, // Changed from -1 to 1 to match test expectations
|
||||
exitCode: -1,
|
||||
isRunning: false,
|
||||
output: lastEntry.output + `\n[Error: ${err.message || "Failed to execute command"}]\n`,
|
||||
};
|
||||
@@ -328,7 +234,7 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
setCurrentSessionId(null);
|
||||
currentEntryRef.current = null;
|
||||
}
|
||||
}, [isRunning, handleLocalCommand]);
|
||||
}, [isRunning]);
|
||||
|
||||
/**
|
||||
* Kill the currently running command.
|
||||
@@ -337,7 +243,7 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
if (!currentSessionId || !isRunning) return;
|
||||
|
||||
try {
|
||||
await killTerminalSession(currentSessionId); // No signal argument
|
||||
await killTerminalSession(currentSessionId, "SIGTERM");
|
||||
|
||||
// Close SSE connection
|
||||
if (eventSourceRef.current) {
|
||||
@@ -374,7 +280,7 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
const clearHistory = useCallback(() => {
|
||||
// Kill any running process first
|
||||
if (isRunning && currentSessionId) {
|
||||
killTerminalSession(currentSessionId).catch(() => {
|
||||
killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
|
||||
// Ignore errors during cleanup
|
||||
});
|
||||
}
|
||||
@@ -388,89 +294,48 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
setCurrentSessionId(null);
|
||||
setIsRunning(false);
|
||||
setHistoryIndex(-1);
|
||||
setOriginalInput("");
|
||||
currentEntryRef.current = null;
|
||||
}, [isRunning, currentSessionId]);
|
||||
|
||||
/**
|
||||
* Navigate command history with direction parameter.
|
||||
* This is the interface expected by TerminalModal component.
|
||||
* Also handles setting the input value.
|
||||
* Navigate to previous command in history (Up arrow).
|
||||
* Returns the command string or null if no history.
|
||||
*/
|
||||
const navigateHistory = useCallback((direction: "up" | "down", currentInput?: string): string | null => {
|
||||
const navigateHistoryUp = useCallback(() => {
|
||||
if (historyRef.current.length === 0) return null;
|
||||
|
||||
// Read current index from the ref (most up-to-date value)
|
||||
const currentIndex = historyIndexRef.current;
|
||||
const newIndex = historyIndex + 1;
|
||||
if (newIndex >= historyRef.current.length) return null;
|
||||
|
||||
if (direction === "up") {
|
||||
// Store original input on first navigation up
|
||||
if (currentIndex === -1 && currentInput !== undefined) {
|
||||
setOriginalInput(currentInput);
|
||||
} else if (currentIndex === -1) {
|
||||
setOriginalInput(inputValue);
|
||||
}
|
||||
|
||||
const newIndex = currentIndex + 1;
|
||||
if (newIndex >= historyRef.current.length) return null;
|
||||
|
||||
// Update both state and ref immediately
|
||||
setHistoryIndex(newIndex);
|
||||
historyIndexRef.current = newIndex;
|
||||
|
||||
// Calculate which command to return (most recent first)
|
||||
// history = [first, second], length = 2
|
||||
// newIndex = 0: return history[2 - 1 - 0] = history[1] = "second"
|
||||
// newIndex = 1: return history[2 - 1 - 1] = history[0] = "first"
|
||||
const commandIndex = historyRef.current.length - 1 - newIndex;
|
||||
const command = historyRef.current[commandIndex]?.command || "";
|
||||
|
||||
setInputValue(command);
|
||||
return command;
|
||||
} else {
|
||||
// Down direction
|
||||
if (currentIndex <= 0) {
|
||||
// Restore original input and reset index
|
||||
setHistoryIndex(-1);
|
||||
historyIndexRef.current = -1;
|
||||
setInputValue(originalInput);
|
||||
return originalInput;
|
||||
}
|
||||
|
||||
const newIndex = currentIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
historyIndexRef.current = newIndex;
|
||||
|
||||
const commandIndex = historyRef.current.length - 1 - newIndex;
|
||||
const command = historyRef.current[commandIndex]?.command || "";
|
||||
|
||||
setInputValue(command);
|
||||
return command;
|
||||
}
|
||||
}, [inputValue, originalInput]);
|
||||
|
||||
/**
|
||||
* Navigate to previous command in history (Up arrow).
|
||||
* Wrapper around navigateHistory for direct use.
|
||||
*/
|
||||
const navigateHistoryUp = useCallback((): string | null => {
|
||||
return navigateHistory("up", inputValue);
|
||||
}, [navigateHistory, inputValue]);
|
||||
setHistoryIndex(newIndex);
|
||||
const command = historyRef.current[historyRef.current.length - 1 - newIndex]?.command || "";
|
||||
setInputValue(command);
|
||||
return command;
|
||||
}, [historyIndex]);
|
||||
|
||||
/**
|
||||
* Navigate to next command in history (Down arrow).
|
||||
* Wrapper around navigateHistory for direct use.
|
||||
* Returns the command string or null if at end.
|
||||
*/
|
||||
const navigateHistoryDown = useCallback((): string | null => {
|
||||
return navigateHistory("down");
|
||||
}, [navigateHistory]);
|
||||
const navigateHistoryDown = useCallback(() => {
|
||||
if (historyIndex <= 0) {
|
||||
setHistoryIndex(-1);
|
||||
setInputValue("");
|
||||
return "";
|
||||
}
|
||||
|
||||
const newIndex = historyIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
const command = historyRef.current[historyRef.current.length - 1 - newIndex]?.command || "";
|
||||
setInputValue(command);
|
||||
return command;
|
||||
}, [historyIndex]);
|
||||
|
||||
/**
|
||||
* Reset history navigation to default state.
|
||||
*/
|
||||
const resetHistoryNavigation = useCallback(() => {
|
||||
setHistoryIndex(-1);
|
||||
historyIndexRef.current = -1;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -487,7 +352,7 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
return () => {
|
||||
// Kill any running process
|
||||
if (currentSessionId) {
|
||||
killTerminalSession(currentSessionId).catch(() => {
|
||||
killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
|
||||
// Ignore errors during cleanup
|
||||
});
|
||||
}
|
||||
@@ -504,19 +369,15 @@ export function useTerminal(): TerminalState & TerminalActions {
|
||||
history,
|
||||
currentSessionId,
|
||||
isRunning,
|
||||
input: inputValue, // Alias for compatibility
|
||||
inputValue,
|
||||
historyIndex,
|
||||
error,
|
||||
currentDirectory,
|
||||
|
||||
// Actions
|
||||
executeCommand,
|
||||
clearHistory,
|
||||
killCurrentCommand,
|
||||
setInputValue,
|
||||
setInput: setInputValue, // Alias for compatibility
|
||||
navigateHistory,
|
||||
navigateHistoryUp,
|
||||
navigateHistoryDown,
|
||||
resetHistoryNavigation,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user