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 287ce1d6d2
commit b8de98cc6d
3 changed files with 722 additions and 594 deletions

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";
/**
* Single command entry in terminal history.
* Represents a single command execution entry in terminal history.
*/
export interface TerminalHistoryEntry {
/** The command that was executed */
id: string;
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;
isRunning: boolean;
}
/**
* Terminal state managed by the useTerminal hook.
* State of the current terminal session.
*/
export interface TerminalState {
/** Command history - newest entries at the end */
/** Command history entries */
history: TerminalHistoryEntry[];
/** Current input value */
input: string;
/** Currently active session ID (null if no running command) */
currentSessionId: string | null;
/** 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;
/** Current input value in the terminal */
inputValue: string;
/** Index for navigating command history with up/down arrows (-1 means not navigating) */
historyIndex: number;
/** Error message if something went wrong */
error: string | null;
}
/**
* Actions provided by the useTerminal hook.
* Actions available from the useTerminal hook.
*/
export interface TerminalActions {
/** Execute a command */
/** Execute a command in the terminal */
executeCommand: (command: string) => Promise<void>;
/** Clear command history */
/** Clear the terminal 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;
/** Set the input value */
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.
* 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 {
// History of executed commands
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 [currentDirectory, setCurrentDirectory] = useState("~");
// Refs for managing SSE and history navigation
const [isRunning, setIsRunning] = useState(false);
// 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 historyIndexRef = useRef<number>(-1);
const inputBeforeHistoryRef = useRef<string>("");
// Cleanup on unmount
const currentEntryRef = useRef<TerminalHistoryEntry | null>(null);
const historyRef = useRef(history);
// Keep history ref in sync for access in event handlers
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]);
historyRef.current = history;
}, [history]);
/**
* 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 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;
}
if (!command.trim() || isRunning) return;
setError(null);
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
}
// Create new history entry
const entry: TerminalHistoryEntry = {
id: crypto.randomUUID(),
command: command.trim(),
output: "",
exitCode: null,
timestamp: new Date(),
isRunning: true,
};
eventSource.onerror = () => {
// Connection error or closed
if (eventSourceRef.current === eventSource) {
currentEntryRef.current = entry;
setHistory((prev) => [...prev, entry]);
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);
setCurrentSessionId(null);
currentEntryRef.current = null;
// Close the SSE connection
es.close();
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);
setCurrentSessionId(null);
currentEntryRef.current = null;
}
}, [isRunning]);
@@ -225,32 +241,36 @@ export function useTerminal(): TerminalState & TerminalActions {
*/
const killCurrentCommand = useCallback(async () => {
if (!currentSessionId || !isRunning) return;
try {
await killTerminalSession(currentSessionId);
await killTerminalSession(currentSessionId, "SIGTERM");
// Close SSE connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// Update history
// Update history entry
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;
const lastEntry = prev[prev.length - 1];
if (!lastEntry || !lastEntry.isRunning) return prev;
const updatedEntry = {
...lastEntry,
exitCode: 130, // Standard exit code for SIGINT
isRunning: false,
output: lastEntry.output + "\n[Process terminated]\n",
};
return [...prev.slice(0, -1), updatedEntry];
});
setIsRunning(false);
setCurrentSessionId(null);
} catch {
// Ignore errors - process might have already exited
currentEntryRef.current = null;
} catch (err: any) {
setError(err.message || "Failed to kill process");
}
}, [currentSessionId, isRunning]);
@@ -258,65 +278,109 @@ export function useTerminal(): TerminalState & TerminalActions {
* Clear all command history.
*/
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([]);
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.
* Returns the command to set in input, or null if no change.
* Clear the error message.
*/
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();
const clearError = useCallback(() => {
setError(null);
}, []);
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;
/**
* Cleanup on unmount - kill running process and close SSE.
*/
useEffect(() => {
return () => {
// Kill any running process
if (currentSessionId) {
killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
// Ignore errors during cleanup
});
}
// Move up in history (towards more recent commands)
const newIndex = historyIndexRef.current + 1;
if (newIndex < commandHistory.length) {
historyIndexRef.current = newIndex;
return commandHistory[newIndex];
// Close SSE connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
} 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]);
};
}, [currentSessionId]);
return {
// State
history,
input,
isRunning,
currentSessionId,
currentDirectory,
isRunning,
inputValue,
historyIndex,
error,
// Actions
executeCommand,
clearHistory,
setInput,
killCurrentCommand,
navigateHistory,
setInputValue,
navigateHistoryUp,
navigateHistoryDown,
resetHistoryNavigation,
clearError,
};
}