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
---
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
- Support for common commands: git, npm/pnpm/yarn, ls, cat, cd, clear, etc.
- Command validation to block dangerous operations (rm -rf /, etc.)
- Process kill support (Ctrl+C)
- Clear screen (Ctrl+L)
- Terminal accessible regardless of task state
- Keyboard shortcuts: Ctrl+C to kill process, Ctrl+L to clear screen
- Security validation with allowlist/blocklist for commands
- 30-second timeout for commands with automatic cleanup
- Monospace font styling with color-coded output (stdout/stderr/exit codes)
- 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
- **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
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
- `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/branches` - List branches
- `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/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
- `GET /api/git/remotes` - List GitHub remotes
- `POST /api/github/issues/fetch` - Fetch issues (`{ owner, repo, limit?, labels? }`)

View File

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

View File

@@ -477,3 +477,58 @@ export function pushBranch(): Promise<GitPushResult> {
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", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal, inProgressCount: 1 });
renderHeader({ onToggleTerminal });
fireEvent.click(screen.getByTitle("Open Terminal"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("shows badge with count when in-progress tasks exist", () => {
renderHeader({ onToggleTerminal: noop, inProgressCount: 3 });
expect(screen.getByTestId("terminal-badge")).toBeDefined();
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");
it("is always enabled regardless of task state", () => {
renderHeader({ onToggleTerminal: noop });
const btn = screen.getByTitle("Open Terminal");
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 type { ThemeMode } from "@kb/core";
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, Terminal } from "lucide-react";
interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
onToggleTerminal?: () => void;
inProgressCount?: number;
globalPaused?: boolean;
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list";
onChangeView?: (view: "board" | "list") => void;
themeMode?: ThemeMode;
onToggleTheme?: () => void;
}
export function Header({
onOpenSettings,
onOpenGitHubImport,
onToggleTerminal,
inProgressCount = 0,
globalPaused,
enginePaused,
onToggleGlobalPause,
onToggleEnginePause,
view = "board",
onChangeView,
themeMode = "dark",
onToggleTheme,
}: HeaderProps) {
const hasInProgressTasks = inProgressCount > 0;
return (
<header className="header">
<div className="header-left">
@@ -63,41 +54,18 @@ export function Header({
</button>
</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 */}
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />
</button>
{/* Terminal button - always enabled */}
{/* Terminal button - always available for interactive shell access */}
<button
className={`btn-icon btn-icon--terminal${hasInProgressTasks ? " has-badge" : ""}`}
className="btn-icon btn-icon--terminal"
onClick={onToggleTerminal}
title="Open Terminal"
data-testid="terminal-toggle-btn"
>
<Terminal size={16} />
{hasInProgressTasks && (
<span className="btn-badge" data-testid="terminal-badge">
{inProgressCount > 9 ? "9+" : inProgressCount}
</span>
)}
</button>
{/* Pause button (soft pause): stops new work, lets agents finish */}
<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 { useTerminal } from "../hooks/useTerminal";
@@ -8,24 +8,38 @@ interface TerminalModalProps {
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) {
const {
history,
input,
isRunning,
currentDirectory,
inputValue,
setInputValue,
executeCommand,
clearHistory,
setInput,
killCurrentCommand,
navigateHistory,
navigateHistoryUp,
navigateHistoryDown,
resetHistoryNavigation,
} = useTerminal();
const inputRef = useRef<HTMLInputElement>(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(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
@@ -35,77 +49,19 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
// Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// Execute initial command if provided
useEffect(() => {
if (isOpen && initialCommand && !isRunning && history.length === 0) {
if (isOpen && initialCommand && showWelcome) {
setShowWelcome(false);
executeCommand(initialCommand);
}
}, [isOpen, initialCommand, isRunning, history.length, executeCommand]);
}, [isOpen, initialCommand, executeCommand, showWelcome]);
// Handle keyboard shortcuts
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
// Handle escape key to close modal
useEffect(() => {
if (!isOpen) return;
@@ -116,24 +72,91 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
return () => document.removeEventListener("keydown", handleKey);
}, [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;
return (
<div className="modal-overlay open" onClick={handleOverlayClick} data-testid="terminal-modal-overlay">
<div className="modal terminal-modal interactive" data-testid="terminal-modal">
<div
className="modal-overlay open"
onClick={handleOverlayClick}
data-testid="terminal-modal-overlay"
>
<div className="modal terminal-modal" data-testid="terminal-modal">
{/* Header */}
<div className="terminal-header">
<div className="terminal-title">
<div className="terminal-title" data-testid="terminal-title">
<TerminalIcon size={16} />
<span>Terminal</span>
<span>Interactive Terminal</span>
</div>
<div className="terminal-actions">
<button
className="terminal-clear-btn"
onClick={clearHistory}
disabled={history.length === 0}
title="Clear history (Ctrl+L)"
onClick={handleClear}
data-testid="terminal-clear-btn"
title="Clear terminal (Ctrl+L)"
>
<Trash2 size={14} />
<span>Clear</span>
@@ -142,7 +165,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title="Close terminal (Esc)"
title="Close terminal"
>
<X size={20} />
</button>
@@ -150,38 +173,63 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
</div>
{/* Output area */}
<div className="terminal-content" ref={outputRef} data-testid="terminal-content">
{history.length === 0 ? (
<div
className="terminal-output"
ref={outputRef}
data-testid="terminal-output"
>
{showWelcome && history.length === 0 ? (
<div className="terminal-welcome" data-testid="terminal-welcome">
<p>Interactive Terminal</p>
<p>Type commands and press Enter to execute.</p>
<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 className="terminal-welcome-icon">
<TerminalIcon size={48} />
</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 className="terminal-output" data-testid="terminal-output">
{history.map((entry, index) => (
<div key={index} className="terminal-entry" data-testid={`terminal-entry-${index}`}>
<div className="terminal-history">
{history.map((entry) => (
<div
key={entry.id}
className="terminal-entry"
data-testid={`terminal-entry-${entry.id}`}
>
<div className="terminal-prompt-line">
<span className="terminal-prompt">$</span>
<span className="terminal-command">{entry.command}</span>
{entry.isRunning && <span className="terminal-running-indicator">●</span>}
</div>
{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}
</pre>
)}
{!entry.isRunning && entry.exitCode !== null && (
<div
className={`terminal-exit-code ${entry.exitCode !== 0 ? "error" : ""}`}
data-testid={`terminal-exit-${index}`}
>
{entry.exitCode === 0 ? "✓" : `✗ Exit code: ${entry.exitCode}`}
{entry.isRunning && (
<div className="terminal-running-indicator">
<span className="terminal-spinner" />
<span>Running...</span>
</div>
)}
</div>
@@ -192,38 +240,32 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
{/* Input area */}
<div className="terminal-input-area" data-testid="terminal-input-area">
<div className="terminal-input-line">
<span className="terminal-prompt">$</span>
<form onSubmit={handleSubmit} className="terminal-form">
<span className="terminal-input-prompt">$</span>
<input
ref={inputRef}
type="text"
className="terminal-input"
value={input}
onChange={(e) => setInput(e.target.value)}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command..."
placeholder={isRunning ? "Command running..." : "Type a command..."}
disabled={isRunning}
data-testid="terminal-input"
autoFocus
spellCheck={false}
autoComplete="off"
autoCorrect="off"
data-testid="terminal-input"
/>
{isRunning && (
<button
type="button"
className="terminal-kill-btn"
onClick={killCurrentCommand}
title="Kill process (Ctrl+C)"
data-testid="terminal-kill-btn"
title="Kill process (Ctrl+C)"
>
Stop
</button>
)}
</div>
<div className="terminal-status">
{currentDirectory}
{isRunning && <span className="terminal-status-running">Running...</span>}
</div>
</form>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
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 * as useTerminalModule from "../../hooks/useTerminal";
@@ -14,21 +14,26 @@ describe("TerminalModal", () => {
const mockOnClose = vi.fn();
const mockExecuteCommand = vi.fn();
const mockClearHistory = vi.fn();
const mockSetInput = 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 = {}) => ({
history: [],
input: "",
isRunning: false,
currentSessionId: null,
currentDirectory: "~/project",
isRunning: false,
inputValue: "",
historyIndex: -1,
error: null,
executeCommand: mockExecuteCommand,
clearHistory: mockClearHistory,
setInput: mockSetInput,
killCurrentCommand: mockKillCurrentCommand,
navigateHistory: mockNavigateHistory,
setInputValue: mockSetInputValue,
navigateHistoryUp: mockNavigateHistoryUp,
navigateHistoryDown: mockNavigateHistoryDown,
resetHistoryNavigation: mockResetHistoryNavigation,
...overrides,
});
@@ -36,9 +41,12 @@ describe("TerminalModal", () => {
mockOnClose.mockClear();
mockExecuteCommand.mockClear();
mockClearHistory.mockClear();
mockSetInput.mockClear();
mockKillCurrentCommand.mockClear();
mockNavigateHistory.mockClear();
mockSetInputValue.mockClear();
mockNavigateHistoryUp.mockClear();
mockNavigateHistoryDown.mockClear();
mockResetHistoryNavigation.mockClear();
mockUseTerminal.mockReturnValue(createMockTerminalState());
});
@@ -46,138 +54,156 @@ describe("TerminalModal", () => {
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", () => {
const { container } = render(
<TerminalModal isOpen={false} onClose={mockOnClose} />
);
expect(container.firstChild).toBeNull();
});
it("renders when open", () => {
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", () => {
it("shows welcome message when empty", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(
createMockTerminalState({
isRunning: true,
currentSessionId: "session-123",
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} />);
expect(screen.getByTestId("terminal-output")).toBeTruthy();
expect(screen.getByText("ls -la")).toBeTruthy();
expect(screen.getByTestId("terminal-output-0").textContent).toContain("file1");
expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
expect(screen.getByText("Running...")).toBeTruthy();
});
it("calls onClose when clicking overlay", () => {
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 () => {
it("displays command history with output", () => {
mockUseTerminal.mockReturnValue(
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} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => {
expect(mockExecuteCommand).toHaveBeenCalledWith("ls -la");
});
expect(screen.getByTestId("terminal-entry-entry-1")).toBeTruthy();
expect(screen.getByTestId("terminal-entry-entry-2")).toBeTruthy();
expect(screen.getByText("echo hello")).toBeTruthy();
expect(screen.getByText("ls")).toBeTruthy();
});
it("does not execute empty command on Enter", () => {
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", () => {
it("disables input while command is running", () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
input: "current",
})
createMockTerminalState({ isRunning: true })
);
mockNavigateHistory.mockReturnValue("previous");
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(mockNavigateHistory).toHaveBeenCalledWith("up", "current");
expect(mockSetInput).toHaveBeenCalledWith("previous");
expect(input).toBeDisabled();
expect(input).toHaveAttribute("placeholder", "Command running...");
});
it("navigates history on down arrow", () => {
it("handles Ctrl+C to kill running process", () => {
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
mockUseTerminal.mockReturnValue(
createMockTerminalState({
input: "current",
})
createMockTerminalState({ isRunning: true })
);
mockNavigateHistory.mockReturnValue("next");
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(mockNavigateHistory).toHaveBeenCalledWith("down", "current");
expect(mockSetInput).toHaveBeenCalledWith("next");
fireEvent.keyDown(input, { key: "c", ctrlKey: true });
expect(mockKillCurrentCommand).toHaveBeenCalled();
});
it("clears history on Ctrl+L", () => {
it("handles Ctrl+L to clear history", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
const input = screen.getByTestId("terminal-input");
@@ -186,134 +212,95 @@ describe("TerminalModal", () => {
expect(mockClearHistory).toHaveBeenCalled();
});
it("kills running command on Ctrl+C when running", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({
isRunning: true,
})
);
it("handles Up arrow to navigate history", () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(() => {
expect(mockKillCurrentCommand).toHaveBeenCalled();
});
});
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");
const input = screen.getByTestId("terminal-input");
expect(document.activeElement).toBe(input);
});
});
});

View File

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

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 {
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;
// 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)
router.post("/tasks/:id/archive", async (req, res) => {
try {
@@ -1497,6 +1505,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
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
const client = new GitHubClient(githubToken);
@@ -1598,6 +1617,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
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
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 ─────────────────────────────────────────────────
/**
@@ -1937,40 +1812,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
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.
* Silently logs errors without affecting the user experience.