Add explore skill: interactive pseudocode browser
Serves a localhost page in the pseudo-skill register with the reference markers as clickable buttons: clicks land in <workdir>/events.jsonl, a persistent Monitor tail wakes the session, the session rewrites page.html, and the browser live-reloads over SSE. Actions per marker: expand / show real code / explain / ask, plus a page-level ask box; anchors become forge deep-links when the project has a reachable remote; incidental findings are surfaced on the page as anchored notes. The port is configured in one place (the PORT constant atop scripts/server.py; --port overrides per run). Mechanics proven live against the aura repo before extraction into this skill.
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: explore
|
||||||
|
description: Use when the user wants to explore a project interactively in the browser — invoked as `/explore <project>` or when the user asks for a clickable/served pseudocode page. Serves a localhost page showing the project as commented pseudocode in the pseudo-skill register, with the reference markers rendered as clickable buttons; every click (expand / show real code / explain / ask, plus a free-text box) lands as an event that wakes the session through a Monitor back-channel, the session rewrites the page, and the browser live-reloads via SSE. Anchors become forge deep-links when the project has a web-reachable remote; incidental findings (doc/code drift, stale comments) are surfaced on the page itself as anchored finding notes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# explore — interactive pseudocode browser
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`/explore <project>` turns the `pseudo` skill's register into a served,
|
||||||
|
clickable page. The user reads pseudocode in the browser and zooms by
|
||||||
|
clicking instead of typing: each marker is a button, each click wakes
|
||||||
|
the session, the session rewrites the page, SSE reloads the browser.
|
||||||
|
|
||||||
|
The loop:
|
||||||
|
|
||||||
|
```
|
||||||
|
browser click ──POST /event──▶ server.py ──append──▶ <workdir>/events.jsonl
|
||||||
|
│
|
||||||
|
Monitor "tail -F -n 0 events.jsonl"
|
||||||
|
│
|
||||||
|
this session
|
||||||
|
│
|
||||||
|
browser ◀──SSE "reload"── server.py ◀──mtime── rewrites <workdir>/page.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Latency expectations are conversational, not app-like: every click costs
|
||||||
|
one thinking cycle. Tell the user this once at start.
|
||||||
|
|
||||||
|
## Moving parts
|
||||||
|
|
||||||
|
- `scripts/server.py` — stdlib localhost server. **The port is
|
||||||
|
configured in exactly one place: the `PORT` constant at the top of
|
||||||
|
this file.** A per-run override exists (`--port N`) for a second
|
||||||
|
concurrent instance; the constant is the default every instance
|
||||||
|
shares.
|
||||||
|
- `templates/page.html` — the page scaffold (CSS, click menu, ask box,
|
||||||
|
SSE client). Placeholders: `{{TITLE}}` (project name, appears in
|
||||||
|
tab and header), `{{HINT}}` (one line: what the project is + current
|
||||||
|
altitude), `{{CONTENT}}` (the pseudocode block, see content rules).
|
||||||
|
- Workdir `~/.cache/explore/<project-slug>/` — holds the generated
|
||||||
|
`page.html` and `events.jsonl`. Scratch, never committed, safe to
|
||||||
|
delete after the session.
|
||||||
|
|
||||||
|
## Start procedure
|
||||||
|
|
||||||
|
1. `mkdir -p ~/.cache/explore/<project-slug>`
|
||||||
|
2. Generate `<workdir>/page.html` from `templates/page.html`, filling
|
||||||
|
the three placeholders. `{{CONTENT}}` starts at the project's main
|
||||||
|
entry point (pseudo rule 9) unless the user pointed elsewhere.
|
||||||
|
3. Start the server in the background:
|
||||||
|
`python3 <skill-dir>/scripts/server.py <workdir>` (add `--port N`
|
||||||
|
only for a second concurrent instance).
|
||||||
|
4. Arm the back-channel as a persistent Monitor:
|
||||||
|
`tail -F -n 0 <workdir>/events.jsonl` (`-n 0` is load-bearing:
|
||||||
|
without it, stale events replay at arm time).
|
||||||
|
5. Give the user the URL the server printed.
|
||||||
|
|
||||||
|
## Content rules
|
||||||
|
|
||||||
|
The `pseudo` skill's Iron Law governs everything inside `{{CONTENT}}`:
|
||||||
|
anchor line first, one altitude per view, markers as pointing handles
|
||||||
|
explained inline (never a legend), data shapes beside control flow,
|
||||||
|
reduce/omit the rest. Read that skill's rules before generating the
|
||||||
|
first page. On top of it, the served medium adds:
|
||||||
|
|
||||||
|
- **Escape first, tag second.** `{{CONTENT}}` and every `.note` body
|
||||||
|
are substituted into HTML: escape `&`, `<`, `>` in all source and
|
||||||
|
pseudocode text before insertion (Rust is full of `Vec<T>`, `&mut`,
|
||||||
|
`a < b`), and only then wrap tokens in highlight spans. A stray `<`
|
||||||
|
swallows the rest of the page, including the SSE script.
|
||||||
|
- **Markers are buttons.** `[N]` renders as
|
||||||
|
`<button class="marker" data-marker="N">[N]</button>`. Expansions
|
||||||
|
introduce sub-markers (`4a`, `4b`) — the zoom is recursive.
|
||||||
|
- **Anchors are links.** When the project has a web-reachable forge
|
||||||
|
remote (Gitea/GitHub), every file anchor becomes an
|
||||||
|
`<a class="src" href="…#L10-L20" target="_blank" rel="noopener">`
|
||||||
|
deep-link. Verify the URL pattern once per project (curl the file
|
||||||
|
URL, expect 200) before emitting the first link; skip links when
|
||||||
|
there is no reachable remote.
|
||||||
|
- **Real code** (`code` action): a `.note.code` block at the marker —
|
||||||
|
caption line = linked `file:from–to` anchor, then the code verbatim
|
||||||
|
with light span highlighting (`.c` comments, `.k` keywords, `.s`
|
||||||
|
strings).
|
||||||
|
- **Explanations** (`explain` action): a `.note` block anchored at the
|
||||||
|
marker. Page-level `ask` answers go in a `<section id="qa">` between
|
||||||
|
the pseudocode and the menu div (question dimmed in `.q`, answer in
|
||||||
|
`.note`).
|
||||||
|
- **Findings.** Anything noticed in passing — doc/code drift, a stale
|
||||||
|
comment, a suspicious contract — is surfaced on the page itself: an
|
||||||
|
amber `.note.finding` (⚠) anchored at the spot with deep-links to
|
||||||
|
the evidence, and a wavy `.warn` span (with `title` tooltip) on the
|
||||||
|
stale phrase where it is displayed. A one-line chat mention suffices
|
||||||
|
beside it; filing a tracker issue stays a separate ask.
|
||||||
|
- **Language.** Scaffold and pseudocode are English (repo register).
|
||||||
|
Answers to `ask` events are written in the user's language.
|
||||||
|
|
||||||
|
## Handling events
|
||||||
|
|
||||||
|
Each Monitor line is one click: `{marker, action, text?, ts}`
|
||||||
|
(`marker: null` = page-level; `text` only on `ask`). Respond by
|
||||||
|
rewriting `<workdir>/page.html` — the SSE watcher reloads the browser
|
||||||
|
automatically, so the rewrite IS the reply. Every rewrite MUST
|
||||||
|
preserve the full template scaffold: the SSE `<script>` (EventSource +
|
||||||
|
reload listener), the `#menu` div, and the `#ask` form — after each
|
||||||
|
reload that script is what re-binds every marker button. Edit only the
|
||||||
|
pseudocode, the anchored notes, and the `#qa` section; dropping the
|
||||||
|
scaffold silently kills the loop. Read the real source
|
||||||
|
before expanding or explaining; keep each response at the altitude the
|
||||||
|
click asked for. Batch related edits into as few writes as possible
|
||||||
|
(each write triggers a visible reload).
|
||||||
|
|
||||||
|
## Stop
|
||||||
|
|
||||||
|
`TaskStop` the server task and the Monitor. The workdir is scratch and
|
||||||
|
may be deleted. To switch projects: stop the server, start it again on
|
||||||
|
the new project's workdir, and re-arm the Monitor on the NEW workdir's
|
||||||
|
`events.jsonl` — server and monitor are both bound to one workdir at
|
||||||
|
startup, so neither survives a workdir switch.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""explore — localhost server behind the /explore skill.
|
||||||
|
|
||||||
|
Serves <workdir>/page.html, records marker-click events into
|
||||||
|
<workdir>/events.jsonl (one JSON object per line), and pushes an SSE
|
||||||
|
"reload" event whenever page.html changes on disk. The Claude session
|
||||||
|
arms a Monitor (`tail -F -n 0 <workdir>/events.jsonl`) as the
|
||||||
|
back-channel; rewriting page.html is the reply channel.
|
||||||
|
|
||||||
|
Usage: server.py <workdir> [--port N]
|
||||||
|
|
||||||
|
Stdlib only. Binds to localhost.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# The single place the default port is configured (--port overrides per run).
|
||||||
|
PORT = 8000
|
||||||
|
|
||||||
|
# SSE tuning: how often to poll the page mtime, and how many idle polls
|
||||||
|
# between keep-alive comments (0.5s * 30 = one ping every 15s).
|
||||||
|
POLL_S = 0.5
|
||||||
|
PINGS_EVERY = 30
|
||||||
|
|
||||||
|
# Set in main() from the workdir argument.
|
||||||
|
PAGE = None
|
||||||
|
EVENTS = None
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path in ("/", "/index.html"):
|
||||||
|
self._serve_page()
|
||||||
|
elif self.path == "/stream":
|
||||||
|
self._serve_stream()
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path == "/event":
|
||||||
|
self._record_event()
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def _serve_page(self):
|
||||||
|
# Read fresh from disk every time — Claude rewrites this file.
|
||||||
|
try:
|
||||||
|
body = PAGE.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
self.send_error(503, "page.html missing")
|
||||||
|
return
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _serve_stream(self):
|
||||||
|
# Long-lived SSE connection: emit "reload" whenever page.html
|
||||||
|
# changes on disk, keep-alive comments in between.
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
last = PAGE.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
last = 0
|
||||||
|
idle = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(POLL_S)
|
||||||
|
try:
|
||||||
|
cur = PAGE.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
continue # page briefly missing mid-rewrite
|
||||||
|
if cur != last:
|
||||||
|
last = cur
|
||||||
|
self.wfile.write(b"event: reload\ndata: 1\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
idle = 0
|
||||||
|
else:
|
||||||
|
idle += 1
|
||||||
|
if idle >= PINGS_EVERY:
|
||||||
|
self.wfile.write(b": ping\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
idle = 0
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
return # client went away — normal
|
||||||
|
|
||||||
|
def _record_event(self):
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
except ValueError:
|
||||||
|
self.send_error(411)
|
||||||
|
return
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
try:
|
||||||
|
event = json.loads(raw)
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
raise ValueError("expected a JSON object")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
self.send_error(400, "invalid JSON")
|
||||||
|
return
|
||||||
|
event["ts"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
# Single-line appends in "a" mode are atomic enough here.
|
||||||
|
with EVENTS.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||||
|
self.send_response(204)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass # keep stdout clean — it is the background-task log
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global PAGE, EVENTS
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("workdir", type=Path, help="directory holding page.html and events.jsonl")
|
||||||
|
ap.add_argument("--port", type=int, default=PORT, help=f"listen port (default {PORT})")
|
||||||
|
a = ap.parse_args()
|
||||||
|
workdir = a.workdir.resolve()
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
PAGE = workdir / "page.html"
|
||||||
|
EVENTS = workdir / "events.jsonl"
|
||||||
|
EVENTS.touch()
|
||||||
|
srv = ThreadingHTTPServer(("127.0.0.1", a.port), Handler)
|
||||||
|
print(f"explore serving {workdir} on http://127.0.0.1:{a.port}", flush=True)
|
||||||
|
srv.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>explore — {{TITLE}}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #10141a; --panel: #171c24; --border: #232b36;
|
||||||
|
--fg: #d6dde6; --dim: #68737f; --comment: #7d8894;
|
||||||
|
--kw: #c792ea; --str: #a5d6a7;
|
||||||
|
--marker-bg: #1f3a52; --marker-border: #35618a; --marker-fg: #8fd6ff;
|
||||||
|
--ok: #63d68f; --busy: #e8c268; --err: #e87979;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; }
|
||||||
|
body {
|
||||||
|
background: var(--bg); color: var(--fg);
|
||||||
|
font: 15px/1.5 "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||||
|
max-width: 980px; margin-inline: auto; padding: 2rem 1.5rem 5rem;
|
||||||
|
}
|
||||||
|
header { display: flex; align-items: baseline; gap: .9rem; margin-bottom: .4rem; }
|
||||||
|
h1 { font-size: 1.05rem; margin: 0; font-weight: 600; }
|
||||||
|
#conn { font-size: .78rem; color: var(--dim); }
|
||||||
|
#conn.live::before { content: "\25CF "; color: var(--ok); }
|
||||||
|
#conn.dead::before { content: "\25CF "; color: var(--err); }
|
||||||
|
p.hint { color: var(--dim); font-size: .82rem; margin: 0 0 1.4rem; }
|
||||||
|
pre.pseudo {
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 1.15rem 1.35rem; overflow-x: auto; line-height: 1.72; margin: 0;
|
||||||
|
}
|
||||||
|
.c { color: var(--comment); }
|
||||||
|
.note {
|
||||||
|
display: block; margin: .4rem 0 .55rem; padding: .5rem .85rem;
|
||||||
|
border-left: 3px solid var(--marker-border); border-radius: 0 6px 6px 0;
|
||||||
|
background: #1a2330; color: #aebccb; font-size: .88em; line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.note.code { white-space: pre; overflow-x: auto; font-size: .85em; line-height: 1.5; color: var(--fg); }
|
||||||
|
.note.finding { border-left-color: var(--busy); background: #241f14; color: #d9c9a0; }
|
||||||
|
.warn { text-decoration: underline wavy var(--busy); text-decoration-skip-ink: none; cursor: help; }
|
||||||
|
.k { color: var(--kw); }
|
||||||
|
.s { color: var(--str); }
|
||||||
|
a.src { color: inherit; text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||||
|
a.src:hover { color: var(--marker-fg); }
|
||||||
|
button.marker {
|
||||||
|
font: inherit; font-size: .92em; line-height: 1.25;
|
||||||
|
border: 1px solid var(--marker-border); border-radius: 5px;
|
||||||
|
background: var(--marker-bg); color: var(--marker-fg);
|
||||||
|
padding: 0 .4em; cursor: pointer; vertical-align: baseline;
|
||||||
|
}
|
||||||
|
button.marker:hover { background: #2a4f70; }
|
||||||
|
|
||||||
|
/* click menu */
|
||||||
|
#menu {
|
||||||
|
position: fixed; z-index: 10; display: none; flex-direction: column;
|
||||||
|
background: var(--panel); border: 1px solid var(--marker-border);
|
||||||
|
border-radius: 8px; padding: .3rem; box-shadow: 0 6px 24px #0009;
|
||||||
|
}
|
||||||
|
#menu.open { display: flex; }
|
||||||
|
#menu button {
|
||||||
|
font: inherit; font-size: .85rem; text-align: left;
|
||||||
|
background: none; border: 0; color: var(--fg);
|
||||||
|
padding: .35rem .7rem; border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
#menu button:hover { background: var(--marker-bg); color: var(--marker-fg); }
|
||||||
|
|
||||||
|
/* page-level Q&A */
|
||||||
|
#qa { margin-top: 1.3rem; }
|
||||||
|
#qa .q { color: var(--dim); font-size: .84rem; margin: 0 0 .35rem; }
|
||||||
|
|
||||||
|
/* status + ask box */
|
||||||
|
#status { min-height: 1.4rem; margin: 1rem 0 0; font-size: .84rem; color: var(--dim); }
|
||||||
|
#status.busy { color: var(--busy); }
|
||||||
|
#status.err { color: var(--err); }
|
||||||
|
form#ask { display: flex; gap: .6rem; margin-top: 1.6rem; }
|
||||||
|
form#ask input {
|
||||||
|
flex: 1; font: inherit; font-size: .88rem;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 7px;
|
||||||
|
color: var(--fg); padding: .5rem .8rem; outline: none;
|
||||||
|
}
|
||||||
|
form#ask input:focus { border-color: var(--marker-border); }
|
||||||
|
form#ask button {
|
||||||
|
font: inherit; font-size: .88rem; cursor: pointer;
|
||||||
|
background: var(--marker-bg); color: var(--marker-fg);
|
||||||
|
border: 1px solid var(--marker-border); border-radius: 7px; padding: .5rem 1rem;
|
||||||
|
}
|
||||||
|
form#ask button:hover { background: #2a4f70; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>explore — {{TITLE}}</h1>
|
||||||
|
<span id="conn" class="dead">connecting…</span>
|
||||||
|
</header>
|
||||||
|
<p class="hint">{{HINT}} Click a <button class="marker" type="button" tabindex="-1" style="pointer-events:none">[n]</button> marker to zoom.</p>
|
||||||
|
|
||||||
|
<pre class="pseudo">{{CONTENT}}</pre>
|
||||||
|
|
||||||
|
<div id="menu" role="menu">
|
||||||
|
<button data-action="expand">expand — one level deeper</button>
|
||||||
|
<button data-action="code">show real code</button>
|
||||||
|
<button data-action="explain">explain</button>
|
||||||
|
<button data-action="ask">ask about this…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="status"></p>
|
||||||
|
|
||||||
|
<form id="ask">
|
||||||
|
<input name="q" placeholder="Ask anything about this page…" autocomplete="off">
|
||||||
|
<button type="submit">send</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
const menu = document.getElementById("menu");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
let currentMarker = null;
|
||||||
|
|
||||||
|
function setStatus(cls, text) {
|
||||||
|
statusEl.className = cls;
|
||||||
|
statusEl.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(ev) {
|
||||||
|
const label = ev.marker ? `${ev.action} [${ev.marker}]` : ev.action;
|
||||||
|
setStatus("busy", `sent: ${label} — waiting for Claude to rewrite the page…`);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/event", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(ev),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
} catch {
|
||||||
|
setStatus("err", "send failed — is the explore server running?");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// marker click -> action menu next to the marker
|
||||||
|
document.querySelectorAll("button.marker[data-marker]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
currentMarker = btn.dataset.marker;
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
menu.classList.add("open");
|
||||||
|
// measure after showing, then clamp into the viewport
|
||||||
|
const mw = menu.offsetWidth, mh = menu.offsetHeight;
|
||||||
|
let x = rect.left, y = rect.bottom + 6;
|
||||||
|
if (x + mw > innerWidth - 8) x = innerWidth - mw - 8;
|
||||||
|
if (y + mh > innerHeight - 8) y = rect.top - mh - 6;
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.addEventListener("click", (e) => {
|
||||||
|
const action = e.target.dataset && e.target.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
menu.classList.remove("open");
|
||||||
|
if (action === "ask") {
|
||||||
|
const text = prompt(`Question about [${currentMarker}]:`);
|
||||||
|
if (text === null || text.trim() === "") return;
|
||||||
|
send({ marker: currentMarker, action, text: text.trim() });
|
||||||
|
} else {
|
||||||
|
send({ marker: currentMarker, action });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", () => menu.classList.remove("open"));
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") menu.classList.remove("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
// free-text question about the whole page
|
||||||
|
document.getElementById("ask").addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.target.elements.q;
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
send({ marker: null, action: "ask", text });
|
||||||
|
input.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// live-reload via SSE
|
||||||
|
const conn = document.getElementById("conn");
|
||||||
|
const es = new EventSource("/stream");
|
||||||
|
es.addEventListener("reload", () => location.reload());
|
||||||
|
es.onopen = () => { conn.className = "live"; conn.textContent = "live"; };
|
||||||
|
es.onerror = () => { conn.className = "dead"; conn.textContent = "reconnecting…"; };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user