feat(KB-057): complete Step 2 — useTerminal hook with history, SSE streaming, and command execution

This commit is contained in:
gsxdsm
2026-03-29 20:57:18 -07:00
parent c5b09e1cb1
commit 287ce1d6d2
6 changed files with 827 additions and 3 deletions

View File

@@ -63,6 +63,12 @@ export function TaskCard({
const titleInputRef = useRef<HTMLInputElement>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
const touchOpenHandledRef = useRef(false);
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) return false;
return !!target.closest("button, a, input, textarea, select, label, [role='button']");
}, []);
// Reset edit state when task changes
useEffect(() => {
@@ -134,6 +140,21 @@ export function TaskCard({
}
}, [task.id, onOpenDetail, addToast, isEditing]);
const handleCardClick = useCallback((e: React.MouseEvent) => {
if (touchOpenHandledRef.current) {
touchOpenHandledRef.current = false;
return;
}
if (isInteractiveTarget(e.target)) return;
handleClick();
}, [handleClick, isInteractiveTarget]);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (isInteractiveTarget(e.target)) return;
touchOpenHandledRef.current = true;
handleClick();
}, [handleClick, isInteractiveTarget]);
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
e.stopPropagation(); // Prevent card click
try {
@@ -308,7 +329,8 @@ export function TaskCard({
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
onClick={handleClick}
onClick={handleCardClick}
onTouchEnd={handleTouchEnd}
onDoubleClick={handleDoubleClick}
>
<div className="card-header">

View File

@@ -0,0 +1,409 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useTerminal } from "../useTerminal";
import * as apiModule from "../../api";
// Mock the API module
vi.mock("../../api", () => ({
execTerminalCommand: vi.fn(),
killTerminalSession: vi.fn(),
getTerminalStreamUrl: vi.fn((id) => `/api/terminal/sessions/${id}/stream`),
}));
// Mock EventSource
class MockEventSource {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onopen: (() => void) | null = null;
url: string;
constructor(url: string) {
this.url = url;
// Simulate connection opening
setTimeout(() => {
if (this.onopen) this.onopen();
}, 0);
}
close() {
// Clean up
}
// Helper for tests to simulate messages
simulateMessage(data: unknown, eventType = "message") {
// Create a proper MessageEvent-like object
const event = {
type: eventType,
data: typeof data === "string" ? data : JSON.stringify(data),
lastEventId: "",
origin: "",
ports: [],
source: null,
bubbles: false,
cancelable: false,
composed: false,
initEvent: () => {},
preventDefault: () => {},
stopImmediatePropagation: () => {},
stopPropagation: () => {},
currentTarget: null,
target: null,
timeStamp: Date.now(),
eventPhase: 0,
isTrusted: true,
returnValue: true,
srcElement: null,
nativeEvent: undefined,
isDefaultPrevented: () => false,
isPropagationStopped: () => false,
persist: () => {},
} as unknown as MessageEvent;
if (this.onmessage) this.onmessage(event);
}
simulateError() {
if (this.onerror) this.onerror();
}
}
global.EventSource = MockEventSource as unknown as typeof EventSource;
const mockExecTerminalCommand = vi.mocked(apiModule.execTerminalCommand);
const mockKillTerminalSession = vi.mocked(apiModule.killTerminalSession);
const mockGetTerminalStreamUrl = vi.mocked(apiModule.getTerminalStreamUrl);
describe("useTerminal", () => {
beforeEach(() => {
mockExecTerminalCommand.mockReset();
mockKillTerminalSession.mockReset();
mockKillTerminalSession.mockResolvedValue({ killed: true, sessionId: "test-id" });
mockGetTerminalStreamUrl.mockReset();
mockGetTerminalStreamUrl.mockReturnValue("/api/terminal/sessions/test-id/stream");
});
afterEach(() => {
vi.clearAllMocks();
});
it("initializes with empty state", () => {
const { result } = renderHook(() => useTerminal());
expect(result.current.history).toEqual([]);
expect(result.current.input).toBe("");
expect(result.current.isRunning).toBe(false);
expect(result.current.currentSessionId).toBeNull();
expect(result.current.currentDirectory).toBe("~");
});
it("sets input value", () => {
const { result } = renderHook(() => useTerminal());
act(() => {
result.current.setInput("ls -la");
});
expect(result.current.input).toBe("ls -la");
});
it("executes command and adds to history", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("ls");
});
expect(mockExecTerminalCommand).toHaveBeenCalledWith("ls");
expect(result.current.history).toHaveLength(1);
expect(result.current.history[0]?.command).toBe("ls");
// Wait for isRunning to be set
await waitFor(() => {
expect(result.current.isRunning).toBe(true);
});
expect(result.current.currentSessionId).toBe("test-id");
});
it("does not execute empty commands", async () => {
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand(" ");
});
expect(mockExecTerminalCommand).not.toHaveBeenCalled();
expect(result.current.history).toHaveLength(0);
});
it("does not execute while another command is running", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("sleep 10");
});
// Try to execute another command while first is running
await act(async () => {
await result.current.executeCommand("ls");
});
expect(mockExecTerminalCommand).toHaveBeenCalledTimes(1);
});
it("clears command history", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("ls");
});
expect(result.current.history).toHaveLength(1);
act(() => {
result.current.clearHistory();
});
expect(result.current.history).toHaveLength(0);
});
it("handles cd command locally", async () => {
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("cd /some/path");
});
expect(mockExecTerminalCommand).not.toHaveBeenCalled();
expect(result.current.currentDirectory).toBe("/some/path");
expect(result.current.history).toHaveLength(1);
expect(result.current.history[0]?.exitCode).toBe(0);
});
it("handles cd without args as going to home", async () => {
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("cd /some/path");
});
expect(result.current.currentDirectory).toBe("/some/path");
await act(async () => {
await result.current.executeCommand("cd");
});
expect(result.current.currentDirectory).toBe("~");
});
it("handles clear command locally", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("ls");
});
expect(result.current.history).toHaveLength(1);
await act(async () => {
await result.current.executeCommand("clear");
});
await waitFor(() => {
expect(result.current.history).toHaveLength(0);
});
expect(mockExecTerminalCommand).toHaveBeenCalledTimes(1); // Only for ls
});
it("handles cls command as clear", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("ls");
});
await act(async () => {
await result.current.executeCommand("cls");
});
await waitFor(() => {
expect(result.current.history).toHaveLength(0);
});
});
it("clears input after executing command", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
act(() => {
result.current.setInput("ls -la");
});
await act(async () => {
await result.current.executeCommand(result.current.input);
});
expect(result.current.input).toBe("");
});
it("handles command execution error", async () => {
mockExecTerminalCommand.mockRejectedValue(new Error("Command not allowed"));
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("rm -rf /");
});
expect(result.current.history).toHaveLength(1);
expect(result.current.history[0]?.exitCode).toBe(1);
expect(result.current.history[0]?.output).toContain("Command not allowed");
expect(result.current.isRunning).toBe(false);
});
it("kills current command", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
mockKillTerminalSession.mockResolvedValue({ killed: true, sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("sleep 10");
});
// Wait for isRunning to be true
await waitFor(() => {
expect(result.current.isRunning).toBe(true);
});
await act(async () => {
await result.current.killCurrentCommand();
});
expect(mockKillTerminalSession).toHaveBeenCalledWith("test-id");
// Wait for isRunning to be false after killing
await waitFor(() => {
expect(result.current.isRunning).toBe(false);
});
expect(result.current.history[0]?.exitCode).toBe(130);
});
it("does not kill if no command is running", async () => {
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.killCurrentCommand();
});
expect(mockKillTerminalSession).not.toHaveBeenCalled();
});
it("navigates history with up arrow", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("first");
});
await act(async () => {
await result.current.executeCommand("second");
});
// Simulate up arrow - should return most recent command (second)
let historyCmd: string | null = null;
act(() => {
historyCmd = result.current.navigateHistory("up", result.current.input);
});
expect(historyCmd).toBe("second");
// Another up arrow - should go to older command (first)
act(() => {
historyCmd = result.current.navigateHistory("up", result.current.input);
});
expect(historyCmd).toBe("first");
});
it("navigates history with down arrow", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand("first");
});
await act(async () => {
await result.current.executeCommand("second");
});
// Type something before navigating history (need to set it explicitly since executeCommand clears input)
act(() => {
result.current.setInput("typing...");
});
// Navigate up twice to get to oldest command
act(() => {
result.current.navigateHistory("up", result.current.input);
});
act(() => {
result.current.navigateHistory("up", result.current.input);
});
// Navigate down - should return more recent command (second)
let historyCmd: string | null = null;
act(() => {
historyCmd = result.current.navigateHistory("down", result.current.input);
});
expect(historyCmd).toBe("second");
// Navigate down past start - should restore original input that was typed
act(() => {
historyCmd = result.current.navigateHistory("down", result.current.input);
});
expect(historyCmd).toBe("typing...");
});
it("returns null when navigating empty history", () => {
const { result } = renderHook(() => useTerminal());
let historyCmd: string | null = "not-null";
act(() => {
historyCmd = result.current.navigateHistory("up");
});
expect(historyCmd).toBeNull();
});
it("trims commands before execution", async () => {
mockExecTerminalCommand.mockResolvedValue({ sessionId: "test-id" });
const { result } = renderHook(() => useTerminal());
await act(async () => {
await result.current.executeCommand(" ls ");
});
expect(mockExecTerminalCommand).toHaveBeenCalledWith("ls");
expect(result.current.history[0]?.command).toBe("ls");
});
});

