pyrtan / sourcev0.19.0 · main

Merge pull request #26 from b-i-t-8/claude/focused-wright-oodha7

b-i-t-8 · 2026-10-27 · f346bb47eb1c94f0f784c9863c2fda0ffa6ae287 · parents 54ef0cf f247772

Suggest sentences in the ask box: Tab completes, a list drops down

Changes

 src/pyrtan/page.py        | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 tests/test_service_hub.py |   1 +
 2 files changed, 129 insertions(+), 3 deletions(-)
diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py
index 492ef28..9700a83 100644
--- a/src/pyrtan/page.py
+++ b/src/pyrtan/page.py
@@ -85,6 +85,15 @@ PAGE = r"""<!doctype html>
.status { font-weight:600 } .done{color:var(--ok)} .failed,.error{color:var(--bad)} .asked{color:var(--ask)} .say { font-size:18px; margin:6px 0 } pre { margin:8px 0 0; padding:10px 12px; background:var(--bg); border:1px solid var(--line); border-radius:8px; overflow:auto; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap }
+ .suggest { position:relative; flex:1 1 180px; min-width:0 }
+ form.row .suggest input[type=text] { position:relative; width:100%; background:transparent }
+ .suggest .ghost { position:absolute; inset:0; padding:10px 12px; border:1px solid transparent; border-radius:8px; background:var(--card); white-space:pre; overflow:hidden; pointer-events:none }
+ .suggest .ghost .typed { visibility:hidden } .suggest .ghost .rest { color:var(--mute) }
+ .suggestions { position:absolute; z-index:10; left:0; right:0; top:calc(100% + 4px); list-style:none; margin:0; padding:4px 0; background:var(--card); border:1px solid var(--line); border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.12); max-height:320px; overflow-y:auto }
+ .suggestions li { padding:7px 12px; cursor:pointer; display:flex; gap:8px; align-items:baseline }
+ .suggestions li[aria-selected=true], .suggestions li:hover { background:var(--soft) }
+ .suggestions li b { font-weight:600 } .suggestions li .text { flex:1; min-width:0; overflow-wrap:anywhere }
+ .suggestions li .from { color:var(--mute); font-size:13px }
details summary { cursor:pointer; color:var(--mute) } ul.tries { list-style:none; padding:0; margin:0; display:flex; flex-wrap:wrap; gap:8px } ul.tries li a { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:var(--card); color:var(--fg); text-decoration:none; font-size:14px }
@@ -152,7 +161,11 @@ PAGE = r"""<!doctype html>
<section id="tab-ask"> <form class="row" id="ask">
- <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off">
+ <div class="suggest">
+ <div class="ghost" id="ghost" aria-hidden="true"><span class="typed"></span><span class="rest"></span></div>
+ <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="both" aria-expanded="false" aria-controls="suggestions">
+ <ul class="suggestions" id="suggestions" role="listbox" aria-label="Suggestions" hidden></ul>
+ </div>
<button type="submit" id="go">Ask</button> </form> <div class="card" style="margin-bottom:24px">
@@ -423,10 +436,18 @@ async function loadDirectory() {
} window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1)));
+// A trigger as a sentence: the first of each choice, an input's default or "…"
+// for its blank, and the optional words kept or dropped.
+function phrase(t, pat, optional) {
+ return pat.replace(/\[([^\]|]*)[^\]]*\]/g, optional ? "$1" : "").replace(/\(([^|)]*)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim();
+}
function sample(t) { if (examples[t.name]) return examples[t.name];
- const pat = (t.triggers || [])[0]; if (!pat) return null;
- return pat.replace(/\[[^\]]*\]/g, "").replace(/\((\w+)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim();
+ const pat = (t.triggers || [])[0]; return pat ? phrase(t, pat, false) : null;
+}
+// Every sentence a tree answers to, for the ask box to suggest; one that starts with a blank suggests nothing.
+function samples(t) {
+ return [examples[t.name], ...(t.triggers || []).map(p => phrase(t, p, true))].filter(s => s && !s.startsWith("…"));
} async function detect() {
@@ -548,6 +569,7 @@ async function load() {
problem(local && !(c.trees || []).length ? "No catalog answered and none was kept in this browser, so it holds no trees yet. Join a hub above, or try again online." : "", true); const body = $("#caps tbody"); const tries = $("#tries"); body.innerHTML = ""; tries.innerHTML = "";
+ suggest.from((c.trees || []).filter(t => !local || (runsHere(t) && !anySite(t))));
for (const t of c.trees || []) { const tr = document.createElement("tr"); const here = !local ? "" : !runsHere(t) ? `<div class="gap">needs a hub: ${esc((t.sources||[]).filter(x => x !== "http").join(", "))}</div>` : anySite(t) ? '<div class="gap">any site; most will not let a page read them</div>' : "";
@@ -555,6 +577,9 @@ async function load() {
body.appendChild(tr); const s = sample(t); if (s && !s.includes("…") && (!local || (runsHere(t) && !anySite(t)))) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); } }
+ // The greyed suggestion in the empty box (Tab takes it) is one this swarm answers.
+ const first = tries.querySelector("a[data-q]"), offered = [...tries.querySelectorAll("a[data-q]")].map(a => a.dataset.q);
+ if (first && !offered.includes($("#text").placeholder)) $("#text").placeholder = first.dataset.q;
showTab((location.hash || "#ask").slice(1)); }
@@ -632,6 +657,7 @@ function renderTrace(tr) {
async function ask(snapshot, answer) { const text = $("#text").value.trim(); if (!text && !snapshot) return;
+ if (!snapshot) { suggest.remember(text); suggest.close(); }
$("#go").disabled = true; const t0 = performance.now(); let r;
@@ -664,6 +690,105 @@ async function ask(snapshot, answer) {
} $("#ask").addEventListener("submit", (e) => { e.preventDefault(); ask(); });
+
+// ---------------------------------------------------------------- suggestions in the ask box
+// The sentences this swarm's trees answer to, and the ones asked in this
+// browser before. The best that starts with what is typed shows greyed after
+// it (Tab or → takes it; Tab in the empty box takes the placeholder), and up to
+// eight drop down under the box: ↑ ↓ pick, Enter asks, Esc closes. A sentence
+// with a blank ("latest release of …") fills the box up to the blank instead of asking.
+const ASKED_KEY = "pyrtan.console.asked";
+const suggest = {
+ pool: [], items: [], active: -1, typed: "", open: false,
+ input: $("#text"), list: $("#suggestions"),
+ recent() { try { const r = JSON.parse(localStorage.getItem(ASKED_KEY) || "[]"); return Array.isArray(r) ? r.filter(s => typeof s === "string") : []; } catch (e) { return []; } },
+ remember(text) {
+ const r = [text, ...this.recent().filter(s => s.toLowerCase() !== text.toLowerCase())].slice(0, 20);
+ try { localStorage.setItem(ASKED_KEY, JSON.stringify(r)); } catch (e) {}
+ },
+ from(trees) {
+ const seen = new Set(); this.pool = [];
+ for (const t of trees) for (const text of samples(t)) { const k = text.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.pool.push({text, tree: t.name}); } }
+ },
+ // Recent first, then the trees': those that start with what is typed, then those with every typed word starting one of theirs.
+ match(q) {
+ const low = q.toLowerCase(), words = low.split(/\s+/).filter(Boolean), seen = new Set(), out = [];
+ const add = (e) => { const k = e.text.toLowerCase(); if (!seen.has(k) && out.length < 8) { seen.add(k); out.push(e); } };
+ const all = [...this.recent().map(text => ({text, recent: true})), ...this.pool];
+ for (const e of all) if (e.text.toLowerCase().startsWith(low)) add(e);
+ for (const e of all) { const ws = e.text.toLowerCase().split(/\s+/); if (words.every(w => ws.some(x => x.startsWith(w)))) add(e); }
+ return out;
+ },
+ fill(e) { const i = e.text.indexOf("…"); return i < 0 ? e.text : e.text.slice(0, i); },
+ // The rest of the first suggestion that starts with what is typed, while the caret is at the end.
+ completion() {
+ const el = this.input, v = el.value, low = v.toLowerCase();
+ if (!v || document.activeElement !== el || el.selectionStart !== v.length || el.selectionEnd !== v.length) return "";
+ const e = this.items.find(e => { const f = this.fill(e); return f.length > v.length && f.toLowerCase().startsWith(low); });
+ return e ? this.fill(e).slice(v.length) : "";
+ },
+ ghost() {
+ const rest = this.input.scrollWidth <= this.input.clientWidth ? this.completion() : ""; // scrolled text would not line up
+ $("#ghost .typed").textContent = rest ? this.input.value : "";
+ $("#ghost .rest").textContent = rest;
+ },
+ render() {
+ const v = this.typed, low = v.toLowerCase();
+ this.list.innerHTML = "";
+ this.items.forEach((e, i) => {
+ const li = document.createElement("li");
+ li.id = "suggestion-" + i; li.setAttribute("role", "option"); li.setAttribute("aria-selected", String(i === this.active));
+ const head = v && e.text.toLowerCase().startsWith(low) ? v.length : v ? 0 : e.text.length; // what is typed plain, the rest bold
+ li.innerHTML = `<span class="text">${esc(e.text.slice(0, head))}<b>${esc(e.text.slice(head))}</b></span><span class="from">${e.recent ? "recent" : esc(e.tree)}</span>`;
+ li.addEventListener("click", () => this.choose(e));
+ this.list.appendChild(li);
+ });
+ const shown = this.open && this.items.length > 0;
+ this.list.hidden = !shown; this.input.setAttribute("aria-expanded", String(shown));
+ if (shown && this.active >= 0) this.input.setAttribute("aria-activedescendant", "suggestion-" + this.active); else this.input.removeAttribute("aria-activedescendant");
+ },
+ refresh() {
+ this.typed = this.input.value; this.active = -1;
+ this.items = this.open ? this.match(this.typed) : [];
+ this.render(); this.ghost();
+ },
+ close() { this.open = false; this.refresh(); },
+ choose(e) {
+ this.input.value = this.fill(e); this.input.focus();
+ if (e.text.includes("…")) { this.open = true; this.refresh(); } else ask();
+ },
+ key(ev) {
+ if (ev.isComposing || ev.ctrlKey || ev.altKey || ev.metaKey) return;
+ const shown = !this.list.hidden;
+ if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
+ ev.preventDefault();
+ if (!shown) { this.open = true; this.refresh(); return; }
+ // -1 is what was typed: the list wraps through it, as a search box's does.
+ const n = this.items.length + 1;
+ this.active = ((this.active + 1 + (ev.key === "ArrowDown" ? 1 : -1)) % n + n) % n - 1;
+ this.input.value = this.active < 0 ? this.typed : this.fill(this.items[this.active]);
+ this.render(); this.ghost();
+ } else if ((ev.key === "Tab" && !ev.shiftKey) || ev.key === "ArrowRight") {
+ const v = this.input.value, rest = this.completion();
+ const take = rest ? v + rest : ev.key === "Tab" && !v ? this.input.placeholder : "";
+ if (!take) return; // nothing to complete: Tab moves on, → moves the caret
+ ev.preventDefault(); this.input.value = take; this.open = true; this.refresh();
+ } else if (ev.key === "Escape" && shown) {
+ ev.preventDefault(); this.input.value = this.typed; this.close();
+ } else if (ev.key === "Enter" && shown && this.active >= 0 && this.items[this.active].text.includes("…")) {
+ ev.preventDefault(); this.choose(this.items[this.active]);
+ }
+ },
+};
+suggest.input.addEventListener("input", () => { suggest.open = true; suggest.refresh(); });
+suggest.input.addEventListener("keydown", (e) => suggest.key(e));
+suggest.input.addEventListener("keyup", () => suggest.ghost());
+suggest.input.addEventListener("mousedown", () => { if (!suggest.open) { suggest.open = true; suggest.refresh(); } });
+suggest.input.addEventListener("click", () => suggest.ghost());
+suggest.input.addEventListener("focus", () => suggest.ghost());
+suggest.input.addEventListener("blur", () => suggest.close());
+suggest.list.addEventListener("mousedown", (e) => e.preventDefault()); // keep the box focused, so the click lands
+window.addEventListener("resize", () => suggest.ghost());
$("#tries").addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; location.hash = "#ask"; ask(); }); $("#connect-form").addEventListener("submit", (e) => { e.preventDefault(); diff --git a/tests/test_service_hub.py b/tests/test_service_hub.py index a0e6bc7..965c14f 100644 --- a/tests/test_service_hub.py +++ b/tests/test_service_hub.py
@@ -223,6 +223,7 @@ def test_demo_page_is_served_by_service_and_hub(example_service):
# The wordmark's two halves pick their own colours; the picker is inline, not another request. assert b'data-theme-part="pyr">pyr<' in page and b'data-theme-part="tan">tan<' in page assert b"__THEME__" not in page and b'localStorage.getItem(KEY)' in page
+ assert b'role="combobox"' in page and b'id="suggestions"' in page and b'pyrtan.console.asked' in page # the ask box suggests
status, api = http_json("GET", f"{server.url}/api") assert status == 200 and api["pyrtan"] == "service" hub = Hub()