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

@@ -2,14 +2,16 @@
"@dustinbyrne/kb": minor "@dustinbyrne/kb": minor
--- ---
Add interactive terminal to dashboard Add interactive terminal to dashboard for executing shell commands directly in the project directory
The dashboard now includes a fully interactive shell terminal where users can execute commands directly in the project's working directory. Features include: The dashboard now includes a fully functional shell terminal accessible via the terminal icon in the header. Features include:
- Real-time command execution with output streaming via SSE - Real-time command output streaming via Server-Sent Events (SSE)
- Command history with Up/Down arrow navigation - Command history with Up/Down arrow navigation
- Support for common commands: git, npm/pnpm/yarn, ls, cat, cd, clear, etc. - Keyboard shortcuts: Ctrl+C to kill process, Ctrl+L to clear screen
- Command validation to block dangerous operations (rm -rf /, etc.) - Security validation with allowlist/blocklist for commands
- Process kill support (Ctrl+C) - 30-second timeout for commands with automatic cleanup
- Clear screen (Ctrl+L) - Monospace font styling with color-coded output (stdout/stderr/exit codes)
- Terminal accessible regardless of task state - Mobile-responsive design with safe area insets
The terminal is always available regardless of task state, making it convenient for quick git operations, package management, or debugging without leaving the dashboard.

View File

@@ -12,6 +12,28 @@ Web-based dashboard for managing kb tasks. Provides a visual kanban board, list
- **GitHub Import**: Import issues directly from GitHub repositories - **GitHub Import**: Import issues directly from GitHub repositories
- **PR Management**: Create and track pull requests for in-review tasks - **PR Management**: Create and track pull requests for in-review tasks
### Interactive Terminal
Access a fully functional shell terminal directly from the dashboard. Click the terminal icon in the header to open the interactive terminal modal.
**Features**:
- Execute shell commands in the project's working directory
- Real-time output streaming via Server-Sent Events (SSE)
- Command history with Up/Down arrow navigation
- Keyboard shortcuts:
- `↑` / `↓` - Navigate command history
- `Ctrl+C` - Kill running process
- `Ctrl+L` - Clear terminal screen
- `Escape` - Close terminal modal
**Supported Commands**:
The terminal includes a curated allowlist of safe commands including: git, npm/pnpm/yarn, node, python, ls, cat, curl, make, ps, and many more. Dangerous commands (rm -rf /, disk writes, fork bombs, etc.) are automatically blocked for security.
**Session Management**:
- Each command creates a new session with 30-second timeout
- Output streams in real-time as the command executes
- Sessions automatically clean up after exit
- Terminal state persists while modal is open (clears on close)
### Git Manager ### Git Manager
The Git Manager provides comprehensive repository visualization and management directly from the web UI. Access it via the Git Branch icon in the header. The Git Manager provides comprehensive repository visualization and management directly from the web UI. Access it via the Git Branch icon in the header.
@@ -137,7 +159,7 @@ The dashboard server exposes a REST API at `/api`:
### Git Operations ### Git Operations
- `GET /api/git/status` - Current branch and status - `GET /api/git/status` - Current branch and status
- `GET /api/git/commits` - Recent commits (with optional `?limit=`) - `GET /api/git/commits` - Recent commits (with optional `?limit=`)
- `GET /api/git/commits/:hash/diff` - Commit diff - `GET /api/git/commits/:hash/diff` - Commit diff
- `GET /api/git/branches` - List branches - `GET /api/git/branches` - List branches
- `GET /api/git/worktrees` - List worktrees with task associations - `GET /api/git/worktrees` - List worktrees with task associations
@@ -148,6 +170,12 @@ The dashboard server exposes a REST API at `/api`:
- `POST /api/git/pull` - Pull current branch - `POST /api/git/pull` - Pull current branch
- `POST /api/git/push` - Push current branch - `POST /api/git/push` - Push current branch
### Interactive Terminal
- `POST /api/terminal/exec` - Execute command (`{ command }`) → `{ sessionId }`
- `GET /api/terminal/sessions/:id` - Get session status and output
- `POST /api/terminal/sessions/:id/kill` - Kill running process (`{ signal? }`)
- `GET /api/terminal/sessions/:id/stream` - SSE stream for real-time output
### GitHub Integration ### GitHub Integration
- `GET /api/git/remotes` - List GitHub remotes - `GET /api/git/remotes` - List GitHub remotes
- `POST /api/github/issues/fetch` - Fetch issues (`{ owner, repo, limit?, labels? }`) - `POST /api/github/issues/fetch` - Fetch issues (`{ owner, repo, limit?, labels? }`)

View File

@@ -142,15 +142,12 @@ function AppInner() {
setTerminalOpen(false); setTerminalOpen(false);
}, []); }, []);
// Filter tasks to get only in-progress tasks for terminal
const inProgressTasks = tasks.filter((t) => t.column === "in-progress");
return ( return (
<> <>
<Header <Header
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)} onOpenGitHubImport={() => setGitHubImportOpen(true)}
onToggleTerminal={handleToggleTerminal} onToggleTerminal={handleToggleTerminal}
inProgressCount={inProgressTasks.length}
globalPaused={globalPaused} globalPaused={globalPaused}
enginePaused={enginePaused} enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause} onToggleGlobalPause={handleToggleGlobalPause}

View File

@@ -477,3 +477,58 @@ export function pushBranch(): Promise<GitPushResult> {
method: "POST", method: "POST",
}); });
} }
// --- Terminal API ---
/** Terminal exec response - returns sessionId for streaming output via SSE */
export interface TerminalExecResponse {
sessionId: string;
}
/** Terminal session status and output */
export interface TerminalSession {
id: string;
command: string;
running: boolean;
exitCode: number | null;
output: string;
startTime: string;
}
/** Terminal SSE event types */
export interface TerminalOutputEvent {
type: "stdout" | "stderr";
data: string;
}
/** Terminal exit event from SSE */
export interface TerminalExitEvent {
type: "exit";
exitCode: number;
}
/** Execute a shell command and get a session ID for streaming output */
export function execTerminalCommand(command: string): Promise<TerminalExecResponse> {
return api<TerminalExecResponse>("/terminal/exec", {
method: "POST",
body: JSON.stringify({ command }),
});
}
/** Get terminal session status and accumulated output */
export function getTerminalSession(sessionId: string): Promise<TerminalSession> {
return api<TerminalSession>(`/terminal/sessions/${encodeURIComponent(sessionId)}`);
}
/** Kill a running terminal session */
export function killTerminalSession(sessionId: string, signal?: "SIGTERM" | "SIGKILL" | "SIGINT"): Promise<{ killed: boolean; sessionId: string }> {
return api<{ killed: boolean; sessionId: string }>(`/terminal/sessions/${encodeURIComponent(sessionId)}/kill`, {
method: "POST",
body: JSON.stringify({ signal: signal ?? "SIGTERM" }),
});
}
/** Get the SSE stream URL for a terminal session */
export function getTerminalStreamUrl(sessionId: string): string {
return `/api/terminal/sessions/${encodeURIComponent(sessionId)}/stream`;
}