View File

@@ -0,0 +1,322 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { execTerminalCommand, killTerminalSession, getTerminalStreamUrl } from "../api";
/**
* Single command entry in terminal history.
*/
export interface TerminalHistoryEntry {
/** The command that was executed */
command: string;
/** Combined stdout/stderr output */
output: string;
/** Exit code (null if still running) */
exitCode: number | null;
/** Whether the command is currently running */
isRunning: boolean;
/** Timestamp when command was executed */
timestamp: Date;
}
/**
* Terminal state managed by the useTerminal hook.
*/
export interface TerminalState {
/** Command history - newest entries at the end */
history: TerminalHistoryEntry[];
/** Current input value */
input: string;
/** Whether a command is currently executing */
isRunning: boolean;
/** ID of the current session (if running) */
currentSessionId: string | null;
/** Current working directory (tracked via cd commands) */
currentDirectory: string;
}
/**
* Actions provided by the useTerminal hook.
*/
export interface TerminalActions {
/** Execute a command */
executeCommand: (command: string) => Promise<void>;
/** Clear command history */
clearHistory: () => void;
/** Set input value */
setInput: (input: string) => void;
/** Kill the currently running command */
killCurrentCommand: () => Promise<void>;
/** Navigate command history (for up/down arrow) */
navigateHistory: (direction: "up" | "down") => string | null;
}
/**
* Hook for managing an interactive terminal session.
* Handles command execution, output streaming via SSE, and history management.
*/
export function useTerminal(): TerminalState & TerminalActions {
const [history, setHistory] = useState<TerminalHistoryEntry[]>([]);
const [input, setInput] = useState("");
const [isRunning, setIsRunning] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [currentDirectory, setCurrentDirectory] = useState("~");
// Refs for managing SSE and history navigation
const eventSourceRef = useRef<EventSource | null>(null);
const historyIndexRef = useRef<number>(-1);
const inputBeforeHistoryRef = useRef<string>("");
// Cleanup on unmount
useEffect(() => {
return () => {
// Kill any running command
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.
*/
const executeCommand = useCallback(async (command: string) => {
const trimmedCommand = command.trim();
if (!trimmedCommand || isRunning) return;
// 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 {
// Create session
const { sessionId } = await execTerminalCommand(trimmedCommand);
setCurrentSessionId(sessionId);
// Open SSE connection
const eventSource = new EventSource(getTerminalStreamUrl(sessionId));
eventSourceRef.current = eventSource;
// 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 = () => {
// Connection error or closed
if (eventSourceRef.current === eventSource) {
setIsRunning(false);
setCurrentSessionId(null);
eventSourceRef.current = null;
}
};
} 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;
});
setIsRunning(false);
setCurrentSessionId(null);
}
}, [isRunning]);
/**
* Kill the currently running command.
*/
const killCurrentCommand = useCallback(async () => {
if (!currentSessionId || !isRunning) return;
try {
await killTerminalSession(currentSessionId);
// Close SSE connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// Update history
setHistory((prev) => {
const updated = [...prev];
const lastEntry = updated[updated.length - 1];
if (lastEntry) {
lastEntry.output += "\n[Process terminated]\n";
lastEntry.exitCode = 130; // SIGINT exit code
lastEntry.isRunning = false;
}
return updated;
});
setIsRunning(false);
setCurrentSessionId(null);
} catch {
// Ignore errors - process might have already exited
}
}, [currentSessionId, isRunning]);
/**
* Clear all command history.
*/
const clearHistory = useCallback(() => {
setHistory([]);
historyIndexRef.current = -1;
}, []);
/**
* Navigate command history with up/down arrows.
* Returns the command to set in input, or null if no change.
*/
const navigateHistory = useCallback((direction: "up" | "down", currentInput?: string): string | null => {
// Filter to commands with non-empty content (include running ones for navigation)
// 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;
// Get current input value (passed as param or from closure)
const inputValue = currentInput ?? input;
if (direction === "up") {
// Save current input if starting navigation
if (historyIndexRef.current === -1) {
inputBeforeHistoryRef.current = inputValue;
}
// Move up in history (towards more recent commands)
const newIndex = historyIndexRef.current + 1;
if (newIndex < commandHistory.length) {
historyIndexRef.current = newIndex;
return commandHistory[newIndex];
}
} else {
// Move down in history (towards older commands or back to input)
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 {
history,
input,
isRunning,
currentSessionId,
currentDirectory,
executeCommand,
clearHistory,
setInput,
killCurrentCommand,
navigateHistory,
};
}