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:
Auto
2026-01-12 11:55:50 +02:00
parent c1985eb285
commit a7f8c3aa8d
16 changed files with 1032 additions and 194 deletions

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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(

View File

@@ -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

View File

@@ -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(

View File

@@ -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")