feat(KB-057): add interactive terminal to dashboard

- Add backend terminal API with SSE streaming for real-time output
- Create useTerminal hook with command history and navigation
- Refactor TerminalModal into interactive shell component
- Update App and Header for standalone terminal button
- Add CSS styles for terminal UI (prompts, output, spinner, welcome screen)
- Update tests for new terminal behavior
This commit is contained in:
gsxdsm
2026-03-29 21:09:57 -07:00
parent 5b575923b5
commit bfd3343be4
11 changed files with 763 additions and 1599 deletions

View File

@@ -23,16 +23,12 @@ export interface TerminalState {
currentSessionId: string | null;
/** Whether a command is currently executing */
isRunning: boolean;
/** Current input value in the terminal (alias for inputValue) */
input: 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;
/** Current working directory */
currentDirectory: string;
}
/**
@@ -47,10 +43,6 @@ export interface TerminalActions {
killCurrentCommand: () => Promise<void>;
/** Set the input value */
setInputValue: (value: string) => void;
/** Set the input value (alias for setInputValue) */
setInput: (value: string) => void;
/** Navigate command history with direction and current input */
navigateHistory: (direction: "up" | "down", currentInput?: string) => string | null;
/** Navigate to previous command in history (for up arrow) */
navigateHistoryUp: () => string | null;
/** Navigate to next command in history (for down arrow) */
@@ -73,16 +65,16 @@ export interface TerminalActions {
*
* @example
* ```tsx
* const { history, isRunning, input, setInput, executeCommand, clearHistory } = useTerminal();
* const { history, isRunning, inputValue, setInputValue, executeCommand, clearHistory } = useTerminal();
*
* // In your component:
* <input
* value={input}
* onChange={(e) => setInput(e.target.value)}
* value={inputValue}
* onChange={(e) => setInputValue(e.target.value)}
* onKeyDown={(e) => {
* if (e.key === 'Enter') executeCommand(input);
* if (e.key === 'ArrowUp') navigateHistory('up', input);
* if (e.key === 'ArrowDown') navigateHistory('down', input);
* if (e.key === 'Enter') executeCommand(inputValue);
* if (e.key === 'ArrowUp') navigateHistoryUp();
* if (e.key === 'ArrowDown') navigateHistoryDown();
* }}
* />
* ```
@@ -98,109 +90,24 @@ export function useTerminal(): TerminalState & TerminalActions {
// Input state
const [inputValue, setInputValue] = useState("");
const [historyIndex, setHistoryIndex] = useState(-1);
const [originalInput, setOriginalInput] = useState(""); // Store original input when navigating history
const [error, setError] = useState<string | null>(null);
// Current directory state (tracked locally for cd commands)
const [currentDirectory, setCurrentDirectory] = useState("~");
// Refs for managing SSE and abort controllers
const eventSourceRef = useRef<EventSource | null>(null);
const currentEntryRef = useRef<TerminalHistoryEntry | null>(null);
const historyRef = useRef(history);
const currentDirRef = useRef(currentDirectory);
const historyIndexRef = useRef(historyIndex); // Track historyIndex in ref for navigation
// Keep history ref in sync for access in event handlers
useEffect(() => {
historyRef.current = history;
}, [history]);
// Keep current dir ref in sync
useEffect(() => {
currentDirRef.current = currentDirectory;
}, [currentDirectory]);
// Keep historyIndex ref in sync
useEffect(() => {
historyIndexRef.current = historyIndex;
}, [historyIndex]);
/**
* Handle local commands (cd, clear, cls) without API call.
* Returns true if command was handled locally.
*/
const handleLocalCommand = useCallback((command: string): boolean => {
const trimmed = command.trim();
// Handle clear/cls commands locally - just clear history, don't add command
if (trimmed === "clear" || trimmed === "cls") {
setHistory([]);
setInputValue("");
setHistoryIndex(-1);
return true;
}
// Handle cd command locally
if (trimmed === "cd" || trimmed.startsWith("cd ")) {
const args = trimmed.slice(2).trim();
if (!args || args === "~" || args === "~/") {
setCurrentDirectory("~");
} else if (args.startsWith("/")) {
setCurrentDirectory(args);
} else if (args === "..") {
// Simple parent directory handling
if (currentDirRef.current === "~") {
setCurrentDirectory("~");
} else {
const parts = currentDirRef.current.split("/").filter(Boolean);
parts.pop();
setCurrentDirectory(parts.length === 0 ? "~" : "/" + parts.join("/"));
}
} else {
// Relative path
if (currentDirRef.current === "~") {
setCurrentDirectory("~/" + args);
} else if (currentDirRef.current === "/") {
setCurrentDirectory("/" + args);
} else {
setCurrentDirectory(currentDirRef.current + "/" + args);
}
}
// Add to history with success
const entry: TerminalHistoryEntry = {
id: crypto.randomUUID(),
command: trimmed,
output: "",
exitCode: 0,
timestamp: new Date(),
isRunning: false,
};
setHistory((prev) => [...prev, entry]);
return true;
}
return false;
}, []);
/**
* Execute a shell command in the terminal.
* Creates a new session and streams output via SSE.
*/
const executeCommand = useCallback(async (command: string) => {
if (!command.trim()) return;
// Try to handle local commands first (these can run even while another command is running)
if (handleLocalCommand(command)) {
setInputValue("");
setHistoryIndex(-1);
return;
}
// For API commands, check if one is already running
if (isRunning) return;
if (!command.trim() || isRunning) return;
setError(null);
@@ -219,7 +126,6 @@ export function useTerminal(): TerminalState & TerminalActions {
setHistory((prev) => [...prev, entry]);
setIsRunning(true);
setInputValue("");
setOriginalInput(""); // Clear original input on new command
setHistoryIndex(-1);
// Execute command via API
@@ -309,14 +215,14 @@ export function useTerminal(): TerminalState & TerminalActions {
} catch (err: any) {
setError(err.message || "Failed to execute command");
// Mark entry as failed with exit code 1 (as expected by tests)
// Mark entry as failed
setHistory((prev) => {
const lastEntry = prev[prev.length - 1];
if (!lastEntry || !lastEntry.isRunning) return prev;
const updatedEntry = {
...lastEntry,
exitCode: 1, // Changed from -1 to 1 to match test expectations
exitCode: -1,
isRunning: false,
output: lastEntry.output + `\n[Error: ${err.message || "Failed to execute command"}]\n`,
};
@@ -328,7 +234,7 @@ export function useTerminal(): TerminalState & TerminalActions {
setCurrentSessionId(null);
currentEntryRef.current = null;
}
}, [isRunning, handleLocalCommand]);
}, [isRunning]);
/**
* Kill the currently running command.
@@ -337,7 +243,7 @@ export function useTerminal(): TerminalState & TerminalActions {
if (!currentSessionId || !isRunning) return;
try {
await killTerminalSession(currentSessionId); // No signal argument
await killTerminalSession(currentSessionId, "SIGTERM");
// Close SSE connection
if (eventSourceRef.current) {
@@ -374,7 +280,7 @@ export function useTerminal(): TerminalState & TerminalActions {
const clearHistory = useCallback(() => {
// Kill any running process first
if (isRunning && currentSessionId) {
killTerminalSession(currentSessionId).catch(() => {
killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
// Ignore errors during cleanup
});
}
@@ -388,89 +294,48 @@ export function useTerminal(): TerminalState & TerminalActions {
setCurrentSessionId(null);
setIsRunning(false);
setHistoryIndex(-1);
setOriginalInput("");
currentEntryRef.current = null;
}, [isRunning, currentSessionId]);
/**
* Navigate command history with direction parameter.
* This is the interface expected by TerminalModal component.
* Also handles setting the input value.
* Navigate to previous command in history (Up arrow).
* Returns the command string or null if no history.
*/
const navigateHistory = useCallback((direction: "up" | "down", currentInput?: string): string | null => {
const navigateHistoryUp = useCallback(() => {
if (historyRef.current.length === 0) return null;
// Read current index from the ref (most up-to-date value)
const currentIndex = historyIndexRef.current;
const newIndex = historyIndex + 1;
if (newIndex >= historyRef.current.length) return null;
if (direction === "up") {
// Store original input on first navigation up
if (currentIndex === -1 && currentInput !== undefined) {
setOriginalInput(currentInput);
} else if (currentIndex === -1) {
setOriginalInput(inputValue);
}
const newIndex = currentIndex + 1;
if (newIndex >= historyRef.current.length) return null;
// Update both state and ref immediately
setHistoryIndex(newIndex);
historyIndexRef.current = newIndex;
// Calculate which command to return (most recent first)
// history = [first, second], length = 2
// newIndex = 0: return history[2 - 1 - 0] = history[1] = "second"
// newIndex = 1: return history[2 - 1 - 1] = history[0] = "first"
const commandIndex = historyRef.current.length - 1 - newIndex;
const command = historyRef.current[commandIndex]?.command || "";
setInputValue(command);
return command;
} else {
// Down direction
if (currentIndex <= 0) {
// Restore original input and reset index
setHistoryIndex(-1);
historyIndexRef.current = -1;
setInputValue(originalInput);
return originalInput;
}
const newIndex = currentIndex - 1;
setHistoryIndex(newIndex);
historyIndexRef.current = newIndex;
const commandIndex = historyRef.current.length - 1 - newIndex;
const command = historyRef.current[commandIndex]?.command || "";
setInputValue(command);
return command;
}
}, [inputValue, originalInput]);
/**
* Navigate to previous command in history (Up arrow).
* Wrapper around navigateHistory for direct use.
*/
const navigateHistoryUp = useCallback((): string | null => {
return navigateHistory("up", inputValue);
}, [navigateHistory, inputValue]);
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).
* Wrapper around navigateHistory for direct use.
* Returns the command string or null if at end.
*/
const navigateHistoryDown = useCallback((): string | null => {
return navigateHistory("down");
}, [navigateHistory]);
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);
historyIndexRef.current = -1;
}, []);
/**
@@ -487,7 +352,7 @@ export function useTerminal(): TerminalState & TerminalActions {
return () => {
// Kill any running process
if (currentSessionId) {
killTerminalSession(currentSessionId).catch(() => {
killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
// Ignore errors during cleanup
});
}
@@ -504,19 +369,15 @@ export function useTerminal(): TerminalState & TerminalActions {
history,
currentSessionId,
isRunning,
input: inputValue, // Alias for compatibility
inputValue,
historyIndex,
error,
currentDirectory,
// Actions
executeCommand,
clearHistory,
killCurrentCommand,
setInputValue,
setInput: setInputValue, // Alias for compatibility
navigateHistory,
navigateHistoryUp,
navigateHistoryDown,
resetHistoryNavigation,