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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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? }`)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 •
|
||||||
|
<kbd>Ctrl</kbd>+<kbd>C</kbd> Kill process •
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user