Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Install into any protoAgent agent from this git URL — it's not tied to one age

- A left-rail **Terminal** view (ADR 0026) — an xterm.js page (fit + clickable-links
addons) served by the plugin, connected to a shell over a WebSocket.
- **Tabs** — run several sessions in one view (+ to add, × to close, double-click to
rename). Each tab owns its own xterm + WebSocket, and each WebSocket gets its own
PTY shell — closing a tab kills only that shell.
- A **real PTY** on the backend — stdlib `pty` (no pip deps), so it's a genuine
interactive shell: TUIs, colour, resize, `Ctrl-C`, the works. The wire protocol
mirrors protoMaker's terminal (`data`/`exit`/`connected` ⇄ `input`/`resize`/`ping`).
Expand Down Expand Up @@ -87,8 +90,8 @@ Then open the **Terminal** rail icon. (Make sure the host has an operator bearer

## Roadmap

A solid single terminal session per view. Possible next steps: multi-session tabs,
split panes, a search overlay, and validating the experimental Windows backend. PRs welcome.
Multi-session tabs, a real PTY, themed + offline. Possible next steps: split panes, a
search overlay, and validating the experimental Windows backend. PRs welcome.

Enabled by default once installed (the WS bearer gate is the protection) — disable
with `plugins.disabled: [terminal]`.
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id: terminal
name: Terminal
version: 0.3.0
version: 0.4.0
description: >-
A full terminal in the protoAgent console — an xterm.js view wired to a real PTY
shell over a WebSocket. The terminal is themed from the protoAgent design system
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "terminal-plugin"
version = "0.3.0" # keep in lockstep with protoagent.plugin.yaml (a test enforces it)
version = "0.4.0" # keep in lockstep with protoagent.plugin.yaml (a test enforces it)
description = "A full terminal (xterm.js + a real PTY over WebSocket) as a protoAgent console plugin."
requires-python = ">=3.11"

Expand Down
9 changes: 9 additions & 0 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ def test_view_page_pulls_in_the_protoagent_theme_and_four_rules():
assert "options.theme" in PAGE # re-applies the theme, not just on first paint


def test_view_page_has_multi_session_tabs():
from terminal.view import PAGE

# a tab bar + per-session lifecycle (each tab owns its own xterm + WS → its own PTY)
assert 'id="tabs"' in PAGE and 'id="newtab"' in PAGE
assert "newSession" in PAGE and "closeSession" in PAGE and "switchTo" in PAGE
assert "const sessions = new Map()" in PAGE # the session registry


def test_register_mounts_the_public_router(registry):
import terminal

Expand Down
179 changes: 111 additions & 68 deletions view.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
channel (it carries the operator bearer as a ?token= param) · slug-aware base (works
on the host window AND through the fleet proxy) · links the DS plugin-kit.

THEME: the terminal is themed from protoAgent's design system. The page reads the
console's ``--pl-*`` CSS tokens (set by the DS kit's protoagent:init handshake) and
maps them onto xterm's theme object — background/foreground/cursor/selection + the 16
ANSI colours — and RE-APPLIES on a live re-theme (a MutationObserver on :root). So
the terminal always matches the console's theme.

No build step — vanilla JS; xterm.js + addons load from jsDelivr (vendoring is a
follow-up). ``PAGE`` is the HTML; api.py returns it on GET /view.
TABS: several independent sessions in one view. Each tab owns its own xterm + fit
addon + WebSocket — and each WebSocket gets its own PTY shell on the backend (one PTY
per connection), so closing a tab kills only that shell.

THEME: every terminal is themed from protoAgent's design system. The page reads the
console's ``--pl-*`` CSS tokens and maps them onto xterm's theme — background/
foreground/cursor/selection + the 16 ANSI colours — and RE-APPLIES on a live re-theme
(a MutationObserver on :root), across all open tabs.