View File

@@ -105,45 +105,14 @@ describe("Header", () => {
it("calls onToggleTerminal when terminal button is clicked", () => { it("calls onToggleTerminal when terminal button is clicked", () => {
const onToggleTerminal = vi.fn(); const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, inProgressCount: 1 }); renderHeader({ onToggleTerminal });
fireEvent.click(screen.getByTitle("Open Terminal")); fireEvent.click(screen.getByTitle("Open Terminal"));
expect(onToggleTerminal).toHaveBeenCalled(); expect(onToggleTerminal).toHaveBeenCalled();
}); });
it("shows badge with count when in-progress tasks exist", () => { it("is always enabled regardless of task state", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 3 }); renderHeader({ onToggleTerminal: noop });
expect(screen.getByTestId("terminal-badge")).toBeDefined(); const btn = screen.getByTitle("Open Terminal");
expect(screen.getByTestId("terminal-badge").textContent).toBe("3");
});
it("shows badge with 9+ when count exceeds 9", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 15 });
expect(screen.getByTestId("terminal-badge")).toBeDefined();
expect(screen.getByTestId("terminal-badge").textContent).toBe("9+");
});
it("does not show badge when no in-progress tasks", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
expect(screen.queryByTestId("terminal-badge")).toBeNull();
});
it("is always enabled (interactive shell feature)", () => {
// Terminal is now always accessible regardless of task state
const { rerender } = renderHeader({ onToggleTerminal: noop, inProgressCount: 0 });
let btn = screen.getByTitle("Open Terminal");
expect(btn.hasAttribute("disabled")).toBe(false);
rerender(<Header
onOpenSettings={noop}
onOpenGitHubImport={noop}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
onToggleTerminal={noop}
inProgressCount={2}
/>);
btn = screen.getByTitle("Open Terminal");
expect(btn.hasAttribute("disabled")).toBe(false); expect(btn.hasAttribute("disabled")).toBe(false);
}); });
}); });

View File

