This soldier is {live.status}. Rouse it from the Status tab, then chat.
)}
+ {tab === 'pty' && }
{tab === 'status' && (
{live.skills?.length > 0 &&
skills loaded{live.skills.map((s) => {s})}
}
diff --git a/src/Pty.jsx b/src/Pty.jsx
new file mode 100644
index 0000000..7d4a980
--- /dev/null
+++ b/src/Pty.jsx
@@ -0,0 +1,88 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { api } from './api.js';
+
+// Native web terminal β polls the instance's tui frames and (for the owner,
+// who holds the accesskey) sends keystrokes through amerc's input queue. No
+// relay, no iframe: just HTTP. The broker writes keystrokes to its pty and
+// pushes tui frames back via POST /agents/instances/:id/tui.
+
+const stripAnsi = (s) => String(s || '')
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI sequences
+ .replace(/\x1b[()][0-9A-Za-z]/g, '') // charset selects
+ .replace(/\x1b[=>]/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
+
+function keyToData(e) {
+ if (e.ctrlKey && /^[a-z]$/i.test(e.key)) { const c = e.key.toLowerCase().charCodeAt(0) - 96; return String.fromCharCode(c); }
+ switch (e.key) {
+ case 'Enter': return '\r';
+ case 'Backspace': return '\x7f';
+ case 'Tab': return '\t';
+ case 'Escape': return '\x1b';
+ case 'ArrowUp': return '\x1b[A';
+ case 'ArrowDown': return '\x1b[B';
+ case 'ArrowRight': return '\x1b[C';
+ case 'ArrowLeft': return '\x1b[D';
+ case 'Home': return '\x1b[H';
+ case 'End': return '\x1b[F';
+ case 'Delete': return '\x1b[3~';
+ default: return e.key.length === 1 ? e.key : null;
+ }
+}
+
+export default function PtyPanel({ instance, accesskey }) {
+ const [tui, setTui] = useState(instance.tui || '');
+ const [status, setStatus] = useState(instance.status || '');
+ const [focused, setFocused] = useState(false);
+ const [err, setErr] = useState('');
+ const screenRef = useRef(null);
+ const interactive = !!accesskey;
+
+ // poll tui frames
+ useEffect(() => {
+ let alive = true;
+ const q = accesskey ? `?accesskey=${encodeURIComponent(accesskey)}` : '';
+ const tick = () => api(`/agents/instances/${instance.id}${q}`)
+ .then((d) => { if (alive && d.instance) { if (d.tui != null) setTui(d.tui); setStatus(d.instance.status); } })
+ .catch(() => {});
+ tick(); const t = setInterval(tick, 1200);
+ return () => { alive = false; clearInterval(t); };
+ }, [instance.id, accesskey]);
+
+ // keep scrolled to the bottom
+ useEffect(() => { const el = screenRef.current; if (el) el.scrollTop = el.scrollHeight; }, [tui]);
+
+ const send = useCallback(async (data) => {
+ if (!interactive) return;
+ try { await api(`/agents/instances/${instance.id}/input`, { method: 'POST', body: { data, accesskey } }); }
+ catch (e) { setErr(e.message); }
+ }, [instance.id, accesskey, interactive]);
+
+ const onKeyDown = (e) => {
+ if (!interactive) return;
+ if (e.metaKey || (e.ctrlKey && (e.key === 'c' || e.key === 'v') && !focused)) return; // allow copy/paste chrome
+ const d = keyToData(e);
+ if (d != null) { e.preventDefault(); send(d); }
+ };
+ const onPaste = (e) => { if (!interactive) return; const t = e.clipboardData?.getData('text'); if (t) { e.preventDefault(); send(t); } };
+
+ return (
+
+
+
+ terminal Β· #{instance.id}
+ {interactive ? (focused ? 'β¨ typing β keys go to the agent' : 'click the screen to type') : 'π view only (owner drives)'}
+
+
setFocused(true)}
+ onBlur={() => setFocused(false)}
+ >{stripAnsi(tui) || (status === 'online' ? 'waiting for the agent to paint its terminalβ¦' : `instance is ${status} β no live terminal`)}
+ {err &&
{err}
}
+ {interactive &&
Keystrokes (incl. Enter, arrows, Ctrl-C) stream to the agent's pty; the screen refreshes ~1Γ/sec. Your broker must long-poll GET /api/agents/instances/{instance.id}/input and push frames to POST β¦/tui.
}
+
+ );
+}
diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx
index 8649cfa..4305bfc 100644
--- a/src/Scene2D.jsx
+++ b/src/Scene2D.jsx
@@ -12,7 +12,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => ();
const X = ({ size = 20 }) => ();
-const RELEASE = '3.1.0-paintedmobile';
+const RELEASE = '3.2.0-pty';
const NAV = [
{ id: 'home', label: 'Tavern' },
@@ -482,6 +482,7 @@ function StatusPage() {
}
const CHANGELOG = [
+ { v: '3.2', date: 'Jun 2026', title: 'A native web terminal', notes: ['The Web PTY now runs on amerc itself β no relay: it streams the agentβs live terminal and sends your keystrokes (Enter, arrows, Ctrl-C, paste) straight through', 'Open it from a soldier in My Legion (Terminal tab) or from any agent you use'] },
{ v: '3.1', date: 'Jun 2026', title: 'The tavern breathes', notes: ['Lantern glow flickers and the bar light pulses over the painted scene', 'On phones the tavern reads cleanly with big tap targets for Browse, Legion and Mansion'] },
{ v: '3.0', date: 'Jun 2026', title: 'A painted tavern', notes: ['The home tavern is now a hand-painted scene β an elf host, an orc bartender and a dwarf patron in a lamplit hall β replacing the old pixel tiles', 'Every painted fixture is still a door: the board opens the roster, the barkeep signs you in, the elf pitches the tour, the side passages lead to your Legion and Mansion'] },
{ v: '2.5', date: 'Jun 2026', title: 'Chat speaks markdown', notes: ['Chat messages now render markdown β code blocks, lists, tables and links β so an agent can answer with formatted, clickable replies (it picks its own links, to the netdisk or anywhere)'] },
diff --git a/src/styles.css b/src/styles.css
index d9e228a..e038db4 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1952,3 +1952,14 @@ button {
.gm-cta-row { display: flex; }
.gm-stage3 .gm-room { width: 100%; }
}
+
+/* native web terminal (3.2) */
+.pty { display: flex; flex-direction: column; gap: 8px; }
+.pty-bar { display: flex; align-items: center; gap: 9px; }
+.pty-title { color: #cfe2f2; font-size: 12.5px; font-family: ui-monospace, Menlo, monospace; }
+.pty-mode { margin-left: auto; color: #8a7bb0; font-size: 11px; }
+.pty-screen { margin: 0; background: #05070c; border: 1px solid #1c2438; border-radius: 9px; padding: 12px; min-height: 260px; max-height: 50vh; overflow: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; line-height: 1.4; color: #cfe6d8; white-space: pre-wrap; word-break: break-word; outline: none; cursor: text; }
+.pty-screen.on { border-color: #46c8e0; box-shadow: 0 0 0 1px #46c8e0, inset 0 0 18px rgba(70,200,235,0.06); }
+.pty-screen.ro { cursor: default; }
+.pty-hint { margin: 0; }
+.pty-hint code { font-size: 11px; }