No build step — vanilla JS; xterm.js + addons are VENDORED and served by this plugin
(offline). ``PAGE`` is the HTML; api.py returns it on GET /view.
"""

from __future__ import annotations
Expand All @@ -21,12 +24,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Terminal</title>
<script>
// RULE 3 — slug-aware base: "" on the host window, "/agents/<slug>" through the
// fleet proxy. Split on the prefix this page is served under; every asset + the WS
// URL hangs off it.
// RULE 3 — slug-aware base ("" on host, "/agents/<slug>" through the fleet proxy).
var BASE = location.pathname.split("/plugins/")[0];
// Stylesheets, BASE-prefixed: the VENDORED xterm css (offline — served by this
// plugin, no CDN) and RULE 4's DS kit css (so the chrome themes off --pl-* tokens).
// Stylesheets, BASE-prefixed: the VENDORED xterm css (offline) + the DS kit css (rule 4).
(function(){
["/plugins/terminal/static/xterm.css", "/_ds/plugin-kit.css"].forEach(function(p){
var l=document.createElement("link"); l.rel="stylesheet"; l.href=BASE+p; document.head.appendChild(l);
Expand All @@ -38,40 +38,49 @@
html,body{margin:0;height:100%;background:var(--pl-color-bg,#0a0a0c);color:var(--pl-color-fg,#ededed);
font-family:var(--pl-font-sans,ui-sans-serif,system-ui,sans-serif);font-size:12px}
.wrap{display:flex;flex-direction:column;height:100%}
.bar{display:flex;align-items:center;gap:var(--pl-space-3,10px);padding:6px 12px;
border-bottom:var(--pl-border-width,1px) solid var(--pl-color-border,#26262b);flex:0 0 auto}
.bar .t{font-weight:600;color:var(--pl-color-fg,#ededed)} .bar .sp{flex:1}
.bar{display:flex;align-items:center;gap:6px;padding:4px 8px;flex:0 0 auto;
border-bottom:var(--pl-border-width,1px) solid var(--pl-color-border,#26262b)}
.tabs{display:flex;gap:4px;overflow-x:auto;max-width:70%}
.tab{display:flex;align-items:center;gap:6px;padding:3px 8px;border-radius:var(--pl-radius,6px);
background:transparent;color:var(--pl-color-fg-muted,#9a9aa5);border:1px solid transparent;
cursor:pointer;font-size:11px;white-space:nowrap}
.tab:hover{background:var(--pl-color-bg-raised,#1a1a1f)}
.tab.active{background:var(--pl-color-bg-raised,#1a1a1f);color:var(--pl-color-fg,#ededed);
border-color:var(--pl-color-border,#26262b)}
.tab .x{opacity:.6;font-size:13px;line-height:1} .tab .x:hover{opacity:1;color:var(--pl-color-status-error,#f87171)}
button.pl{background:var(--pl-color-bg-raised,#1a1a1f);color:var(--pl-color-fg,#ededed);
border:var(--pl-border-width,1px) solid var(--pl-color-border,#26262b);border-radius:var(--pl-radius,6px);
padding:2px 9px;font-size:13px;cursor:pointer;line-height:1.2}
.sp{flex:1}
.meta{color:var(--pl-color-fg-muted,#9a9aa5);font-family:var(--pl-font-mono,ui-monospace,monospace);font-size:11px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--pl-color-fg-muted,#9a9aa5);margin-right:6px}
.dot.ok{background:var(--pl-color-status-success,#4ade80)} .dot.bad{background:var(--pl-color-status-error,#f87171)}
button.pl{background:var(--pl-color-bg-raised,#1a1a1f);color:var(--pl-color-fg,#ededed);
border:var(--pl-border-width,1px) solid var(--pl-color-border,#26262b);border-radius:var(--pl-radius,6px);
padding:3px 10px;font-size:11px;cursor:pointer}
#term{flex:1 1 auto;min-height:0;padding:6px 8px;background:var(--pl-color-bg,#0a0a0c)}
#terms{position:relative;flex:1 1 auto;min-height:0}
.termpane{position:absolute;inset:0;padding:6px 8px;background:var(--pl-color-bg,#0a0a0c);display:none}
.termpane.active{display:block}
#err{padding:12px;color:var(--pl-color-status-error,#f87171)}
</style>
</head><body><div class="wrap">
<div class="bar">
<span class="t">Terminal</span>
<span class="meta" id="shell"></span>
<div class="tabs" id="tabs"></div>
<button class="pl" id="newtab" title="New terminal">+</button>
<span class="sp"></span>
<span class="meta" id="shell"></span>
<span class="meta"><span class="dot" id="dot"></span><span id="status">connecting…</span></span>
<button class="pl" id="reconnect" style="display:none" onclick="window.__reconnect()">Reconnect</button>
</div>
<div id="term"></div>
<div id="terms"></div>
<div id="err" hidden></div>
</div>
<script type="module">
// The DS kit owns the protoagent:init handshake (operator bearer + theme tokens) and
// the slug-aware token. ESM module → dynamic import. Fallback to a tokenless shim on
// an older host with no /_ds (the terminal still works on a no-auth/loopback host).
// the slug-aware token. ESM module → dynamic import. Fallback to a tokenless shim.
let kit;
try { kit = await import(BASE + "/_ds/plugin-kit.js"); }
catch (e) { kit = { initPluginView(cb){ cb && cb(); }, getToken(){ return ""; } }; }

// Load the VENDORED xterm UMD bundles (served by this plugin — offline, no CDN), then
// read their globals: xterm's UMD spreads its exports onto window (→ window.Terminal);
// the addons expose window.<Name>.<Name>.
// Load the VENDORED xterm UMD bundles (served by this plugin — offline), then read
// their globals: xterm spreads its exports onto window (→ window.Terminal); the addons
// expose window.<Name>.<Name>.
function loadScript(src){
return new Promise((res, rej) => {
const s = document.createElement("script");
Expand All @@ -82,7 +91,7 @@
const ST = BASE + "/plugins/terminal/static/";
let Terminal, FitAddon, WebLinksAddon;
try {
await loadScript(ST + "xterm.js"); // defines the globals the addons attach to
await loadScript(ST + "xterm.js");
await Promise.all([loadScript(ST + "addon-fit.js"), loadScript(ST + "addon-web-links.js")]);
Terminal = window.Terminal;
FitAddon = window.FitAddon.FitAddon;
Expand All @@ -104,8 +113,6 @@
cursor: css("--pl-color-accent", "#9b87f2"),
cursorAccent: css("--pl-color-bg", "#0a0a0c"),
selectionBackground: "rgba(155,135,242,0.35)",
// 16 ANSI — status tokens where they map cleanly, sensible defaults otherwise, so
// colourful CLI output (git, ls, grep…) reads well against the console's palette.
black: css("--pl-color-bg-raised", "#1a1a1f"),
red: css("--pl-color-status-error", "#f87171"),
green: css("--pl-color-status-success", "#4ade80"),
Expand All @@ -119,57 +126,93 @@
brightWhite: css("--pl-color-fg", "#f8fafc"),
};
}

const term = new Terminal({
cursorBlink: true, fontSize: 13, scrollback: 5000, allowProposedApi: true,
fontFamily: "var(--pl-font-mono), Menlo, Monaco, 'Courier New', monospace",
theme: xtermTheme(),
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open($("term"));
function applyTheme(){ try { term.options.theme = xtermTheme(); } catch (e) {} }
// Live re-theme: the kit re-sets the --pl-* vars on :root → rebuild the xterm theme.
function applyTheme(){ const t = xtermTheme(); for (const s of sessions.values()){ try { s.term.options.theme = t; } catch (e) {} } }
// Live re-theme: the kit re-sets the --pl-* vars on :root → rebuild every tab's theme.
new MutationObserver(applyTheme).observe(document.documentElement, { attributes: true, attributeFilter: ["style", "class"] });

// ── transport: a WebSocket to the PTY bridge, carrying the operator bearer ──────
let ws = null;
function status(text, cls){ $("status").textContent = text; $("dot").className = "dot" + (cls ? " " + cls : ""); }
function send(obj){ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
function fit(){ try { fitAddon.fit(); } catch (e) {} send({ type: "resize", cols: term.cols, rows: term.rows }); }
// ── sessions (tabs) — each owns an xterm + fit + WebSocket (→ its own PTY) ──────
const sessions = new Map(); // id → { id, name, term, fit, ws, el, status, statusCls, exited, meta }
let activeId = null;
let counter = 0;

function setStatus(text, cls){ $("status").textContent = text; $("dot").className = "dot" + (cls ? " " + cls : ""); }

function wsUrl(){
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const tok = (kit.getToken && kit.getToken()) || "";
return proto + "//" + location.host + BASE + "/plugins/terminal/ws?token=" + encodeURIComponent(tok);
}

let exited = false;
function connect(){
$("reconnect").style.display = "none";
status("connecting…");
ws = new WebSocket(wsUrl());
ws.onopen = () => { status("connected", "ok"); fit(); term.focus(); };
ws.onmessage = (e) => {
function fit(s){ try { s.fit.fit(); } catch (e) {} if (s.ws && s.ws.readyState === 1) s.ws.send(JSON.stringify({ type: "resize", cols: s.term.cols, rows: s.term.rows })); }

function renderTabs(){
const tabs = $("tabs"); tabs.innerHTML = "";
for (const s of sessions.values()){
const b = document.createElement("button");
b.className = "tab" + (s.id === activeId ? " active" : ""); b.dataset.id = s.id;
const lbl = document.createElement("span"); lbl.className = "lbl"; lbl.textContent = s.name;
const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "Close";
b.appendChild(lbl); b.appendChild(x); tabs.appendChild(b);
b.onclick = (e) => { if (e.target === x) closeSession(s.id); else switchTo(s.id); };
b.ondblclick = (e) => { if (e.target !== x){ const n = prompt("Rename terminal", s.name); if (n){ s.name = n; renderTabs(); } } };
}
}

function switchTo(id){
activeId = id;
for (const s of sessions.values()) s.el.classList.toggle("active", s.id === id);
renderTabs();
const s = sessions.get(id);
if (s){ fit(s); s.term.focus(); $("shell").textContent = s.meta || ""; setStatus(s.status || "…", s.statusCls); }
}

function connect(s){
s.ws = new WebSocket(wsUrl());
s.ws.onopen = () => { s.status = "connected"; s.statusCls = "ok"; if (s.id === activeId) setStatus("connected", "ok"); fit(s); };
s.ws.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
if (m.type === "data") term.write(m.data);
else if (m.type === "connected") { $("shell").textContent = (m.shell || "") + " " + (m.cwd || ""); status("connected", "ok"); }
else if (m.type === "exit") { exited = true; term.write("\r\n\x1b[2m[process exited" + (m.exitCode != null ? " (" + m.exitCode + ")" : "") + "]\x1b[0m\r\n"); }
if (m.type === "data") s.term.write(m.data);
else if (m.type === "connected"){ s.meta = (m.shell || "") + " " + (m.cwd || ""); if (s.id === activeId) $("shell").textContent = s.meta; }
else if (m.type === "exit"){ s.exited = true; s.term.write("\r\n\x1b[2m[process exited" + (m.exitCode != null ? " (" + m.exitCode + ")" : "") + "]\x1b[0m\r\n"); }
};
ws.onclose = () => { status(exited ? "exited" : "disconnected", "bad"); $("reconnect").style.display = ""; };
ws.onerror = () => status("error", "bad");
s.ws.onclose = () => { s.status = s.exited ? "exited" : "disconnected"; s.statusCls = "bad"; if (s.id === activeId) setStatus(s.status, "bad"); };
s.ws.onerror = () => { s.status = "error"; s.statusCls = "bad"; if (s.id === activeId) setStatus("error", "bad"); };
}

function newSession(){
const id = "t" + (++counter);
const el = document.createElement("div"); el.className = "termpane"; el.dataset.id = id; $("terms").appendChild(el);
const term = new Terminal({
cursorBlink: true, fontSize: 13, scrollback: 5000, allowProposedApi: true,
fontFamily: "var(--pl-font-mono), Menlo, Monaco, 'Courier New', monospace", theme: xtermTheme(),
});
const fitA = new FitAddon(); term.loadAddon(fitA); term.loadAddon(new WebLinksAddon()); term.open(el);
const s = { id, name: "Terminal " + counter, term, fit: fitA, ws: null, el, status: "connecting…", statusCls: "", exited: false, meta: "" };
term.onData((d) => { if (s.ws && s.ws.readyState === 1) s.ws.send(JSON.stringify({ type: "input", data: d })); });
sessions.set(id, s);
connect(s);
switchTo(id);
return s;
}

function closeSession(id){
const s = sessions.get(id); if (!s) return;
try { if (s.ws) s.ws.close(); } catch (e) {}
try { s.term.dispose(); } catch (e) {}
s.el.remove(); sessions.delete(id);
if (activeId === id){
const next = sessions.keys().next().value;
if (next) switchTo(next); else newSession(); // always keep at least one terminal
} else renderTabs();
}
window.__reconnect = () => { exited = false; term.reset(); connect(); };

term.onData((d) => send({ type: "input", data: d }));
new ResizeObserver(() => fit()).observe($("term"));
setInterval(() => send({ type: "ping" }), 30000);
$("newtab").onclick = () => newSession();
new ResizeObserver(() => { const s = sessions.get(activeId); if (s) fit(s); }).observe($("terms"));
setInterval(() => { for (const s of sessions.values()) if (s.ws && s.ws.readyState === 1) s.ws.send(JSON.stringify({ type: "ping" })); }, 30000);

// Boot once, on whichever fires first: the handshake (bearer + theme arrive with it,
// so the WS authenticates) or a short timer (no-handshake / standalone).
// Boot once: open the first tab (the handshake supplies bearer + theme first if present).
let booted = false;
function boot(){ if (booted) return; booted = true; applyTheme(); connect(); }
function boot(){ if (booted) return; booted = true; applyTheme(); newSession(); }
kit.initPluginView(() => { applyTheme(); boot(); });
setTimeout(boot, 1000);
</script></body></html>"""
Loading