feat(KB-057): complete Step 2 — useTerminal hook with history, SSE streaming, and command execution
This commit is contained in:
@@ -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">
|
||||
|
||||
409
packages/dashboard/app/hooks/__tests__/useTerminal.test.tsx
Normal file
409
packages/dashboard/app/hooks/__tests__/useTerminal.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
322
packages/dashboard/app/hooks/useTerminal.ts
Normal file
322
packages/dashboard/app/hooks/useTerminal.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1845,6 +1845,68 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/terminal/sessions/:id/stream
|
||||
* SSE endpoint for real-time terminal output streaming.
|
||||
* Events: terminal:output (stdout/stderr), terminal:exit
|
||||
*/
|
||||
router.get("/terminal/sessions/:id/stream", (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const session = terminalSessionManager.getSession(id);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering if present
|
||||
|
||||
// Send initial connection event
|
||||
res.write(`event: connected\ndata: ${JSON.stringify({ sessionId: id })}\n\n`);
|
||||
|
||||
// Handler for output events
|
||||
const onOutput = (event: import("./terminal.js").TerminalOutputEvent) => {
|
||||
if (event.sessionId !== id) return;
|
||||
|
||||
const eventName = event.type === "exit" ? "terminal:exit" : "terminal:output";
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
...(event.exitCode !== undefined && { exitCode: event.exitCode }),
|
||||
});
|
||||
|
||||
res.write(`event: ${eventName}\ndata: ${data}\n\n`);
|
||||
|
||||
// Close connection on exit after a brief delay to ensure client receives final data
|
||||
if (event.type === "exit") {
|
||||
setTimeout(() => {
|
||||
res.end();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to session manager events
|
||||
terminalSessionManager.on("output", onOutput);
|
||||
|
||||
// Handle client disconnect
|
||||
req.on("close", () => {
|
||||
terminalSessionManager.off("output", onOutput);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
req.on("error", () => {
|
||||
terminalSessionManager.off("output", onOutput);
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user