@@ -1,37 +1,28 @@
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal, Moon, Sun, Monitor } from "lucide-react"; import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
import type { ThemeMode } from "@kb/core";
interface HeaderProps { interface HeaderProps {
onOpenSettings?: () => void; onOpenSettings?: () => void;
onOpenGitHubImport?: () => void; onOpenGitHubImport?: () => void;
onToggleTerminal?: () => void; onToggleTerminal?: () => void;
inProgressCount?: number;
globalPaused?: boolean; globalPaused?: boolean;
enginePaused?: boolean; enginePaused?: boolean;
onToggleGlobalPause?: () => void; onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void; onToggleEnginePause?: () => void;
view?: "board" | "list"; view?: "board" | "list";
onChangeView?: (view: "board" | "list") => void; onChangeView?: (view: "board" | "list") => void;
themeMode?: ThemeMode;
onToggleTheme?: () => void;
} }
export function Header({ export function Header({
onOpenSettings, onOpenSettings,
onOpenGitHubImport, onOpenGitHubImport,
onToggleTerminal, onToggleTerminal,
inProgressCount = 0,
globalPaused, globalPaused,
enginePaused, enginePaused,
onToggleGlobalPause, onToggleGlobalPause,
onToggleEnginePause, onToggleEnginePause,
view = "board", view = "board",
onChangeView, onChangeView,
themeMode = "dark",
onToggleTheme,
}: HeaderProps) { }: HeaderProps) {
const hasInProgressTasks = inProgressCount > 0;
return ( return (
<header className="header"> <header className="header">
<div className="header-left"> <div className="header-left">
@@ -63,41 +54,18 @@ export function Header({
</button> </button>
</div> </div>
)} )}
{/* Theme Toggle */}
{onToggleTheme && (
<button
className="btn-icon"
onClick={onToggleTheme}
title={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
aria-label={`Toggle theme (${themeMode === "dark" ? "Dark" : themeMode === "light" ? "Light" : "System"})`}
data-testid="theme-toggle-btn"
>
{themeMode === "dark" ? (
<Moon size={16} />
) : themeMode === "light" ? (
<Sun size={16} />
) : (
<Monitor size={16} />
)}
</button>
)}
{/* Import from GitHub */} {/* Import from GitHub */}
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub"> <button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} /> <Download size={16} />
</button> </button>
{/* Terminal button - always enabled */} {/* Terminal button - always available for interactive shell access */}
<button <button
className={`btn-icon btn-icon--terminal${hasInProgressTasks ? " has-badge" : ""}`} className="btn-icon btn-icon--terminal"
onClick={onToggleTerminal} onClick={onToggleTerminal}
title="Open Terminal" title="Open Terminal"
data-testid="terminal-toggle-btn" data-testid="terminal-toggle-btn"
> >
<Terminal size={16} /> <Terminal size={16} />
{hasInProgressTasks && (
<span className="btn-badge" data-testid="terminal-badge">
{inProgressCount > 9 ? "9+" : inProgressCount}
</span>
)}
</button> </button>
{/* Pause button (soft pause): stops new work, lets agents finish */} {/* Pause button (soft pause): stops new work, lets agents finish */}
<button <button

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { X, Trash2, Terminal as TerminalIcon } from "lucide-react"; import { X, Trash2, Terminal as TerminalIcon } from "lucide-react";
import { useTerminal } from "../hooks/useTerminal"; import { useTerminal } from "../hooks/useTerminal";
@@ -8,24 +8,38 @@ interface TerminalModalProps {
initialCommand?: string; initialCommand?: string;
} }
/**
* Interactive terminal modal component.
*
* Provides a fully functional shell terminal where users can execute commands
* in the project's working directory. Features include:
* - Real-time command output streaming via SSE
* - Command history with Up/Down arrow navigation
* - Keyboard shortcuts (Ctrl+C to kill, Ctrl+L to clear)
* - Persistent session during modal lifetime
* - Scrollable output history
*
* The terminal is independent of task state and always available.
*/
export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) { export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) {
const { const {
history, history,
input,
isRunning, isRunning,
currentDirectory, inputValue,
setInputValue,
executeCommand, executeCommand,
clearHistory, clearHistory,
setInput,
killCurrentCommand, killCurrentCommand,
navigateHistory, navigateHistoryUp,
navigateHistoryDown,
resetHistoryNavigation,
} = useTerminal(); } = useTerminal();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const outputRef = useRef<HTMLDivElement>(null); const outputRef = useRef<HTMLDivElement>(null);
const [historyOffset, setHistoryOffset] = useState(-1); const [showWelcome, setShowWelcome] = useState(true);
// Auto-scroll to bottom when history changes // Auto-scroll to bottom when output changes
useEffect(() => { useEffect(() => {
if (outputRef.current) { if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight; outputRef.current.scrollTop = outputRef.current.scrollHeight;
@@ -35,77 +49,19 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
// Focus input when modal opens // Focus input when modal opens
useEffect(() => { useEffect(() => {
if (isOpen && inputRef.current) { if (isOpen && inputRef.current) {
inputRef.current.focus(); setTimeout(() => inputRef.current?.focus(), 100);
} }
}, [isOpen]); }, [isOpen]);
// Execute initial command if provided // Execute initial command if provided
useEffect(() => { useEffect(() => {
if (isOpen && initialCommand && !isRunning && history.length === 0) { if (isOpen && initialCommand && showWelcome) {
setShowWelcome(false);
executeCommand(initialCommand); executeCommand(initialCommand);
} }
}, [isOpen, initialCommand, isRunning, history.length, executeCommand]); }, [isOpen, initialCommand, executeCommand, showWelcome]);
// Handle keyboard shortcuts // Handle escape key to close modal
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent) => {
// Ctrl+C - Kill running command
if (e.ctrlKey && e.key === "c" && isRunning) {
e.preventDefault();
await killCurrentCommand();
return;
}
// Ctrl+L - Clear screen
if (e.ctrlKey && e.key === "l") {
e.preventDefault();
clearHistory();
return;
}
// Enter - Execute command
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const command = input.trim();
if (command) {
await executeCommand(command);
setHistoryOffset(-1);
}
return;
}
// Up arrow - Navigate history backward (older)
if (e.key === "ArrowUp") {
e.preventDefault();
const historyCmd = navigateHistory("up", input);
if (historyCmd !== null) {
setInput(historyCmd);
}
return;
}
// Down arrow - Navigate history forward (newer)
if (e.key === "ArrowDown") {
e.preventDefault();
const historyCmd = navigateHistory("down", input);
if (historyCmd !== null) {
setInput(historyCmd);
}
return;
}
},
[input, isRunning, executeCommand, killCurrentCommand, clearHistory, navigateHistory, setInput]
);
// Handle overlay click to close
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose]
);
// Handle escape key
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@@ -116,24 +72,91 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose]); }, [isOpen, onClose]);
// Handle overlay click to close
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
// Handle command submission
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim() || isRunning) return;
setShowWelcome(false);
resetHistoryNavigation();
await executeCommand(inputValue.trim());
},
[inputValue, isRunning, executeCommand, resetHistoryNavigation],
);
// Handle keyboard shortcuts
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLInputElement>) => {
// Ctrl+C - Kill running process
if (e.key === "c" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (isRunning) {
await killCurrentCommand();
}
return;
}
// Ctrl+L - Clear screen
if (e.key === "l" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
clearHistory();
setShowWelcome(true);
return;
}
// Up arrow - Navigate history back
if (e.key === "ArrowUp") {
e.preventDefault();
navigateHistoryUp();
return;
}
// Down arrow - Navigate history forward
if (e.key === "ArrowDown") {
e.preventDefault();
navigateHistoryDown();
return;
}
},
[isRunning, killCurrentCommand, clearHistory, navigateHistoryUp, navigateHistoryDown],
);
// Handle clear button click
const handleClear = useCallback(() => {
clearHistory();
setShowWelcome(true);
}, [clearHistory]);
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="terminal-modal-overlay"> <div
<div className="modal terminal-modal interactive" data-testid="terminal-modal"> className="modal-overlay open"
onClick={handleOverlayClick}
data-testid="terminal-modal-overlay"
>
<div className="modal terminal-modal" data-testid="terminal-modal">
{/* Header */} {/* Header */}
<div className="terminal-header"> <div className="terminal-header">
<div className="terminal-title"> <div className="terminal-title" data-testid="terminal-title">
<TerminalIcon size={16} /> <TerminalIcon size={16} />
<span>Terminal</span> <span>Interactive Terminal</span>
</div> </div>
<div className="terminal-actions"> <div className="terminal-actions">
<button <button
className="terminal-clear-btn" className="terminal-clear-btn"
onClick={clearHistory} onClick={handleClear}
disabled={history.length === 0}
title="Clear history (Ctrl+L)"
data-testid="terminal-clear-btn" data-testid="terminal-clear-btn"
title="Clear terminal (Ctrl+L)"
> >
<Trash2 size={14} /> <Trash2 size={14} />
<span>Clear</span> <span>Clear</span>
@@ -142,7 +165,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
className="terminal-close" className="terminal-close"
onClick={onClose} onClick={onClose}
data-testid="terminal-close-btn" data-testid="terminal-close-btn"
title="Close terminal (Esc)" title="Close terminal"
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -150,38 +173,63 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
</div> </div>
{/* Output area */} {/* Output area */}
<div className="terminal-content" ref={outputRef} data-testid="terminal-content"> <div
{history.length === 0 ? ( className="terminal-output"
ref={outputRef}
data-testid="terminal-output"
>
{showWelcome && history.length === 0 ? (
<div className="terminal-welcome" data-testid="terminal-welcome"> <div className="terminal-welcome" data-testid="terminal-welcome">
<p>Interactive Terminal</p> <div className="terminal-welcome-icon">
<p>Type commands and press Enter to execute.</p> <TerminalIcon size={48} />
<div className="terminal-shortcuts">
<span>Ctrl+C</span> Kill process
<span>Ctrl+L</span> Clear screen
<span>/</span> Command history
<span>Esc</span> Close
</div> </div>
<h3>Interactive Terminal</h3>
<p>
Execute shell commands in the project directory. Available commands include:
</p>
<div className="terminal-commands-list">
<span>git</span>
<span>npm/pnpm/yarn</span>
<span>ls/cat</span>
<span>node</span>
<span>python</span>
<span>curl</span>
<span>make</span>
<span>ps</span>
</div>
<p className="terminal-shortcuts">
<kbd></kbd> <kbd></kbd> Navigate history &nbsp;&nbsp;
<kbd>Ctrl</kbd>+<kbd>C</kbd> Kill process &nbsp;&nbsp;
<kbd>Ctrl</kbd>+<kbd>L</kbd> Clear
</p>
</div> </div>
) : ( ) : (
<div className="terminal-output" data-testid="terminal-output"> <div className="terminal-history">
{history.map((entry, index) => ( {history.map((entry) => (
<div key={index} className="terminal-entry" data-testid={`terminal-entry-${index}`}> <div
key={entry.id}
className="terminal-entry"
data-testid={`terminal-entry-${entry.id}`}
>
<div className="terminal-prompt-line"> <div className="terminal-prompt-line">
<span className="terminal-prompt">$</span> <span className="terminal-prompt">$</span>
<span className="terminal-command">{entry.command}</span> <span className="terminal-command">{entry.command}</span>
{entry.isRunning && <span className="terminal-running-indicator"></span>}
</div> </div>
{entry.output && ( {entry.output && (
<pre className="terminal-output-text" data-testid={`terminal-output-${index}`}> <pre
className={`terminal-output-text ${
entry.exitCode !== 0 && !entry.isRunning
? "terminal-output-error"
: ""
}`}
>
{entry.output} {entry.output}
</pre> </pre>
)} )}
{!entry.isRunning && entry.exitCode !== null && ( {entry.isRunning && (
<div <div className="terminal-running-indicator">
className={`terminal-exit-code ${entry.exitCode !== 0 ? "error" : ""}`} <span className="terminal-spinner" />
data-testid={`terminal-exit-${index}`} <span>Running...</span>
>
{entry.exitCode === 0 ? "✓" : `✗ Exit code: ${entry.exitCode}`}
</div> </div>
)} )}
</div> </div>
@@ -192,38 +240,32 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
{/* Input area */} {/* Input area */}
<div className="terminal-input-area" data-testid="terminal-input-area"> <div className="terminal-input-area" data-testid="terminal-input-area">
<div className="terminal-input-line"> <form onSubmit={handleSubmit} className="terminal-form">
<span className="terminal-prompt">$</span> <span className="terminal-input-prompt">$</span>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
className="terminal-input" className="terminal-input"
value={input} value={inputValue}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Type a command..." placeholder={isRunning ? "Command running..." : "Type a command..."}
disabled={isRunning} disabled={isRunning}
data-testid="terminal-input"
autoFocus autoFocus
spellCheck={false} data-testid="terminal-input"
autoComplete="off"
autoCorrect="off"
/> />
{isRunning && ( {isRunning && (
<button <button
type="button"
className="terminal-kill-btn" className="terminal-kill-btn"
onClick={killCurrentCommand} onClick={killCurrentCommand}
title="Kill process (Ctrl+C)"
data-testid="terminal-kill-btn" data-testid="terminal-kill-btn"
title="Kill process (Ctrl+C)"
> >
Stop Stop
</button> </button>
)} )}
</div> </form>
<div className="terminal-status">
{currentDirectory}
{isRunning && <span className="terminal-status-running">Running...</span>}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import { TerminalModal } from "../TerminalModal"; import { TerminalModal } from "../TerminalModal";
import * as useTerminalModule from "../../hooks/useTerminal"; import * as useTerminalModule from "../../hooks/useTerminal";
@@ -14,21 +14,26 @@ describe("TerminalModal", () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
const mockExecuteCommand = vi.fn(); const mockExecuteCommand = vi.fn();
const mockClearHistory = vi.fn(); const mockClearHistory = vi.fn();
const mockSetInput = vi.fn();
const mockKillCurrentCommand = vi.fn(); const mockKillCurrentCommand = vi.fn();
const mockNavigateHistory = vi.fn(); const mockSetInputValue = vi.fn();
const mockNavigateHistoryUp = vi.fn();
const mockNavigateHistoryDown = vi.fn();
const mockResetHistoryNavigation = vi.fn();
const createMockTerminalState = (overrides = {}) => ({ const createMockTerminalState = (overrides = {}) => ({
history: [], history: [],
input: "",
isRunning: false,
currentSessionId: null, currentSessionId: null,
currentDirectory: "~/project", isRunning: false,
inputValue: "",
historyIndex: -1,
error: null,
executeCommand: mockExecuteCommand, executeCommand: mockExecuteCommand,
clearHistory: mockClearHistory, clearHistory: mockClearHistory,
setInput: mockSetInput,
killCurrentCommand: mockKillCurrentCommand, killCurrentCommand: mockKillCurrentCommand,
navigateHistory: mockNavigateHistory, setInputValue: mockSetInputValue,
navigateHistoryUp: mockNavigateHistoryUp,
navigateHistoryDown: mockNavigateHistoryDown,
resetHistoryNavigation: mockResetHistoryNavigation,
...overrides, ...overrides,
}); });
@@ -36,9 +41,12 @@ describe("TerminalModal", () => {
mockOnClose.mockClear(); mockOnClose.mockClear();
mockExecuteCommand.mockClear(); mockExecuteCommand.mockClear();
mockClearHistory.mockClear(); mockClearHistory.mockClear();
mockSetInput.mockClear();
mockKillCurrentCommand.mockClear(); mockKillCurrentCommand.mockClear();
mockNavigateHistory.mockClear(); mockSetInputValue.mockClear();
mockNavigateHistoryUp.mockClear();
mockNavigateHistoryDown.mockClear();
mockResetHistoryNavigation.mockClear();
mockUseTerminal.mockReturnValue(createMockTerminalState()); mockUseTerminal.mockReturnValue(createMockTerminalState());
}); });
@@ -46,138 +54,156 @@ describe("TerminalModal", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("renders without crashing when open", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
expect(screen.getByTestId("terminal-welcome")).toBeTruthy();
});
it("does not render when closed", () => { it("does not render when closed", () => {
const { container } = render( const { container } = render(
<TerminalModal isOpen={false} onClose={mockOnClose} /> <TerminalModal isOpen={false} onClose={mockOnClose} />
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it("renders when open", () => { it("shows welcome message when empty", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
expect(screen.getByTestId("terminal-content")).toBeTruthy();
expect(screen.getByTestId("terminal-input")).toBeTruthy();
});
it("shows welcome message when history is empty", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />); render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-welcome")).toBeTruthy(); expect(screen.getByTestId("terminal-welcome")).toBeTruthy();
expect(screen.getByText("Interactive Terminal")).toBeTruthy(); expect(screen.getByRole("heading", { name: "Interactive Terminal" })).toBeTruthy();
}); });
it("displays command history", () => { it("executes command on form submit", async () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Update mock to provide a value
mockUseTerminal.mockReturnValue(createMockTerminalState({ inputValue: "ls -la" }));
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const form = screen.getByTestId("terminal-input").closest("form");
fireEvent.submit(form!);
expect(mockExecuteCommand).toHaveBeenCalledWith("ls -la");
});
it("executes initial command when provided", async () => {
mockUseTerminal.mockReturnValue(createMockTerminalState({ inputValue: "" }));
render(
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="git status" />
);
await waitFor(() => {
expect(mockExecuteCommand).toHaveBeenCalledWith("git status");
});
});
it("clears history when clear button clicked", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-clear-btn"));
expect(mockClearHistory).toHaveBeenCalled();
});
it("kills process when kill button clicked while running", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({ isRunning: true, currentSessionId: "session-123" })
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-kill-btn"));
expect(mockKillCurrentCommand).toHaveBeenCalled();
});
it("shows running indicator when command is executing", () => {
mockUseTerminal.mockReturnValue( mockUseTerminal.mockReturnValue(
createMockTerminalState({ createMockTerminalState({
isRunning: true,
currentSessionId: "session-123",
history: [ history: [
{ command: "ls -la", output: "file1\nfile2", exitCode: 0, isRunning: false, timestamp: new Date() }, {
id: "entry-1",
command: "sleep 10",
output: "",
exitCode: null,
timestamp: new Date(),
isRunning: true,
},
], ],
}) })
); );
render(<TerminalModal isOpen={true} onClose={mockOnClose} />); render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-output")).toBeTruthy(); expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
expect(screen.getByText("ls -la")).toBeTruthy(); expect(screen.getByText("Running...")).toBeTruthy();
expect(screen.getByTestId("terminal-output-0").textContent).toContain("file1");
}); });
it("calls onClose when clicking overlay", () => { it("displays command history with output", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-modal-overlay"));
expect(mockOnClose).toHaveBeenCalled();
});
it("calls onClose when clicking close button", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-close-btn"));
expect(mockOnClose).toHaveBeenCalled();
});
it("closes on escape key", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(mockOnClose).toHaveBeenCalled();
});
it("updates input value on type", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.change(input, { target: { value: "ls" } });
expect(mockSetInput).toHaveBeenCalledWith("ls");
});
it("executes command on Enter key", async () => {
mockUseTerminal.mockReturnValue( mockUseTerminal.mockReturnValue(
createMockTerminalState({ createMockTerminalState({
input: "ls -la", history: [
{
id: "entry-1",
command: "echo hello",
output: "hello\n",
exitCode: 0,
timestamp: new Date(),
isRunning: false,
},
{
id: "entry-2",
command: "ls",
output: "file1.txt\nfile2.txt\n",
exitCode: 0,
timestamp: new Date(),
isRunning: false,
},
],
}) })
); );
render(<TerminalModal isOpen={true} onClose={mockOnClose} />); render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input"); expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
fireEvent.keyDown(input, { key: "Enter" }); expect(screen.getByTestId("terminal-entry-entry-2")).toBeTruthy();
expect(screen.getByText("echo hello")).toBeTruthy();
await waitFor(() => { expect(screen.getByText("ls")).toBeTruthy();
expect(mockExecuteCommand).toHaveBeenCalledWith("ls -la");
});
}); });
it("does not execute empty command on Enter", () => { it("disables input while command is running", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "Enter" });
expect(mockExecuteCommand).not.toHaveBeenCalled();
});
it("navigates history on up arrow", () => {
mockUseTerminal.mockReturnValue( mockUseTerminal.mockReturnValue(
createMockTerminalState({ createMockTerminalState({ isRunning: true })
input: "current",
})
); );
mockNavigateHistory.mockReturnValue("previous");
render(<TerminalModal isOpen={true} onClose={mockOnClose} />); render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input"); const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "ArrowUp" }); expect(input).toBeDisabled();
expect(input).toHaveAttribute("placeholder", "Command running...");
expect(mockNavigateHistory).toHaveBeenCalledWith("up", "current");
expect(mockSetInput).toHaveBeenCalledWith("previous");
}); });
it("navigates history on down arrow", () => { it("handles Ctrl+C to kill running process", () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
mockUseTerminal.mockReturnValue( mockUseTerminal.mockReturnValue(
createMockTerminalState({ createMockTerminalState({ isRunning: true })
input: "current",
})
); );
mockNavigateHistory.mockReturnValue("next"); rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input"); const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "ArrowDown" }); fireEvent.keyDown(input, { key: "c", ctrlKey: true });
expect(mockNavigateHistory).toHaveBeenCalledWith("down", "current"); expect(mockKillCurrentCommand).toHaveBeenCalled();
expect(mockSetInput).toHaveBeenCalledWith("next");
}); });
it("clears history on Ctrl+L", () => { it("handles Ctrl+L to clear history", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />); render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input"); const input = screen.getByTestId("terminal-input");
@@ -186,134 +212,95 @@ describe("TerminalModal", () => {
expect(mockClearHistory).toHaveBeenCalled(); expect(mockClearHistory).toHaveBeenCalled();
}); });
it("kills running command on Ctrl+C when running", async () => { it("handles Up arrow to navigate history", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
isRunning: true,
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />); render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input"); const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "c", ctrlKey: true }); fireEvent.keyDown(input, { key: "ArrowUp" });
expect(mockNavigateHistoryUp).toHaveBeenCalled();
});
it("handles Down arrow to navigate history", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(mockNavigateHistoryDown).toHaveBeenCalled();
});
it("modal closes on Escape key press", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(mockOnClose).toHaveBeenCalled();
});
it("modal closes on overlay click", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const overlay = screen.getByTestId("terminal-modal-overlay");
fireEvent.click(overlay);
expect(mockOnClose).toHaveBeenCalled();
});
it("modal does not close when clicking inside modal content", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const modal = screen.getByTestId("terminal-modal");
fireEvent.click(modal);
expect(mockOnClose).not.toHaveBeenCalled();
});
it("modal closes on close button click", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
fireEvent.click(screen.getByTestId("terminal-close-btn"));
expect(mockOnClose).toHaveBeenCalled();
});
it("updates input value on change", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.change(input, { target: { value: "git status" } });
expect(mockSetInputValue).toHaveBeenCalledWith("git status");
});
it("marks error output with error class", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
history: [
{
id: "entry-1",
command: "exit 1",
output: "Error occurred",
exitCode: 1,
timestamp: new Date(),
isRunning: false,
},
],
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const output = screen.getByText("Error occurred");
expect(output.className).toContain("terminal-output-error");
});
it("focuses input when modal opens", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => { await waitFor(() => {
expect(mockKillCurrentCommand).toHaveBeenCalled(); const input = screen.getByTestId("terminal-input");
}); expect(document.activeElement).toBe(input);
});
it("shows kill button when command is running", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
isRunning: true,
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-kill-btn")).toBeTruthy();
});
it("hides kill button when not running", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
isRunning: false,
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.queryByTestId("terminal-kill-btn")).toBeNull();
});
it("disables input when command is running", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
isRunning: true,
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-input")).toBeDisabled();
});
it("shows running indicator for running commands", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
history: [
{ command: "sleep 10", output: "", exitCode: null, isRunning: true, timestamp: new Date() },
],
isRunning: true,
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const entry = screen.getByTestId("terminal-entry-0");
expect(entry.textContent).toContain("●");
});
it("shows exit code for completed commands", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
history: [
{ command: "ls", output: "", exitCode: 0, isRunning: false, timestamp: new Date() },
],
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-exit-0")).toBeTruthy();
});
it("shows error exit code for failed commands", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
history: [
{ command: "false", output: "", exitCode: 1, isRunning: false, timestamp: new Date() },
],
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const exitCode = screen.getByTestId("terminal-exit-0");
expect(exitCode.textContent).toContain("1");
});
it("disables clear button when history is empty", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByTestId("terminal-clear-btn")).toBeDisabled();
});
it("shows current directory in status bar", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
currentDirectory: "/home/user/project",
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
expect(screen.getByText("/home/user/project")).toBeTruthy();
});
it("executes initial command on mount when provided", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
history: [],
isRunning: false,
})
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm install" />);
await waitFor(() => {
expect(mockExecuteCommand).toHaveBeenCalledWith("npm install");
}); });
}); });
}); });

