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:
gsxdsm
2026-03-29 21:09:57 -07:00
parent 5b575923b5
commit bfd3343be4
11 changed files with 763 additions and 1599 deletions

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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 &nbsp;&nbsp;
<kbd>Ctrl</kbd>+<kbd>C</kbd> Kill process &nbsp;&nbsp;
<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>

View File

@@ -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);
});
});
});