first commit
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
// pigo remote-control SPA.
|
||||
//
|
||||
// Connects to /ws, renders streamed session output, submits prompts, and
|
||||
// answers confirmation requests. Vanilla JS, no build step — the server embeds
|
||||
// this file and serves it as a static asset.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var out = document.getElementById("output");
|
||||
var form = document.getElementById("composer");
|
||||
var prompt = document.getElementById("prompt");
|
||||
var sendBtn = document.getElementById("send");
|
||||
var dot = document.getElementById("dot");
|
||||
var statusText = document.getElementById("statusText");
|
||||
|
||||
var confirmEl = document.getElementById("confirm");
|
||||
var confirmTool = document.getElementById("confirmTool");
|
||||
var confirmSummary = document.getElementById("confirmSummary");
|
||||
var confirmAlways = document.getElementById("confirmAlways");
|
||||
var btnApprove = document.getElementById("btnApprove");
|
||||
var btnReject = document.getElementById("btnReject");
|
||||
|
||||
var ws = null;
|
||||
var backoff = 500; // ms, exponential up to 10s
|
||||
var pendingConfirmId = null;
|
||||
|
||||
// Strip ANSI escape sequences so terminal color codes don't clutter mobile.
|
||||
var ansi = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
||||
function clean(s) { return s.replace(ansi, ""); }
|
||||
|
||||
function atBottom() {
|
||||
return out.scrollHeight - out.scrollTop - out.clientHeight < 40;
|
||||
}
|
||||
function appendOutput(text) {
|
||||
var stick = atBottom();
|
||||
out.appendChild(document.createTextNode(clean(text)));
|
||||
// Cap the scrollback so long sessions don't exhaust memory.
|
||||
while (out.childNodes.length > 4000) {
|
||||
out.removeChild(out.firstChild);
|
||||
}
|
||||
if (stick) out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(state, label) {
|
||||
dot.className = state === "on" ? "on" : state === "off" ? "off" : "";
|
||||
statusText.textContent = label;
|
||||
var live = state === "on";
|
||||
prompt.disabled = !live;
|
||||
sendBtn.disabled = !live;
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(obj));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function showConfirm(frame) {
|
||||
pendingConfirmId = frame.confirmId;
|
||||
confirmTool.textContent = frame.tool || "tool";
|
||||
confirmSummary.textContent = clean(frame.summary || "");
|
||||
confirmAlways.checked = false;
|
||||
confirmEl.classList.add("show");
|
||||
}
|
||||
function hideConfirm() {
|
||||
confirmEl.classList.remove("show");
|
||||
pendingConfirmId = null;
|
||||
}
|
||||
function decide(approve) {
|
||||
if (pendingConfirmId == null) return;
|
||||
send({ type: "decide", confirmId: pendingConfirmId, approve: approve, always: confirmAlways.checked });
|
||||
hideConfirm();
|
||||
}
|
||||
btnApprove.addEventListener("click", function () { decide(true); });
|
||||
btnReject.addEventListener("click", function () { decide(false); });
|
||||
|
||||
function handleFrame(frame) {
|
||||
switch (frame.type) {
|
||||
case "output":
|
||||
appendOutput(frame.text || "");
|
||||
break;
|
||||
case "confirm":
|
||||
showConfirm(frame);
|
||||
break;
|
||||
case "status":
|
||||
if (frame.state === "connected") {
|
||||
setStatus("on", "connected");
|
||||
} else if (frame.state === "ended") {
|
||||
setStatus("off", "session ended");
|
||||
} else if (frame.state === "disconnected") {
|
||||
setStatus("off", frame.reason || "disconnected");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
var proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(proto + "//" + location.host + "/ws");
|
||||
|
||||
ws.onopen = function () {
|
||||
backoff = 500;
|
||||
setStatus("on", "connected");
|
||||
};
|
||||
ws.onmessage = function (ev) {
|
||||
var frame;
|
||||
try { frame = JSON.parse(ev.data); } catch (e) { return; }
|
||||
handleFrame(frame);
|
||||
};
|
||||
ws.onclose = function () {
|
||||
setStatus("off", "reconnecting…");
|
||||
hideConfirm();
|
||||
scheduleReconnect();
|
||||
};
|
||||
ws.onerror = function () {
|
||||
try { ws.close(); } catch (e) {}
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
setTimeout(function () {
|
||||
backoff = Math.min(backoff * 2, 10000);
|
||||
connect();
|
||||
}, backoff);
|
||||
}
|
||||
|
||||
// Auto-grow the textarea and submit on Enter (Shift+Enter = newline).
|
||||
prompt.addEventListener("input", function () {
|
||||
prompt.style.height = "auto";
|
||||
prompt.style.height = Math.min(prompt.scrollHeight, window.innerHeight * 0.4) + "px";
|
||||
});
|
||||
prompt.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var text = prompt.value;
|
||||
if (!text.trim()) return;
|
||||
if (send({ type: "input", text: text })) {
|
||||
prompt.value = "";
|
||||
prompt.style.height = "auto";
|
||||
}
|
||||
});
|
||||
|
||||
setStatus("", "connecting…");
|
||||
connect();
|
||||
})();
|
||||
@@ -0,0 +1,135 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b0f14">
|
||||
<title>pigo remote</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121822;
|
||||
--panel-2: #1a2331;
|
||||
--fg: #e6edf3;
|
||||
--muted: #8b98a5;
|
||||
--accent: #4f9cf9;
|
||||
--ok: #3fb950;
|
||||
--danger: #f85149;
|
||||
--border: #223042;
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; height: 100%;
|
||||
background: var(--bg); color: var(--fg);
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100dvh;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .6rem .8rem; border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
header .title { font-weight: 600; letter-spacing: .3px; }
|
||||
#status {
|
||||
margin-left: auto; font-size: .78rem; color: var(--muted);
|
||||
display: inline-flex; align-items: center; gap: .4rem;
|
||||
}
|
||||
#dot { width: .6rem; height: .6rem; border-radius: 50%; background: var(--muted); }
|
||||
#dot.on { background: var(--ok); }
|
||||
#dot.off { background: var(--danger); }
|
||||
|
||||
#output {
|
||||
flex: 1 1 auto; overflow-y: auto; overflow-x: auto;
|
||||
padding: .8rem; margin: 0;
|
||||
font-family: var(--mono); font-size: .84rem; line-height: 1.45;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
form#composer {
|
||||
display: flex; gap: .5rem; padding: .6rem;
|
||||
border-top: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
#prompt {
|
||||
flex: 1 1 auto; resize: none;
|
||||
background: var(--panel-2); color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: .6rem;
|
||||
padding: .6rem .7rem; font-size: 1rem; font-family: inherit;
|
||||
max-height: 40vh; min-height: 2.6rem;
|
||||
}
|
||||
button {
|
||||
border: 0; border-radius: .6rem; padding: 0 1rem;
|
||||
font-size: .95rem; font-weight: 600; cursor: pointer;
|
||||
background: var(--accent); color: #04121f;
|
||||
}
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* Confirmation sheet */
|
||||
#confirm {
|
||||
position: fixed; inset: 0; display: none;
|
||||
align-items: flex-end; justify-content: center;
|
||||
background: rgba(0,0,0,.55); z-index: 10;
|
||||
}
|
||||
#confirm.show { display: flex; }
|
||||
.sheet {
|
||||
width: 100%; max-width: 640px;
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 1rem 1rem 0 0; padding: 1rem 1rem 1.3rem;
|
||||
}
|
||||
.sheet h2 { margin: 0 0 .3rem; font-size: 1rem; }
|
||||
.sheet .tool { color: var(--accent); font-family: var(--mono); }
|
||||
.sheet pre {
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: .6rem; padding: .6rem; margin: .6rem 0 1rem;
|
||||
font-family: var(--mono); font-size: .82rem; overflow-x: auto;
|
||||
white-space: pre-wrap; word-break: break-word; max-height: 40vh;
|
||||
}
|
||||
.sheet .row { display: flex; gap: .5rem; }
|
||||
.sheet .row button { flex: 1; padding: .7rem 0; }
|
||||
.btn-approve { background: var(--ok); color: #04121f; }
|
||||
.btn-reject { background: var(--danger); color: #1a0303; }
|
||||
.btn-always { background: var(--panel-2); color: var(--fg); border: 1px solid var(--border); }
|
||||
label.always {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
color: var(--muted); font-size: .85rem; margin-bottom: .8rem;
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
#confirm { align-items: center; }
|
||||
.sheet { border-radius: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="title">pigo remote</span>
|
||||
<span id="status"><span id="dot"></span><span id="statusText">connecting…</span></span>
|
||||
</header>
|
||||
|
||||
<pre id="output" aria-live="polite"></pre>
|
||||
|
||||
<form id="composer" autocomplete="off">
|
||||
<textarea id="prompt" rows="1" placeholder="Type a prompt and press Send…" enterkeyhint="send"></textarea>
|
||||
<button id="send" type="submit">Send</button>
|
||||
</form>
|
||||
|
||||
<div id="confirm" role="dialog" aria-modal="true" aria-labelledby="confirmTitle">
|
||||
<div class="sheet">
|
||||
<h2 id="confirmTitle">Approve <span class="tool" id="confirmTool"></span>?</h2>
|
||||
<pre id="confirmSummary"></pre>
|
||||
<label class="always"><input type="checkbox" id="confirmAlways"> Always approve this tool</label>
|
||||
<div class="row">
|
||||
<button type="button" class="btn-reject" id="btnReject">Reject</button>
|
||||
<button type="button" class="btn-approve" id="btnApprove">Approve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user