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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user