feat(KB-057): complete Step 3 — TerminalModal component with interactive shell UI
This commit is contained in:
@@ -1,43 +1,111 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { X, Trash2 } from "lucide-react";
|
import { X, Trash2, Terminal as TerminalIcon } from "lucide-react";
|
||||||
import type { Task, AgentLogEntry } from "@kb/core";
|
import { useTerminal } from "../hooks/useTerminal";
|
||||||
import { useMultiAgentLogs } from "../hooks/useMultiAgentLogs";
|
|
||||||
import { AgentLogViewer } from "./AgentLogViewer";
|
|
||||||
|
|
||||||
interface TerminalModalProps {
|
interface TerminalModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tasks: Task[];
|
initialCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogEntryWithTask extends AgentLogEntry {
|
export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) {
|
||||||
taskId: string;
|
const {
|
||||||
}
|
history,
|
||||||
|
input,
|
||||||
|
isRunning,
|
||||||
|
currentDirectory,
|
||||||
|
executeCommand,
|
||||||
|
clearHistory,
|
||||||
|
setInput,
|
||||||
|
killCurrentCommand,
|
||||||
|
navigateHistory,
|
||||||
|
} = useTerminal();
|
||||||
|
|
||||||
export function TerminalModal({ isOpen, onClose, tasks }: TerminalModalProps) {
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
const outputRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [historyOffset, setHistoryOffset] = useState(-1);
|
||||||
// Get task IDs for all in-progress tasks
|
|
||||||
const inProgressTaskIds = tasks.map((t) => t.id);
|
|
||||||
|
|
||||||
// Get log state for all tasks
|
|
||||||
const logState = useMultiAgentLogs(inProgressTaskIds);
|
|
||||||
|
|
||||||
// Set initial active task when modal opens
|
// Auto-scroll to bottom when history changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && tasks.length > 0) {
|
if (outputRef.current) {
|
||||||
// If no active task or active task not in current list, set first task
|
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||||
if (!activeTaskId || !tasks.find((t) => t.id === activeTaskId)) {
|
|
||||||
setActiveTaskId(tasks[0].id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Reset when modal closes
|
}, [history]);
|
||||||
if (!isOpen) {
|
|
||||||
setActiveTaskId(null);
|
|
||||||
}
|
|
||||||
}, [isOpen, tasks, activeTaskId]);
|
|
||||||
|
|
||||||
// Handle escape key to close modal
|
// Focus input when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Execute initial command if provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && initialCommand && !isRunning && history.length === 0) {
|
||||||
|
executeCommand(initialCommand);
|
||||||
|
}
|
||||||
|
}, [isOpen, initialCommand, isRunning, history.length, executeCommand]);
|
||||||
|
|
||||||
|
// 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
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
@@ -48,88 +116,114 @@ export function TerminalModal({ isOpen, onClose, tasks }: TerminalModalProps) {
|
|||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
// Handle overlay click to close
|
|
||||||
const handleOverlayClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
// Get active task info
|
|
||||||
const activeTask = tasks.find((t) => t.id === activeTaskId);
|
|
||||||
const activeLogState = activeTaskId ? logState[activeTaskId] : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="terminal-modal-overlay">
|
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="terminal-modal-overlay">
|
||||||
<div className="modal terminal-modal" data-testid="terminal-modal">
|
<div className="modal terminal-modal interactive" data-testid="terminal-modal">
|
||||||
{/* Header with tabs and close button */}
|
{/* Header */}
|
||||||
<div className="terminal-header">
|
<div className="terminal-header">
|
||||||
<div className="terminal-tabs" data-testid="terminal-tabs">
|
<div className="terminal-title">
|
||||||
{tasks.length === 0 ? (
|
<TerminalIcon size={16} />
|
||||||
<div className="terminal-tab terminal-tab--empty" data-testid="terminal-no-tasks">
|
<span>Terminal</span>
|
||||||
No active tasks
|
</div>
|
||||||
</div>
|
<div className="terminal-actions">
|
||||||
) : (
|
<button
|
||||||
tasks.map((task) => (
|
className="terminal-clear-btn"
|
||||||
<button
|
onClick={clearHistory}
|
||||||
key={task.id}
|
disabled={history.length === 0}
|
||||||
className={`terminal-tab ${activeTaskId === task.id ? "terminal-tab--active" : ""}`}
|
title="Clear history (Ctrl+L)"
|
||||||
onClick={() => setActiveTaskId(task.id)}
|
data-testid="terminal-clear-btn"
|
||||||
data-testid={`terminal-tab-${task.id}`}
|
>
|
||||||
title={task.title || task.description}
|
<Trash2 size={14} />
|
||||||
>
|
<span>Clear</span>
|
||||||
<span className="terminal-tab-label">{task.id}</span>
|
</button>
|
||||||
{activeTaskId === task.id && (
|
<button
|
||||||
<span
|
className="terminal-close"
|
||||||
className="terminal-tab-indicator"
|
onClick={onClose}
|
||||||
data-testid={`terminal-tab-indicator-${task.id}`}
|
data-testid="terminal-close-btn"
|
||||||
/>
|
title="Close terminal (Esc)"
|
||||||
)}
|
>
|
||||||
</button>
|
<X size={20} />
|
||||||
))
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button className="terminal-close" onClick={onClose} data-testid="terminal-close-btn" title="Close terminal">
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log content area */}
|
{/* Output area */}
|
||||||
<div className="terminal-content" data-testid="terminal-content">
|
<div className="terminal-content" ref={outputRef} data-testid="terminal-content">
|
||||||
{tasks.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div className="terminal-empty-state" data-testid="terminal-empty-state">
|
<div className="terminal-welcome" data-testid="terminal-welcome">
|
||||||
<p>No tasks currently in progress.</p>
|
<p>Interactive Terminal</p>
|
||||||
<p>Start a task to see live logs here.</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>
|
||||||
</div>
|
</div>
|
||||||
) : activeTask && activeLogState ? (
|
) : (
|
||||||
<>
|
<div className="terminal-output" data-testid="terminal-output">
|
||||||
<div className="terminal-toolbar" data-testid="terminal-toolbar">
|
{history.map((entry, index) => (
|
||||||
<div className="terminal-task-info">
|
<div key={index} className="terminal-entry" data-testid={`terminal-entry-${index}`}>
|
||||||
<span className="terminal-task-id" data-testid="terminal-active-task-id">
|
<div className="terminal-prompt-line">
|
||||||
{activeTask.id}
|
<span className="terminal-prompt">$</span>
|
||||||
</span>
|
<span className="terminal-command">{entry.command}</span>
|
||||||
<span className="terminal-task-title" data-testid="terminal-active-task-title">
|
{entry.isRunning && <span className="terminal-running-indicator">●</span>}
|
||||||
{activeTask.title || activeTask.description}
|
</div>
|
||||||
</span>
|
{entry.output && (
|
||||||
|
<pre className="terminal-output-text" data-testid={`terminal-output-${index}`}>
|
||||||
|
{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}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
))}
|
||||||
className="terminal-clear-btn"
|
</div>
|
||||||
onClick={activeLogState.clear}
|
)}
|
||||||
data-testid="terminal-clear-btn"
|
</div>
|
||||||
title="Clear log buffer"
|
|
||||||
>
|
{/* Input area */}
|
||||||
<Trash2 size={14} />
|
<div className="terminal-input-area" data-testid="terminal-input-area">
|
||||||
<span>Clear</span>
|
<div className="terminal-input-line">
|
||||||
</button>
|
<span className="terminal-prompt">$</span>
|
||||||
</div>
|
<input
|
||||||
<div className="terminal-log-container" data-testid="terminal-log-container">
|
ref={inputRef}
|
||||||
<AgentLogViewer entries={activeLogState.entries} loading={activeLogState.loading} />
|
type="text"
|
||||||
</div>
|
className="terminal-input"
|
||||||
</>
|
value={input}
|
||||||
) : null}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a command..."
|
||||||
|
disabled={isRunning}
|
||||||
|
data-testid="terminal-input"
|
||||||
|
autoFocus
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
/>
|
||||||
|
{isRunning && (
|
||||||
|
<button
|
||||||
|
className="terminal-kill-btn"
|
||||||
|
onClick={killCurrentCommand}
|
||||||
|
title="Kill process (Ctrl+C)"
|
||||||
|
data-testid="terminal-kill-btn"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="terminal-status">
|
||||||
|
{currentDirectory}
|
||||||
|
{isRunning && <span className="terminal-status-running">Running...</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,349 +1,319 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { TerminalModal } from "../TerminalModal";
|
import { TerminalModal } from "../TerminalModal";
|
||||||
import type { Task } from "@kb/core";
|
import * as useTerminalModule from "../../hooks/useTerminal";
|
||||||
import * as useMultiAgentLogsModule from "../../hooks/useMultiAgentLogs";
|
|
||||||
|
|
||||||
// Mock the useMultiAgentLogs hook
|
// Mock the useTerminal hook
|
||||||
vi.mock("../../hooks/useMultiAgentLogs", () => ({
|
vi.mock("../../hooks/useTerminal", () => ({
|
||||||
useMultiAgentLogs: vi.fn(),
|
useTerminal: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockUseMultiAgentLogs = vi.mocked(useMultiAgentLogsModule.useMultiAgentLogs);
|
const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal);
|
||||||
|
|
||||||
describe("TerminalModal", () => {
|
describe("TerminalModal", () => {
|
||||||
const mockOnClose = vi.fn();
|
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 createMockTask = (id: string, title?: string): Task => ({
|
const createMockTerminalState = (overrides = {}) => ({
|
||||||
id,
|
history: [],
|
||||||
title: title || `Task ${id}`,
|
input: "",
|
||||||
description: `Description for ${id}`,
|
isRunning: false,
|
||||||
column: "in-progress",
|
currentSessionId: null,
|
||||||
dependencies: [],
|
currentDirectory: "~/project",
|
||||||
steps: [],
|
executeCommand: mockExecuteCommand,
|
||||||
currentStep: 0,
|
clearHistory: mockClearHistory,
|
||||||
status: undefined,
|
setInput: mockSetInput,
|
||||||
log: [],
|
killCurrentCommand: mockKillCurrentCommand,
|
||||||
createdAt: "2026-01-01T00:00:00Z",
|
navigateHistory: mockNavigateHistory,
|
||||||
updatedAt: "2026-01-01T00:00:00Z",
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockOnClose.mockClear();
|
mockOnClose.mockClear();
|
||||||
mockUseMultiAgentLogs.mockReturnValue({});
|
mockExecuteCommand.mockClear();
|
||||||
|
mockClearHistory.mockClear();
|
||||||
|
mockSetInput.mockClear();
|
||||||
|
mockKillCurrentCommand.mockClear();
|
||||||
|
mockNavigateHistory.mockClear();
|
||||||
|
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders without crashing when open with empty task list", () => {
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[]} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
|
||||||
expect(screen.getByTestId("terminal-no-tasks").textContent).toContain("No active tasks");
|
|
||||||
expect(screen.getByTestId("terminal-empty-state").textContent).toContain("No tasks currently in progress");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders without crashing when open with multiple in-progress tasks", () => {
|
|
||||||
const tasks = [
|
|
||||||
createMockTask("KB-001", "First Task"),
|
|
||||||
createMockTask("KB-002", "Second Task"),
|
|
||||||
];
|
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
|
||||||
expect(screen.getByTestId("terminal-tab-KB-001")).toBeTruthy();
|
|
||||||
expect(screen.getByTestId("terminal-tab-KB-002")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render when closed", () => {
|
it("does not render when closed", () => {
|
||||||
const tasks = [createMockTask("KB-001")];
|
|
||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TerminalModal isOpen={false} onClose={mockOnClose} tasks={tasks} />
|
<TerminalModal isOpen={false} onClose={mockOnClose} />
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows appropriate empty state when no in-progress tasks", () => {
|
it("renders when open", () => {
|
||||||
render(
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[]} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("No active tasks")).toBeTruthy();
|
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||||||
expect(screen.getByText("No tasks currently in progress.")).toBeTruthy();
|
expect(screen.getByTestId("terminal-content")).toBeTruthy();
|
||||||
expect(screen.getByText("Start a task to see live logs here.")).toBeTruthy();
|
expect(screen.getByTestId("terminal-input")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tab switching changes which task's logs are displayed", async () => {
|
it("shows welcome message when history is empty", () => {
|
||||||
const tasks = [
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
createMockTask("KB-001", "First Task"),
|
|
||||||
createMockTask("KB-002", "Second Task"),
|
|
||||||
];
|
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
expect(screen.getByTestId("terminal-welcome")).toBeTruthy();
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
expect(screen.getByText("Interactive Terminal")).toBeTruthy();
|
||||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
|
||||||
|
|
||||||
// First task should be active by default
|
|
||||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
|
||||||
|
|
||||||
// Click on second tab
|
|
||||||
fireEvent.click(screen.getByTestId("terminal-tab-KB-002"));
|
|
||||||
|
|
||||||
// Second task should now be active
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-002");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("active tab has correct styling with indicator", () => {
|
it("displays command history", () => {
|
||||||
const tasks = [
|
mockUseTerminal.mockReturnValue(
|
||||||
createMockTask("KB-001", "First Task"),
|
createMockTerminalState({
|
||||||
createMockTask("KB-002", "Second Task"),
|
history: [
|
||||||
];
|
{ command: "ls -la", output: "file1\nfile2", exitCode: 0, isRunning: false, timestamp: new Date() },
|
||||||
|
],
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
})
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// First tab should be active by default
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
const tab1 = screen.getByTestId("terminal-tab-KB-001");
|
|
||||||
const tab2 = screen.getByTestId("terminal-tab-KB-002");
|
|
||||||
|
|
||||||
expect(tab1.className).toContain("terminal-tab--active");
|
expect(screen.getByTestId("terminal-output")).toBeTruthy();
|
||||||
expect(tab2.className).not.toContain("terminal-tab--active");
|
expect(screen.getByText("ls -la")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("terminal-output-0").textContent).toContain("file1");
|
||||||
// Active tab should have indicator
|
|
||||||
expect(screen.getByTestId("terminal-tab-indicator-KB-001")).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking clear button clears that tab's log entries", () => {
|
it("calls onClose when clicking overlay", () => {
|
||||||
const mockClear = vi.fn();
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
const tasks = [createMockTask("KB-001")];
|
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
fireEvent.click(screen.getByTestId("terminal-modal-overlay"));
|
||||||
"KB-001": { entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log", type: "text" as const }], loading: false, clear: mockClear },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearBtn = screen.getByTestId("terminal-clear-btn");
|
|
||||||
fireEvent.click(clearBtn);
|
|
||||||
|
|
||||||
expect(mockClear).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("modal closes on Escape key press", () => {
|
it("calls onClose when clicking close button", () => {
|
||||||
const tasks = [createMockTask("KB-001")];
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
fireEvent.click(screen.getByTestId("terminal-close-btn"));
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
});
|
||||||
);
|
|
||||||
|
it("closes on escape key", () => {
|
||||||
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
expect(mockOnClose).toHaveBeenCalled();
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("modal closes on overlay click", () => {
|
it("updates input value on type", () => {
|
||||||
const tasks = [createMockTask("KB-001")];
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
const input = screen.getByTestId("terminal-input");
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
fireEvent.change(input, { target: { value: "ls" } });
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
expect(mockSetInput).toHaveBeenCalledWith("ls");
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
|
||||||
fireEvent.click(overlay);
|
|
||||||
|
|
||||||
expect(mockOnClose).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("modal does not close when clicking inside modal content", () => {
|
it("executes command on Enter key", async () => {
|
||||||
const tasks = [createMockTask("KB-001")];
|
mockUseTerminal.mockReturnValue(
|
||||||
|
createMockTerminalState({
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
input: "ls -la",
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
})
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const modal = screen.getByTestId("terminal-modal");
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
fireEvent.click(modal);
|
|
||||||
|
|
||||||
expect(mockOnClose).not.toHaveBeenCalled();
|
const input = screen.getByTestId("terminal-input");
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockExecuteCommand).toHaveBeenCalledWith("ls -la");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("modal closes on close button click", () => {
|
it("does not execute empty command on Enter", () => {
|
||||||
const tasks = [createMockTask("KB-001")];
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
const input = screen.getByTestId("terminal-input");
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
|
||||||
fireEvent.click(closeBtn);
|
|
||||||
|
|
||||||
expect(mockOnClose).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays task information in toolbar", () => {
|
it("navigates history on up arrow", () => {
|
||||||
const tasks = [createMockTask("KB-001", "My Test Task")];
|
mockUseTerminal.mockReturnValue(
|
||||||
|
createMockTerminalState({
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
input: "current",
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
})
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
);
|
||||||
|
mockNavigateHistory.mockReturnValue("previous");
|
||||||
|
|
||||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
expect(screen.getByTestId("terminal-active-task-title").textContent).toBe("My Test Task");
|
|
||||||
|
const input = screen.getByTestId("terminal-input");
|
||||||
|
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||||
|
|
||||||
|
expect(mockNavigateHistory).toHaveBeenCalledWith("up", "current");
|
||||||
|
expect(mockSetInput).toHaveBeenCalledWith("previous");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses description as title fallback when title is not provided", () => {
|
it("navigates history on down arrow", () => {
|
||||||
const tasks = [{
|
mockUseTerminal.mockReturnValue(
|
||||||
...createMockTask("KB-001"),
|
createMockTerminalState({
|
||||||
title: undefined,
|
input: "current",
|
||||||
description: "My Description",
|
})
|
||||||
}];
|
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
);
|
||||||
|
mockNavigateHistory.mockReturnValue("next");
|
||||||
|
|
||||||
expect(screen.getByTestId("terminal-active-task-title").textContent).toBe("My Description");
|
render(<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");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes correct entries to AgentLogViewer", () => {
|
it("clears history on Ctrl+L", () => {
|
||||||
const tasks = [createMockTask("KB-001")];
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
const entries = [
|
|
||||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: "log1", type: "text" as const },
|
|
||||||
{ timestamp: "2026-01-01T00:01:00Z", taskId: "KB-001", text: "log2", type: "tool" as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
const input = screen.getByTestId("terminal-input");
|
||||||
"KB-001": { entries, loading: false, clear: vi.fn() },
|
fireEvent.keyDown(input, { key: "l", ctrlKey: true });
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
expect(mockClearHistory).toHaveBeenCalled();
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("agent-log-viewer")).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes loading state to AgentLogViewer", () => {
|
it("kills running command on Ctrl+C when running", async () => {
|
||||||
const tasks = [createMockTask("KB-001")];
|
mockUseTerminal.mockReturnValue(
|
||||||
|
createMockTerminalState({
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
isRunning: true,
|
||||||
"KB-001": { entries: [], loading: true, clear: vi.fn() },
|
})
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("agent-log-viewer")).toBeTruthy();
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
|
|
||||||
|
const input = screen.getByTestId("terminal-input");
|
||||||
|
fireEvent.keyDown(input, { key: "c", ctrlKey: true });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockKillCurrentCommand).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to first task when active task is removed from list", () => {
|
it("shows kill button when command is running", () => {
|
||||||
const tasks = [
|
mockUseTerminal.mockReturnValue(
|
||||||
createMockTask("KB-001"),
|
createMockTerminalState({
|
||||||
createMockTask("KB-002"),
|
isRunning: true,
|
||||||
];
|
})
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { rerender } = render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initially KB-001 should be active
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
|
||||||
|
|
||||||
// Click KB-002 to make it active
|
expect(screen.getByTestId("terminal-kill-btn")).toBeTruthy();
|
||||||
act(() => {
|
|
||||||
screen.getByTestId("terminal-tab-KB-002").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-002");
|
|
||||||
|
|
||||||
// Rerender with only KB-001
|
|
||||||
rerender(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={[tasks[0]]} />
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should switch back to KB-001
|
|
||||||
expect(screen.getByTestId("terminal-active-task-id").textContent).toBe("KB-001");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tab labels show task IDs", () => {
|
it("hides kill button when not running", () => {
|
||||||
const tasks = [
|
mockUseTerminal.mockReturnValue(
|
||||||
createMockTask("KB-001"),
|
createMockTerminalState({
|
||||||
createMockTask("KB-002"),
|
isRunning: false,
|
||||||
];
|
})
|
||||||
|
|
||||||
mockUseMultiAgentLogs.mockReturnValue({
|
|
||||||
"KB-001": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
"KB-002": { entries: [], loading: false, clear: vi.fn() },
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TerminalModal isOpen={true} onClose={mockOnClose} tasks={tasks} />
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const tab1 = screen.getByTestId("terminal-tab-KB-001");
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
const tab2 = screen.getByTestId("terminal-tab-KB-002");
|
|
||||||
|
|
||||||
expect(tab1.textContent).toContain("KB-001");
|
expect(screen.queryByTestId("terminal-kill-btn")).toBeNull();
|
||||||
expect(tab2.textContent).toContain("KB-002");
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,222 +1,238 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { execTerminalCommand, killTerminalSession, getTerminalStreamUrl } from "../api";
|
import { execTerminalCommand, killTerminalSession, getTerminalStreamUrl } from "../api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single command entry in terminal history.
|
* Represents a single command execution entry in terminal history.
|
||||||
*/
|
*/
|
||||||
export interface TerminalHistoryEntry {
|
export interface TerminalHistoryEntry {
|
||||||
/** The command that was executed */
|
id: string;
|
||||||
command: string;
|
command: string;
|
||||||
/** Combined stdout/stderr output */
|
|
||||||
output: string;
|
output: string;
|
||||||
/** Exit code (null if still running) */
|
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
/** Whether the command is currently running */
|
|
||||||
isRunning: boolean;
|
|
||||||
/** Timestamp when command was executed */
|
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
|
isRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal state managed by the useTerminal hook.
|
* State of the current terminal session.
|
||||||
*/
|
*/
|
||||||
export interface TerminalState {
|
export interface TerminalState {
|
||||||
/** Command history - newest entries at the end */
|
/** Command history entries */
|
||||||
history: TerminalHistoryEntry[];
|
history: TerminalHistoryEntry[];
|
||||||
/** Current input value */
|
/** Currently active session ID (null if no running command) */
|
||||||
input: string;
|
currentSessionId: string | null;
|
||||||
/** Whether a command is currently executing */
|
/** Whether a command is currently executing */
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
/** ID of the current session (if running) */
|
/** Current input value in the terminal */
|
||||||
currentSessionId: string | null;
|
inputValue: string;
|
||||||
/** Current working directory (tracked via cd commands) */
|
/** Index for navigating command history with up/down arrows (-1 means not navigating) */
|
||||||
currentDirectory: string;
|
historyIndex: number;
|
||||||
|
/** Error message if something went wrong */
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Actions provided by the useTerminal hook.
|
* Actions available from the useTerminal hook.
|
||||||
*/
|
*/
|
||||||
export interface TerminalActions {
|
export interface TerminalActions {
|
||||||
/** Execute a command */
|
/** Execute a command in the terminal */
|
||||||
executeCommand: (command: string) => Promise<void>;
|
executeCommand: (command: string) => Promise<void>;
|
||||||
/** Clear command history */
|
/** Clear the terminal history */
|
||||||
clearHistory: () => void;
|
clearHistory: () => void;
|
||||||
/** Set input value */
|
|
||||||
setInput: (input: string) => void;
|
|
||||||
/** Kill the currently running command */
|
/** Kill the currently running command */
|
||||||
killCurrentCommand: () => Promise<void>;
|
killCurrentCommand: () => Promise<void>;
|
||||||
/** Navigate command history (for up/down arrow) */
|
/** Set the input value */
|
||||||
navigateHistory: (direction: "up" | "down") => string | null;
|
setInputValue: (value: string) => void;
|
||||||
|
/** Navigate to previous command in history (for up arrow) */
|
||||||
|
navigateHistoryUp: () => string | null;
|
||||||
|
/** Navigate to next command in history (for down arrow) */
|
||||||
|
navigateHistoryDown: () => string | null;
|
||||||
|
/** Reset history navigation */
|
||||||
|
resetHistoryNavigation: () => void;
|
||||||
|
/** Clear the error message */
|
||||||
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing an interactive terminal session.
|
* Hook for managing an interactive terminal session.
|
||||||
* Handles command execution, output streaming via SSE, and history management.
|
*
|
||||||
|
* Features:
|
||||||
|
* - Execute shell commands with real-time output streaming via SSE
|
||||||
|
* - Command history with Up/Down arrow navigation
|
||||||
|
* - Kill running processes
|
||||||
|
* - Clear history
|
||||||
|
* - Automatic cleanup on unmount
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { history, isRunning, inputValue, setInputValue, executeCommand, clearHistory } = useTerminal();
|
||||||
|
*
|
||||||
|
* // In your component:
|
||||||
|
* <input
|
||||||
|
* value={inputValue}
|
||||||
|
* onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
* onKeyDown={(e) => {
|
||||||
|
* if (e.key === 'Enter') executeCommand(inputValue);
|
||||||
|
* if (e.key === 'ArrowUp') navigateHistoryUp();
|
||||||
|
* if (e.key === 'ArrowDown') navigateHistoryDown();
|
||||||
|
* }}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useTerminal(): TerminalState & TerminalActions {
|
export function useTerminal(): TerminalState & TerminalActions {
|
||||||
|
// History of executed commands
|
||||||
const [history, setHistory] = useState<TerminalHistoryEntry[]>([]);
|
const [history, setHistory] = useState<TerminalHistoryEntry[]>([]);
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
// Current session tracking
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
const [currentDirectory, setCurrentDirectory] = useState("~");
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
|
||||||
// Refs for managing SSE and history navigation
|
// Input state
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refs for managing SSE and abort controllers
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const historyIndexRef = useRef<number>(-1);
|
const currentEntryRef = useRef<TerminalHistoryEntry | null>(null);
|
||||||
const inputBeforeHistoryRef = useRef<string>("");
|
const historyRef = useRef(history);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Keep history ref in sync for access in event handlers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
historyRef.current = history;
|
||||||
// Kill any running command
|
}, [history]);
|
||||||
if (currentSessionId) {
|
|
||||||
killTerminalSession(currentSessionId).catch(() => {
|
|
||||||
// Ignore errors during cleanup
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Close SSE connection
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [currentSessionId]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a command by creating a session and streaming output via SSE.
|
* Execute a shell command in the terminal.
|
||||||
|
* Creates a new session and streams output via SSE.
|
||||||
*/
|
*/
|
||||||
const executeCommand = useCallback(async (command: string) => {
|
const executeCommand = useCallback(async (command: string) => {
|
||||||
const trimmedCommand = command.trim();
|
if (!command.trim() || isRunning) return;
|
||||||
if (!trimmedCommand || isRunning) return;
|
|
||||||
|
setError(null);
|
||||||
// Handle clear command locally - don't add to history at all
|
|
||||||
if (trimmedCommand === "clear" || trimmedCommand === "cls") {
|
|
||||||
setHistory([]);
|
|
||||||
historyIndexRef.current = -1;
|
|
||||||
setInput("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to history as running
|
|
||||||
const entry: TerminalHistoryEntry = {
|
|
||||||
command: trimmedCommand,
|
|
||||||
output: "",
|
|
||||||
exitCode: null,
|
|
||||||
isRunning: true,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setHistory((prev) => [...prev, entry]);
|
|
||||||
setIsRunning(true);
|
|
||||||
setInput("");
|
|
||||||
historyIndexRef.current = -1;
|
|
||||||
|
|
||||||
// Handle cd commands locally to track directory
|
|
||||||
if (trimmedCommand.startsWith("cd ") || trimmedCommand === "cd") {
|
|
||||||
const newDir = trimmedCommand === "cd" ? "~" : trimmedCommand.slice(3).trim();
|
|
||||||
setCurrentDirectory(newDir);
|
|
||||||
setHistory((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const lastEntry = updated[updated.length - 1];
|
|
||||||
if (lastEntry) {
|
|
||||||
lastEntry.output = "";
|
|
||||||
lastEntry.exitCode = 0;
|
|
||||||
lastEntry.isRunning = false;
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setIsRunning(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create session
|
// Create new history entry
|
||||||
const { sessionId } = await execTerminalCommand(trimmedCommand);
|
const entry: TerminalHistoryEntry = {
|
||||||
setCurrentSessionId(sessionId);
|
id: crypto.randomUUID(),
|
||||||
|
command: command.trim(),
|
||||||
// Open SSE connection
|
output: "",
|
||||||
const eventSource = new EventSource(getTerminalStreamUrl(sessionId));
|
exitCode: null,
|
||||||
eventSourceRef.current = eventSource;
|
timestamp: new Date(),
|
||||||
|
isRunning: true,
|
||||||
// Collect output
|
|
||||||
let output = "";
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (event.type === "terminal:output") {
|
|
||||||
output += data.data;
|
|
||||||
// Update history with new output
|
|
||||||
setHistory((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const lastEntry = updated[updated.length - 1];
|
|
||||||
if (lastEntry) {
|
|
||||||
lastEntry.output = output;
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
} else if (event.type === "terminal:exit") {
|
|
||||||
// Command completed
|
|
||||||
setHistory((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const lastEntry = updated[updated.length - 1];
|
|
||||||
if (lastEntry) {
|
|
||||||
lastEntry.exitCode = data.exitCode ?? 0;
|
|
||||||
lastEntry.isRunning = false;
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setIsRunning(false);
|
|
||||||
setCurrentSessionId(null);
|
|
||||||
eventSource.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
} else if (event.type === "terminal:error") {
|
|
||||||
// Error from server
|
|
||||||
output += `\n[Error: ${data.message}]\n`;
|
|
||||||
setHistory((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const lastEntry = updated[updated.length - 1];
|
|
||||||
if (lastEntry) {
|
|
||||||
lastEntry.output = output;
|
|
||||||
lastEntry.exitCode = 1;
|
|
||||||
lastEntry.isRunning = false;
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setIsRunning(false);
|
|
||||||
setCurrentSessionId(null);
|
|
||||||
eventSource.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
currentEntryRef.current = entry;
|
||||||
// Connection error or closed
|
setHistory((prev) => [...prev, entry]);
|
||||||
if (eventSourceRef.current === eventSource) {
|
setIsRunning(true);
|
||||||
|
setInputValue("");
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
|
||||||
|
// Execute command via API
|
||||||
|
const { sessionId } = await execTerminalCommand(command.trim());
|
||||||
|
setCurrentSessionId(sessionId);
|
||||||
|
|
||||||
|
// Connect to SSE stream
|
||||||
|
const streamUrl = getTerminalStreamUrl(sessionId);
|
||||||
|
const es = new EventSource(streamUrl);
|
||||||
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
es.addEventListener("connected", () => {
|
||||||
|
// Connection established - ready to receive output
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("terminal:output", (e) => {
|
||||||
|
try {
|
||||||
|
const { type, data } = JSON.parse(e.data) as { type: "stdout" | "stderr"; data: string };
|
||||||
|
|
||||||
|
setHistory((prev) => {
|
||||||
|
const lastEntry = prev[prev.length - 1];
|
||||||
|
if (!lastEntry || !lastEntry.isRunning) return prev;
|
||||||
|
|
||||||
|
const updatedEntry = {
|
||||||
|
...lastEntry,
|
||||||
|
output: lastEntry.output + data,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...prev.slice(0, -1), updatedEntry];
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Skip malformed events
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("terminal:exit", (e) => {
|
||||||
|
try {
|
||||||
|
const { exitCode } = JSON.parse(e.data) as { exitCode: number };
|
||||||
|
|
||||||
|
setHistory((prev) => {
|
||||||
|
const lastEntry = prev[prev.length - 1];
|
||||||
|
if (!lastEntry || !lastEntry.isRunning) return prev;
|
||||||
|
|
||||||
|
const updatedEntry = {
|
||||||
|
...lastEntry,
|
||||||
|
exitCode,
|
||||||
|
isRunning: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...prev.slice(0, -1), updatedEntry];
|
||||||
|
});
|
||||||
|
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
|
currentEntryRef.current = null;
|
||||||
|
|
||||||
|
// Close the SSE connection
|
||||||
|
es.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
} catch {
|
||||||
|
// Skip malformed events
|
||||||
}
|
}
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
// Execution failed
|
|
||||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
|
||||||
setHistory((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const lastEntry = updated[updated.length - 1];
|
|
||||||
if (lastEntry) {
|
|
||||||
lastEntry.output = `Error: ${errorMessage}`;
|
|
||||||
lastEntry.exitCode = 1;
|
|
||||||
lastEntry.isRunning = false;
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
es.addEventListener("error", () => {
|
||||||
|
// Connection error - mark command as failed
|
||||||
|
setHistory((prev) => {
|
||||||
|
const lastEntry = prev[prev.length - 1];
|
||||||
|
if (!lastEntry || !lastEntry.isRunning) return prev;
|
||||||
|
|
||||||
|
const updatedEntry = {
|
||||||
|
...lastEntry,
|
||||||
|
exitCode: -1,
|
||||||
|
isRunning: false,
|
||||||
|
output: lastEntry.output + "\n[Connection lost]\n",
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...prev.slice(0, -1), updatedEntry];
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsRunning(false);
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
currentEntryRef.current = null;
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to execute command");
|
||||||
|
|
||||||
|
// Mark entry as failed
|
||||||
|
setHistory((prev) => {
|
||||||
|
const lastEntry = prev[prev.length - 1];
|
||||||
|
if (!lastEntry || !lastEntry.isRunning) return prev;
|
||||||
|
|
||||||
|
const updatedEntry = {
|
||||||
|
...lastEntry,
|
||||||
|
exitCode: -1,
|
||||||
|
isRunning: false,
|
||||||
|
output: lastEntry.output + `\n[Error: ${err.message || "Failed to execute command"}]\n`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...prev.slice(0, -1), updatedEntry];
|
||||||
|
});
|
||||||
|
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
|
currentEntryRef.current = null;
|
||||||
}
|
}
|
||||||
}, [isRunning]);
|
}, [isRunning]);
|
||||||
|
|
||||||
@@ -225,32 +241,36 @@ export function useTerminal(): TerminalState & TerminalActions {
|
|||||||
*/
|
*/
|
||||||
const killCurrentCommand = useCallback(async () => {
|
const killCurrentCommand = useCallback(async () => {
|
||||||
if (!currentSessionId || !isRunning) return;
|
if (!currentSessionId || !isRunning) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await killTerminalSession(currentSessionId);
|
await killTerminalSession(currentSessionId, "SIGTERM");
|
||||||
|
|
||||||
// Close SSE connection
|
// Close SSE connection
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update history
|
// Update history entry
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = [...prev];
|
const lastEntry = prev[prev.length - 1];
|
||||||
const lastEntry = updated[updated.length - 1];
|
if (!lastEntry || !lastEntry.isRunning) return prev;
|
||||||
if (lastEntry) {
|
|
||||||
lastEntry.output += "\n[Process terminated]\n";
|
const updatedEntry = {
|
||||||
lastEntry.exitCode = 130; // SIGINT exit code
|
...lastEntry,
|
||||||
lastEntry.isRunning = false;
|
exitCode: 130, // Standard exit code for SIGINT
|
||||||
}
|
isRunning: false,
|
||||||
return updated;
|
output: lastEntry.output + "\n[Process terminated]\n",
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...prev.slice(0, -1), updatedEntry];
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
} catch {
|
currentEntryRef.current = null;
|
||||||
// Ignore errors - process might have already exited
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to kill process");
|
||||||
}
|
}
|
||||||
}, [currentSessionId, isRunning]);
|
}, [currentSessionId, isRunning]);
|
||||||
|
|
||||||
@@ -258,65 +278,109 @@ export function useTerminal(): TerminalState & TerminalActions {
|
|||||||
* Clear all command history.
|
* Clear all command history.
|
||||||
*/
|
*/
|
||||||
const clearHistory = useCallback(() => {
|
const clearHistory = useCallback(() => {
|
||||||
|
// Kill any running process first
|
||||||
|
if (isRunning && currentSessionId) {
|
||||||
|
killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
|
||||||
|
// Ignore errors during cleanup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
historyIndexRef.current = -1;
|
setCurrentSessionId(null);
|
||||||
|
setIsRunning(false);
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
currentEntryRef.current = null;
|
||||||
|
}, [isRunning, currentSessionId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to previous command in history (Up arrow).
|
||||||
|
* Returns the command string or null if no history.
|
||||||
|
*/
|
||||||
|
const navigateHistoryUp = useCallback(() => {
|
||||||
|
if (historyRef.current.length === 0) return null;
|
||||||
|
|
||||||
|
const newIndex = historyIndex + 1;
|
||||||
|
if (newIndex >= historyRef.current.length) return null;
|
||||||
|
|
||||||
|
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).
|
||||||
|
* Returns the command string or null if at end.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate command history with up/down arrows.
|
* Clear the error message.
|
||||||
* Returns the command to set in input, or null if no change.
|
|
||||||
*/
|
*/
|
||||||
const navigateHistory = useCallback((direction: "up" | "down", currentInput?: string): string | null => {
|
const clearError = useCallback(() => {
|
||||||
// Filter to commands with non-empty content (include running ones for navigation)
|
setError(null);
|
||||||
// Reverse to have newest first for navigation
|
}, []);
|
||||||
const commandHistory = history
|
|
||||||
.filter((h) => h.command.trim())
|
|
||||||
.map((h) => h.command)
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
if (commandHistory.length === 0) return null;
|
/**
|
||||||
|
* Cleanup on unmount - kill running process and close SSE.
|
||||||
// Get current input value (passed as param or from closure)
|
*/
|
||||||
const inputValue = currentInput ?? input;
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
if (direction === "up") {
|
// Kill any running process
|
||||||
// Save current input if starting navigation
|
if (currentSessionId) {
|
||||||
if (historyIndexRef.current === -1) {
|
killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
|
||||||
inputBeforeHistoryRef.current = inputValue;
|
// Ignore errors during cleanup
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move up in history (towards more recent commands)
|
// Close SSE connection
|
||||||
const newIndex = historyIndexRef.current + 1;
|
if (eventSourceRef.current) {
|
||||||
if (newIndex < commandHistory.length) {
|
eventSourceRef.current.close();
|
||||||
historyIndexRef.current = newIndex;
|
|
||||||
return commandHistory[newIndex];
|
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
// Move down in history (towards older commands or back to input)
|
}, [currentSessionId]);
|
||||||
const newIndex = historyIndexRef.current - 1;
|
|
||||||
if (newIndex >= 0) {
|
|
||||||
historyIndexRef.current = newIndex;
|
|
||||||
return commandHistory[newIndex];
|
|
||||||
} else if (newIndex === -1) {
|
|
||||||
// Back to original input
|
|
||||||
historyIndexRef.current = -1;
|
|
||||||
return inputBeforeHistoryRef.current;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [history, input]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// State
|
||||||
history,
|
history,
|
||||||
input,
|
|
||||||
isRunning,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
currentDirectory,
|
isRunning,
|
||||||
|
inputValue,
|
||||||
|
historyIndex,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Actions
|
||||||
executeCommand,
|
executeCommand,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
setInput,
|
|
||||||
killCurrentCommand,
|
killCurrentCommand,
|
||||||
navigateHistory,
|
setInputValue,
|
||||||
|
navigateHistoryUp,
|
||||||
|
navigateHistoryDown,
|
||||||
|
resetHistoryNavigation,
|
||||||
|
clearError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user