From 195b98e68820c8b9de6a288ebf46ac80caee5b7e Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 22:56:25 -0500 Subject: [PATCH 01/11] feat: enhance terminal functionality and settings - Added new endpoints for terminal settings: GET and PUT /settings to retrieve and update terminal configurations. - Implemented session limit checks during session creation, returning a 429 status when the limit is reached. - Introduced a new TerminalSection in settings view for customizing terminal appearance and behavior, including font family, default font size, line height, and screen reader mode. - Added support for new terminal features such as search functionality and improved error handling with a TerminalErrorBoundary component. - Updated terminal layout persistence to include session IDs for reconnection and enhanced terminal state management. - Introduced new keyboard shortcuts for terminal actions, including creating new terminal tabs. - Enhanced UI with scrollbar theming for terminal components. --- apps/server/src/routes/terminal/index.ts | 6 + .../src/routes/terminal/routes/sessions.ts | 15 + .../src/routes/terminal/routes/settings.ts | 55 + apps/server/src/services/terminal-service.ts | 56 +- apps/ui/eslint.config.mjs | 5 + apps/ui/package.json | 2 + apps/ui/src/components/ui/keyboard-map.tsx | 2 + .../ui/src/components/views/settings-view.tsx | 3 + .../views/settings-view/config/navigation.ts | 2 + .../settings-view/hooks/use-settings-view.ts | 1 + .../terminal/terminal-section.tsx | 175 +++ .../setup-view/components/terminal-output.tsx | 8 +- .../ui/src/components/views/terminal-view.tsx | 868 ++++++++++++-- .../terminal-view/terminal-error-boundary.tsx | 94 ++ .../views/terminal-view/terminal-panel.tsx | 1043 ++++++++++++++++- apps/ui/src/config/terminal-themes.ts | 30 +- apps/ui/src/lib/electron.ts | 5 + apps/ui/src/lib/http-api-client.ts | 38 + apps/ui/src/main.ts | 23 + apps/ui/src/preload.ts | 2 + apps/ui/src/routes/__root.tsx | 4 + apps/ui/src/store/app-store.ts | 399 ++++++- apps/ui/src/styles/global.css | 20 + package-lock.json | 22 +- 24 files changed, 2729 insertions(+), 149 deletions(-) create mode 100644 apps/server/src/routes/terminal/routes/settings.ts create mode 100644 apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx create mode 100644 apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx diff --git a/apps/server/src/routes/terminal/index.ts b/apps/server/src/routes/terminal/index.ts index 7ee0e978..404189d7 100644 --- a/apps/server/src/routes/terminal/index.ts +++ b/apps/server/src/routes/terminal/index.ts @@ -21,6 +21,10 @@ import { } from "./routes/sessions.js"; import { createSessionDeleteHandler } from "./routes/session-delete.js"; import { createSessionResizeHandler } from "./routes/session-resize.js"; +import { + createSettingsGetHandler, + createSettingsUpdateHandler, +} from "./routes/settings.js"; // Re-export for use in main index.ts export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired }; @@ -39,6 +43,8 @@ export function createTerminalRoutes(): Router { router.post("/sessions", createSessionsCreateHandler()); router.delete("/sessions/:id", createSessionDeleteHandler()); router.post("/sessions/:id/resize", createSessionResizeHandler()); + router.get("/settings", createSettingsGetHandler()); + router.put("/settings", createSettingsUpdateHandler()); return router; } diff --git a/apps/server/src/routes/terminal/routes/sessions.ts b/apps/server/src/routes/terminal/routes/sessions.ts index 1c1138c0..43a2b10f 100644 --- a/apps/server/src/routes/terminal/routes/sessions.ts +++ b/apps/server/src/routes/terminal/routes/sessions.ts @@ -34,6 +34,21 @@ export function createSessionsCreateHandler() { shell, }); + // Check if session creation was refused due to limit + if (!session) { + const maxSessions = terminalService.getMaxSessions(); + const currentSessions = terminalService.getSessionCount(); + logger.warn(`Session limit reached: ${currentSessions}/${maxSessions}`); + res.status(429).json({ + success: false, + error: "Maximum terminal sessions reached", + details: `Server limit is ${maxSessions} concurrent sessions. Please close unused terminals.`, + currentSessions, + maxSessions, + }); + return; + } + res.json({ success: true, data: { diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts new file mode 100644 index 00000000..9bd493c8 --- /dev/null +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -0,0 +1,55 @@ +/** + * GET/PUT /settings endpoint - Get/Update terminal settings + */ + +import type { Request, Response } from "express"; +import { getTerminalService } from "../../../services/terminal-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createSettingsGetHandler() { + return (_req: Request, res: Response): void => { + const terminalService = getTerminalService(); + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + }; +} + +export function createSettingsUpdateHandler() { + return (req: Request, res: Response): void => { + try { + const terminalService = getTerminalService(); + const { maxSessions } = req.body; + + if (typeof maxSessions === "number") { + if (maxSessions < 1 || maxSessions > 500) { + res.status(400).json({ + success: false, + error: "maxSessions must be between 1 and 500", + }); + return; + } + terminalService.setMaxSessions(maxSessions); + } + + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + } catch (error) { + logError(error, "Update terminal settings failed"); + res.status(500).json({ + success: false, + error: "Failed to update terminal settings", + details: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 6d8faa7f..8d671f79 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -13,9 +13,16 @@ import * as fs from "fs"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal +// Maximum number of concurrent terminal sessions +// Can be overridden via TERMINAL_MAX_SESSIONS environment variable +// Default set to 1000 - effectively unlimited for most use cases +let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || "1000", 10); + // Throttle output to prevent overwhelming WebSocket under heavy load -const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate -const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch +// Using 4ms for responsive input feedback while still preventing flood +// Note: 16ms caused perceived input lag, especially with backspace +const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input +const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency export interface TerminalSession { id: string; @@ -176,9 +183,40 @@ export class TerminalService extends EventEmitter { } /** - * Create a new terminal session + * Get current session count */ - createSession(options: TerminalOptions = {}): TerminalSession { + getSessionCount(): number { + return this.sessions.size; + } + + /** + * Get maximum allowed sessions + */ + getMaxSessions(): number { + return maxSessions; + } + + /** + * Set maximum allowed sessions (can be called dynamically) + */ + setMaxSessions(limit: number): void { + if (limit >= 1 && limit <= 500) { + maxSessions = limit; + console.log(`[Terminal] Max sessions limit updated to ${limit}`); + } + } + + /** + * Create a new terminal session + * Returns null if the maximum session limit has been reached + */ + createSession(options: TerminalOptions = {}): TerminalSession | null { + // Check session limit + if (this.sessions.size >= maxSessions) { + console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`); + return null; + } + const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const { shell: detectedShell, args: shellArgs } = this.detectShell(); @@ -188,11 +226,15 @@ export class TerminalService extends EventEmitter { const cwd = this.resolveWorkingDirectory(options.cwd); // Build environment with some useful defaults + // These settings ensure consistent terminal behavior across platforms const env: Record = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor", TERM_PROGRAM: "automaker-terminal", + // Ensure proper locale for character handling + LANG: process.env.LANG || "en_US.UTF-8", + LC_ALL: process.env.LC_ALL || process.env.LANG || "en_US.UTF-8", ...options.env, }; @@ -350,12 +392,16 @@ export class TerminalService extends EventEmitter { clearTimeout(session.resizeDebounceTimeout); session.resizeDebounceTimeout = null; } - session.pty.kill(); + // Use SIGKILL for forceful termination - shell processes may ignore SIGTERM/SIGHUP + // This ensures the PTY process is actually killed, especially on WSL + session.pty.kill("SIGKILL"); this.sessions.delete(sessionId); console.log(`[Terminal] Session ${sessionId} killed`); return true; } catch (error) { console.error(`[Terminal] Error killing session ${sessionId}:`, error); + // Still try to remove from map even if kill fails + this.sessions.delete(sessionId); return false; } } diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 150f0bad..aed07d98 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -2,6 +2,7 @@ import { defineConfig, globalIgnores } from "eslint/config"; import js from "@eslint/js"; import ts from "@typescript-eslint/eslint-plugin"; import tsParser from "@typescript-eslint/parser"; +import globals from "globals"; const eslintConfig = defineConfig([ js.configs.recommended, @@ -13,6 +14,10 @@ const eslintConfig = defineConfig([ ecmaVersion: "latest", sourceType: "module", }, + globals: { + ...globals.browser, + ...globals.es2021, + }, }, plugins: { "@typescript-eslint": ts, diff --git a/apps/ui/package.json b/apps/ui/package.json index d5d81131..2c228020 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -60,6 +60,8 @@ "@tanstack/react-router": "^1.141.6", "@uiw/react-codemirror": "^4.25.4", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", diff --git a/apps/ui/src/components/ui/keyboard-map.tsx b/apps/ui/src/components/ui/keyboard-map.tsx index c9ab6845..eaae645e 100644 --- a/apps/ui/src/components/ui/keyboard-map.tsx +++ b/apps/ui/src/components/ui/keyboard-map.tsx @@ -103,6 +103,7 @@ const SHORTCUT_LABELS: Record = { splitTerminalRight: "Split Right", splitTerminalDown: "Split Down", closeTerminal: "Close Terminal", + newTerminalTab: "New Tab", }; // Categorize shortcuts for color coding @@ -127,6 +128,7 @@ const SHORTCUT_CATEGORIES: Record ); + case "terminal": + return ; case "keyboard": return ( +
+
+
+ +
+

Terminal

+
+

+ Customize terminal appearance and behavior. Theme follows your app theme in Appearance settings. +

+
+
+ {/* Font Family */} +
+ + +
+ + {/* Default Font Size */} +
+
+ + {defaultFontSize}px +
+ setTerminalDefaultFontSize(value)} + className="flex-1" + /> +
+ + {/* Line Height */} +
+
+ + {lineHeight.toFixed(1)} +
+ { + setTerminalLineHeight(value); + }} + onValueCommit={() => { + toast.info("Line height changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ + {/* Scrollback Lines */} +
+
+ + + {(scrollbackLines / 1000).toFixed(0)}k lines + +
+ setTerminalScrollbackLines(value)} + onValueCommit={() => { + toast.info("Scrollback changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ + {/* Default Run Script */} +
+ +

+ Command to run automatically when opening a new terminal (e.g., "claude", "codex") +

+ setTerminalDefaultRunScript(e.target.value)} + placeholder="e.g., claude, codex, npm run dev" + className="bg-accent/30 border-border/50" + /> +
+ + {/* Screen Reader Mode */} +
+
+ +

+ Enable accessibility mode for screen readers +

+
+ { + setTerminalScreenReaderMode(checked); + toast.success(checked ? "Screen reader mode enabled" : "Screen reader mode disabled", { + description: "Restart terminal for changes to take effect", + }); + }} + /> +
+
+ + ); +} diff --git a/apps/ui/src/components/views/setup-view/components/terminal-output.tsx b/apps/ui/src/components/views/setup-view/components/terminal-output.tsx index ae7ac7f9..c7a36bce 100644 --- a/apps/ui/src/components/views/setup-view/components/terminal-output.tsx +++ b/apps/ui/src/components/views/setup-view/components/terminal-output.tsx @@ -4,14 +4,14 @@ interface TerminalOutputProps { export function TerminalOutput({ lines }: TerminalOutputProps) { return ( -
+
{lines.map((line, index) => ( -
- $ {line} +
+ $ {line}
))} {lines.length === 0 && ( -
Waiting for output...
+
Waiting for output...
)}
); diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 34d5879b..8c405d13 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -12,28 +12,42 @@ import { RefreshCw, X, SquarePlus, + Settings, } from "lucide-react"; -import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store"; -import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; +import { useAppStore, type TerminalPanelContent, type TerminalTab, type PersistedTerminalPanel } from "@/store/app-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { TERMINAL_FONT_OPTIONS } from "@/config/terminal-themes"; +import { toast } from "sonner"; import { Panel, PanelGroup, PanelResizeHandle, } from "react-resizable-panels"; import { TerminalPanel } from "./terminal-view/terminal-panel"; +import { TerminalErrorBoundary } from "./terminal-view/terminal-error-boundary"; import { DndContext, DragEndEvent, DragStartEvent, DragOverEvent, PointerSensor, + TouchSensor, + KeyboardSensor, useSensor, useSensors, closestCenter, DragOverlay, useDroppable, + useDraggable, + defaultDropAnimationSideEffects, } from "@dnd-kit/core"; import { cn } from "@/lib/utils"; @@ -48,39 +62,114 @@ interface TerminalStatus { }; } -// Tab component with drop target support +// Tab component with drag-drop support and double-click to rename function TerminalTabButton({ tab, isActive, onClick, onClose, + onRename, isDropTarget, + isDraggingTab, }: { tab: TerminalTab; isActive: boolean; onClick: () => void; onClose: () => void; + onRename: (newName: string) => void; isDropTarget: boolean; + isDraggingTab: boolean; }) { - const { setNodeRef, isOver } = useDroppable({ + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(tab.name); + const inputRef = useRef(null); + + const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `tab-${tab.id}`, data: { type: "tab", tabId: tab.id }, }); + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef: setDragRef, + isDragging, + } = useDraggable({ + id: `drag-tab-${tab.id}`, + data: { type: "drag-tab", tabId: tab.id }, + }); + + // Combine refs + const setRefs = (node: HTMLDivElement | null) => { + setDropRef(node); + setDragRef(node); + }; + + // Focus input when entering edit mode + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleDoubleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setEditName(tab.name); + setIsEditing(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + finishEditing(); + } else if (e.key === "Escape") { + e.preventDefault(); + setIsEditing(false); + setEditName(tab.name); + } + }; + + const finishEditing = () => { + const trimmedName = editName.trim(); + if (trimmedName && trimmedName !== tab.name) { + onRename(trimmedName); + } + setIsEditing(false); + }; + return (
- {tab.name} + {isEditing ? ( + setEditName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + onClick={(e) => e.stopPropagation()} + className="w-20 px-1 py-0 text-sm bg-background border border-border rounded focus:outline-none focus:ring-1 focus:ring-brand-500" + /> + ) : ( + {tab.name} + )} + + {/* Global Terminal Settings */} + + + + + +
+
+

Terminal Settings

+

+ Configure global terminal appearance +

+
+ + {/* Default Font Size */} +
+
+ + {terminalState.defaultFontSize}px +
+ setTerminalDefaultFontSize(value)} + onValueCommit={() => { + toast.info("Font size changed", { + description: "New terminals will use this size", + }); + }} + /> +
+ + {/* Font Family */} +
+ + +
+ + {/* Line Height */} +
+
+ + {terminalState.lineHeight.toFixed(1)} +
+ setTerminalLineHeight(value)} + onValueCommit={() => { + toast.info("Line height changed", { + description: "Restart terminal for changes to take effect", + }); + }} + /> +
+ + {/* Default Run Script */} +
+ + setTerminalDefaultRunScript(e.target.value)} + placeholder="e.g., claude, npm run dev" + className="h-8 text-sm" + /> +

+ Command to run when opening new terminals +

+
+ +
+
+
{/* Active tab content */}
- {activeTab?.layout ? ( + {terminalState.maximizedSessionId ? ( + // When a terminal is maximized, render only that terminal + { + const sessionId = terminalState.maximizedSessionId!; + toggleTerminalMaximized(sessionId); + killTerminal(sessionId); + createTerminal(); + }} + > + setActiveTerminalSession(terminalState.maximizedSessionId!)} + onClose={() => killTerminal(terminalState.maximizedSessionId!)} + onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)} + onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)} + onNewTab={createTerminalInNewTab} + isDragging={false} + isDropTarget={false} + fontSize={findTerminalFontSize(terminalState.maximizedSessionId)} + onFontSizeChange={(size) => setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)} + isMaximized={true} + onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)} + /> + + ) : activeTab?.layout ? ( renderPanelContent(activeTab.layout) ) : (
@@ -702,7 +1377,14 @@ export function TerminalView() {
{/* Drag overlay */} - + {activeDragId ? (
diff --git a/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx b/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx new file mode 100644 index 00000000..a0fd3d12 --- /dev/null +++ b/apps/ui/src/components/views/terminal-view/terminal-error-boundary.tsx @@ -0,0 +1,94 @@ +import React, { Component, ErrorInfo } from "react"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface Props { + children: React.ReactNode; + sessionId: string; + onRestart?: () => void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * BUG-06 fix: Error boundary for terminal components + * Catches xterm.js errors (WebGL context loss, canvas errors, etc.) + * and displays a friendly recovery UI instead of crashing the app. + */ +export class TerminalErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("[TerminalErrorBoundary] Terminal crashed:", { + sessionId: this.props.sessionId, + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + } + + handleRestart = () => { + this.setState({ hasError: false, error: null }); + this.props.onRestart?.(); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+ +
+
+

+ Terminal Crashed +

+

+ {this.state.error?.message?.includes("WebGL") + ? "WebGL context was lost. This can happen with GPU driver issues." + : "An unexpected error occurred in the terminal."} +

+
+ + {this.state.error && ( +
+ + Technical details + +
+                {this.state.error.message}
+              
+
+ )} +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index dbab56e0..2ee46236 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -11,12 +11,30 @@ import { ClipboardPaste, CheckSquare, Trash2, + ImageIcon, + Loader2, + Settings, + RotateCcw, + Search, + ChevronUp, + ChevronDown, + Maximize2, + Minimize2, + ArrowDown, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Slider } from "@/components/ui/slider"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { useDraggable, useDroppable } from "@dnd-kit/core"; import { useAppStore } from "@/store/app-store"; -import { getTerminalTheme } from "@/config/terminal-themes"; +import { useShallow } from "zustand/react/shallow"; +import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT } from "@/config/terminal-themes"; +import { toast } from "sonner"; +import { getElectronAPI } from "@/lib/electron"; // Font size constraints const MIN_FONT_SIZE = 8; @@ -26,6 +44,15 @@ const DEFAULT_FONT_SIZE = 14; // Resize constraints const RESIZE_DEBOUNCE_MS = 100; // Short debounce for responsive feel +// Image drag-drop constants +const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB + +// Large paste handling constants +const LARGE_PASTE_WARNING_THRESHOLD = 1024 * 1024; // 1MB - show warning for pastes this size or larger +const PASTE_CHUNK_SIZE = 8 * 1024; // 8KB chunks for large pastes +const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket + interface TerminalPanelProps { sessionId: string; authToken: string | null; @@ -34,15 +61,22 @@ interface TerminalPanelProps { onClose: () => void; onSplitHorizontal: () => void; onSplitVertical: () => void; + onNewTab?: () => void; isDragging?: boolean; isDropTarget?: boolean; fontSize: number; onFontSizeChange: (size: number) => void; + runCommandOnConnect?: string; // Command to run when terminal first connects (for new terminals) + onCommandRan?: () => void; // Callback when the initial command has been sent + isMaximized?: boolean; + onToggleMaximize?: () => void; } // Type for xterm Terminal - we'll use any since we're dynamically importing type XTerminal = InstanceType; type XFitAddon = InstanceType; +type XSearchAddon = InstanceType; +type XWebLinksAddon = InstanceType; export function TerminalPanel({ sessionId, @@ -52,10 +86,15 @@ export function TerminalPanel({ onClose, onSplitHorizontal, onSplitVertical, + onNewTab, isDragging = false, isDropTarget = false, fontSize, onFontSizeChange, + runCommandOnConnect, + onCommandRan, + isMaximized = false, + onToggleMaximize, }: TerminalPanelProps) { const terminalRef = useRef(null); const containerRef = useRef(null); @@ -63,9 +102,11 @@ export function TerminalPanel({ const fitAddonRef = useRef(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); + const heartbeatIntervalRef = useRef(null); const lastShortcutTimeRef = useRef(0); const resizeDebounceRef = useRef(null); const focusHandlerRef = useRef<{ dispose: () => void } | null>(null); + const linkProviderRef = useRef<{ dispose: () => void } | null>(null); const [isTerminalReady, setIsTerminalReady] = useState(false); const [shellName, setShellName] = useState("shell"); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); @@ -74,6 +115,48 @@ export function TerminalPanel({ const contextMenuRef = useRef(null); const [focusedMenuIndex, setFocusedMenuIndex] = useState(0); const focusedMenuIndexRef = useRef(0); + const [isImageDragOver, setIsImageDragOver] = useState(false); + const [isProcessingImage, setIsProcessingImage] = useState(false); + const hasRunInitialCommandRef = useRef(false); + const searchAddonRef = useRef(null); + const searchInputRef = useRef(null); + const [showSearch, setShowSearch] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const showSearchRef = useRef(false); + const [isAtBottom, setIsAtBottom] = useState(true); + + const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected" | "auth_failed">("connecting"); + const reconnectAttemptsRef = useRef(0); + const MAX_RECONNECT_ATTEMPTS = 5; + const INITIAL_RECONNECT_DELAY = 1000; + const [processExitCode, setProcessExitCode] = useState(null); + + // Get current project for image saving + const currentProject = useAppStore((state) => state.currentProject); + + // Get terminal settings from store - grouped with shallow comparison to reduce re-renders + const { + defaultRunScript, + screenReaderMode, + fontFamily, + scrollbackLines, + lineHeight, + } = useAppStore( + useShallow((state) => ({ + defaultRunScript: state.terminalState.defaultRunScript, + screenReaderMode: state.terminalState.screenReaderMode, + fontFamily: state.terminalState.fontFamily, + scrollbackLines: state.terminalState.scrollbackLines, + lineHeight: state.terminalState.lineHeight, + })) + ); + + // Action setters are stable references, can use individual selectors + const setTerminalDefaultRunScript = useAppStore((state) => state.setTerminalDefaultRunScript); + const setTerminalScreenReaderMode = useAppStore((state) => state.setTerminalScreenReaderMode); + const setTerminalFontFamily = useAppStore((state) => state.setTerminalFontFamily); + const setTerminalScrollbackLines = useAppStore((state) => state.setTerminalScrollbackLines); + const setTerminalLineHeight = useAppStore((state) => state.setTerminalLineHeight); // Detect platform on mount useEffect(() => { @@ -94,6 +177,30 @@ export function TerminalPanel({ const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); const effectiveTheme = getEffectiveTheme(); + // Track system dark mode preference for "system" theme + const [systemIsDark, setSystemIsDark] = useState(() => { + if (typeof window !== "undefined") { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } + return false; + }); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (e: MediaQueryListEvent) => { + setSystemIsDark(e.matches); + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, []); + + // Resolve "system" theme to actual light/dark + const resolvedTheme = effectiveTheme === "system" + ? (systemIsDark ? "dark" : "light") + : effectiveTheme; + // Use refs for callbacks and values to avoid effect re-runs const onFocusRef = useRef(onFocus); onFocusRef.current = onFocus; @@ -103,10 +210,12 @@ export function TerminalPanel({ onSplitHorizontalRef.current = onSplitHorizontal; const onSplitVerticalRef = useRef(onSplitVertical); onSplitVerticalRef.current = onSplitVertical; + const onNewTabRef = useRef(onNewTab); + onNewTabRef.current = onNewTab; const fontSizeRef = useRef(fontSize); fontSizeRef.current = fontSize; - const themeRef = useRef(effectiveTheme); - themeRef.current = effectiveTheme; + const themeRef = useRef(resolvedTheme); + themeRef.current = resolvedTheme; const copySelectionRef = useRef<() => Promise>(() => Promise.resolve(false)); const pasteFromClipboardRef = useRef<() => Promise>(() => Promise.resolve()); @@ -123,24 +232,71 @@ export function TerminalPanel({ onFontSizeChange(DEFAULT_FONT_SIZE); }, [onFontSizeChange]); + // Strip ANSI escape codes from text + const stripAnsi = (text: string): string => { + // Match ANSI escape sequences: + // - CSI sequences: \x1b[...letter + // - OSC sequences: \x1b]...ST + // - Other escape sequences: \x1b followed by various characters + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[>=<]|\x1b[78HM]|\x1b#[0-9]|\x1b./g, ''); + }; + // Copy selected text to clipboard const copySelection = useCallback(async (): Promise => { const terminal = xtermRef.current; if (!terminal) return false; const selection = terminal.getSelection(); - if (!selection) return false; + if (!selection) { + toast.error("Nothing to copy", { + description: "Select some text first", + }); + return false; + } try { - await navigator.clipboard.writeText(selection); + // Strip any ANSI escape codes that might be in the selection + const cleanText = stripAnsi(selection); + await navigator.clipboard.writeText(cleanText); + toast.success("Copied to clipboard"); return true; } catch (err) { console.error("[Terminal] Copy failed:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + toast.error("Copy failed", { + description: errorMessage.includes("permission") + ? "Clipboard permission denied" + : "Could not access clipboard", + }); return false; } }, []); copySelectionRef.current = copySelection; + // Helper function to send text in chunks with delay + const sendTextInChunks = useCallback(async (text: string) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + // For small pastes, send all at once + if (text.length <= PASTE_CHUNK_SIZE) { + ws.send(JSON.stringify({ type: "input", data: text })); + return; + } + + // For large pastes, chunk it + for (let i = 0; i < text.length; i += PASTE_CHUNK_SIZE) { + if (ws.readyState !== WebSocket.OPEN) break; + const chunk = text.slice(i, i + PASTE_CHUNK_SIZE); + ws.send(JSON.stringify({ type: "input", data: chunk })); + // Small delay between chunks to prevent overwhelming the WebSocket + if (i + PASTE_CHUNK_SIZE < text.length) { + await new Promise(resolve => setTimeout(resolve, PASTE_CHUNK_DELAY_MS)); + } + } + }, []); + // Paste from clipboard const pasteFromClipboard = useCallback(async () => { const terminal = xtermRef.current; @@ -148,13 +304,38 @@ export function TerminalPanel({ try { const text = await navigator.clipboard.readText(); - if (text && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: "input", data: text })); + if (!text) { + toast.error("Nothing to paste", { + description: "Clipboard is empty", + }); + return; } + + if (wsRef.current.readyState !== WebSocket.OPEN) { + toast.error("Terminal not connected"); + return; + } + + // Warn for large pastes + if (text.length >= LARGE_PASTE_WARNING_THRESHOLD) { + const sizeMB = (text.length / (1024 * 1024)).toFixed(1); + toast.warning(`Large paste (${sizeMB}MB)`, { + description: "Sending in chunks, this may take a moment...", + duration: 3000, + }); + } + + await sendTextInChunks(text); } catch (err) { console.error("[Terminal] Paste failed:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + toast.error("Paste failed", { + description: errorMessage.includes("permission") + ? "Clipboard permission denied" + : "Could not read from clipboard", + }); } - }, []); + }, [sendTextInChunks]); pasteFromClipboardRef.current = pasteFromClipboard; // Select all terminal content @@ -167,6 +348,35 @@ export function TerminalPanel({ xtermRef.current?.clear(); }, []); + // Search functions + const searchNext = useCallback(() => { + if (searchAddonRef.current && searchQuery) { + searchAddonRef.current.findNext(searchQuery, { caseSensitive: false, regex: false }); + } + }, [searchQuery]); + + const searchPrevious = useCallback(() => { + if (searchAddonRef.current && searchQuery) { + searchAddonRef.current.findPrevious(searchQuery, { caseSensitive: false, regex: false }); + } + }, [searchQuery]); + + const closeSearch = useCallback(() => { + setShowSearch(false); + showSearchRef.current = false; + setSearchQuery(""); + searchAddonRef.current?.clearDecorations(); + xtermRef.current?.focus(); + }, []); + + // Scroll to bottom of terminal + const scrollToBottom = useCallback(() => { + if (xtermRef.current) { + xtermRef.current.scrollToBottom(); + setIsAtBottom(true); + } + }, []); + // Close context menu const closeContextMenu = useCallback(() => { setContextMenu(null); @@ -224,10 +434,14 @@ export function TerminalPanel({ { Terminal }, { FitAddon }, { WebglAddon }, + { SearchAddon }, + { WebLinksAddon }, ] = await Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-webgl"), + import("@xterm/addon-search"), + import("@xterm/addon-web-links"), ]); // Also import CSS @@ -238,23 +452,149 @@ export function TerminalPanel({ // Get terminal theme matching the app theme const terminalTheme = getTerminalTheme(themeRef.current); + // Get settings from store (read at initialization time) + const terminalSettings = useAppStore.getState().terminalState; + const screenReaderEnabled = terminalSettings.screenReaderMode; + const terminalFontFamily = terminalSettings.fontFamily || DEFAULT_TERMINAL_FONT; + const terminalScrollback = terminalSettings.scrollbackLines || 5000; + const terminalLineHeight = terminalSettings.lineHeight || 1.0; + // Create terminal instance with the current global font size and theme const terminal = new Terminal({ cursorBlink: true, cursorStyle: "block", fontSize: fontSizeRef.current, - fontFamily: "Menlo, Monaco, 'Courier New', monospace", + fontFamily: terminalFontFamily, + lineHeight: terminalLineHeight, + letterSpacing: 0, theme: terminalTheme, allowProposedApi: true, + screenReaderMode: screenReaderEnabled, + scrollback: terminalScrollback, }); // Create fit addon const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); + // Create search addon + const searchAddon = new SearchAddon(); + terminal.loadAddon(searchAddon); + searchAddonRef.current = searchAddon; + + // Create web links addon for clickable URLs + const webLinksAddon = new WebLinksAddon(); + terminal.loadAddon(webLinksAddon); + // Open terminal terminal.open(terminalRef.current); + // Register custom link provider for file paths + // Detects patterns like /path/to/file.ts:123:45 or ./src/file.js:10 + const filePathLinkProvider = { + provideLinks: (lineNumber: number, callback: (links: { range: { start: { x: number; y: number }; end: { x: number; y: number } }; text: string; activate: (event: MouseEvent, text: string) => void }[] | undefined) => void) => { + const line = terminal.buffer.active.getLine(lineNumber - 1); + if (!line) { + callback(undefined); + return; + } + + const lineText = line.translateToString(true); + const links: { range: { start: { x: number; y: number }; end: { x: number; y: number } }; text: string; activate: (event: MouseEvent, text: string) => void }[] = []; + + // File path patterns: + // 1. Absolute Unix: /path/to/file.ext:line:col or /path/to/file.ext:line + // 2. Home directory: ~/path/to/file.ext:line:col + // 3. Absolute Windows: C:\path\to\file.ext:line:col (less common in terminal output) + // 4. Relative: ./path/file.ext:line or src/file.ext:line + // Common formats from compilers/linters: + // - ESLint: /path/file.ts:10:5 + // - TypeScript: src/file.ts(10,5) + // - Go: /path/file.go:10:5 + const filePathRegex = /(?:^|[\s'"(])(((?:\/|\.\/|\.\.\/|~\/)[^\s:'"()]+|[a-zA-Z]:\\[^\s:'"()]+|[a-zA-Z0-9_-]+\/[^\s:'"()]+)(?:[:(\s](\d+)(?:[:,)](\d+))?)?)/g; + + let match; + while ((match = filePathRegex.exec(lineText)) !== null) { + const fullMatch = match[1]; + const filePath = match[2]; + const lineNum = match[3] ? parseInt(match[3], 10) : undefined; + const colNum = match[4] ? parseInt(match[4], 10) : undefined; + + // Skip common false positives (URLs, etc.) + if (filePath.startsWith('http://') || filePath.startsWith('https://') || filePath.startsWith('ws://')) { + continue; + } + + // Calculate the start position (1-indexed for xterm) + const startX = match.index + (match[0].length - match[1].length) + 1; + const endX = startX + fullMatch.length; + + links.push({ + range: { + start: { x: startX, y: lineNumber }, + end: { x: endX, y: lineNumber }, + }, + text: fullMatch, + activate: async (event: MouseEvent, text: string) => { + // Parse the path and line/column from the matched text + const pathMatch = text.match(/^([^\s:()]+)(?:[:(\s](\d+)(?:[:,)](\d+))?)?/); + if (!pathMatch) return; + + const clickedPath = pathMatch[1]; + const clickedLine = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined; + const clickedCol = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined; + + // Resolve paths to absolute paths + let absolutePath = clickedPath; + const api = getElectronAPI(); + + if (clickedPath.startsWith('~/')) { + // Home directory path - expand ~ to user's home directory + try { + const homePath = await api.getPath?.('home'); + if (homePath) { + absolutePath = homePath + clickedPath.slice(1); // Replace ~ with home path + } + } catch { + // If we can't get home path, just use the path as-is + console.warn("[Terminal] Could not resolve home directory path"); + } + } else if (!clickedPath.startsWith('/') && !clickedPath.match(/^[a-zA-Z]:\\/)) { + // Relative path - resolve against project path + const projectPath = useAppStore.getState().currentProject?.path; + if (projectPath) { + absolutePath = `${projectPath}/${clickedPath}`.replace(/\/+/g, '/'); + } else { + toast.warning("Cannot open relative path", { + description: "No project selected. Open a project to click relative file paths.", + }); + return; + } + } + + // Open in editor using VS Code URL scheme + // Works in both web (via anchor click) and Electron (via shell.openExternal) + try { + const result = await api.openInEditor?.(absolutePath, clickedLine, clickedCol); + if (result && !result.success) { + toast.error("Failed to open in editor", { description: result.error }); + } + } catch (error) { + console.error("[Terminal] Failed to open file:", error); + toast.error("Failed to open file", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }, + }); + } + + callback(links.length > 0 ? links : undefined); + }, + }; + + linkProviderRef.current = terminal.registerLinkProvider(filePathLinkProvider); + // Try to load WebGL addon for better performance try { const webglAddon = new WebglAddon(); @@ -267,9 +607,9 @@ export function TerminalPanel({ } // Fit terminal to container - wait for stable dimensions - // Use multiple RAFs to let react-resizable-panels finish layout + // Use initial delay then multiple RAFs to let react-resizable-panels finish layout let fitAttempts = 0; - const MAX_FIT_ATTEMPTS = 5; + const MAX_FIT_ATTEMPTS = 10; let lastWidth = 0; let lastHeight = 0; @@ -300,7 +640,8 @@ export function TerminalPanel({ requestAnimationFrame(attemptFit); }; - requestAnimationFrame(attemptFit); + // Initial delay allows complex layouts to settle before attempting fit + setTimeout(() => requestAnimationFrame(attemptFit), 50); xtermRef.current = terminal; fitAddonRef.current = fitAddon; @@ -327,8 +668,8 @@ export function TerminalPanel({ // Use event.code for keyboard-layout-independent key detection const code = event.code; - // Alt+D - Split right - if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyD') { + // Ctrl+Shift+D - Split right (uses Ctrl+Shift to avoid Alt+D readline conflict) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyD') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -337,8 +678,8 @@ export function TerminalPanel({ return false; } - // Alt+S - Split down - if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyS') { + // Ctrl+Shift+S - Split down (uses Ctrl+Shift to avoid readline conflicts) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyS') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -347,8 +688,8 @@ export function TerminalPanel({ return false; } - // Alt+W - Close terminal - if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyW') { + // Ctrl+Shift+W - Close terminal (uses Ctrl+Shift to avoid readline conflicts) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyW') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -357,6 +698,16 @@ export function TerminalPanel({ return false; } + // Ctrl+Shift+T - New terminal tab (uses Ctrl+Shift for consistency) + if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyT') { + event.preventDefault(); + if (canTrigger && onNewTabRef.current) { + lastShortcutTimeRef.current = now; + onNewTabRef.current(); + } + return false; + } + const modKey = isMacRef.current ? event.metaKey : event.ctrlKey; const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey; @@ -394,6 +745,14 @@ export function TerminalPanel({ return false; } + // Ctrl+Shift+F / Cmd+Shift+F - Toggle search + if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyF') { + event.preventDefault(); + showSearchRef.current = !showSearchRef.current; + setShowSearch(showSearchRef.current); + return false; + } + // Let xterm handle all other keys return true; }); @@ -411,6 +770,12 @@ export function TerminalPanel({ focusHandlerRef.current = null; } + // Dispose link provider to prevent memory leak + if (linkProviderRef.current) { + linkProviderRef.current.dispose(); + linkProviderRef.current = null; + } + // Clear resize debounce timer if (resizeDebounceRef.current) { clearTimeout(resizeDebounceRef.current); @@ -422,6 +787,7 @@ export function TerminalPanel({ xtermRef.current = null; } fitAddonRef.current = null; + searchAddonRef.current = null; setIsTerminalReady(false); }; }, []); // No dependencies - only run once on mount @@ -444,6 +810,19 @@ export function TerminalPanel({ ws.onopen = () => { console.log(`[Terminal] WebSocket connected for session ${sessionId}`); + + setConnectionStatus("connected"); + reconnectAttemptsRef.current = 0; + + // Start heartbeat to keep connection alive (prevents proxy/load balancer timeouts) + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + } + heartbeatIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping" })); + } + }, 30000); // Ping every 30 seconds }; ws.onmessage = (event) => { @@ -460,6 +839,8 @@ export function TerminalPanel({ // Use reset() which is more reliable than clear() or escape sequences terminal.reset(); terminal.write(msg.data); + // Mark as already initialized - don't run initial command for restored sessions + hasRunInitialCommandRef.current = true; } break; case "connected": @@ -469,9 +850,26 @@ export function TerminalPanel({ const name = msg.shell.split("/").pop() || msg.shell; setShellName(name); } + // Run initial command if specified and not already run + // Only run for new terminals (no scrollback received) + if ( + runCommandOnConnect && + !hasRunInitialCommandRef.current && + ws.readyState === WebSocket.OPEN + ) { + hasRunInitialCommandRef.current = true; + // Small delay to let the shell prompt appear + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + "\n" })); + onCommandRan?.(); + } + }, 100); + } break; case "exit": terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); + setProcessExitCode(msg.exitCode); break; case "pong": // Heartbeat response @@ -486,18 +884,66 @@ export function TerminalPanel({ console.log(`[Terminal] WebSocket closed for session ${sessionId}:`, event.code, event.reason); wsRef.current = null; - // Don't reconnect if closed normally or auth failed - if (event.code === 1000 || event.code === 4001 || event.code === 4003) { + // Clear heartbeat interval + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } + + if (event.code === 4001) { + setConnectionStatus("auth_failed"); + toast.error("Terminal authentication expired", { + description: "Please unlock the terminal again to reconnect.", + duration: 5000, + }); return; } - // Attempt reconnect after a delay + // Don't reconnect if closed normally + if (event.code === 1000 || event.code === 4003) { + setConnectionStatus("disconnected"); + return; + } + + if (event.code === 4004) { + setConnectionStatus("disconnected"); + toast.error("Terminal session not found", { + description: "The session may have expired. Please create a new terminal.", + duration: 5000, + }); + return; + } + + reconnectAttemptsRef.current++; + + if (reconnectAttemptsRef.current > MAX_RECONNECT_ATTEMPTS) { + setConnectionStatus("disconnected"); + toast.error("Terminal disconnected", { + description: "Maximum reconnection attempts reached. Click to retry.", + action: { + label: "Retry", + onClick: () => { + reconnectAttemptsRef.current = 0; + setConnectionStatus("reconnecting"); + connect(); + }, + }, + duration: 10000, + }); + return; + } + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current - 1); + setConnectionStatus("reconnecting"); + + // Attempt reconnect after exponential delay reconnectTimeoutRef.current = setTimeout(() => { if (xtermRef.current) { - console.log(`[Terminal] Attempting reconnect for session ${sessionId}`); + console.log(`[Terminal] Attempting reconnect for session ${sessionId} (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`); connect(); } - }, 2000); + }, delay); }; ws.onerror = (error) => { @@ -517,6 +963,10 @@ export function TerminalPanel({ // Cleanup return () => { dataHandler.dispose(); + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = null; + } if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } @@ -580,12 +1030,69 @@ export function TerminalPanel({ }; }, [handleResize]); + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + xtermRef.current.options.fontSize = fontSize; + fitAddonRef.current?.fit(); + } + }, [fontSize, isTerminalReady]); + + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + xtermRef.current.options.fontFamily = fontFamily; + fitAddonRef.current?.fit(); + } + }, [fontFamily, isTerminalReady]); + + useEffect(() => { + if (xtermRef.current && isTerminalReady) { + xtermRef.current.options.lineHeight = lineHeight; + fitAddonRef.current?.fit(); + } + }, [lineHeight, isTerminalReady]); + // Focus terminal when becoming active or when terminal becomes ready useEffect(() => { - if (isActive && isTerminalReady && xtermRef.current) { + if (isActive && isTerminalReady && xtermRef.current && !showSearch) { xtermRef.current.focus(); } - }, [isActive, isTerminalReady]); + }, [isActive, isTerminalReady, showSearch]); + + // Focus search input when search bar opens + useEffect(() => { + if (showSearch && searchInputRef.current) { + searchInputRef.current.focus(); + searchInputRef.current.select(); + } + }, [showSearch]); + + // Monitor scroll position to show/hide "Jump to bottom" button + useEffect(() => { + if (!isTerminalReady || !terminalRef.current) return; + + // xterm creates a viewport element with class .xterm-viewport + const viewport = terminalRef.current.querySelector('.xterm-viewport') as HTMLElement | null; + if (!viewport) return; + + const checkScrollPosition = () => { + // Check if scrolled to bottom (with small tolerance for rounding) + const scrollTop = viewport.scrollTop; + const scrollHeight = viewport.scrollHeight; + const clientHeight = viewport.clientHeight; + const isBottom = scrollHeight - scrollTop - clientHeight <= 5; + setIsAtBottom(isBottom); + }; + + // Initial check + checkScrollPosition(); + + // Listen for scroll events + viewport.addEventListener('scroll', checkScrollPosition, { passive: true }); + + return () => { + viewport.removeEventListener('scroll', checkScrollPosition); + }; + }, [isTerminalReady]); // Update terminal font size when it changes useEffect(() => { @@ -607,13 +1114,13 @@ export function TerminalPanel({ } }, [fontSize, isTerminalReady]); - // Update terminal theme when app theme changes + // Update terminal theme when app theme changes (including system preference) useEffect(() => { if (xtermRef.current && isTerminalReady) { - const terminalTheme = getTerminalTheme(effectiveTheme); + const terminalTheme = getTerminalTheme(resolvedTheme); xtermRef.current.options.theme = terminalTheme; } - }, [effectiveTheme, isTerminalReady]); + }, [resolvedTheme, isTerminalReady]); // Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0) useEffect(() => { @@ -624,24 +1131,24 @@ export function TerminalPanel({ // Only handle if Ctrl (or Cmd on Mac) is pressed if (!e.ctrlKey && !e.metaKey) return; - // Ctrl/Cmd + Plus or Ctrl/Cmd + = (for keyboards without numpad) - if (e.key === "+" || e.key === "=") { + // Ctrl/Cmd + Plus (Equal key or NumpadAdd for international keyboard support) + if (e.code === "Equal" || e.code === "NumpadAdd") { e.preventDefault(); e.stopPropagation(); zoomIn(); return; } - // Ctrl/Cmd + Minus - if (e.key === "-") { + // Ctrl/Cmd + Minus (Minus key or NumpadSubtract) + if (e.code === "Minus" || e.code === "NumpadSubtract") { e.preventDefault(); e.stopPropagation(); zoomOut(); return; } - // Ctrl/Cmd + 0 to reset - if (e.key === "0") { + // Ctrl/Cmd + 0 to reset (Digit0 or Numpad0) + if (e.code === "Digit0" || e.code === "Numpad0") { e.preventDefault(); e.stopPropagation(); resetZoom(); @@ -710,23 +1217,29 @@ export function TerminalPanel({ switch (e.key) { case "Escape": e.preventDefault(); + e.stopPropagation(); closeContextMenu(); + xtermRef.current?.focus(); break; case "ArrowDown": e.preventDefault(); + e.stopPropagation(); updateFocusIndex((focusedMenuIndexRef.current + 1) % menuActions.length); break; case "ArrowUp": e.preventDefault(); + e.stopPropagation(); updateFocusIndex((focusedMenuIndexRef.current - 1 + menuActions.length) % menuActions.length); break; case "Enter": case " ": e.preventDefault(); + e.stopPropagation(); handleContextMenuAction(menuActions[focusedMenuIndexRef.current]); break; case "Tab": e.preventDefault(); + e.stopPropagation(); closeContextMenu(); break; } @@ -781,14 +1294,171 @@ export function TerminalPanel({ setContextMenu({ x, y }); }, []); + // Convert file to base64 + const fileToBase64 = useCallback((file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Failed to read file as base64')); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + }, []); + + // Save image to temp folder via Electron API + const saveImageToTemp = useCallback(async ( + base64Data: string, + filename: string, + mimeType: string + ): Promise => { + try { + const api = getElectronAPI(); + if (!api.saveImageToTemp) { + // Fallback path when Electron API is not available (browser mode) + console.warn('[Terminal] saveImageToTemp not available, returning fallback path'); + return `.automaker/images/${Date.now()}_${filename}`; + } + + const projectPath = currentProject?.path; + const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath); + if (result.success && result.path) { + return result.path; + } + console.error('[Terminal] Failed to save image:', result.error); + return null; + } catch (error) { + console.error('[Terminal] Error saving image:', error); + return null; + } + }, [currentProject?.path]); + + // Check if drag event contains image files + const hasImageFiles = useCallback((e: React.DragEvent): boolean => { + const types = e.dataTransfer.types; + const items = e.dataTransfer.items; + + // Check if Files type is present + if (!types.includes('Files')) return false; + + // Check if any item is an image + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file' && ACCEPTED_IMAGE_TYPES.includes(item.type)) { + return true; + } + } + return false; + }, []); + + // Handle image drag over terminal + const handleImageDragOver = useCallback((e: React.DragEvent) => { + // Only handle if contains image files + if (!hasImageFiles(e)) return; + + e.preventDefault(); + e.stopPropagation(); + setIsImageDragOver(true); + }, [hasImageFiles]); + + // Handle image drag leave + const handleImageDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only reset if leaving the actual container (not just moving to a child) + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (relatedTarget && terminalRef.current?.contains(relatedTarget)) { + return; + } + + setIsImageDragOver(false); + }, []); + + // Handle image drop on terminal + const handleImageDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsImageDragOver(false); + + if (isProcessingImage) return; + + const files = e.dataTransfer.files; + if (!files.length) return; + + // Filter to only image files + const imageFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (ACCEPTED_IMAGE_TYPES.includes(file.type)) { + if (file.size > MAX_IMAGE_SIZE) { + toast.error(`Image too large: ${file.name}`, { + description: 'Maximum size is 10MB', + }); + continue; + } + imageFiles.push(file); + } + } + + if (imageFiles.length === 0) { + toast.error('No valid images found', { + description: 'Drop PNG, JPG, GIF, or WebP images', + }); + return; + } + + setIsProcessingImage(true); + const savedPaths: string[] = []; + + for (const file of imageFiles) { + try { + const base64 = await fileToBase64(file); + const savedPath = await saveImageToTemp(base64, file.name, file.type); + if (savedPath) { + savedPaths.push(savedPath); + } else { + toast.error(`Failed to save: ${file.name}`); + } + } catch (error) { + console.error('[Terminal] Error processing image:', error); + toast.error(`Error processing: ${file.name}`); + } + } + + setIsProcessingImage(false); + + if (savedPaths.length === 0) return; + + // Send image paths to terminal as input + // Format: space-separated paths, each wrapped in quotes if containing spaces + const formattedPaths = savedPaths + .map(p => p.includes(' ') ? `"${p}"` : p) + .join(' '); + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'input', data: formattedPaths })); + toast.success( + savedPaths.length === 1 ? 'Image path inserted' : `${savedPaths.length} image paths inserted`, + { description: 'Press Enter to send' } + ); + } else { + toast.error('Terminal not connected'); + } + }, [isProcessingImage, fileToBase64, saveImageToTemp]); + // Combine refs for the container const setRefs = useCallback((node: HTMLDivElement | null) => { containerRef.current = node; setDropRef(node); }, [setDropRef]); - // Get current terminal theme for xterm styling - const currentTerminalTheme = getTerminalTheme(effectiveTheme); + // Get current terminal theme for xterm styling (resolved for system preference) + const currentTerminalTheme = getTerminalTheme(resolvedTheme); return (
)} + {/* Image drop overlay */} + {isImageDragOver && ( +
+
+ {isProcessingImage ? ( + <> + + Processing... + + ) : ( + <> + + Drop image for Claude Code + + )} +
+
+ )} + {/* Header bar with drag handle - uses app theme CSS variables */}
{/* Drag handle */} @@ -849,6 +1538,32 @@ export function TerminalPanel({ {fontSize}px )} + {connectionStatus === "reconnecting" && ( + + + Reconnecting... + + )} + {connectionStatus === "disconnected" && ( + + Disconnected + + )} + {connectionStatus === "auth_failed" && ( + + Auth Failed + + )} + {processExitCode !== null && ( + + Exited ({processExitCode}) + + )}
{/* Zoom and action buttons */} @@ -881,6 +1596,156 @@ export function TerminalPanel({ + {/* Settings popover */} + + + + + e.stopPropagation()} + > +
+
+
+ + {fontSize}px +
+
+ onFontSizeChange(value)} + className="flex-1" + /> + +
+
+ +
+ + setTerminalDefaultRunScript(e.target.value)} + placeholder="e.g., claude" + className="h-7 text-xs" + /> +

+ Command to run when creating a new terminal +

+
+ +
+ + +
+ +
+
+ + {(scrollbackLines / 1000).toFixed(0)}k lines +
+ { + setTerminalScrollbackLines(value); + }} + onValueCommit={() => { + toast.info("Scrollback changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ +
+
+ + {lineHeight.toFixed(1)} +
+ { + setTerminalLineHeight(value); + }} + onValueCommit={() => { + toast.info("Line height changed", { + description: "Restart terminal for changes to take effect", + }); + }} + className="flex-1" + /> +
+ +
+
+ +

+ Enable accessibility mode +

+
+ { + setTerminalScreenReaderMode(checked); + toast.info(checked ? "Screen reader enabled" : "Screen reader disabled", { + description: "Restart terminal for changes to take effect", + }); + }} + /> +
+ +
+

Zoom: Ctrl++ / Ctrl+- / Ctrl+0

+

Or use Ctrl+scroll wheel

+
+
+
+
+
{/* Split/close buttons */} @@ -908,6 +1773,24 @@ export function TerminalPanel({ > + {onToggleMaximize && ( + + )}
+ {/* Search bar */} + {showSearch && ( +
+ + { + setSearchQuery(e.target.value); + // Auto-search as user types + if (searchAddonRef.current && e.target.value) { + searchAddonRef.current.findNext(e.target.value, { caseSensitive: false, regex: false }); + } else if (searchAddonRef.current) { + searchAddonRef.current.clearDecorations(); + } + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + searchPrevious(); + } else { + searchNext(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + closeSearch(); + } + }} + placeholder="Search..." + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none min-w-0" + /> + + + +
+ )} + {/* Terminal container - uses terminal theme */}
+ {/* Jump to bottom button - shown when scrolled up */} + {!isAtBottom && ( + + )} + {/* Context menu */} {contextMenu && (
Promise; trashItem?: (filePath: string) => Promise; getPath: (name: string) => Promise; + openInEditor?: ( + filePath: string, + line?: number, + column?: number + ) => Promise<{ success: boolean; error?: string }>; saveImageToTemp?: ( data: string, filename: string, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 5814fa08..037aea13 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -217,6 +217,44 @@ export class HttpApiClient implements ElectronAPI { return { success: true }; } + async openInEditor( + filePath: string, + line?: number, + column?: number + ): Promise<{ success: boolean; error?: string }> { + // Build VS Code URL scheme: vscode://file/path:line:column + // This works on systems where VS Code's URL handler is registered + // URL encode the path to handle special characters (spaces, brackets, etc.) + // but preserve the leading slash for absolute paths + const encodedPath = filePath.startsWith('/') + ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') + : filePath.split('/').map(encodeURIComponent).join('/'); + let url = `vscode://file${encodedPath}`; + if (line !== undefined && line > 0) { + url += `:${line}`; + if (column !== undefined && column > 0) { + url += `:${column}`; + } + } + + try { + // Use anchor click approach which is most reliable for custom URL schemes + // This triggers the browser's URL handler without navigation issues + const anchor = document.createElement("a"); + anchor.href = url; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to open in editor", + }; + } + } + // File picker - uses server-side file browser dialog async openDirectory(): Promise { const fileBrowser = getGlobalFileBrowser(); diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index 4d84ffb7..e650f686 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -411,6 +411,29 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => { } }); +// Open file in editor (VS Code, etc.) with optional line/column +ipcMain.handle("shell:openInEditor", async (_, filePath: string, line?: number, column?: number) => { + try { + // Build VS Code URL scheme: vscode://file/path:line:column + // This works on all platforms where VS Code is installed + // URL encode the path to handle special characters (spaces, brackets, etc.) + const encodedPath = filePath.startsWith('/') + ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') + : filePath.split('/').map(encodeURIComponent).join('/'); + let url = `vscode://file${encodedPath}`; + if (line !== undefined && line > 0) { + url += `:${line}`; + if (column !== undefined && column > 0) { + url += `:${column}`; + } + } + await shell.openExternal(url); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +}); + // App info ipcMain.handle("app:getPath", async (_, name: Parameters[0]) => { return app.getPath(name); diff --git a/apps/ui/src/preload.ts b/apps/ui/src/preload.ts index da755111..79fa0d49 100644 --- a/apps/ui/src/preload.ts +++ b/apps/ui/src/preload.ts @@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.invoke("shell:openExternal", url), openPath: (filePath: string): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke("shell:openPath", filePath), + openInEditor: (filePath: string, line?: number, column?: number): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke("shell:openInEditor", filePath, line, column), // App info getPath: (name: string): Promise => ipcRenderer.invoke("app:getPath", name), diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index c144dd78..055af47d 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -36,6 +36,10 @@ function RootLayoutContent() { if (role === "textbox" || role === "searchbox" || role === "combobox") { return; } + // Don't intercept when focused inside a terminal + if (activeElement.closest(".xterm") || activeElement.closest("[data-terminal-container]")) { + return; + } } if (event.ctrlKey || event.altKey || event.metaKey) { diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index ee128598..aa1f63f0 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -176,6 +176,7 @@ export interface KeyboardShortcuts { splitTerminalRight: string; splitTerminalDown: string; closeTerminal: string; + newTerminalTab: string; } // Default keyboard shortcuts @@ -210,6 +211,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { splitTerminalRight: "Alt+D", splitTerminalDown: "Alt+S", closeTerminal: "Alt+W", + newTerminalTab: "Alt+T", }; export interface ImageAttachment { @@ -356,6 +358,7 @@ export type TerminalPanelContent = | { type: "terminal"; sessionId: string; size?: number; fontSize?: number } | { type: "split"; + id: string; // Stable ID for React key stability direction: "horizontal" | "vertical"; panels: TerminalPanelContent[]; size?: number; @@ -374,7 +377,57 @@ export interface TerminalState { tabs: TerminalTab[]; activeTabId: string | null; activeSessionId: string | null; + maximizedSessionId: string | null; // Session ID of the maximized terminal pane (null if none) defaultFontSize: number; // Default font size for new terminals + defaultRunScript: string; // Script to run when a new terminal is created (e.g., "claude" to start Claude Code) + screenReaderMode: boolean; // Enable screen reader accessibility mode + fontFamily: string; // Font family for terminal text + scrollbackLines: number; // Number of lines to keep in scrollback buffer + lineHeight: number; // Line height multiplier for terminal text + maxSessions: number; // Maximum concurrent terminal sessions (server setting) +} + +// Persisted terminal layout - now includes sessionIds for reconnection +// Used to restore terminal layout structure when switching projects +export type PersistedTerminalPanel = + | { type: "terminal"; size?: number; fontSize?: number; sessionId?: string } + | { + type: "split"; + id?: string; // Optional for backwards compatibility with older persisted layouts + direction: "horizontal" | "vertical"; + panels: PersistedTerminalPanel[]; + size?: number; + }; + +// Helper to generate unique split IDs +const generateSplitId = () => `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + +export interface PersistedTerminalTab { + id: string; + name: string; + layout: PersistedTerminalPanel | null; +} + +export interface PersistedTerminalState { + tabs: PersistedTerminalTab[]; + activeTabIndex: number; // Use index instead of ID since IDs are regenerated + defaultFontSize: number; + defaultRunScript?: string; // Optional to support existing persisted data + screenReaderMode?: boolean; // Optional to support existing persisted data + fontFamily?: string; // Optional to support existing persisted data + scrollbackLines?: number; // Optional to support existing persisted data + lineHeight?: number; // Optional to support existing persisted data +} + +// Persisted terminal settings - stored globally (not per-project) +export interface PersistedTerminalSettings { + defaultFontSize: number; + defaultRunScript: string; + screenReaderMode: boolean; + fontFamily: string; + scrollbackLines: number; + lineHeight: number; + maxSessions: number; } export interface AppState { @@ -491,6 +544,10 @@ export interface AppState { // Terminal state terminalState: TerminalState; + // Terminal layout persistence (per-project, keyed by project path) + // Stores the tab/split structure so it can be restored when switching projects + terminalLayoutByProject: Record; + // Spec Creation State (per-project, keyed by project path) // Tracks which project is currently having its spec generated specCreatingForProject: string | null; @@ -720,6 +777,7 @@ export interface AppActions { // Terminal actions setTerminalUnlocked: (unlocked: boolean, token?: string) => void; setActiveTerminalSession: (sessionId: string | null) => void; + toggleTerminalMaximized: (sessionId: string) => void; addTerminalToLayout: ( sessionId: string, direction?: "horizontal" | "vertical", @@ -729,16 +787,37 @@ export interface AppActions { swapTerminals: (sessionId1: string, sessionId2: string) => void; clearTerminalState: () => void; setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void; + setTerminalDefaultFontSize: (fontSize: number) => void; + setTerminalDefaultRunScript: (script: string) => void; + setTerminalScreenReaderMode: (enabled: boolean) => void; + setTerminalFontFamily: (fontFamily: string) => void; + setTerminalScrollbackLines: (lines: number) => void; + setTerminalLineHeight: (lineHeight: number) => void; + setTerminalMaxSessions: (maxSessions: number) => void; addTerminalTab: (name?: string) => string; removeTerminalTab: (tabId: string) => void; setActiveTerminalTab: (tabId: string) => void; renameTerminalTab: (tabId: string, name: string) => void; + reorderTerminalTabs: (fromTabId: string, toTabId: string) => void; moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void; addTerminalToTab: ( sessionId: string, tabId: string, direction?: "horizontal" | "vertical" ) => void; + setTerminalTabLayout: ( + tabId: string, + layout: TerminalPanelContent, + activeSessionId?: string + ) => void; + updateTerminalPanelSizes: ( + tabId: string, + panelKeys: string[], + sizes: number[] + ) => void; + saveTerminalLayout: (projectPath: string) => void; + getPersistedTerminalLayout: (projectPath: string) => PersistedTerminalState | null; + clearPersistedTerminalLayout: (projectPath: string) => void; // Spec Creation actions setSpecCreatingForProject: (projectPath: string | null) => void; @@ -841,8 +920,16 @@ const initialState: AppState = { tabs: [], activeTabId: null, activeSessionId: null, + maximizedSessionId: null, defaultFontSize: 14, + defaultRunScript: "", + screenReaderMode: false, + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + scrollbackLines: 5000, + lineHeight: 1.0, + maxSessions: 100, }, + terminalLayoutByProject: {}, specCreatingForProject: null, defaultPlanningMode: 'skip' as PlanningMode, defaultRequirePlanApproval: false, @@ -1732,6 +1819,19 @@ export const useAppStore = create()( }); }, + toggleTerminalMaximized: (sessionId) => { + const current = get().terminalState; + const newMaximized = current.maximizedSessionId === sessionId ? null : sessionId; + set({ + terminalState: { + ...current, + maximizedSessionId: newMaximized, + // Also set as active when maximizing + activeSessionId: newMaximized ?? current.activeSessionId, + }, + }); + }, + addTerminalToLayout: ( sessionId, direction = "horizontal", @@ -1781,6 +1881,7 @@ export const useAppStore = create()( // Found the target - split it return { type: "split", + id: generateSplitId(), direction: targetDirection, panels: [{ ...node, size: 50 }, newTerminal], }; @@ -1805,6 +1906,7 @@ export const useAppStore = create()( if (node.type === "terminal") { return { type: "split", + id: generateSplitId(), direction: targetDirection, panels: [{ ...node, size: 50 }, newTerminal], }; @@ -1823,6 +1925,7 @@ export const useAppStore = create()( // Different direction, wrap in new split return { type: "split", + id: generateSplitId(), direction: targetDirection, panels: [{ ...node, size: 50 }, newTerminal], }; @@ -1884,7 +1987,12 @@ export const useAppStore = create()( } if (newPanels.length === 0) return null; if (newPanels.length === 1) return newPanels[0]; - return { ...node, panels: newPanels }; + // Normalize sizes to sum to 100% + const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); + const normalizedPanels = totalSize > 0 + ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) + : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); + return { ...node, panels: normalizedPanels }; }; let newTabs = current.tabs.map((tab) => { @@ -1948,14 +2056,25 @@ export const useAppStore = create()( }, clearTerminalState: () => { + const current = get().terminalState; set({ terminalState: { - isUnlocked: false, - authToken: null, + // Preserve auth state - user shouldn't need to re-authenticate + isUnlocked: current.isUnlocked, + authToken: current.authToken, + // Clear session-specific state only tabs: [], activeTabId: null, activeSessionId: null, - defaultFontSize: 14, + maximizedSessionId: null, + // Preserve user preferences - these should persist across projects + defaultFontSize: current.defaultFontSize, + defaultRunScript: current.defaultRunScript, + screenReaderMode: current.screenReaderMode, + fontFamily: current.fontFamily, + scrollbackLines: current.scrollbackLines, + lineHeight: current.lineHeight, + maxSessions: current.maxSessions, }, }); }, @@ -1986,6 +2105,62 @@ export const useAppStore = create()( }); }, + setTerminalDefaultFontSize: (fontSize) => { + const current = get().terminalState; + const clampedSize = Math.max(8, Math.min(32, fontSize)); + set({ + terminalState: { ...current, defaultFontSize: clampedSize }, + }); + }, + + setTerminalDefaultRunScript: (script) => { + const current = get().terminalState; + set({ + terminalState: { ...current, defaultRunScript: script }, + }); + }, + + setTerminalScreenReaderMode: (enabled) => { + const current = get().terminalState; + set({ + terminalState: { ...current, screenReaderMode: enabled }, + }); + }, + + setTerminalFontFamily: (fontFamily) => { + const current = get().terminalState; + set({ + terminalState: { ...current, fontFamily }, + }); + }, + + setTerminalScrollbackLines: (lines) => { + const current = get().terminalState; + // Clamp to reasonable range: 1000 - 100000 lines + const clampedLines = Math.max(1000, Math.min(100000, lines)); + set({ + terminalState: { ...current, scrollbackLines: clampedLines }, + }); + }, + + setTerminalLineHeight: (lineHeight) => { + const current = get().terminalState; + // Clamp to reasonable range: 1.0 - 2.0 + const clampedHeight = Math.max(1.0, Math.min(2.0, lineHeight)); + set({ + terminalState: { ...current, lineHeight: clampedHeight }, + }); + }, + + setTerminalMaxSessions: (maxSessions) => { + const current = get().terminalState; + // Clamp to reasonable range: 1 - 500 + const clampedMax = Math.max(1, Math.min(500, maxSessions)); + set({ + terminalState: { ...current, maxSessions: clampedMax }, + }); + }, + addTerminalTab: (name) => { const current = get().terminalState; const newTabId = `tab-${Date.now()}`; @@ -2078,6 +2253,25 @@ export const useAppStore = create()( }); }, + reorderTerminalTabs: (fromTabId, toTabId) => { + const current = get().terminalState; + const fromIndex = current.tabs.findIndex((t) => t.id === fromTabId); + const toIndex = current.tabs.findIndex((t) => t.id === toTabId); + + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return; + } + + // Reorder tabs by moving fromIndex to toIndex + const newTabs = [...current.tabs]; + const [movedTab] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, movedTab); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + moveTerminalToTab: (sessionId, targetTabId) => { const current = get().terminalState; @@ -2128,7 +2322,12 @@ export const useAppStore = create()( } if (newPanels.length === 0) return null; if (newPanels.length === 1) return newPanels[0]; - return { ...node, panels: newPanels }; + // Normalize sizes to sum to 100% + const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); + const normalizedPanels = totalSize > 0 + ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) + : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); + return { ...node, panels: normalizedPanels }; }; const newSourceLayout = removeAndCollapse(sourceTab.layout); @@ -2178,6 +2377,7 @@ export const useAppStore = create()( } else if (targetTab.layout.type === "terminal") { newTargetLayout = { type: "split", + id: generateSplitId(), direction: "horizontal", panels: [{ ...targetTab.layout, size: 50 }, terminalNode], }; @@ -2228,6 +2428,7 @@ export const useAppStore = create()( } else if (tab.layout.type === "terminal") { newLayout = { type: "split", + id: generateSplitId(), direction, panels: [{ ...tab.layout, size: 50 }, terminalNode], }; @@ -2244,6 +2445,7 @@ export const useAppStore = create()( } else { newLayout = { type: "split", + id: generateSplitId(), direction, panels: [{ ...tab.layout, size: 50 }, terminalNode], }; @@ -2264,6 +2466,154 @@ export const useAppStore = create()( }); }, + setTerminalTabLayout: (tabId, layout, activeSessionId) => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab) return; + + const newTabs = current.tabs.map((t) => + t.id === tabId ? { ...t, layout } : t + ); + + // Find first terminal in layout if no activeSessionId provided + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === "terminal") return node.sessionId; + for (const p of node.panels) { + const found = findFirst(p); + if (found) return found; + } + return null; + }; + + const newActiveSessionId = activeSessionId || findFirst(layout); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: tabId, + activeSessionId: newActiveSessionId, + }, + }); + }, + + updateTerminalPanelSizes: (tabId, panelKeys, sizes) => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab || !tab.layout) return; + + // Create a map of panel key to new size + const sizeMap = new Map(); + panelKeys.forEach((key, index) => { + sizeMap.set(key, sizes[index]); + }); + + // Helper to generate panel key (matches getPanelKey in terminal-view.tsx) + const getPanelKey = (panel: TerminalPanelContent): string => { + if (panel.type === "terminal") return panel.sessionId; + const childKeys = panel.panels.map(getPanelKey).join("-"); + return `split-${panel.direction}-${childKeys}`; + }; + + // Recursively update sizes in the layout + const updateSizes = (panel: TerminalPanelContent): TerminalPanelContent => { + const key = getPanelKey(panel); + const newSize = sizeMap.get(key); + + if (panel.type === "terminal") { + return newSize !== undefined ? { ...panel, size: newSize } : panel; + } + + return { + ...panel, + size: newSize !== undefined ? newSize : panel.size, + panels: panel.panels.map(updateSizes), + }; + }; + + const updatedLayout = updateSizes(tab.layout); + + const newTabs = current.tabs.map((t) => + t.id === tabId ? { ...t, layout: updatedLayout } : t + ); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + // Convert runtime layout to persisted format (preserves sessionIds for reconnection) + saveTerminalLayout: (projectPath) => { + const current = get().terminalState; + if (current.tabs.length === 0) { + // Nothing to save, clear any existing layout + const { [projectPath]: _, ...rest } = get().terminalLayoutByProject; + set({ terminalLayoutByProject: rest }); + return; + } + + // Convert TerminalPanelContent to PersistedTerminalPanel + // Now preserves sessionId so we can reconnect when switching back + const persistPanel = ( + panel: TerminalPanelContent + ): PersistedTerminalPanel => { + if (panel.type === "terminal") { + return { + type: "terminal", + size: panel.size, + fontSize: panel.fontSize, + sessionId: panel.sessionId, // Preserve for reconnection + }; + } + return { + type: "split", + id: panel.id, // Preserve stable ID + direction: panel.direction, + panels: panel.panels.map(persistPanel), + size: panel.size, + }; + }; + + const persistedTabs: PersistedTerminalTab[] = current.tabs.map( + (tab) => ({ + id: tab.id, + name: tab.name, + layout: tab.layout ? persistPanel(tab.layout) : null, + }) + ); + + const activeTabIndex = current.tabs.findIndex( + (t) => t.id === current.activeTabId + ); + + const persisted: PersistedTerminalState = { + tabs: persistedTabs, + activeTabIndex: activeTabIndex >= 0 ? activeTabIndex : 0, + defaultFontSize: current.defaultFontSize, + defaultRunScript: current.defaultRunScript, + screenReaderMode: current.screenReaderMode, + fontFamily: current.fontFamily, + scrollbackLines: current.scrollbackLines, + lineHeight: current.lineHeight, + }; + + set({ + terminalLayoutByProject: { + ...get().terminalLayoutByProject, + [projectPath]: persisted, + }, + }); + }, + + getPersistedTerminalLayout: (projectPath) => { + return get().terminalLayoutByProject[projectPath] || null; + }, + + clearPersistedTerminalLayout: (projectPath) => { + const { [projectPath]: _, ...rest } = get().terminalLayoutByProject; + set({ terminalLayoutByProject: rest }); + }, + // Spec Creation actions setSpecCreatingForProject: (projectPath) => { set({ specCreatingForProject: projectPath }); @@ -2312,6 +2662,30 @@ export const useAppStore = create()( } } + // Rehydrate terminal settings from persisted state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const persistedSettings = (state as any).terminalSettings as PersistedTerminalSettings | undefined; + if (persistedSettings) { + state.terminalState = { + ...state.terminalState, + // Preserve session state (tabs, activeTabId, etc.) but restore settings + isUnlocked: state.terminalState?.isUnlocked ?? false, + authToken: state.terminalState?.authToken ?? null, + tabs: state.terminalState?.tabs ?? [], + activeTabId: state.terminalState?.activeTabId ?? null, + activeSessionId: state.terminalState?.activeSessionId ?? null, + maximizedSessionId: state.terminalState?.maximizedSessionId ?? null, + // Restore persisted settings + defaultFontSize: persistedSettings.defaultFontSize ?? 14, + defaultRunScript: persistedSettings.defaultRunScript ?? "", + screenReaderMode: persistedSettings.screenReaderMode ?? false, + fontFamily: persistedSettings.fontFamily ?? "Menlo, Monaco, 'Courier New', monospace", + scrollbackLines: persistedSettings.scrollbackLines ?? 5000, + lineHeight: persistedSettings.lineHeight ?? 1.0, + maxSessions: persistedSettings.maxSessions ?? 100, + }; + } + return state as AppState; }, partialize: (state) => ({ @@ -2349,10 +2723,23 @@ export const useAppStore = create()( lastSelectedSessionByProject: state.lastSelectedSessionByProject, // Board background settings boardBackgroundByProject: state.boardBackgroundByProject, + // Terminal layout persistence (per-project) + terminalLayoutByProject: state.terminalLayoutByProject, + // Terminal settings persistence (global) + terminalSettings: { + defaultFontSize: state.terminalState.defaultFontSize, + defaultRunScript: state.terminalState.defaultRunScript, + screenReaderMode: state.terminalState.screenReaderMode, + fontFamily: state.terminalState.fontFamily, + scrollbackLines: state.terminalState.scrollbackLines, + lineHeight: state.terminalState.lineHeight, + maxSessions: state.terminalState.maxSessions, + } as PersistedTerminalSettings, defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, defaultAIProfileId: state.defaultAIProfileId, - }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, } ) ); diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 04e15212..606feb6f 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -2929,3 +2929,23 @@ .animate-accordion-up { animation: accordion-up 0.2s ease-out forwards; } + +/* Terminal scrollbar theming */ +.xterm-viewport::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.xterm-viewport::-webkit-scrollbar-track { + background: var(--muted); + border-radius: 4px; +} + +.xterm-viewport::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.xterm-viewport::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} diff --git a/package-lock.json b/package-lock.json index 257bd1f2..c2221d93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,8 @@ "@tanstack/react-router": "^1.141.6", "@uiw/react-codemirror": "^4.25.4", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", @@ -1003,7 +1005,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -6290,6 +6292,24 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/addon-search": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz", + "integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, "node_modules/@xterm/addon-webgl": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz", From 2b1a7660b69f0aba2d71c219bbebb1539c281a1b Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:02:31 -0500 Subject: [PATCH 02/11] refactor: update terminal session limits and improve layout saving - Refactored session limit checks in terminal settings to use constants for minimum and maximum session values. - Enhanced terminal layout saving mechanism with debouncing to prevent excessive writes during rapid changes. - Updated error messages to reflect new session limit constants. --- .../src/routes/terminal/routes/settings.ts | 6 +++--- apps/server/src/services/terminal-service.ts | 6 +++++- apps/ui/src/components/views/terminal-view.tsx | 18 ++++++++++++++++-- package-lock.json | 2 +- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index 9bd493c8..f4d6c007 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -3,7 +3,7 @@ */ import type { Request, Response } from "express"; -import { getTerminalService } from "../../../services/terminal-service.js"; +import { getTerminalService, MIN_MAX_SESSIONS, MAX_MAX_SESSIONS } from "../../../services/terminal-service.js"; import { getErrorMessage, logError } from "../common.js"; export function createSettingsGetHandler() { @@ -26,10 +26,10 @@ export function createSettingsUpdateHandler() { const { maxSessions } = req.body; if (typeof maxSessions === "number") { - if (maxSessions < 1 || maxSessions > 500) { + if (maxSessions < MIN_MAX_SESSIONS || maxSessions > MAX_MAX_SESSIONS) { res.status(400).json({ success: false, - error: "maxSessions must be between 1 and 500", + error: `maxSessions must be between ${MIN_MAX_SESSIONS} and ${MAX_MAX_SESSIONS}`, }); return; } diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 8d671f79..4d506d25 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -13,6 +13,10 @@ import * as fs from "fs"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal +// Session limit constants - shared with routes/settings.ts +export const MIN_MAX_SESSIONS = 1; +export const MAX_MAX_SESSIONS = 500; + // Maximum number of concurrent terminal sessions // Can be overridden via TERMINAL_MAX_SESSIONS environment variable // Default set to 1000 - effectively unlimited for most use cases @@ -200,7 +204,7 @@ export class TerminalService extends EventEmitter { * Set maximum allowed sessions (can be called dynamically) */ setMaxSessions(limit: number): void { - if (limit >= 1 && limit <= 500) { + if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) { maxSessions = limit; console.log(`[Terminal] Max sessions limit updated to ${limit}`); } diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 8c405d13..4d801e42 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -627,12 +627,26 @@ export function TerminalView() { } }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); - // Save terminal layout whenever it changes (debounced via the effect) + // Save terminal layout whenever it changes (debounced to prevent excessive writes) // Also save when tabs become empty so closed terminals stay closed on refresh + const saveLayoutTimeoutRef = useRef(null); useEffect(() => { if (currentProject?.path && !isRestoringLayoutRef.current) { - saveTerminalLayout(currentProject.path); + // Debounce saves to prevent excessive localStorage writes during rapid changes + if (saveLayoutTimeoutRef.current) { + clearTimeout(saveLayoutTimeoutRef.current); + } + saveLayoutTimeoutRef.current = setTimeout(() => { + saveTerminalLayout(currentProject.path); + saveLayoutTimeoutRef.current = null; + }, 500); // 500ms debounce } + + return () => { + if (saveLayoutTimeoutRef.current) { + clearTimeout(saveLayoutTimeoutRef.current); + } + }; }, [terminalState.tabs, currentProject?.path, saveTerminalLayout]); // Handle password authentication diff --git a/package-lock.json b/package-lock.json index c2221d93..149743db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1005,7 +1005,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", From 18ccfa21e0978f7c212e1a17be08b07d3ff584f7 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:10:19 -0500 Subject: [PATCH 03/11] feat: enhance terminal service with path validation and session termination improvements - Added path validation in resolveWorkingDirectory to reject paths with null bytes and normalize paths. - Improved killSession method to attempt graceful termination with SIGTERM before falling back to SIGKILL after a delay. - Enhanced logging for session termination to provide clearer feedback on the process. --- apps/server/src/services/terminal-service.ts | 39 +++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 4d506d25..2a2602ec 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -9,6 +9,7 @@ import * as pty from "node-pty"; import { EventEmitter } from "events"; import * as os from "os"; import * as fs from "fs"; +import * as path from "path"; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -155,6 +156,7 @@ export class TerminalService extends EventEmitter { /** * Validate and resolve a working directory path + * Includes basic sanitization against null bytes and path normalization */ private resolveWorkingDirectory(requestedCwd?: string): string { const homeDir = os.homedir(); @@ -167,11 +169,21 @@ export class TerminalService extends EventEmitter { // Clean up the path let cwd = requestedCwd.trim(); + // Reject paths with null bytes (could bypass path checks) + if (cwd.includes("\0")) { + console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, "\\0")}`); + return homeDir; + } + // Fix double slashes at start (but not for Windows UNC paths) if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) { cwd = cwd.slice(1); } + // Normalize the path to resolve . and .. segments + // This converts relative paths to absolute and cleans up the path + cwd = path.resolve(cwd); + // Check if path exists and is a directory try { const stat = fs.statSync(cwd); @@ -379,6 +391,7 @@ export class TerminalService extends EventEmitter { /** * Kill a terminal session + * Attempts graceful SIGTERM first, then SIGKILL after 1 second if still alive */ killSession(sessionId: string): boolean { const session = this.sessions.get(sessionId); @@ -396,11 +409,27 @@ export class TerminalService extends EventEmitter { clearTimeout(session.resizeDebounceTimeout); session.resizeDebounceTimeout = null; } - // Use SIGKILL for forceful termination - shell processes may ignore SIGTERM/SIGHUP - // This ensures the PTY process is actually killed, especially on WSL - session.pty.kill("SIGKILL"); - this.sessions.delete(sessionId); - console.log(`[Terminal] Session ${sessionId} killed`); + + // First try graceful SIGTERM to allow process cleanup + console.log(`[Terminal] Session ${sessionId} sending SIGTERM`); + session.pty.kill("SIGTERM"); + + // Schedule SIGKILL fallback if process doesn't exit gracefully + // The onExit handler will remove session from map when it actually exits + setTimeout(() => { + if (this.sessions.has(sessionId)) { + console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); + try { + session.pty.kill("SIGKILL"); + } catch { + // Process may have already exited + } + // Force remove from map if still present + this.sessions.delete(sessionId); + } + }, 1000); + + console.log(`[Terminal] Session ${sessionId} kill initiated`); return true; } catch (error) { console.error(`[Terminal] Error killing session ${sessionId}:`, error); From 0e944e274aa49504f185d5c9e9af30f75a045a5e Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:13:30 -0500 Subject: [PATCH 04/11] feat: increase maximum terminal session limit and improve path handling - Updated the maximum terminal session limit from 500 to 1000 to accommodate more concurrent sessions. - Enhanced path handling in the editor and HTTP API client to normalize file paths for both Unix and Windows systems, ensuring consistent URL encoding. --- apps/server/src/services/terminal-service.ts | 2 +- apps/ui/src/lib/http-api-client.ts | 9 +++++---- apps/ui/src/main.ts | 8 +++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 2a2602ec..b569ec28 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -16,7 +16,7 @@ const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal // Session limit constants - shared with routes/settings.ts export const MIN_MAX_SESSIONS = 1; -export const MAX_MAX_SESSIONS = 500; +export const MAX_MAX_SESSIONS = 1000; // Maximum number of concurrent terminal sessions // Can be overridden via TERMINAL_MAX_SESSIONS environment variable diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 037aea13..c3694f99 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -225,10 +225,11 @@ export class HttpApiClient implements ElectronAPI { // Build VS Code URL scheme: vscode://file/path:line:column // This works on systems where VS Code's URL handler is registered // URL encode the path to handle special characters (spaces, brackets, etc.) - // but preserve the leading slash for absolute paths - const encodedPath = filePath.startsWith('/') - ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') - : filePath.split('/').map(encodeURIComponent).join('/'); + // Handle both Unix (/) and Windows (\) path separators + const normalizedPath = filePath.replace(/\\/g, '/'); + const encodedPath = normalizedPath.startsWith('/') + ? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/') + : normalizedPath.split('/').map(encodeURIComponent).join('/'); let url = `vscode://file${encodedPath}`; if (line !== undefined && line > 0) { url += `:${line}`; diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts index e650f686..a07ec8ab 100644 --- a/apps/ui/src/main.ts +++ b/apps/ui/src/main.ts @@ -417,9 +417,11 @@ ipcMain.handle("shell:openInEditor", async (_, filePath: string, line?: number, // Build VS Code URL scheme: vscode://file/path:line:column // This works on all platforms where VS Code is installed // URL encode the path to handle special characters (spaces, brackets, etc.) - const encodedPath = filePath.startsWith('/') - ? '/' + filePath.slice(1).split('/').map(encodeURIComponent).join('/') - : filePath.split('/').map(encodeURIComponent).join('/'); + // Handle both Unix (/) and Windows (\) path separators + const normalizedPath = filePath.replace(/\\/g, '/'); + const encodedPath = normalizedPath.startsWith('/') + ? '/' + normalizedPath.slice(1).split('/').map(encodeURIComponent).join('/') + : normalizedPath.split('/').map(encodeURIComponent).join('/'); let url = `vscode://file${encodedPath}`; if (line !== undefined && line > 0) { url += `:${line}`; From 39b21830dc6bb624e08accaebf2d4cfcbadafe40 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:18:13 -0500 Subject: [PATCH 05/11] feat: validate maxSessions input in settings update handler - Added validation to ensure maxSessions is an integer before processing the request. - Responds with a 400 status and an error message if the input is not a valid integer. --- .../src/routes/terminal/routes/settings.ts | 7 ++++ .../ui/src/components/views/terminal-view.tsx | 32 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index f4d6c007..21151ee6 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -26,6 +26,13 @@ export function createSettingsUpdateHandler() { const { maxSessions } = req.body; if (typeof maxSessions === "number") { + if (!Number.isInteger(maxSessions)) { + res.status(400).json({ + success: false, + error: "maxSessions must be an integer", + }); + return; + } if (maxSessions < MIN_MAX_SESSIONS || maxSessions > MAX_MAX_SESSIONS) { res.status(400).json({ success: false, diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 4d801e42..3559d6b7 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -245,7 +245,7 @@ export function TerminalView() { const lastCreateTimeRef = useRef(0); const isCreatingRef = useRef(false); const prevProjectPathRef = useRef(null); - const isRestoringLayoutRef = useRef(false); + const restoringProjectPathRef = useRef(null); const [newSessionIds, setNewSessionIds] = useState>(new Set()); const [serverSessionInfo, setServerSessionInfo] = useState<{ current: number; max: number } | null>(null); const hasShownHighRamWarningRef = useRef(false); @@ -438,11 +438,14 @@ export function TerminalView() { const currentPath = currentProject?.path || null; const prevPath = prevProjectPathRef.current; - // Skip if no change or if we're restoring layout - if (currentPath === prevPath || isRestoringLayoutRef.current) { + // Skip if no change + if (currentPath === prevPath) { return; } + // If we're restoring a different project, that restore will be stale - let it finish but ignore results + // The path check in restoreLayout will handle this + // Save layout for previous project (if there was one and has terminals) if (prevPath && terminalState.tabs.length > 0) { saveTerminalLayout(prevPath); @@ -462,13 +465,20 @@ export function TerminalView() { if (savedLayout && savedLayout.tabs.length > 0) { // Restore the saved layout - try to reconnect to existing sessions - isRestoringLayoutRef.current = true; + // Track which project we're restoring to detect stale restores + restoringProjectPathRef.current = currentPath; // Clear existing terminals first (only client state, sessions stay on server) clearTerminalState(); // Create terminals and build layout - try to reconnect or create new const restoreLayout = async () => { + // Check if we're still restoring the same project (user may have switched) + if (restoringProjectPathRef.current !== currentPath) { + console.log("[Terminal] Restore cancelled - project changed"); + return; + } + let failedSessions = 0; let totalSessions = 0; let reconnectedSessions = 0; @@ -578,6 +588,12 @@ export function TerminalView() { // For each saved tab, rebuild the layout for (let tabIndex = 0; tabIndex < savedLayout.tabs.length; tabIndex++) { + // Check if project changed during restore - bail out early + if (restoringProjectPathRef.current !== currentPath) { + console.log("[Terminal] Restore cancelled mid-loop - project changed"); + return; + } + const savedTab = savedLayout.tabs[tabIndex]; // Create the tab first @@ -619,7 +635,10 @@ export function TerminalView() { duration: 5000, }); } finally { - isRestoringLayoutRef.current = false; + // Only clear if we're still the active restore + if (restoringProjectPathRef.current === currentPath) { + restoringProjectPathRef.current = null; + } } }; @@ -631,7 +650,8 @@ export function TerminalView() { // Also save when tabs become empty so closed terminals stay closed on refresh const saveLayoutTimeoutRef = useRef(null); useEffect(() => { - if (currentProject?.path && !isRestoringLayoutRef.current) { + // Don't save while restoring this project's layout + if (currentProject?.path && restoringProjectPathRef.current !== currentProject.path) { // Debounce saves to prevent excessive localStorage writes during rapid changes if (saveLayoutTimeoutRef.current) { clearTimeout(saveLayoutTimeoutRef.current); From 8f5e782583b928c0273229622799c5daa55c7854 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:20:31 -0500 Subject: [PATCH 06/11] refactor: update token generation method and improve maxSessions validation - Changed the token generation method to use slice instead of substr for better readability. - Enhanced maxSessions validation in the settings update handler to check for undefined values and ensure the input is a number before processing. --- apps/server/src/routes/terminal/common.ts | 2 +- apps/server/src/routes/terminal/routes/settings.ts | 10 +++++++++- apps/server/src/services/terminal-service.ts | 2 +- apps/ui/src/components/views/terminal-view.tsx | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/routes/terminal/common.ts b/apps/server/src/routes/terminal/common.ts index 80b3a496..85039c39 100644 --- a/apps/server/src/routes/terminal/common.ts +++ b/apps/server/src/routes/terminal/common.ts @@ -54,7 +54,7 @@ export function getTokenData( export function generateToken(): string { return `term-${Date.now()}-${Math.random() .toString(36) - .substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`; + .slice(2, 17)}${Math.random().toString(36).slice(2, 17)}`; } /** diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index 21151ee6..08e670ae 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -25,7 +25,15 @@ export function createSettingsUpdateHandler() { const terminalService = getTerminalService(); const { maxSessions } = req.body; - if (typeof maxSessions === "number") { + // Validate maxSessions if provided + if (maxSessions !== undefined) { + if (typeof maxSessions !== "number") { + res.status(400).json({ + success: false, + error: "maxSessions must be a number", + }); + return; + } if (!Number.isInteger(maxSessions)) { res.status(400).json({ success: false, diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index b569ec28..c11af057 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -233,7 +233,7 @@ export class TerminalService extends EventEmitter { return null; } - const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const { shell: detectedShell, args: shellArgs } = this.detectShell(); const shell = options.shell || detectedShell; diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 3559d6b7..5f2b1a42 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -579,7 +579,7 @@ export function TerminalView() { return { type: "split", - id: persisted.id || `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: persisted.id || `split-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, direction: persisted.direction, panels: childPanels, size: persisted.size, From 820f43078b6870fb8ab9a28e2aec4bf09e5cb2f5 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:26:28 -0500 Subject: [PATCH 07/11] feat: enhance terminal input validation and update keyboard shortcuts - Added validation for terminal input to ensure it is a string and limited to 1MB to prevent memory issues. - Implemented checks for terminal resize dimensions to ensure they are positive integers within specified bounds. - Updated keyboard shortcuts for terminal actions to use Alt key combinations instead of Ctrl+Shift for better accessibility. --- apps/server/src/index.ts | 23 +++++++++++++++++++ apps/server/src/routes/terminal/common.ts | 7 +++--- .../views/terminal-view/terminal-panel.tsx | 22 +++++++++--------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index a4b32872..40c69377 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -297,11 +297,34 @@ terminalWss.on( switch (msg.type) { case "input": + // Validate input data type and length + if (typeof msg.data !== "string") { + ws.send(JSON.stringify({ type: "error", message: "Invalid input type" })); + break; + } + // Limit input size to 1MB to prevent memory issues + if (msg.data.length > 1024 * 1024) { + ws.send(JSON.stringify({ type: "error", message: "Input too large" })); + break; + } // Write user input to terminal terminalService.write(sessionId, msg.data); break; case "resize": + // Validate resize dimensions are positive integers within reasonable bounds + if ( + typeof msg.cols !== "number" || + typeof msg.rows !== "number" || + !Number.isInteger(msg.cols) || + !Number.isInteger(msg.rows) || + msg.cols < 1 || + msg.cols > 1000 || + msg.rows < 1 || + msg.rows > 500 + ) { + break; // Silently ignore invalid resize requests + } // Resize terminal with deduplication and rate limiting if (msg.cols && msg.rows) { const now = Date.now(); diff --git a/apps/server/src/routes/terminal/common.ts b/apps/server/src/routes/terminal/common.ts index 85039c39..7ce223d6 100644 --- a/apps/server/src/routes/terminal/common.ts +++ b/apps/server/src/routes/terminal/common.ts @@ -2,6 +2,7 @@ * Common utilities and state for terminal routes */ +import { randomBytes } from "crypto"; import { createLogger } from "../../lib/logger.js"; import type { Request, Response, NextFunction } from "express"; import { getTerminalService } from "../../services/terminal-service.js"; @@ -49,12 +50,10 @@ export function getTokenData( } /** - * Generate a secure random token + * Generate a cryptographically secure random token */ export function generateToken(): string { - return `term-${Date.now()}-${Math.random() - .toString(36) - .slice(2, 17)}${Math.random().toString(36).slice(2, 17)}`; + return `term-${randomBytes(32).toString("base64url")}`; } /** diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 2ee46236..4a10cc11 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -668,8 +668,8 @@ export function TerminalPanel({ // Use event.code for keyboard-layout-independent key detection const code = event.code; - // Ctrl+Shift+D - Split right (uses Ctrl+Shift to avoid Alt+D readline conflict) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyD') { + // Alt+D - Split right + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyD') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -678,8 +678,8 @@ export function TerminalPanel({ return false; } - // Ctrl+Shift+S - Split down (uses Ctrl+Shift to avoid readline conflicts) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyS') { + // Alt+S - Split down + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyS') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -688,8 +688,8 @@ export function TerminalPanel({ return false; } - // Ctrl+Shift+W - Close terminal (uses Ctrl+Shift to avoid readline conflicts) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyW') { + // Alt+W - Close terminal + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyW') { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -698,8 +698,8 @@ export function TerminalPanel({ return false; } - // Ctrl+Shift+T - New terminal tab (uses Ctrl+Shift for consistency) - if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && code === 'KeyT') { + // Alt+T - New terminal tab + if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyT') { event.preventDefault(); if (canTrigger && onNewTabRef.current) { lastShortcutTimeRef.current = now; @@ -1757,7 +1757,7 @@ export function TerminalPanel({ e.stopPropagation(); onSplitHorizontal(); }} - title="Split Right (Cmd+D)" + title="Split Right (Alt+D)" > @@ -1769,7 +1769,7 @@ export function TerminalPanel({ e.stopPropagation(); onSplitVertical(); }} - title="Split Down (Cmd+Shift+D)" + title="Split Down (Alt+S)" > @@ -1799,7 +1799,7 @@ export function TerminalPanel({ e.stopPropagation(); onClose(); }} - title="Close Terminal (Cmd+W)" + title="Close Terminal (Alt+W)" > From f504a00ce6ff1ff9e1728a02c7fcc3dc5e1f221c Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sat, 20 Dec 2025 23:35:03 -0500 Subject: [PATCH 08/11] feat: improve error handling in terminal settings retrieval and enhance path normalization - Wrapped the terminal settings retrieval in a try-catch block to handle potential errors and respond with a 500 status and error details. - Updated path normalization logic to skip resolution for WSL UNC paths, preventing potential issues with path handling in Windows Subsystem for Linux. - Enhanced unit tests for session termination to include timer-based assertions for graceful session killing. --- .../src/routes/terminal/routes/settings.ts | 25 +++++++++++++------ apps/server/src/services/terminal-service.ts | 6 +++-- .../unit/services/terminal-service.test.ts | 10 +++++++- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index 08e670ae..3af142a8 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -8,14 +8,23 @@ import { getErrorMessage, logError } from "../common.js"; export function createSettingsGetHandler() { return (_req: Request, res: Response): void => { - const terminalService = getTerminalService(); - res.json({ - success: true, - data: { - maxSessions: terminalService.getMaxSessions(), - currentSessions: terminalService.getSessionCount(), - }, - }); + try { + const terminalService = getTerminalService(); + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + } catch (error) { + logError(error, "Get terminal settings failed"); + res.status(500).json({ + success: false, + error: "Failed to get terminal settings", + details: getErrorMessage(error), + }); + } }; } diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index c11af057..8eb0d76b 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -181,8 +181,10 @@ export class TerminalService extends EventEmitter { } // Normalize the path to resolve . and .. segments - // This converts relative paths to absolute and cleans up the path - cwd = path.resolve(cwd); + // Skip normalization for WSL UNC paths as path.resolve would break them + if (!cwd.startsWith("//wsl")) { + cwd = path.resolve(cwd); + } // Check if path exists and is a directory try { diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index d273061a..b20e8047 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -408,6 +408,7 @@ describe("terminal-service.ts", () => { describe("killSession", () => { it("should kill existing session", () => { + vi.useFakeTimers(); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); @@ -416,8 +417,15 @@ describe("terminal-service.ts", () => { const result = service.killSession(session.id); expect(result).toBe(true); - expect(mockPtyProcess.kill).toHaveBeenCalled(); + expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM"); + + // Session is removed after SIGKILL timeout (1 second) + vi.advanceTimersByTime(1000); + + expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGKILL"); expect(service.getSession(session.id)).toBeUndefined(); + + vi.useRealTimers(); }); it("should return false for non-existent session", () => { From 7ddd9f8be103c7fa7f51462a0033549a0d25db3f Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sun, 21 Dec 2025 15:33:43 -0500 Subject: [PATCH 09/11] feat: enhance terminal navigation and session management - Implemented spatial navigation between terminal panes using directional shortcuts (Ctrl+Alt+Arrow keys). - Improved session handling by ensuring stale sessions are automatically removed when the server indicates they are invalid. - Added customizable keyboard shortcuts for terminal actions and enhanced search functionality with dedicated highlighting colors. - Updated terminal themes to include search highlighting colors for better visibility during searches. - Refactored terminal layout saving logic to prevent incomplete state saves during project restoration. --- .../ui/src/components/views/terminal-view.tsx | 163 ++++++++++++--- .../views/terminal-view/terminal-panel.tsx | 197 +++++++++++++++--- apps/ui/src/config/terminal-themes.ts | 85 ++++++++ apps/ui/src/hooks/use-keyboard-shortcuts.ts | 96 +++++++++ apps/ui/src/store/app-store.ts | 33 +++ docs/terminal.md | 26 ++- 6 files changed, 536 insertions(+), 64 deletions(-) diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 5f2b1a42..36e0054d 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -447,7 +447,8 @@ export function TerminalView() { // The path check in restoreLayout will handle this // Save layout for previous project (if there was one and has terminals) - if (prevPath && terminalState.tabs.length > 0) { + // BUT don't save if we were mid-restore for that project (would save incomplete state) + if (prevPath && terminalState.tabs.length > 0 && restoringProjectPathRef.current !== prevPath) { saveTerminalLayout(prevPath); } @@ -460,19 +461,25 @@ export function TerminalView() { return; } + // ALWAYS clear existing terminals when switching projects + // This is critical - prevents old project's terminals from "bleeding" into new project + clearTerminalState(); + // Check for saved layout for this project const savedLayout = getPersistedTerminalLayout(currentPath); - if (savedLayout && savedLayout.tabs.length > 0) { - // Restore the saved layout - try to reconnect to existing sessions - // Track which project we're restoring to detect stale restores - restoringProjectPathRef.current = currentPath; + // If no saved layout or no tabs, we're done - terminal starts fresh for this project + if (!savedLayout || savedLayout.tabs.length === 0) { + console.log("[Terminal] No saved layout for project, starting fresh"); + return; + } - // Clear existing terminals first (only client state, sessions stay on server) - clearTerminalState(); + // Restore the saved layout - try to reconnect to existing sessions + // Track which project we're restoring to detect stale restores + restoringProjectPathRef.current = currentPath; - // Create terminals and build layout - try to reconnect or create new - const restoreLayout = async () => { + // Create terminals and build layout - try to reconnect or create new + const restoreLayout = async () => { // Check if we're still restoring the same project (user may have switched) if (restoringProjectPathRef.current !== currentPath) { console.log("[Terminal] Restore cancelled - project changed"); @@ -643,21 +650,29 @@ export function TerminalView() { }; restoreLayout(); - } }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); // Save terminal layout whenever it changes (debounced to prevent excessive writes) // Also save when tabs become empty so closed terminals stay closed on refresh const saveLayoutTimeoutRef = useRef(null); + const pendingSavePathRef = useRef(null); useEffect(() => { + const projectPath = currentProject?.path; // Don't save while restoring this project's layout - if (currentProject?.path && restoringProjectPathRef.current !== currentProject.path) { + if (projectPath && restoringProjectPathRef.current !== projectPath) { // Debounce saves to prevent excessive localStorage writes during rapid changes if (saveLayoutTimeoutRef.current) { clearTimeout(saveLayoutTimeoutRef.current); } + // Capture the project path at schedule time so we save to the correct project + // even if user switches projects before the timeout fires + pendingSavePathRef.current = projectPath; saveLayoutTimeoutRef.current = setTimeout(() => { - saveTerminalLayout(currentProject.path); + // Only save if we're still on the same project + if (pendingSavePathRef.current === projectPath) { + saveTerminalLayout(projectPath); + } + pendingSavePathRef.current = null; saveLayoutTimeoutRef.current = null; }, 500); // 500ms debounce } @@ -949,28 +964,93 @@ export function TerminalView() { }); }, []); - // Navigate between terminal panes with Ctrl+Alt+Arrow keys - const navigateToTerminal = useCallback((direction: "next" | "prev") => { + // Navigate between terminal panes with directional awareness + // Arrow keys navigate in the actual spatial direction within the layout + const navigateToTerminal = useCallback((direction: "up" | "down" | "left" | "right") => { if (!activeTab?.layout) return; - const terminalIds = getTerminalIds(activeTab.layout); - if (terminalIds.length <= 1) return; - - const currentIndex = terminalIds.indexOf(terminalState.activeSessionId || ""); - if (currentIndex === -1) { + const currentSessionId = terminalState.activeSessionId; + if (!currentSessionId) { // If no terminal is active, focus the first one - setActiveTerminalSession(terminalIds[0]); + const terminalIds = getTerminalIds(activeTab.layout); + if (terminalIds.length > 0) { + setActiveTerminalSession(terminalIds[0]); + } return; } - let newIndex: number; - if (direction === "next") { - newIndex = (currentIndex + 1) % terminalIds.length; - } else { - newIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length; - } + // Find the terminal in the given direction + // The algorithm traverses the layout tree to find spatially adjacent terminals + const findTerminalInDirection = ( + layout: TerminalPanelContent, + targetId: string, + dir: "up" | "down" | "left" | "right" + ): string | null => { + // Helper to get all terminal IDs from a layout subtree + const getAllTerminals = (node: TerminalPanelContent): string[] => { + if (node.type === "terminal") return [node.sessionId]; + return node.panels.flatMap(getAllTerminals); + }; - setActiveTerminalSession(terminalIds[newIndex]); + // Helper to find terminal and its path in the tree + type PathEntry = { node: TerminalPanelContent; index: number; direction: "horizontal" | "vertical" }; + const findPath = ( + node: TerminalPanelContent, + target: string, + path: PathEntry[] = [] + ): PathEntry[] | null => { + if (node.type === "terminal") { + return node.sessionId === target ? path : null; + } + for (let i = 0; i < node.panels.length; i++) { + const result = findPath(node.panels[i], target, [ + ...path, + { node, index: i, direction: node.direction }, + ]); + if (result) return result; + } + return null; + }; + + const path = findPath(layout, targetId); + if (!path || path.length === 0) return null; + + // Determine which split direction we need based on arrow direction + // left/right navigation works in "horizontal" splits (panels side by side) + // up/down navigation works in "vertical" splits (panels stacked) + const neededDirection = dir === "left" || dir === "right" ? "horizontal" : "vertical"; + const goingForward = dir === "right" || dir === "down"; + + // Walk up the path to find a split in the right direction with an adjacent panel + for (let i = path.length - 1; i >= 0; i--) { + const entry = path[i]; + if (entry.direction === neededDirection) { + const siblings = entry.node.type === "split" ? entry.node.panels : []; + const nextIndex = goingForward ? entry.index + 1 : entry.index - 1; + + if (nextIndex >= 0 && nextIndex < siblings.length) { + // Found an adjacent panel in the right direction + const adjacentPanel = siblings[nextIndex]; + const adjacentTerminals = getAllTerminals(adjacentPanel); + + if (adjacentTerminals.length > 0) { + // When moving forward (right/down), pick the first terminal in that subtree + // When moving backward (left/up), pick the last terminal in that subtree + return goingForward + ? adjacentTerminals[0] + : adjacentTerminals[adjacentTerminals.length - 1]; + } + } + } + } + + return null; + }; + + const nextTerminal = findTerminalInDirection(activeTab.layout, currentSessionId, direction); + if (nextTerminal) { + setActiveTerminalSession(nextTerminal); + } }, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]); // Handle global keyboard shortcuts for pane navigation @@ -978,12 +1058,18 @@ export function TerminalView() { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) { - if (e.key === "ArrowRight" || e.key === "ArrowDown") { + if (e.key === "ArrowRight") { e.preventDefault(); - navigateToTerminal("next"); - } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + navigateToTerminal("right"); + } else if (e.key === "ArrowLeft") { e.preventDefault(); - navigateToTerminal("prev"); + navigateToTerminal("left"); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + navigateToTerminal("down"); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + navigateToTerminal("up"); } } }; @@ -1019,6 +1105,16 @@ export function TerminalView() { onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)} onSplitVertical={() => createTerminal("vertical", content.sessionId)} onNewTab={createTerminalInNewTab} + onNavigateUp={() => navigateToTerminal("up")} + onNavigateDown={() => navigateToTerminal("down")} + onNavigateLeft={() => navigateToTerminal("left")} + onNavigateRight={() => navigateToTerminal("right")} + onSessionInvalid={() => { + // Auto-remove stale session when server says it doesn't exist + // This handles cases like server restart where sessions are lost + console.log(`[Terminal] Session ${content.sessionId} is invalid, removing from layout`); + killTerminal(content.sessionId); + }} isDragging={activeDragId === content.sessionId} isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} fontSize={terminalFontSize} @@ -1384,6 +1480,11 @@ export function TerminalView() { onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)} onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)} onNewTab={createTerminalInNewTab} + onSessionInvalid={() => { + const sessionId = terminalState.maximizedSessionId!; + console.log(`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`); + killTerminal(sessionId); + }} isDragging={false} isDropTarget={false} fontSize={findTerminalFontSize(terminalState.maximizedSessionId)} diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 4a10cc11..95520ef7 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -30,8 +30,9 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useDraggable, useDroppable } from "@dnd-kit/core"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, type KeyboardShortcuts } from "@/store/app-store"; import { useShallow } from "zustand/react/shallow"; +import { matchesShortcutWithCode } from "@/hooks/use-keyboard-shortcuts"; import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT } from "@/config/terminal-themes"; import { toast } from "sonner"; import { getElectronAPI } from "@/lib/electron"; @@ -62,6 +63,11 @@ interface TerminalPanelProps { onSplitHorizontal: () => void; onSplitVertical: () => void; onNewTab?: () => void; + onNavigateUp?: () => void; // Navigate to terminal pane above + onNavigateDown?: () => void; // Navigate to terminal pane below + onNavigateLeft?: () => void; // Navigate to terminal pane on the left + onNavigateRight?: () => void; // Navigate to terminal pane on the right + onSessionInvalid?: () => void; // Called when session is no longer valid on server (e.g., server restarted) isDragging?: boolean; isDropTarget?: boolean; fontSize: number; @@ -87,6 +93,11 @@ export function TerminalPanel({ onSplitHorizontal, onSplitVertical, onNewTab, + onNavigateUp, + onNavigateDown, + onNavigateLeft, + onNavigateRight, + onSessionInvalid, isDragging = false, isDropTarget = false, fontSize, @@ -177,6 +188,15 @@ export function TerminalPanel({ const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); const effectiveTheme = getEffectiveTheme(); + // Get keyboard shortcuts from store - merged with defaults + const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); + const mergedShortcuts: KeyboardShortcuts = { + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...keyboardShortcuts, + }; + const shortcutsRef = useRef(mergedShortcuts); + shortcutsRef.current = mergedShortcuts; + // Track system dark mode preference for "system" theme const [systemIsDark, setSystemIsDark] = useState(() => { if (typeof window !== "undefined") { @@ -212,6 +232,16 @@ export function TerminalPanel({ onSplitVerticalRef.current = onSplitVertical; const onNewTabRef = useRef(onNewTab); onNewTabRef.current = onNewTab; + const onNavigateUpRef = useRef(onNavigateUp); + onNavigateUpRef.current = onNavigateUp; + const onNavigateDownRef = useRef(onNavigateDown); + onNavigateDownRef.current = onNavigateDown; + const onNavigateLeftRef = useRef(onNavigateLeft); + onNavigateLeftRef.current = onNavigateLeft; + const onNavigateRightRef = useRef(onNavigateRight); + onNavigateRightRef.current = onNavigateRight; + const onSessionInvalidRef = useRef(onSessionInvalid); + onSessionInvalidRef.current = onSessionInvalid; const fontSizeRef = useRef(fontSize); fontSizeRef.current = fontSize; const themeRef = useRef(resolvedTheme); @@ -348,18 +378,33 @@ export function TerminalPanel({ xtermRef.current?.clear(); }, []); + // Get theme colors for search highlighting + const terminalTheme = getTerminalTheme(effectiveTheme); + const searchOptions = { + caseSensitive: false, + regex: false, + decorations: { + matchBackground: terminalTheme.searchMatchBackground, + matchBorder: terminalTheme.searchMatchBorder, + matchOverviewRuler: terminalTheme.searchMatchBorder, + activeMatchBackground: terminalTheme.searchActiveMatchBackground, + activeMatchBorder: terminalTheme.searchActiveMatchBorder, + activeMatchColorOverviewRuler: terminalTheme.searchActiveMatchBorder, + }, + }; + // Search functions const searchNext = useCallback(() => { if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findNext(searchQuery, { caseSensitive: false, regex: false }); + searchAddonRef.current.findNext(searchQuery, searchOptions); } - }, [searchQuery]); + }, [searchQuery, searchOptions]); const searchPrevious = useCallback(() => { if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findPrevious(searchQuery, { caseSensitive: false, regex: false }); + searchAddonRef.current.findPrevious(searchQuery, searchOptions); } - }, [searchQuery]); + }, [searchQuery, searchOptions]); const closeSearch = useCallback(() => { setShowSearch(false); @@ -369,6 +414,32 @@ export function TerminalPanel({ xtermRef.current?.focus(); }, []); + // Handle pane navigation keyboard shortcuts at container level (capture phase) + // This ensures we intercept before xterm can process the event + const handleContainerKeyDownCapture = useCallback((event: React.KeyboardEvent) => { + // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally + if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { + const code = event.nativeEvent.code; + if (code === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + onNavigateRight?.(); + } else if (code === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + onNavigateLeft?.(); + } else if (code === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + onNavigateDown?.(); + } else if (code === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + onNavigateUp?.(); + } + } + }, [onNavigateUp, onNavigateDown, onNavigateLeft, onNavigateRight]); + // Scroll to bottom of terminal const scrollToBottom = useCallback(() => { if (xtermRef.current) { @@ -482,8 +553,21 @@ export function TerminalPanel({ terminal.loadAddon(searchAddon); searchAddonRef.current = searchAddon; - // Create web links addon for clickable URLs - const webLinksAddon = new WebLinksAddon(); + // Create web links addon for clickable URLs with custom handler for Electron + const webLinksAddon = new WebLinksAddon((_event: MouseEvent, uri: string) => { + // Use Electron API to open external links in system browser + const api = getElectronAPI(); + if (api?.openExternalLink) { + api.openExternalLink(uri).catch((error) => { + console.error("[Terminal] Failed to open URL:", error); + // Fallback to window.open if Electron API fails + window.open(uri, "_blank", "noopener,noreferrer"); + }); + } else { + // Web fallback + window.open(uri, "_blank", "noopener,noreferrer"); + } + }); terminal.loadAddon(webLinksAddon); // Open terminal @@ -661,15 +745,45 @@ export function TerminalPanel({ // Only intercept keydown events if (event.type !== 'keydown') return true; + // Use event.code for keyboard-layout-independent key detection + const code = event.code; + + // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally + // Handle this FIRST before any other checks to prevent xterm from capturing it + // Use explicit check for both Ctrl and Meta to work on all platforms + if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { + if (code === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + onNavigateRightRef.current?.(); + return false; + } else if (code === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + onNavigateLeftRef.current?.(); + return false; + } else if (code === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + onNavigateDownRef.current?.(); + return false; + } else if (code === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + onNavigateUpRef.current?.(); + return false; + } + } + // Check cooldown to prevent rapid terminal creation const now = Date.now(); const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS; - // Use event.code for keyboard-layout-independent key detection - const code = event.code; + // Get current shortcuts from ref (allows customization) + const shortcuts = shortcutsRef.current; - // Alt+D - Split right - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyD') { + // Split right (default: Alt+D) + if (matchesShortcutWithCode(event, shortcuts.splitTerminalRight)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -678,8 +792,8 @@ export function TerminalPanel({ return false; } - // Alt+S - Split down - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyS') { + // Split down (default: Alt+S) + if (matchesShortcutWithCode(event, shortcuts.splitTerminalDown)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -688,8 +802,8 @@ export function TerminalPanel({ return false; } - // Alt+W - Close terminal - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyW') { + // Close terminal (default: Alt+W) + if (matchesShortcutWithCode(event, shortcuts.closeTerminal)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -698,8 +812,8 @@ export function TerminalPanel({ return false; } - // Alt+T - New terminal tab - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyT') { + // New terminal tab (default: Alt+T) + if (matchesShortcutWithCode(event, shortcuts.newTerminalTab)) { event.preventDefault(); if (canTrigger && onNewTabRef.current) { lastShortcutTimeRef.current = now; @@ -843,11 +957,20 @@ export function TerminalPanel({ hasRunInitialCommandRef.current = true; } break; - case "connected": + case "connected": { console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`); + // Detect shell type from path + const shellPath = (msg.shell || "").toLowerCase(); + // Windows shells use backslash paths and include powershell/pwsh/cmd + const isWindowsShell = shellPath.includes("\\") || + shellPath.includes("powershell") || + shellPath.includes("pwsh") || + shellPath.includes("cmd.exe"); + const isPowerShell = shellPath.includes("powershell") || shellPath.includes("pwsh"); + if (msg.shell) { - // Extract shell name from path (e.g., "/bin/bash" -> "bash") - const name = msg.shell.split("/").pop() || msg.shell; + // Extract shell name from path (e.g., "/bin/bash" -> "bash", "C:\...\powershell.exe" -> "powershell.exe") + const name = msg.shell.split(/[/\\]/).pop() || msg.shell; setShellName(name); } // Run initial command if specified and not already run @@ -858,15 +981,22 @@ export function TerminalPanel({ ws.readyState === WebSocket.OPEN ) { hasRunInitialCommandRef.current = true; - // Small delay to let the shell prompt appear + // Use appropriate line ending for the shell type + // Windows shells (PowerShell, cmd) expect \r\n, Unix shells expect \n + const lineEnding = isWindowsShell ? "\r\n" : "\n"; + // PowerShell takes longer to initialize (profile loading, etc.) + // Use 500ms for PowerShell, 100ms for other shells + const delay = isPowerShell ? 500 : 100; + setTimeout(() => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + "\n" })); + ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + lineEnding })); onCommandRan?.(); } - }, 100); + }, delay); } break; + } case "exit": terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); setProcessExitCode(msg.exitCode); @@ -907,10 +1037,20 @@ export function TerminalPanel({ if (event.code === 4004) { setConnectionStatus("disconnected"); - toast.error("Terminal session not found", { - description: "The session may have expired. Please create a new terminal.", - duration: 5000, - }); + // Notify parent that this session is no longer valid on the server + // This allows automatic cleanup of stale sessions (e.g., after server restart) + if (onSessionInvalidRef.current) { + onSessionInvalidRef.current(); + toast.info("Terminal session expired", { + description: "The session was automatically removed. Create a new terminal to continue.", + duration: 5000, + }); + } else { + toast.error("Terminal session not found", { + description: "The session may have expired. Please create a new terminal.", + duration: 5000, + }); + } return; } @@ -1472,6 +1612,7 @@ export function TerminalPanel({ isOver && isDropTarget && "ring-2 ring-green-500 ring-inset" )} onClick={onFocus} + onKeyDownCapture={handleContainerKeyDownCapture} tabIndex={0} data-terminal-container="true" > @@ -1818,7 +1959,7 @@ export function TerminalPanel({ setSearchQuery(e.target.value); // Auto-search as user types if (searchAddonRef.current && e.target.value) { - searchAddonRef.current.findNext(e.target.value, { caseSensitive: false, regex: false }); + searchAddonRef.current.findNext(e.target.value, searchOptions); } else if (searchAddonRef.current) { searchAddonRef.current.clearDecorations(); } diff --git a/apps/ui/src/config/terminal-themes.ts b/apps/ui/src/config/terminal-themes.ts index c4c6ce6a..2aa44209 100644 --- a/apps/ui/src/config/terminal-themes.ts +++ b/apps/ui/src/config/terminal-themes.ts @@ -28,6 +28,11 @@ export interface TerminalTheme { brightMagenta: string; brightCyan: string; brightWhite: string; + // Search highlighting colors - for xterm SearchAddon + searchMatchBackground: string; + searchMatchBorder: string; + searchActiveMatchBackground: string; + searchActiveMatchBorder: string; } /** @@ -77,6 +82,11 @@ const darkTheme: TerminalTheme = { brightMagenta: "#c586c0", brightCyan: "#4ec9b0", brightWhite: "#ffffff", + // Search colors - bright yellow for visibility on dark background + searchMatchBackground: "#6b5300", + searchMatchBorder: "#e2ac00", + searchActiveMatchBackground: "#ff8c00", + searchActiveMatchBorder: "#ffb74d", }; // Light theme @@ -102,6 +112,11 @@ const lightTheme: TerminalTheme = { brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", + // Search colors - darker for visibility on light background + searchMatchBackground: "#fff3b0", + searchMatchBorder: "#c9a500", + searchActiveMatchBackground: "#ffcc00", + searchActiveMatchBorder: "#996600", }; // Retro / Cyberpunk theme - neon green on black @@ -128,6 +143,11 @@ const retroTheme: TerminalTheme = { brightMagenta: "#ff55ff", brightCyan: "#55ffff", brightWhite: "#ffffff", + // Search colors - magenta/pink for contrast with green text + searchMatchBackground: "#660066", + searchMatchBorder: "#ff00ff", + searchActiveMatchBackground: "#cc00cc", + searchActiveMatchBorder: "#ff66ff", }; // Dracula theme @@ -153,6 +173,11 @@ const draculaTheme: TerminalTheme = { brightMagenta: "#ff92df", brightCyan: "#a4ffff", brightWhite: "#ffffff", + // Search colors - orange for visibility + searchMatchBackground: "#8b5a00", + searchMatchBorder: "#ffb86c", + searchActiveMatchBackground: "#ff9500", + searchActiveMatchBorder: "#ffcc80", }; // Nord theme @@ -178,6 +203,11 @@ const nordTheme: TerminalTheme = { brightMagenta: "#b48ead", brightCyan: "#8fbcbb", brightWhite: "#eceff4", + // Search colors - warm yellow/orange for cold blue theme + searchMatchBackground: "#5e4a00", + searchMatchBorder: "#ebcb8b", + searchActiveMatchBackground: "#d08770", + searchActiveMatchBorder: "#e8a87a", }; // Monokai theme @@ -203,6 +233,11 @@ const monokaiTheme: TerminalTheme = { brightMagenta: "#ae81ff", brightCyan: "#a1efe4", brightWhite: "#f9f8f5", + // Search colors - orange/gold for contrast + searchMatchBackground: "#6b4400", + searchMatchBorder: "#f4bf75", + searchActiveMatchBackground: "#e69500", + searchActiveMatchBorder: "#ffd080", }; // Tokyo Night theme @@ -228,6 +263,11 @@ const tokyonightTheme: TerminalTheme = { brightMagenta: "#bb9af7", brightCyan: "#7dcfff", brightWhite: "#c0caf5", + // Search colors - warm orange for cold blue theme + searchMatchBackground: "#5c4a00", + searchMatchBorder: "#e0af68", + searchActiveMatchBackground: "#ff9e64", + searchActiveMatchBorder: "#ffb380", }; // Solarized Dark theme (improved contrast for WCAG compliance) @@ -253,6 +293,11 @@ const solarizedTheme: TerminalTheme = { brightMagenta: "#6c71c4", brightCyan: "#93a1a1", brightWhite: "#fdf6e3", + // Search colors - orange (solarized orange) for visibility + searchMatchBackground: "#5c3d00", + searchMatchBorder: "#b58900", + searchActiveMatchBackground: "#cb4b16", + searchActiveMatchBorder: "#e07040", }; // Gruvbox Dark theme @@ -278,6 +323,11 @@ const gruvboxTheme: TerminalTheme = { brightMagenta: "#d3869b", brightCyan: "#8ec07c", brightWhite: "#ebdbb2", + // Search colors - bright orange for gruvbox + searchMatchBackground: "#6b4500", + searchMatchBorder: "#d79921", + searchActiveMatchBackground: "#fe8019", + searchActiveMatchBorder: "#ffaa40", }; // Catppuccin Mocha theme @@ -303,6 +353,11 @@ const catppuccinTheme: TerminalTheme = { brightMagenta: "#cba6f7", brightCyan: "#94e2d5", brightWhite: "#a6adc8", + // Search colors - peach/orange from catppuccin palette + searchMatchBackground: "#5c4020", + searchMatchBorder: "#fab387", + searchActiveMatchBackground: "#fab387", + searchActiveMatchBorder: "#fcc8a0", }; // One Dark theme @@ -328,6 +383,11 @@ const onedarkTheme: TerminalTheme = { brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", + // Search colors - orange/gold for visibility + searchMatchBackground: "#5c4500", + searchMatchBorder: "#e5c07b", + searchActiveMatchBackground: "#d19a66", + searchActiveMatchBorder: "#e8b888", }; // Synthwave '84 theme @@ -353,6 +413,11 @@ const synthwaveTheme: TerminalTheme = { brightMagenta: "#ff7edb", brightCyan: "#03edf9", brightWhite: "#ffffff", + // Search colors - hot pink/magenta for synthwave aesthetic + searchMatchBackground: "#6b2a7a", + searchMatchBorder: "#ff7edb", + searchActiveMatchBackground: "#ff7edb", + searchActiveMatchBorder: "#ffffff", }; // Red theme - Dark theme with red accents @@ -378,6 +443,11 @@ const redTheme: TerminalTheme = { brightMagenta: "#cc77aa", brightCyan: "#77aaaa", brightWhite: "#d0c0c0", + // Search colors - orange/gold to contrast with red theme + searchMatchBackground: "#5a3520", + searchMatchBorder: "#ccaa55", + searchActiveMatchBackground: "#ddbb66", + searchActiveMatchBorder: "#ffdd88", }; // Cream theme - Warm, soft, easy on the eyes @@ -403,6 +473,11 @@ const creamTheme: TerminalTheme = { brightMagenta: "#c080a0", brightCyan: "#70b0a0", brightWhite: "#d0c0b0", + // Search colors - blue for contrast on light cream background + searchMatchBackground: "#c0d4e8", + searchMatchBorder: "#6b8aaa", + searchActiveMatchBackground: "#6b8aaa", + searchActiveMatchBorder: "#4a6a8a", }; // Sunset theme - Mellow oranges and soft pastels @@ -428,6 +503,11 @@ const sunsetTheme: TerminalTheme = { brightMagenta: "#dd88aa", brightCyan: "#88ddbb", brightWhite: "#f5e8dd", + // Search colors - orange for warm sunset theme + searchMatchBackground: "#5a3a30", + searchMatchBorder: "#ddaa66", + searchActiveMatchBackground: "#eebb77", + searchActiveMatchBorder: "#ffdd99", }; // Gray theme - Modern, minimal gray scheme inspired by Cursor @@ -453,6 +533,11 @@ const grayTheme: TerminalTheme = { brightMagenta: "#c098c8", brightCyan: "#80b8c8", brightWhite: "#e0e0e8", + // Search colors - blue for modern feel + searchMatchBackground: "#3a4a60", + searchMatchBorder: "#7090c0", + searchActiveMatchBackground: "#90b0d8", + searchActiveMatchBorder: "#b0d0f0", }; // Theme mapping diff --git a/apps/ui/src/hooks/use-keyboard-shortcuts.ts b/apps/ui/src/hooks/use-keyboard-shortcuts.ts index f5e72d12..2e77e160 100644 --- a/apps/ui/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/ui/src/hooks/use-keyboard-shortcuts.ts @@ -77,6 +77,102 @@ function isInputFocused(): boolean { return false; } +/** + * Convert a key character to its corresponding event.code + * This is used for keyboard-layout independent matching in terminals + */ +function keyToCode(key: string): string { + const upperKey = key.toUpperCase(); + + // Letters A-Z map to KeyA-KeyZ + if (/^[A-Z]$/.test(upperKey)) { + return `Key${upperKey}`; + } + + // Numbers 0-9 on main row map to Digit0-Digit9 + if (/^[0-9]$/.test(key)) { + return `Digit${key}`; + } + + // Special key mappings + const specialMappings: Record = { + "`": "Backquote", + "~": "Backquote", + "-": "Minus", + "_": "Minus", + "=": "Equal", + "+": "Equal", + "[": "BracketLeft", + "{": "BracketLeft", + "]": "BracketRight", + "}": "BracketRight", + "\\": "Backslash", + "|": "Backslash", + ";": "Semicolon", + ":": "Semicolon", + "'": "Quote", + '"': "Quote", + ",": "Comma", + "<": "Comma", + ".": "Period", + ">": "Period", + "/": "Slash", + "?": "Slash", + " ": "Space", + "Enter": "Enter", + "Tab": "Tab", + "Escape": "Escape", + "Backspace": "Backspace", + "Delete": "Delete", + "ArrowUp": "ArrowUp", + "ArrowDown": "ArrowDown", + "ArrowLeft": "ArrowLeft", + "ArrowRight": "ArrowRight", + }; + + return specialMappings[key] || specialMappings[upperKey] || key; +} + +/** + * Check if a keyboard event matches a shortcut definition using event.code + * This is keyboard-layout independent - useful for terminals where Alt+key + * combinations can produce special characters with event.key + */ +export function matchesShortcutWithCode(event: KeyboardEvent, shortcutStr: string): boolean { + const shortcut = parseShortcut(shortcutStr); + if (!shortcut.key) return false; + + // Convert the shortcut key to event.code format + const expectedCode = keyToCode(shortcut.key); + + // Check if the code matches + if (event.code !== expectedCode) { + return false; + } + + // Check modifier keys + const cmdCtrlPressed = event.metaKey || event.ctrlKey; + const shiftPressed = event.shiftKey; + const altPressed = event.altKey; + + // If shortcut requires cmdCtrl, it must be pressed + if (shortcut.cmdCtrl && !cmdCtrlPressed) return false; + // If shortcut doesn't require cmdCtrl, it shouldn't be pressed + if (!shortcut.cmdCtrl && cmdCtrlPressed) return false; + + // If shortcut requires shift, it must be pressed + if (shortcut.shift && !shiftPressed) return false; + // If shortcut doesn't require shift, it shouldn't be pressed + if (!shortcut.shift && shiftPressed) return false; + + // If shortcut requires alt, it must be pressed + if (shortcut.alt && !altPressed) return false; + // If shortcut doesn't require alt, it shouldn't be pressed + if (!shortcut.alt && altPressed) return false; + + return true; +} + /** * Check if a keyboard event matches a shortcut definition */ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index aa1f63f0..5c942a18 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -2239,6 +2239,9 @@ export const useAppStore = create()( ...current, activeTabId: tabId, activeSessionId: newActiveSessionId, + // Clear maximized state when switching tabs - the maximized terminal + // belongs to the previous tab and shouldn't persist across tab switches + maximizedSessionId: null, }, }); }, @@ -2636,6 +2639,36 @@ export const useAppStore = create()( { name: "automaker-storage", version: 2, // Increment when making breaking changes to persisted state + // Custom merge function to properly restore terminal settings on every load + // The default shallow merge doesn't work because we persist terminalSettings + // separately from terminalState (to avoid persisting session data like tabs) + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial & { terminalSettings?: PersistedTerminalSettings }; + const current = currentState as AppState & AppActions; + + // Start with default shallow merge + const merged = { ...current, ...persisted } as AppState & AppActions; + + // Restore terminal settings into terminalState + // terminalSettings is persisted separately from terminalState to avoid + // persisting session data (tabs, activeSessionId, etc.) + if (persisted.terminalSettings) { + merged.terminalState = { + // Start with current (initial) terminalState for session fields + ...current.terminalState, + // Override with persisted settings + defaultFontSize: persisted.terminalSettings.defaultFontSize ?? current.terminalState.defaultFontSize, + defaultRunScript: persisted.terminalSettings.defaultRunScript ?? current.terminalState.defaultRunScript, + screenReaderMode: persisted.terminalSettings.screenReaderMode ?? current.terminalState.screenReaderMode, + fontFamily: persisted.terminalSettings.fontFamily ?? current.terminalState.fontFamily, + scrollbackLines: persisted.terminalSettings.scrollbackLines ?? current.terminalState.scrollbackLines, + lineHeight: persisted.terminalSettings.lineHeight ?? current.terminalState.lineHeight, + maxSessions: persisted.terminalSettings.maxSessions ?? current.terminalState.maxSessions, + }; + } + + return merged; + }, migrate: (persistedState: unknown, version: number) => { const state = persistedState as Partial; diff --git a/docs/terminal.md b/docs/terminal.md index 8a30678f..ae78623f 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -32,11 +32,27 @@ When password protection is enabled: When the terminal is focused, the following shortcuts are available: -| Shortcut | Action | -| -------- | --------------------------------------- | -| `Alt+D` | Split terminal right (horizontal split) | -| `Alt+S` | Split terminal down (vertical split) | -| `Alt+W` | Close current terminal | +| Shortcut | Action | +| -------- | ---------------------------------------- | +| `Alt+T` | Open new terminal tab | +| `Alt+D` | Split terminal right (horizontal split) | +| `Alt+S` | Split terminal down (vertical split) | +| `Alt+W` | Close current terminal | + +These shortcuts are customizable via the keyboard shortcuts settings (Settings > Keyboard Shortcuts). + +### Split Pane Navigation + +Navigate between terminal panes using directional shortcuts: + +| Shortcut | Action | +| --------------------------------- | ----------------------------------- | +| `Ctrl+Alt+ArrowUp` (or `Cmd+Alt`) | Move focus to terminal pane above | +| `Ctrl+Alt+ArrowDown` | Move focus to terminal pane below | +| `Ctrl+Alt+ArrowLeft` | Move focus to terminal pane on left | +| `Ctrl+Alt+ArrowRight` | Move focus to terminal pane on right| + +The navigation is spatially aware - pressing Down will move to the terminal below your current one, not just cycle through terminals in order. Global shortcut (works anywhere in the app): | Shortcut | Action | From 7869ec046acd49a4b06da6b8c5a65a3862b87c88 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sun, 21 Dec 2025 18:03:42 -0500 Subject: [PATCH 10/11] feat: enhance terminal session management and cleanup - Added functionality to collect and kill all terminal sessions on the server before clearing terminal state to prevent orphaned processes. - Implemented cleanup of terminal sessions during page unload using sendBeacon for reliable delivery. - Refactored terminal state clearing logic to ensure server sessions are terminated before switching projects. - Improved handling of search decorations to prevent visual artifacts during terminal disposal and content restoration. --- .../ui/src/components/views/terminal-view.tsx | 98 ++++++++++++++++++- .../views/terminal-view/terminal-panel.tsx | 11 ++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 36e0054d..9cefa582 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -269,6 +269,49 @@ export function TerminalView() { const defaultRunScript = useAppStore((state) => state.terminalState.defaultRunScript); const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; + + // Helper to collect all session IDs from all tabs + const collectAllSessionIds = useCallback((): string[] => { + const sessionIds: string[] = []; + const collectFromLayout = (node: TerminalPanelContent | null): void => { + if (!node) return; + if (node.type === "terminal") { + sessionIds.push(node.sessionId); + } else { + node.panels.forEach(collectFromLayout); + } + }; + terminalState.tabs.forEach(tab => collectFromLayout(tab.layout)); + return sessionIds; + }, [terminalState.tabs]); + + // Kill all terminal sessions on the server + // This should be called before clearTerminalState() to prevent orphaned server sessions + const killAllSessions = useCallback(async () => { + const sessionIds = collectAllSessionIds(); + if (sessionIds.length === 0) return; + + const headers: Record = {}; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + console.log(`[Terminal] Killing ${sessionIds.length} sessions on server`); + + // Kill all sessions in parallel + await Promise.allSettled( + sessionIds.map(async (sessionId) => { + try { + await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, { + method: "DELETE", + headers, + }); + } catch (err) { + console.error(`[Terminal] Failed to kill session ${sessionId}:`, err); + } + }) + ); + }, [collectAllSessionIds, terminalState.authToken, serverUrl]); const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation // Helper to check if terminal creation should be debounced @@ -426,6 +469,47 @@ export function TerminalView() { fetchStatus(); }, [fetchStatus]); + // Clean up all terminal sessions when the page/app is about to close + // This prevents orphaned PTY processes on the server + useEffect(() => { + const handleBeforeUnload = () => { + // Use sendBeacon for reliable delivery during page unload + // Fall back to sync fetch if sendBeacon is not available + const sessionIds = collectAllSessionIds(); + if (sessionIds.length === 0) return; + + const headers: Record = { + "Content-Type": "application/json", + }; + if (terminalState.authToken) { + headers["X-Terminal-Token"] = terminalState.authToken; + } + + // Try to use the bulk delete endpoint if available, otherwise delete individually + // Using sendBeacon for reliability during page unload + sessionIds.forEach((sessionId) => { + const url = `${serverUrl}/api/terminal/sessions/${sessionId}`; + // sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest + // which is more reliable during page unload than fetch + try { + const xhr = new XMLHttpRequest(); + xhr.open("DELETE", url, false); // synchronous + if (terminalState.authToken) { + xhr.setRequestHeader("X-Terminal-Token", terminalState.authToken); + } + xhr.send(); + } catch { + // Ignore errors during unload - best effort cleanup + } + }); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [collectAllSessionIds, terminalState.authToken, serverUrl]); + // Fetch server settings when terminal is unlocked useEffect(() => { if (terminalState.isUnlocked) { @@ -455,15 +539,23 @@ export function TerminalView() { // Update the previous project ref prevProjectPathRef.current = currentPath; + // Helper to kill sessions and clear state + const killAndClear = async () => { + // Kill all server-side sessions first to prevent orphaned processes + await killAllSessions(); + clearTerminalState(); + }; + // If no current project, just clear terminals if (!currentPath) { - clearTerminalState(); + killAndClear(); return; } // ALWAYS clear existing terminals when switching projects // This is critical - prevents old project's terminals from "bleeding" into new project - clearTerminalState(); + // We need to kill server sessions first to prevent orphans + killAndClear(); // Check for saved layout for this project const savedLayout = getPersistedTerminalLayout(currentPath); @@ -650,7 +742,7 @@ export function TerminalView() { }; restoreLayout(); - }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); + }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl, killAllSessions]); // Save terminal layout whenever it changes (debounced to prevent excessive writes) // Also save when tabs become empty so closed terminals stay closed on refresh diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 95520ef7..102e07a4 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -896,12 +896,17 @@ export function TerminalPanel({ resizeDebounceRef.current = null; } + // Clear search decorations before disposing to prevent visual artifacts + if (searchAddonRef.current) { + searchAddonRef.current.clearDecorations(); + searchAddonRef.current = null; + } + if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; - searchAddonRef.current = null; setIsTerminalReady(false); }; }, []); // No dependencies - only run once on mount @@ -950,6 +955,8 @@ export function TerminalPanel({ // Only process scrollback if there's actual data // Don't clear if empty - prevents blank terminal issue if (msg.data && msg.data.length > 0) { + // Clear any stale search decorations before restoring content + searchAddonRef.current?.clearDecorations(); // Use reset() which is more reliable than clear() or escape sequences terminal.reset(); terminal.write(msg.data); @@ -1257,6 +1264,8 @@ export function TerminalPanel({ // Update terminal theme when app theme changes (including system preference) useEffect(() => { if (xtermRef.current && isTerminalReady) { + // Clear any search decorations first to prevent stale color artifacts + searchAddonRef.current?.clearDecorations(); const terminalTheme = getTerminalTheme(resolvedTheme); xtermRef.current.options.theme = terminalTheme; } From 8d578558ff6e0ca78ef62502116973cac9d70135 Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sun, 21 Dec 2025 20:31:57 -0500 Subject: [PATCH 11/11] style: fix formatting with Prettier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude_settings.json | 2 +- .github/scripts/upload-to-r2.js | 179 ++-- .github/workflows/e2e-tests.yml | 8 +- .github/workflows/pr-check.yml | 4 +- apps/app/next-env.d.ts | 2 +- apps/server/src/lib/auth.ts | 10 +- apps/server/src/providers/base-provider.ts | 10 +- apps/server/src/providers/claude-provider.ts | 88 +- apps/server/src/providers/provider-factory.ts | 23 +- apps/server/src/providers/types.ts | 14 +- apps/server/src/routes/agent/routes/clear.ts | 12 +- .../server/src/routes/agent/routes/history.ts | 12 +- apps/server/src/routes/agent/routes/model.ts | 12 +- apps/server/src/routes/agent/routes/stop.ts | 12 +- apps/server/src/routes/app-spec/index.ts | 27 +- .../src/routes/app-spec/routes/status.ts | 4 +- .../server/src/routes/app-spec/routes/stop.ts | 8 +- .../routes/auto-mode/routes/commit-feature.ts | 24 +- .../routes/auto-mode/routes/context-exists.ts | 23 +- .../src/routes/auto-mode/routes/status.ts | 8 +- .../routes/auto-mode/routes/stop-feature.ts | 12 +- .../routes/auto-mode/routes/verify-feature.ts | 23 +- .../server/src/routes/enhance-prompt/index.ts | 6 +- .../routes/features/routes/agent-output.ts | 23 +- .../src/routes/features/routes/delete.ts | 18 +- apps/server/src/routes/features/routes/get.ts | 20 +- apps/server/src/routes/fs/index.ts | 60 +- apps/server/src/routes/git/routes/diffs.ts | 14 +- .../server/src/routes/git/routes/file-diff.ts | 40 +- apps/server/src/routes/health/index.ts | 10 +- .../src/routes/health/routes/detailed.ts | 10 +- apps/server/src/routes/health/routes/index.ts | 6 +- apps/server/src/routes/models/index.ts | 10 +- .../src/routes/models/routes/available.ts | 30 +- .../server/src/routes/running-agents/index.ts | 12 +- .../src/routes/running-agents/routes/index.ts | 8 +- apps/server/src/routes/sessions/index.ts | 28 +- .../src/routes/sessions/routes/archive.ts | 10 +- .../src/routes/sessions/routes/create.ts | 17 +- .../src/routes/sessions/routes/delete.ts | 10 +- .../src/routes/sessions/routes/index.ts | 12 +- .../src/routes/sessions/routes/unarchive.ts | 10 +- .../src/routes/sessions/routes/update.ts | 10 +- .../src/routes/setup/get-claude-status.ts | 90 +- apps/server/src/routes/setup/index.ts | 38 +- .../src/routes/setup/routes/auth-claude.ts | 8 +- .../src/routes/setup/routes/claude-status.ts | 8 +- .../src/routes/setup/routes/gh-status.ts | 55 +- .../src/routes/setup/routes/install-claude.ts | 8 +- .../src/routes/setup/routes/platform.ts | 14 +- .../src/routes/suggestions/routes/status.ts | 6 +- .../src/routes/suggestions/routes/stop.ts | 11 +- apps/server/src/routes/templates/index.ts | 6 +- apps/server/src/routes/terminal/index.ts | 42 +- .../server/src/routes/terminal/routes/auth.ts | 8 +- .../src/routes/terminal/routes/logout.ts | 6 +- .../routes/terminal/routes/session-delete.ts | 6 +- .../routes/terminal/routes/session-resize.ts | 8 +- .../src/routes/terminal/routes/settings.ts | 24 +- .../src/routes/terminal/routes/status.ts | 9 +- apps/server/src/routes/workspace/index.ts | 10 +- .../routes/worktree/routes/checkout-branch.ts | 23 +- .../src/routes/worktree/routes/commit.ts | 27 +- .../src/routes/worktree/routes/create-pr.ts | 99 +- .../routes/worktree/routes/list-branches.ts | 30 +- .../worktree/routes/list-dev-servers.ts | 8 +- .../src/routes/worktree/routes/merge.ts | 44 +- .../routes/worktree/routes/open-in-editor.ts | 50 +- .../src/routes/worktree/routes/pr-info.ts | 147 ++- .../server/src/routes/worktree/routes/pull.ts | 40 +- .../server/src/routes/worktree/routes/push.ts | 21 +- .../src/routes/worktree/routes/start-dev.ts | 14 +- .../src/routes/worktree/routes/stop-dev.ts | 12 +- .../routes/worktree/routes/switch-branch.ts | 65 +- apps/server/src/services/terminal-service.ts | 90 +- apps/server/tests/fixtures/images.ts | 8 +- .../integration/helpers/git-test-repo.ts | 63 +- .../worktree/create.integration.test.ts | 41 +- .../auto-mode-service.integration.test.ts | 581 +++++------ .../tests/unit/lib/app-spec-format.test.ts | 176 ++-- apps/server/tests/unit/lib/auth.test.ts | 70 +- .../unit/lib/enhancement-prompts.test.ts | 178 ++-- apps/server/tests/unit/lib/events.test.ts | 56 +- .../unit/providers/provider-factory.test.ts | 158 ++- .../tests/unit/routes/app-spec/common.test.ts | 74 +- .../parse-and-create-features.test.ts | 46 +- .../auto-mode-service-planning.test.ts | 279 +++--- .../unit/services/auto-mode-service.test.ts | 34 +- .../services/auto-mode-task-parsing.test.ts | 138 +-- .../unit/services/terminal-service.test.ts | 348 +++---- apps/server/tests/utils/helpers.ts | 2 +- apps/server/tests/utils/mocks.ts | 16 +- apps/ui/docs/AGENT_ARCHITECTURE.md | 8 +- apps/ui/docs/SESSION_MANAGEMENT.md | 36 +- apps/ui/eslint.config.mjs | 150 +-- apps/ui/index.html | 7 +- apps/ui/scripts/prepare-server.mjs | 19 +- apps/ui/scripts/rebuild-server-natives.cjs | 16 +- apps/ui/scripts/setup-e2e-fixtures.mjs | 16 +- apps/ui/src/components/ui/autocomplete.tsx | 80 +- apps/ui/src/components/ui/badge.tsx | 51 +- .../src/components/ui/branch-autocomplete.tsx | 28 +- .../components/ui/category-autocomplete.tsx | 11 +- apps/ui/src/components/ui/checkbox.tsx | 24 +- apps/ui/src/components/ui/command.tsx | 92 +- apps/ui/src/components/ui/count-up-timer.tsx | 11 +- .../components/ui/delete-confirm-dialog.tsx | 27 +- apps/ui/src/components/ui/dialog.tsx | 158 ++- apps/ui/src/components/ui/dropdown-menu.tsx | 204 ++-- apps/ui/src/components/ui/git-diff-panel.tsx | 268 +++-- apps/ui/src/components/ui/input.tsx | 36 +- apps/ui/src/components/ui/keyboard-map.tsx | 734 +++++++------- apps/ui/src/components/ui/label.tsx | 18 +- apps/ui/src/components/ui/log-viewer.tsx | 460 +++++---- apps/ui/src/components/ui/markdown.tsx | 35 +- apps/ui/src/components/ui/popover.tsx | 29 +- apps/ui/src/components/ui/radio-group.tsx | 22 +- apps/ui/src/components/ui/select.tsx | 45 +- apps/ui/src/components/ui/slider.tsx | 42 +- apps/ui/src/components/ui/switch.tsx | 14 +- apps/ui/src/components/ui/tabs.tsx | 37 +- .../src/components/ui/task-progress-panel.tsx | 215 ++-- apps/ui/src/components/ui/textarea.tsx | 20 +- apps/ui/src/components/ui/tooltip.tsx | 35 +- .../src/components/ui/xml-syntax-editor.tsx | 104 +- .../src/components/views/agent-tools-view.tsx | 139 +-- .../views/board-view/board-controls.tsx | 45 +- .../views/board-view/board-search-bar.tsx | 40 +- .../board-view/components/kanban-column.tsx | 33 +- .../components/views/board-view/constants.ts | 24 +- .../board-view/dialogs/add-feature-dialog.tsx | 214 ++-- .../dialogs/archive-all-verified-dialog.tsx | 14 +- .../dialogs/commit-worktree-dialog.tsx | 53 +- .../dialogs/completed-features-modal.tsx | 17 +- .../dialogs/create-branch-dialog.tsx | 55 +- .../board-view/dialogs/create-pr-dialog.tsx | 121 +-- .../dialogs/create-worktree-dialog.tsx | 59 +- .../dialogs/delete-all-verified-dialog.tsx | 16 +- .../delete-completed-feature-dialog.tsx | 17 +- .../dialogs/delete-worktree-dialog.tsx | 68 +- .../dialogs/dependency-tree-dialog.tsx | 110 +- .../dialogs/feature-suggestions-dialog.tsx | 217 ++-- .../board-view/dialogs/follow-up-dialog.tsx | 27 +- .../views/board-view/dialogs/index.ts | 18 +- .../dialogs/plan-approval-dialog.tsx | 61 +- .../views/board-view/hooks/index.ts | 20 +- .../board-view/hooks/use-board-background.ts | 23 +- .../board-view/hooks/use-board-drag-drop.ts | 127 +-- .../board-view/hooks/use-board-effects.ts | 29 +- .../board-view/hooks/use-board-features.ts | 91 +- .../hooks/use-board-keyboard-shortcuts.ts | 14 +- .../board-view/hooks/use-board-persistence.ts | 30 +- .../board-view/hooks/use-follow-up-state.ts | 27 +- .../board-view/hooks/use-suggestions-state.ts | 4 +- .../board-view/shared/branch-selector.tsx | 35 +- .../views/board-view/shared/index.ts | 16 +- .../board-view/shared/model-selector.tsx | 21 +- .../shared/planning-mode-selector.tsx | 151 +-- .../board-view/shared/priority-selector.tsx | 25 +- .../shared/profile-quick-select.tsx | 37 +- .../board-view/shared/testing-tab-content.tsx | 27 +- .../shared/thinking-level-selector.tsx | 19 +- .../components/branch-switch-dropdown.tsx | 34 +- .../worktree-panel/components/index.ts | 6 +- .../components/worktree-actions-dropdown.tsx | 62 +- .../board-view/worktree-panel/hooks/index.ts | 12 +- .../worktree-panel/hooks/use-branches.ts | 15 +- .../hooks/use-default-editor.ts | 9 +- .../worktree-panel/hooks/use-dev-servers.ts | 37 +- .../hooks/use-running-features.ts | 10 +- .../hooks/use-worktree-actions.ts | 42 +- .../views/board-view/worktree-panel/index.ts | 4 +- apps/ui/src/components/views/chat-history.tsx | 69 +- apps/ui/src/components/views/code-view.tsx | 87 +- .../ui/src/components/views/profiles-view.tsx | 94 +- .../views/profiles-view/components/index.ts | 6 +- .../profiles-view/components/profile-form.tsx | 78 +- .../components/profiles-header.tsx | 11 +- .../components/sortable-profile-card.tsx | 58 +- .../views/profiles-view/constants.ts | 46 +- .../components/views/profiles-view/utils.ts | 6 +- .../components/views/running-agents-view.tsx | 89 +- .../ai-enhancement/ai-enhancement-section.tsx | 72 +- .../settings-view/ai-enhancement/index.ts | 2 +- .../settings-view/api-keys/api-key-field.tsx | 18 +- .../api-keys/api-keys-section.tsx | 71 +- .../api-keys/hooks/use-api-key-management.ts | 26 +- .../api-keys/security-notice.tsx | 4 +- .../settings-view/audio/audio-section.tsx | 31 +- .../cli-status/claude-cli-status.tsx | 73 +- .../components/keyboard-map-dialog.tsx | 10 +- .../components/settings-header.tsx | 34 +- .../components/settings-navigation.tsx | 46 +- .../views/settings-view/config/navigation.ts | 24 +- .../danger-zone/danger-zone-section.tsx | 37 +- .../feature-defaults-section.tsx | 131 ++- .../views/settings-view/hooks/index.ts | 4 +- .../settings-view/hooks/use-cli-status.ts | 34 +- .../settings-view/hooks/use-settings-view.ts | 24 +- .../keyboard-shortcuts-section.tsx | 43 +- .../terminal/terminal-section.tsx | 58 +- .../components/auth-method-selector.tsx | 59 +- .../components/cli-installation-card.tsx | 38 +- .../components/copyable-command-field.tsx | 17 +- .../views/setup-view/components/index.ts | 16 +- .../components/ready-state-card.tsx | 20 +- .../setup-view/components/status-badge.tsx | 40 +- .../setup-view/components/status-row.tsx | 20 +- .../setup-view/components/step-indicator.tsx | 9 +- .../views/setup-view/hooks/index.ts | 6 +- .../setup-view/hooks/use-cli-installation.ts | 28 +- .../views/setup-view/hooks/use-cli-status.ts | 35 +- .../views/setup-view/hooks/use-token-save.ts | 18 +- .../setup-view/steps/github-setup-step.tsx | 119 +-- apps/ui/src/components/views/spec-view.tsx | 39 +- .../views/spec-view/components/index.ts | 6 +- .../spec-view/components/spec-editor.tsx | 4 +- .../spec-view/components/spec-empty-state.tsx | 29 +- .../spec-view/components/spec-header.tsx | 26 +- .../components/views/spec-view/constants.ts | 22 +- .../spec-view/dialogs/create-spec-dialog.tsx | 80 +- .../views/spec-view/dialogs/index.ts | 4 +- .../dialogs/regenerate-spec-dialog.tsx | 81 +- .../components/views/spec-view/hooks/index.ts | 6 +- .../spec-view/hooks/use-spec-generation.ts | 524 +++++----- .../views/spec-view/hooks/use-spec-loading.ts | 14 +- .../views/spec-view/hooks/use-spec-save.ts | 13 +- .../src/components/views/spec-view/types.ts | 14 +- .../ui/src/components/views/terminal-view.tsx | 943 +++++++++--------- .../terminal-view/terminal-error-boundary.tsx | 37 +- .../views/terminal-view/terminal-panel.tsx | 753 +++++++------- apps/ui/src/config/terminal-themes.ts | 824 +++++++-------- apps/ui/src/contexts/file-browser-context.tsx | 42 +- apps/ui/src/hooks/use-auto-mode.ts | 184 ++-- apps/ui/src/hooks/use-electron-agent.ts | 142 ++- apps/ui/src/hooks/use-keyboard-shortcuts.ts | 99 +- apps/ui/src/hooks/use-message-queue.ts | 8 +- apps/ui/src/hooks/use-responsive-kanban.ts | 26 +- apps/ui/src/hooks/use-scroll-tracking.ts | 15 +- apps/ui/src/hooks/use-window-state.ts | 13 +- apps/ui/src/lib/agent-context-parser.ts | 70 +- apps/ui/src/lib/log-parser.ts | 344 ++++--- apps/ui/src/lib/templates.ts | 121 ++- apps/ui/src/preload.ts | 34 +- apps/ui/src/routes/agent.tsx | 6 +- apps/ui/src/routes/board.tsx | 6 +- apps/ui/src/routes/context.tsx | 6 +- apps/ui/src/routes/index.tsx | 6 +- apps/ui/src/routes/interview.tsx | 6 +- apps/ui/src/routes/profiles.tsx | 6 +- apps/ui/src/routes/running-agents.tsx | 6 +- apps/ui/src/routes/settings.tsx | 6 +- apps/ui/src/routes/setup.tsx | 6 +- apps/ui/src/routes/spec.tsx | 6 +- apps/ui/src/routes/terminal.tsx | 6 +- apps/ui/src/routes/wiki.tsx | 6 +- apps/ui/src/store/app-store.ts | 822 +++++++-------- apps/ui/src/types/css.d.ts | 4 +- apps/ui/src/types/electron.d.ts | 87 +- apps/ui/src/utils/router.ts | 10 +- apps/ui/tests/context-view.spec.ts | 274 +++-- .../tests/kanban-responsive-scaling.spec.ts | 24 +- apps/ui/tests/profiles-view.spec.ts | 469 ++++----- apps/ui/tests/utils/api/client.ts | 4 +- .../ui/tests/utils/components/autocomplete.ts | 38 +- apps/ui/tests/utils/components/dialogs.ts | 54 +- apps/ui/tests/utils/components/modals.ts | 32 +- apps/ui/tests/utils/core/constants.ts | 96 +- apps/ui/tests/utils/core/elements.ts | 18 +- apps/ui/tests/utils/core/interactions.ts | 23 +- apps/ui/tests/utils/core/waiting.ts | 10 +- apps/ui/tests/utils/features/kanban.ts | 8 +- apps/ui/tests/utils/features/skip-tests.ts | 40 +- apps/ui/tests/utils/features/timers.ts | 12 +- .../tests/utils/features/waiting-approval.ts | 36 +- apps/ui/tests/utils/files/drag-drop.ts | 14 +- apps/ui/tests/utils/helpers/concurrency.ts | 8 +- apps/ui/tests/utils/helpers/log-viewer.ts | 52 +- apps/ui/tests/utils/helpers/scroll.ts | 2 +- apps/ui/tests/utils/index.ts | 56 +- apps/ui/tests/utils/project/fixtures.ts | 32 +- apps/ui/tests/utils/project/setup.ts | 309 +++--- apps/ui/tests/utils/views/agent.ts | 31 +- apps/ui/tests/utils/views/profiles.ts | 186 ++-- apps/ui/tests/utils/views/settings.ts | 2 +- apps/ui/vite.config.mts | 41 +- docs/clean-code.md | 48 +- docs/folder-pattern.md | 42 +- docs/migration-plan-nextjs-to-vite.md | 514 +++++----- docs/server/providers.md | 134 +-- docs/server/route-organization.md | 42 +- docs/server/utilities.md | 193 ++-- docs/terminal.md | 24 +- init.mjs | 23 +- scripts/fix-lockfile-urls.mjs | 17 +- 295 files changed, 9088 insertions(+), 10546 deletions(-) diff --git a/.claude_settings.json b/.claude_settings.json index 246fb900..969f1214 100644 --- a/.claude_settings.json +++ b/.claude_settings.json @@ -21,4 +21,4 @@ "mcp__puppeteer__puppeteer_evaluate" ] } -} \ No newline at end of file +} diff --git a/.github/scripts/upload-to-r2.js b/.github/scripts/upload-to-r2.js index 4749bda1..b54d4b19 100644 --- a/.github/scripts/upload-to-r2.js +++ b/.github/scripts/upload-to-r2.js @@ -1,15 +1,11 @@ -const { - S3Client, - PutObjectCommand, - GetObjectCommand, -} = require("@aws-sdk/client-s3"); -const fs = require("fs"); -const path = require("path"); -const https = require("https"); -const { pipeline } = require("stream/promises"); +const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { pipeline } = require('stream/promises'); const s3Client = new S3Client({ - region: "auto", + region: 'auto', endpoint: process.env.R2_ENDPOINT, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, @@ -28,14 +24,14 @@ async function fetchExistingReleases() { const response = await s3Client.send( new GetObjectCommand({ Bucket: BUCKET, - Key: "releases.json", + Key: 'releases.json', }) ); const body = await response.Body.transformToString(); return JSON.parse(body); } catch (error) { - if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) { - console.log("No existing releases.json found, creating new one"); + if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { + console.log('No existing releases.json found, creating new one'); return { latestVersion: null, releases: [] }; } throw error; @@ -85,7 +81,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { resolve({ accessible: false, statusCode, - error: "Redirect without location header", + error: 'Redirect without location header', }); return; } @@ -93,18 +89,16 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { return https .get(redirectUrl, { timeout: 10000 }, (redirectResponse) => { const redirectStatus = redirectResponse.statusCode; - const contentType = - redirectResponse.headers["content-type"] || ""; + const contentType = redirectResponse.headers['content-type'] || ''; // Check if it's actually a file (zip/tar.gz) and not HTML const isFile = - contentType.includes("application/zip") || - contentType.includes("application/gzip") || - contentType.includes("application/x-gzip") || - contentType.includes("application/x-tar") || - redirectUrl.includes(".zip") || - redirectUrl.includes(".tar.gz"); - const isGood = - redirectStatus >= 200 && redirectStatus < 300 && isFile; + contentType.includes('application/zip') || + contentType.includes('application/gzip') || + contentType.includes('application/x-gzip') || + contentType.includes('application/x-tar') || + redirectUrl.includes('.zip') || + redirectUrl.includes('.tar.gz'); + const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile; redirectResponse.destroy(); resolve({ accessible: isGood, @@ -113,38 +107,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { contentType, }); }) - .on("error", (error) => { + .on('error', (error) => { resolve({ accessible: false, statusCode, error: error.message, }); }) - .on("timeout", function () { + .on('timeout', function () { this.destroy(); resolve({ accessible: false, statusCode, - error: "Timeout following redirect", + error: 'Timeout following redirect', }); }); } // Check if status is good (200-299 range) and it's actually a file - const contentType = response.headers["content-type"] || ""; + const contentType = response.headers['content-type'] || ''; const isFile = - contentType.includes("application/zip") || - contentType.includes("application/gzip") || - contentType.includes("application/x-gzip") || - contentType.includes("application/x-tar") || - url.includes(".zip") || - url.includes(".tar.gz"); + contentType.includes('application/zip') || + contentType.includes('application/gzip') || + contentType.includes('application/x-gzip') || + contentType.includes('application/x-tar') || + url.includes('.zip') || + url.includes('.tar.gz'); const isGood = statusCode >= 200 && statusCode < 300 && isFile; response.destroy(); resolve({ accessible: isGood, statusCode, contentType }); }); - request.on("error", (error) => { + request.on('error', (error) => { resolve({ accessible: false, statusCode: null, @@ -152,12 +146,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { }); }); - request.on("timeout", () => { + request.on('timeout', () => { request.destroy(); resolve({ accessible: false, statusCode: null, - error: "Request timeout", + error: 'Request timeout', }); }); }); @@ -168,22 +162,14 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { `✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})` ); } else { - console.log( - `✓ URL ${url} is accessible (status: ${result.statusCode})` - ); + console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`); } return result.finalUrl || url; // Return the final URL (after redirects) if available } else { - const errorMsg = result.error ? ` - ${result.error}` : ""; - const statusMsg = result.statusCode - ? ` (status: ${result.statusCode})` - : ""; - const contentTypeMsg = result.contentType - ? ` [content-type: ${result.contentType}]` - : ""; - console.log( - `✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}` - ); + const errorMsg = result.error ? ` - ${result.error}` : ''; + const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : ''; + const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : ''; + console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`); } } catch (error) { console.log(`✗ URL ${url} check failed: ${error.message}`); @@ -191,9 +177,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { if (attempt < maxRetries - 1) { const delay = initialDelay * Math.pow(2, attempt); - console.log( - ` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})` - ); + console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`); await new Promise((resolve) => setTimeout(resolve, delay)); } } @@ -207,12 +191,7 @@ async function downloadFromGitHub(url, outputPath) { const statusCode = response.statusCode; // Follow redirects (all redirect types) - if ( - statusCode === 301 || - statusCode === 302 || - statusCode === 307 || - statusCode === 308 - ) { + if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) { const redirectUrl = response.headers.location; response.destroy(); if (!redirectUrl) { @@ -220,39 +199,33 @@ async function downloadFromGitHub(url, outputPath) { return; } // Resolve relative redirects - const finalRedirectUrl = redirectUrl.startsWith("http") + const finalRedirectUrl = redirectUrl.startsWith('http') ? redirectUrl : new URL(redirectUrl, url).href; console.log(` Following redirect: ${finalRedirectUrl}`); - return downloadFromGitHub(finalRedirectUrl, outputPath) - .then(resolve) - .catch(reject); + return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject); } if (statusCode !== 200) { response.destroy(); - reject( - new Error( - `Failed to download ${url}: ${statusCode} ${response.statusMessage}` - ) - ); + reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`)); return; } const fileStream = fs.createWriteStream(outputPath); response.pipe(fileStream); - fileStream.on("finish", () => { + fileStream.on('finish', () => { fileStream.close(); resolve(); }); - fileStream.on("error", (error) => { + fileStream.on('error', (error) => { response.destroy(); reject(error); }); }); - request.on("error", reject); - request.on("timeout", () => { + request.on('error', reject); + request.on('timeout', () => { request.destroy(); reject(new Error(`Request timeout for ${url}`)); }); @@ -260,8 +233,8 @@ async function downloadFromGitHub(url, outputPath) { } async function main() { - const artifactsDir = "artifacts"; - const tempDir = path.join(artifactsDir, "temp"); + const artifactsDir = 'artifacts'; + const tempDir = path.join(artifactsDir, 'temp'); // Create temp directory for downloaded GitHub archives if (!fs.existsSync(tempDir)) { @@ -292,40 +265,30 @@ async function main() { // Find all artifacts const artifacts = { - windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/), - macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/), - macosArm: findArtifacts( - path.join(artifactsDir, "macos-builds"), - /-arm64\.dmg$/ - ), - linux: findArtifacts( - path.join(artifactsDir, "linux-builds"), - /\.AppImage$/ - ), + windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/), + macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/), + macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/), + linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/), sourceZip: [sourceZipPath], sourceTarGz: [sourceTarGzPath], }; - console.log("Found artifacts:"); + console.log('Found artifacts:'); for (const [platform, files] of Object.entries(artifacts)) { console.log( - ` ${platform}: ${ - files.length > 0 - ? files.map((f) => path.basename(f)).join(", ") - : "none" - }` + ` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}` ); } // Upload each artifact to R2 const assets = {}; const contentTypes = { - windows: "application/x-msdownload", - macos: "application/x-apple-diskimage", - macosArm: "application/x-apple-diskimage", - linux: "application/x-executable", - sourceZip: "application/zip", - sourceTarGz: "application/gzip", + windows: 'application/x-msdownload', + macos: 'application/x-apple-diskimage', + macosArm: 'application/x-apple-diskimage', + linux: 'application/x-executable', + sourceZip: 'application/zip', + sourceTarGz: 'application/gzip', }; for (const [platform, files] of Object.entries(artifacts)) { @@ -345,11 +308,11 @@ async function main() { filename, size, arch: - platform === "macosArm" - ? "arm64" - : platform === "sourceZip" || platform === "sourceTarGz" - ? "source" - : "x64", + platform === 'macosArm' + ? 'arm64' + : platform === 'sourceZip' || platform === 'sourceTarGz' + ? 'source' + : 'x64', }; } @@ -364,9 +327,7 @@ async function main() { }; // Remove existing entry for this version if re-running - releasesData.releases = releasesData.releases.filter( - (r) => r.version !== VERSION - ); + releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION); // Prepend new release releasesData.releases.unshift(newRelease); @@ -376,19 +337,19 @@ async function main() { await s3Client.send( new PutObjectCommand({ Bucket: BUCKET, - Key: "releases.json", + Key: 'releases.json', Body: JSON.stringify(releasesData, null, 2), - ContentType: "application/json", - CacheControl: "public, max-age=60", + ContentType: 'application/json', + CacheControl: 'public, max-age=60', }) ); - console.log("Successfully updated releases.json"); + console.log('Successfully updated releases.json'); console.log(`Latest version: ${VERSION}`); console.log(`Total releases: ${releasesData.releases.length}`); } main().catch((err) => { - console.error("Failed to upload to R2:", err); + console.error('Failed to upload to R2:', err); process.exit(1); }); diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9f8e49a8..a4064bda 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -3,7 +3,7 @@ name: E2E Tests on: pull_request: branches: - - "*" + - '*' push: branches: - main @@ -21,8 +21,8 @@ jobs: - name: Setup project uses: ./.github/actions/setup-project with: - check-lockfile: "true" - rebuild-node-pty-path: "apps/server" + check-lockfile: 'true' + rebuild-node-pty-path: 'apps/server' - name: Install Playwright browsers run: npx playwright install --with-deps chromium @@ -58,7 +58,7 @@ jobs: env: CI: true VITE_SERVER_URL: http://localhost:3008 - VITE_SKIP_SETUP: "true" + VITE_SKIP_SETUP: 'true' - name: Upload Playwright report uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 38e0c978..4311eeb0 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -3,7 +3,7 @@ name: PR Build Check on: pull_request: branches: - - "*" + - '*' push: branches: - main @@ -20,7 +20,7 @@ jobs: - name: Setup project uses: ./.github/actions/setup-project with: - check-lockfile: "true" + check-lockfile: 'true' - name: Run build:electron (dir only - faster CI) run: npm run build:electron:dir diff --git a/apps/app/next-env.d.ts b/apps/app/next-env.d.ts index c4b7818f..20e7bcfb 100644 --- a/apps/app/next-env.d.ts +++ b/apps/app/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 331af2cf..145c7b9d 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -4,7 +4,7 @@ * Supports API key authentication via header or environment variable. */ -import type { Request, Response, NextFunction } from "express"; +import type { Request, Response, NextFunction } from 'express'; // API key from environment (optional - if not set, auth is disabled) const API_KEY = process.env.AUTOMAKER_API_KEY; @@ -23,12 +23,12 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): } // Check for API key in header - const providedKey = req.headers["x-api-key"] as string | undefined; + const providedKey = req.headers['x-api-key'] as string | undefined; if (!providedKey) { res.status(401).json({ success: false, - error: "Authentication required. Provide X-API-Key header.", + error: 'Authentication required. Provide X-API-Key header.', }); return; } @@ -36,7 +36,7 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): if (providedKey !== API_KEY) { res.status(403).json({ success: false, - error: "Invalid API key.", + error: 'Invalid API key.', }); return; } @@ -57,6 +57,6 @@ export function isAuthEnabled(): boolean { export function getAuthStatus(): { enabled: boolean; method: string } { return { enabled: !!API_KEY, - method: API_KEY ? "api_key" : "none", + method: API_KEY ? 'api_key' : 'none', }; } diff --git a/apps/server/src/providers/base-provider.ts b/apps/server/src/providers/base-provider.ts index f481b83c..2b1880d3 100644 --- a/apps/server/src/providers/base-provider.ts +++ b/apps/server/src/providers/base-provider.ts @@ -9,7 +9,7 @@ import type { InstallationStatus, ValidationResult, ModelDefinition, -} from "./types.js"; +} from './types.js'; /** * Base provider class that all provider implementations must extend @@ -33,9 +33,7 @@ export abstract class BaseProvider { * @param options Execution options * @returns AsyncGenerator yielding provider messages */ - abstract executeQuery( - options: ExecuteOptions - ): AsyncGenerator; + abstract executeQuery(options: ExecuteOptions): AsyncGenerator; /** * Detect if the provider is installed and configured @@ -59,7 +57,7 @@ export abstract class BaseProvider { // Base validation (can be overridden) if (!this.config) { - errors.push("Provider config is missing"); + errors.push('Provider config is missing'); } return { @@ -76,7 +74,7 @@ export abstract class BaseProvider { */ supportsFeature(feature: string): boolean { // Default implementation - override in subclasses - const commonFeatures = ["tools", "text"]; + const commonFeatures = ['tools', 'text']; return commonFeatures.includes(feature); } diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index ea8471e1..21df839e 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -5,26 +5,24 @@ * with the provider architecture. */ -import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; -import { BaseProvider } from "./base-provider.js"; +import { query, type Options } from '@anthropic-ai/claude-agent-sdk'; +import { BaseProvider } from './base-provider.js'; import type { ExecuteOptions, ProviderMessage, InstallationStatus, ModelDefinition, -} from "./types.js"; +} from './types.js'; export class ClaudeProvider extends BaseProvider { getName(): string { - return "claude"; + return 'claude'; } /** * Execute a query using Claude Agent SDK */ - async *executeQuery( - options: ExecuteOptions - ): AsyncGenerator { + async *executeQuery(options: ExecuteOptions): AsyncGenerator { const { prompt, model, @@ -38,16 +36,7 @@ export class ClaudeProvider extends BaseProvider { } = options; // Build Claude SDK options - const defaultTools = [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - "WebSearch", - "WebFetch", - ]; + const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; const toolsToUse = allowedTools || defaultTools; const sdkOptions: Options = { @@ -56,7 +45,7 @@ export class ClaudeProvider extends BaseProvider { maxTurns, cwd, allowedTools: toolsToUse, - permissionMode: "acceptEdits", + permissionMode: 'acceptEdits', sandbox: { enabled: true, autoAllowBashIfSandboxed: true, @@ -75,10 +64,10 @@ export class ClaudeProvider extends BaseProvider { // Multi-part prompt (with images) promptPayload = (async function* () { const multiPartPrompt = { - type: "user" as const, - session_id: "", + type: 'user' as const, + session_id: '', message: { - role: "user" as const, + role: 'user' as const, content: prompt, }, parent_tool_use_id: null, @@ -99,10 +88,7 @@ export class ClaudeProvider extends BaseProvider { yield msg as ProviderMessage; } } catch (error) { - console.error( - "[ClaudeProvider] executeQuery() error during execution:", - error - ); + console.error('[ClaudeProvider] executeQuery() error during execution:', error); throw error; } } @@ -116,7 +102,7 @@ export class ClaudeProvider extends BaseProvider { const status: InstallationStatus = { installed: true, - method: "sdk", + method: 'sdk', hasApiKey, authenticated: hasApiKey, }; @@ -130,53 +116,53 @@ export class ClaudeProvider extends BaseProvider { getAvailableModels(): ModelDefinition[] { const models = [ { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", - modelString: "claude-opus-4-5-20251101", - provider: "anthropic", - description: "Most capable Claude model", + id: 'claude-opus-4-5-20251101', + name: 'Claude Opus 4.5', + modelString: 'claude-opus-4-5-20251101', + provider: 'anthropic', + description: 'Most capable Claude model', contextWindow: 200000, maxOutputTokens: 16000, supportsVision: true, supportsTools: true, - tier: "premium" as const, + tier: 'premium' as const, default: true, }, { - id: "claude-sonnet-4-20250514", - name: "Claude Sonnet 4", - modelString: "claude-sonnet-4-20250514", - provider: "anthropic", - description: "Balanced performance and cost", + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + modelString: 'claude-sonnet-4-20250514', + provider: 'anthropic', + description: 'Balanced performance and cost', contextWindow: 200000, maxOutputTokens: 16000, supportsVision: true, supportsTools: true, - tier: "standard" as const, + tier: 'standard' as const, }, { - id: "claude-3-5-sonnet-20241022", - name: "Claude 3.5 Sonnet", - modelString: "claude-3-5-sonnet-20241022", - provider: "anthropic", - description: "Fast and capable", + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + modelString: 'claude-3-5-sonnet-20241022', + provider: 'anthropic', + description: 'Fast and capable', contextWindow: 200000, maxOutputTokens: 8000, supportsVision: true, supportsTools: true, - tier: "standard" as const, + tier: 'standard' as const, }, { - id: "claude-3-5-haiku-20241022", - name: "Claude 3.5 Haiku", - modelString: "claude-3-5-haiku-20241022", - provider: "anthropic", - description: "Fastest Claude model", + id: 'claude-3-5-haiku-20241022', + name: 'Claude 3.5 Haiku', + modelString: 'claude-3-5-haiku-20241022', + provider: 'anthropic', + description: 'Fastest Claude model', contextWindow: 200000, maxOutputTokens: 8000, supportsVision: true, supportsTools: true, - tier: "basic" as const, + tier: 'basic' as const, }, ] satisfies ModelDefinition[]; return models; @@ -186,7 +172,7 @@ export class ClaudeProvider extends BaseProvider { * Check if the provider supports a specific feature */ supportsFeature(feature: string): boolean { - const supportedFeatures = ["tools", "text", "vision", "thinking"]; + const supportedFeatures = ['tools', 'text', 'vision', 'thinking']; return supportedFeatures.includes(feature); } } diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index f45bf008..0ef9b36e 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -6,9 +6,9 @@ * new providers (Cursor, OpenCode, etc.) trivial - just add one line. */ -import { BaseProvider } from "./base-provider.js"; -import { ClaudeProvider } from "./claude-provider.js"; -import type { InstallationStatus } from "./types.js"; +import { BaseProvider } from './base-provider.js'; +import { ClaudeProvider } from './claude-provider.js'; +import type { InstallationStatus } from './types.js'; export class ProviderFactory { /** @@ -21,10 +21,7 @@ export class ProviderFactory { const lowerModel = modelId.toLowerCase(); // Claude models (claude-*, opus, sonnet, haiku) - if ( - lowerModel.startsWith("claude-") || - ["haiku", "sonnet", "opus"].includes(lowerModel) - ) { + if (lowerModel.startsWith('claude-') || ['haiku', 'sonnet', 'opus'].includes(lowerModel)) { return new ClaudeProvider(); } @@ -37,9 +34,7 @@ export class ProviderFactory { // } // Default to Claude for unknown models - console.warn( - `[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude` - ); + console.warn(`[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude`); return new ClaudeProvider(); } @@ -58,9 +53,7 @@ export class ProviderFactory { * * @returns Map of provider name to installation status */ - static async checkAllProviders(): Promise< - Record - > { + static async checkAllProviders(): Promise> { const providers = this.getAllProviders(); const statuses: Record = {}; @@ -83,8 +76,8 @@ export class ProviderFactory { const lowerName = name.toLowerCase(); switch (lowerName) { - case "claude": - case "anthropic": + case 'claude': + case 'anthropic': return new ClaudeProvider(); // Future providers: diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index 6a05b6df..f3aa22d5 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -15,7 +15,7 @@ export interface ProviderConfig { * Message in conversation history */ export interface ConversationMessage { - role: "user" | "assistant"; + role: 'user' | 'assistant'; content: string | Array<{ type: string; text?: string; source?: object }>; } @@ -39,7 +39,7 @@ export interface ExecuteOptions { * Content block in a provider message (matches Claude SDK format) */ export interface ContentBlock { - type: "text" | "tool_use" | "thinking" | "tool_result"; + type: 'text' | 'tool_use' | 'thinking' | 'tool_result'; text?: string; thinking?: string; name?: string; @@ -52,11 +52,11 @@ export interface ContentBlock { * Message returned by a provider (matches Claude SDK streaming format) */ export interface ProviderMessage { - type: "assistant" | "user" | "error" | "result"; - subtype?: "success" | "error"; + type: 'assistant' | 'user' | 'error' | 'result'; + subtype?: 'success' | 'error'; session_id?: string; message?: { - role: "user" | "assistant"; + role: 'user' | 'assistant'; content: ContentBlock[]; }; result?: string; @@ -71,7 +71,7 @@ export interface InstallationStatus { installed: boolean; path?: string; version?: string; - method?: "cli" | "npm" | "brew" | "sdk"; + method?: 'cli' | 'npm' | 'brew' | 'sdk'; hasApiKey?: boolean; authenticated?: boolean; error?: string; @@ -99,6 +99,6 @@ export interface ModelDefinition { maxOutputTokens?: number; supportsVision?: boolean; supportsTools?: boolean; - tier?: "basic" | "standard" | "premium"; + tier?: 'basic' | 'standard' | 'premium'; default?: boolean; } diff --git a/apps/server/src/routes/agent/routes/clear.ts b/apps/server/src/routes/agent/routes/clear.ts index 42418331..3ee605b6 100644 --- a/apps/server/src/routes/agent/routes/clear.ts +++ b/apps/server/src/routes/agent/routes/clear.ts @@ -2,9 +2,9 @@ * POST /clear endpoint - Clear conversation */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createClearHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -12,16 +12,14 @@ export function createClearHandler(agentService: AgentService) { const { sessionId } = req.body as { sessionId: string }; if (!sessionId) { - res - .status(400) - .json({ success: false, error: "sessionId is required" }); + res.status(400).json({ success: false, error: 'sessionId is required' }); return; } const result = await agentService.clearSession(sessionId); res.json(result); } catch (error) { - logError(error, "Clear session failed"); + logError(error, 'Clear session failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/agent/routes/history.ts b/apps/server/src/routes/agent/routes/history.ts index c2b23be8..0859a142 100644 --- a/apps/server/src/routes/agent/routes/history.ts +++ b/apps/server/src/routes/agent/routes/history.ts @@ -2,9 +2,9 @@ * POST /history endpoint - Get conversation history */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createHistoryHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -12,16 +12,14 @@ export function createHistoryHandler(agentService: AgentService) { const { sessionId } = req.body as { sessionId: string }; if (!sessionId) { - res - .status(400) - .json({ success: false, error: "sessionId is required" }); + res.status(400).json({ success: false, error: 'sessionId is required' }); return; } const result = agentService.getHistory(sessionId); res.json(result); } catch (error) { - logError(error, "Get history failed"); + logError(error, 'Get history failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/agent/routes/model.ts b/apps/server/src/routes/agent/routes/model.ts index 2e1b933e..8e1a1ddd 100644 --- a/apps/server/src/routes/agent/routes/model.ts +++ b/apps/server/src/routes/agent/routes/model.ts @@ -2,9 +2,9 @@ * POST /model endpoint - Set session model */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createModelHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -15,16 +15,14 @@ export function createModelHandler(agentService: AgentService) { }; if (!sessionId || !model) { - res - .status(400) - .json({ success: false, error: "sessionId and model are required" }); + res.status(400).json({ success: false, error: 'sessionId and model are required' }); return; } const result = await agentService.setSessionModel(sessionId, model); res.json({ success: result }); } catch (error) { - logError(error, "Set session model failed"); + logError(error, 'Set session model failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/agent/routes/stop.ts b/apps/server/src/routes/agent/routes/stop.ts index 204c7d4a..c5a5fe01 100644 --- a/apps/server/src/routes/agent/routes/stop.ts +++ b/apps/server/src/routes/agent/routes/stop.ts @@ -2,9 +2,9 @@ * POST /stop endpoint - Stop execution */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createStopHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -12,16 +12,14 @@ export function createStopHandler(agentService: AgentService) { const { sessionId } = req.body as { sessionId: string }; if (!sessionId) { - res - .status(400) - .json({ success: false, error: "sessionId is required" }); + res.status(400).json({ success: false, error: 'sessionId is required' }); return; } const result = await agentService.stopExecution(sessionId); res.json(result); } catch (error) { - logError(error, "Stop execution failed"); + logError(error, 'Stop execution failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts index b37907c8..47950cd3 100644 --- a/apps/server/src/routes/app-spec/index.ts +++ b/apps/server/src/routes/app-spec/index.ts @@ -2,25 +2,22 @@ * Spec Regeneration routes - HTTP API for AI-powered spec generation */ -import { Router } from "express"; -import type { EventEmitter } from "../../lib/events.js"; -import { createCreateHandler } from "./routes/create.js"; -import { createGenerateHandler } from "./routes/generate.js"; -import { createGenerateFeaturesHandler } from "./routes/generate-features.js"; -import { createStopHandler } from "./routes/stop.js"; -import { createStatusHandler } from "./routes/status.js"; +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { createCreateHandler } from './routes/create.js'; +import { createGenerateHandler } from './routes/generate.js'; +import { createGenerateFeaturesHandler } from './routes/generate-features.js'; +import { createStopHandler } from './routes/stop.js'; +import { createStatusHandler } from './routes/status.js'; export function createSpecRegenerationRoutes(events: EventEmitter): Router { const router = Router(); - router.post("/create", createCreateHandler(events)); - router.post("/generate", createGenerateHandler(events)); - router.post("/generate-features", createGenerateFeaturesHandler(events)); - router.post("/stop", createStopHandler()); - router.get("/status", createStatusHandler()); + router.post('/create', createCreateHandler(events)); + router.post('/generate', createGenerateHandler(events)); + router.post('/generate-features', createGenerateFeaturesHandler(events)); + router.post('/stop', createStopHandler()); + router.get('/status', createStatusHandler()); return router; } - - - diff --git a/apps/server/src/routes/app-spec/routes/status.ts b/apps/server/src/routes/app-spec/routes/status.ts index a3c1aac1..542dd4f3 100644 --- a/apps/server/src/routes/app-spec/routes/status.ts +++ b/apps/server/src/routes/app-spec/routes/status.ts @@ -2,8 +2,8 @@ * GET /status endpoint - Get generation status */ -import type { Request, Response } from "express"; -import { getSpecRegenerationStatus, getErrorMessage } from "../common.js"; +import type { Request, Response } from 'express'; +import { getSpecRegenerationStatus, getErrorMessage } from '../common.js'; export function createStatusHandler() { return async (_req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/app-spec/routes/stop.ts b/apps/server/src/routes/app-spec/routes/stop.ts index 7c3bd5ca..0751147b 100644 --- a/apps/server/src/routes/app-spec/routes/stop.ts +++ b/apps/server/src/routes/app-spec/routes/stop.ts @@ -2,12 +2,8 @@ * POST /stop endpoint - Stop generation */ -import type { Request, Response } from "express"; -import { - getSpecRegenerationStatus, - setRunningState, - getErrorMessage, -} from "../common.js"; +import type { Request, Response } from 'express'; +import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js'; export function createStopHandler() { return async (_req: Request, res: Response): Promise => { diff --git a/apps/server/src/routes/auto-mode/routes/commit-feature.ts b/apps/server/src/routes/auto-mode/routes/commit-feature.ts index aaf2e6f5..7db0ae32 100644 --- a/apps/server/src/routes/auto-mode/routes/commit-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/commit-feature.ts @@ -2,9 +2,9 @@ * POST /commit-feature endpoint - Commit feature changes */ -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createCommitFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { @@ -16,23 +16,17 @@ export function createCommitFeatureHandler(autoModeService: AutoModeService) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); return; } - const commitHash = await autoModeService.commitFeature( - projectPath, - featureId, - worktreePath - ); + const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath); res.json({ success: true, commitHash }); } catch (error) { - logError(error, "Commit feature failed"); + logError(error, 'Commit feature failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/auto-mode/routes/context-exists.ts b/apps/server/src/routes/auto-mode/routes/context-exists.ts index 32ebb4ce..ef028f3f 100644 --- a/apps/server/src/routes/auto-mode/routes/context-exists.ts +++ b/apps/server/src/routes/auto-mode/routes/context-exists.ts @@ -2,9 +2,9 @@ * POST /context-exists endpoint - Check if context exists for a feature */ -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createContextExistsHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { @@ -15,22 +15,17 @@ export function createContextExistsHandler(autoModeService: AutoModeService) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); return; } - const exists = await autoModeService.contextExists( - projectPath, - featureId - ); + const exists = await autoModeService.contextExists(projectPath, featureId); res.json({ success: true, exists }); } catch (error) { - logError(error, "Check context exists failed"); + logError(error, 'Check context exists failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts index ba0ee8a1..9a1b4690 100644 --- a/apps/server/src/routes/auto-mode/routes/status.ts +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -2,9 +2,9 @@ * POST /status endpoint - Get auto mode status */ -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createStatusHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { @@ -15,7 +15,7 @@ export function createStatusHandler(autoModeService: AutoModeService) { ...status, }); } catch (error) { - logError(error, "Get status failed"); + logError(error, 'Get status failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/auto-mode/routes/stop-feature.ts b/apps/server/src/routes/auto-mode/routes/stop-feature.ts index 0468e9d3..bec9a4aa 100644 --- a/apps/server/src/routes/auto-mode/routes/stop-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/stop-feature.ts @@ -2,9 +2,9 @@ * POST /stop-feature endpoint - Stop a specific feature */ -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createStopFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { @@ -12,16 +12,14 @@ export function createStopFeatureHandler(autoModeService: AutoModeService) { const { featureId } = req.body as { featureId: string }; if (!featureId) { - res - .status(400) - .json({ success: false, error: "featureId is required" }); + res.status(400).json({ success: false, error: 'featureId is required' }); return; } const stopped = await autoModeService.stopFeature(featureId); res.json({ success: true, stopped }); } catch (error) { - logError(error, "Stop feature failed"); + logError(error, 'Stop feature failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/auto-mode/routes/verify-feature.ts b/apps/server/src/routes/auto-mode/routes/verify-feature.ts index 456eecb2..f8f4f6f7 100644 --- a/apps/server/src/routes/auto-mode/routes/verify-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/verify-feature.ts @@ -2,9 +2,9 @@ * POST /verify-feature endpoint - Verify a feature */ -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createVerifyFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { @@ -15,22 +15,17 @@ export function createVerifyFeatureHandler(autoModeService: AutoModeService) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); return; } - const passes = await autoModeService.verifyFeature( - projectPath, - featureId - ); + const passes = await autoModeService.verifyFeature(projectPath, featureId); res.json({ success: true, passes }); } catch (error) { - logError(error, "Verify feature failed"); + logError(error, 'Verify feature failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/enhance-prompt/index.ts b/apps/server/src/routes/enhance-prompt/index.ts index bd414a5d..952bf347 100644 --- a/apps/server/src/routes/enhance-prompt/index.ts +++ b/apps/server/src/routes/enhance-prompt/index.ts @@ -5,8 +5,8 @@ * with different enhancement modes (improve, expand, simplify, etc.) */ -import { Router } from "express"; -import { createEnhanceHandler } from "./routes/enhance.js"; +import { Router } from 'express'; +import { createEnhanceHandler } from './routes/enhance.js'; /** * Create the enhance-prompt router @@ -16,7 +16,7 @@ import { createEnhanceHandler } from "./routes/enhance.js"; export function createEnhancePromptRoutes(): Router { const router = Router(); - router.post("/", createEnhanceHandler()); + router.post('/', createEnhanceHandler()); return router; } diff --git a/apps/server/src/routes/features/routes/agent-output.ts b/apps/server/src/routes/features/routes/agent-output.ts index 62f8f50a..f928644a 100644 --- a/apps/server/src/routes/features/routes/agent-output.ts +++ b/apps/server/src/routes/features/routes/agent-output.ts @@ -2,9 +2,9 @@ * POST /agent-output endpoint - Get agent output for a feature */ -import type { Request, Response } from "express"; -import { FeatureLoader } from "../../../services/feature-loader.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; export function createAgentOutputHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { @@ -15,22 +15,17 @@ export function createAgentOutputHandler(featureLoader: FeatureLoader) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); return; } - const content = await featureLoader.getAgentOutput( - projectPath, - featureId - ); + const content = await featureLoader.getAgentOutput(projectPath, featureId); res.json({ success: true, content }); } catch (error) { - logError(error, "Get agent output failed"); + logError(error, 'Get agent output failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/features/routes/delete.ts b/apps/server/src/routes/features/routes/delete.ts index bf5408d5..2b6831f6 100644 --- a/apps/server/src/routes/features/routes/delete.ts +++ b/apps/server/src/routes/features/routes/delete.ts @@ -2,9 +2,9 @@ * POST /delete endpoint - Delete a feature */ -import type { Request, Response } from "express"; -import { FeatureLoader } from "../../../services/feature-loader.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; export function createDeleteHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { @@ -15,19 +15,17 @@ export function createDeleteHandler(featureLoader: FeatureLoader) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); return; } const success = await featureLoader.delete(projectPath, featureId); res.json({ success }); } catch (error) { - logError(error, "Delete feature failed"); + logError(error, 'Delete feature failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/features/routes/get.ts b/apps/server/src/routes/features/routes/get.ts index 17900bb0..96f63fb8 100644 --- a/apps/server/src/routes/features/routes/get.ts +++ b/apps/server/src/routes/features/routes/get.ts @@ -2,9 +2,9 @@ * POST /get endpoint - Get a single feature */ -import type { Request, Response } from "express"; -import { FeatureLoader } from "../../../services/feature-loader.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; export function createGetHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { @@ -15,24 +15,22 @@ export function createGetHandler(featureLoader: FeatureLoader) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); return; } const feature = await featureLoader.get(projectPath, featureId); if (!feature) { - res.status(404).json({ success: false, error: "Feature not found" }); + res.status(404).json({ success: false, error: 'Feature not found' }); return; } res.json({ success: true, feature }); } catch (error) { - logError(error, "Get feature failed"); + logError(error, 'Get feature failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/fs/index.ts b/apps/server/src/routes/fs/index.ts index 6fc67dad..58732b3a 100644 --- a/apps/server/src/routes/fs/index.ts +++ b/apps/server/src/routes/fs/index.ts @@ -3,40 +3,40 @@ * Provides REST API equivalents for Electron IPC file operations */ -import { Router } from "express"; -import type { EventEmitter } from "../../lib/events.js"; -import { createReadHandler } from "./routes/read.js"; -import { createWriteHandler } from "./routes/write.js"; -import { createMkdirHandler } from "./routes/mkdir.js"; -import { createReaddirHandler } from "./routes/readdir.js"; -import { createExistsHandler } from "./routes/exists.js"; -import { createStatHandler } from "./routes/stat.js"; -import { createDeleteHandler } from "./routes/delete.js"; -import { createValidatePathHandler } from "./routes/validate-path.js"; -import { createResolveDirectoryHandler } from "./routes/resolve-directory.js"; -import { createSaveImageHandler } from "./routes/save-image.js"; -import { createBrowseHandler } from "./routes/browse.js"; -import { createImageHandler } from "./routes/image.js"; -import { createSaveBoardBackgroundHandler } from "./routes/save-board-background.js"; -import { createDeleteBoardBackgroundHandler } from "./routes/delete-board-background.js"; +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { createReadHandler } from './routes/read.js'; +import { createWriteHandler } from './routes/write.js'; +import { createMkdirHandler } from './routes/mkdir.js'; +import { createReaddirHandler } from './routes/readdir.js'; +import { createExistsHandler } from './routes/exists.js'; +import { createStatHandler } from './routes/stat.js'; +import { createDeleteHandler } from './routes/delete.js'; +import { createValidatePathHandler } from './routes/validate-path.js'; +import { createResolveDirectoryHandler } from './routes/resolve-directory.js'; +import { createSaveImageHandler } from './routes/save-image.js'; +import { createBrowseHandler } from './routes/browse.js'; +import { createImageHandler } from './routes/image.js'; +import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js'; +import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js'; export function createFsRoutes(_events: EventEmitter): Router { const router = Router(); - router.post("/read", createReadHandler()); - router.post("/write", createWriteHandler()); - router.post("/mkdir", createMkdirHandler()); - router.post("/readdir", createReaddirHandler()); - router.post("/exists", createExistsHandler()); - router.post("/stat", createStatHandler()); - router.post("/delete", createDeleteHandler()); - router.post("/validate-path", createValidatePathHandler()); - router.post("/resolve-directory", createResolveDirectoryHandler()); - router.post("/save-image", createSaveImageHandler()); - router.post("/browse", createBrowseHandler()); - router.get("/image", createImageHandler()); - router.post("/save-board-background", createSaveBoardBackgroundHandler()); - router.post("/delete-board-background", createDeleteBoardBackgroundHandler()); + router.post('/read', createReadHandler()); + router.post('/write', createWriteHandler()); + router.post('/mkdir', createMkdirHandler()); + router.post('/readdir', createReaddirHandler()); + router.post('/exists', createExistsHandler()); + router.post('/stat', createStatHandler()); + router.post('/delete', createDeleteHandler()); + router.post('/validate-path', createValidatePathHandler()); + router.post('/resolve-directory', createResolveDirectoryHandler()); + router.post('/save-image', createSaveImageHandler()); + router.post('/browse', createBrowseHandler()); + router.get('/image', createImageHandler()); + router.post('/save-board-background', createSaveBoardBackgroundHandler()); + router.post('/delete-board-background', createDeleteBoardBackgroundHandler()); return router; } diff --git a/apps/server/src/routes/git/routes/diffs.ts b/apps/server/src/routes/git/routes/diffs.ts index eb532a03..ca919dcf 100644 --- a/apps/server/src/routes/git/routes/diffs.ts +++ b/apps/server/src/routes/git/routes/diffs.ts @@ -2,9 +2,9 @@ * POST /diffs endpoint - Get diffs for the main project */ -import type { Request, Response } from "express"; -import { getErrorMessage, logError } from "../common.js"; -import { getGitRepositoryDiffs } from "../../common.js"; +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { getGitRepositoryDiffs } from '../../common.js'; export function createDiffsHandler() { return async (req: Request, res: Response): Promise => { @@ -12,7 +12,7 @@ export function createDiffsHandler() { const { projectPath } = req.body as { projectPath: string }; if (!projectPath) { - res.status(400).json({ success: false, error: "projectPath required" }); + res.status(400).json({ success: false, error: 'projectPath required' }); return; } @@ -25,11 +25,11 @@ export function createDiffsHandler() { hasChanges: result.hasChanges, }); } catch (innerError) { - logError(innerError, "Git diff failed"); - res.json({ success: true, diff: "", files: [], hasChanges: false }); + logError(innerError, 'Git diff failed'); + res.json({ success: true, diff: '', files: [], hasChanges: false }); } } catch (error) { - logError(error, "Get diffs failed"); + logError(error, 'Get diffs failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/git/routes/file-diff.ts b/apps/server/src/routes/git/routes/file-diff.ts index fdf66998..6203ecc4 100644 --- a/apps/server/src/routes/git/routes/file-diff.ts +++ b/apps/server/src/routes/git/routes/file-diff.ts @@ -2,11 +2,11 @@ * POST /file-diff endpoint - Get diff for a specific file */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; -import { generateSyntheticDiffForNewFile } from "../../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; +import { generateSyntheticDiffForNewFile } from '../../common.js'; const execAsync = promisify(exec); @@ -19,20 +19,17 @@ export function createFileDiffHandler() { }; if (!projectPath || !filePath) { - res - .status(400) - .json({ success: false, error: "projectPath and filePath required" }); + res.status(400).json({ success: false, error: 'projectPath and filePath required' }); return; } try { // First check if the file is untracked - const { stdout: status } = await execAsync( - `git status --porcelain -- "${filePath}"`, - { cwd: projectPath } - ); + const { stdout: status } = await execAsync(`git status --porcelain -- "${filePath}"`, { + cwd: projectPath, + }); - const isUntracked = status.trim().startsWith("??"); + const isUntracked = status.trim().startsWith('??'); let diff: string; if (isUntracked) { @@ -40,23 +37,20 @@ export function createFileDiffHandler() { diff = await generateSyntheticDiffForNewFile(projectPath, filePath); } else { // Use regular git diff for tracked files - const result = await execAsync( - `git diff HEAD -- "${filePath}"`, - { - cwd: projectPath, - maxBuffer: 10 * 1024 * 1024, - } - ); + const result = await execAsync(`git diff HEAD -- "${filePath}"`, { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, + }); diff = result.stdout; } res.json({ success: true, diff, filePath }); } catch (innerError) { - logError(innerError, "Git file diff failed"); - res.json({ success: true, diff: "", filePath }); + logError(innerError, 'Git file diff failed'); + res.json({ success: true, diff: '', filePath }); } } catch (error) { - logError(error, "Get file diff failed"); + logError(error, 'Get file diff failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/health/index.ts b/apps/server/src/routes/health/index.ts index 6ec62532..31439e66 100644 --- a/apps/server/src/routes/health/index.ts +++ b/apps/server/src/routes/health/index.ts @@ -2,15 +2,15 @@ * Health check routes */ -import { Router } from "express"; -import { createIndexHandler } from "./routes/index.js"; -import { createDetailedHandler } from "./routes/detailed.js"; +import { Router } from 'express'; +import { createIndexHandler } from './routes/index.js'; +import { createDetailedHandler } from './routes/detailed.js'; export function createHealthRoutes(): Router { const router = Router(); - router.get("/", createIndexHandler()); - router.get("/detailed", createDetailedHandler()); + router.get('/', createIndexHandler()); + router.get('/detailed', createDetailedHandler()); return router; } diff --git a/apps/server/src/routes/health/routes/detailed.ts b/apps/server/src/routes/health/routes/detailed.ts index 22deba78..5aa2e6b1 100644 --- a/apps/server/src/routes/health/routes/detailed.ts +++ b/apps/server/src/routes/health/routes/detailed.ts @@ -2,18 +2,18 @@ * GET /detailed endpoint - Detailed health check */ -import type { Request, Response } from "express"; -import { getAuthStatus } from "../../../lib/auth.js"; +import type { Request, Response } from 'express'; +import { getAuthStatus } from '../../../lib/auth.js'; export function createDetailedHandler() { return (_req: Request, res: Response): void => { res.json({ - status: "ok", + status: 'ok', timestamp: new Date().toISOString(), - version: process.env.npm_package_version || "0.1.0", + version: process.env.npm_package_version || '0.1.0', uptime: process.uptime(), memory: process.memoryUsage(), - dataDir: process.env.DATA_DIR || "./data", + dataDir: process.env.DATA_DIR || './data', auth: getAuthStatus(), env: { nodeVersion: process.version, diff --git a/apps/server/src/routes/health/routes/index.ts b/apps/server/src/routes/health/routes/index.ts index e571b78e..1501f6a6 100644 --- a/apps/server/src/routes/health/routes/index.ts +++ b/apps/server/src/routes/health/routes/index.ts @@ -2,14 +2,14 @@ * GET / endpoint - Basic health check */ -import type { Request, Response } from "express"; +import type { Request, Response } from 'express'; export function createIndexHandler() { return (_req: Request, res: Response): void => { res.json({ - status: "ok", + status: 'ok', timestamp: new Date().toISOString(), - version: process.env.npm_package_version || "0.1.0", + version: process.env.npm_package_version || '0.1.0', }); }; } diff --git a/apps/server/src/routes/models/index.ts b/apps/server/src/routes/models/index.ts index 4ed1fda2..14d0beab 100644 --- a/apps/server/src/routes/models/index.ts +++ b/apps/server/src/routes/models/index.ts @@ -2,15 +2,15 @@ * Models routes - HTTP API for model providers and availability */ -import { Router } from "express"; -import { createAvailableHandler } from "./routes/available.js"; -import { createProvidersHandler } from "./routes/providers.js"; +import { Router } from 'express'; +import { createAvailableHandler } from './routes/available.js'; +import { createProvidersHandler } from './routes/providers.js'; export function createModelsRoutes(): Router { const router = Router(); - router.get("/available", createAvailableHandler()); - router.get("/providers", createProvidersHandler()); + router.get('/available', createAvailableHandler()); + router.get('/providers', createProvidersHandler()); return router; } diff --git a/apps/server/src/routes/models/routes/available.ts b/apps/server/src/routes/models/routes/available.ts index 3e26b690..4ac4e0b1 100644 --- a/apps/server/src/routes/models/routes/available.ts +++ b/apps/server/src/routes/models/routes/available.ts @@ -2,8 +2,8 @@ * GET /available endpoint - Get available models */ -import type { Request, Response } from "express"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; interface ModelDefinition { id: string; @@ -20,36 +20,36 @@ export function createAvailableHandler() { try { const models: ModelDefinition[] = [ { - id: "claude-opus-4-5-20251101", - name: "Claude Opus 4.5", - provider: "anthropic", + id: 'claude-opus-4-5-20251101', + name: 'Claude Opus 4.5', + provider: 'anthropic', contextWindow: 200000, maxOutputTokens: 16384, supportsVision: true, supportsTools: true, }, { - id: "claude-sonnet-4-20250514", - name: "Claude Sonnet 4", - provider: "anthropic", + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + provider: 'anthropic', contextWindow: 200000, maxOutputTokens: 16384, supportsVision: true, supportsTools: true, }, { - id: "claude-3-5-sonnet-20241022", - name: "Claude 3.5 Sonnet", - provider: "anthropic", + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + provider: 'anthropic', contextWindow: 200000, maxOutputTokens: 8192, supportsVision: true, supportsTools: true, }, { - id: "claude-3-5-haiku-20241022", - name: "Claude 3.5 Haiku", - provider: "anthropic", + id: 'claude-3-5-haiku-20241022', + name: 'Claude 3.5 Haiku', + provider: 'anthropic', contextWindow: 200000, maxOutputTokens: 8192, supportsVision: true, @@ -59,7 +59,7 @@ export function createAvailableHandler() { res.json({ success: true, models }); } catch (error) { - logError(error, "Get available models failed"); + logError(error, 'Get available models failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/running-agents/index.ts b/apps/server/src/routes/running-agents/index.ts index cef82fea..a1dbffcd 100644 --- a/apps/server/src/routes/running-agents/index.ts +++ b/apps/server/src/routes/running-agents/index.ts @@ -2,16 +2,14 @@ * Running Agents routes - HTTP API for tracking active agent executions */ -import { Router } from "express"; -import type { AutoModeService } from "../../services/auto-mode-service.js"; -import { createIndexHandler } from "./routes/index.js"; +import { Router } from 'express'; +import type { AutoModeService } from '../../services/auto-mode-service.js'; +import { createIndexHandler } from './routes/index.js'; -export function createRunningAgentsRoutes( - autoModeService: AutoModeService -): Router { +export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router { const router = Router(); - router.get("/", createIndexHandler(autoModeService)); + router.get('/', createIndexHandler(autoModeService)); return router; } diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index e2f7e14e..72a3f838 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -2,9 +2,9 @@ * GET / endpoint - Get all running agents */ -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createIndexHandler(autoModeService: AutoModeService) { return async (_req: Request, res: Response): Promise => { @@ -18,7 +18,7 @@ export function createIndexHandler(autoModeService: AutoModeService) { totalCount: runningAgents.length, }); } catch (error) { - logError(error, "Get running agents failed"); + logError(error, 'Get running agents failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts index 1cae202d..e625671f 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -2,24 +2,24 @@ * Sessions routes - HTTP API for session management */ -import { Router } from "express"; -import { AgentService } from "../../services/agent-service.js"; -import { createIndexHandler } from "./routes/index.js"; -import { createCreateHandler } from "./routes/create.js"; -import { createUpdateHandler } from "./routes/update.js"; -import { createArchiveHandler } from "./routes/archive.js"; -import { createUnarchiveHandler } from "./routes/unarchive.js"; -import { createDeleteHandler } from "./routes/delete.js"; +import { Router } from 'express'; +import { AgentService } from '../../services/agent-service.js'; +import { createIndexHandler } from './routes/index.js'; +import { createCreateHandler } from './routes/create.js'; +import { createUpdateHandler } from './routes/update.js'; +import { createArchiveHandler } from './routes/archive.js'; +import { createUnarchiveHandler } from './routes/unarchive.js'; +import { createDeleteHandler } from './routes/delete.js'; export function createSessionsRoutes(agentService: AgentService): Router { const router = Router(); - router.get("/", createIndexHandler(agentService)); - router.post("/", createCreateHandler(agentService)); - router.put("/:sessionId", createUpdateHandler(agentService)); - router.post("/:sessionId/archive", createArchiveHandler(agentService)); - router.post("/:sessionId/unarchive", createUnarchiveHandler(agentService)); - router.delete("/:sessionId", createDeleteHandler(agentService)); + router.get('/', createIndexHandler(agentService)); + router.post('/', createCreateHandler(agentService)); + router.put('/:sessionId', createUpdateHandler(agentService)); + router.post('/:sessionId/archive', createArchiveHandler(agentService)); + router.post('/:sessionId/unarchive', createUnarchiveHandler(agentService)); + router.delete('/:sessionId', createDeleteHandler(agentService)); return router; } diff --git a/apps/server/src/routes/sessions/routes/archive.ts b/apps/server/src/routes/sessions/routes/archive.ts index dd9b6aa0..3407e5cd 100644 --- a/apps/server/src/routes/sessions/routes/archive.ts +++ b/apps/server/src/routes/sessions/routes/archive.ts @@ -2,9 +2,9 @@ * POST /:sessionId/archive endpoint - Archive a session */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createArchiveHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -13,13 +13,13 @@ export function createArchiveHandler(agentService: AgentService) { const success = await agentService.archiveSession(sessionId); if (!success) { - res.status(404).json({ success: false, error: "Session not found" }); + res.status(404).json({ success: false, error: 'Session not found' }); return; } res.json({ success: true }); } catch (error) { - logError(error, "Archive session failed"); + logError(error, 'Archive session failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/sessions/routes/create.ts b/apps/server/src/routes/sessions/routes/create.ts index 7faf9e36..2917168c 100644 --- a/apps/server/src/routes/sessions/routes/create.ts +++ b/apps/server/src/routes/sessions/routes/create.ts @@ -2,9 +2,9 @@ * POST / endpoint - Create a new session */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createCreateHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -17,19 +17,14 @@ export function createCreateHandler(agentService: AgentService) { }; if (!name) { - res.status(400).json({ success: false, error: "name is required" }); + res.status(400).json({ success: false, error: 'name is required' }); return; } - const session = await agentService.createSession( - name, - projectPath, - workingDirectory, - model - ); + const session = await agentService.createSession(name, projectPath, workingDirectory, model); res.json({ success: true, session }); } catch (error) { - logError(error, "Create session failed"); + logError(error, 'Create session failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/sessions/routes/delete.ts b/apps/server/src/routes/sessions/routes/delete.ts index 2d4c9f4c..91bbc39d 100644 --- a/apps/server/src/routes/sessions/routes/delete.ts +++ b/apps/server/src/routes/sessions/routes/delete.ts @@ -2,9 +2,9 @@ * DELETE /:sessionId endpoint - Delete a session */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createDeleteHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -13,13 +13,13 @@ export function createDeleteHandler(agentService: AgentService) { const success = await agentService.deleteSession(sessionId); if (!success) { - res.status(404).json({ success: false, error: "Session not found" }); + res.status(404).json({ success: false, error: 'Session not found' }); return; } res.json({ success: true }); } catch (error) { - logError(error, "Delete session failed"); + logError(error, 'Delete session failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/sessions/routes/index.ts b/apps/server/src/routes/sessions/routes/index.ts index 64b891db..5f82bcab 100644 --- a/apps/server/src/routes/sessions/routes/index.ts +++ b/apps/server/src/routes/sessions/routes/index.ts @@ -2,14 +2,14 @@ * GET / endpoint - List all sessions */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createIndexHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { try { - const includeArchived = req.query.includeArchived === "true"; + const includeArchived = req.query.includeArchived === 'true'; const sessionsRaw = await agentService.listSessions(includeArchived); // Transform to match frontend SessionListItem interface @@ -17,7 +17,7 @@ export function createIndexHandler(agentService: AgentService) { sessionsRaw.map(async (s) => { const messages = await agentService.loadSession(s.id); const lastMessage = messages[messages.length - 1]; - const preview = lastMessage?.content?.slice(0, 100) || ""; + const preview = lastMessage?.content?.slice(0, 100) || ''; return { id: s.id, @@ -36,7 +36,7 @@ export function createIndexHandler(agentService: AgentService) { res.json({ success: true, sessions }); } catch (error) { - logError(error, "List sessions failed"); + logError(error, 'List sessions failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/sessions/routes/unarchive.ts b/apps/server/src/routes/sessions/routes/unarchive.ts index 07e4be17..638d3150 100644 --- a/apps/server/src/routes/sessions/routes/unarchive.ts +++ b/apps/server/src/routes/sessions/routes/unarchive.ts @@ -2,9 +2,9 @@ * POST /:sessionId/unarchive endpoint - Unarchive a session */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createUnarchiveHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -13,13 +13,13 @@ export function createUnarchiveHandler(agentService: AgentService) { const success = await agentService.unarchiveSession(sessionId); if (!success) { - res.status(404).json({ success: false, error: "Session not found" }); + res.status(404).json({ success: false, error: 'Session not found' }); return; } res.json({ success: true }); } catch (error) { - logError(error, "Unarchive session failed"); + logError(error, 'Unarchive session failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/sessions/routes/update.ts b/apps/server/src/routes/sessions/routes/update.ts index 2dbea431..7705fa22 100644 --- a/apps/server/src/routes/sessions/routes/update.ts +++ b/apps/server/src/routes/sessions/routes/update.ts @@ -2,9 +2,9 @@ * PUT /:sessionId endpoint - Update a session */ -import type { Request, Response } from "express"; -import { AgentService } from "../../../services/agent-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createUpdateHandler(agentService: AgentService) { return async (req: Request, res: Response): Promise => { @@ -22,13 +22,13 @@ export function createUpdateHandler(agentService: AgentService) { model, }); if (!session) { - res.status(404).json({ success: false, error: "Session not found" }); + res.status(404).json({ success: false, error: 'Session not found' }); return; } res.json({ success: true, session }); } catch (error) { - logError(error, "Update session failed"); + logError(error, 'Update session failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/setup/get-claude-status.ts b/apps/server/src/routes/setup/get-claude-status.ts index 2ae072ff..922d363f 100644 --- a/apps/server/src/routes/setup/get-claude-status.ts +++ b/apps/server/src/routes/setup/get-claude-status.ts @@ -2,36 +2,36 @@ * Business logic for getting Claude CLI status */ -import { exec } from "child_process"; -import { promisify } from "util"; -import os from "os"; -import path from "path"; -import fs from "fs/promises"; -import { getApiKey } from "./common.js"; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { getApiKey } from './common.js'; const execAsync = promisify(exec); export async function getClaudeStatus() { let installed = false; - let version = ""; - let cliPath = ""; - let method = "none"; + let version = ''; + let cliPath = ''; + let method = 'none'; - const isWindows = process.platform === "win32"; + const isWindows = process.platform === 'win32'; // Try to find Claude CLI using platform-specific command try { // Use 'where' on Windows, 'which' on Unix-like systems - const findCommand = isWindows ? "where claude" : "which claude"; + const findCommand = isWindows ? 'where claude' : 'which claude'; const { stdout } = await execAsync(findCommand); // 'where' on Windows can return multiple paths - take the first one cliPath = stdout.trim().split(/\r?\n/)[0]; installed = true; - method = "path"; + method = 'path'; // Get version try { - const { stdout: versionOut } = await execAsync("claude --version"); + const { stdout: versionOut } = await execAsync('claude --version'); version = versionOut.trim(); } catch { // Version command might not be available @@ -40,22 +40,22 @@ export async function getClaudeStatus() { // Not in PATH, try common locations based on platform const commonPaths = isWindows ? (() => { - const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); return [ // Windows-specific paths - path.join(os.homedir(), ".local", "bin", "claude.exe"), - path.join(appData, "npm", "claude.cmd"), - path.join(appData, "npm", "claude"), - path.join(appData, ".npm-global", "bin", "claude.cmd"), - path.join(appData, ".npm-global", "bin", "claude"), + path.join(os.homedir(), '.local', 'bin', 'claude.exe'), + path.join(appData, 'npm', 'claude.cmd'), + path.join(appData, 'npm', 'claude'), + path.join(appData, '.npm-global', 'bin', 'claude.cmd'), + path.join(appData, '.npm-global', 'bin', 'claude'), ]; })() : [ // Unix (Linux/macOS) paths - path.join(os.homedir(), ".local", "bin", "claude"), - path.join(os.homedir(), ".claude", "local", "claude"), - "/usr/local/bin/claude", - path.join(os.homedir(), ".npm-global", "bin", "claude"), + path.join(os.homedir(), '.local', 'bin', 'claude'), + path.join(os.homedir(), '.claude', 'local', 'claude'), + '/usr/local/bin/claude', + path.join(os.homedir(), '.npm-global', 'bin', 'claude'), ]; for (const p of commonPaths) { @@ -63,7 +63,7 @@ export async function getClaudeStatus() { await fs.access(p); cliPath = p; installed = true; - method = "local"; + method = 'local'; // Get version from this path try { @@ -84,11 +84,11 @@ export async function getClaudeStatus() { // apiKeys.anthropic stores direct API keys for pay-per-use let auth = { authenticated: false, - method: "none" as string, + method: 'none' as string, hasCredentialsFile: false, hasToken: false, - hasStoredOAuthToken: !!getApiKey("anthropic_oauth_token"), - hasStoredApiKey: !!getApiKey("anthropic"), + hasStoredOAuthToken: !!getApiKey('anthropic_oauth_token'), + hasStoredApiKey: !!getApiKey('anthropic'), hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY, // Additional fields for detailed status oauthTokenValid: false, @@ -97,13 +97,13 @@ export async function getClaudeStatus() { hasRecentActivity: false, }; - const claudeDir = path.join(os.homedir(), ".claude"); + const claudeDir = path.join(os.homedir(), '.claude'); // Check for recent Claude CLI activity - indicates working authentication // The stats-cache.json file is only populated when the CLI is working properly - const statsCachePath = path.join(claudeDir, "stats-cache.json"); + const statsCachePath = path.join(claudeDir, 'stats-cache.json'); try { - const statsContent = await fs.readFile(statsCachePath, "utf-8"); + const statsContent = await fs.readFile(statsCachePath, 'utf-8'); const stats = JSON.parse(statsContent); // Check if there's any activity (which means the CLI is authenticated and working) @@ -111,26 +111,26 @@ export async function getClaudeStatus() { auth.hasRecentActivity = true; auth.hasCliAuth = true; auth.authenticated = true; - auth.method = "cli_authenticated"; + auth.method = 'cli_authenticated'; } } catch { // Stats file doesn't exist or is invalid } // Check for settings.json - indicates CLI has been set up - const settingsPath = path.join(claudeDir, "settings.json"); + const settingsPath = path.join(claudeDir, 'settings.json'); try { await fs.access(settingsPath); // If settings exist but no activity, CLI might be set up but not authenticated if (!auth.hasCliAuth) { // Try to check for other indicators of auth - const sessionsDir = path.join(claudeDir, "projects"); + const sessionsDir = path.join(claudeDir, 'projects'); try { const sessions = await fs.readdir(sessionsDir); if (sessions.length > 0) { auth.hasCliAuth = true; auth.authenticated = true; - auth.method = "cli_authenticated"; + auth.method = 'cli_authenticated'; } } catch { // Sessions directory doesn't exist @@ -143,13 +143,13 @@ export async function getClaudeStatus() { // Check for credentials file (OAuth tokens from claude login) // Note: Claude CLI may use ".credentials.json" (hidden) or "credentials.json" depending on version/platform const credentialsPaths = [ - path.join(claudeDir, ".credentials.json"), - path.join(claudeDir, "credentials.json"), + path.join(claudeDir, '.credentials.json'), + path.join(claudeDir, 'credentials.json'), ]; for (const credentialsPath of credentialsPaths) { try { - const credentialsContent = await fs.readFile(credentialsPath, "utf-8"); + const credentialsContent = await fs.readFile(credentialsPath, 'utf-8'); const credentials = JSON.parse(credentialsContent); auth.hasCredentialsFile = true; @@ -158,11 +158,11 @@ export async function getClaudeStatus() { auth.hasStoredOAuthToken = true; auth.oauthTokenValid = true; auth.authenticated = true; - auth.method = "oauth_token"; // Stored OAuth token from credentials file + auth.method = 'oauth_token'; // Stored OAuth token from credentials file } else if (credentials.api_key) { auth.apiKeyValid = true; auth.authenticated = true; - auth.method = "api_key"; // Stored API key in credentials file + auth.method = 'api_key'; // Stored API key in credentials file } break; // Found and processed credentials file } catch { @@ -174,25 +174,25 @@ export async function getClaudeStatus() { if (auth.hasEnvApiKey) { auth.authenticated = true; auth.apiKeyValid = true; - auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var + auth.method = 'api_key_env'; // API key from ANTHROPIC_API_KEY env var } // In-memory stored OAuth token (from setup wizard - subscription auth) - if (!auth.authenticated && getApiKey("anthropic_oauth_token")) { + if (!auth.authenticated && getApiKey('anthropic_oauth_token')) { auth.authenticated = true; auth.oauthTokenValid = true; - auth.method = "oauth_token"; // Stored OAuth token from setup wizard + auth.method = 'oauth_token'; // Stored OAuth token from setup wizard } // In-memory stored API key (from settings UI - pay-per-use) - if (!auth.authenticated && getApiKey("anthropic")) { + if (!auth.authenticated && getApiKey('anthropic')) { auth.authenticated = true; auth.apiKeyValid = true; - auth.method = "api_key"; // Manually stored API key + auth.method = 'api_key'; // Manually stored API key } return { - status: installed ? "installed" : "not_installed", + status: installed ? 'installed' : 'not_installed', installed, method, version, diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index 2b5db942..3681b2fc 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -2,29 +2,29 @@ * Setup routes - HTTP API for CLI detection, API keys, and platform info */ -import { Router } from "express"; -import { createClaudeStatusHandler } from "./routes/claude-status.js"; -import { createInstallClaudeHandler } from "./routes/install-claude.js"; -import { createAuthClaudeHandler } from "./routes/auth-claude.js"; -import { createStoreApiKeyHandler } from "./routes/store-api-key.js"; -import { createDeleteApiKeyHandler } from "./routes/delete-api-key.js"; -import { createApiKeysHandler } from "./routes/api-keys.js"; -import { createPlatformHandler } from "./routes/platform.js"; -import { createVerifyClaudeAuthHandler } from "./routes/verify-claude-auth.js"; -import { createGhStatusHandler } from "./routes/gh-status.js"; +import { Router } from 'express'; +import { createClaudeStatusHandler } from './routes/claude-status.js'; +import { createInstallClaudeHandler } from './routes/install-claude.js'; +import { createAuthClaudeHandler } from './routes/auth-claude.js'; +import { createStoreApiKeyHandler } from './routes/store-api-key.js'; +import { createDeleteApiKeyHandler } from './routes/delete-api-key.js'; +import { createApiKeysHandler } from './routes/api-keys.js'; +import { createPlatformHandler } from './routes/platform.js'; +import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js'; +import { createGhStatusHandler } from './routes/gh-status.js'; export function createSetupRoutes(): Router { const router = Router(); - router.get("/claude-status", createClaudeStatusHandler()); - router.post("/install-claude", createInstallClaudeHandler()); - router.post("/auth-claude", createAuthClaudeHandler()); - router.post("/store-api-key", createStoreApiKeyHandler()); - router.post("/delete-api-key", createDeleteApiKeyHandler()); - router.get("/api-keys", createApiKeysHandler()); - router.get("/platform", createPlatformHandler()); - router.post("/verify-claude-auth", createVerifyClaudeAuthHandler()); - router.get("/gh-status", createGhStatusHandler()); + router.get('/claude-status', createClaudeStatusHandler()); + router.post('/install-claude', createInstallClaudeHandler()); + router.post('/auth-claude', createAuthClaudeHandler()); + router.post('/store-api-key', createStoreApiKeyHandler()); + router.post('/delete-api-key', createDeleteApiKeyHandler()); + router.get('/api-keys', createApiKeysHandler()); + router.get('/platform', createPlatformHandler()); + router.post('/verify-claude-auth', createVerifyClaudeAuthHandler()); + router.get('/gh-status', createGhStatusHandler()); return router; } diff --git a/apps/server/src/routes/setup/routes/auth-claude.ts b/apps/server/src/routes/setup/routes/auth-claude.ts index 2ab8401d..4531501d 100644 --- a/apps/server/src/routes/setup/routes/auth-claude.ts +++ b/apps/server/src/routes/setup/routes/auth-claude.ts @@ -2,8 +2,8 @@ * POST /auth-claude endpoint - Auth Claude */ -import type { Request, Response } from "express"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; export function createAuthClaudeHandler() { return async (_req: Request, res: Response): Promise => { @@ -11,11 +11,11 @@ export function createAuthClaudeHandler() { res.json({ success: true, requiresManualAuth: true, - command: "claude login", + command: 'claude login', message: "Please run 'claude login' in your terminal to authenticate", }); } catch (error) { - logError(error, "Auth Claude failed"); + logError(error, 'Auth Claude failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/setup/routes/claude-status.ts b/apps/server/src/routes/setup/routes/claude-status.ts index 232a47bd..f2ae4a59 100644 --- a/apps/server/src/routes/setup/routes/claude-status.ts +++ b/apps/server/src/routes/setup/routes/claude-status.ts @@ -2,9 +2,9 @@ * GET /claude-status endpoint - Get Claude CLI status */ -import type { Request, Response } from "express"; -import { getClaudeStatus } from "../get-claude-status.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getClaudeStatus } from '../get-claude-status.js'; +import { getErrorMessage, logError } from '../common.js'; export function createClaudeStatusHandler() { return async (_req: Request, res: Response): Promise => { @@ -15,7 +15,7 @@ export function createClaudeStatusHandler() { ...status, }); } catch (error) { - logError(error, "Get Claude status failed"); + logError(error, 'Get Claude status failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/setup/routes/gh-status.ts b/apps/server/src/routes/setup/routes/gh-status.ts index 7dcf5d82..4d36561c 100644 --- a/apps/server/src/routes/setup/routes/gh-status.ts +++ b/apps/server/src/routes/setup/routes/gh-status.ts @@ -2,24 +2,26 @@ * GET /gh-status endpoint - Get GitHub CLI status */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import os from "os"; -import path from "path"; -import fs from "fs/promises"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); // Extended PATH to include common tool installation locations const extendedPath = [ process.env.PATH, - "/opt/homebrew/bin", - "/usr/local/bin", - "/home/linuxbrew/.linuxbrew/bin", + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', `${process.env.HOME}/.local/bin`, -].filter(Boolean).join(":"); +] + .filter(Boolean) + .join(':'); const execEnv = { ...process.env, @@ -44,11 +46,11 @@ async function getGhStatus(): Promise { user: null, }; - const isWindows = process.platform === "win32"; + const isWindows = process.platform === 'win32'; // Check if gh CLI is installed try { - const findCommand = isWindows ? "where gh" : "command -v gh"; + const findCommand = isWindows ? 'where gh' : 'command -v gh'; const { stdout } = await execAsync(findCommand, { env: execEnv }); status.path = stdout.trim().split(/\r?\n/)[0]; status.installed = true; @@ -56,14 +58,14 @@ async function getGhStatus(): Promise { // gh not in PATH, try common locations const commonPaths = isWindows ? [ - path.join(process.env.LOCALAPPDATA || "", "Programs", "gh", "bin", "gh.exe"), - path.join(process.env.ProgramFiles || "", "GitHub CLI", "gh.exe"), + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'gh', 'bin', 'gh.exe'), + path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'), ] : [ - "/opt/homebrew/bin/gh", - "/usr/local/bin/gh", - path.join(os.homedir(), ".local", "bin", "gh"), - "/home/linuxbrew/.linuxbrew/bin/gh", + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + path.join(os.homedir(), '.local', 'bin', 'gh'), + '/home/linuxbrew/.linuxbrew/bin/gh', ]; for (const p of commonPaths) { @@ -84,30 +86,31 @@ async function getGhStatus(): Promise { // Get version try { - const { stdout } = await execAsync("gh --version", { env: execEnv }); + const { stdout } = await execAsync('gh --version', { env: execEnv }); // Extract version from output like "gh version 2.40.1 (2024-01-09)" const versionMatch = stdout.match(/gh version ([\d.]+)/); - status.version = versionMatch ? versionMatch[1] : stdout.trim().split("\n")[0]; + status.version = versionMatch ? versionMatch[1] : stdout.trim().split('\n')[0]; } catch { // Version command failed } // Check authentication status try { - const { stdout } = await execAsync("gh auth status", { env: execEnv }); + const { stdout } = await execAsync('gh auth status', { env: execEnv }); // If this succeeds without error, we're authenticated status.authenticated = true; // Try to extract username from output - const userMatch = stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) || - stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i); + const userMatch = + stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) || + stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i); if (userMatch) { status.user = userMatch[1]; } } catch (error: unknown) { // Auth status returns non-zero if not authenticated const err = error as { stderr?: string }; - if (err.stderr?.includes("not logged in")) { + if (err.stderr?.includes('not logged in')) { status.authenticated = false; } } @@ -124,7 +127,7 @@ export function createGhStatusHandler() { ...status, }); } catch (error) { - logError(error, "Get GitHub CLI status failed"); + logError(error, 'Get GitHub CLI status failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/setup/routes/install-claude.ts b/apps/server/src/routes/setup/routes/install-claude.ts index c471fc6c..644f5e10 100644 --- a/apps/server/src/routes/setup/routes/install-claude.ts +++ b/apps/server/src/routes/setup/routes/install-claude.ts @@ -2,8 +2,8 @@ * POST /install-claude endpoint - Install Claude CLI */ -import type { Request, Response } from "express"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; export function createInstallClaudeHandler() { return async (_req: Request, res: Response): Promise => { @@ -13,10 +13,10 @@ export function createInstallClaudeHandler() { res.json({ success: false, error: - "CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code", + 'CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code', }); } catch (error) { - logError(error, "Install Claude CLI failed"); + logError(error, 'Install Claude CLI failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/setup/routes/platform.ts b/apps/server/src/routes/setup/routes/platform.ts index 40788d0b..303cdd87 100644 --- a/apps/server/src/routes/setup/routes/platform.ts +++ b/apps/server/src/routes/setup/routes/platform.ts @@ -2,9 +2,9 @@ * GET /platform endpoint - Get platform info */ -import type { Request, Response } from "express"; -import os from "os"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import os from 'os'; +import { getErrorMessage, logError } from '../common.js'; export function createPlatformHandler() { return async (_req: Request, res: Response): Promise => { @@ -15,12 +15,12 @@ export function createPlatformHandler() { platform, arch: os.arch(), homeDir: os.homedir(), - isWindows: platform === "win32", - isMac: platform === "darwin", - isLinux: platform === "linux", + isWindows: platform === 'win32', + isMac: platform === 'darwin', + isLinux: platform === 'linux', }); } catch (error) { - logError(error, "Get platform info failed"); + logError(error, 'Get platform info failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/suggestions/routes/status.ts b/apps/server/src/routes/suggestions/routes/status.ts index d62dfa17..eb135e06 100644 --- a/apps/server/src/routes/suggestions/routes/status.ts +++ b/apps/server/src/routes/suggestions/routes/status.ts @@ -2,8 +2,8 @@ * GET /status endpoint - Get status */ -import type { Request, Response } from "express"; -import { getSuggestionsStatus, getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js'; export function createStatusHandler() { return async (_req: Request, res: Response): Promise => { @@ -11,7 +11,7 @@ export function createStatusHandler() { const { isRunning } = getSuggestionsStatus(); res.json({ success: true, isRunning }); } catch (error) { - logError(error, "Get status failed"); + logError(error, 'Get status failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/suggestions/routes/stop.ts b/apps/server/src/routes/suggestions/routes/stop.ts index 3a18a0be..f9e01fb6 100644 --- a/apps/server/src/routes/suggestions/routes/stop.ts +++ b/apps/server/src/routes/suggestions/routes/stop.ts @@ -2,13 +2,8 @@ * POST /stop endpoint - Stop suggestions generation */ -import type { Request, Response } from "express"; -import { - getSuggestionsStatus, - setRunningState, - getErrorMessage, - logError, -} from "../common.js"; +import type { Request, Response } from 'express'; +import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js'; export function createStopHandler() { return async (_req: Request, res: Response): Promise => { @@ -20,7 +15,7 @@ export function createStopHandler() { setRunningState(false, null); res.json({ success: true }); } catch (error) { - logError(error, "Stop suggestions failed"); + logError(error, 'Stop suggestions failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/templates/index.ts b/apps/server/src/routes/templates/index.ts index 4e7462fe..38eb0270 100644 --- a/apps/server/src/routes/templates/index.ts +++ b/apps/server/src/routes/templates/index.ts @@ -3,13 +3,13 @@ * Provides API for cloning GitHub starter templates */ -import { Router } from "express"; -import { createCloneHandler } from "./routes/clone.js"; +import { Router } from 'express'; +import { createCloneHandler } from './routes/clone.js'; export function createTemplatesRoutes(): Router { const router = Router(); - router.post("/clone", createCloneHandler()); + router.post('/clone', createCloneHandler()); return router; } diff --git a/apps/server/src/routes/terminal/index.ts b/apps/server/src/routes/terminal/index.ts index 404189d7..380801e5 100644 --- a/apps/server/src/routes/terminal/index.ts +++ b/apps/server/src/routes/terminal/index.ts @@ -5,26 +5,20 @@ * WebSocket connections for real-time I/O are handled separately in index.ts. */ -import { Router } from "express"; +import { Router } from 'express'; import { terminalAuthMiddleware, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired, -} from "./common.js"; -import { createStatusHandler } from "./routes/status.js"; -import { createAuthHandler } from "./routes/auth.js"; -import { createLogoutHandler } from "./routes/logout.js"; -import { - createSessionsListHandler, - createSessionsCreateHandler, -} from "./routes/sessions.js"; -import { createSessionDeleteHandler } from "./routes/session-delete.js"; -import { createSessionResizeHandler } from "./routes/session-resize.js"; -import { - createSettingsGetHandler, - createSettingsUpdateHandler, -} from "./routes/settings.js"; +} from './common.js'; +import { createStatusHandler } from './routes/status.js'; +import { createAuthHandler } from './routes/auth.js'; +import { createLogoutHandler } from './routes/logout.js'; +import { createSessionsListHandler, createSessionsCreateHandler } from './routes/sessions.js'; +import { createSessionDeleteHandler } from './routes/session-delete.js'; +import { createSessionResizeHandler } from './routes/session-resize.js'; +import { createSettingsGetHandler, createSettingsUpdateHandler } from './routes/settings.js'; // Re-export for use in main index.ts export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired }; @@ -32,19 +26,19 @@ export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired }; export function createTerminalRoutes(): Router { const router = Router(); - router.get("/status", createStatusHandler()); - router.post("/auth", createAuthHandler()); - router.post("/logout", createLogoutHandler()); + router.get('/status', createStatusHandler()); + router.post('/auth', createAuthHandler()); + router.post('/logout', createLogoutHandler()); // Apply terminal auth middleware to all routes below router.use(terminalAuthMiddleware); - router.get("/sessions", createSessionsListHandler()); - router.post("/sessions", createSessionsCreateHandler()); - router.delete("/sessions/:id", createSessionDeleteHandler()); - router.post("/sessions/:id/resize", createSessionResizeHandler()); - router.get("/settings", createSettingsGetHandler()); - router.put("/settings", createSettingsUpdateHandler()); + router.get('/sessions', createSessionsListHandler()); + router.post('/sessions', createSessionsCreateHandler()); + router.delete('/sessions/:id', createSessionDeleteHandler()); + router.post('/sessions/:id/resize', createSessionResizeHandler()); + router.get('/settings', createSettingsGetHandler()); + router.put('/settings', createSettingsUpdateHandler()); return router; } diff --git a/apps/server/src/routes/terminal/routes/auth.ts b/apps/server/src/routes/terminal/routes/auth.ts index 234d4572..1d6156bd 100644 --- a/apps/server/src/routes/terminal/routes/auth.ts +++ b/apps/server/src/routes/terminal/routes/auth.ts @@ -2,7 +2,7 @@ * POST /auth endpoint - Authenticate with password to get a session token */ -import type { Request, Response } from "express"; +import type { Request, Response } from 'express'; import { getTerminalEnabledConfigValue, getTerminalPasswordConfig, @@ -10,14 +10,14 @@ import { addToken, getTokenExpiryMs, getErrorMessage, -} from "../common.js"; +} from '../common.js'; export function createAuthHandler() { return (req: Request, res: Response): void => { if (!getTerminalEnabledConfigValue()) { res.status(403).json({ success: false, - error: "Terminal access is disabled", + error: 'Terminal access is disabled', }); return; } @@ -41,7 +41,7 @@ export function createAuthHandler() { if (!password || password !== terminalPassword) { res.status(401).json({ success: false, - error: "Invalid password", + error: 'Invalid password', }); return; } diff --git a/apps/server/src/routes/terminal/routes/logout.ts b/apps/server/src/routes/terminal/routes/logout.ts index 9e3c8fa3..2af85713 100644 --- a/apps/server/src/routes/terminal/routes/logout.ts +++ b/apps/server/src/routes/terminal/routes/logout.ts @@ -2,12 +2,12 @@ * POST /logout endpoint - Invalidate a session token */ -import type { Request, Response } from "express"; -import { deleteToken } from "../common.js"; +import type { Request, Response } from 'express'; +import { deleteToken } from '../common.js'; export function createLogoutHandler() { return (req: Request, res: Response): void => { - const token = (req.headers["x-terminal-token"] as string) || req.body.token; + const token = (req.headers['x-terminal-token'] as string) || req.body.token; if (token) { deleteToken(token); diff --git a/apps/server/src/routes/terminal/routes/session-delete.ts b/apps/server/src/routes/terminal/routes/session-delete.ts index aa3f96cb..dec3c694 100644 --- a/apps/server/src/routes/terminal/routes/session-delete.ts +++ b/apps/server/src/routes/terminal/routes/session-delete.ts @@ -2,8 +2,8 @@ * DELETE /sessions/:id endpoint - Kill a terminal session */ -import type { Request, Response } from "express"; -import { getTerminalService } from "../../../services/terminal-service.js"; +import type { Request, Response } from 'express'; +import { getTerminalService } from '../../../services/terminal-service.js'; export function createSessionDeleteHandler() { return (req: Request, res: Response): void => { @@ -14,7 +14,7 @@ export function createSessionDeleteHandler() { if (!killed) { res.status(404).json({ success: false, - error: "Session not found", + error: 'Session not found', }); return; } diff --git a/apps/server/src/routes/terminal/routes/session-resize.ts b/apps/server/src/routes/terminal/routes/session-resize.ts index a6a8a70d..41db9763 100644 --- a/apps/server/src/routes/terminal/routes/session-resize.ts +++ b/apps/server/src/routes/terminal/routes/session-resize.ts @@ -2,8 +2,8 @@ * POST /sessions/:id/resize endpoint - Resize a terminal session */ -import type { Request, Response } from "express"; -import { getTerminalService } from "../../../services/terminal-service.js"; +import type { Request, Response } from 'express'; +import { getTerminalService } from '../../../services/terminal-service.js'; export function createSessionResizeHandler() { return (req: Request, res: Response): void => { @@ -14,7 +14,7 @@ export function createSessionResizeHandler() { if (!cols || !rows) { res.status(400).json({ success: false, - error: "cols and rows are required", + error: 'cols and rows are required', }); return; } @@ -24,7 +24,7 @@ export function createSessionResizeHandler() { if (!resized) { res.status(404).json({ success: false, - error: "Session not found", + error: 'Session not found', }); return; } diff --git a/apps/server/src/routes/terminal/routes/settings.ts b/apps/server/src/routes/terminal/routes/settings.ts index 3af142a8..9d814606 100644 --- a/apps/server/src/routes/terminal/routes/settings.ts +++ b/apps/server/src/routes/terminal/routes/settings.ts @@ -2,9 +2,13 @@ * GET/PUT /settings endpoint - Get/Update terminal settings */ -import type { Request, Response } from "express"; -import { getTerminalService, MIN_MAX_SESSIONS, MAX_MAX_SESSIONS } from "../../../services/terminal-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { + getTerminalService, + MIN_MAX_SESSIONS, + MAX_MAX_SESSIONS, +} from '../../../services/terminal-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createSettingsGetHandler() { return (_req: Request, res: Response): void => { @@ -18,10 +22,10 @@ export function createSettingsGetHandler() { }, }); } catch (error) { - logError(error, "Get terminal settings failed"); + logError(error, 'Get terminal settings failed'); res.status(500).json({ success: false, - error: "Failed to get terminal settings", + error: 'Failed to get terminal settings', details: getErrorMessage(error), }); } @@ -36,17 +40,17 @@ export function createSettingsUpdateHandler() { // Validate maxSessions if provided if (maxSessions !== undefined) { - if (typeof maxSessions !== "number") { + if (typeof maxSessions !== 'number') { res.status(400).json({ success: false, - error: "maxSessions must be a number", + error: 'maxSessions must be a number', }); return; } if (!Number.isInteger(maxSessions)) { res.status(400).json({ success: false, - error: "maxSessions must be an integer", + error: 'maxSessions must be an integer', }); return; } @@ -68,10 +72,10 @@ export function createSettingsUpdateHandler() { }, }); } catch (error) { - logError(error, "Update terminal settings failed"); + logError(error, 'Update terminal settings failed'); res.status(500).json({ success: false, - error: "Failed to update terminal settings", + error: 'Failed to update terminal settings', details: getErrorMessage(error), }); } diff --git a/apps/server/src/routes/terminal/routes/status.ts b/apps/server/src/routes/terminal/routes/status.ts index 014c482a..670b405c 100644 --- a/apps/server/src/routes/terminal/routes/status.ts +++ b/apps/server/src/routes/terminal/routes/status.ts @@ -2,12 +2,9 @@ * GET /status endpoint - Get terminal status */ -import type { Request, Response } from "express"; -import { getTerminalService } from "../../../services/terminal-service.js"; -import { - getTerminalEnabledConfigValue, - isTerminalPasswordRequired, -} from "../common.js"; +import type { Request, Response } from 'express'; +import { getTerminalService } from '../../../services/terminal-service.js'; +import { getTerminalEnabledConfigValue, isTerminalPasswordRequired } from '../common.js'; export function createStatusHandler() { return (_req: Request, res: Response): void => { diff --git a/apps/server/src/routes/workspace/index.ts b/apps/server/src/routes/workspace/index.ts index ec247a89..37424997 100644 --- a/apps/server/src/routes/workspace/index.ts +++ b/apps/server/src/routes/workspace/index.ts @@ -3,15 +3,15 @@ * Provides API endpoints for workspace directory management */ -import { Router } from "express"; -import { createConfigHandler } from "./routes/config.js"; -import { createDirectoriesHandler } from "./routes/directories.js"; +import { Router } from 'express'; +import { createConfigHandler } from './routes/config.js'; +import { createDirectoriesHandler } from './routes/directories.js'; export function createWorkspaceRoutes(): Router { const router = Router(); - router.get("/config", createConfigHandler()); - router.get("/directories", createDirectoriesHandler()); + router.get('/config', createConfigHandler()); + router.get('/directories', createDirectoriesHandler()); return router; } diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index 50254a69..ef8ddc47 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -2,10 +2,10 @@ * POST /checkout-branch endpoint - Create and checkout a new branch */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -20,7 +20,7 @@ export function createCheckoutBranchHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath required", + error: 'worktreePath required', }); return; } @@ -28,7 +28,7 @@ export function createCheckoutBranchHandler() { if (!branchName) { res.status(400).json({ success: false, - error: "branchName required", + error: 'branchName required', }); return; } @@ -38,16 +38,15 @@ export function createCheckoutBranchHandler() { if (invalidChars.test(branchName)) { res.status(400).json({ success: false, - error: "Branch name contains invalid characters", + error: 'Branch name contains invalid characters', }); return; } // Get current branch for reference - const { stdout: currentBranchOutput } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: worktreePath } - ); + const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); const currentBranch = currentBranchOutput.trim(); // Check if branch already exists @@ -79,7 +78,7 @@ export function createCheckoutBranchHandler() { }, }); } catch (error) { - logError(error, "Checkout branch failed"); + logError(error, 'Checkout branch failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/commit.ts b/apps/server/src/routes/worktree/routes/commit.ts index 273c7964..6cdc39c1 100644 --- a/apps/server/src/routes/worktree/routes/commit.ts +++ b/apps/server/src/routes/worktree/routes/commit.ts @@ -2,10 +2,10 @@ * POST /commit endpoint - Commit changes in a worktree */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -20,13 +20,13 @@ export function createCommitHandler() { if (!worktreePath || !message) { res.status(400).json({ success: false, - error: "worktreePath and message required", + error: 'worktreePath and message required', }); return; } // Check for uncommitted changes - const { stdout: status } = await execAsync("git status --porcelain", { + const { stdout: status } = await execAsync('git status --porcelain', { cwd: worktreePath, }); @@ -35,14 +35,14 @@ export function createCommitHandler() { success: true, result: { committed: false, - message: "No changes to commit", + message: 'No changes to commit', }, }); return; } // Stage all changes - await execAsync("git add -A", { cwd: worktreePath }); + await execAsync('git add -A', { cwd: worktreePath }); // Create commit await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { @@ -50,16 +50,15 @@ export function createCommitHandler() { }); // Get commit hash - const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", { + const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', { cwd: worktreePath, }); const commitHash = hashOutput.trim().substring(0, 8); // Get branch name - const { stdout: branchOutput } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: worktreePath } - ); + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); const branchName = branchOutput.trim(); res.json({ @@ -72,7 +71,7 @@ export function createCommitHandler() { }, }); } catch (error) { - logError(error, "Commit worktree failed"); + logError(error, 'Commit worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index 488fa3b5..1e71bfab 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -2,7 +2,7 @@ * POST /create-pr endpoint - Commit changes and create a pull request from a worktree */ -import type { Request, Response } from "express"; +import type { Request, Response } from 'express'; import { getErrorMessage, logError, @@ -10,26 +10,27 @@ import { execEnv, isValidBranchName, isGhCliAvailable, -} from "../common.js"; -import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js"; +} from '../common.js'; +import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; export function createCreatePRHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as { - worktreePath: string; - projectPath?: string; - commitMessage?: string; - prTitle?: string; - prBody?: string; - baseBranch?: string; - draft?: boolean; - }; + const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = + req.body as { + worktreePath: string; + projectPath?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + baseBranch?: string; + draft?: boolean; + }; if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath required", + error: 'worktreePath required', }); return; } @@ -39,23 +40,23 @@ export function createCreatePRHandler() { const effectiveProjectPath = projectPath || worktreePath; // Get current branch name - const { stdout: branchOutput } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: worktreePath, env: execEnv } - ); + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + env: execEnv, + }); const branchName = branchOutput.trim(); // Validate branch name for security if (!isValidBranchName(branchName)) { res.status(400).json({ success: false, - error: "Invalid branch name contains unsafe characters", + error: 'Invalid branch name contains unsafe characters', }); return; } // Check for uncommitted changes - const { stdout: status } = await execAsync("git status --porcelain", { + const { stdout: status } = await execAsync('git status --porcelain', { cwd: worktreePath, env: execEnv, }); @@ -67,7 +68,7 @@ export function createCreatePRHandler() { const message = commitMessage || `Changes from ${branchName}`; // Stage all changes - await execAsync("git add -A", { cwd: worktreePath, env: execEnv }); + await execAsync('git add -A', { cwd: worktreePath, env: execEnv }); // Create commit await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { @@ -76,7 +77,7 @@ export function createCreatePRHandler() { }); // Get commit hash - const { stdout: hashOutput } = await execAsync("git rev-parse HEAD", { + const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', { cwd: worktreePath, env: execEnv, }); @@ -100,8 +101,8 @@ export function createCreatePRHandler() { } catch (error2: unknown) { // Capture push error for reporting const err = error2 as { stderr?: string; message?: string }; - pushError = err.stderr || err.message || "Push failed"; - console.error("[CreatePR] Push failed:", pushError); + pushError = err.stderr || err.message || 'Push failed'; + console.error('[CreatePR] Push failed:', pushError); } } @@ -115,10 +116,10 @@ export function createCreatePRHandler() { } // Create PR using gh CLI or provide browser fallback - const base = baseBranch || "main"; + const base = baseBranch || 'main'; const title = prTitle || branchName; const body = prBody || `Changes from branch ${branchName}`; - const draftFlag = draft ? "--draft" : ""; + const draftFlag = draft ? '--draft' : ''; let prUrl: string | null = null; let prError: string | null = null; @@ -131,7 +132,7 @@ export function createCreatePRHandler() { let upstreamRepo: string | null = null; let originOwner: string | null = null; try { - const { stdout: remotes } = await execAsync("git remote -v", { + const { stdout: remotes } = await execAsync('git remote -v', { cwd: worktreePath, env: execEnv, }); @@ -150,15 +151,17 @@ export function createCreatePRHandler() { } if (!match) { // Try HTTPS format: https://github.com/owner/repo.git - match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); + match = line.match( + /^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ + ); } if (match) { const [, remoteName, owner, repo] = match; - if (remoteName === "upstream") { + if (remoteName === 'upstream') { upstreamRepo = `${owner}/${repo}`; repoUrl = `https://github.com/${owner}/${repo}`; - } else if (remoteName === "origin") { + } else if (remoteName === 'origin') { originOwner = owner; if (!repoUrl) { repoUrl = `https://github.com/${owner}/${repo}`; @@ -173,7 +176,7 @@ export function createCreatePRHandler() { // Fallback: Try to get repo URL from git config if remote parsing failed if (!repoUrl) { try { - const { stdout: originUrl } = await execAsync("git config --get remote.origin.url", { + const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', { cwd: worktreePath, env: execEnv, }); @@ -217,9 +220,11 @@ export function createCreatePRHandler() { // This is more reliable than gh pr view as it explicitly searches by branch name // For forks, we need to use owner:branch format for the head parameter const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; - const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : ""; + const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : ''; - console.log(`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`); + console.log( + `[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})` + ); try { const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`; console.log(`[CreatePR] Running: ${listCmd}`); @@ -234,7 +239,9 @@ export function createCreatePRHandler() { if (Array.isArray(existingPrs) && existingPrs.length > 0) { const existingPr = existingPrs[0]; // PR already exists - use it and store metadata - console.log(`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`); + console.log( + `[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}` + ); prUrl = existingPr.url; prNumber = existingPr.number; prAlreadyExisted = true; @@ -244,10 +251,12 @@ export function createCreatePRHandler() { number: existingPr.number, url: existingPr.url, title: existingPr.title || title, - state: existingPr.state || "open", + state: existingPr.state || 'open', createdAt: new Date().toISOString(), }); - console.log(`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`); + console.log( + `[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}` + ); } else { console.log(`[CreatePR] No existing PR found for branch ${branchName}`); } @@ -293,23 +302,25 @@ export function createCreatePRHandler() { number: prNumber, url: prUrl, title, - state: draft ? "draft" : "open", + state: draft ? 'draft' : 'open', createdAt: new Date().toISOString(), }); - console.log(`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`); + console.log( + `[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}` + ); } catch (metadataError) { - console.error("[CreatePR] Failed to store PR metadata:", metadataError); + console.error('[CreatePR] Failed to store PR metadata:', metadataError); } } } } catch (ghError: unknown) { // gh CLI failed - check if it's "already exists" error and try to fetch the PR const err = ghError as { stderr?: string; message?: string }; - const errorMessage = err.stderr || err.message || "PR creation failed"; + const errorMessage = err.stderr || err.message || 'PR creation failed'; console.log(`[CreatePR] gh pr create failed: ${errorMessage}`); // If error indicates PR already exists, try to fetch it - if (errorMessage.toLowerCase().includes("already exists")) { + if (errorMessage.toLowerCase().includes('already exists')) { console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`); try { const { stdout: viewOutput } = await execAsync( @@ -326,13 +337,13 @@ export function createCreatePRHandler() { number: existingPr.number, url: existingPr.url, title: existingPr.title || title, - state: existingPr.state || "open", + state: existingPr.state || 'open', createdAt: new Date().toISOString(), }); console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`); } } catch (viewError) { - console.error("[CreatePR] Failed to fetch existing PR:", viewError); + console.error('[CreatePR] Failed to fetch existing PR:', viewError); prError = errorMessage; } } else { @@ -341,7 +352,7 @@ export function createCreatePRHandler() { } } } else { - prError = "gh_cli_not_available"; + prError = 'gh_cli_not_available'; } // Return result with browser fallback URL @@ -362,7 +373,7 @@ export function createCreatePRHandler() { }, }); } catch (error) { - logError(error, "Create PR failed"); + logError(error, 'Create PR failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 0b07eb17..5fab4aff 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -2,10 +2,10 @@ * POST /list-branches endpoint - List all local branches */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logWorktreeError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logWorktreeError } from '../common.js'; const execAsync = promisify(exec); @@ -25,33 +25,31 @@ export function createListBranchesHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath required", + error: 'worktreePath required', }); return; } // Get current branch - const { stdout: currentBranchOutput } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: worktreePath } - ); + const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); const currentBranch = currentBranchOutput.trim(); // List all local branches // Use double quotes around the format string for cross-platform compatibility // Single quotes are preserved literally on Windows; double quotes work on both - const { stdout: branchesOutput } = await execAsync( - 'git branch --format="%(refname:short)"', - { cwd: worktreePath } - ); + const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', { + cwd: worktreePath, + }); const branches: BranchInfo[] = branchesOutput .trim() - .split("\n") + .split('\n') .filter((b) => b.trim()) .map((name) => { // Remove any surrounding quotes (Windows git may preserve them) - const cleanName = name.trim().replace(/^['"]|['"]$/g, ""); + const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); return { name: cleanName, isCurrent: cleanName === currentBranch, @@ -93,7 +91,7 @@ export function createListBranchesHandler() { }); } catch (error) { const worktreePath = req.body?.worktreePath; - logWorktreeError(error, "List branches failed", worktreePath); + logWorktreeError(error, 'List branches failed', worktreePath); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/list-dev-servers.ts b/apps/server/src/routes/worktree/routes/list-dev-servers.ts index ff5c527a..c1093ea5 100644 --- a/apps/server/src/routes/worktree/routes/list-dev-servers.ts +++ b/apps/server/src/routes/worktree/routes/list-dev-servers.ts @@ -5,9 +5,9 @@ * including their ports and URLs. */ -import type { Request, Response } from "express"; -import { getDevServerService } from "../../../services/dev-server-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createListDevServersHandler() { return async (_req: Request, res: Response): Promise => { @@ -22,7 +22,7 @@ export function createListDevServersHandler() { }, }); } catch (error) { - logError(error, "List dev servers failed"); + logError(error, 'List dev servers failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index f9499d85..40ac8dd4 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -2,11 +2,11 @@ * POST /merge endpoint - Merge feature (merge worktree branch into main) */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import path from "path"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -20,42 +20,34 @@ export function createMergeHandler() { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId required", - }); + res.status(400).json({ + success: false, + error: 'projectPath and featureId required', + }); return; } const branchName = `feature/${featureId}`; // Git worktrees are stored in project directory - const worktreePath = path.join(projectPath, ".worktrees", featureId); + const worktreePath = path.join(projectPath, '.worktrees', featureId); // Get current branch - const { stdout: currentBranch } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: projectPath } - ); + const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: projectPath, + }); // Merge the feature branch const mergeCmd = options?.squash ? `git merge --squash ${branchName}` - : `git merge ${branchName} -m "${ - options?.message || `Merge ${branchName}` - }"`; + : `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`; await execAsync(mergeCmd, { cwd: projectPath }); // If squash merge, need to commit if (options?.squash) { - await execAsync( - `git commit -m "${ - options?.message || `Merge ${branchName} (squash)` - }"`, - { cwd: projectPath } - ); + await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, { + cwd: projectPath, + }); } // Clean up worktree and branch @@ -70,7 +62,7 @@ export function createMergeHandler() { res.json({ success: true, mergedBranch: branchName }); } catch (error) { - logError(error, "Merge worktree failed"); + logError(error, 'Merge worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index 04f9815f..40e71b00 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -3,10 +3,10 @@ * GET /default-editor endpoint - Get the name of the default code editor */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -29,8 +29,8 @@ async function detectDefaultEditor(): Promise { // Try Cursor first (if user has Cursor, they probably prefer it) try { - await execAsync("which cursor || where cursor"); - cachedEditor = { name: "Cursor", command: "cursor" }; + await execAsync('which cursor || where cursor'); + cachedEditor = { name: 'Cursor', command: 'cursor' }; return cachedEditor; } catch { // Cursor not found @@ -38,8 +38,8 @@ async function detectDefaultEditor(): Promise { // Try VS Code try { - await execAsync("which code || where code"); - cachedEditor = { name: "VS Code", command: "code" }; + await execAsync('which code || where code'); + cachedEditor = { name: 'VS Code', command: 'code' }; return cachedEditor; } catch { // VS Code not found @@ -47,8 +47,8 @@ async function detectDefaultEditor(): Promise { // Try Zed try { - await execAsync("which zed || where zed"); - cachedEditor = { name: "Zed", command: "zed" }; + await execAsync('which zed || where zed'); + cachedEditor = { name: 'Zed', command: 'zed' }; return cachedEditor; } catch { // Zed not found @@ -56,8 +56,8 @@ async function detectDefaultEditor(): Promise { // Try Sublime Text try { - await execAsync("which subl || where subl"); - cachedEditor = { name: "Sublime Text", command: "subl" }; + await execAsync('which subl || where subl'); + cachedEditor = { name: 'Sublime Text', command: 'subl' }; return cachedEditor; } catch { // Sublime not found @@ -65,12 +65,12 @@ async function detectDefaultEditor(): Promise { // Fallback to file manager const platform = process.platform; - if (platform === "darwin") { - cachedEditor = { name: "Finder", command: "open" }; - } else if (platform === "win32") { - cachedEditor = { name: "Explorer", command: "explorer" }; + if (platform === 'darwin') { + cachedEditor = { name: 'Finder', command: 'open' }; + } else if (platform === 'win32') { + cachedEditor = { name: 'Explorer', command: 'explorer' }; } else { - cachedEditor = { name: "File Manager", command: "xdg-open" }; + cachedEditor = { name: 'File Manager', command: 'xdg-open' }; } return cachedEditor; } @@ -87,7 +87,7 @@ export function createGetDefaultEditorHandler() { }, }); } catch (error) { - logError(error, "Get default editor failed"); + logError(error, 'Get default editor failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; @@ -103,7 +103,7 @@ export function createOpenInEditorHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath required", + error: 'worktreePath required', }); return; } @@ -125,15 +125,15 @@ export function createOpenInEditorHandler() { let openCommand: string; let fallbackName: string; - if (platform === "darwin") { + if (platform === 'darwin') { openCommand = `open "${worktreePath}"`; - fallbackName = "Finder"; - } else if (platform === "win32") { + fallbackName = 'Finder'; + } else if (platform === 'win32') { openCommand = `explorer "${worktreePath}"`; - fallbackName = "Explorer"; + fallbackName = 'Explorer'; } else { openCommand = `xdg-open "${worktreePath}"`; - fallbackName = "File Manager"; + fallbackName = 'File Manager'; } await execAsync(openCommand); @@ -146,7 +146,7 @@ export function createOpenInEditorHandler() { }); } } catch (error) { - logError(error, "Open in editor failed"); + logError(error, 'Open in editor failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/pr-info.ts b/apps/server/src/routes/worktree/routes/pr-info.ts index 779e81cb..cb64ccd9 100644 --- a/apps/server/src/routes/worktree/routes/pr-info.ts +++ b/apps/server/src/routes/worktree/routes/pr-info.ts @@ -2,7 +2,7 @@ * POST /pr-info endpoint - Get PR info and comments for a branch */ -import type { Request, Response } from "express"; +import type { Request, Response } from 'express'; import { getErrorMessage, logError, @@ -10,7 +10,7 @@ import { execEnv, isValidBranchName, isGhCliAvailable, -} from "../common.js"; +} from '../common.js'; export interface PRComment { id: number; @@ -44,7 +44,7 @@ export function createPRInfoHandler() { if (!worktreePath || !branchName) { res.status(400).json({ success: false, - error: "worktreePath and branchName required", + error: 'worktreePath and branchName required', }); return; } @@ -53,7 +53,7 @@ export function createPRInfoHandler() { if (!isValidBranchName(branchName)) { res.status(400).json({ success: false, - error: "Invalid branch name contains unsafe characters", + error: 'Invalid branch name contains unsafe characters', }); return; } @@ -67,7 +67,7 @@ export function createPRInfoHandler() { result: { hasPR: false, ghCliAvailable: false, - error: "gh CLI not available", + error: 'gh CLI not available', }, }); return; @@ -79,7 +79,7 @@ export function createPRInfoHandler() { let originRepo: string | null = null; try { - const { stdout: remotes } = await execAsync("git remote -v", { + const { stdout: remotes } = await execAsync('git remote -v', { cwd: worktreePath, env: execEnv, }); @@ -87,21 +87,15 @@ export function createPRInfoHandler() { const lines = remotes.split(/\r?\n/); for (const line of lines) { let match = - line.match( - /^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/ - ) || - line.match( - /^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ - ) || - line.match( - /^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ - ); + line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); if (match) { const [, remoteName, owner, repo] = match; - if (remoteName === "upstream") { + if (remoteName === 'upstream') { upstreamRepo = `${owner}/${repo}`; - } else if (remoteName === "origin") { + } else if (remoteName === 'origin') { originOwner = owner; originRepo = repo; } @@ -113,16 +107,11 @@ export function createPRInfoHandler() { if (!originOwner || !originRepo) { try { - const { stdout: originUrl } = await execAsync( - "git config --get remote.origin.url", - { - cwd: worktreePath, - env: execEnv, - } - ); - const match = originUrl - .trim() - .match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); + const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', { + cwd: worktreePath, + env: execEnv, + }); + const match = originUrl.trim().match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); if (match) { if (!originOwner) { originOwner = match[1]; @@ -137,21 +126,18 @@ export function createPRInfoHandler() { } const targetRepo = - upstreamRepo || (originOwner && originRepo - ? `${originOwner}/${originRepo}` - : null); - const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : ""; - const headRef = - upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; + upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null); + const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : ''; + const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; // Get PR info for the branch using gh CLI try { // First, find the PR associated with this branch const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`; - const { stdout: prListOutput } = await execAsync( - listCmd, - { cwd: worktreePath, env: execEnv } - ); + const { stdout: prListOutput } = await execAsync(listCmd, { + cwd: worktreePath, + env: execEnv, + }); const prList = JSON.parse(prListOutput); @@ -173,25 +159,22 @@ export function createPRInfoHandler() { let comments: PRComment[] = []; try { const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`; - const { stdout: commentsOutput } = await execAsync( - viewCmd, - { cwd: worktreePath, env: execEnv } - ); + const { stdout: commentsOutput } = await execAsync(viewCmd, { + cwd: worktreePath, + env: execEnv, + }); const commentsData = JSON.parse(commentsOutput); - comments = (commentsData.comments || []).map((c: { - id: number; - author: { login: string }; - body: string; - createdAt: string; - }) => ({ - id: c.id, - author: c.author?.login || "unknown", - body: c.body, - createdAt: c.createdAt, - isReviewComment: false, - })); + comments = (commentsData.comments || []).map( + (c: { id: number; author: { login: string }; body: string; createdAt: string }) => ({ + id: c.id, + author: c.author?.login || 'unknown', + body: c.body, + createdAt: c.createdAt, + isReviewComment: false, + }) + ); } catch (error) { - console.warn("[PRInfo] Failed to fetch PR comments:", error); + console.warn('[PRInfo] Failed to fetch PR comments:', error); } // Get review comments (inline code comments) @@ -201,33 +184,35 @@ export function createPRInfoHandler() { try { const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`; const reviewsCmd = `gh api ${reviewsEndpoint}`; - const { stdout: reviewsOutput } = await execAsync( - reviewsCmd, - { cwd: worktreePath, env: execEnv } - ); + const { stdout: reviewsOutput } = await execAsync(reviewsCmd, { + cwd: worktreePath, + env: execEnv, + }); const reviewsData = JSON.parse(reviewsOutput); - reviewComments = reviewsData.map((c: { - id: number; - user: { login: string }; - body: string; - path: string; - line?: number; - original_line?: number; - created_at: string; - }) => ({ - id: c.id, - author: c.user?.login || "unknown", - body: c.body, - path: c.path, - line: c.line || c.original_line, - createdAt: c.created_at, - isReviewComment: true, - })); + reviewComments = reviewsData.map( + (c: { + id: number; + user: { login: string }; + body: string; + path: string; + line?: number; + original_line?: number; + created_at: string; + }) => ({ + id: c.id, + author: c.user?.login || 'unknown', + body: c.body, + path: c.path, + line: c.line || c.original_line, + createdAt: c.created_at, + isReviewComment: true, + }) + ); } catch (error) { - console.warn("[PRInfo] Failed to fetch review comments:", error); + console.warn('[PRInfo] Failed to fetch review comments:', error); } } else { - console.warn("[PRInfo] Cannot fetch review comments: repository info not available"); + console.warn('[PRInfo] Cannot fetch review comments: repository info not available'); } const prInfo: PRInfo = { @@ -235,8 +220,8 @@ export function createPRInfoHandler() { title: pr.title, url: pr.url, state: pr.state, - author: pr.author?.login || "unknown", - body: pr.body || "", + author: pr.author?.login || 'unknown', + body: pr.body || '', comments, reviewComments, }; @@ -251,7 +236,7 @@ export function createPRInfoHandler() { }); } catch (error) { // gh CLI failed - might not be authenticated or no remote - logError(error, "Failed to get PR info"); + logError(error, 'Failed to get PR info'); res.json({ success: true, result: { @@ -262,7 +247,7 @@ export function createPRInfoHandler() { }); } } catch (error) { - logError(error, "PR info handler failed"); + logError(error, 'PR info handler failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/pull.ts b/apps/server/src/routes/worktree/routes/pull.ts index 119192d0..4384e207 100644 --- a/apps/server/src/routes/worktree/routes/pull.ts +++ b/apps/server/src/routes/worktree/routes/pull.ts @@ -2,10 +2,10 @@ * POST /pull endpoint - Pull latest changes for a worktree/branch */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -19,23 +19,22 @@ export function createPullHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath required", + error: 'worktreePath required', }); return; } // Get current branch name - const { stdout: branchOutput } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: worktreePath } - ); + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); const branchName = branchOutput.trim(); // Fetch latest from remote - await execAsync("git fetch origin", { cwd: worktreePath }); + await execAsync('git fetch origin', { cwd: worktreePath }); // Check if there are local changes that would be overwritten - const { stdout: status } = await execAsync("git status --porcelain", { + const { stdout: status } = await execAsync('git status --porcelain', { cwd: worktreePath, }); const hasLocalChanges = status.trim().length > 0; @@ -43,35 +42,34 @@ export function createPullHandler() { if (hasLocalChanges) { res.status(400).json({ success: false, - error: "You have local changes. Please commit them before pulling.", + error: 'You have local changes. Please commit them before pulling.', }); return; } // Pull latest changes try { - const { stdout: pullOutput } = await execAsync( - `git pull origin ${branchName}`, - { cwd: worktreePath } - ); + const { stdout: pullOutput } = await execAsync(`git pull origin ${branchName}`, { + cwd: worktreePath, + }); // Check if we pulled any changes - const alreadyUpToDate = pullOutput.includes("Already up to date"); + const alreadyUpToDate = pullOutput.includes('Already up to date'); res.json({ success: true, result: { branch: branchName, pulled: !alreadyUpToDate, - message: alreadyUpToDate ? "Already up to date" : "Pulled latest changes", + message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', }, }); } catch (pullError: unknown) { const err = pullError as { stderr?: string; message?: string }; - const errorMsg = err.stderr || err.message || "Pull failed"; + const errorMsg = err.stderr || err.message || 'Pull failed'; // Check for common errors - if (errorMsg.includes("no tracking information")) { + if (errorMsg.includes('no tracking information')) { res.status(400).json({ success: false, error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=origin/${branchName}`, @@ -85,7 +83,7 @@ export function createPullHandler() { }); } } catch (error) { - logError(error, "Pull failed"); + logError(error, 'Pull failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/push.ts b/apps/server/src/routes/worktree/routes/push.ts index d9447a2b..c0337f43 100644 --- a/apps/server/src/routes/worktree/routes/push.ts +++ b/apps/server/src/routes/worktree/routes/push.ts @@ -2,10 +2,10 @@ * POST /push endpoint - Push a worktree branch to remote */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -20,20 +20,19 @@ export function createPushHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath required", + error: 'worktreePath required', }); return; } // Get branch name - const { stdout: branchOutput } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: worktreePath } - ); + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); const branchName = branchOutput.trim(); // Push the branch - const forceFlag = force ? "--force" : ""; + const forceFlag = force ? '--force' : ''; try { await execAsync(`git push -u origin ${branchName} ${forceFlag}`, { cwd: worktreePath, @@ -54,7 +53,7 @@ export function createPushHandler() { }, }); } catch (error) { - logError(error, "Push worktree failed"); + logError(error, 'Push worktree failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/start-dev.ts b/apps/server/src/routes/worktree/routes/start-dev.ts index fcd0cec7..13b93f9b 100644 --- a/apps/server/src/routes/worktree/routes/start-dev.ts +++ b/apps/server/src/routes/worktree/routes/start-dev.ts @@ -6,9 +6,9 @@ * affecting the main dev server. */ -import type { Request, Response } from "express"; -import { getDevServerService } from "../../../services/dev-server-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createStartDevHandler() { return async (req: Request, res: Response): Promise => { @@ -21,7 +21,7 @@ export function createStartDevHandler() { if (!projectPath) { res.status(400).json({ success: false, - error: "projectPath is required", + error: 'projectPath is required', }); return; } @@ -29,7 +29,7 @@ export function createStartDevHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath is required", + error: 'worktreePath is required', }); return; } @@ -50,11 +50,11 @@ export function createStartDevHandler() { } else { res.status(400).json({ success: false, - error: result.error || "Failed to start dev server", + error: result.error || 'Failed to start dev server', }); } } catch (error) { - logError(error, "Start dev server failed"); + logError(error, 'Start dev server failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/stop-dev.ts b/apps/server/src/routes/worktree/routes/stop-dev.ts index 2c22b006..1dbc7340 100644 --- a/apps/server/src/routes/worktree/routes/stop-dev.ts +++ b/apps/server/src/routes/worktree/routes/stop-dev.ts @@ -5,9 +5,9 @@ * freeing up the ports for reuse. */ -import type { Request, Response } from "express"; -import { getDevServerService } from "../../../services/dev-server-service.js"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; export function createStopDevHandler() { return async (req: Request, res: Response): Promise => { @@ -19,7 +19,7 @@ export function createStopDevHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath is required", + error: 'worktreePath is required', }); return; } @@ -38,11 +38,11 @@ export function createStopDevHandler() { } else { res.status(400).json({ success: false, - error: result.error || "Failed to stop dev server", + error: result.error || 'Failed to stop dev server', }); } } catch (error) { - logError(error, "Stop dev server failed"); + logError(error, 'Stop dev server failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index c3c4cdb4..3df7a3f2 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -6,10 +6,10 @@ * the user should commit first. */ -import type { Request, Response } from "express"; -import { exec } from "child_process"; -import { promisify } from "util"; -import { getErrorMessage, logError } from "../common.js"; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; const execAsync = promisify(exec); @@ -19,13 +19,16 @@ const execAsync = promisify(exec); */ async function hasUncommittedChanges(cwd: string): Promise { try { - const { stdout } = await execAsync("git status --porcelain", { cwd }); - const lines = stdout.trim().split("\n").filter((line) => { - if (!line.trim()) return false; - // Exclude .worktrees/ directory (created by automaker) - if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false; - return true; - }); + const { stdout } = await execAsync('git status --porcelain', { cwd }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + // Exclude .worktrees/ directory (created by automaker) + if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false; + return true; + }); return lines.length > 0; } catch { return false; @@ -38,18 +41,21 @@ async function hasUncommittedChanges(cwd: string): Promise { */ async function getChangesSummary(cwd: string): Promise { try { - const { stdout } = await execAsync("git status --short", { cwd }); - const lines = stdout.trim().split("\n").filter((line) => { - if (!line.trim()) return false; - // Exclude .worktrees/ directory - if (line.includes(".worktrees/") || line.endsWith(".worktrees")) return false; - return true; - }); - if (lines.length === 0) return ""; - if (lines.length <= 5) return lines.join(", "); - return `${lines.slice(0, 5).join(", ")} and ${lines.length - 5} more files`; + const { stdout } = await execAsync('git status --short', { cwd }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + // Exclude .worktrees/ directory + if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false; + return true; + }); + if (lines.length === 0) return ''; + if (lines.length <= 5) return lines.join(', '); + return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`; } catch { - return "unknown changes"; + return 'unknown changes'; } } @@ -64,7 +70,7 @@ export function createSwitchBranchHandler() { if (!worktreePath) { res.status(400).json({ success: false, - error: "worktreePath required", + error: 'worktreePath required', }); return; } @@ -72,16 +78,15 @@ export function createSwitchBranchHandler() { if (!branchName) { res.status(400).json({ success: false, - error: "branchName required", + error: 'branchName required', }); return; } // Get current branch - const { stdout: currentBranchOutput } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: worktreePath } - ); + const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); const previousBranch = currentBranchOutput.trim(); if (previousBranch === branchName) { @@ -115,7 +120,7 @@ export function createSwitchBranchHandler() { res.status(400).json({ success: false, error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`, - code: "UNCOMMITTED_CHANGES", + code: 'UNCOMMITTED_CHANGES', }); return; } @@ -132,7 +137,7 @@ export function createSwitchBranchHandler() { }, }); } catch (error) { - logError(error, "Switch branch failed"); + logError(error, 'Switch branch failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index 8eb0d76b..7d59633e 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -5,11 +5,11 @@ * Supports cross-platform shell detection including WSL. */ -import * as pty from "node-pty"; -import { EventEmitter } from "events"; -import * as os from "os"; -import * as fs from "fs"; -import * as path from "path"; +import * as pty from 'node-pty'; +import { EventEmitter } from 'events'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -21,7 +21,7 @@ export const MAX_MAX_SESSIONS = 1000; // Maximum number of concurrent terminal sessions // Can be overridden via TERMINAL_MAX_SESSIONS environment variable // Default set to 1000 - effectively unlimited for most use cases -let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || "1000", 10); +let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10); // Throttle output to prevent overwhelming WebSocket under heavy load // Using 4ms for responsive input feedback while still preventing flood @@ -65,20 +65,20 @@ export class TerminalService extends EventEmitter { const platform = os.platform(); // Check if running in WSL - if (platform === "linux" && this.isWSL()) { + if (platform === 'linux' && this.isWSL()) { // In WSL, prefer the user's configured shell or bash - const userShell = process.env.SHELL || "/bin/bash"; + const userShell = process.env.SHELL || '/bin/bash'; if (fs.existsSync(userShell)) { - return { shell: userShell, args: ["--login"] }; + return { shell: userShell, args: ['--login'] }; } - return { shell: "/bin/bash", args: ["--login"] }; + return { shell: '/bin/bash', args: ['--login'] }; } switch (platform) { - case "win32": { + case 'win32': { // Windows: prefer PowerShell, fall back to cmd - const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; - const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"; + const pwsh = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + const pwshCore = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; if (fs.existsSync(pwshCore)) { return { shell: pwshCore, args: [] }; @@ -86,32 +86,32 @@ export class TerminalService extends EventEmitter { if (fs.existsSync(pwsh)) { return { shell: pwsh, args: [] }; } - return { shell: "cmd.exe", args: [] }; + return { shell: 'cmd.exe', args: [] }; } - case "darwin": { + case 'darwin': { // macOS: prefer user's shell, then zsh, then bash const userShell = process.env.SHELL; if (userShell && fs.existsSync(userShell)) { - return { shell: userShell, args: ["--login"] }; + return { shell: userShell, args: ['--login'] }; } - if (fs.existsSync("/bin/zsh")) { - return { shell: "/bin/zsh", args: ["--login"] }; + if (fs.existsSync('/bin/zsh')) { + return { shell: '/bin/zsh', args: ['--login'] }; } - return { shell: "/bin/bash", args: ["--login"] }; + return { shell: '/bin/bash', args: ['--login'] }; } - case "linux": + case 'linux': default: { // Linux: prefer user's shell, then bash, then sh const userShell = process.env.SHELL; if (userShell && fs.existsSync(userShell)) { - return { shell: userShell, args: ["--login"] }; + return { shell: userShell, args: ['--login'] }; } - if (fs.existsSync("/bin/bash")) { - return { shell: "/bin/bash", args: ["--login"] }; + if (fs.existsSync('/bin/bash')) { + return { shell: '/bin/bash', args: ['--login'] }; } - return { shell: "/bin/sh", args: [] }; + return { shell: '/bin/sh', args: [] }; } } } @@ -122,9 +122,9 @@ export class TerminalService extends EventEmitter { isWSL(): boolean { try { // Check /proc/version for Microsoft/WSL indicators - if (fs.existsSync("/proc/version")) { - const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase(); - return version.includes("microsoft") || version.includes("wsl"); + if (fs.existsSync('/proc/version')) { + const version = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); + return version.includes('microsoft') || version.includes('wsl'); } // Check for WSL environment variable if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) { @@ -170,19 +170,19 @@ export class TerminalService extends EventEmitter { let cwd = requestedCwd.trim(); // Reject paths with null bytes (could bypass path checks) - if (cwd.includes("\0")) { - console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, "\\0")}`); + if (cwd.includes('\0')) { + console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`); return homeDir; } // Fix double slashes at start (but not for Windows UNC paths) - if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) { + if (cwd.startsWith('//') && !cwd.startsWith('//wsl')) { cwd = cwd.slice(1); } // Normalize the path to resolve . and .. segments // Skip normalization for WSL UNC paths as path.resolve would break them - if (!cwd.startsWith("//wsl")) { + if (!cwd.startsWith('//wsl')) { cwd = path.resolve(cwd); } @@ -247,19 +247,19 @@ export class TerminalService extends EventEmitter { // These settings ensure consistent terminal behavior across platforms const env: Record = { ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - TERM_PROGRAM: "automaker-terminal", + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'automaker-terminal', // Ensure proper locale for character handling - LANG: process.env.LANG || "en_US.UTF-8", - LC_ALL: process.env.LC_ALL || process.env.LANG || "en_US.UTF-8", + LANG: process.env.LANG || 'en_US.UTF-8', + LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8', ...options.env, }; console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`); const ptyProcess = pty.spawn(shell, shellArgs, { - name: "xterm-256color", + name: 'xterm-256color', cols: options.cols || 80, rows: options.rows || 24, cwd, @@ -272,8 +272,8 @@ export class TerminalService extends EventEmitter { cwd, createdAt: new Date(), shell, - scrollbackBuffer: "", - outputBuffer: "", + scrollbackBuffer: '', + outputBuffer: '', flushTimeout: null, resizeInProgress: false, resizeDebounceTimeout: null, @@ -293,12 +293,12 @@ export class TerminalService extends EventEmitter { // Schedule another flush for remaining data session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); } else { - session.outputBuffer = ""; + session.outputBuffer = ''; session.flushTimeout = null; } this.dataCallbacks.forEach((cb) => cb(id, dataToSend)); - this.emit("data", id, dataToSend); + this.emit('data', id, dataToSend); }; // Forward data events with throttling @@ -331,7 +331,7 @@ export class TerminalService extends EventEmitter { console.log(`[Terminal] Session ${id} exited with code ${exitCode}`); this.sessions.delete(id); this.exitCallbacks.forEach((cb) => cb(id, exitCode)); - this.emit("exit", id, exitCode); + this.emit('exit', id, exitCode); }); console.log(`[Terminal] Session ${id} created successfully`); @@ -414,7 +414,7 @@ export class TerminalService extends EventEmitter { // First try graceful SIGTERM to allow process cleanup console.log(`[Terminal] Session ${sessionId} sending SIGTERM`); - session.pty.kill("SIGTERM"); + session.pty.kill('SIGTERM'); // Schedule SIGKILL fallback if process doesn't exit gracefully // The onExit handler will remove session from map when it actually exits @@ -422,7 +422,7 @@ export class TerminalService extends EventEmitter { if (this.sessions.has(sessionId)) { console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); try { - session.pty.kill("SIGKILL"); + session.pty.kill('SIGKILL'); } catch { // Process may have already exited } @@ -467,7 +467,7 @@ export class TerminalService extends EventEmitter { // Clear any pending output that hasn't been flushed yet // This data is already in scrollbackBuffer - session.outputBuffer = ""; + session.outputBuffer = ''; if (session.flushTimeout) { clearTimeout(session.flushTimeout); session.flushTimeout = null; diff --git a/apps/server/tests/fixtures/images.ts b/apps/server/tests/fixtures/images.ts index b14f4adf..f7e768c6 100644 --- a/apps/server/tests/fixtures/images.ts +++ b/apps/server/tests/fixtures/images.ts @@ -4,11 +4,11 @@ // 1x1 transparent PNG base64 data export const pngBase64Fixture = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; export const imageDataFixture = { base64: pngBase64Fixture, - mimeType: "image/png", - filename: "test.png", - originalPath: "/path/to/test.png", + mimeType: 'image/png', + filename: 'test.png', + originalPath: '/path/to/test.png', }; diff --git a/apps/server/tests/integration/helpers/git-test-repo.ts b/apps/server/tests/integration/helpers/git-test-repo.ts index f307bbb1..4ec95926 100644 --- a/apps/server/tests/integration/helpers/git-test-repo.ts +++ b/apps/server/tests/integration/helpers/git-test-repo.ts @@ -1,11 +1,11 @@ /** * Helper for creating test git repositories for integration tests */ -import { exec } from "child_process"; -import { promisify } from "util"; -import * as fs from "fs/promises"; -import * as path from "path"; -import * as os from "os"; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; const execAsync = promisify(exec); @@ -18,36 +18,36 @@ export interface TestRepo { * Create a temporary git repository for testing */ export async function createTestGitRepo(): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "automaker-test-")); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-')); // Initialize git repo - await execAsync("git init", { cwd: tmpDir }); + await execAsync('git init', { cwd: tmpDir }); await execAsync('git config user.email "test@example.com"', { cwd: tmpDir }); await execAsync('git config user.name "Test User"', { cwd: tmpDir }); // Create initial commit - await fs.writeFile(path.join(tmpDir, "README.md"), "# Test Project\n"); - await execAsync("git add .", { cwd: tmpDir }); + await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n'); + await execAsync('git add .', { cwd: tmpDir }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir }); // Create main branch explicitly - await execAsync("git branch -M main", { cwd: tmpDir }); + await execAsync('git branch -M main', { cwd: tmpDir }); return { path: tmpDir, cleanup: async () => { try { // Remove all worktrees first - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await execAsync('git worktree list --porcelain', { cwd: tmpDir, - }).catch(() => ({ stdout: "" })); + }).catch(() => ({ stdout: '' })); const worktrees = stdout - .split("\n\n") + .split('\n\n') .slice(1) // Skip main worktree .map((block) => { - const pathLine = block.split("\n").find((line) => line.startsWith("worktree ")); - return pathLine ? pathLine.replace("worktree ", "") : null; + const pathLine = block.split('\n').find((line) => line.startsWith('worktree ')); + return pathLine ? pathLine.replace('worktree ', '') : null; }) .filter(Boolean); @@ -64,7 +64,7 @@ export async function createTestGitRepo(): Promise { // Remove the repository await fs.rm(tmpDir, { recursive: true, force: true }); } catch (error) { - console.error("Failed to cleanup test repo:", error); + console.error('Failed to cleanup test repo:', error); } }, }; @@ -78,24 +78,21 @@ export async function createTestFeature( featureId: string, featureData: any ): Promise { - const featuresDir = path.join(repoPath, ".automaker", "features"); + const featuresDir = path.join(repoPath, '.automaker', 'features'); const featureDir = path.join(featuresDir, featureId); await fs.mkdir(featureDir, { recursive: true }); - await fs.writeFile( - path.join(featureDir, "feature.json"), - JSON.stringify(featureData, null, 2) - ); + await fs.writeFile(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2)); } /** * Get list of git branches */ export async function listBranches(repoPath: string): Promise { - const { stdout } = await execAsync("git branch --list", { cwd: repoPath }); + const { stdout } = await execAsync('git branch --list', { cwd: repoPath }); return stdout - .split("\n") - .map((line) => line.trim().replace(/^[*+]\s*/, "")) + .split('\n') + .map((line) => line.trim().replace(/^[*+]\s*/, '')) .filter(Boolean); } @@ -104,16 +101,16 @@ export async function listBranches(repoPath: string): Promise { */ export async function listWorktrees(repoPath: string): Promise { try { - const { stdout } = await execAsync("git worktree list --porcelain", { + const { stdout } = await execAsync('git worktree list --porcelain', { cwd: repoPath, }); return stdout - .split("\n\n") + .split('\n\n') .slice(1) // Skip main worktree .map((block) => { - const pathLine = block.split("\n").find((line) => line.startsWith("worktree ")); - return pathLine ? pathLine.replace("worktree ", "") : null; + const pathLine = block.split('\n').find((line) => line.startsWith('worktree ')); + return pathLine ? pathLine.replace('worktree ', '') : null; }) .filter(Boolean) as string[]; } catch { @@ -124,10 +121,7 @@ export async function listWorktrees(repoPath: string): Promise { /** * Check if a branch exists */ -export async function branchExists( - repoPath: string, - branchName: string -): Promise { +export async function branchExists(repoPath: string, branchName: string): Promise { const branches = await listBranches(repoPath); return branches.includes(branchName); } @@ -135,10 +129,7 @@ export async function branchExists( /** * Check if a worktree exists */ -export async function worktreeExists( - repoPath: string, - worktreePath: string -): Promise { +export async function worktreeExists(repoPath: string, worktreePath: string): Promise { const worktrees = await listWorktrees(repoPath); return worktrees.some((wt) => wt === worktreePath); } diff --git a/apps/server/tests/integration/routes/worktree/create.integration.test.ts b/apps/server/tests/integration/routes/worktree/create.integration.test.ts index 03b85e7e..433b610a 100644 --- a/apps/server/tests/integration/routes/worktree/create.integration.test.ts +++ b/apps/server/tests/integration/routes/worktree/create.integration.test.ts @@ -1,22 +1,20 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { createCreateHandler } from "@/routes/worktree/routes/create.js"; -import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from "@/routes/worktree/common.js"; -import { exec } from "child_process"; -import { promisify } from "util"; -import * as fs from "fs/promises"; -import * as os from "os"; -import * as path from "path"; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createCreateHandler } from '@/routes/worktree/routes/create.js'; +import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from '@/routes/worktree/common.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; const execAsync = promisify(exec); -describe("worktree create route - repositories without commits", () => { +describe('worktree create route - repositories without commits', () => { let repoPath: string | null = null; async function initRepoWithoutCommit() { - repoPath = await fs.mkdtemp( - path.join(os.tmpdir(), "automaker-no-commit-") - ); - await execAsync("git init", { cwd: repoPath }); + repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); + await execAsync('git init', { cwd: repoPath }); await execAsync('git config user.email "test@example.com"', { cwd: repoPath, }); @@ -32,14 +30,14 @@ describe("worktree create route - repositories without commits", () => { repoPath = null; }); - it("creates an initial commit before adding a worktree when HEAD is missing", async () => { + it('creates an initial commit before adding a worktree when HEAD is missing', async () => { await initRepoWithoutCommit(); const handler = createCreateHandler(); const json = vi.fn(); const status = vi.fn().mockReturnThis(); const req = { - body: { projectPath: repoPath, branchName: "feature/no-head" }, + body: { projectPath: repoPath, branchName: 'feature/no-head' }, } as any; const res = { json, @@ -53,17 +51,12 @@ describe("worktree create route - repositories without commits", () => { const payload = json.mock.calls[0][0]; expect(payload.success).toBe(true); - const { stdout: commitCount } = await execAsync( - "git rev-list --count HEAD", - { cwd: repoPath! } - ); + const { stdout: commitCount } = await execAsync('git rev-list --count HEAD', { + cwd: repoPath!, + }); expect(Number(commitCount.trim())).toBeGreaterThan(0); - const { stdout: latestMessage } = await execAsync( - "git log -1 --pretty=%B", - { cwd: repoPath! } - ); + const { stdout: latestMessage } = await execAsync('git log -1 --pretty=%B', { cwd: repoPath! }); expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE); }); }); - diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index ebf0857f..d9d6ee13 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { AutoModeService } from "@/services/auto-mode-service.js"; -import { ProviderFactory } from "@/providers/provider-factory.js"; -import { FeatureLoader } from "@/services/feature-loader.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AutoModeService } from '@/services/auto-mode-service.js'; +import { ProviderFactory } from '@/providers/provider-factory.js'; +import { FeatureLoader } from '@/services/feature-loader.js'; import { createTestGitRepo, createTestFeature, @@ -10,17 +10,17 @@ import { branchExists, worktreeExists, type TestRepo, -} from "../helpers/git-test-repo.js"; -import * as fs from "fs/promises"; -import * as path from "path"; -import { exec } from "child_process"; -import { promisify } from "util"; +} from '../helpers/git-test-repo.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; const execAsync = promisify(exec); -vi.mock("@/providers/provider-factory.js"); +vi.mock('@/providers/provider-factory.js'); -describe("auto-mode-service.ts (integration)", () => { +describe('auto-mode-service.ts (integration)', () => { let service: AutoModeService; let testRepo: TestRepo; let featureLoader: FeatureLoader; @@ -46,22 +46,22 @@ describe("auto-mode-service.ts (integration)", () => { } }); - describe("worktree operations", () => { - it("should use existing git worktree for feature", async () => { - const branchName = "feature/test-feature-1"; - + describe('worktree operations', () => { + it('should use existing git worktree for feature', async () => { + const branchName = 'feature/test-feature-1'; + // Create a test feature with branchName set - await createTestFeature(testRepo.path, "test-feature-1", { - id: "test-feature-1", - category: "test", - description: "Test feature", - status: "pending", + await createTestFeature(testRepo.path, 'test-feature-1', { + id: 'test-feature-1', + category: 'test', + description: 'Test feature', + status: 'pending', branchName: branchName, }); // Create worktree before executing (worktrees are now created when features are added/edited) - const worktreesDir = path.join(testRepo.path, ".worktrees"); - const worktreePath = path.join(worktreesDir, "test-feature-1"); + const worktreesDir = path.join(testRepo.path, '.worktrees'); + const worktreePath = path.join(worktreesDir, 'test-feature-1'); await fs.mkdir(worktreesDir, { recursive: true }); await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, { cwd: testRepo.path, @@ -69,30 +69,28 @@ describe("auto-mode-service.ts (integration)", () => { // Mock provider to complete quickly const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "assistant", + type: 'assistant', message: { - role: "assistant", - content: [{ type: "text", text: "Feature implemented" }], + role: 'assistant', + content: [{ type: 'text', text: 'Feature implemented' }], }, }; yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Execute feature with worktrees enabled await service.executeFeature( testRepo.path, - "test-feature-1", + 'test-feature-1', true, // useWorktrees false // isAutoMode ); @@ -107,8 +105,8 @@ describe("auto-mode-service.ts (integration)", () => { const worktrees = await listWorktrees(testRepo.path); expect(worktrees.length).toBeGreaterThan(0); // Verify that at least one worktree path contains our feature ID - const worktreePathsMatch = worktrees.some(wt => - wt.includes("test-feature-1") || wt.includes(".worktrees") + const worktreePathsMatch = worktrees.some( + (wt) => wt.includes('test-feature-1') || wt.includes('.worktrees') ); expect(worktreePathsMatch).toBe(true); @@ -116,243 +114,200 @@ describe("auto-mode-service.ts (integration)", () => { // This is expected behavior - manual cleanup is required }, 30000); - it("should handle error gracefully", async () => { - await createTestFeature(testRepo.path, "test-feature-error", { - id: "test-feature-error", - category: "test", - description: "Test feature that errors", - status: "pending", + it('should handle error gracefully', async () => { + await createTestFeature(testRepo.path, 'test-feature-error', { + id: 'test-feature-error', + category: 'test', + description: 'Test feature that errors', + status: 'pending', }); // Mock provider that throws error const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { - throw new Error("Provider error"); + throw new Error('Provider error'); }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Execute feature (should handle error) - await service.executeFeature( - testRepo.path, - "test-feature-error", - true, - false - ); + await service.executeFeature(testRepo.path, 'test-feature-error', true, false); // Verify feature status was updated to backlog (error status) - const feature = await featureLoader.get( - testRepo.path, - "test-feature-error" - ); - expect(feature?.status).toBe("backlog"); + const feature = await featureLoader.get(testRepo.path, 'test-feature-error'); + expect(feature?.status).toBe('backlog'); }, 30000); - it("should work without worktrees", async () => { - await createTestFeature(testRepo.path, "test-no-worktree", { - id: "test-no-worktree", - category: "test", - description: "Test without worktree", - status: "pending", + it('should work without worktrees', async () => { + await createTestFeature(testRepo.path, 'test-no-worktree', { + id: 'test-no-worktree', + category: 'test', + description: 'Test without worktree', + status: 'pending', }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Execute without worktrees await service.executeFeature( testRepo.path, - "test-no-worktree", + 'test-no-worktree', false, // useWorktrees = false false ); // Feature should be updated successfully - const feature = await featureLoader.get( - testRepo.path, - "test-no-worktree" - ); - expect(feature?.status).toBe("waiting_approval"); + const feature = await featureLoader.get(testRepo.path, 'test-no-worktree'); + expect(feature?.status).toBe('waiting_approval'); }, 30000); }); - describe("feature execution", () => { - it("should execute feature and update status", async () => { - await createTestFeature(testRepo.path, "feature-exec-1", { - id: "feature-exec-1", - category: "ui", - description: "Execute this feature", - status: "pending", + describe('feature execution', () => { + it('should execute feature and update status', async () => { + await createTestFeature(testRepo.path, 'feature-exec-1', { + id: 'feature-exec-1', + category: 'ui', + description: 'Execute this feature', + status: 'pending', }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "assistant", + type: 'assistant', message: { - role: "assistant", - content: [{ type: "text", text: "Implemented the feature" }], + role: 'assistant', + content: [{ type: 'text', text: 'Implemented the feature' }], }, }; yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); await service.executeFeature( testRepo.path, - "feature-exec-1", + 'feature-exec-1', false, // Don't use worktrees so agent output is saved to main project false ); // Check feature status was updated - const feature = await featureLoader.get(testRepo.path, "feature-exec-1"); - expect(feature?.status).toBe("waiting_approval"); + const feature = await featureLoader.get(testRepo.path, 'feature-exec-1'); + expect(feature?.status).toBe('waiting_approval'); // Check agent output was saved - const agentOutput = await featureLoader.getAgentOutput( - testRepo.path, - "feature-exec-1" - ); + const agentOutput = await featureLoader.getAgentOutput(testRepo.path, 'feature-exec-1'); expect(agentOutput).toBeTruthy(); - expect(agentOutput).toContain("Implemented the feature"); + expect(agentOutput).toContain('Implemented the feature'); }, 30000); - it("should handle feature not found", async () => { + it('should handle feature not found', async () => { const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Try to execute non-existent feature - await service.executeFeature( - testRepo.path, - "nonexistent-feature", - true, - false - ); + await service.executeFeature(testRepo.path, 'nonexistent-feature', true, false); // Should emit error event expect(mockEvents.emit).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - featureId: "nonexistent-feature", - error: expect.stringContaining("not found"), + featureId: 'nonexistent-feature', + error: expect.stringContaining('not found'), }) ); }, 30000); - it("should prevent duplicate feature execution", async () => { - await createTestFeature(testRepo.path, "feature-dup", { - id: "feature-dup", - category: "test", - description: "Duplicate test", - status: "pending", + it('should prevent duplicate feature execution', async () => { + await createTestFeature(testRepo.path, 'feature-dup', { + id: 'feature-dup', + category: 'test', + description: 'Duplicate test', + status: 'pending', }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { // Simulate slow execution await new Promise((resolve) => setTimeout(resolve, 500)); yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Start first execution - const promise1 = service.executeFeature( - testRepo.path, - "feature-dup", - false, - false - ); + const promise1 = service.executeFeature(testRepo.path, 'feature-dup', false, false); // Try to start second execution (should throw) await expect( - service.executeFeature(testRepo.path, "feature-dup", false, false) - ).rejects.toThrow("already running"); + service.executeFeature(testRepo.path, 'feature-dup', false, false) + ).rejects.toThrow('already running'); await promise1; }, 30000); - it("should use feature-specific model", async () => { - await createTestFeature(testRepo.path, "feature-model", { - id: "feature-model", - category: "test", - description: "Model test", - status: "pending", - model: "claude-sonnet-4-20250514", + it('should use feature-specific model', async () => { + await createTestFeature(testRepo.path, 'feature-model', { + id: 'feature-model', + category: 'test', + description: 'Model test', + status: 'pending', + model: 'claude-sonnet-4-20250514', }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - await service.executeFeature( - testRepo.path, - "feature-model", - false, - false - ); + await service.executeFeature(testRepo.path, 'feature-model', false, false); // Should have used claude-sonnet-4-20250514 - expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith( - "claude-sonnet-4-20250514" - ); + expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-20250514'); }, 30000); }); - describe("auto loop", () => { - it("should start and stop auto loop", async () => { + describe('auto loop', () => { + it('should start and stop auto loop', async () => { const startPromise = service.startAutoLoop(testRepo.path, 2); // Give it time to start @@ -365,35 +320,33 @@ describe("auto-mode-service.ts (integration)", () => { await startPromise.catch(() => {}); // Cleanup }, 10000); - it("should process pending features in auto loop", async () => { + it('should process pending features in auto loop', async () => { // Create multiple pending features - await createTestFeature(testRepo.path, "auto-1", { - id: "auto-1", - category: "test", - description: "Auto feature 1", - status: "pending", + await createTestFeature(testRepo.path, 'auto-1', { + id: 'auto-1', + category: 'test', + description: 'Auto feature 1', + status: 'pending', }); - await createTestFeature(testRepo.path, "auto-2", { - id: "auto-2", - category: "test", - description: "Auto feature 2", - status: "pending", + await createTestFeature(testRepo.path, 'auto-2', { + id: 'auto-2', + category: 'test', + description: 'Auto feature 2', + status: 'pending', }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Start auto loop const startPromise = service.startAutoLoop(testRepo.path, 2); @@ -406,25 +359,25 @@ describe("auto-mode-service.ts (integration)", () => { await startPromise.catch(() => {}); // Check that features were updated - const feature1 = await featureLoader.get(testRepo.path, "auto-1"); - const feature2 = await featureLoader.get(testRepo.path, "auto-2"); + const feature1 = await featureLoader.get(testRepo.path, 'auto-1'); + const feature2 = await featureLoader.get(testRepo.path, 'auto-2'); // At least one should have been processed const processedCount = [feature1, feature2].filter( - (f) => f?.status === "waiting_approval" || f?.status === "in_progress" + (f) => f?.status === 'waiting_approval' || f?.status === 'in_progress' ).length; expect(processedCount).toBeGreaterThan(0); }, 15000); - it("should respect max concurrency", async () => { + it('should respect max concurrency', async () => { // Create 5 features for (let i = 1; i <= 5; i++) { await createTestFeature(testRepo.path, `concurrent-${i}`, { id: `concurrent-${i}`, - category: "test", + category: 'test', description: `Concurrent feature ${i}`, - status: "pending", + status: 'pending', }); } @@ -432,7 +385,7 @@ describe("auto-mode-service.ts (integration)", () => { let maxConcurrent = 0; const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { concurrentCount++; maxConcurrent = Math.max(maxConcurrent, concurrentCount); @@ -443,15 +396,13 @@ describe("auto-mode-service.ts (integration)", () => { concurrentCount--; yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Start with max concurrency of 2 const startPromise = service.startAutoLoop(testRepo.path, 2); @@ -466,7 +417,7 @@ describe("auto-mode-service.ts (integration)", () => { expect(maxConcurrent).toBeLessThanOrEqual(2); }, 15000); - it("should emit auto mode events", async () => { + it('should emit auto mode events', async () => { const startPromise = service.startAutoLoop(testRepo.path, 1); // Wait for start event @@ -474,7 +425,7 @@ describe("auto-mode-service.ts (integration)", () => { // Check start event was emitted const startEvent = mockEvents.emit.mock.calls.find((call) => - call[1]?.message?.includes("Auto mode started") + call[1]?.message?.includes('Auto mode started') ); expect(startEvent).toBeTruthy(); @@ -484,74 +435,69 @@ describe("auto-mode-service.ts (integration)", () => { // Check stop event was emitted (emitted immediately by stopAutoLoop) const stopEvent = mockEvents.emit.mock.calls.find( (call) => - call[1]?.type === "auto_mode_stopped" || - call[1]?.message?.includes("Auto mode stopped") + call[1]?.type === 'auto_mode_stopped' || call[1]?.message?.includes('Auto mode stopped') ); expect(stopEvent).toBeTruthy(); }, 10000); }); - describe("error handling", () => { - it("should handle provider errors gracefully", async () => { - await createTestFeature(testRepo.path, "error-feature", { - id: "error-feature", - category: "test", - description: "Error test", - status: "pending", + describe('error handling', () => { + it('should handle provider errors gracefully', async () => { + await createTestFeature(testRepo.path, 'error-feature', { + id: 'error-feature', + category: 'test', + description: 'Error test', + status: 'pending', }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { - throw new Error("Provider execution failed"); + throw new Error('Provider execution failed'); }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); // Should not throw - await service.executeFeature(testRepo.path, "error-feature", true, false); + await service.executeFeature(testRepo.path, 'error-feature', true, false); // Feature should be marked as backlog (error status) - const feature = await featureLoader.get(testRepo.path, "error-feature"); - expect(feature?.status).toBe("backlog"); + const feature = await featureLoader.get(testRepo.path, 'error-feature'); + expect(feature?.status).toBe('backlog'); }, 30000); - it("should continue auto loop after feature error", async () => { - await createTestFeature(testRepo.path, "fail-1", { - id: "fail-1", - category: "test", - description: "Will fail", - status: "pending", + it('should continue auto loop after feature error', async () => { + await createTestFeature(testRepo.path, 'fail-1', { + id: 'fail-1', + category: 'test', + description: 'Will fail', + status: 'pending', }); - await createTestFeature(testRepo.path, "success-1", { - id: "success-1", - category: "test", - description: "Will succeed", - status: "pending", + await createTestFeature(testRepo.path, 'success-1', { + id: 'success-1', + category: 'test', + description: 'Will succeed', + status: 'pending', }); let callCount = 0; const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { callCount++; if (callCount === 1) { - throw new Error("First feature fails"); + throw new Error('First feature fails'); } yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); const startPromise = service.startAutoLoop(testRepo.path, 1); @@ -566,200 +512,177 @@ describe("auto-mode-service.ts (integration)", () => { }, 15000); }); - describe("planning mode", () => { - it("should execute feature with skip planning mode", async () => { - await createTestFeature(testRepo.path, "skip-plan-feature", { - id: "skip-plan-feature", - category: "test", - description: "Feature with skip planning", - status: "pending", - planningMode: "skip", + describe('planning mode', () => { + it('should execute feature with skip planning mode', async () => { + await createTestFeature(testRepo.path, 'skip-plan-feature', { + id: 'skip-plan-feature', + category: 'test', + description: 'Feature with skip planning', + status: 'pending', + planningMode: 'skip', }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "assistant", + type: 'assistant', message: { - role: "assistant", - content: [{ type: "text", text: "Feature implemented" }], + role: 'assistant', + content: [{ type: 'text', text: 'Feature implemented' }], }, }; yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - await service.executeFeature( - testRepo.path, - "skip-plan-feature", - false, - false - ); + await service.executeFeature(testRepo.path, 'skip-plan-feature', false, false); - const feature = await featureLoader.get(testRepo.path, "skip-plan-feature"); - expect(feature?.status).toBe("waiting_approval"); + const feature = await featureLoader.get(testRepo.path, 'skip-plan-feature'); + expect(feature?.status).toBe('waiting_approval'); }, 30000); - it("should execute feature with lite planning mode without approval", async () => { - await createTestFeature(testRepo.path, "lite-plan-feature", { - id: "lite-plan-feature", - category: "test", - description: "Feature with lite planning", - status: "pending", - planningMode: "lite", + it('should execute feature with lite planning mode without approval', async () => { + await createTestFeature(testRepo.path, 'lite-plan-feature', { + id: 'lite-plan-feature', + category: 'test', + description: 'Feature with lite planning', + status: 'pending', + planningMode: 'lite', requirePlanApproval: false, }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "assistant", + type: 'assistant', message: { - role: "assistant", - content: [{ type: "text", text: "[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented" }], + role: 'assistant', + content: [ + { + type: 'text', + text: '[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented', + }, + ], }, }; yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - await service.executeFeature( - testRepo.path, - "lite-plan-feature", - false, - false - ); + await service.executeFeature(testRepo.path, 'lite-plan-feature', false, false); - const feature = await featureLoader.get(testRepo.path, "lite-plan-feature"); - expect(feature?.status).toBe("waiting_approval"); + const feature = await featureLoader.get(testRepo.path, 'lite-plan-feature'); + expect(feature?.status).toBe('waiting_approval'); }, 30000); - it("should emit planning_started event for spec mode", async () => { - await createTestFeature(testRepo.path, "spec-plan-feature", { - id: "spec-plan-feature", - category: "test", - description: "Feature with spec planning", - status: "pending", - planningMode: "spec", + it('should emit planning_started event for spec mode', async () => { + await createTestFeature(testRepo.path, 'spec-plan-feature', { + id: 'spec-plan-feature', + category: 'test', + description: 'Feature with spec planning', + status: 'pending', + planningMode: 'spec', requirePlanApproval: false, }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "assistant", + type: 'assistant', message: { - role: "assistant", - content: [{ type: "text", text: "Spec generated\n\n[SPEC_GENERATED] Review the spec." }], + role: 'assistant', + content: [ + { type: 'text', text: 'Spec generated\n\n[SPEC_GENERATED] Review the spec.' }, + ], }, }; yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - await service.executeFeature( - testRepo.path, - "spec-plan-feature", - false, - false - ); + await service.executeFeature(testRepo.path, 'spec-plan-feature', false, false); // Check planning_started event was emitted - const planningEvent = mockEvents.emit.mock.calls.find( - (call) => call[1]?.mode === "spec" - ); + const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'spec'); expect(planningEvent).toBeTruthy(); }, 30000); - it("should handle feature with full planning mode", async () => { - await createTestFeature(testRepo.path, "full-plan-feature", { - id: "full-plan-feature", - category: "test", - description: "Feature with full planning", - status: "pending", - planningMode: "full", + it('should handle feature with full planning mode', async () => { + await createTestFeature(testRepo.path, 'full-plan-feature', { + id: 'full-plan-feature', + category: 'test', + description: 'Feature with full planning', + status: 'pending', + planningMode: 'full', requirePlanApproval: false, }); const mockProvider = { - getName: () => "claude", + getName: () => 'claude', executeQuery: async function* () { yield { - type: "assistant", + type: 'assistant', message: { - role: "assistant", - content: [{ type: "text", text: "Full spec with phases\n\n[SPEC_GENERATED] Review." }], + role: 'assistant', + content: [ + { type: 'text', text: 'Full spec with phases\n\n[SPEC_GENERATED] Review.' }, + ], }, }; yield { - type: "result", - subtype: "success", + type: 'result', + subtype: 'success', }; }, }; - vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( - mockProvider as any - ); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); - await service.executeFeature( - testRepo.path, - "full-plan-feature", - false, - false - ); + await service.executeFeature(testRepo.path, 'full-plan-feature', false, false); // Check planning_started event was emitted with full mode - const planningEvent = mockEvents.emit.mock.calls.find( - (call) => call[1]?.mode === "full" - ); + const planningEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.mode === 'full'); expect(planningEvent).toBeTruthy(); }, 30000); - it("should track pending approval correctly", async () => { + it('should track pending approval correctly', async () => { // Initially no pending approvals - expect(service.hasPendingApproval("non-existent")).toBe(false); + expect(service.hasPendingApproval('non-existent')).toBe(false); }); - it("should cancel pending approval gracefully", () => { + it('should cancel pending approval gracefully', () => { // Should not throw when cancelling non-existent approval - expect(() => service.cancelPlanApproval("non-existent")).not.toThrow(); + expect(() => service.cancelPlanApproval('non-existent')).not.toThrow(); }); - it("should resolve approval with error for non-existent feature", async () => { + it('should resolve approval with error for non-existent feature', async () => { const result = await service.resolvePlanApproval( - "non-existent", + 'non-existent', true, undefined, undefined, undefined ); expect(result.success).toBe(false); - expect(result.error).toContain("No pending approval"); + expect(result.error).toContain('No pending approval'); }); }); }); diff --git a/apps/server/tests/unit/lib/app-spec-format.test.ts b/apps/server/tests/unit/lib/app-spec-format.test.ts index 43eb5359..eef78814 100644 --- a/apps/server/tests/unit/lib/app-spec-format.test.ts +++ b/apps/server/tests/unit/lib/app-spec-format.test.ts @@ -1,143 +1,137 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest'; import { specToXml, getStructuredSpecPromptInstruction, getAppSpecFormatInstruction, APP_SPEC_XML_FORMAT, type SpecOutput, -} from "@/lib/app-spec-format.js"; +} from '@/lib/app-spec-format.js'; -describe("app-spec-format.ts", () => { - describe("specToXml", () => { - it("should convert minimal spec to XML", () => { +describe('app-spec-format.ts', () => { + describe('specToXml', () => { + it('should convert minimal spec to XML', () => { const spec: SpecOutput = { - project_name: "Test Project", - overview: "A test project", - technology_stack: ["TypeScript", "Node.js"], - core_capabilities: ["Testing", "Development"], - implemented_features: [ - { name: "Feature 1", description: "First feature" }, - ], + project_name: 'Test Project', + overview: 'A test project', + technology_stack: ['TypeScript', 'Node.js'], + core_capabilities: ['Testing', 'Development'], + implemented_features: [{ name: 'Feature 1', description: 'First feature' }], }; const xml = specToXml(spec); expect(xml).toContain(''); - expect(xml).toContain(""); - expect(xml).toContain(""); - expect(xml).toContain("Test Project"); - expect(xml).toContain("TypeScript"); - expect(xml).toContain("Testing"); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('Test Project'); + expect(xml).toContain('TypeScript'); + expect(xml).toContain('Testing'); }); - it("should escape XML special characters", () => { + it('should escape XML special characters', () => { const spec: SpecOutput = { - project_name: "Test & Project", - overview: "Description with ", - technology_stack: ["TypeScript"], - core_capabilities: ["Cap"], + project_name: 'Test & Project', + overview: 'Description with ', + technology_stack: ['TypeScript'], + core_capabilities: ['Cap'], implemented_features: [], }; const xml = specToXml(spec); - expect(xml).toContain("Test & Project"); - expect(xml).toContain("<tags>"); + expect(xml).toContain('Test & Project'); + expect(xml).toContain('<tags>'); }); - it("should include file_locations when provided", () => { + it('should include file_locations when provided', () => { const spec: SpecOutput = { - project_name: "Test", - overview: "Test", - technology_stack: ["TS"], - core_capabilities: ["Cap"], + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], implemented_features: [ { - name: "Feature", - description: "Desc", - file_locations: ["src/index.ts"], + name: 'Feature', + description: 'Desc', + file_locations: ['src/index.ts'], }, ], }; const xml = specToXml(spec); - expect(xml).toContain(""); - expect(xml).toContain("src/index.ts"); + expect(xml).toContain(''); + expect(xml).toContain('src/index.ts'); }); - it("should not include file_locations when empty", () => { + it('should not include file_locations when empty', () => { const spec: SpecOutput = { - project_name: "Test", - overview: "Test", - technology_stack: ["TS"], - core_capabilities: ["Cap"], - implemented_features: [ - { name: "Feature", description: "Desc", file_locations: [] }, - ], + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], + implemented_features: [{ name: 'Feature', description: 'Desc', file_locations: [] }], }; const xml = specToXml(spec); - expect(xml).not.toContain(""); + expect(xml).not.toContain(''); }); - it("should include additional_requirements when provided", () => { + it('should include additional_requirements when provided', () => { const spec: SpecOutput = { - project_name: "Test", - overview: "Test", - technology_stack: ["TS"], - core_capabilities: ["Cap"], + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], implemented_features: [], - additional_requirements: ["Node.js 18+"], + additional_requirements: ['Node.js 18+'], }; const xml = specToXml(spec); - expect(xml).toContain(""); - expect(xml).toContain("Node.js 18+"); + expect(xml).toContain(''); + expect(xml).toContain('Node.js 18+'); }); - it("should include development_guidelines when provided", () => { + it('should include development_guidelines when provided', () => { const spec: SpecOutput = { - project_name: "Test", - overview: "Test", - technology_stack: ["TS"], - core_capabilities: ["Cap"], + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], implemented_features: [], - development_guidelines: ["Use ESLint"], + development_guidelines: ['Use ESLint'], }; const xml = specToXml(spec); - expect(xml).toContain(""); - expect(xml).toContain("Use ESLint"); + expect(xml).toContain(''); + expect(xml).toContain('Use ESLint'); }); - it("should include implementation_roadmap when provided", () => { + it('should include implementation_roadmap when provided', () => { const spec: SpecOutput = { - project_name: "Test", - overview: "Test", - technology_stack: ["TS"], - core_capabilities: ["Cap"], + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], implemented_features: [], - implementation_roadmap: [ - { phase: "Phase 1", status: "completed", description: "Setup" }, - ], + implementation_roadmap: [{ phase: 'Phase 1', status: 'completed', description: 'Setup' }], }; const xml = specToXml(spec); - expect(xml).toContain(""); - expect(xml).toContain("completed"); + expect(xml).toContain(''); + expect(xml).toContain('completed'); }); - it("should not include optional sections when empty", () => { + it('should not include optional sections when empty', () => { const spec: SpecOutput = { - project_name: "Test", - overview: "Test", - technology_stack: ["TS"], - core_capabilities: ["Cap"], + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], implemented_features: [], additional_requirements: [], development_guidelines: [], @@ -146,44 +140,44 @@ describe("app-spec-format.ts", () => { const xml = specToXml(spec); - expect(xml).not.toContain(""); - expect(xml).not.toContain(""); - expect(xml).not.toContain(""); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); }); }); - describe("getStructuredSpecPromptInstruction", () => { - it("should return non-empty prompt instruction", () => { + describe('getStructuredSpecPromptInstruction', () => { + it('should return non-empty prompt instruction', () => { const instruction = getStructuredSpecPromptInstruction(); expect(instruction).toBeTruthy(); expect(instruction.length).toBeGreaterThan(100); }); - it("should mention required fields", () => { + it('should mention required fields', () => { const instruction = getStructuredSpecPromptInstruction(); - expect(instruction).toContain("project_name"); - expect(instruction).toContain("overview"); - expect(instruction).toContain("technology_stack"); + expect(instruction).toContain('project_name'); + expect(instruction).toContain('overview'); + expect(instruction).toContain('technology_stack'); }); }); - describe("getAppSpecFormatInstruction", () => { - it("should return non-empty format instruction", () => { + describe('getAppSpecFormatInstruction', () => { + it('should return non-empty format instruction', () => { const instruction = getAppSpecFormatInstruction(); expect(instruction).toBeTruthy(); expect(instruction.length).toBeGreaterThan(100); }); - it("should include critical formatting requirements", () => { + it('should include critical formatting requirements', () => { const instruction = getAppSpecFormatInstruction(); - expect(instruction).toContain("CRITICAL FORMATTING REQUIREMENTS"); + expect(instruction).toContain('CRITICAL FORMATTING REQUIREMENTS'); }); }); - describe("APP_SPEC_XML_FORMAT", () => { - it("should contain valid XML template structure", () => { - expect(APP_SPEC_XML_FORMAT).toContain(""); - expect(APP_SPEC_XML_FORMAT).toContain(""); + describe('APP_SPEC_XML_FORMAT', () => { + it('should contain valid XML template structure', () => { + expect(APP_SPEC_XML_FORMAT).toContain(''); + expect(APP_SPEC_XML_FORMAT).toContain(''); }); }); }); diff --git a/apps/server/tests/unit/lib/auth.test.ts b/apps/server/tests/unit/lib/auth.test.ts index 97390bd3..91c1c461 100644 --- a/apps/server/tests/unit/lib/auth.test.ts +++ b/apps/server/tests/unit/lib/auth.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { createMockExpressContext } from "../../utils/mocks.js"; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createMockExpressContext } from '../../utils/mocks.js'; /** * Note: auth.ts reads AUTOMAKER_API_KEY at module load time. * We need to reset modules and reimport for each test to get fresh state. */ -describe("auth.ts", () => { +describe('auth.ts', () => { beforeEach(() => { vi.resetModules(); }); - describe("authMiddleware - no API key", () => { - it("should call next() when no API key is set", async () => { + describe('authMiddleware - no API key', () => { + it('should call next() when no API key is set', async () => { delete process.env.AUTOMAKER_API_KEY; - const { authMiddleware } = await import("@/lib/auth.js"); + const { authMiddleware } = await import('@/lib/auth.js'); const { req, res, next } = createMockExpressContext(); authMiddleware(req, res, next); @@ -24,11 +24,11 @@ describe("auth.ts", () => { }); }); - describe("authMiddleware - with API key", () => { - it("should reject request without API key header", async () => { - process.env.AUTOMAKER_API_KEY = "test-secret-key"; + describe('authMiddleware - with API key', () => { + it('should reject request without API key header', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; - const { authMiddleware } = await import("@/lib/auth.js"); + const { authMiddleware } = await import('@/lib/auth.js'); const { req, res, next } = createMockExpressContext(); authMiddleware(req, res, next); @@ -36,34 +36,34 @@ describe("auth.ts", () => { expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ success: false, - error: "Authentication required. Provide X-API-Key header.", + error: 'Authentication required. Provide X-API-Key header.', }); expect(next).not.toHaveBeenCalled(); }); - it("should reject request with invalid API key", async () => { - process.env.AUTOMAKER_API_KEY = "test-secret-key"; + it('should reject request with invalid API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; - const { authMiddleware } = await import("@/lib/auth.js"); + const { authMiddleware } = await import('@/lib/auth.js'); const { req, res, next } = createMockExpressContext(); - req.headers["x-api-key"] = "wrong-key"; + req.headers['x-api-key'] = 'wrong-key'; authMiddleware(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ success: false, - error: "Invalid API key.", + error: 'Invalid API key.', }); expect(next).not.toHaveBeenCalled(); }); - it("should call next() with valid API key", async () => { - process.env.AUTOMAKER_API_KEY = "test-secret-key"; + it('should call next() with valid API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; - const { authMiddleware } = await import("@/lib/auth.js"); - const { req, res, next} = createMockExpressContext(); - req.headers["x-api-key"] = "test-secret-key"; + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + req.headers['x-api-key'] = 'test-secret-key'; authMiddleware(req, res, next); @@ -72,44 +72,44 @@ describe("auth.ts", () => { }); }); - describe("isAuthEnabled", () => { - it("should return false when no API key is set", async () => { + describe('isAuthEnabled', () => { + it('should return false when no API key is set', async () => { delete process.env.AUTOMAKER_API_KEY; - const { isAuthEnabled } = await import("@/lib/auth.js"); + const { isAuthEnabled } = await import('@/lib/auth.js'); expect(isAuthEnabled()).toBe(false); }); - it("should return true when API key is set", async () => { - process.env.AUTOMAKER_API_KEY = "test-key"; + it('should return true when API key is set', async () => { + process.env.AUTOMAKER_API_KEY = 'test-key'; - const { isAuthEnabled } = await import("@/lib/auth.js"); + const { isAuthEnabled } = await import('@/lib/auth.js'); expect(isAuthEnabled()).toBe(true); }); }); - describe("getAuthStatus", () => { - it("should return disabled status when no API key", async () => { + describe('getAuthStatus', () => { + it('should return disabled status when no API key', async () => { delete process.env.AUTOMAKER_API_KEY; - const { getAuthStatus } = await import("@/lib/auth.js"); + const { getAuthStatus } = await import('@/lib/auth.js'); const status = getAuthStatus(); expect(status).toEqual({ enabled: false, - method: "none", + method: 'none', }); }); - it("should return enabled status when API key is set", async () => { - process.env.AUTOMAKER_API_KEY = "test-key"; + it('should return enabled status when API key is set', async () => { + process.env.AUTOMAKER_API_KEY = 'test-key'; - const { getAuthStatus } = await import("@/lib/auth.js"); + const { getAuthStatus } = await import('@/lib/auth.js'); const status = getAuthStatus(); expect(status).toEqual({ enabled: true, - method: "api_key", + method: 'api_key', }); }); }); diff --git a/apps/server/tests/unit/lib/enhancement-prompts.test.ts b/apps/server/tests/unit/lib/enhancement-prompts.test.ts index d780612d..ab139861 100644 --- a/apps/server/tests/unit/lib/enhancement-prompts.test.ts +++ b/apps/server/tests/unit/lib/enhancement-prompts.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest'; import { getEnhancementPrompt, getSystemPrompt, @@ -15,38 +15,38 @@ import { SIMPLIFY_EXAMPLES, ACCEPTANCE_EXAMPLES, type EnhancementMode, -} from "@/lib/enhancement-prompts.js"; +} from '@/lib/enhancement-prompts.js'; -describe("enhancement-prompts.ts", () => { - describe("System Prompt Constants", () => { - it("should have non-empty improve system prompt", () => { +describe('enhancement-prompts.ts', () => { + describe('System Prompt Constants', () => { + it('should have non-empty improve system prompt', () => { expect(IMPROVE_SYSTEM_PROMPT).toBeDefined(); expect(IMPROVE_SYSTEM_PROMPT.length).toBeGreaterThan(100); - expect(IMPROVE_SYSTEM_PROMPT).toContain("ANALYZE"); - expect(IMPROVE_SYSTEM_PROMPT).toContain("CLARIFY"); + expect(IMPROVE_SYSTEM_PROMPT).toContain('ANALYZE'); + expect(IMPROVE_SYSTEM_PROMPT).toContain('CLARIFY'); }); - it("should have non-empty technical system prompt", () => { + it('should have non-empty technical system prompt', () => { expect(TECHNICAL_SYSTEM_PROMPT).toBeDefined(); expect(TECHNICAL_SYSTEM_PROMPT.length).toBeGreaterThan(100); - expect(TECHNICAL_SYSTEM_PROMPT).toContain("technical"); + expect(TECHNICAL_SYSTEM_PROMPT).toContain('technical'); }); - it("should have non-empty simplify system prompt", () => { + it('should have non-empty simplify system prompt', () => { expect(SIMPLIFY_SYSTEM_PROMPT).toBeDefined(); expect(SIMPLIFY_SYSTEM_PROMPT.length).toBeGreaterThan(100); - expect(SIMPLIFY_SYSTEM_PROMPT).toContain("simplify"); + expect(SIMPLIFY_SYSTEM_PROMPT).toContain('simplify'); }); - it("should have non-empty acceptance system prompt", () => { + it('should have non-empty acceptance system prompt', () => { expect(ACCEPTANCE_SYSTEM_PROMPT).toBeDefined(); expect(ACCEPTANCE_SYSTEM_PROMPT.length).toBeGreaterThan(100); - expect(ACCEPTANCE_SYSTEM_PROMPT).toContain("acceptance criteria"); + expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('acceptance criteria'); }); }); - describe("Example Constants", () => { - it("should have improve examples with input and output", () => { + describe('Example Constants', () => { + it('should have improve examples with input and output', () => { expect(IMPROVE_EXAMPLES).toBeDefined(); expect(IMPROVE_EXAMPLES.length).toBeGreaterThan(0); IMPROVE_EXAMPLES.forEach((example) => { @@ -57,7 +57,7 @@ describe("enhancement-prompts.ts", () => { }); }); - it("should have technical examples with input and output", () => { + it('should have technical examples with input and output', () => { expect(TECHNICAL_EXAMPLES).toBeDefined(); expect(TECHNICAL_EXAMPLES.length).toBeGreaterThan(0); TECHNICAL_EXAMPLES.forEach((example) => { @@ -66,7 +66,7 @@ describe("enhancement-prompts.ts", () => { }); }); - it("should have simplify examples with input and output", () => { + it('should have simplify examples with input and output', () => { expect(SIMPLIFY_EXAMPLES).toBeDefined(); expect(SIMPLIFY_EXAMPLES.length).toBeGreaterThan(0); SIMPLIFY_EXAMPLES.forEach((example) => { @@ -75,7 +75,7 @@ describe("enhancement-prompts.ts", () => { }); }); - it("should have acceptance examples with input and output", () => { + it('should have acceptance examples with input and output', () => { expect(ACCEPTANCE_EXAMPLES).toBeDefined(); expect(ACCEPTANCE_EXAMPLES.length).toBeGreaterThan(0); ACCEPTANCE_EXAMPLES.forEach((example) => { @@ -85,66 +85,66 @@ describe("enhancement-prompts.ts", () => { }); }); - describe("getEnhancementPrompt", () => { - it("should return config for improve mode", () => { - const config = getEnhancementPrompt("improve"); + describe('getEnhancementPrompt', () => { + it('should return config for improve mode', () => { + const config = getEnhancementPrompt('improve'); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); - expect(config.description).toContain("clear"); + expect(config.description).toContain('clear'); }); - it("should return config for technical mode", () => { - const config = getEnhancementPrompt("technical"); + it('should return config for technical mode', () => { + const config = getEnhancementPrompt('technical'); expect(config.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT); - expect(config.description).toContain("technical"); + expect(config.description).toContain('technical'); }); - it("should return config for simplify mode", () => { - const config = getEnhancementPrompt("simplify"); + it('should return config for simplify mode', () => { + const config = getEnhancementPrompt('simplify'); expect(config.systemPrompt).toBe(SIMPLIFY_SYSTEM_PROMPT); - expect(config.description).toContain("concise"); + expect(config.description).toContain('concise'); }); - it("should return config for acceptance mode", () => { - const config = getEnhancementPrompt("acceptance"); + it('should return config for acceptance mode', () => { + const config = getEnhancementPrompt('acceptance'); expect(config.systemPrompt).toBe(ACCEPTANCE_SYSTEM_PROMPT); - expect(config.description).toContain("acceptance"); + expect(config.description).toContain('acceptance'); }); - it("should handle case-insensitive mode", () => { - const config = getEnhancementPrompt("IMPROVE"); + it('should handle case-insensitive mode', () => { + const config = getEnhancementPrompt('IMPROVE'); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); }); - it("should fall back to improve for invalid mode", () => { - const config = getEnhancementPrompt("invalid-mode"); + it('should fall back to improve for invalid mode', () => { + const config = getEnhancementPrompt('invalid-mode'); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); }); - it("should fall back to improve for empty string", () => { - const config = getEnhancementPrompt(""); + it('should fall back to improve for empty string', () => { + const config = getEnhancementPrompt(''); expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); }); }); - describe("getSystemPrompt", () => { - it("should return correct system prompt for each mode", () => { - expect(getSystemPrompt("improve")).toBe(IMPROVE_SYSTEM_PROMPT); - expect(getSystemPrompt("technical")).toBe(TECHNICAL_SYSTEM_PROMPT); - expect(getSystemPrompt("simplify")).toBe(SIMPLIFY_SYSTEM_PROMPT); - expect(getSystemPrompt("acceptance")).toBe(ACCEPTANCE_SYSTEM_PROMPT); + describe('getSystemPrompt', () => { + it('should return correct system prompt for each mode', () => { + expect(getSystemPrompt('improve')).toBe(IMPROVE_SYSTEM_PROMPT); + expect(getSystemPrompt('technical')).toBe(TECHNICAL_SYSTEM_PROMPT); + expect(getSystemPrompt('simplify')).toBe(SIMPLIFY_SYSTEM_PROMPT); + expect(getSystemPrompt('acceptance')).toBe(ACCEPTANCE_SYSTEM_PROMPT); }); }); - describe("getExamples", () => { - it("should return correct examples for each mode", () => { - expect(getExamples("improve")).toBe(IMPROVE_EXAMPLES); - expect(getExamples("technical")).toBe(TECHNICAL_EXAMPLES); - expect(getExamples("simplify")).toBe(SIMPLIFY_EXAMPLES); - expect(getExamples("acceptance")).toBe(ACCEPTANCE_EXAMPLES); + describe('getExamples', () => { + it('should return correct examples for each mode', () => { + expect(getExamples('improve')).toBe(IMPROVE_EXAMPLES); + expect(getExamples('technical')).toBe(TECHNICAL_EXAMPLES); + expect(getExamples('simplify')).toBe(SIMPLIFY_EXAMPLES); + expect(getExamples('acceptance')).toBe(ACCEPTANCE_EXAMPLES); }); - it("should return arrays with example objects", () => { - const modes: EnhancementMode[] = ["improve", "technical", "simplify", "acceptance"]; + it('should return arrays with example objects', () => { + const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance']; modes.forEach((mode) => { const examples = getExamples(mode); expect(Array.isArray(examples)).toBe(true); @@ -153,38 +153,38 @@ describe("enhancement-prompts.ts", () => { }); }); - describe("buildUserPrompt", () => { - const testText = "Add a logout button"; + describe('buildUserPrompt', () => { + const testText = 'Add a logout button'; - it("should build prompt with examples by default", () => { - const prompt = buildUserPrompt("improve", testText); - expect(prompt).toContain("Example 1:"); + it('should build prompt with examples by default', () => { + const prompt = buildUserPrompt('improve', testText); + expect(prompt).toContain('Example 1:'); expect(prompt).toContain(testText); - expect(prompt).toContain("Now, please enhance the following task description:"); + expect(prompt).toContain('Now, please enhance the following task description:'); }); - it("should build prompt without examples when includeExamples is false", () => { - const prompt = buildUserPrompt("improve", testText, false); - expect(prompt).not.toContain("Example 1:"); + it('should build prompt without examples when includeExamples is false', () => { + const prompt = buildUserPrompt('improve', testText, false); + expect(prompt).not.toContain('Example 1:'); expect(prompt).toContain(testText); - expect(prompt).toContain("Please enhance the following task description:"); + expect(prompt).toContain('Please enhance the following task description:'); }); - it("should include all examples for improve mode", () => { - const prompt = buildUserPrompt("improve", testText); + it('should include all examples for improve mode', () => { + const prompt = buildUserPrompt('improve', testText); IMPROVE_EXAMPLES.forEach((example, index) => { expect(prompt).toContain(`Example ${index + 1}:`); expect(prompt).toContain(example.input); }); }); - it("should include separator between examples", () => { - const prompt = buildUserPrompt("improve", testText); - expect(prompt).toContain("---"); + it('should include separator between examples', () => { + const prompt = buildUserPrompt('improve', testText); + expect(prompt).toContain('---'); }); - it("should work with all enhancement modes", () => { - const modes: EnhancementMode[] = ["improve", "technical", "simplify", "acceptance"]; + it('should work with all enhancement modes', () => { + const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance']; modes.forEach((mode) => { const prompt = buildUserPrompt(mode, testText); expect(prompt).toContain(testText); @@ -192,40 +192,40 @@ describe("enhancement-prompts.ts", () => { }); }); - it("should preserve the original text exactly", () => { - const specialText = "Add feature with special chars: <>&\"'"; - const prompt = buildUserPrompt("improve", specialText); + it('should preserve the original text exactly', () => { + const specialText = 'Add feature with special chars: <>&"\''; + const prompt = buildUserPrompt('improve', specialText); expect(prompt).toContain(specialText); }); }); - describe("isValidEnhancementMode", () => { - it("should return true for valid modes", () => { - expect(isValidEnhancementMode("improve")).toBe(true); - expect(isValidEnhancementMode("technical")).toBe(true); - expect(isValidEnhancementMode("simplify")).toBe(true); - expect(isValidEnhancementMode("acceptance")).toBe(true); + describe('isValidEnhancementMode', () => { + it('should return true for valid modes', () => { + expect(isValidEnhancementMode('improve')).toBe(true); + expect(isValidEnhancementMode('technical')).toBe(true); + expect(isValidEnhancementMode('simplify')).toBe(true); + expect(isValidEnhancementMode('acceptance')).toBe(true); }); - it("should return false for invalid modes", () => { - expect(isValidEnhancementMode("invalid")).toBe(false); - expect(isValidEnhancementMode("IMPROVE")).toBe(false); // case-sensitive - expect(isValidEnhancementMode("")).toBe(false); - expect(isValidEnhancementMode("random")).toBe(false); + it('should return false for invalid modes', () => { + expect(isValidEnhancementMode('invalid')).toBe(false); + expect(isValidEnhancementMode('IMPROVE')).toBe(false); // case-sensitive + expect(isValidEnhancementMode('')).toBe(false); + expect(isValidEnhancementMode('random')).toBe(false); }); }); - describe("getAvailableEnhancementModes", () => { - it("should return all four enhancement modes", () => { + describe('getAvailableEnhancementModes', () => { + it('should return all four enhancement modes', () => { const modes = getAvailableEnhancementModes(); expect(modes).toHaveLength(4); - expect(modes).toContain("improve"); - expect(modes).toContain("technical"); - expect(modes).toContain("simplify"); - expect(modes).toContain("acceptance"); + expect(modes).toContain('improve'); + expect(modes).toContain('technical'); + expect(modes).toContain('simplify'); + expect(modes).toContain('acceptance'); }); - it("should return an array", () => { + it('should return an array', () => { const modes = getAvailableEnhancementModes(); expect(Array.isArray(modes)).toBe(true); }); diff --git a/apps/server/tests/unit/lib/events.test.ts b/apps/server/tests/unit/lib/events.test.ts index 4741a365..a8a78092 100644 --- a/apps/server/tests/unit/lib/events.test.ts +++ b/apps/server/tests/unit/lib/events.test.ts @@ -1,20 +1,20 @@ -import { describe, it, expect, vi } from "vitest"; -import { createEventEmitter, type EventType } from "@/lib/events.js"; +import { describe, it, expect, vi } from 'vitest'; +import { createEventEmitter, type EventType } from '@/lib/events.js'; -describe("events.ts", () => { - describe("createEventEmitter", () => { - it("should emit events to single subscriber", () => { +describe('events.ts', () => { + describe('createEventEmitter', () => { + it('should emit events to single subscriber', () => { const emitter = createEventEmitter(); const callback = vi.fn(); emitter.subscribe(callback); - emitter.emit("agent:stream", { message: "test" }); + emitter.emit('agent:stream', { message: 'test' }); expect(callback).toHaveBeenCalledOnce(); - expect(callback).toHaveBeenCalledWith("agent:stream", { message: "test" }); + expect(callback).toHaveBeenCalledWith('agent:stream', { message: 'test' }); }); - it("should emit events to multiple subscribers", () => { + it('should emit events to multiple subscribers', () => { const emitter = createEventEmitter(); const callback1 = vi.fn(); const callback2 = vi.fn(); @@ -23,42 +23,42 @@ describe("events.ts", () => { emitter.subscribe(callback1); emitter.subscribe(callback2); emitter.subscribe(callback3); - emitter.emit("feature:started", { id: "123" }); + emitter.emit('feature:started', { id: '123' }); expect(callback1).toHaveBeenCalledOnce(); expect(callback2).toHaveBeenCalledOnce(); expect(callback3).toHaveBeenCalledOnce(); - expect(callback1).toHaveBeenCalledWith("feature:started", { id: "123" }); + expect(callback1).toHaveBeenCalledWith('feature:started', { id: '123' }); }); - it("should support unsubscribe functionality", () => { + it('should support unsubscribe functionality', () => { const emitter = createEventEmitter(); const callback = vi.fn(); const unsubscribe = emitter.subscribe(callback); - emitter.emit("agent:stream", { test: 1 }); + emitter.emit('agent:stream', { test: 1 }); expect(callback).toHaveBeenCalledOnce(); unsubscribe(); - emitter.emit("agent:stream", { test: 2 }); + emitter.emit('agent:stream', { test: 2 }); expect(callback).toHaveBeenCalledOnce(); // Still called only once }); - it("should handle errors in subscribers without crashing", () => { + it('should handle errors in subscribers without crashing', () => { const emitter = createEventEmitter(); const errorCallback = vi.fn(() => { - throw new Error("Subscriber error"); + throw new Error('Subscriber error'); }); const normalCallback = vi.fn(); - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); emitter.subscribe(errorCallback); emitter.subscribe(normalCallback); expect(() => { - emitter.emit("feature:error", { error: "test" }); + emitter.emit('feature:error', { error: 'test' }); }).not.toThrow(); expect(errorCallback).toHaveBeenCalledOnce(); @@ -68,17 +68,17 @@ describe("events.ts", () => { consoleSpy.mockRestore(); }); - it("should emit different event types", () => { + it('should emit different event types', () => { const emitter = createEventEmitter(); const callback = vi.fn(); emitter.subscribe(callback); const eventTypes: EventType[] = [ - "agent:stream", - "auto-mode:started", - "feature:completed", - "project:analysis-progress", + 'agent:stream', + 'auto-mode:started', + 'feature:completed', + 'project:analysis-progress', ]; eventTypes.forEach((type) => { @@ -88,15 +88,15 @@ describe("events.ts", () => { expect(callback).toHaveBeenCalledTimes(4); }); - it("should handle emitting without subscribers", () => { + it('should handle emitting without subscribers', () => { const emitter = createEventEmitter(); expect(() => { - emitter.emit("agent:stream", { test: true }); + emitter.emit('agent:stream', { test: true }); }).not.toThrow(); }); - it("should allow multiple subscriptions and unsubscriptions", () => { + it('should allow multiple subscriptions and unsubscriptions', () => { const emitter = createEventEmitter(); const callback1 = vi.fn(); const callback2 = vi.fn(); @@ -106,14 +106,14 @@ describe("events.ts", () => { const unsub2 = emitter.subscribe(callback2); const unsub3 = emitter.subscribe(callback3); - emitter.emit("feature:started", { test: 1 }); + emitter.emit('feature:started', { test: 1 }); expect(callback1).toHaveBeenCalledOnce(); expect(callback2).toHaveBeenCalledOnce(); expect(callback3).toHaveBeenCalledOnce(); unsub2(); - emitter.emit("feature:started", { test: 2 }); + emitter.emit('feature:started', { test: 2 }); expect(callback1).toHaveBeenCalledTimes(2); expect(callback2).toHaveBeenCalledOnce(); // Still just once expect(callback3).toHaveBeenCalledTimes(2); @@ -121,7 +121,7 @@ describe("events.ts", () => { unsub1(); unsub3(); - emitter.emit("feature:started", { test: 3 }); + emitter.emit('feature:started', { test: 3 }); expect(callback1).toHaveBeenCalledTimes(2); expect(callback2).toHaveBeenCalledOnce(); expect(callback3).toHaveBeenCalledTimes(2); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index cd34af15..069fbf86 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -1,13 +1,13 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { ProviderFactory } from "@/providers/provider-factory.js"; -import { ClaudeProvider } from "@/providers/claude-provider.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ProviderFactory } from '@/providers/provider-factory.js'; +import { ClaudeProvider } from '@/providers/claude-provider.js'; -describe("provider-factory.ts", () => { +describe('provider-factory.ts', () => { let consoleSpy: any; beforeEach(() => { consoleSpy = { - warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), }; }); @@ -15,55 +15,49 @@ describe("provider-factory.ts", () => { consoleSpy.warn.mockRestore(); }); - describe("getProviderForModel", () => { - describe("Claude models (claude-* prefix)", () => { - it("should return ClaudeProvider for claude-opus-4-5-20251101", () => { - const provider = ProviderFactory.getProviderForModel( - "claude-opus-4-5-20251101" - ); + describe('getProviderForModel', () => { + describe('Claude models (claude-* prefix)', () => { + it('should return ClaudeProvider for claude-opus-4-5-20251101', () => { + const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101'); expect(provider).toBeInstanceOf(ClaudeProvider); }); - it("should return ClaudeProvider for claude-sonnet-4-20250514", () => { - const provider = ProviderFactory.getProviderForModel( - "claude-sonnet-4-20250514" - ); + it('should return ClaudeProvider for claude-sonnet-4-20250514', () => { + const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-20250514'); expect(provider).toBeInstanceOf(ClaudeProvider); }); - it("should return ClaudeProvider for claude-haiku-4-5", () => { - const provider = ProviderFactory.getProviderForModel("claude-haiku-4-5"); + it('should return ClaudeProvider for claude-haiku-4-5', () => { + const provider = ProviderFactory.getProviderForModel('claude-haiku-4-5'); expect(provider).toBeInstanceOf(ClaudeProvider); }); - it("should be case-insensitive for claude models", () => { - const provider = ProviderFactory.getProviderForModel( - "CLAUDE-OPUS-4-5-20251101" - ); + it('should be case-insensitive for claude models', () => { + const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101'); expect(provider).toBeInstanceOf(ClaudeProvider); }); }); - describe("Claude aliases", () => { + describe('Claude aliases', () => { it("should return ClaudeProvider for 'haiku'", () => { - const provider = ProviderFactory.getProviderForModel("haiku"); + const provider = ProviderFactory.getProviderForModel('haiku'); expect(provider).toBeInstanceOf(ClaudeProvider); }); it("should return ClaudeProvider for 'sonnet'", () => { - const provider = ProviderFactory.getProviderForModel("sonnet"); + const provider = ProviderFactory.getProviderForModel('sonnet'); expect(provider).toBeInstanceOf(ClaudeProvider); }); it("should return ClaudeProvider for 'opus'", () => { - const provider = ProviderFactory.getProviderForModel("opus"); + const provider = ProviderFactory.getProviderForModel('opus'); expect(provider).toBeInstanceOf(ClaudeProvider); }); - it("should be case-insensitive for aliases", () => { - const provider1 = ProviderFactory.getProviderForModel("HAIKU"); - const provider2 = ProviderFactory.getProviderForModel("Sonnet"); - const provider3 = ProviderFactory.getProviderForModel("Opus"); + it('should be case-insensitive for aliases', () => { + const provider1 = ProviderFactory.getProviderForModel('HAIKU'); + const provider2 = ProviderFactory.getProviderForModel('Sonnet'); + const provider3 = ProviderFactory.getProviderForModel('Opus'); expect(provider1).toBeInstanceOf(ClaudeProvider); expect(provider2).toBeInstanceOf(ClaudeProvider); @@ -71,65 +65,61 @@ describe("provider-factory.ts", () => { }); }); - describe("Unknown models", () => { - it("should default to ClaudeProvider for unknown model", () => { - const provider = ProviderFactory.getProviderForModel("unknown-model-123"); + describe('Unknown models', () => { + it('should default to ClaudeProvider for unknown model', () => { + const provider = ProviderFactory.getProviderForModel('unknown-model-123'); expect(provider).toBeInstanceOf(ClaudeProvider); }); - it("should warn when defaulting to Claude", () => { - ProviderFactory.getProviderForModel("random-model"); + it('should warn when defaulting to Claude', () => { + ProviderFactory.getProviderForModel('random-model'); expect(consoleSpy.warn).toHaveBeenCalledWith( - expect.stringContaining("Unknown model prefix") + expect.stringContaining('Unknown model prefix') ); + expect(consoleSpy.warn).toHaveBeenCalledWith(expect.stringContaining('random-model')); expect(consoleSpy.warn).toHaveBeenCalledWith( - expect.stringContaining("random-model") - ); - expect(consoleSpy.warn).toHaveBeenCalledWith( - expect.stringContaining("defaulting to Claude") + expect.stringContaining('defaulting to Claude') ); }); - it("should handle empty string", () => { - const provider = ProviderFactory.getProviderForModel(""); + it('should handle empty string', () => { + const provider = ProviderFactory.getProviderForModel(''); expect(provider).toBeInstanceOf(ClaudeProvider); expect(consoleSpy.warn).toHaveBeenCalled(); }); - it("should default to ClaudeProvider for gpt models (not supported)", () => { - const provider = ProviderFactory.getProviderForModel("gpt-5.2"); + it('should default to ClaudeProvider for gpt models (not supported)', () => { + const provider = ProviderFactory.getProviderForModel('gpt-5.2'); expect(provider).toBeInstanceOf(ClaudeProvider); expect(consoleSpy.warn).toHaveBeenCalled(); }); - it("should default to ClaudeProvider for o-series models (not supported)", () => { - const provider = ProviderFactory.getProviderForModel("o1"); + it('should default to ClaudeProvider for o-series models (not supported)', () => { + const provider = ProviderFactory.getProviderForModel('o1'); expect(provider).toBeInstanceOf(ClaudeProvider); expect(consoleSpy.warn).toHaveBeenCalled(); }); }); }); - describe("getAllProviders", () => { - it("should return array of all providers", () => { + describe('getAllProviders', () => { + it('should return array of all providers', () => { const providers = ProviderFactory.getAllProviders(); expect(Array.isArray(providers)).toBe(true); }); - it("should include ClaudeProvider", () => { + it('should include ClaudeProvider', () => { const providers = ProviderFactory.getAllProviders(); - const hasClaudeProvider = providers.some( - (p) => p instanceof ClaudeProvider - ); + const hasClaudeProvider = providers.some((p) => p instanceof ClaudeProvider); expect(hasClaudeProvider).toBe(true); }); - it("should return exactly 1 provider", () => { + it('should return exactly 1 provider', () => { const providers = ProviderFactory.getAllProviders(); expect(providers).toHaveLength(1); }); - it("should create new instances each time", () => { + it('should create new instances each time', () => { const providers1 = ProviderFactory.getAllProviders(); const providers2 = ProviderFactory.getAllProviders(); @@ -137,60 +127,60 @@ describe("provider-factory.ts", () => { }); }); - describe("checkAllProviders", () => { - it("should return installation status for all providers", async () => { + describe('checkAllProviders', () => { + it('should return installation status for all providers', async () => { const statuses = await ProviderFactory.checkAllProviders(); - expect(statuses).toHaveProperty("claude"); + expect(statuses).toHaveProperty('claude'); }); - it("should call detectInstallation on each provider", async () => { + it('should call detectInstallation on each provider', async () => { const statuses = await ProviderFactory.checkAllProviders(); - expect(statuses.claude).toHaveProperty("installed"); + expect(statuses.claude).toHaveProperty('installed'); }); - it("should return correct provider names as keys", async () => { + it('should return correct provider names as keys', async () => { const statuses = await ProviderFactory.checkAllProviders(); const keys = Object.keys(statuses); - expect(keys).toContain("claude"); + expect(keys).toContain('claude'); expect(keys).toHaveLength(1); }); }); - describe("getProviderByName", () => { + describe('getProviderByName', () => { it("should return ClaudeProvider for 'claude'", () => { - const provider = ProviderFactory.getProviderByName("claude"); + const provider = ProviderFactory.getProviderByName('claude'); expect(provider).toBeInstanceOf(ClaudeProvider); }); it("should return ClaudeProvider for 'anthropic'", () => { - const provider = ProviderFactory.getProviderByName("anthropic"); + const provider = ProviderFactory.getProviderByName('anthropic'); expect(provider).toBeInstanceOf(ClaudeProvider); }); - it("should be case-insensitive", () => { - const provider1 = ProviderFactory.getProviderByName("CLAUDE"); - const provider2 = ProviderFactory.getProviderByName("ANTHROPIC"); + it('should be case-insensitive', () => { + const provider1 = ProviderFactory.getProviderByName('CLAUDE'); + const provider2 = ProviderFactory.getProviderByName('ANTHROPIC'); expect(provider1).toBeInstanceOf(ClaudeProvider); expect(provider2).toBeInstanceOf(ClaudeProvider); }); - it("should return null for unknown provider", () => { - const provider = ProviderFactory.getProviderByName("unknown"); + it('should return null for unknown provider', () => { + const provider = ProviderFactory.getProviderByName('unknown'); expect(provider).toBeNull(); }); - it("should return null for empty string", () => { - const provider = ProviderFactory.getProviderByName(""); + it('should return null for empty string', () => { + const provider = ProviderFactory.getProviderByName(''); expect(provider).toBeNull(); }); - it("should create new instance each time", () => { - const provider1 = ProviderFactory.getProviderByName("claude"); - const provider2 = ProviderFactory.getProviderByName("claude"); + it('should create new instance each time', () => { + const provider1 = ProviderFactory.getProviderByName('claude'); + const provider2 = ProviderFactory.getProviderByName('claude'); expect(provider1).not.toBe(provider2); expect(provider1).toBeInstanceOf(ClaudeProvider); @@ -198,35 +188,33 @@ describe("provider-factory.ts", () => { }); }); - describe("getAllAvailableModels", () => { - it("should return array of models", () => { + describe('getAllAvailableModels', () => { + it('should return array of models', () => { const models = ProviderFactory.getAllAvailableModels(); expect(Array.isArray(models)).toBe(true); }); - it("should include models from all providers", () => { + it('should include models from all providers', () => { const models = ProviderFactory.getAllAvailableModels(); expect(models.length).toBeGreaterThan(0); }); - it("should return models with required fields", () => { + it('should return models with required fields', () => { const models = ProviderFactory.getAllAvailableModels(); models.forEach((model) => { - expect(model).toHaveProperty("id"); - expect(model).toHaveProperty("name"); - expect(typeof model.id).toBe("string"); - expect(typeof model.name).toBe("string"); + expect(model).toHaveProperty('id'); + expect(model).toHaveProperty('name'); + expect(typeof model.id).toBe('string'); + expect(typeof model.name).toBe('string'); }); }); - it("should include Claude models", () => { + it('should include Claude models', () => { const models = ProviderFactory.getAllAvailableModels(); // Claude models should include claude-* in their IDs - const hasClaudeModels = models.some((m) => - m.id.toLowerCase().includes("claude") - ); + const hasClaudeModels = models.some((m) => m.id.toLowerCase().includes('claude')); expect(hasClaudeModels).toBe(true); }); diff --git a/apps/server/tests/unit/routes/app-spec/common.test.ts b/apps/server/tests/unit/routes/app-spec/common.test.ts index 14ec98d1..aeaf8ea5 100644 --- a/apps/server/tests/unit/routes/app-spec/common.test.ts +++ b/apps/server/tests/unit/routes/app-spec/common.test.ts @@ -1,65 +1,59 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach } from 'vitest'; import { setRunningState, getErrorMessage, getSpecRegenerationStatus, -} from "@/routes/app-spec/common.js"; +} from '@/routes/app-spec/common.js'; -describe("app-spec/common.ts", () => { +describe('app-spec/common.ts', () => { beforeEach(() => { // Reset state before each test setRunningState(false, null); }); - describe("setRunningState", () => { - it("should set isRunning to true when running is true", () => { + describe('setRunningState', () => { + it('should set isRunning to true when running is true', () => { setRunningState(true); expect(getSpecRegenerationStatus().isRunning).toBe(true); }); - it("should set isRunning to false when running is false", () => { + it('should set isRunning to false when running is false', () => { setRunningState(true); setRunningState(false); expect(getSpecRegenerationStatus().isRunning).toBe(false); }); - it("should set currentAbortController when provided", () => { + it('should set currentAbortController when provided', () => { const controller = new AbortController(); setRunningState(true, controller); - expect(getSpecRegenerationStatus().currentAbortController).toBe( - controller - ); + expect(getSpecRegenerationStatus().currentAbortController).toBe(controller); }); - it("should set currentAbortController to null when not provided", () => { + it('should set currentAbortController to null when not provided', () => { const controller = new AbortController(); setRunningState(true, controller); setRunningState(false); expect(getSpecRegenerationStatus().currentAbortController).toBe(null); }); - it("should set currentAbortController to null when explicitly passed null", () => { + it('should set currentAbortController to null when explicitly passed null', () => { const controller = new AbortController(); setRunningState(true, controller); setRunningState(true, null); expect(getSpecRegenerationStatus().currentAbortController).toBe(null); }); - it("should update state multiple times correctly", () => { + it('should update state multiple times correctly', () => { const controller1 = new AbortController(); const controller2 = new AbortController(); setRunningState(true, controller1); expect(getSpecRegenerationStatus().isRunning).toBe(true); - expect(getSpecRegenerationStatus().currentAbortController).toBe( - controller1 - ); + expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1); setRunningState(true, controller2); expect(getSpecRegenerationStatus().isRunning).toBe(true); - expect(getSpecRegenerationStatus().currentAbortController).toBe( - controller2 - ); + expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2); setRunningState(false, null); expect(getSpecRegenerationStatus().isRunning).toBe(false); @@ -67,42 +61,42 @@ describe("app-spec/common.ts", () => { }); }); - describe("getErrorMessage", () => { - it("should return message from Error instance", () => { - const error = new Error("Test error message"); - expect(getErrorMessage(error)).toBe("Test error message"); + describe('getErrorMessage', () => { + it('should return message from Error instance', () => { + const error = new Error('Test error message'); + expect(getErrorMessage(error)).toBe('Test error message'); }); it("should return 'Unknown error' for non-Error objects", () => { - expect(getErrorMessage("string error")).toBe("Unknown error"); - expect(getErrorMessage(123)).toBe("Unknown error"); - expect(getErrorMessage(null)).toBe("Unknown error"); - expect(getErrorMessage(undefined)).toBe("Unknown error"); - expect(getErrorMessage({})).toBe("Unknown error"); - expect(getErrorMessage([])).toBe("Unknown error"); + expect(getErrorMessage('string error')).toBe('Unknown error'); + expect(getErrorMessage(123)).toBe('Unknown error'); + expect(getErrorMessage(null)).toBe('Unknown error'); + expect(getErrorMessage(undefined)).toBe('Unknown error'); + expect(getErrorMessage({})).toBe('Unknown error'); + expect(getErrorMessage([])).toBe('Unknown error'); }); - it("should return message from Error with empty message", () => { - const error = new Error(""); - expect(getErrorMessage(error)).toBe(""); + it('should return message from Error with empty message', () => { + const error = new Error(''); + expect(getErrorMessage(error)).toBe(''); }); - it("should handle Error objects with custom properties", () => { - const error = new Error("Base message"); - (error as any).customProp = "custom value"; - expect(getErrorMessage(error)).toBe("Base message"); + it('should handle Error objects with custom properties', () => { + const error = new Error('Base message'); + (error as any).customProp = 'custom value'; + expect(getErrorMessage(error)).toBe('Base message'); }); - it("should handle Error objects created with different constructors", () => { + it('should handle Error objects created with different constructors', () => { class CustomError extends Error { constructor(message: string) { super(message); - this.name = "CustomError"; + this.name = 'CustomError'; } } - const customError = new CustomError("Custom error message"); - expect(getErrorMessage(customError)).toBe("Custom error message"); + const customError = new CustomError('Custom error message'); + expect(getErrorMessage(customError)).toBe('Custom error message'); }); }); }); diff --git a/apps/server/tests/unit/routes/app-spec/parse-and-create-features.test.ts b/apps/server/tests/unit/routes/app-spec/parse-and-create-features.test.ts index 7b3d0568..9bb5c120 100644 --- a/apps/server/tests/unit/routes/app-spec/parse-and-create-features.test.ts +++ b/apps/server/tests/unit/routes/app-spec/parse-and-create-features.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest'; -describe("app-spec/parse-and-create-features.ts - JSON extraction", () => { +describe('app-spec/parse-and-create-features.ts - JSON extraction', () => { // Test the JSON extraction regex pattern used in parseAndCreateFeatures const jsonExtractionPattern = /\{[\s\S]*"features"[\s\S]*\}/; - describe("JSON extraction regex", () => { - it("should extract JSON with features array", () => { + describe('JSON extraction regex', () => { + it('should extract JSON with features array', () => { const content = `Here is the response: { "features": [ @@ -26,7 +26,7 @@ describe("app-spec/parse-and-create-features.ts - JSON extraction", () => { expect(match![0]).toContain('"id": "feature-1"'); }); - it("should extract JSON with multiple features", () => { + it('should extract JSON with multiple features', () => { const content = `Some text before { "features": [ @@ -49,7 +49,7 @@ Some text after`; expect(match![0]).toContain('"feature-2"'); }); - it("should extract JSON with nested objects and arrays", () => { + it('should extract JSON with nested objects and arrays', () => { const content = `Response: { "features": [ @@ -69,7 +69,7 @@ Some text after`; expect(match![0]).toContain('"dep-1"'); }); - it("should handle JSON with whitespace and newlines", () => { + it('should handle JSON with whitespace and newlines', () => { const content = `Text before { "features": [ @@ -87,7 +87,7 @@ Text after`; expect(match![0]).toContain('"features"'); }); - it("should extract JSON when features array is empty", () => { + it('should extract JSON when features array is empty', () => { const content = `Response: { "features": [] @@ -96,10 +96,10 @@ Text after`; const match = content.match(jsonExtractionPattern); expect(match).not.toBeNull(); expect(match![0]).toContain('"features"'); - expect(match![0]).toContain("[]"); + expect(match![0]).toContain('[]'); }); - it("should not match content without features key", () => { + it('should not match content without features key', () => { const content = `{ "otherKey": "value" }`; @@ -108,13 +108,13 @@ Text after`; expect(match).toBeNull(); }); - it("should not match content without JSON structure", () => { - const content = "Just plain text with features mentioned"; + it('should not match content without JSON structure', () => { + const content = 'Just plain text with features mentioned'; const match = content.match(jsonExtractionPattern); expect(match).toBeNull(); }); - it("should extract JSON when features key appears multiple times", () => { + it('should extract JSON when features key appears multiple times', () => { const content = `Before: { "features": [ @@ -132,7 +132,7 @@ After: The word "features" appears again`; expect(match![0]).toContain('"features"'); }); - it("should handle JSON with escaped quotes", () => { + it('should handle JSON with escaped quotes', () => { const content = `{ "features": [ { @@ -147,7 +147,7 @@ After: The word "features" appears again`; expect(match![0]).toContain('"features"'); }); - it("should extract JSON with complex nested structure", () => { + it('should extract JSON with complex nested structure', () => { const content = `Response: { "features": [ @@ -177,8 +177,8 @@ After: The word "features" appears again`; }); }); - describe("JSON parsing validation", () => { - it("should parse valid feature JSON structure", () => { + describe('JSON parsing validation', () => { + it('should parse valid feature JSON structure', () => { const validJson = `{ "features": [ { @@ -196,11 +196,11 @@ After: The word "features" appears again`; expect(parsed.features).toBeDefined(); expect(Array.isArray(parsed.features)).toBe(true); expect(parsed.features.length).toBe(1); - expect(parsed.features[0].id).toBe("feature-1"); - expect(parsed.features[0].title).toBe("Test Feature"); + expect(parsed.features[0].id).toBe('feature-1'); + expect(parsed.features[0].title).toBe('Test Feature'); }); - it("should handle features with optional fields", () => { + it('should handle features with optional fields', () => { const jsonWithOptionalFields = `{ "features": [ { @@ -213,14 +213,14 @@ After: The word "features" appears again`; }`; const parsed = JSON.parse(jsonWithOptionalFields); - expect(parsed.features[0].id).toBe("feature-1"); + expect(parsed.features[0].id).toBe('feature-1'); expect(parsed.features[0].priority).toBe(2); // description and dependencies are optional expect(parsed.features[0].description).toBeUndefined(); expect(parsed.features[0].dependencies).toBeUndefined(); }); - it("should handle features with dependencies", () => { + it('should handle features with dependencies', () => { const jsonWithDeps = `{ "features": [ { @@ -238,7 +238,7 @@ After: The word "features" appears again`; const parsed = JSON.parse(jsonWithDeps); expect(parsed.features[0].dependencies).toEqual([]); - expect(parsed.features[1].dependencies).toEqual(["feature-1"]); + expect(parsed.features[1].dependencies).toEqual(['feature-1']); }); }); }); diff --git a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts index 09483e78..7b52fe38 100644 --- a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts +++ b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { AutoModeService } from "@/services/auto-mode-service.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AutoModeService } from '@/services/auto-mode-service.js'; -describe("auto-mode-service.ts - Planning Mode", () => { +describe('auto-mode-service.ts - Planning Mode', () => { let service: AutoModeService; const mockEvents = { subscribe: vi.fn(), @@ -18,98 +18,98 @@ describe("auto-mode-service.ts - Planning Mode", () => { await service.stopAutoLoop().catch(() => {}); }); - describe("getPlanningPromptPrefix", () => { + describe('getPlanningPromptPrefix', () => { // Access private method through any cast for testing const getPlanningPromptPrefix = (svc: any, feature: any) => { return svc.getPlanningPromptPrefix(feature); }; - it("should return empty string for skip mode", () => { - const feature = { id: "test", planningMode: "skip" as const }; + it('should return empty string for skip mode', () => { + const feature = { id: 'test', planningMode: 'skip' as const }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toBe(""); + expect(result).toBe(''); }); - it("should return empty string when planningMode is undefined", () => { - const feature = { id: "test" }; + it('should return empty string when planningMode is undefined', () => { + const feature = { id: 'test' }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toBe(""); + expect(result).toBe(''); }); - it("should return lite prompt for lite mode without approval", () => { + it('should return lite prompt for lite mode without approval', () => { const feature = { - id: "test", - planningMode: "lite" as const, - requirePlanApproval: false + id: 'test', + planningMode: 'lite' as const, + requirePlanApproval: false, }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Planning Phase (Lite Mode)"); - expect(result).toContain("[PLAN_GENERATED]"); - expect(result).toContain("Feature Request"); + expect(result).toContain('Planning Phase (Lite Mode)'); + expect(result).toContain('[PLAN_GENERATED]'); + expect(result).toContain('Feature Request'); }); - it("should return lite_with_approval prompt for lite mode with approval", () => { + it('should return lite_with_approval prompt for lite mode with approval', () => { const feature = { - id: "test", - planningMode: "lite" as const, - requirePlanApproval: true + id: 'test', + planningMode: 'lite' as const, + requirePlanApproval: true, }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Planning Phase (Lite Mode)"); - expect(result).toContain("[SPEC_GENERATED]"); - expect(result).toContain("DO NOT proceed with implementation"); + expect(result).toContain('Planning Phase (Lite Mode)'); + expect(result).toContain('[SPEC_GENERATED]'); + expect(result).toContain('DO NOT proceed with implementation'); }); - it("should return spec prompt for spec mode", () => { + it('should return spec prompt for spec mode', () => { const feature = { - id: "test", - planningMode: "spec" as const + id: 'test', + planningMode: 'spec' as const, }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Specification Phase (Spec Mode)"); - expect(result).toContain("```tasks"); - expect(result).toContain("T001"); - expect(result).toContain("[TASK_START]"); - expect(result).toContain("[TASK_COMPLETE]"); + expect(result).toContain('Specification Phase (Spec Mode)'); + expect(result).toContain('```tasks'); + expect(result).toContain('T001'); + expect(result).toContain('[TASK_START]'); + expect(result).toContain('[TASK_COMPLETE]'); }); - it("should return full prompt for full mode", () => { + it('should return full prompt for full mode', () => { const feature = { - id: "test", - planningMode: "full" as const + id: 'test', + planningMode: 'full' as const, }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Full Specification Phase (Full SDD Mode)"); - expect(result).toContain("Phase 1: Foundation"); - expect(result).toContain("Phase 2: Core Implementation"); - expect(result).toContain("Phase 3: Integration & Testing"); + expect(result).toContain('Full Specification Phase (Full SDD Mode)'); + expect(result).toContain('Phase 1: Foundation'); + expect(result).toContain('Phase 2: Core Implementation'); + expect(result).toContain('Phase 3: Integration & Testing'); }); - it("should include the separator and Feature Request header", () => { + it('should include the separator and Feature Request header', () => { const feature = { - id: "test", - planningMode: "spec" as const + id: 'test', + planningMode: 'spec' as const, }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("---"); - expect(result).toContain("## Feature Request"); + expect(result).toContain('---'); + expect(result).toContain('## Feature Request'); }); - it("should instruct agent to NOT output exploration text", () => { - const modes = ["lite", "spec", "full"] as const; + it('should instruct agent to NOT output exploration text', () => { + const modes = ['lite', 'spec', 'full'] as const; for (const mode of modes) { - const feature = { id: "test", planningMode: mode }; + const feature = { id: 'test', planningMode: mode }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Do NOT output exploration text"); - expect(result).toContain("Start DIRECTLY"); + expect(result).toContain('Do NOT output exploration text'); + expect(result).toContain('Start DIRECTLY'); } }); }); - describe("parseTasksFromSpec (via module)", () => { + describe('parseTasksFromSpec (via module)', () => { // We need to test the module-level function // Import it directly for testing - it("should parse tasks from a valid tasks block", async () => { + it('should parse tasks from a valid tasks block', async () => { // This tests the internal logic through integration // The function is module-level, so we verify behavior through the service const specContent = ` @@ -123,12 +123,12 @@ describe("auto-mode-service.ts - Planning Mode", () => { `; // Since parseTasksFromSpec is a module-level function, // we verify its behavior indirectly through plan parsing - expect(specContent).toContain("T001"); - expect(specContent).toContain("T002"); - expect(specContent).toContain("T003"); + expect(specContent).toContain('T001'); + expect(specContent).toContain('T002'); + expect(specContent).toContain('T003'); }); - it("should handle tasks block with phases", () => { + it('should handle tasks block with phases', () => { const specContent = ` \`\`\`tasks ## Phase 1: Setup @@ -139,190 +139,191 @@ describe("auto-mode-service.ts - Planning Mode", () => { - [ ] T003: Create main module | File: src/index.ts \`\`\` `; - expect(specContent).toContain("Phase 1"); - expect(specContent).toContain("Phase 2"); - expect(specContent).toContain("T001"); - expect(specContent).toContain("T003"); + expect(specContent).toContain('Phase 1'); + expect(specContent).toContain('Phase 2'); + expect(specContent).toContain('T001'); + expect(specContent).toContain('T003'); }); }); - describe("plan approval flow", () => { - it("should track pending approvals correctly", () => { - expect(service.hasPendingApproval("test-feature")).toBe(false); + describe('plan approval flow', () => { + it('should track pending approvals correctly', () => { + expect(service.hasPendingApproval('test-feature')).toBe(false); }); - it("should allow cancelling non-existent approval without error", () => { - expect(() => service.cancelPlanApproval("non-existent")).not.toThrow(); + it('should allow cancelling non-existent approval without error', () => { + expect(() => service.cancelPlanApproval('non-existent')).not.toThrow(); }); - it("should return running features count after stop", async () => { + it('should return running features count after stop', async () => { const count = await service.stopAutoLoop(); expect(count).toBe(0); }); }); - describe("resolvePlanApproval", () => { - it("should return error when no pending approval exists", async () => { + describe('resolvePlanApproval', () => { + it('should return error when no pending approval exists', async () => { const result = await service.resolvePlanApproval( - "non-existent-feature", + 'non-existent-feature', true, undefined, undefined, undefined ); expect(result.success).toBe(false); - expect(result.error).toContain("No pending approval"); + expect(result.error).toContain('No pending approval'); }); - it("should handle approval with edited plan", async () => { + it('should handle approval with edited plan', async () => { // Without a pending approval, this should fail gracefully const result = await service.resolvePlanApproval( - "test-feature", + 'test-feature', true, - "Edited plan content", + 'Edited plan content', undefined, undefined ); expect(result.success).toBe(false); }); - it("should handle rejection with feedback", async () => { + it('should handle rejection with feedback', async () => { const result = await service.resolvePlanApproval( - "test-feature", + 'test-feature', false, undefined, - "Please add more details", + 'Please add more details', undefined ); expect(result.success).toBe(false); }); }); - describe("buildFeaturePrompt", () => { + describe('buildFeaturePrompt', () => { const buildFeaturePrompt = (svc: any, feature: any) => { return svc.buildFeaturePrompt(feature); }; - it("should include feature ID and description", () => { + it('should include feature ID and description', () => { const feature = { - id: "feat-123", - description: "Add user authentication", + id: 'feat-123', + description: 'Add user authentication', }; const result = buildFeaturePrompt(service, feature); - expect(result).toContain("feat-123"); - expect(result).toContain("Add user authentication"); + expect(result).toContain('feat-123'); + expect(result).toContain('Add user authentication'); }); - it("should include specification when present", () => { + it('should include specification when present', () => { const feature = { - id: "feat-123", - description: "Test feature", - spec: "Detailed specification here", + id: 'feat-123', + description: 'Test feature', + spec: 'Detailed specification here', }; const result = buildFeaturePrompt(service, feature); - expect(result).toContain("Specification:"); - expect(result).toContain("Detailed specification here"); + expect(result).toContain('Specification:'); + expect(result).toContain('Detailed specification here'); }); - it("should include image paths when present", () => { + it('should include image paths when present', () => { const feature = { - id: "feat-123", - description: "Test feature", + id: 'feat-123', + description: 'Test feature', imagePaths: [ - { path: "/tmp/image1.png", filename: "image1.png", mimeType: "image/png" }, - "/tmp/image2.jpg", + { path: '/tmp/image1.png', filename: 'image1.png', mimeType: 'image/png' }, + '/tmp/image2.jpg', ], }; const result = buildFeaturePrompt(service, feature); - expect(result).toContain("Context Images Attached"); - expect(result).toContain("image1.png"); - expect(result).toContain("/tmp/image2.jpg"); + expect(result).toContain('Context Images Attached'); + expect(result).toContain('image1.png'); + expect(result).toContain('/tmp/image2.jpg'); }); - it("should include summary tags instruction", () => { + it('should include summary tags instruction', () => { const feature = { - id: "feat-123", - description: "Test feature", + id: 'feat-123', + description: 'Test feature', }; const result = buildFeaturePrompt(service, feature); - expect(result).toContain(""); - expect(result).toContain(""); + expect(result).toContain(''); + expect(result).toContain(''); }); }); - describe("extractTitleFromDescription", () => { + describe('extractTitleFromDescription', () => { const extractTitle = (svc: any, description: string) => { return svc.extractTitleFromDescription(description); }; it("should return 'Untitled Feature' for empty description", () => { - expect(extractTitle(service, "")).toBe("Untitled Feature"); - expect(extractTitle(service, " ")).toBe("Untitled Feature"); + expect(extractTitle(service, '')).toBe('Untitled Feature'); + expect(extractTitle(service, ' ')).toBe('Untitled Feature'); }); - it("should return first line if under 60 characters", () => { - const description = "Add user login\nWith email validation"; - expect(extractTitle(service, description)).toBe("Add user login"); + it('should return first line if under 60 characters', () => { + const description = 'Add user login\nWith email validation'; + expect(extractTitle(service, description)).toBe('Add user login'); }); - it("should truncate long first lines to 60 characters", () => { - const description = "This is a very long feature description that exceeds the sixty character limit significantly"; + it('should truncate long first lines to 60 characters', () => { + const description = + 'This is a very long feature description that exceeds the sixty character limit significantly'; const result = extractTitle(service, description); expect(result.length).toBe(60); - expect(result).toContain("..."); + expect(result).toContain('...'); }); }); - describe("PLANNING_PROMPTS structure", () => { + describe('PLANNING_PROMPTS structure', () => { const getPlanningPromptPrefix = (svc: any, feature: any) => { return svc.getPlanningPromptPrefix(feature); }; - it("should have all required planning modes", () => { - const modes = ["lite", "spec", "full"] as const; + it('should have all required planning modes', () => { + const modes = ['lite', 'spec', 'full'] as const; for (const mode of modes) { - const feature = { id: "test", planningMode: mode }; + const feature = { id: 'test', planningMode: mode }; const result = getPlanningPromptPrefix(service, feature); expect(result.length).toBeGreaterThan(100); } }); - it("lite prompt should include correct structure", () => { - const feature = { id: "test", planningMode: "lite" as const }; + it('lite prompt should include correct structure', () => { + const feature = { id: 'test', planningMode: 'lite' as const }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Goal"); - expect(result).toContain("Approach"); - expect(result).toContain("Files to Touch"); - expect(result).toContain("Tasks"); - expect(result).toContain("Risks"); + expect(result).toContain('Goal'); + expect(result).toContain('Approach'); + expect(result).toContain('Files to Touch'); + expect(result).toContain('Tasks'); + expect(result).toContain('Risks'); }); - it("spec prompt should include task format instructions", () => { - const feature = { id: "test", planningMode: "spec" as const }; + it('spec prompt should include task format instructions', () => { + const feature = { id: 'test', planningMode: 'spec' as const }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Problem"); - expect(result).toContain("Solution"); - expect(result).toContain("Acceptance Criteria"); - expect(result).toContain("GIVEN-WHEN-THEN"); - expect(result).toContain("Implementation Tasks"); - expect(result).toContain("Verification"); + expect(result).toContain('Problem'); + expect(result).toContain('Solution'); + expect(result).toContain('Acceptance Criteria'); + expect(result).toContain('GIVEN-WHEN-THEN'); + expect(result).toContain('Implementation Tasks'); + expect(result).toContain('Verification'); }); - it("full prompt should include phases", () => { - const feature = { id: "test", planningMode: "full" as const }; + it('full prompt should include phases', () => { + const feature = { id: 'test', planningMode: 'full' as const }; const result = getPlanningPromptPrefix(service, feature); - expect(result).toContain("Problem Statement"); - expect(result).toContain("User Story"); - expect(result).toContain("Technical Context"); - expect(result).toContain("Non-Goals"); - expect(result).toContain("Phase 1"); - expect(result).toContain("Phase 2"); - expect(result).toContain("Phase 3"); + expect(result).toContain('Problem Statement'); + expect(result).toContain('User Story'); + expect(result).toContain('Technical Context'); + expect(result).toContain('Non-Goals'); + expect(result).toContain('Phase 1'); + expect(result).toContain('Phase 2'); + expect(result).toContain('Phase 3'); }); }); - describe("status management", () => { - it("should report correct status", () => { + describe('status management', () => { + it('should report correct status', () => { const status = service.getStatus(); expect(status.runningFeatures).toEqual([]); expect(status.isRunning).toBe(false); diff --git a/apps/server/tests/unit/services/auto-mode-service.test.ts b/apps/server/tests/unit/services/auto-mode-service.test.ts index f108a638..ec0959d7 100644 --- a/apps/server/tests/unit/services/auto-mode-service.test.ts +++ b/apps/server/tests/unit/services/auto-mode-service.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { AutoModeService } from "@/services/auto-mode-service.js"; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AutoModeService } from '@/services/auto-mode-service.js'; -describe("auto-mode-service.ts", () => { +describe('auto-mode-service.ts', () => { let service: AutoModeService; const mockEvents = { subscribe: vi.fn(), @@ -13,29 +13,27 @@ describe("auto-mode-service.ts", () => { service = new AutoModeService(mockEvents as any); }); - describe("constructor", () => { - it("should initialize with event emitter", () => { + describe('constructor', () => { + it('should initialize with event emitter', () => { expect(service).toBeDefined(); }); }); - describe("startAutoLoop", () => { - it("should throw if auto mode is already running", async () => { + describe('startAutoLoop', () => { + it('should throw if auto mode is already running', async () => { // Start first loop - const promise1 = service.startAutoLoop("/test/project", 3); + const promise1 = service.startAutoLoop('/test/project', 3); // Try to start second loop - await expect( - service.startAutoLoop("/test/project", 3) - ).rejects.toThrow("already running"); + await expect(service.startAutoLoop('/test/project', 3)).rejects.toThrow('already running'); // Cleanup await service.stopAutoLoop(); await promise1.catch(() => {}); }); - it("should emit auto mode start event", async () => { - const promise = service.startAutoLoop("/test/project", 3); + it('should emit auto mode start event', async () => { + const promise = service.startAutoLoop('/test/project', 3); // Give it time to emit the event await new Promise((resolve) => setTimeout(resolve, 10)); @@ -43,7 +41,7 @@ describe("auto-mode-service.ts", () => { expect(mockEvents.emit).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - message: expect.stringContaining("Auto mode started"), + message: expect.stringContaining('Auto mode started'), }) ); @@ -53,9 +51,9 @@ describe("auto-mode-service.ts", () => { }); }); - describe("stopAutoLoop", () => { - it("should stop the auto loop", async () => { - const promise = service.startAutoLoop("/test/project", 3); + describe('stopAutoLoop', () => { + it('should stop the auto loop', async () => { + const promise = service.startAutoLoop('/test/project', 3); const runningCount = await service.stopAutoLoop(); @@ -63,7 +61,7 @@ describe("auto-mode-service.ts", () => { await promise.catch(() => {}); }); - it("should return 0 when not running", async () => { + it('should return 0 when not running', async () => { const runningCount = await service.stopAutoLoop(); expect(runningCount).toBe(0); }); diff --git a/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts b/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts index becdd309..984e38c5 100644 --- a/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts +++ b/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from 'vitest'; /** * Test the task parsing logic by reimplementing the parsing functions @@ -88,59 +88,59 @@ function parseTasksFromSpec(specContent: string): ParsedTask[] { return tasks; } -describe("Task Parsing", () => { - describe("parseTaskLine", () => { - it("should parse task with file path", () => { - const line = "- [ ] T001: Create user model | File: src/models/user.ts"; +describe('Task Parsing', () => { + describe('parseTaskLine', () => { + it('should parse task with file path', () => { + const line = '- [ ] T001: Create user model | File: src/models/user.ts'; const result = parseTaskLine(line); expect(result).toEqual({ - id: "T001", - description: "Create user model", - filePath: "src/models/user.ts", + id: 'T001', + description: 'Create user model', + filePath: 'src/models/user.ts', phase: undefined, - status: "pending", + status: 'pending', }); }); - it("should parse task without file path", () => { - const line = "- [ ] T002: Setup database connection"; + it('should parse task without file path', () => { + const line = '- [ ] T002: Setup database connection'; const result = parseTaskLine(line); expect(result).toEqual({ - id: "T002", - description: "Setup database connection", + id: 'T002', + description: 'Setup database connection', phase: undefined, - status: "pending", + status: 'pending', }); }); - it("should include phase when provided", () => { - const line = "- [ ] T003: Write tests | File: tests/user.test.ts"; - const result = parseTaskLine(line, "Phase 1: Foundation"); - expect(result?.phase).toBe("Phase 1: Foundation"); + it('should include phase when provided', () => { + const line = '- [ ] T003: Write tests | File: tests/user.test.ts'; + const result = parseTaskLine(line, 'Phase 1: Foundation'); + expect(result?.phase).toBe('Phase 1: Foundation'); }); - it("should return null for invalid line", () => { - expect(parseTaskLine("- [ ] Invalid format")).toBeNull(); - expect(parseTaskLine("Not a task line")).toBeNull(); - expect(parseTaskLine("")).toBeNull(); + it('should return null for invalid line', () => { + expect(parseTaskLine('- [ ] Invalid format')).toBeNull(); + expect(parseTaskLine('Not a task line')).toBeNull(); + expect(parseTaskLine('')).toBeNull(); }); - it("should handle multi-word descriptions", () => { - const line = "- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts"; + it('should handle multi-word descriptions', () => { + const line = '- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts'; const result = parseTaskLine(line); - expect(result?.description).toBe("Implement user authentication with JWT tokens"); + expect(result?.description).toBe('Implement user authentication with JWT tokens'); }); - it("should trim whitespace from description and file path", () => { - const line = "- [ ] T005: Create API endpoint | File: src/routes/api.ts "; + it('should trim whitespace from description and file path', () => { + const line = '- [ ] T005: Create API endpoint | File: src/routes/api.ts '; const result = parseTaskLine(line); - expect(result?.description).toBe("Create API endpoint"); - expect(result?.filePath).toBe("src/routes/api.ts"); + expect(result?.description).toBe('Create API endpoint'); + expect(result?.filePath).toBe('src/routes/api.ts'); }); }); - describe("parseTasksFromSpec", () => { - it("should parse tasks from a tasks code block", () => { + describe('parseTasksFromSpec', () => { + it('should parse tasks from a tasks code block', () => { const specContent = ` ## Specification @@ -157,12 +157,12 @@ Some notes here. `; const tasks = parseTasksFromSpec(specContent); expect(tasks).toHaveLength(3); - expect(tasks[0].id).toBe("T001"); - expect(tasks[1].id).toBe("T002"); - expect(tasks[2].id).toBe("T003"); + expect(tasks[0].id).toBe('T001'); + expect(tasks[1].id).toBe('T002'); + expect(tasks[2].id).toBe('T003'); }); - it("should parse tasks with phases", () => { + it('should parse tasks with phases', () => { const specContent = ` \`\`\`tasks ## Phase 1: Foundation @@ -179,20 +179,20 @@ Some notes here. `; const tasks = parseTasksFromSpec(specContent); expect(tasks).toHaveLength(5); - expect(tasks[0].phase).toBe("Phase 1: Foundation"); - expect(tasks[1].phase).toBe("Phase 1: Foundation"); - expect(tasks[2].phase).toBe("Phase 2: Implementation"); - expect(tasks[3].phase).toBe("Phase 2: Implementation"); - expect(tasks[4].phase).toBe("Phase 3: Testing"); + expect(tasks[0].phase).toBe('Phase 1: Foundation'); + expect(tasks[1].phase).toBe('Phase 1: Foundation'); + expect(tasks[2].phase).toBe('Phase 2: Implementation'); + expect(tasks[3].phase).toBe('Phase 2: Implementation'); + expect(tasks[4].phase).toBe('Phase 3: Testing'); }); - it("should return empty array for content without tasks", () => { - const specContent = "Just some text without any tasks"; + it('should return empty array for content without tasks', () => { + const specContent = 'Just some text without any tasks'; const tasks = parseTasksFromSpec(specContent); expect(tasks).toEqual([]); }); - it("should fallback to finding task lines outside code block", () => { + it('should fallback to finding task lines outside code block', () => { const specContent = ` ## Implementation Plan @@ -201,11 +201,11 @@ Some notes here. `; const tasks = parseTasksFromSpec(specContent); expect(tasks).toHaveLength(2); - expect(tasks[0].id).toBe("T001"); - expect(tasks[1].id).toBe("T002"); + expect(tasks[0].id).toBe('T001'); + expect(tasks[1].id).toBe('T002'); }); - it("should handle empty tasks block", () => { + it('should handle empty tasks block', () => { const specContent = ` \`\`\`tasks \`\`\` @@ -214,7 +214,7 @@ Some notes here. expect(tasks).toEqual([]); }); - it("should handle mixed valid and invalid lines", () => { + it('should handle mixed valid and invalid lines', () => { const specContent = ` \`\`\`tasks - [ ] T001: Valid task | File: src/valid.ts @@ -227,7 +227,7 @@ Some other text expect(tasks).toHaveLength(2); }); - it("should preserve task order", () => { + it('should preserve task order', () => { const specContent = ` \`\`\`tasks - [ ] T003: Third @@ -236,12 +236,12 @@ Some other text \`\`\` `; const tasks = parseTasksFromSpec(specContent); - expect(tasks[0].id).toBe("T003"); - expect(tasks[1].id).toBe("T001"); - expect(tasks[2].id).toBe("T002"); + expect(tasks[0].id).toBe('T003'); + expect(tasks[1].id).toBe('T001'); + expect(tasks[2].id).toBe('T002'); }); - it("should handle task IDs with different numbers", () => { + it('should handle task IDs with different numbers', () => { const specContent = ` \`\`\`tasks - [ ] T001: First @@ -251,14 +251,14 @@ Some other text `; const tasks = parseTasksFromSpec(specContent); expect(tasks).toHaveLength(3); - expect(tasks[0].id).toBe("T001"); - expect(tasks[1].id).toBe("T010"); - expect(tasks[2].id).toBe("T100"); + expect(tasks[0].id).toBe('T001'); + expect(tasks[1].id).toBe('T010'); + expect(tasks[2].id).toBe('T100'); }); }); - describe("spec content generation patterns", () => { - it("should match the expected lite mode output format", () => { + describe('spec content generation patterns', () => { + it('should match the expected lite mode output format', () => { const liteModeOutput = ` 1. **Goal**: Implement user registration 2. **Approach**: Create form component, add validation, connect to API @@ -271,12 +271,12 @@ Some other text [PLAN_GENERATED] Planning outline complete. `; - expect(liteModeOutput).toContain("[PLAN_GENERATED]"); - expect(liteModeOutput).toContain("Goal"); - expect(liteModeOutput).toContain("Approach"); + expect(liteModeOutput).toContain('[PLAN_GENERATED]'); + expect(liteModeOutput).toContain('Goal'); + expect(liteModeOutput).toContain('Approach'); }); - it("should match the expected spec mode output format", () => { + it('should match the expected spec mode output format', () => { const specModeOutput = ` 1. **Problem**: Users cannot register for accounts @@ -300,12 +300,12 @@ Some other text [SPEC_GENERATED] Please review the specification above. `; - expect(specModeOutput).toContain("[SPEC_GENERATED]"); - expect(specModeOutput).toContain("```tasks"); - expect(specModeOutput).toContain("T001"); + expect(specModeOutput).toContain('[SPEC_GENERATED]'); + expect(specModeOutput).toContain('```tasks'); + expect(specModeOutput).toContain('T001'); }); - it("should match the expected full mode output format", () => { + it('should match the expected full mode output format', () => { const fullModeOutput = ` 1. **Problem Statement**: Users need ability to create accounts @@ -336,10 +336,10 @@ Some other text [SPEC_GENERATED] Please review the comprehensive specification above. `; - expect(fullModeOutput).toContain("Phase 1"); - expect(fullModeOutput).toContain("Phase 2"); - expect(fullModeOutput).toContain("Phase 3"); - expect(fullModeOutput).toContain("[SPEC_GENERATED]"); + expect(fullModeOutput).toContain('Phase 1'); + expect(fullModeOutput).toContain('Phase 2'); + expect(fullModeOutput).toContain('Phase 3'); + expect(fullModeOutput).toContain('[SPEC_GENERATED]'); }); }); }); diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts index b20e8047..44e823b0 100644 --- a/apps/server/tests/unit/services/terminal-service.test.ts +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { TerminalService, getTerminalService } from "@/services/terminal-service.js"; -import * as pty from "node-pty"; -import * as os from "os"; -import * as fs from "fs"; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TerminalService, getTerminalService } from '@/services/terminal-service.js'; +import * as pty from 'node-pty'; +import * as os from 'os'; +import * as fs from 'fs'; -vi.mock("node-pty"); -vi.mock("fs"); -vi.mock("os"); +vi.mock('node-pty'); +vi.mock('fs'); +vi.mock('os'); -describe("terminal-service.ts", () => { +describe('terminal-service.ts', () => { let service: TerminalService; let mockPtyProcess: any; @@ -26,225 +26,225 @@ describe("terminal-service.ts", () => { }; vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess); - vi.mocked(os.homedir).mockReturnValue("/home/user"); - vi.mocked(os.platform).mockReturnValue("linux"); - vi.mocked(os.arch).mockReturnValue("x64"); + vi.mocked(os.homedir).mockReturnValue('/home/user'); + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.arch).mockReturnValue('x64'); }); afterEach(() => { service.cleanup(); }); - describe("detectShell", () => { - it("should detect PowerShell Core on Windows when available", () => { - vi.mocked(os.platform).mockReturnValue("win32"); + describe('detectShell', () => { + it('should detect PowerShell Core on Windows when available', () => { + vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(fs.existsSync).mockImplementation((path: any) => { - return path === "C:\\Program Files\\PowerShell\\7\\pwsh.exe"; + return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; }); const result = service.detectShell(); - expect(result.shell).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe"); + expect(result.shell).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe'); expect(result.args).toEqual([]); }); - it("should fall back to PowerShell on Windows if Core not available", () => { - vi.mocked(os.platform).mockReturnValue("win32"); + it('should fall back to PowerShell on Windows if Core not available', () => { + vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(fs.existsSync).mockImplementation((path: any) => { - return path === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; }); const result = service.detectShell(); - expect(result.shell).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + expect(result.shell).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); expect(result.args).toEqual([]); }); - it("should fall back to cmd.exe on Windows if no PowerShell", () => { - vi.mocked(os.platform).mockReturnValue("win32"); + it('should fall back to cmd.exe on Windows if no PowerShell', () => { + vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(fs.existsSync).mockReturnValue(false); const result = service.detectShell(); - expect(result.shell).toBe("cmd.exe"); + expect(result.shell).toBe('cmd.exe'); expect(result.args).toEqual([]); }); - it("should detect user shell on macOS", () => { - vi.mocked(os.platform).mockReturnValue("darwin"); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/zsh" }); + it('should detect user shell on macOS', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' }); vi.mocked(fs.existsSync).mockReturnValue(true); const result = service.detectShell(); - expect(result.shell).toBe("/bin/zsh"); - expect(result.args).toEqual(["--login"]); + expect(result.shell).toBe('/bin/zsh'); + expect(result.args).toEqual(['--login']); }); - it("should fall back to zsh on macOS if user shell not available", () => { - vi.mocked(os.platform).mockReturnValue("darwin"); - vi.spyOn(process, "env", "get").mockReturnValue({}); + it('should fall back to zsh on macOS if user shell not available', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); vi.mocked(fs.existsSync).mockImplementation((path: any) => { - return path === "/bin/zsh"; + return path === '/bin/zsh'; }); const result = service.detectShell(); - expect(result.shell).toBe("/bin/zsh"); - expect(result.args).toEqual(["--login"]); + expect(result.shell).toBe('/bin/zsh'); + expect(result.args).toEqual(['--login']); }); - it("should fall back to bash on macOS if zsh not available", () => { - vi.mocked(os.platform).mockReturnValue("darwin"); - vi.spyOn(process, "env", "get").mockReturnValue({}); + it('should fall back to bash on macOS if zsh not available', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); vi.mocked(fs.existsSync).mockReturnValue(false); const result = service.detectShell(); - expect(result.shell).toBe("/bin/bash"); - expect(result.args).toEqual(["--login"]); + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); }); - it("should detect user shell on Linux", () => { - vi.mocked(os.platform).mockReturnValue("linux"); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + it('should detect user shell on Linux', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); vi.mocked(fs.existsSync).mockReturnValue(true); const result = service.detectShell(); - expect(result.shell).toBe("/bin/bash"); - expect(result.args).toEqual(["--login"]); + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); }); - it("should fall back to bash on Linux if user shell not available", () => { - vi.mocked(os.platform).mockReturnValue("linux"); - vi.spyOn(process, "env", "get").mockReturnValue({}); + it('should fall back to bash on Linux if user shell not available', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); vi.mocked(fs.existsSync).mockImplementation((path: any) => { - return path === "/bin/bash"; + return path === '/bin/bash'; }); const result = service.detectShell(); - expect(result.shell).toBe("/bin/bash"); - expect(result.args).toEqual(["--login"]); + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); }); - it("should fall back to sh on Linux if bash not available", () => { - vi.mocked(os.platform).mockReturnValue("linux"); - vi.spyOn(process, "env", "get").mockReturnValue({}); + it('should fall back to sh on Linux if bash not available', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); vi.mocked(fs.existsSync).mockReturnValue(false); const result = service.detectShell(); - expect(result.shell).toBe("/bin/sh"); + expect(result.shell).toBe('/bin/sh'); expect(result.args).toEqual([]); }); - it("should detect WSL and use appropriate shell", () => { - vi.mocked(os.platform).mockReturnValue("linux"); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + it('should detect WSL and use appropriate shell', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2"); + vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); const result = service.detectShell(); - expect(result.shell).toBe("/bin/bash"); - expect(result.args).toEqual(["--login"]); + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); }); }); - describe("isWSL", () => { - it("should return true if /proc/version contains microsoft", () => { + describe('isWSL', () => { + it('should return true if /proc/version contains microsoft', () => { vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2"); + vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-microsoft-standard-WSL2'); expect(service.isWSL()).toBe(true); }); - it("should return true if /proc/version contains wsl", () => { + it('should return true if /proc/version contains wsl', () => { vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-wsl2"); + vi.mocked(fs.readFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); expect(service.isWSL()).toBe(true); }); - it("should return true if WSL_DISTRO_NAME is set", () => { + it('should return true if WSL_DISTRO_NAME is set', () => { vi.mocked(fs.existsSync).mockReturnValue(false); - vi.spyOn(process, "env", "get").mockReturnValue({ WSL_DISTRO_NAME: "Ubuntu" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' }); expect(service.isWSL()).toBe(true); }); - it("should return true if WSLENV is set", () => { + it('should return true if WSLENV is set', () => { vi.mocked(fs.existsSync).mockReturnValue(false); - vi.spyOn(process, "env", "get").mockReturnValue({ WSLENV: "PATH/l" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' }); expect(service.isWSL()).toBe(true); }); - it("should return false if not in WSL", () => { + it('should return false if not in WSL', () => { vi.mocked(fs.existsSync).mockReturnValue(false); - vi.spyOn(process, "env", "get").mockReturnValue({}); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); expect(service.isWSL()).toBe(false); }); - it("should return false if error reading /proc/version", () => { + it('should return false if error reading /proc/version', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockImplementation(() => { - throw new Error("Permission denied"); + throw new Error('Permission denied'); }); expect(service.isWSL()).toBe(false); }); }); - describe("getPlatformInfo", () => { - it("should return platform information", () => { - vi.mocked(os.platform).mockReturnValue("linux"); - vi.mocked(os.arch).mockReturnValue("x64"); + describe('getPlatformInfo', () => { + it('should return platform information', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.arch).mockReturnValue('x64'); vi.mocked(fs.existsSync).mockReturnValue(true); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const info = service.getPlatformInfo(); - expect(info.platform).toBe("linux"); - expect(info.arch).toBe("x64"); - expect(info.defaultShell).toBe("/bin/bash"); - expect(typeof info.isWSL).toBe("boolean"); + expect(info.platform).toBe('linux'); + expect(info.arch).toBe('x64'); + expect(info.defaultShell).toBe('/bin/bash'); + expect(typeof info.isWSL).toBe('boolean'); }); }); - describe("createSession", () => { - it("should create a new terminal session", () => { + describe('createSession', () => { + it('should create a new terminal session', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession({ - cwd: "/test/dir", + cwd: '/test/dir', cols: 100, rows: 30, }); expect(session.id).toMatch(/^term-/); - expect(session.cwd).toBe("/test/dir"); - expect(session.shell).toBe("/bin/bash"); + expect(session.cwd).toBe('/test/dir'); + expect(session.shell).toBe('/bin/bash'); expect(pty.spawn).toHaveBeenCalledWith( - "/bin/bash", - ["--login"], + '/bin/bash', + ['--login'], expect.objectContaining({ - cwd: "/test/dir", + cwd: '/test/dir', cols: 100, rows: 30, }) ); }); - it("should use default cols and rows if not provided", () => { + it('should use default cols and rows if not provided', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); service.createSession(); @@ -258,61 +258,61 @@ describe("terminal-service.ts", () => { ); }); - it("should fall back to home directory if cwd does not exist", () => { + it('should fall back to home directory if cwd does not exist', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockImplementation(() => { - throw new Error("ENOENT"); + throw new Error('ENOENT'); }); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession({ - cwd: "/nonexistent", + cwd: '/nonexistent', }); - expect(session.cwd).toBe("/home/user"); + expect(session.cwd).toBe('/home/user'); }); - it("should fall back to home directory if cwd is not a directory", () => { + it('should fall back to home directory if cwd is not a directory', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession({ - cwd: "/file.txt", + cwd: '/file.txt', }); - expect(session.cwd).toBe("/home/user"); + expect(session.cwd).toBe('/home/user'); }); - it("should fix double slashes in path", () => { + it('should fix double slashes in path', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession({ - cwd: "//test/dir", + cwd: '//test/dir', }); - expect(session.cwd).toBe("/test/dir"); + expect(session.cwd).toBe('/test/dir'); }); - it("should preserve WSL UNC paths", () => { + it('should preserve WSL UNC paths', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession({ - cwd: "//wsl$/Ubuntu/home", + cwd: '//wsl$/Ubuntu/home', }); - expect(session.cwd).toBe("//wsl$/Ubuntu/home"); + expect(session.cwd).toBe('//wsl$/Ubuntu/home'); }); - it("should handle data events from PTY", () => { + it('should handle data events from PTY', () => { vi.useFakeTimers(); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const dataCallback = vi.fn(); service.onData(dataCallback); @@ -321,7 +321,7 @@ describe("terminal-service.ts", () => { // Simulate data event const onDataHandler = mockPtyProcess.onData.mock.calls[0][0]; - onDataHandler("test data"); + onDataHandler('test data'); // Wait for throttled output vi.advanceTimersByTime(20); @@ -331,10 +331,10 @@ describe("terminal-service.ts", () => { vi.useRealTimers(); }); - it("should handle exit events from PTY", () => { + it('should handle exit events from PTY', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const exitCallback = vi.fn(); service.onExit(exitCallback); @@ -350,32 +350,32 @@ describe("terminal-service.ts", () => { }); }); - describe("write", () => { - it("should write data to existing session", () => { + describe('write', () => { + it('should write data to existing session', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession(); - const result = service.write(session.id, "ls\n"); + const result = service.write(session.id, 'ls\n'); expect(result).toBe(true); - expect(mockPtyProcess.write).toHaveBeenCalledWith("ls\n"); + expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n'); }); - it("should return false for non-existent session", () => { - const result = service.write("nonexistent", "data"); + it('should return false for non-existent session', () => { + const result = service.write('nonexistent', 'data'); expect(result).toBe(false); expect(mockPtyProcess.write).not.toHaveBeenCalled(); }); }); - describe("resize", () => { - it("should resize existing session", () => { + describe('resize', () => { + it('should resize existing session', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession(); const result = service.resize(session.id, 120, 40); @@ -384,19 +384,19 @@ describe("terminal-service.ts", () => { expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); }); - it("should return false for non-existent session", () => { - const result = service.resize("nonexistent", 120, 40); + it('should return false for non-existent session', () => { + const result = service.resize('nonexistent', 120, 40); expect(result).toBe(false); expect(mockPtyProcess.resize).not.toHaveBeenCalled(); }); - it("should handle resize errors", () => { + it('should handle resize errors', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.resize.mockImplementation(() => { - throw new Error("Resize failed"); + throw new Error('Resize failed'); }); const session = service.createSession(); @@ -406,40 +406,40 @@ describe("terminal-service.ts", () => { }); }); - describe("killSession", () => { - it("should kill existing session", () => { + describe('killSession', () => { + it('should kill existing session', () => { vi.useFakeTimers(); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession(); const result = service.killSession(session.id); expect(result).toBe(true); - expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM"); + expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM'); // Session is removed after SIGKILL timeout (1 second) vi.advanceTimersByTime(1000); - expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGKILL"); + expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL'); expect(service.getSession(session.id)).toBeUndefined(); vi.useRealTimers(); }); - it("should return false for non-existent session", () => { - const result = service.killSession("nonexistent"); + it('should return false for non-existent session', () => { + const result = service.killSession('nonexistent'); expect(result).toBe(false); }); - it("should handle kill errors", () => { + it('should handle kill errors', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.kill.mockImplementation(() => { - throw new Error("Kill failed"); + throw new Error('Kill failed'); }); const session = service.createSession(); @@ -449,11 +449,11 @@ describe("terminal-service.ts", () => { }); }); - describe("getSession", () => { - it("should return existing session", () => { + describe('getSession', () => { + it('should return existing session', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession(); const retrieved = service.getSession(session.id); @@ -461,84 +461,84 @@ describe("terminal-service.ts", () => { expect(retrieved).toBe(session); }); - it("should return undefined for non-existent session", () => { - const retrieved = service.getSession("nonexistent"); + it('should return undefined for non-existent session', () => { + const retrieved = service.getSession('nonexistent'); expect(retrieved).toBeUndefined(); }); }); - describe("getScrollback", () => { - it("should return scrollback buffer for existing session", () => { + describe('getScrollback', () => { + it('should return scrollback buffer for existing session', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session = service.createSession(); - session.scrollbackBuffer = "test scrollback"; + session.scrollbackBuffer = 'test scrollback'; const scrollback = service.getScrollback(session.id); - expect(scrollback).toBe("test scrollback"); + expect(scrollback).toBe('test scrollback'); }); - it("should return null for non-existent session", () => { - const scrollback = service.getScrollback("nonexistent"); + it('should return null for non-existent session', () => { + const scrollback = service.getScrollback('nonexistent'); expect(scrollback).toBeNull(); }); }); - describe("getAllSessions", () => { - it("should return all active sessions", () => { + describe('getAllSessions', () => { + it('should return all active sessions', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); - const session1 = service.createSession({ cwd: "/dir1" }); - const session2 = service.createSession({ cwd: "/dir2" }); + const session1 = service.createSession({ cwd: '/dir1' }); + const session2 = service.createSession({ cwd: '/dir2' }); const sessions = service.getAllSessions(); expect(sessions).toHaveLength(2); expect(sessions[0].id).toBe(session1.id); expect(sessions[1].id).toBe(session2.id); - expect(sessions[0].cwd).toBe("/dir1"); - expect(sessions[1].cwd).toBe("/dir2"); + expect(sessions[0].cwd).toBe('/dir1'); + expect(sessions[1].cwd).toBe('/dir2'); }); - it("should return empty array if no sessions", () => { + it('should return empty array if no sessions', () => { const sessions = service.getAllSessions(); expect(sessions).toEqual([]); }); }); - describe("onData and onExit", () => { - it("should allow subscribing and unsubscribing from data events", () => { + describe('onData and onExit', () => { + it('should allow subscribing and unsubscribing from data events', () => { const callback = vi.fn(); const unsubscribe = service.onData(callback); - expect(typeof unsubscribe).toBe("function"); + expect(typeof unsubscribe).toBe('function'); unsubscribe(); }); - it("should allow subscribing and unsubscribing from exit events", () => { + it('should allow subscribing and unsubscribing from exit events', () => { const callback = vi.fn(); const unsubscribe = service.onExit(callback); - expect(typeof unsubscribe).toBe("function"); + expect(typeof unsubscribe).toBe('function'); unsubscribe(); }); }); - describe("cleanup", () => { - it("should clean up all sessions", () => { + describe('cleanup', () => { + it('should clean up all sessions', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); const session1 = service.createSession(); const session2 = service.createSession(); @@ -550,12 +550,12 @@ describe("terminal-service.ts", () => { expect(service.getAllSessions()).toHaveLength(0); }); - it("should handle cleanup errors gracefully", () => { + it('should handle cleanup errors gracefully', () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); - vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); mockPtyProcess.kill.mockImplementation(() => { - throw new Error("Kill failed"); + throw new Error('Kill failed'); }); service.createSession(); @@ -564,8 +564,8 @@ describe("terminal-service.ts", () => { }); }); - describe("getTerminalService", () => { - it("should return singleton instance", () => { + describe('getTerminalService', () => { + it('should return singleton instance', () => { const instance1 = getTerminalService(); const instance2 = getTerminalService(); diff --git a/apps/server/tests/utils/helpers.ts b/apps/server/tests/utils/helpers.ts index 9daa99ec..cf928f07 100644 --- a/apps/server/tests/utils/helpers.ts +++ b/apps/server/tests/utils/helpers.ts @@ -24,7 +24,7 @@ export async function waitFor( const start = Date.now(); while (!condition()) { if (Date.now() - start > timeout) { - throw new Error("Timeout waiting for condition"); + throw new Error('Timeout waiting for condition'); } await new Promise((resolve) => setTimeout(resolve, interval)); } diff --git a/apps/server/tests/utils/mocks.ts b/apps/server/tests/utils/mocks.ts index ce5b1457..380ac9fd 100644 --- a/apps/server/tests/utils/mocks.ts +++ b/apps/server/tests/utils/mocks.ts @@ -3,10 +3,10 @@ * Provides reusable mocks for common dependencies */ -import { vi } from "vitest"; -import type { ChildProcess } from "child_process"; -import { EventEmitter } from "events"; -import type { Readable } from "stream"; +import { vi } from 'vitest'; +import type { ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import type { Readable } from 'stream'; /** * Mock child_process.spawn for subprocess tests @@ -31,19 +31,19 @@ export function createMockChildProcess(options: { process.nextTick(() => { // Emit stdout lines for (const line of stdout) { - mockProcess.stdout.emit("data", Buffer.from(line + "\n")); + mockProcess.stdout.emit('data', Buffer.from(line + '\n')); } // Emit stderr lines for (const line of stderr) { - mockProcess.stderr.emit("data", Buffer.from(line + "\n")); + mockProcess.stderr.emit('data', Buffer.from(line + '\n')); } // Emit exit or error if (shouldError) { - mockProcess.emit("error", new Error("Process error")); + mockProcess.emit('error', new Error('Process error')); } else { - mockProcess.emit("exit", exitCode); + mockProcess.emit('exit', exitCode); } }); diff --git a/apps/ui/docs/AGENT_ARCHITECTURE.md b/apps/ui/docs/AGENT_ARCHITECTURE.md index ca5bee5e..4c9f0d11 100644 --- a/apps/ui/docs/AGENT_ARCHITECTURE.md +++ b/apps/ui/docs/AGENT_ARCHITECTURE.md @@ -90,9 +90,9 @@ const { clearHistory, // Clear conversation error, // Error state } = useElectronAgent({ - sessionId: "project_xyz", - workingDirectory: "/path/to/project", - onToolUse: (tool) => console.log("Using:", tool), + sessionId: 'project_xyz', + workingDirectory: '/path/to/project', + onToolUse: (tool) => console.log('Using:', tool), }); ``` @@ -160,7 +160,7 @@ Each session file contains: Session IDs are generated from project paths: ```typescript -const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, "_")}`; +const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}`; ``` This ensures: diff --git a/apps/ui/docs/SESSION_MANAGEMENT.md b/apps/ui/docs/SESSION_MANAGEMENT.md index 9ca27867..b4c5eac7 100644 --- a/apps/ui/docs/SESSION_MANAGEMENT.md +++ b/apps/ui/docs/SESSION_MANAGEMENT.md @@ -7,24 +7,28 @@ The Automaker Agent Chat now supports multiple concurrent sessions, allowing you ## Features ### ✨ Multiple Sessions + - Create unlimited agent sessions per project - Each session has its own conversation history - Switch between sessions instantly - Sessions persist across app restarts ### 📋 Session Organization + - Custom names for easy identification - Last message preview - Message count tracking - Sort by most recently updated ### 🗄️ Archive & Delete + - Archive old sessions to declutter - Unarchive when needed - Permanently delete sessions - Confirm before destructive actions ### 💾 Automatic Persistence + - All sessions auto-save to disk - Survive Next.js restarts - Survive Electron app restarts @@ -67,6 +71,7 @@ Click the panel icon in the header to show/hide the session manager. 4. The new session is immediately active **Example session names:** + - "Feature: Dark Mode" - "Bug: Login redirect" - "Refactor: API layer" @@ -93,6 +98,7 @@ Click the **"Clear"** button in the chat header to delete all messages from the 3. Toggle **"Show Archived"** to view archived sessions **When to archive:** + - Completed features - Resolved bugs - Old experiments @@ -117,16 +123,19 @@ Click the **"Clear"** button in the chat header to delete all messages from the Sessions are stored in your user data directory: **macOS:** + ``` ~/Library/Application Support/automaker/agent-sessions/ ``` **Windows:** + ``` %APPDATA%/automaker/agent-sessions/ ``` **Linux:** + ``` ~/.config/automaker/agent-sessions/ ``` @@ -215,12 +224,14 @@ Use prefixes to organize sessions by type: ### When to Create Multiple Sessions **Do create separate sessions for:** + - ✅ Different features - ✅ Unrelated bugs - ✅ Experimental work - ✅ Different contexts or approaches **Don't create separate sessions for:** + - ❌ Same feature, different iterations - ❌ Related bug fixes - ❌ Continuation of previous work @@ -272,7 +283,7 @@ Use prefixes to organize sessions by type: ## Keyboard Shortcuts -*(Coming soon)* +_(Coming soon)_ - `Cmd/Ctrl + K` - Create new session - `Cmd/Ctrl + [` - Previous session @@ -284,11 +295,13 @@ Use prefixes to organize sessions by type: ### Session Not Saving **Check:** + - Electron has write permissions - Disk space available - Check Electron console for errors **Solution:** + ```bash # macOS - Check permissions ls -la ~/Library/Application\ Support/automaker/ @@ -300,11 +313,13 @@ chmod -R u+w ~/Library/Application\ Support/automaker/ ### Can't Switch Sessions **Check:** + - Session is not archived - No errors in console - Agent is not currently processing **Solution:** + - Wait for current message to complete - Check for error messages - Try clearing and reloading @@ -312,11 +327,13 @@ chmod -R u+w ~/Library/Application\ Support/automaker/ ### Session Disappeared **Check:** + - Not filtered by archive status - Not accidentally deleted - Check backup files **Recovery:** + - Toggle "Show Archived" - Check filesystem for `.json` files - Restore from backup if available @@ -326,15 +343,17 @@ chmod -R u+w ~/Library/Application\ Support/automaker/ For developers integrating session management: ### Create Session + ```typescript const result = await window.electronAPI.sessions.create( - "Session Name", - "/project/path", - "/working/directory" + 'Session Name', + '/project/path', + '/working/directory' ); ``` ### List Sessions + ```typescript const { sessions } = await window.electronAPI.sessions.list( false // includeArchived @@ -342,21 +361,20 @@ const { sessions } = await window.electronAPI.sessions.list( ``` ### Update Session + ```typescript -await window.electronAPI.sessions.update( - sessionId, - "New Name", - ["tag1", "tag2"] -); +await window.electronAPI.sessions.update(sessionId, 'New Name', ['tag1', 'tag2']); ``` ### Archive/Unarchive + ```typescript await window.electronAPI.sessions.archive(sessionId); await window.electronAPI.sessions.unarchive(sessionId); ``` ### Delete Session + ```typescript await window.electronAPI.sessions.delete(sessionId); ``` diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 0b7d6f0e..d7bc54d4 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -1,111 +1,111 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import js from "@eslint/js"; -import ts from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; +import { defineConfig, globalIgnores } from 'eslint/config'; +import js from '@eslint/js'; +import ts from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; const eslintConfig = defineConfig([ js.configs.recommended, { - files: ["**/*.mjs", "**/*.cjs"], + files: ['**/*.mjs', '**/*.cjs'], languageOptions: { globals: { - console: "readonly", - process: "readonly", - require: "readonly", - __dirname: "readonly", - __filename: "readonly", + console: 'readonly', + process: 'readonly', + require: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', }, }, }, { - files: ["**/*.ts", "**/*.tsx"], + files: ['**/*.ts', '**/*.tsx'], languageOptions: { parser: tsParser, parserOptions: { - ecmaVersion: "latest", - sourceType: "module", + ecmaVersion: 'latest', + sourceType: 'module', }, globals: { // Browser/DOM APIs - window: "readonly", - document: "readonly", - navigator: "readonly", - Navigator: "readonly", - localStorage: "readonly", - sessionStorage: "readonly", - fetch: "readonly", - WebSocket: "readonly", - File: "readonly", - FileList: "readonly", - FileReader: "readonly", - Blob: "readonly", - atob: "readonly", - crypto: "readonly", - prompt: "readonly", - confirm: "readonly", - getComputedStyle: "readonly", - requestAnimationFrame: "readonly", + window: 'readonly', + document: 'readonly', + navigator: 'readonly', + Navigator: 'readonly', + localStorage: 'readonly', + sessionStorage: 'readonly', + fetch: 'readonly', + WebSocket: 'readonly', + File: 'readonly', + FileList: 'readonly', + FileReader: 'readonly', + Blob: 'readonly', + atob: 'readonly', + crypto: 'readonly', + prompt: 'readonly', + confirm: 'readonly', + getComputedStyle: 'readonly', + requestAnimationFrame: 'readonly', // DOM Element Types - HTMLElement: "readonly", - HTMLInputElement: "readonly", - HTMLDivElement: "readonly", - HTMLButtonElement: "readonly", - HTMLSpanElement: "readonly", - HTMLTextAreaElement: "readonly", - HTMLHeadingElement: "readonly", - HTMLParagraphElement: "readonly", - HTMLImageElement: "readonly", - Element: "readonly", + HTMLElement: 'readonly', + HTMLInputElement: 'readonly', + HTMLDivElement: 'readonly', + HTMLButtonElement: 'readonly', + HTMLSpanElement: 'readonly', + HTMLTextAreaElement: 'readonly', + HTMLHeadingElement: 'readonly', + HTMLParagraphElement: 'readonly', + HTMLImageElement: 'readonly', + Element: 'readonly', // Event Types - Event: "readonly", - KeyboardEvent: "readonly", - DragEvent: "readonly", - PointerEvent: "readonly", - CustomEvent: "readonly", - ClipboardEvent: "readonly", - WheelEvent: "readonly", - DataTransfer: "readonly", + Event: 'readonly', + KeyboardEvent: 'readonly', + DragEvent: 'readonly', + PointerEvent: 'readonly', + CustomEvent: 'readonly', + ClipboardEvent: 'readonly', + WheelEvent: 'readonly', + DataTransfer: 'readonly', // Web APIs - ResizeObserver: "readonly", - AbortSignal: "readonly", - Audio: "readonly", - ScrollBehavior: "readonly", + ResizeObserver: 'readonly', + AbortSignal: 'readonly', + Audio: 'readonly', + ScrollBehavior: 'readonly', // Timers - setTimeout: "readonly", - setInterval: "readonly", - clearTimeout: "readonly", - clearInterval: "readonly", + setTimeout: 'readonly', + setInterval: 'readonly', + clearTimeout: 'readonly', + clearInterval: 'readonly', // Node.js (for scripts and Electron) - process: "readonly", - require: "readonly", - __dirname: "readonly", - __filename: "readonly", - NodeJS: "readonly", + process: 'readonly', + require: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + NodeJS: 'readonly', // React - React: "readonly", - JSX: "readonly", + React: 'readonly', + JSX: 'readonly', // Electron - Electron: "readonly", + Electron: 'readonly', // Console - console: "readonly", + console: 'readonly', }, }, plugins: { - "@typescript-eslint": ts, + '@typescript-eslint': ts, }, rules: { ...ts.configs.recommended.rules, - "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], - "@typescript-eslint/no-explicit-any": "warn", + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', }, }, globalIgnores([ - "dist/**", - "dist-electron/**", - "node_modules/**", - "server-bundle/**", - "release/**", - "src/routeTree.gen.ts", + 'dist/**', + 'dist-electron/**', + 'node_modules/**', + 'server-bundle/**', + 'release/**', + 'src/routeTree.gen.ts', ]), ]); diff --git a/apps/ui/index.html b/apps/ui/index.html index 02087b01..49a7aa1e 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -8,7 +8,7 @@