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