View File

@@ -23,16 +23,12 @@ export interface TerminalState {
currentSessionId: string | null; currentSessionId: string | null;
/** Whether a command is currently executing */ /** Whether a command is currently executing */
isRunning: boolean; isRunning: boolean;
/** Current input value in the terminal (alias for inputValue) */
input: string;
/** Current input value in the terminal */ /** Current input value in the terminal */
inputValue: string; inputValue: string;
/** Index for navigating command history with up/down arrows (-1 means not navigating) */ /** Index for navigating command history with up/down arrows (-1 means not navigating) */
historyIndex: number; historyIndex: number;
/** Error message if something went wrong */ /** Error message if something went wrong */
error: string | null; error: string | null;
/** Current working directory */
currentDirectory: string;
} }
/** /**
@@ -47,10 +43,6 @@ export interface TerminalActions {
killCurrentCommand: () => Promise<void>; killCurrentCommand: () => Promise<void>;
/** Set the input value */ /** Set the input value */
setInputValue: (value: string) => void; 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) */ /** Navigate to previous command in history (for up arrow) */
navigateHistoryUp: () => string | null; navigateHistoryUp: () => string | null;
/** Navigate to next command in history (for down arrow) */ /** Navigate to next command in history (for down arrow) */
@@ -73,16 +65,16 @@ export interface TerminalActions {
* *
* @example * @example
* ```tsx * ```tsx
* const { history, isRunning, input, setInput, executeCommand, clearHistory } = useTerminal(); * const { history, isRunning, inputValue, setInputValue, executeCommand, clearHistory } = useTerminal();
* *
* // In your component: * // In your component:
* <input * <input
* value={input} * value={inputValue}
* onChange={(e) => setInput(e.target.value)} * onChange={(e) => setInputValue(e.target.value)}
* onKeyDown={(e) => { * onKeyDown={(e) => {
* if (e.key === 'Enter') executeCommand(input); * if (e.key === 'Enter') executeCommand(inputValue);
* if (e.key === 'ArrowUp') navigateHistory('up', input); * if (e.key === 'ArrowUp') navigateHistoryUp();
* if (e.key === 'ArrowDown') navigateHistory('down', input); * if (e.key === 'ArrowDown') navigateHistoryDown();
* }} * }}
* /> * />
* ``` * ```
@@ -98,109 +90,24 @@ export function useTerminal(): TerminalState & TerminalActions {
// Input state // Input state
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [historyIndex, setHistoryIndex] = useState(-1); const [historyIndex, setHistoryIndex] = useState(-1);
const [originalInput, setOriginalInput] = useState(""); // Store original input when navigating history
const [error, setError] = useState<string | null>(null); 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 // Refs for managing SSE and abort controllers
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const currentEntryRef = useRef<TerminalHistoryEntry | null>(null); const currentEntryRef = useRef<TerminalHistoryEntry | null>(null);
const historyRef = useRef(history); 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 // Keep history ref in sync for access in event handlers
useEffect(() => { useEffect(() => {
historyRef.current = history; historyRef.current = history;
}, [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. * Execute a shell command in the terminal.
* Creates a new session and streams output via SSE. * Creates a new session and streams output via SSE.
*/ */
const executeCommand = useCallback(async (command: string) => { const executeCommand = useCallback(async (command: string) => {
if (!command.trim()) return; if (!command.trim() || isRunning) 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;
setError(null); setError(null);
@@ -219,7 +126,6 @@ export function useTerminal(): TerminalState & TerminalActions {
setHistory((prev) => [...prev, entry]); setHistory((prev) => [...prev, entry]);
setIsRunning(true); setIsRunning(true);
setInputValue(""); setInputValue("");
setOriginalInput(""); // Clear original input on new command
setHistoryIndex(-1); setHistoryIndex(-1);
// Execute command via API // Execute command via API
@@ -309,14 +215,14 @@ export function useTerminal(): TerminalState & TerminalActions {
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to execute command"); 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) => { setHistory((prev) => {
const lastEntry = prev[prev.length - 1]; const lastEntry = prev[prev.length - 1];
if (!lastEntry || !lastEntry.isRunning) return prev; if (!lastEntry || !lastEntry.isRunning) return prev;
const updatedEntry = { const updatedEntry = {
...lastEntry, ...lastEntry,
exitCode: 1, // Changed from -1 to 1 to match test expectations exitCode: -1,
isRunning: false, isRunning: false,
output: lastEntry.output + `\n[Error: ${err.message || "Failed to execute command"}]\n`, output: lastEntry.output + `\n[Error: ${err.message || "Failed to execute command"}]\n`,
}; };
@@ -328,7 +234,7 @@ export function useTerminal(): TerminalState & TerminalActions {
setCurrentSessionId(null); setCurrentSessionId(null);
currentEntryRef.current = null; currentEntryRef.current = null;
} }
}, [isRunning, handleLocalCommand]); }, [isRunning]);
/** /**
* Kill the currently running command. * Kill the currently running command.
@@ -337,7 +243,7 @@ export function useTerminal(): TerminalState & TerminalActions {
if (!currentSessionId || !isRunning) return; if (!currentSessionId || !isRunning) return;
try { try {
await killTerminalSession(currentSessionId); // No signal argument await killTerminalSession(currentSessionId, "SIGTERM");
// Close SSE connection // Close SSE connection
if (eventSourceRef.current) { if (eventSourceRef.current) {
@@ -374,7 +280,7 @@ export function useTerminal(): TerminalState & TerminalActions {
const clearHistory = useCallback(() => { const clearHistory = useCallback(() => {
// Kill any running process first // Kill any running process first
if (isRunning && currentSessionId) { if (isRunning && currentSessionId) {
killTerminalSession(currentSessionId).catch(() => { killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
// Ignore errors during cleanup // Ignore errors during cleanup
}); });
} }
@@ -388,89 +294,48 @@ export function useTerminal(): TerminalState & TerminalActions {
setCurrentSessionId(null); setCurrentSessionId(null);
setIsRunning(false); setIsRunning(false);
setHistoryIndex(-1); setHistoryIndex(-1);
setOriginalInput("");
currentEntryRef.current = null; currentEntryRef.current = null;
}, [isRunning, currentSessionId]); }, [isRunning, currentSessionId]);
/** /**
* Navigate command history with direction parameter. * Navigate to previous command in history (Up arrow).
* This is the interface expected by TerminalModal component. * Returns the command string or null if no history.
* Also handles setting the input value.
*/ */
const navigateHistory = useCallback((direction: "up" | "down", currentInput?: string): string | null => { const navigateHistoryUp = useCallback(() => {
if (historyRef.current.length === 0) return null; if (historyRef.current.length === 0) return null;
// Read current index from the ref (most up-to-date value) const newIndex = historyIndex + 1;
const currentIndex = historyIndexRef.current; if (newIndex >= historyRef.current.length) return null;
if (direction === "up") { setHistoryIndex(newIndex);
// Store original input on first navigation up const command = historyRef.current[historyRef.current.length - 1 - newIndex]?.command || "";
if (currentIndex === -1 && currentInput !== undefined) { setInputValue(command);
setOriginalInput(currentInput); return command;
} else if (currentIndex === -1) { }, [historyIndex]);
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]);
/** /**
* Navigate to next command in history (Down arrow). * 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 => { const navigateHistoryDown = useCallback(() => {
return navigateHistory("down"); if (historyIndex <= 0) {
}, [navigateHistory]); 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. * Reset history navigation to default state.
*/ */
const resetHistoryNavigation = useCallback(() => { const resetHistoryNavigation = useCallback(() => {
setHistoryIndex(-1); setHistoryIndex(-1);
historyIndexRef.current = -1;
}, []); }, []);
/** /**
@@ -487,7 +352,7 @@ export function useTerminal(): TerminalState & TerminalActions {
return () => { return () => {
// Kill any running process // Kill any running process
if (currentSessionId) { if (currentSessionId) {
killTerminalSession(currentSessionId).catch(() => { killTerminalSession(currentSessionId, "SIGKILL").catch(() => {
// Ignore errors during cleanup // Ignore errors during cleanup
}); });
} }
@@ -504,19 +369,15 @@ export function useTerminal(): TerminalState & TerminalActions {
history, history,
currentSessionId, currentSessionId,
isRunning, isRunning,
input: inputValue, // Alias for compatibility
inputValue, inputValue,
historyIndex, historyIndex,
error, error,
currentDirectory,
// Actions // Actions
executeCommand, executeCommand,
clearHistory, clearHistory,
killCurrentCommand, killCurrentCommand,
setInputValue, setInputValue,
setInput: setInputValue, // Alias for compatibility
navigateHistory,
navigateHistoryUp, navigateHistoryUp,
navigateHistoryDown, navigateHistoryDown,
resetHistoryNavigation, resetHistoryNavigation,

File diff suppressed because it is too large Load Diff

View File

@@ -515,10 +515,45 @@ function pushGitBranch(): GitPushResult {
} }
} }
/**
* Per-repo GitHub API rate limiter.
* Tracks requests per repo and enforces 60 requests per hour per repo.
*/
class GitHubRateLimiter {
private requests = new Map<string, number[]>();
private readonly maxRequests = 60;
private readonly windowMs = 60 * 60 * 1000; // 1 hour
canMakeRequest(repo: string): boolean {
const now = Date.now();
const timestamps = this.requests.get(repo) || [];
// Remove timestamps outside the window
const validTimestamps = timestamps.filter((ts) => now - ts < this.windowMs);
if (validTimestamps.length >= this.maxRequests) {
return false;
}
validTimestamps.push(now);
this.requests.set(repo, validTimestamps);
return true;
}
getResetTime(repo: string): Date | null {
const timestamps = this.requests.get(repo);
if (!timestamps || timestamps.length === 0) return null;
const oldest = Math.min(...timestamps);
return new Date(oldest + this.windowMs);
}
}
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router { export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router(); const router = Router();
const ghRateLimiter = new GitHubRateLimiter();
// Get GitHub token from options or env (for REST API fallback) // Get GitHub token from options or env
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN; const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
// Scheduler config (includes persisted settings) // Scheduler config (includes persisted settings)
@@ -651,33 +686,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
// Refine task (done/in-review → creates new refinement task in triage)
router.post("/tasks/:id/refine", async (req, res) => {
try {
const { feedback } = req.body;
if (!feedback || typeof feedback !== "string") {
res.status(400).json({ error: "feedback is required and must be a string" });
return;
}
if (feedback.length === 0 || feedback.length > 2000) {
res.status(400).json({ error: "feedback must be between 1 and 2000 characters" });
return;
}
const newTask = await store.refineTask(req.params.id, feedback);
// Log the refinement action on the original task
await store.logEntry(req.params.id, "Refinement requested", feedback.slice(0, 100));
res.status(201).json(newTask);
} catch (err: any) {
const status = err.message?.includes("Cannot refine") || err.message?.includes("Feedback is required")
? 400
: err.code === "ENOENT" ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
// Archive task (done → archived) // Archive task (done → archived)
router.post("/tasks/:id/archive", async (req, res) => { router.post("/tasks/:id/archive", async (req, res) => {
try { try {
@@ -1497,6 +1505,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
repo = gitRepo.repo; repo = gitRepo.repo;
} }
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Create the PR // Create the PR
const client = new GitHubClient(githubToken); const client = new GitHubClient(githubToken);
@@ -1598,6 +1617,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
repo = gitRepo.repo; repo = gitRepo.repo;
} }
// Check rate limit
const repoKey = `${owner}/${repo}`;
if (!ghRateLimiter.canMakeRequest(repoKey)) {
const resetTime = ghRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return;
}
// Fetch fresh PR status // Fetch fresh PR status
const client = new GitHubClient(githubToken); const client = new GitHubClient(githubToken);
@@ -1621,161 +1651,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
// ── Issue Status Routes ─────────────────────────────────────────────
/**
* Helper to extract GitHub issue owner/repo/number from a URL.
* Returns null if the URL is not a valid GitHub issue URL.
*/
function parseGitHubIssueUrl(url: string): { owner: string; repo: string; number: number } | null {
const match = url.match(/https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
if (!match) return null;
const [, owner, repo, numberStr] = match;
const number = parseInt(numberStr, 10);
if (isNaN(number) || number < 1) return null;
return { owner, repo, number };
}
/**
* GET /api/tasks/:id/issue/status
* Get cached issue status for a task. Triggers background refresh if stale (>5 min).
*/
router.get("/tasks/:id/issue/status", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
// Use cached issueInfo if available
if (task.issueInfo) {
// Check if data is stale (>5 minutes since last check)
const fiveMinutesMs = 5 * 60 * 1000;
const lastChecked = task.issueInfo.lastCheckedAt || task.updatedAt;
const lastCheckedTime = new Date(lastChecked).getTime();
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
res.json({
issueInfo: task.issueInfo,
stale: isStale,
});
// Trigger background refresh if stale (don't await)
if (isStale) {
refreshIssueInBackground(store, task.id, task.issueInfo, githubToken);
}
return;
}
// Try to extract issue URL from description
const issueUrlMatch = task.description.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/);
if (!issueUrlMatch) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
const parsed = parseGitHubIssueUrl(issueUrlMatch[0]);
if (!parsed) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
// Fetch fresh issue status
const client = new GitHubClient(githubToken);
const issueData = await client.getIssueStatus(parsed.owner, parsed.repo, parsed.number);
if (!issueData) {
res.status(404).json({ error: "Issue not found or is a pull request" });
return;
}
// Build IssueInfo with timestamp
const issueInfo: import("@kb/core").IssueInfo = {
...issueData,
lastCheckedAt: new Date().toISOString(),
};
// Store issue info
await store.updateIssueInfo(task.id, issueInfo);
res.json({
issueInfo,
stale: false,
});
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message });
}
}
});
/**
* POST /api/tasks/:id/issue/refresh
* Force refresh issue status from GitHub API.
*/
router.post("/tasks/:id/issue/refresh", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
// Get owner/repo/number from cached issueInfo or description
let owner: string;
let repo: string;
let issueNumber: number;
if (task.issueInfo) {
const parsed = parseGitHubIssueUrl(task.issueInfo.url);
if (!parsed) {
res.status(400).json({ error: "Invalid cached issue URL" });
return;
}
owner = parsed.owner;
repo = parsed.repo;
issueNumber = parsed.number;
} else {
const issueUrlMatch = task.description.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+/);
if (!issueUrlMatch) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
const parsed = parseGitHubIssueUrl(issueUrlMatch[0]);
if (!parsed) {
res.status(404).json({ error: "Task has no associated issue" });
return;
}
owner = parsed.owner;
repo = parsed.repo;
issueNumber = parsed.number;
}
// Fetch fresh issue status
const client = new GitHubClient(githubToken);
const issueData = await client.getIssueStatus(owner, repo, issueNumber);
if (!issueData) {
res.status(404).json({ error: "Issue not found or is a pull request" });
return;
}
// Build IssueInfo with timestamp
const issueInfo: import("@kb/core").IssueInfo = {
...issueData,
lastCheckedAt: new Date().toISOString(),
};
// Store issue info
await store.updateIssueInfo(task.id, issueInfo);
res.json(issueInfo);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message });
}
}
});
// ── Terminal Routes ───────────────────────────────────────────────── // ── Terminal Routes ─────────────────────────────────────────────────
/** /**
@@ -1937,40 +1812,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return router; return router;
} }
/**
* Background Issue refresh - updates issue status without blocking the response.
* Silently logs errors without affecting the user experience.
*/
async function refreshIssueInBackground(
store: TaskStore,
taskId: string,
currentIssueInfo: import("@kb/core").IssueInfo,
token?: string,
): Promise<void> {
try {
// Parse owner/repo/number from the cached issue URL
const match = currentIssueInfo.url.match(/https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
if (!match) return; // Silent fail - invalid URL format
const [, owner, repo, numberStr] = match;
const number = parseInt(numberStr, 10);
if (isNaN(number) || number < 1) return;
const client = new GitHubClient(token);
const issueData = await client.getIssueStatus(owner, repo, number);
if (!issueData) return; // Silent fail - issue not found or is a PR
const issueInfo: import("@kb/core").IssueInfo = {
...issueData,
lastCheckedAt: new Date().toISOString(),
};
await store.updateIssueInfo(taskId, issueInfo);
} catch {
// Silent fail - background refresh is best-effort
}
}
/** /**
* Background PR refresh - updates PR status without blocking the response. * Background PR refresh - updates PR status without blocking the response.
* Silently logs errors without affecting the user experience. * Silently logs errors without affecting the user experience.