first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+153
View File
@@ -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();
})();
+135
View File
@@ -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>