From d7a1cbd292605bbe2b9cabfbe313daa77181526a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 05:12:54 +0000 Subject: [PATCH] Replace command-runner Terminal with a real interactive PTY The previous Terminal ran one command at a time via subprocess.run and returned its output - it couldn't run interactive programs (colors, live input, TUIs like gemini's chat/auth flow), which is what a terminal actually needs to do. Backend: new /ws/terminal WebSocket endpoint spawns a real shell attached to a pseudo-terminal (stdlib pty on POSIX, pywinpty/ConPTY on Windows) and streams raw I/O bidirectionally, with resize support. Replaces the old POST /api/terminal/run and GET /api/terminal/session endpoints entirely. Frontend: terminal.js now loads xterm.js + the fit addon from CDN and renders a real terminal emulator wired to the WebSocket, instead of a scrollback div with a single input line. Verified on this Linux sandbox via raw WebSocket tests: shell spawns correctly, commands execute and echo real output, resize propagates to the PTY (confirmed via ), and closing the connection cleanly kills the shell process with no orphans (interactive bash ignores SIGTERM by default, so cleanup uses SIGKILL). Could not visually verify the xterm.js browser rendering in this sandbox since its egress policy blocks the CDN (cdn.jsdelivr.net) outright - same CDN this app already uses for chart.js, so expected to work on a normal machine; please confirm on Windows. --- dashboard/api.js | 3 - dashboard/pages/terminal.js | 198 +++++++++++++++++++----------------- dashboard/styles.css | 25 +---- requirements.txt | 1 + server.py | 146 +++++++++++++++++++------- 5 files changed, 216 insertions(+), 157 deletions(-) diff --git a/dashboard/api.js b/dashboard/api.js index b2bf437..8f36079 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -52,9 +52,6 @@ const api = { discoverStandards: () => api.post('/api/standards/discover'), chat: (agent, message, controller) => api.post('/api/chat', { agent, message }, controller), getChatHistory: () => api.get('/api/chat/history'), - // Terminal - getTerminalSession: () => api.get('/api/terminal/session'), - runTerminalCommand: (command) => api.post('/api/terminal/run', { command }), // Kanban getKanbanBoard: (status) => api.get(status ? `/api/kanban/board?status=${encodeURIComponent(status)}` : '/api/kanban/board'), getKanbanTask: (id) => api.get(`/api/kanban/tasks/${encodeURIComponent(id)}`), diff --git a/dashboard/pages/terminal.js b/dashboard/pages/terminal.js index c05afc1..25548a1 100644 --- a/dashboard/pages/terminal.js +++ b/dashboard/pages/terminal.js @@ -1,114 +1,120 @@ +let _termInstance = null; +let _termSocket = null; + +function loadXterm() { + if (window.Terminal && window.FitAddon) return Promise.resolve(); + return new Promise((resolve, reject) => { + if (!document.querySelector('link[data-xterm-css]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css'; + link.setAttribute('data-xterm-css', '1'); + document.head.appendChild(link); + } + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js'; + script.onload = () => { + const fitScript = document.createElement('script'); + fitScript.src = 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js'; + fitScript.onload = () => resolve(); + fitScript.onerror = () => reject(new Error('Failed to load xterm-addon-fit')); + document.body.appendChild(fitScript); + }; + script.onerror = () => reject(new Error('Failed to load xterm.js')); + document.body.appendChild(script); + }); +} + +function setTerminalStatus(kind, text) { + const el = document.getElementById('terminalStatus'); + if (!el) return; + el.textContent = text; + el.className = `badge badge-${kind === 'online' ? 'success' : kind === 'offline' ? 'danger' : 'warning'}`; +} + +function closeTerminalSession() { + if (window._terminalResizeHandler) { + window.removeEventListener('resize', window._terminalResizeHandler); + window._terminalResizeHandler = null; + } + if (_termSocket) { + try { _termSocket.close(); } catch {} + _termSocket = null; + } + if (_termInstance) { + try { _termInstance.dispose(); } catch {} + _termInstance = null; + } +} + async function renderTerminal() { const content = document.getElementById('pageContent'); content.innerHTML = ` -
-
-
- $ - -
+
+
`; - window._terminalHistory = window._terminalHistory || []; - window._terminalHistoryIndex = window._terminalHistory.length; - window._terminalBusy = false; + closeTerminalSession(); try { - const session = await api.getTerminalSession(); - updateTerminalPrompt(session.cwd); - } catch { - appendTerminalLine('Could not reach the terminal backend.', 'stderr'); - } - - document.getElementById('terminalInput').focus(); -} - -function focusTerminalInput() { - const input = document.getElementById('terminalInput'); - if (input) input.focus(); -} - -function updateTerminalPrompt(cwd) { - const prompt = document.getElementById('terminalPrompt'); - if (prompt) prompt.textContent = `${cwd} $`; -} - -function appendTerminalLine(text, kind = '') { - const output = document.getElementById('terminalOutput'); - if (!output || !text) return; - const line = document.createElement('div'); - if (kind) line.className = `terminal-line-${kind}`; - line.textContent = text; - output.appendChild(line); - output.scrollTop = output.scrollHeight; -} - -function clearTerminal() { - const output = document.getElementById('terminalOutput'); - if (output) output.innerHTML = ''; -} - -async function handleTerminalKey(e) { - const input = e.target; - - if (e.key === 'ArrowUp') { - e.preventDefault(); - if (window._terminalHistoryIndex > 0) { - window._terminalHistoryIndex--; - input.value = window._terminalHistory[window._terminalHistoryIndex] || ''; - } - return; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - if (window._terminalHistoryIndex < window._terminalHistory.length - 1) { - window._terminalHistoryIndex++; - input.value = window._terminalHistory[window._terminalHistoryIndex] || ''; - } else { - window._terminalHistoryIndex = window._terminalHistory.length; - input.value = ''; - } - return; - } - if (e.key !== 'Enter' || window._terminalBusy) return; - - const command = input.value.trim(); - input.value = ''; - if (!command) return; - - window._terminalHistory.push(command); - window._terminalHistoryIndex = window._terminalHistory.length; - - appendTerminalLine(command, 'cmd'); - - if (command === 'clear' || command === 'cls') { - clearTerminal(); - return; - } - - window._terminalBusy = true; - input.disabled = true; - try { - const r = await api.runTerminalCommand(command); - if (r.stdout) appendTerminalLine(r.stdout.replace(/\n$/, '')); - if (r.stderr) appendTerminalLine(r.stderr.replace(/\n$/, ''), 'stderr'); - if (r.timed_out) appendTerminalLine('Command timed out after 60s.', 'info'); - updateTerminalPrompt(r.cwd); + await loadXterm(); } catch (err) { - appendTerminalLine(`Error: ${err.message}`, 'stderr'); - } finally { - window._terminalBusy = false; - input.disabled = false; - input.focus(); + document.getElementById('xtermContainer').innerHTML = + `
⚠
Failed to load terminal library
${escapeHtml(err.message || String(err))}
`; + return; } + + const term = new window.Terminal({ + cursorBlink: true, + fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace", + fontSize: 13, + theme: { background: '#0a0e14', foreground: '#c9d1d9' }, + }); + const fitAddon = new window.FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById('xtermContainer')); + fitAddon.fit(); + _termInstance = term; + + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket(`${proto}//${window.location.host}/ws/terminal`); + _termSocket = socket; + + socket.onopen = () => { + setTerminalStatus('online', 'Connected'); + fitAddon.fit(); + socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + term.focus(); + }; + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'output') term.write(msg.data); + } catch {} + }; + socket.onclose = () => setTerminalStatus('offline', 'Disconnected'); + socket.onerror = () => setTerminalStatus('offline', 'Connection error'); + + term.onData((data) => { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data })); + }); + + const resizeHandler = () => { + fitAddon.fit(); + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); + }; + window.addEventListener('resize', resizeHandler); + window._terminalResizeHandler = resizeHandler; + + window.addEventListener('hashchange', closeTerminalSession, { once: true }); } diff --git a/dashboard/styles.css b/dashboard/styles.css index 57921ed..79bdefd 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -824,34 +824,17 @@ pre { /* Terminal */ .terminal-panel { flex: 1; display: flex; flex-direction: column; - min-height: 0; + min-height: 0; height: calc(100vh - 180px); background: #0a0e14; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); - font-family: var(--font-mono); } -.terminal-output { - flex: 1; overflow-y: auto; padding: 16px; - font-size: 13px; line-height: 1.6; - color: #c9d1d9; white-space: pre-wrap; word-break: break-word; -} -.terminal-line-cmd { color: #7ee787; } -.terminal-line-cmd::before { content: '$ '; color: #58a6ff; } -.terminal-line-stderr { color: #ff7b72; } -.terminal-line-info { color: var(--text-muted); font-style: italic; } -.terminal-input-row { - display: flex; align-items: center; gap: 8px; - padding: 10px 16px; - background: #0d1117; - border-top: 1px solid var(--border); -} -.terminal-prompt { color: #58a6ff; font-size: 13px; white-space: nowrap; } -.terminal-input { - flex: 1; background: transparent; border: none; outline: none; - color: #c9d1d9; font-family: var(--font-mono); font-size: 13px; +.terminal-xterm-container { + flex: 1; min-height: 0; padding: 8px; } +.terminal-xterm-container .xterm { height: 100%; } /* ─── v0.2.0 UI Modernization ─── */ diff --git a/requirements.txt b/requirements.txt index 2d33445..a5e18cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]>=0.32.0 apscheduler>=3.10.0 pydantic>=2.0.0 python-multipart>=0.0.12 +pywinpty>=2.0.0; sys_platform == "win32" diff --git a/server.py b/server.py index ee525e5..f137ee7 100644 --- a/server.py +++ b/server.py @@ -4,10 +4,12 @@ Agentic OS β€” FastAPI Backend Multi-agent orchestration server for opencode, Hermes, Gemini CLI """ import argparse +import asyncio import json import os import re import shutil +import signal import subprocess import tarfile import threading @@ -17,7 +19,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles @@ -101,9 +103,6 @@ class ChatRequest(BaseModel): agent: str message: str -class TerminalRunRequest(BaseModel): - command: str - # ─── Helper Functions ───────────────────────────────────────────── def read_file(path: Path): @@ -667,45 +666,118 @@ def chat(req: ChatRequest): def get_chat_history(): return load_chat_history() -# ─── Routes: Terminal ───────────────────────────────────────────── +# ─── Routes: Terminal (real interactive PTY over WebSocket) ────── -_terminal_cwd = str(BASE_DIR) +class PtySession: + """Wraps a real pseudo-terminal shell process, POSIX (stdlib pty) or Windows (pywinpty).""" -@app.get("/api/terminal/session") -def get_terminal_session(): - return {"cwd": _terminal_cwd} + def __init__(self): + self.master_fd = None + self.pid = None + self.winpty_process = None -@app.post("/api/terminal/run") -def run_terminal_command(req: TerminalRunRequest): - global _terminal_cwd - command = req.command.strip() - if not command: - return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False} + def start(self, cwd: str): + if os.name == "nt": + import winpty + shell = shutil.which("powershell.exe") or "powershell.exe" + self.winpty_process = winpty.PtyProcess.spawn([shell, "-NoLogo"], cwd=cwd) + else: + import pty + pid, fd = pty.fork() + if pid == 0: + os.chdir(cwd) + shell = os.environ.get("SHELL", "/bin/bash") + os.execvp(shell, [shell]) + else: + self.master_fd = fd + self.pid = pid - if command == "cd" or command.startswith("cd "): - target = command[2:].strip() or str(Path.home()) - new_dir = (Path(_terminal_cwd) / target).resolve() if not Path(target).is_absolute() else Path(target).resolve() - if not new_dir.is_dir(): - return {"cwd": _terminal_cwd, "stdout": "", "stderr": f"cd: no such directory: {target}", "returncode": 1, "timed_out": False} - _terminal_cwd = str(new_dir) - append_audit({"action": "terminal_command", "command": "cd", "cwd": _terminal_cwd}) - return {"cwd": _terminal_cwd, "stdout": "", "stderr": "", "returncode": 0, "timed_out": False} + def read(self, size: int = 4096): + if self.winpty_process is not None: + return self.winpty_process.read(size) + return os.read(self.master_fd, size) + + def write(self, data: str): + if self.winpty_process is not None: + self.winpty_process.write(data) + else: + os.write(self.master_fd, data.encode()) + + def resize(self, cols: int, rows: int): + if self.winpty_process is not None: + self.winpty_process.setwinsize(rows, cols) + else: + import fcntl + import struct + import termios + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + + def close(self): + if self.winpty_process is not None: + try: + self.winpty_process.close(force=True) + except Exception: + pass + else: + try: + os.close(self.master_fd) + except OSError: + pass + if self.pid: + # Interactive shells commonly ignore SIGTERM; SIGKILL can't be blocked/ignored. + try: + os.kill(self.pid, signal.SIGKILL) + except OSError: + pass + +@app.websocket("/ws/terminal") +async def ws_terminal(websocket: WebSocket): + await websocket.accept() + session = PtySession() + try: + session.start(str(BASE_DIR)) + except Exception as e: + await websocket.send_json({"type": "output", "data": f"Failed to start terminal: {e}\r\n"}) + await websocket.close() + return + + append_audit({"action": "terminal_session_started"}) + loop = asyncio.get_event_loop() + stop = False + + def reader(): + while not stop: + try: + data = session.read(4096) + except (OSError, EOFError): + break + if not data: + break + text = data.decode(errors="replace") if isinstance(data, bytes) else data + fut = asyncio.run_coroutine_threadsafe(websocket.send_json({"type": "output", "data": text}), loop) + try: + fut.result(timeout=5) + except Exception: + break + asyncio.run_coroutine_threadsafe(websocket.close(), loop) + + threading.Thread(target=reader, daemon=True).start() try: - if os.name == "nt": - r = subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - cwd=_terminal_cwd, capture_output=True, text=True, timeout=60, - ) - else: - r = subprocess.run( - command, shell=True, cwd=_terminal_cwd, - capture_output=True, text=True, timeout=60, - ) - append_audit({"action": "terminal_command", "command": command[:200], "cwd": _terminal_cwd}) - return {"cwd": _terminal_cwd, "stdout": r.stdout, "stderr": r.stderr, "returncode": r.returncode, "timed_out": False} - except subprocess.TimeoutExpired: - return {"cwd": _terminal_cwd, "stdout": "", "stderr": "Command timed out after 60s.", "returncode": -1, "timed_out": True} + while True: + msg = await websocket.receive_json() + if msg.get("type") == "input": + session.write(msg.get("data", "")) + elif msg.get("type") == "resize": + session.resize(int(msg.get("cols", 80)), int(msg.get("rows", 24))) + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + stop = True + session.close() + append_audit({"action": "terminal_session_ended"}) # ═══════════════════════════════════════════════════════════════════ # v0.2.0 β€” New Feature Endpoints