diff --git a/.env.example b/.env.example index 157af45..c426100 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,6 @@ # Optional: N8N webhook for progress notifications # PROGRESS_N8N_WEBHOOK_URL=https://your-n8n-instance.com/webhook/... -# CLI Command Selection -# Choose which CLI command to use for the agent. -# - claude: Uses Anthropic's official Claude Code CLI (default) -# - glm: Uses GLM CLI (or any other compatible CLI) -# Defaults to 'claude' if not specified -# CLI_COMMAND=claude - # Playwright Browser Mode # Controls whether Playwright runs Chrome in headless mode (no visible browser window). # - true: Browser runs in background, invisible (recommended for using PC while agent works) diff --git a/README.md b/README.md index a5f6231..9af8172 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,23 @@ When test progress increases, the agent sends: } ``` +### Using GLM Models (Alternative to Claude) + +To use Zhipu AI's GLM models instead of Claude, create a settings file at `~/.claude/settings.json`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", + "ANTHROPIC_AUTH_TOKEN": "your-zhipu-api-key" + } +} +``` + +This routes Claude Code requests through Zhipu's Claude-compatible API, allowing you to use GLM-4.7 and other models while keeping all Claude Code features (MCP servers, hooks, permissions). + +Get an API key at: https://z.ai/subscribe + --- ## Customization diff --git a/client.py b/client.py index c058276..f232a0a 100644 --- a/client.py +++ b/client.py @@ -20,26 +20,12 @@ from security import bash_security_hook # Load environment variables from .env file if present load_dotenv() -# Default CLI command - can be overridden via CLI_COMMAND environment variable -# Common values: "claude" (default), "glm" -DEFAULT_CLI_COMMAND = "claude" - # Default Playwright headless mode - can be overridden via PLAYWRIGHT_HEADLESS env var # When True, browser runs invisibly in background # When False, browser window is visible (default - useful for monitoring agent progress) DEFAULT_PLAYWRIGHT_HEADLESS = False -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", DEFAULT_CLI_COMMAND) - - def get_playwright_headless() -> bool: """ Get the Playwright headless mode setting. @@ -187,14 +173,12 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False): print(" - Project settings enabled (skills, commands, CLAUDE.md)") print() - # Use system CLI instead of bundled one (avoids Bun runtime crash on Windows) - # CLI command is configurable via CLI_COMMAND environment variable - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Use system Claude CLI instead of bundled one (avoids Bun runtime crash on Windows) + system_cli = shutil.which("claude") if system_cli: print(f" - Using system CLI: {system_cli}") else: - print(f" - Warning: System CLI '{cli_command}' not found, using bundled CLI") + print(" - Warning: System 'claude' CLI not found, using bundled CLI") # Build MCP servers config - features is always included, playwright only in standard mode mcp_servers = { diff --git a/server/main.py b/server/main.py index 1c40868..8be2a50 100644 --- a/server/main.py +++ b/server/main.py @@ -6,7 +6,6 @@ Main entry point for the Autonomous Coding UI server. Provides REST API, WebSocket, and static file serving. """ -import os import shutil from contextlib import asynccontextmanager from pathlib import Path @@ -16,16 +15,6 @@ from dotenv import load_dotenv # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - from fastapi import FastAPI, HTTPException, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse @@ -152,9 +141,8 @@ async def health_check(): @app.get("/api/setup/status", response_model=SetupStatus) async def setup_status(): """Check system setup status.""" - # Check for CLI (configurable via CLI_COMMAND environment variable) - cli_command = get_cli_command() - claude_cli = shutil.which(cli_command) is not None + # Check for Claude CLI + claude_cli = shutil.which("claude") is not None # Check for CLI configuration directory # Note: CLI no longer stores credentials in ~/.claude/.credentials.json diff --git a/server/routers/terminal.py b/server/routers/terminal.py index 196e69f..2183369 100644 --- a/server/routers/terminal.py +++ b/server/routers/terminal.py @@ -2,8 +2,9 @@ Terminal Router =============== -WebSocket endpoint for interactive terminal I/O with PTY support. +REST and WebSocket endpoints for interactive terminal I/O with PTY support. Provides real-time bidirectional communication with terminal sessions. +Supports multiple terminals per project with create, list, rename, delete operations. """ import asyncio @@ -14,9 +15,18 @@ import re import sys from pathlib import Path -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from pydantic import BaseModel -from ..services.terminal_manager import get_terminal_session +from ..services.terminal_manager import ( + create_terminal, + delete_terminal, + get_terminal_info, + get_terminal_session, + list_terminals, + rename_terminal, + stop_terminal_session, +) # Add project root to path for registry import _root = Path(__file__).parent.parent.parent @@ -59,8 +69,170 @@ def validate_project_name(name: str) -> bool: return bool(re.match(r"^[a-zA-Z0-9_-]{1,50}$", name)) -@router.websocket("/ws/{project_name}") -async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: +def validate_terminal_id(terminal_id: str) -> bool: + """ + Validate terminal ID format. + + Args: + terminal_id: The terminal ID to validate + + Returns: + True if valid, False otherwise + """ + return bool(re.match(r"^[a-zA-Z0-9]{1,16}$", terminal_id)) + + +# Pydantic models for request/response bodies +class CreateTerminalRequest(BaseModel): + """Request body for creating a terminal.""" + + name: str | None = None + + +class RenameTerminalRequest(BaseModel): + """Request body for renaming a terminal.""" + + name: str + + +class TerminalInfoResponse(BaseModel): + """Response model for terminal info.""" + + id: str + name: str + created_at: str + + +# REST Endpoints + + +@router.get("/{project_name}") +async def list_project_terminals(project_name: str) -> list[TerminalInfoResponse]: + """ + List all terminals for a project. + + Args: + project_name: Name of the project + + Returns: + List of terminal info objects + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + terminals = list_terminals(project_name) + + # If no terminals exist, create a default one + if not terminals: + info = create_terminal(project_name) + terminals = [info] + + return [ + TerminalInfoResponse(id=t.id, name=t.name, created_at=t.created_at) for t in terminals + ] + + +@router.post("/{project_name}") +async def create_project_terminal( + project_name: str, request: CreateTerminalRequest +) -> TerminalInfoResponse: + """ + Create a new terminal for a project. + + Args: + project_name: Name of the project + request: Request body with optional terminal name + + Returns: + The created terminal info + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + info = create_terminal(project_name, request.name) + return TerminalInfoResponse(id=info.id, name=info.name, created_at=info.created_at) + + +@router.patch("/{project_name}/{terminal_id}") +async def rename_project_terminal( + project_name: str, terminal_id: str, request: RenameTerminalRequest +) -> TerminalInfoResponse: + """ + Rename a terminal. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to rename + request: Request body with new name + + Returns: + The updated terminal info + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + if not validate_terminal_id(terminal_id): + raise HTTPException(status_code=400, detail="Invalid terminal ID") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + if not rename_terminal(project_name, terminal_id, request.name): + raise HTTPException(status_code=404, detail="Terminal not found") + + info = get_terminal_info(project_name, terminal_id) + if not info: + raise HTTPException(status_code=404, detail="Terminal not found") + + return TerminalInfoResponse(id=info.id, name=info.name, created_at=info.created_at) + + +@router.delete("/{project_name}/{terminal_id}") +async def delete_project_terminal(project_name: str, terminal_id: str) -> dict: + """ + Delete a terminal and stop its session. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to delete + + Returns: + Success message + """ + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + if not validate_terminal_id(terminal_id): + raise HTTPException(status_code=400, detail="Invalid terminal ID") + + project_dir = _get_project_path(project_name) + if not project_dir: + raise HTTPException(status_code=404, detail="Project not found") + + # Stop the session if it's running + await stop_terminal_session(project_name, terminal_id) + + # Delete the terminal metadata + if not delete_terminal(project_name, terminal_id): + raise HTTPException(status_code=404, detail="Terminal not found") + + return {"message": "Terminal deleted"} + + +# WebSocket Endpoint + + +@router.websocket("/ws/{project_name}/{terminal_id}") +async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_id: str) -> None: """ WebSocket endpoint for interactive terminal I/O. @@ -84,6 +256,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: ) return + # Validate terminal ID + if not validate_terminal_id(terminal_id): + await websocket.close( + code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID" + ) + return + # Look up project directory from registry project_dir = _get_project_path(project_name) if not project_dir: @@ -100,10 +279,19 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: ) return + # Verify terminal exists in metadata + terminal_info = get_terminal_info(project_name, terminal_id) + if not terminal_info: + await websocket.close( + code=TerminalCloseCode.PROJECT_NOT_FOUND, + reason="Terminal not found", + ) + return + await websocket.accept() - # Get or create terminal session for this project - session = get_terminal_session(project_name, project_dir) + # Get or create terminal session for this project/terminal + session = get_terminal_session(project_name, project_dir, terminal_id) # Queue for output data to send to client output_queue: asyncio.Queue[bytes] = asyncio.Queue() @@ -119,21 +307,9 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: # Register the output callback session.add_output_callback(on_output) - # Start the terminal session if not already active - if not session.is_active: - started = await session.start() - if not started: - session.remove_output_callback(on_output) - try: - await websocket.send_json( - {"type": "error", "message": "Failed to start terminal session"} - ) - except Exception: - pass - await websocket.close( - code=TerminalCloseCode.FAILED_TO_START, reason="Failed to start terminal" - ) - return + # Track if we need to wait for initial resize before starting + # This ensures the PTY is created with correct dimensions from the start + needs_initial_resize = not session.is_active # Task to send queued output to WebSocket async def send_output_task() -> None: @@ -159,6 +335,11 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: async def monitor_exit_task() -> None: """Monitor the terminal session and notify client on exit.""" try: + # Wait for session to become active first (deferred start) + while not session.is_active: + await asyncio.sleep(0.1) + + # Now monitor until it becomes inactive while session.is_active: await asyncio.sleep(0.5) @@ -189,6 +370,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: await websocket.send_json({"type": "pong"}) elif msg_type == "input": + # Only allow input after terminal is started + if not session.is_active: + await websocket.send_json( + {"type": "error", "message": "Terminal not ready - send resize first"} + ) + continue + # Decode base64 input and write to PTY encoded_data = message.get("data", "") # Add size limit to prevent DoS @@ -222,7 +410,27 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: if isinstance(cols, int) and isinstance(rows, int): cols = max(10, min(500, cols)) rows = max(5, min(200, rows)) - session.resize(cols, rows) + + # If this is the first resize and session not started, start with these dimensions + # This ensures the PTY is created with correct size from the beginning + if needs_initial_resize and not session.is_active: + started = await session.start(cols=cols, rows=rows) + if not started: + session.remove_output_callback(on_output) + try: + await websocket.send_json( + {"type": "error", "message": "Failed to start terminal session"} + ) + except Exception: + pass + await websocket.close( + code=TerminalCloseCode.FAILED_TO_START, reason="Failed to start terminal" + ) + return + # Mark that we no longer need initial resize + needs_initial_resize = False + else: + session.resize(cols, rows) else: await websocket.send_json({"type": "error", "message": "Invalid resize dimensions"}) @@ -233,10 +441,10 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: await websocket.send_json({"type": "error", "message": "Invalid JSON"}) except WebSocketDisconnect: - logger.info(f"Terminal WebSocket disconnected for {project_name}") + logger.info(f"Terminal WebSocket disconnected for {project_name}/{terminal_id}") except Exception as e: - logger.exception(f"Terminal WebSocket error for {project_name}") + logger.exception(f"Terminal WebSocket error for {project_name}/{terminal_id}") try: await websocket.send_json({"type": "error", "message": f"Server error: {str(e)}"}) except Exception: @@ -266,8 +474,8 @@ async def terminal_websocket(websocket: WebSocket, project_name: str) -> None: if remaining_callbacks == 0: await session.stop() - logger.info(f"Terminal session stopped for {project_name} (last client disconnected)") + logger.info(f"Terminal session stopped for {project_name}/{terminal_id} (last client disconnected)") else: logger.info( - f"Client disconnected from {project_name}, {remaining_callbacks} clients remaining" + f"Client disconnected from {project_name}/{terminal_id}, {remaining_callbacks} clients remaining" ) diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index bebed94..9e067f1 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -28,17 +28,6 @@ from .assistant_database import ( # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - - logger = logging.getLogger(__name__) # Root directory of the project @@ -242,9 +231,8 @@ class AssistantChatSession: # Get system prompt with project context system_prompt = get_system_prompt(self.project_name, self.project_dir) - # Use system CLI (configurable via CLI_COMMAND environment variable) - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Use system Claude CLI + system_cli = shutil.which("claude") try: self.client = ClaudeSDKClient( diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 659c776..b187804 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -9,7 +9,6 @@ Uses the expand-project.md skill to help users add features to existing projects import asyncio import json import logging -import os import re import shutil import threading @@ -26,16 +25,6 @@ from ..schemas import ImageAttachment # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - logger = logging.getLogger(__name__) @@ -135,14 +124,12 @@ class ExpandChatSession: except UnicodeDecodeError: skill_content = skill_path.read_text(encoding="utf-8", errors="replace") - # Find and validate CLI before creating temp files - # CLI command is configurable via CLI_COMMAND environment variable - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Find and validate Claude CLI before creating temp files + system_cli = shutil.which("claude") if not system_cli: yield { "type": "error", - "content": f"CLI '{cli_command}' not found. Please install it or check your CLI_COMMAND setting." + "content": "Claude CLI not found. Please install it: npm install -g @anthropic-ai/claude-code" } return diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index 7cb2beb..b3b4e1c 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -8,7 +8,6 @@ Uses the create-spec.md skill to guide users through app spec creation. import json import logging -import os import shutil import threading from datetime import datetime @@ -23,16 +22,6 @@ from ..schemas import ImageAttachment # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - logger = logging.getLogger(__name__) @@ -156,10 +145,8 @@ class SpecChatSession: # Create Claude SDK client with limited tools for spec creation # Use Opus for best quality spec generation - # Use system CLI to avoid bundled Bun runtime crash (exit code 3) on Windows - # CLI command is configurable via CLI_COMMAND environment variable - cli_command = get_cli_command() - system_cli = shutil.which(cli_command) + # Use system Claude CLI to avoid bundled Bun runtime crash (exit code 3) on Windows + system_cli = shutil.which("claude") try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( diff --git a/server/services/terminal_manager.py b/server/services/terminal_manager.py index f9264ff..09abfa2 100644 --- a/server/services/terminal_manager.py +++ b/server/services/terminal_manager.py @@ -12,11 +12,24 @@ import os import platform import shutil import threading +import uuid +from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import Callable, Set logger = logging.getLogger(__name__) + +@dataclass +class TerminalInfo: + """Metadata for a terminal instance.""" + + id: str + name: str + created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + + # Platform detection IS_WINDOWS = platform.system() == "Windows" @@ -506,39 +519,214 @@ class TerminalSession: # Global registry of terminal sessions per project with thread safety -_sessions: dict[str, TerminalSession] = {} +# Structure: Dict[project_name, Dict[terminal_id, TerminalSession]] +_sessions: dict[str, dict[str, TerminalSession]] = {} _sessions_lock = threading.Lock() +# Terminal metadata registry (in-memory, resets on server restart) +# Structure: Dict[project_name, List[TerminalInfo]] +_terminal_metadata: dict[str, list[TerminalInfo]] = {} +_metadata_lock = threading.Lock() -def get_terminal_session(project_name: str, project_dir: Path) -> TerminalSession: + +def create_terminal(project_name: str, name: str | None = None) -> TerminalInfo: + """ + Create a new terminal entry for a project. + + Args: + project_name: Name of the project + name: Optional terminal name (auto-generated if not provided) + + Returns: + TerminalInfo for the new terminal + """ + with _metadata_lock: + if project_name not in _terminal_metadata: + _terminal_metadata[project_name] = [] + + terminals = _terminal_metadata[project_name] + + # Auto-generate name if not provided + if name is None: + existing_nums = [] + for t in terminals: + if t.name.startswith("Terminal "): + try: + num = int(t.name.replace("Terminal ", "")) + existing_nums.append(num) + except ValueError: + pass + next_num = max(existing_nums, default=0) + 1 + name = f"Terminal {next_num}" + + terminal_id = str(uuid.uuid4())[:8] + info = TerminalInfo(id=terminal_id, name=name) + terminals.append(info) + + logger.info(f"Created terminal '{name}' (ID: {terminal_id}) for project {project_name}") + return info + + +def list_terminals(project_name: str) -> list[TerminalInfo]: + """ + List all terminals for a project. + + Args: + project_name: Name of the project + + Returns: + List of TerminalInfo for the project + """ + with _metadata_lock: + return list(_terminal_metadata.get(project_name, [])) + + +def rename_terminal(project_name: str, terminal_id: str, new_name: str) -> bool: + """ + Rename a terminal. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to rename + new_name: New name for the terminal + + Returns: + True if renamed successfully, False if terminal not found + """ + with _metadata_lock: + terminals = _terminal_metadata.get(project_name, []) + for terminal in terminals: + if terminal.id == terminal_id: + old_name = terminal.name + terminal.name = new_name + logger.info( + f"Renamed terminal '{old_name}' to '{new_name}' " + f"(ID: {terminal_id}) for project {project_name}" + ) + return True + return False + + +def delete_terminal(project_name: str, terminal_id: str) -> bool: + """ + Delete a terminal and stop its session if active. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal to delete + + Returns: + True if deleted, False if not found + """ + # Remove from metadata + with _metadata_lock: + terminals = _terminal_metadata.get(project_name, []) + for i, terminal in enumerate(terminals): + if terminal.id == terminal_id: + terminals.pop(i) + logger.info( + f"Deleted terminal '{terminal.name}' (ID: {terminal_id}) " + f"for project {project_name}" + ) + break + else: + return False + + # Remove session if exists (will be stopped async by caller) + with _sessions_lock: + project_sessions = _sessions.get(project_name, {}) + if terminal_id in project_sessions: + del project_sessions[terminal_id] + + return True + + +def get_terminal_session( + project_name: str, project_dir: Path, terminal_id: str | None = None +) -> TerminalSession: """ Get or create a terminal session for a project (thread-safe). Args: project_name: Name of the project project_dir: Absolute path to the project directory + terminal_id: ID of the terminal (creates default if not provided) Returns: - TerminalSession instance for the project + TerminalSession instance for the project/terminal """ + # Ensure terminal metadata exists + if terminal_id is None: + # Create default terminal if none exists + terminals = list_terminals(project_name) + if not terminals: + info = create_terminal(project_name) + terminal_id = info.id + else: + terminal_id = terminals[0].id + with _sessions_lock: if project_name not in _sessions: - _sessions[project_name] = TerminalSession(project_name, project_dir) - return _sessions[project_name] + _sessions[project_name] = {} + + project_sessions = _sessions[project_name] + if terminal_id not in project_sessions: + project_sessions[terminal_id] = TerminalSession(project_name, project_dir) + + return project_sessions[terminal_id] -def remove_terminal_session(project_name: str) -> TerminalSession | None: +def remove_terminal_session(project_name: str, terminal_id: str) -> TerminalSession | None: """ Remove a terminal session from the registry. Args: project_name: Name of the project + terminal_id: ID of the terminal Returns: The removed session, or None if not found """ with _sessions_lock: - return _sessions.pop(project_name, None) + project_sessions = _sessions.get(project_name, {}) + return project_sessions.pop(terminal_id, None) + + +def get_terminal_info(project_name: str, terminal_id: str) -> TerminalInfo | None: + """ + Get terminal info by ID. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal + + Returns: + TerminalInfo if found, None otherwise + """ + with _metadata_lock: + terminals = _terminal_metadata.get(project_name, []) + for terminal in terminals: + if terminal.id == terminal_id: + return terminal + return None + + +async def stop_terminal_session(project_name: str, terminal_id: str) -> bool: + """ + Stop a specific terminal session. + + Args: + project_name: Name of the project + terminal_id: ID of the terminal + + Returns: + True if stopped, False if not found + """ + session = remove_terminal_session(project_name, terminal_id) + if session and session.is_active: + await session.stop() + return True + return False async def cleanup_all_terminals() -> None: @@ -548,9 +736,11 @@ async def cleanup_all_terminals() -> None: Called on server shutdown to ensure all PTY processes are terminated. """ with _sessions_lock: - sessions = list(_sessions.values()) + all_sessions = [] + for project_sessions in _sessions.values(): + all_sessions.extend(project_sessions.values()) - for session in sessions: + for session in all_sessions: try: if session.is_active: await session.stop() @@ -560,4 +750,7 @@ async def cleanup_all_terminals() -> None: with _sessions_lock: _sessions.clear() + with _metadata_lock: + _terminal_metadata.clear() + logger.info("All terminal sessions cleaned up") diff --git a/start.py b/start.py index df97909..a230d13 100644 --- a/start.py +++ b/start.py @@ -20,17 +20,6 @@ from auth import is_auth_error, print_auth_error_help # Load environment variables from .env file if present load_dotenv() - -def get_cli_command() -> str: - """ - Get the CLI command to use for the agent. - - Reads from CLI_COMMAND environment variable, defaults to 'claude'. - This allows users to use alternative CLIs like 'glm'. - """ - return os.getenv("CLI_COMMAND", "claude") - - from prompts import ( get_project_prompts_dir, has_project_prompts, @@ -237,9 +226,8 @@ def run_spec_creation(project_dir: Path) -> bool: # Launch CLI with /create-spec command # Project path included in command string so it populates $ARGUMENTS # Capture stderr to detect auth errors while letting stdout flow to terminal - cli_command = get_cli_command() result = subprocess.run( - [cli_command, f"/create-spec {project_dir}"], + ["claude", f"/create-spec {project_dir}"], check=False, # Don't raise on non-zero exit cwd=str(Path(__file__).parent), # Run from project root stderr=subprocess.PIPE, @@ -267,17 +255,13 @@ def run_spec_creation(project_dir: Path) -> bool: print(f"Please ensure app_spec.txt exists in: {get_project_prompts_dir(project_dir)}") # If failed with non-zero exit and no spec, might be auth issue if result.returncode != 0: - print(f"\nIf you're having authentication issues, try running: {cli_command} login") + print("\nIf you're having authentication issues, try running: claude login") return False except FileNotFoundError: - cli_command = get_cli_command() - print(f"\nError: '{cli_command}' command not found.") - if cli_command == "claude": - print("Make sure Claude Code CLI is installed:") - print(" npm install -g @anthropic-ai/claude-code") - else: - print(f"Make sure the '{cli_command}' CLI is installed and in your PATH.") + print("\nError: 'claude' command not found.") + print("Make sure Claude Code CLI is installed:") + print(" npm install -g @anthropic-ai/claude-code") return False except KeyboardInterrupt: print("\n\nSpec creation cancelled.") @@ -429,8 +413,7 @@ def run_agent(project_name: str, project_dir: Path) -> None: print(f"\nAgent error:\n{stderr_output.strip()}") # Still hint about auth if exit was unexpected if "error" in stderr_output.lower() or "exception" in stderr_output.lower(): - cli_command = get_cli_command() - print(f"\nIf this is an authentication issue, try running: {cli_command} login") + print("\nIf this is an authentication issue, try running: claude login") except KeyboardInterrupt: print("\n\nAgent interrupted. Run again to resume.") diff --git a/ui/src/components/DebugLogViewer.tsx b/ui/src/components/DebugLogViewer.tsx index 727fa4b..40c07fc 100644 --- a/ui/src/components/DebugLogViewer.tsx +++ b/ui/src/components/DebugLogViewer.tsx @@ -9,6 +9,9 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server } from 'lucide-react' import { Terminal } from './Terminal' +import { TerminalTabs } from './TerminalTabs' +import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api' +import type { TerminalInfo } from '@/lib/types' const MIN_HEIGHT = 150 const MAX_HEIGHT = 600 @@ -61,6 +64,11 @@ export function DebugLogViewer({ return (saved as TabType) || 'agent' }) + // Terminal management state + const [terminals, setTerminals] = useState([]) + const [activeTerminalId, setActiveTerminalId] = useState(null) + const [isLoadingTerminals, setIsLoadingTerminals] = useState(false) + // Use controlled tab if provided, otherwise use internal state const activeTab = controlledActiveTab ?? internalActiveTab const setActiveTab = (tab: TabType) => { @@ -69,6 +77,91 @@ export function DebugLogViewer({ onTabChange?.(tab) } + // Fetch terminals for the project + const fetchTerminals = useCallback(async () => { + if (!projectName) return + + setIsLoadingTerminals(true) + try { + const terminalList = await listTerminals(projectName) + setTerminals(terminalList) + + // Set active terminal to first one if not set or current one doesn't exist + if (terminalList.length > 0) { + if (!activeTerminalId || !terminalList.find((t) => t.id === activeTerminalId)) { + setActiveTerminalId(terminalList[0].id) + } + } + } catch (err) { + console.error('Failed to fetch terminals:', err) + } finally { + setIsLoadingTerminals(false) + } + }, [projectName, activeTerminalId]) + + // Handle creating a new terminal + const handleCreateTerminal = useCallback(async () => { + if (!projectName) return + + try { + const newTerminal = await createTerminal(projectName) + setTerminals((prev) => [...prev, newTerminal]) + setActiveTerminalId(newTerminal.id) + } catch (err) { + console.error('Failed to create terminal:', err) + } + }, [projectName]) + + // Handle renaming a terminal + const handleRenameTerminal = useCallback( + async (terminalId: string, newName: string) => { + if (!projectName) return + + try { + const updated = await renameTerminal(projectName, terminalId, newName) + setTerminals((prev) => + prev.map((t) => (t.id === terminalId ? updated : t)) + ) + } catch (err) { + console.error('Failed to rename terminal:', err) + } + }, + [projectName] + ) + + // Handle closing a terminal + const handleCloseTerminal = useCallback( + async (terminalId: string) => { + if (!projectName || terminals.length <= 1) return + + try { + await deleteTerminal(projectName, terminalId) + setTerminals((prev) => prev.filter((t) => t.id !== terminalId)) + + // If we closed the active terminal, switch to another one + if (activeTerminalId === terminalId) { + const remaining = terminals.filter((t) => t.id !== terminalId) + if (remaining.length > 0) { + setActiveTerminalId(remaining[0].id) + } + } + } catch (err) { + console.error('Failed to close terminal:', err) + } + }, + [projectName, terminals, activeTerminalId] + ) + + // Fetch terminals when project changes + useEffect(() => { + if (projectName) { + fetchTerminals() + } else { + setTerminals([]) + setActiveTerminalId(null) + } + }, [projectName]) // eslint-disable-line react-hooks/exhaustive-deps + // Auto-scroll to bottom when new agent logs arrive (if user hasn't scrolled up) useEffect(() => { if (autoScroll && scrollRef.current && isOpen && activeTab === 'agent') { @@ -429,10 +522,61 @@ export function DebugLogViewer({ {/* Terminal Tab */} {activeTab === 'terminal' && ( - +
+ {/* Terminal tabs bar */} + {terminals.length > 0 && ( + + )} + + {/* Terminal content - render all terminals and show/hide to preserve buffers */} +
+ {isLoadingTerminals ? ( +
+ Loading terminals... +
+ ) : terminals.length === 0 ? ( +
+ No terminal available +
+ ) : ( + /* Render all terminals stacked on top of each other. + * Active terminal is visible and receives input. + * Inactive terminals are moved off-screen with transform to: + * 1. Trigger IntersectionObserver (xterm.js pauses rendering) + * 2. Preserve terminal buffer content + * 3. Allow proper dimension calculation when becoming visible + * Using transform instead of opacity/display:none for best xterm.js compatibility. + */ + terminals.map((terminal) => { + const isActiveTerminal = terminal.id === activeTerminalId + return ( +
+ +
+ ) + }) + )} +
+
)} )} diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index 0d8ec28..69b6fcb 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -12,6 +12,7 @@ import '@xterm/xterm/css/xterm.css' interface TerminalProps { projectName: string + terminalId: string isActive: boolean } @@ -69,7 +70,7 @@ const TERMINAL_THEME = { const RECONNECT_DELAY_BASE = 1000 const RECONNECT_DELAY_MAX = 30000 -export function Terminal({ projectName, isActive }: TerminalProps) { +export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { const containerRef = useRef(null) const terminalRef = useRef(null) const fitAddonRef = useRef(null) @@ -83,8 +84,11 @@ export function Terminal({ projectName, isActive }: TerminalProps) { const isManualCloseRef = useRef(false) // Store connect function in ref to avoid useEffect dependency issues const connectRef = useRef<(() => void) | null>(null) - // Track last project to avoid duplicate connect on initial activation + // Track last project/terminal to avoid duplicate connect on initial activation const lastProjectRef = useRef(null) + const lastTerminalIdRef = useRef(null) + // Track isActive in a ref to avoid stale closure issues in connect() + const isActiveRef = useRef(isActive) const [isConnected, setIsConnected] = useState(false) const [hasExited, setHasExited] = useState(false) @@ -95,6 +99,11 @@ export function Terminal({ projectName, isActive }: TerminalProps) { hasExitedRef.current = hasExited }, [hasExited]) + // Keep isActiveRef in sync with isActive prop to avoid stale closures + useEffect(() => { + isActiveRef.current = isActive + }, [isActive]) + /** * Encode string to base64 */ @@ -160,9 +169,27 @@ export function Terminal({ projectName, isActive }: TerminalProps) { const fitTerminal = useCallback(() => { if (fitAddonRef.current && terminalRef.current) { try { - fitAddonRef.current.fit() + // Try to get proposed dimensions first + const dimensions = fitAddonRef.current.proposeDimensions() + const hasValidDimensions = dimensions && + dimensions.cols && + dimensions.rows && + !isNaN(dimensions.cols) && + !isNaN(dimensions.rows) && + dimensions.cols >= 1 && + dimensions.rows >= 1 + + if (hasValidDimensions) { + // Valid dimensions - fit the terminal + fitAddonRef.current.fit() + } + + // Always send resize with current terminal dimensions + // This ensures the server has the correct size even if fit() was skipped const { cols, rows } = terminalRef.current - sendResize(cols, rows) + if (cols > 0 && rows > 0) { + sendResize(cols, rows) + } } catch { // Container may not be visible yet, ignore } @@ -173,7 +200,9 @@ export function Terminal({ projectName, isActive }: TerminalProps) { * Connect to the terminal WebSocket */ const connect = useCallback(() => { - if (!projectName || !isActive) return + // Use isActiveRef.current instead of isActive to avoid stale closure issues + // when connect is called from setTimeout callbacks + if (!projectName || !terminalId || !isActiveRef.current) return // Prevent multiple simultaneous connection attempts if ( @@ -192,10 +221,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) { reconnectTimeoutRef.current = null } - // Build WebSocket URL + // Build WebSocket URL with terminal ID const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host - const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}` + const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}/${encodeURIComponent(terminalId)}` try { const ws = new WebSocket(wsUrl) @@ -253,8 +282,8 @@ export function Terminal({ projectName, isActive }: TerminalProps) { wsRef.current = null // Only reconnect if still active, not intentionally exited, and not manually closed - // Use refs to avoid re-creating this callback when state changes - const shouldReconnect = isActive && !hasExitedRef.current && !isManualCloseRef.current + // Use isActiveRef.current to get the current value, avoiding stale closure + const shouldReconnect = isActiveRef.current && !hasExitedRef.current && !isManualCloseRef.current // Reset manual close flag after checking (so subsequent disconnects can auto-reconnect) isManualCloseRef.current = false @@ -289,7 +318,7 @@ export function Terminal({ projectName, isActive }: TerminalProps) { connect() }, delay) } - }, [projectName, isActive, sendResize, decodeBase64]) + }, [projectName, terminalId, sendResize, decodeBase64]) // Keep connect ref up to date useEffect(() => { @@ -325,10 +354,9 @@ export function Terminal({ projectName, isActive }: TerminalProps) { fitAddonRef.current = fitAddon isInitializedRef.current = true - // Initial fit - setTimeout(() => { - fitTerminal() - }, 0) + // NOTE: Don't call fitTerminal() here - let the activation effect handle it + // after layout is fully calculated. This avoids dimension calculation issues + // when the container is first rendered. // Handle keyboard input terminal.onData((data) => { @@ -353,7 +381,7 @@ export function Terminal({ projectName, isActive }: TerminalProps) { terminal.onResize(({ cols, rows }) => { sendResize(cols, rows) }) - }, [fitTerminal, encodeBase64, sendMessage, sendResize]) + }, [encodeBase64, sendMessage, sendResize]) /** * Handle window resize @@ -376,43 +404,83 @@ export function Terminal({ projectName, isActive }: TerminalProps) { */ useEffect(() => { if (!isActive) { - // Clean up when becoming inactive + // When becoming inactive, just clear reconnect timeout but keep WebSocket alive + // This preserves the terminal buffer and connection for when we switch back if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null } - if (wsRef.current) { - wsRef.current.close() - wsRef.current = null - } + // DO NOT close WebSocket here - keep it alive to preserve buffer return } // Initialize terminal if not already done if (!isInitializedRef.current) { initializeTerminal() - } else { - // Re-fit when becoming active again - setTimeout(() => { - fitTerminal() - }, 0) } - // Connect WebSocket using ref to avoid dependency on connect callback - connectRef.current?.() - }, [isActive, initializeTerminal, fitTerminal]) + // Connect WebSocket if not already connected + // Use double rAF + timeout to ensure terminal is rendered with correct dimensions + // before connecting (the fit/refresh effect handles the actual fitting) + let rafId1: number + let rafId2: number + + const connectIfNeeded = () => { + rafId1 = requestAnimationFrame(() => { + rafId2 = requestAnimationFrame(() => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + connectRef.current?.() + } + }) + }) + } + + // Delay connection to ensure terminal dimensions are calculated first + const timeoutId = window.setTimeout(connectIfNeeded, 50) + + return () => { + clearTimeout(timeoutId) + cancelAnimationFrame(rafId1) + cancelAnimationFrame(rafId2) + } + }, [isActive, initializeTerminal]) /** - * Fit terminal when isActive becomes true + * Fit and refresh terminal when isActive becomes true */ useEffect(() => { if (isActive && terminalRef.current) { - // Small delay to ensure container is visible - const timeoutId = setTimeout(() => { - fitTerminal() - terminalRef.current?.focus() - }, 100) - return () => clearTimeout(timeoutId) + // Use double requestAnimationFrame to ensure: + // 1. First rAF: style changes are committed + // 2. Second rAF: layout is recalculated + // This is more reliable than setTimeout for visibility changes + let rafId1: number + let rafId2: number + + const handleActivation = () => { + rafId1 = requestAnimationFrame(() => { + rafId2 = requestAnimationFrame(() => { + if (terminalRef.current && fitAddonRef.current) { + // Fit terminal to get correct dimensions + fitTerminal() + // Refresh the terminal to redraw content after becoming visible + // This fixes rendering issues when switching between terminals + terminalRef.current.refresh(0, terminalRef.current.rows - 1) + // Focus the terminal to receive keyboard input + terminalRef.current.focus() + } + }) + }) + } + + // Small initial delay to ensure React has committed the style changes + const timeoutId = window.setTimeout(handleActivation, 16) + + return () => { + clearTimeout(timeoutId) + cancelAnimationFrame(rafId1) + cancelAnimationFrame(rafId2) + } } }, [isActive, fitTerminal]) @@ -435,25 +503,27 @@ export function Terminal({ projectName, isActive }: TerminalProps) { }, []) /** - * Reconnect when project changes + * Reconnect when project or terminal changes */ useEffect(() => { if (isActive && isInitializedRef.current) { - // Only reconnect if project actually changed, not on initial activation - // This prevents duplicate connect calls when both isActive and projectName effects run - if (lastProjectRef.current === null) { - // Initial activation - just track the project, don't reconnect (the isActive effect handles initial connect) + // Only reconnect if project or terminal actually changed, not on initial activation + // This prevents duplicate connect calls when both isActive and projectName/terminalId effects run + if (lastProjectRef.current === null && lastTerminalIdRef.current === null) { + // Initial activation - just track the project/terminal, don't reconnect (the isActive effect handles initial connect) lastProjectRef.current = projectName + lastTerminalIdRef.current = terminalId return } - if (lastProjectRef.current === projectName) { - // Project didn't change, skip + if (lastProjectRef.current === projectName && lastTerminalIdRef.current === terminalId) { + // Nothing changed, skip return } - // Project changed - update tracking + // Project or terminal changed - update tracking lastProjectRef.current = projectName + lastTerminalIdRef.current = terminalId // Clear terminal and reset cursor position if (terminalRef.current) { @@ -476,10 +546,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) { setExitCode(null) reconnectAttempts.current = 0 - // Connect to new project using ref to avoid dependency on connect callback + // Connect to new project/terminal using ref to avoid dependency on connect callback connectRef.current?.() } - }, [projectName, isActive]) + }, [projectName, terminalId, isActive]) return (
@@ -506,6 +576,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) { ref={containerRef} className="h-full w-full p-2" style={{ minHeight: '100px' }} + onClick={() => { + // Ensure terminal gets focus when container is clicked + terminalRef.current?.focus() + }} />
) diff --git a/ui/src/components/TerminalTabs.tsx b/ui/src/components/TerminalTabs.tsx new file mode 100644 index 0000000..1a29d37 --- /dev/null +++ b/ui/src/components/TerminalTabs.tsx @@ -0,0 +1,246 @@ +/** + * Terminal Tabs Component + * + * Manages multiple terminal tabs with add, rename, and close functionality. + * Supports inline rename via double-click and context menu. + */ + +import { useState, useRef, useEffect, useCallback } from 'react' +import { Plus, X } from 'lucide-react' +import type { TerminalInfo } from '@/lib/types' + +interface TerminalTabsProps { + terminals: TerminalInfo[] + activeTerminalId: string | null + onSelect: (terminalId: string) => void + onCreate: () => void + onRename: (terminalId: string, newName: string) => void + onClose: (terminalId: string) => void +} + +interface ContextMenuState { + visible: boolean + x: number + y: number + terminalId: string | null +} + +export function TerminalTabs({ + terminals, + activeTerminalId, + onSelect, + onCreate, + onRename, + onClose, +}: TerminalTabsProps) { + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + terminalId: null, + }) + const inputRef = useRef(null) + const contextMenuRef = useRef(null) + + // Focus input when editing starts + useEffect(() => { + if (editingId && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [editingId]) + + // Close context menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + contextMenuRef.current && + !contextMenuRef.current.contains(e.target as Node) + ) { + setContextMenu((prev) => ({ ...prev, visible: false })) + } + } + + if (contextMenu.visible) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [contextMenu.visible]) + + // Start editing a terminal name + const startEditing = useCallback((terminal: TerminalInfo) => { + setEditingId(terminal.id) + setEditValue(terminal.name) + setContextMenu((prev) => ({ ...prev, visible: false })) + }, []) + + // Handle edit submission + const submitEdit = useCallback(() => { + if (editingId && editValue.trim()) { + onRename(editingId, editValue.trim()) + } + setEditingId(null) + setEditValue('') + }, [editingId, editValue, onRename]) + + // Cancel editing + const cancelEdit = useCallback(() => { + setEditingId(null) + setEditValue('') + }, []) + + // Handle key events during editing + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + submitEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelEdit() + } + }, + [submitEdit, cancelEdit] + ) + + // Handle double-click to start editing + const handleDoubleClick = useCallback( + (terminal: TerminalInfo) => { + startEditing(terminal) + }, + [startEditing] + ) + + // Handle context menu + const handleContextMenu = useCallback( + (e: React.MouseEvent, terminalId: string) => { + e.preventDefault() + setContextMenu({ + visible: true, + x: e.clientX, + y: e.clientY, + terminalId, + }) + }, + [] + ) + + // Handle context menu actions + const handleContextMenuRename = useCallback(() => { + if (contextMenu.terminalId) { + const terminal = terminals.find((t) => t.id === contextMenu.terminalId) + if (terminal) { + startEditing(terminal) + } + } + }, [contextMenu.terminalId, terminals, startEditing]) + + const handleContextMenuClose = useCallback(() => { + if (contextMenu.terminalId) { + onClose(contextMenu.terminalId) + } + setContextMenu((prev) => ({ ...prev, visible: false })) + }, [contextMenu.terminalId, onClose]) + + // Handle tab close with confirmation if needed + const handleClose = useCallback( + (e: React.MouseEvent, terminalId: string) => { + e.stopPropagation() + onClose(terminalId) + }, + [onClose] + ) + + return ( +
+ {/* Terminal tabs */} + {terminals.map((terminal) => ( +
onSelect(terminal.id)} + onDoubleClick={() => handleDoubleClick(terminal)} + onContextMenu={(e) => handleContextMenu(e, terminal.id)} + > + {editingId === terminal.id ? ( + setEditValue(e.target.value)} + onBlur={submitEdit} + onKeyDown={handleKeyDown} + className="bg-white text-black px-1 py-0 text-sm font-mono border-2 border-black w-24 outline-none" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {terminal.name} + + )} + + {/* Close button */} + {terminals.length > 1 && ( + + )} +
+ ))} + + {/* Add new terminal button */} + + + {/* Context menu */} + {contextMenu.visible && ( +
+ + {terminals.length > 1 && ( + + )} +
+ )} +
+ ) +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 1fdf146..848326c 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -23,6 +23,7 @@ import type { ModelsResponse, DevServerStatusResponse, DevServerConfig, + TerminalInfo, } from './types' const API_BASE = '/api' @@ -333,3 +334,41 @@ export async function stopDevServer( export async function getDevServerConfig(projectName: string): Promise { return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`) } + +// ============================================================================ +// Terminal API +// ============================================================================ + +export async function listTerminals(projectName: string): Promise { + return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`) +} + +export async function createTerminal( + projectName: string, + name?: string +): Promise { + return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`, { + method: 'POST', + body: JSON.stringify({ name: name ?? null }), + }) +} + +export async function renameTerminal( + projectName: string, + terminalId: string, + name: string +): Promise { + return fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }) +} + +export async function deleteTerminal( + projectName: string, + terminalId: string +): Promise { + await fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, { + method: 'DELETE', + }) +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index df303db..cceb704 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -125,6 +125,13 @@ export interface DevServerConfig { effective_command: string | null } +// Terminal types +export interface TerminalInfo { + id: string + name: string + created_at: string +} + // WebSocket message types export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index fea90a2..9b35f51 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/terminaltabs.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file