feat(KB-057): complete Step 3 — TerminalModal component with interactive shell UI

This commit is contained in:
gsxdsm
2026-03-29 20:59:31 -07:00
parent 1698cae66e
commit e24050de69
3 changed files with 722 additions and 594 deletions

View File

@@ -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 // Auto-scroll to bottom when history changes
const inProgressTaskIds = tasks.map((t) => t.id);
// Get log state for all tasks
const logState = useMultiAgentLogs(inProgressTaskIds);
// Set initial active task when modal opens
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>

View File

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

View File

@@ -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;
// Handle clear command locally - don't add to history at all setError(null);
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 = {
id: crypto.randomUUID(),
command: command.trim(),
output: "",
exitCode: null,
timestamp: new Date(),
isRunning: true,
};
currentEntryRef.current = entry;
setHistory((prev) => [...prev, entry]);
setIsRunning(true);
setInputValue("");
setHistoryIndex(-1);
// Execute command via API
const { sessionId } = await execTerminalCommand(command.trim());
setCurrentSessionId(sessionId); setCurrentSessionId(sessionId);
// Open SSE connection // Connect to SSE stream
const eventSource = new EventSource(getTerminalStreamUrl(sessionId)); const streamUrl = getTerminalStreamUrl(sessionId);
eventSourceRef.current = eventSource; const es = new EventSource(streamUrl);
eventSourceRef.current = es;
// Collect output es.addEventListener("connected", () => {
let output = ""; // Connection established - ready to receive output
});
eventSource.onmessage = (event) => { es.addEventListener("terminal:output", (e) => {
try { try {
const data = JSON.parse(event.data); const { type, data } = JSON.parse(e.data) as { type: "stdout" | "stderr"; data: string };
if (event.type === "terminal:output") { setHistory((prev) => {
output += data.data; const lastEntry = prev[prev.length - 1];
// Update history with new output if (!lastEntry || !lastEntry.isRunning) return prev;
setHistory((prev) => {
const updated = [...prev]; const updatedEntry = {
const lastEntry = updated[updated.length - 1]; ...lastEntry,
if (lastEntry) { output: lastEntry.output + data,
lastEntry.output = output; };
}
return updated; return [...prev.slice(0, -1), updatedEntry];
}); });
} 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 { } catch {
// Ignore parse errors // 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];
});
eventSource.onerror = () => {
// Connection error or closed
if (eventSourceRef.current === eventSource) {
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]);
@@ -227,7 +243,7 @@ export function useTerminal(): TerminalState & TerminalActions {
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) {
@@ -235,22 +251,26 @@ export function useTerminal(): TerminalState & TerminalActions {
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,
}; };
} }