147 lines
4.4 KiB
HTML
147 lines
4.4 KiB
HTML
{% extends "_layout.html" %}
|
|
{% set active = "pty" %}
|
|
{% block title %}PTY {{ session_id }} · beaver-gateway{% endblock %}
|
|
{% block content %}
|
|
<style>
|
|
/* xterm.js styles inlined from the CDN bundle — keep them tight and
|
|
constrained to the terminal block so they don't leak into the rest
|
|
of the admin UI. */
|
|
.term-wrap {
|
|
background: #000;
|
|
border-radius: 12px;
|
|
padding: 12px;
|
|
border: 1px solid var(--line);
|
|
}
|
|
.term-host {
|
|
height: 70vh;
|
|
}
|
|
.term-host .xterm {
|
|
height: 100%;
|
|
}
|
|
.term-toolbar {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.term-toolbar .status {
|
|
margin-left: auto;
|
|
font-size: 0.85em;
|
|
color: var(--muted);
|
|
}
|
|
.term-toolbar .status.connected { color: #027a48; }
|
|
.term-toolbar .status.dropped { color: var(--danger); }
|
|
</style>
|
|
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
|
|
<h2>PTY <code>{{ session_id }}</code></h2>
|
|
<p class="muted">
|
|
Live view of the claude subprocess TUI. Keystrokes you type here go
|
|
straight into its stdin — Enter, arrows, Ctrl-C, paste, all work.
|
|
<a href="{{ p }}/pty">← back to list</a>
|
|
</p>
|
|
|
|
<div class="term-toolbar">
|
|
<button id="reconnect-btn" type="button">Reconnect</button>
|
|
<button id="enter-btn" type="button" title="Send a single \r — handy if a paste is stuck in the input box">Send Enter</button>
|
|
<button id="ctrl-c-btn" type="button">Send Ctrl-C</button>
|
|
<span id="status" class="status">connecting…</span>
|
|
</div>
|
|
|
|
<div class="term-wrap">
|
|
<div id="term" class="term-host"></div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
<script>
|
|
(function () {
|
|
const sessionId = {{ session_id|tojson }};
|
|
const wsBase =
|
|
(location.protocol === "https:" ? "wss://" : "ws://") +
|
|
location.host +
|
|
{{ p|tojson }} +
|
|
"/pty/" + encodeURIComponent(sessionId) + "/ws";
|
|
|
|
const term = new Terminal({
|
|
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
|
fontSize: 13,
|
|
theme: {
|
|
background: "#000000",
|
|
foreground: "#d0d0d0",
|
|
},
|
|
convertEol: false,
|
|
cursorBlink: true,
|
|
scrollback: 5000,
|
|
});
|
|
const fit = new FitAddon.FitAddon();
|
|
term.loadAddon(fit);
|
|
term.open(document.getElementById("term"));
|
|
// Defer fit until after layout settles; xterm's measurement reads
|
|
// computed CSS that isn't stable until the page paints once.
|
|
requestAnimationFrame(() => fit.fit());
|
|
window.addEventListener("resize", () => fit.fit());
|
|
|
|
const statusEl = document.getElementById("status");
|
|
function setStatus(text, cls) {
|
|
statusEl.textContent = text;
|
|
statusEl.className = "status" + (cls ? " " + cls : "");
|
|
}
|
|
|
|
let ws = null;
|
|
let manuallyClosed = false;
|
|
|
|
function connect() {
|
|
manuallyClosed = false;
|
|
setStatus("connecting…", "");
|
|
ws = new WebSocket(wsBase);
|
|
ws.binaryType = "arraybuffer";
|
|
ws.onopen = () => setStatus("connected", "connected");
|
|
ws.onclose = () => {
|
|
if (!manuallyClosed) setStatus("disconnected", "dropped");
|
|
};
|
|
ws.onerror = () => setStatus("error", "dropped");
|
|
ws.onmessage = (ev) => {
|
|
if (typeof ev.data === "string") {
|
|
term.write(ev.data);
|
|
} else {
|
|
// ArrayBuffer — feed raw bytes to xterm. It expects either
|
|
// string or Uint8Array; the latter preserves byte boundaries
|
|
// exactly, which matters for ANSI sequences split across frames.
|
|
term.write(new Uint8Array(ev.data));
|
|
}
|
|
};
|
|
}
|
|
|
|
// Keystrokes -> server. xterm.js gives us the exact byte sequence
|
|
// the terminal would emit (e.g. Enter -> "\r", arrows -> "\x1b[A"
|
|
// etc.), so we just pass it through.
|
|
term.onData((data) => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(data);
|
|
}
|
|
});
|
|
|
|
document.getElementById("reconnect-btn").addEventListener("click", () => {
|
|
if (ws) {
|
|
manuallyClosed = true;
|
|
ws.close();
|
|
}
|
|
connect();
|
|
});
|
|
document.getElementById("enter-btn").addEventListener("click", () => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\r");
|
|
term.focus();
|
|
});
|
|
document.getElementById("ctrl-c-btn").addEventListener("click", () => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x03");
|
|
term.focus();
|
|
});
|
|
|
|
connect();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|