From a7f8c3aa8d1dd6e0b2dc462298294a284dec37c3 Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 12 Jan 2026 11:55:50 +0200 Subject: [PATCH] feat: add multiple terminal tabs with rename capability Add support for multiple terminal instances per project with tabbed navigation in the debug panel. Each terminal maintains its own PTY session and WebSocket connection. Backend changes: - Add terminal metadata storage (id, name, created_at) per project - Update terminal_manager.py with create, list, rename, delete functions - Extend WebSocket endpoint to /api/terminal/ws/{project}/{terminal_id} - Add REST endpoints for terminal CRUD operations - Implement deferred PTY start with initial resize message Frontend changes: - Create TerminalTabs component with neobrutalism styling - Support double-click rename and right-click context menu - Fix terminal switching issues with transform-based hiding - Use isActiveRef to prevent stale closure bugs in connect() - Add double requestAnimationFrame for reliable activation timing - Implement proper dimension validation in fitTerminal() Other updates: - Add GLM model configuration documentation to README - Simplify client.py by removing CLI_COMMAND support - Update chat session services with consistent patterns Co-Authored-By: Claude Opus 4.5 --- .env.example | 7 - README.md | 17 ++ client.py | 22 +- server/main.py | 16 +- server/routers/terminal.py | 262 +++++++++++++++++++--- server/services/assistant_chat_session.py | 16 +- server/services/expand_chat_session.py | 19 +- server/services/spec_chat_session.py | 17 +- server/services/terminal_manager.py | 211 ++++++++++++++++- start.py | 29 +-- ui/src/components/DebugLogViewer.tsx | 152 ++++++++++++- ui/src/components/Terminal.tsx | 164 ++++++++++---- ui/src/components/TerminalTabs.tsx | 246 ++++++++++++++++++++ ui/src/lib/api.ts | 39 ++++ ui/src/lib/types.ts | 7 + ui/tsconfig.tsbuildinfo | 2 +- 16 files changed, 1032 insertions(+), 194 deletions(-) create mode 100644 ui/src/components/TerminalTabs.tsx 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