mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-31 06:42:06 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user