From 3170b4b17e6ebbc603946da2ac19c07807ee3589 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:47:21 +0300 Subject: [PATCH 01/10] A thread whose subagents wrote long steps opens on the web and phones The subagents panel took a step's first line by splitting its whole text, which recurses once per character: a 21 KB step overflowed the browser's stack and the thread never drew. Take the first line in one walk. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/core/subs.bend | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/subs.bend b/src/core/subs.bend index c555499f34..dae3b6b7e2 100644 --- a/src/core/subs.bend +++ b/src/core/subs.bend @@ -2,6 +2,7 @@ import Base import ./model.bend as M import ./fold.bend as FO import ./client.bend as U +import ./diff.bend as DF # Subagents # --------- @@ -39,8 +40,11 @@ def Subs.shown() -> Nat: def Subs.done_kept() -> Nat: 3n +# a step's first line, in one walk that stops at the first break (splitting +# a subagent's 20 KB step recursed once per character and overflowed the +# web's and the phones' stacks: the thread never opened) def Subs.line(+t: String) -> String: - Maybe.default(&2, String, List.head(&2, String, String.split(t, '\n')), "") + DF.Diff.first(t) # Reading the log # --------------- From f6596cbf73344d6989959e00289a888c9c525f73 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:47:29 +0300 Subject: [PATCH 02/10] Web: frames decoded in JavaScript, not in Bend host.js turned each frame into a Bend list of bytes, decoded the CBOR in Bend, printed it back to JSON and parsed that again to keep it: on a 15 MB log that was most of a 14 s page load. The phones' bridge already decoded natively; its decoder is now wire.js, shared, and the page keeps the plain object it gives. Same tree as Cbor.decode on every frame of a real log (test/tools/wire_js_test.ts); 9.4 s of decoding became 0.14 s. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/mobile/bridge.js | 92 +--------------------------- src/web/app.bend | 7 +++ src/web/host.js | 12 +++- src/web/wire.js | 119 +++++++++++++++++++++++++++++++++++++ test/tools/wire_js_test.ts | 27 +++++++++ 5 files changed, 165 insertions(+), 92 deletions(-) create mode 100644 src/web/wire.js create mode 100644 test/tools/wire_js_test.ts diff --git a/src/mobile/bridge.js b/src/mobile/bridge.js index 5d23974628..cf0fa3a506 100644 --- a/src/mobile/bridge.js +++ b/src/mobile/bridge.js @@ -11,6 +11,7 @@ // wire format; its dictionaries come from there). import App from "./app.bend"; +import { cbor as decode } from "../web/wire.js"; function* each(list) { for (let xs = list; xs && xs.$ === "Con"; xs = xs.tail) yield xs.head; @@ -42,95 +43,6 @@ function bytes(b64) { return out.subarray(0, n); } -// UTF-8 bytes [i, end) as a string -function utf8(b, i, end) { - let s = ""; - const cs = []; - while (i < end) { - let c = b[i++]; - if (c >= 0xf0) c = ((c & 7) << 18) | ((b[i++] & 63) << 12) | ((b[i++] & 63) << 6) | (b[i++] & 63); - else if (c >= 0xe0) c = ((c & 15) << 12) | ((b[i++] & 63) << 6) | (b[i++] & 63); - else if (c >= 0xc0) c = ((c & 31) << 6) | (b[i++] & 63); - if (c > 0xffff) { - c -= 0x10000; - cs.push(0xd800 | (c >> 10), 0xdc00 | (c & 1023)); - } else cs.push(c); - if (cs.length > 4096) { - s += String.fromCharCode.apply(null, cs); - cs.length = 0; - } - } - return s + String.fromCharCode.apply(null, cs); -} - -// CBOR bytes to Bend's Json tree, as Cbor.decode reads them; malformed or -// truncated bytes are Null, as there -function cbor(b) { - let i = 0; - const bad = () => { - throw new Error("cbor"); - }; - const arg = (ai) => { - if (ai < 24) return ai; - if (ai === 24) return b[i++]; - if (ai === 25) return (b[i++] << 8) | b[i++]; - if (ai === 26) return ((b[i++] << 24) | (b[i++] << 16) | (b[i++] << 8) | b[i++]) >>> 0; - return bad(); - }; - const text = (n) => { - if (i + n > b.length) bad(); - const s = utf8(b, i, i + n); - i += n; - return s; - }; - // tag: 0 none, 6 a dictionary word, 7 a number's raw text - const item = (tag) => { - if (i >= b.length) bad(); - const h = b[i++], maj = h >> 5, ai = h & 31; - if (maj === 6) return tag === 0 && (ai === 6 || ai === 7) ? item(ai) : bad(); - if (maj === 7) { - if (tag !== 0) bad(); - if (ai === 20) return { $: "Flag", value: false }; - if (ai === 21) return { $: "Flag", value: true }; - if (ai === 22) return { $: "Null" }; - return bad(); - } - const n = arg(ai); - if (i > b.length) bad(); - if (maj === 0 && tag === 0) return { $: "Num", raw: String(n) }; - if (maj === 0 && tag === 6) return n < WORDS.length ? { $: "Str", text: WORDS[n] } : bad(); - if (maj === 1 && tag === 0) return { $: "Num", raw: String(-1 - n) }; - if (maj === 3 && tag === 0) return { $: "Str", text: text(n) }; - if (maj === 3 && tag === 7) return { $: "Num", raw: text(n) }; - if (maj === 4 && tag === 0) { - const vs = []; - for (let k = 0; k < n; k += 1) vs.push(item(0)); - let items = { $: "End" }; - for (let k = n - 1; k >= 0; k -= 1) items = { $: "Item", head: vs[k], tail: items }; - return { $: "Arr", items }; - } - if (maj === 5 && tag === 0) { - const ks = [], vs = []; - for (let k = 0; k < n; k += 1) { - const key = item(0); - if (key.$ === "Str") ks.push(key.text); - else if (key.$ === "Num" && /^[0-9]+$/.test(key.raw) && Number(key.raw) < KEYS.length) ks.push(KEYS[Number(key.raw)]); - else bad(); - vs.push(item(0)); - } - let fields = { $: "End" }; - for (let k = n - 1; k >= 0; k -= 1) fields = { $: "Field", key: ks[k], value: vs[k], tail: fields }; - return { $: "Obj", fields }; - } - return bad(); - }; - try { - const v = item(0); - return i === b.length ? v : { $: "Null" }; - } catch { - return { $: "Null" }; - } -} // The client state as flat text and back, with no recursion: Bend lists // are chains of cells ({head, tail}) tens of thousands deep, and // JSON.parse with a reviver recursed once per cell, overflowed the @@ -299,7 +211,7 @@ globalThis.Backplane = { // owe: the app dropped a screen since (a newer call was waiting), so // this one builds one recv(k, data, quiet, owe) { - const o = cbor(bytes(data)); + const o = decode(bytes(data), KEYS, WORDS); const r = App.recv(hubs, k, o); const shows = App.shows(r.hubs, k, o); if (owe === true || (quiet === true && shows)) owed = true; diff --git a/src/web/app.bend b/src/web/app.bend index d35cc545b6..e4d77482ad 100644 --- a/src/web/app.bend +++ b/src/web/app.bend @@ -106,6 +106,13 @@ def origin(u: U.Ui) -> String: U.Ui.info(u, "origin") # The wire: CBOR bytes in and out (the host only moves them) +# the wire's dictionaries, for host.js's own decoder (wire.js) +def cbor_keys() -> List<&2, String>: + C.Cbor.keys() + +def cbor_words() -> List<&2, String>: + C.Cbor.words() + def wire_in(bs: List<&2, U32>) -> J.Json: Maybe.default(&2, J.Json, C.Cbor.decode(bs), J.Null{}) diff --git a/src/web/host.js b/src/web/host.js index 7a617d8b21..29b347f886 100644 --- a/src/web/host.js +++ b/src/web/host.js @@ -8,6 +8,7 @@ import App from "./app.bend"; import * as Solid from "./solid.js"; import * as Plot2d from "./plot2d.js"; +import * as Wire from "./wire.js"; // JSON <-> Bend Json // ------------------ @@ -172,6 +173,11 @@ const cid = (() => { return c; })(); +// the wire's dictionaries (core/cbor.bend), for wire.js +const listOf = (l) => { const out = []; for (let x = l; x && x.$ === "Con"; x = x.tail) out.push(x.head); return out; }; +const KEYS = listOf(App.cbor_keys()); +const WORDS = listOf(App.cbor_words()); + let ui = App.page(App.init(now(), cid), location.origin); // opened from another hub to show one of this machine's bots ("#bot=") if (location.hash) { @@ -955,13 +961,15 @@ function connect() { else if (o) Plot2d.got(o, at); return; } - const j = App.wire_in(toList(bytes)); + // decoded here, straight into Bend's Json tree (wire.js): Bend's own + // decoder walked a list cell per byte, most of a long log's load + const j = Wire.cbor(bytes, KEYS, WORDS); // another thread's streamed text draws nothing and is not kept: only // the log and its changes are, and the page renders when the message // shows or gives commands const t = App.kind(j); const vis = App.shows(ui, j); - if (t === "log" || t === "changes") keep(JSON.parse(App.show(j))); + if (t === "log" || t === "changes") keep(Wire.plain(j)); const r = App.recv(ui, j); ui = r.ui; let acts = false; diff --git a/src/web/wire.js b/src/web/wire.js new file mode 100644 index 0000000000..0899b56bbc --- /dev/null +++ b/src/web/wire.js @@ -0,0 +1,119 @@ +// The hub's binary frames (core/cbor.bend's wire format) decoded in plain +// JavaScript, straight into Bend's Json tree ({$: "Obj", fields: Field +// chain}, ...), as Cbor.decode reads them. Bend's own decoder walks a list +// cell per byte and was most of a page load on a long log; the phones' +// bridge and the web page both use this one. keys and words are the +// dictionaries (Cbor.keys(), Cbor.words()), read from Bend so they never drift. + +// UTF-8 bytes [i, end) as a string +export function utf8(b, i, end) { + let s = ""; + const cs = []; + while (i < end) { + let c = b[i++]; + if (c >= 0xf0) c = ((c & 7) << 18) | ((b[i++] & 63) << 12) | ((b[i++] & 63) << 6) | (b[i++] & 63); + else if (c >= 0xe0) c = ((c & 15) << 12) | ((b[i++] & 63) << 6) | (b[i++] & 63); + else if (c >= 0xc0) c = ((c & 31) << 6) | (b[i++] & 63); + if (c > 0xffff) { + c -= 0x10000; + cs.push(0xd800 | (c >> 10), 0xdc00 | (c & 1023)); + } else cs.push(c); + if (cs.length > 4096) { + s += String.fromCharCode.apply(null, cs); + cs.length = 0; + } + } + return s + String.fromCharCode.apply(null, cs); +} + +// CBOR bytes to Bend's Json tree, as Cbor.decode reads them; malformed or +// truncated bytes are Null, as there +export function cbor(b, KEYS, WORDS) { + let i = 0; + const bad = () => { + throw new Error("cbor"); + }; + const arg = (ai) => { + if (ai < 24) return ai; + if (ai === 24) return b[i++]; + if (ai === 25) return (b[i++] << 8) | b[i++]; + if (ai === 26) return ((b[i++] << 24) | (b[i++] << 16) | (b[i++] << 8) | b[i++]) >>> 0; + return bad(); + }; + const text = (n) => { + if (i + n > b.length) bad(); + const s = utf8(b, i, i + n); + i += n; + return s; + }; + // tag: 0 none, 6 a dictionary word, 7 a number's raw text + const item = (tag) => { + if (i >= b.length) bad(); + const h = b[i++], maj = h >> 5, ai = h & 31; + if (maj === 6) return tag === 0 && (ai === 6 || ai === 7) ? item(ai) : bad(); + if (maj === 7) { + if (tag !== 0) bad(); + if (ai === 20) return { $: "Flag", value: false }; + if (ai === 21) return { $: "Flag", value: true }; + if (ai === 22) return { $: "Null" }; + return bad(); + } + const n = arg(ai); + if (i > b.length) bad(); + if (maj === 0 && tag === 0) return { $: "Num", raw: String(n) }; + if (maj === 0 && tag === 6) return n < WORDS.length ? { $: "Str", text: WORDS[n] } : bad(); + if (maj === 1 && tag === 0) return { $: "Num", raw: String(-1 - n) }; + if (maj === 3 && tag === 0) return { $: "Str", text: text(n) }; + if (maj === 3 && tag === 7) return { $: "Num", raw: text(n) }; + if (maj === 4 && tag === 0) { + const vs = []; + for (let k = 0; k < n; k += 1) vs.push(item(0)); + let items = { $: "End" }; + for (let k = n - 1; k >= 0; k -= 1) items = { $: "Item", head: vs[k], tail: items }; + return { $: "Arr", items }; + } + if (maj === 5 && tag === 0) { + const ks = [], vs = []; + for (let k = 0; k < n; k += 1) { + const key = item(0); + if (key.$ === "Str") ks.push(key.text); + else if (key.$ === "Num" && /^[0-9]+$/.test(key.raw) && Number(key.raw) < KEYS.length) ks.push(KEYS[Number(key.raw)]); + else bad(); + vs.push(item(0)); + } + let fields = { $: "End" }; + for (let k = n - 1; k >= 0; k -= 1) fields = { $: "Field", key: ks[k], value: vs[k], tail: fields }; + return { $: "Obj", fields }; + } + return bad(); + }; + try { + const v = item(0); + return i === b.length ? v : { $: "Null" }; + } catch { + return { $: "Null" }; + } +} + +// a Bend Json tree as plain JavaScript (objects, arrays, strings, numbers, +// booleans, null), as JSON.parse would give its text: no recursion along a +// chain, only per level of nesting +export function plain(j) { + switch (j.$) { + case "Null": return null; + case "Flag": return j.value; + case "Num": return Number(j.raw); + case "Str": return j.text; + case "Arr": { + const out = []; + for (let c = j.items; c.$ === "Item"; c = c.tail) out.push(plain(c.head)); + return out; + } + case "Obj": { + const out = {}; + for (let c = j.fields; c.$ === "Field"; c = c.tail) out[c.key] = plain(c.value); + return out; + } + default: return null; + } +} diff --git a/test/tools/wire_js_test.ts b/test/tools/wire_js_test.ts new file mode 100644 index 0000000000..aeaba5e0c5 --- /dev/null +++ b/test/tools/wire_js_test.ts @@ -0,0 +1,27 @@ +// wire.js (the web's and phones' own CBOR decoder) against Bend's +// Cbor.decode, on every frame a hub sends a joining client: +// bun test/tools/wire_js_test.ts HOST:PORT TOKEN +// build/wire (scripts/build.sh) is Bend's decoder, compiled. +import { readFileSync, readdirSync } from "node:fs"; +import { cbor, plain } from "../../src/web/wire.js"; +const wire = new URL("../../build/wire/", import.meta.url).pathname; +for (const f of readdirSync(wire).filter((f) => f.endsWith(".js"))) (0, eval)(readFileSync(`${wire}/${f}`, "utf8")); +const W = (globalThis as any).Wire; +const src = readFileSync(new URL("../../src/core/cbor.bend", import.meta.url).pathname, "utf8"); +const list = (name: string) => JSON.parse(src.split(`def Cbor.${name}() -> List<&2, String>:`)[1].split("\n\n")[0].replace(/\s+/g, " ").trim()); +const KEYS = list("keys"), WORDS = list("words"); +const [host, token] = process.argv.slice(2); +const ws = new WebSocket(`ws://${host}/ws?token=${token}&since=0&origin=&enc=cbor`); +ws.binaryType = "arraybuffer"; +let frames = 0, bytes = 0, bad = 0, tBend = 0, tJs = 0, last = Date.now(); +ws.onmessage = (e) => { + const b = new Uint8Array(e.data); + if (b[1] === 1 && Buffer.from(b.subarray(3, 7)).toString() === "plot") return; + frames++; bytes += b.length; last = Date.now(); + let t = performance.now(); const want = JSON.parse(W.decode(b)); tBend += performance.now() - t; + t = performance.now(); const got = plain(cbor(b, KEYS, WORDS)); tJs += performance.now() - t; + if (JSON.stringify(got) !== JSON.stringify(want)) { bad++; if (bad < 3) console.log("MISMATCH", JSON.stringify(want).slice(0, 200), "\n ", JSON.stringify(got).slice(0, 200)); } +}; +while (Date.now() - last < 4000) await new Promise((r) => setTimeout(r, 200)); +console.log(`${frames} frames, ${(bytes / 1e6).toFixed(1)} MB: ${bad} mismatches; Bend ${tBend.toFixed(0)} ms, wire.js ${tJs.toFixed(0)} ms`); +ws.close(); process.exit(bad ? 1 : 0); From ddc8074c1cffb4bd0d83dbccf707f4a887f13f74 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:45:55 +0300 Subject: [PATCH 03/10] Hub: a join's chunks encoded and sent in C Each chunk of a joining client's log went through W.Ws.cbor: Bend parsed its JSON into a tree and built the CBOR a list cell per byte, and a long log went out at ~1.5 MB/s (a 100 MB log took over a minute). host.c's Sock.send_cbor makes the same bytes straight from the text and sends the frame with a deadline. test/native/cbor_frame_test.bend compares it with Ws.cbor on every line of an event log: identical on a 25k-line real log and a 17k-line, 100 MB stress log. Live frames still go through Ws.cbor. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + src/server/effects/host.c | 343 +++++++++++++++++++++++++++++++ src/server/effects/host.js | 97 +++++++++ src/server/host.bend | 15 ++ src/server/server.bend | 14 +- test/native/cbor_frame_test.bend | 85 ++++++++ 6 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 test/native/cbor_frame_test.bend diff --git a/AGENTS.md b/AGENTS.md index 3cd01b521b..59fc4be8cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,7 @@ Headless UI checks: start Xvfb on `:77`, run `DISPLAY=:77 BACKPLANE_SNAP=/tmp/sn - In JS, a Bend value is objects nested once per list cell (tens of thousands deep for a log). Anything recursive over it overflows the engine's stack: `JSON.parse` with a reviver lost the phones' kept state at every launch. bridge.js writes the state flat (`flat`/`unflat`, no recursion); time a launch with `test/tools/launch_bench.ts` (`BUN_JSC_useJIT=0`). - Base's `String.split` recurses once per character too: `Img.paths` split every long reply that named an image that way, the phones' JS engine ran out of stack inside `recv`, and the hub's whole frame was lost (a message sent then never showed in that thread). `Img.words` cuts words in one tail-recursive walk and drops a word over `Img.long()`. Replay a real log through bridge.js under the phone's limits to find these: `BUN_JSC_useJIT=0 BUN_JSC_maxPerThreadStackUsage=4194304`. - Base's `String.to_lower`/`to_upper` recurse once per character outside a tail call. On a long message they overflowed the phones' V8 sandbox, which crashes natively (no RangeError) and took the Android app with it. Never lower a whole message text; look at the few characters you need (`Img.may` in images.bend). Android now starts the client again in QuickJS if the sandbox dies (`Engine.eval`, `Core`'s `onReset`). Replay a phone launch on real data at a small stack to find these: `node --stack-size=500` with `test/tools/launch_bench.ts`. +- A join's chunks are encoded and sent in C (`H.Sock.send_cbor`, host.c's `jc_*`: JSON text straight to the same CBOR bytes as `W.Ws.cbor`, checked line by line on real logs by `test/native/cbor_frame_test.bend EVENTS.jsonl`): Bend's encoder sent a 100 MB log at ~1.5 MB/s. Live frames still go through `Ws.cbor` (the writer counts their bytes). - The hub must not read the text of log lines (`String.length`, joining, parsing) per chunk or per frame: a log line is shared, and reading a shared String's characters cost ~1 µs a character (a join's 128 KB chunk took 200 ms on the hub, and every other client waited). Count lines, hand the list to the writer (`WItems`), and let it put the frame together once (core/outbox.bend, server.bend's `Hub.boot`). - The event loop polls sockets only when no fiber can run: a fiber that always has work (a join's chunks back to back, a reader decoding a ping flood) starves every request. Space repeated work out (`Self.post.later`) and keep per-frame work O(frame): `Ws.decode` once checked `List.length` of the whole buffer per frame (`Ws.has` now), so 20000 pings froze the hub for 7 s. `bun test/tools/stall_e2e.ts` measures both. - The hub runs on macOS too (release target darwin-arm64: `backplane-serve` as `backplane`, no window; release.yml's `emit-darwin` emits its C and the web client on Linux, `scripts/build.sh emit`, and `build-darwin` compiles them, `BACKPLANE_PREBUILT=1`: bend's emit needs more memory than a macOS runner has). Shell it runs must not assume GNU or Linux: macOS has no `timeout` (use `Port.tmo()`'s `tmo SECS CMD` in a script, or `P.Proc.run("sh", Port.timeout([SECS, CMD, ...]))`; src/core/port.bend), no `setsid` (perl's `POSIX::setsid`), no `realpath -e`, `find -printf`, `stat -c` or `/proc`. Try a change to such a script on a Mac (`hs-mac-mini` has bend and bun; `scripts/build.sh && scripts/smoke.sh` there). diff --git a/src/server/effects/host.c b/src/server/effects/host.c index d798c6e4f3..957c934f32 100644 --- a/src/server/effects/host.c +++ b/src/server/effects/host.c @@ -894,3 +894,346 @@ static void __attribute__((constructor)) sys_exec_use(void) { } #endif + +// JSON text to CBOR, as Bend does it +// =================================== +// core/cbor.bend's Cbor.encode over J.Json.parse, made in one pass over the +// text: numbers that are plain 32-bit integers as major 0 or 1, any other +// number as tag 7 over its text; strings in the words dictionary as tag 6 +// over their index; map keys in the keys dictionary as their index; heads in +// shortest form; definite lengths. Text that does not parse is null, as +// Maybe.default(J.Null) makes it. Sock.send_cbor frames and sends it. The +// hub only ever sends JSON that Bend printed (Json.show); for that the bytes +// are the same (test/native/cbor_frame_test.bend). Bend's parser is lenient +// about malformed text (it skips commas and colons) and this one is not. + +#if defined(CID_SOCK_SEND_CBOR) || defined(CID_JSON_CBOR_FRAME) + +typedef struct { uint8_t* p; size_t n, cap; int bad; } JcBuf; +typedef struct { const char* p[128]; size_t n[128]; int len; } JcDict; +typedef struct { const char* s; size_t i, n; JcBuf* out; JcBuf tmp; JcDict* keys; JcDict* words; int depth; } Jc; + +static void jc_room(JcBuf* b, size_t more) { + if (b->n + more <= b->cap) return; + size_t cap = b->cap ? b->cap * 2 : 4096; + while (cap < b->n + more) cap *= 2; + uint8_t* q = realloc(b->p, cap); + if (!q) { b->bad = 1; return; } + b->p = q; + b->cap = cap; +} + +static void jc_put(JcBuf* b, const void* s, size_t n) { + jc_room(b, n); + if (b->bad) return; + memcpy(b->p + b->n, s, n); + b->n += n; +} + +static void jc_byte(JcBuf* b, uint8_t c) { jc_put(b, &c, 1); } + +// an item head in shortest form (mb: the major type shifted, major * 32) +static size_t jc_headw(uint32_t v) { return v < 24 ? 1 : v < 256 ? 2 : v < 65536 ? 3 : 5; } +static void jc_head_at(uint8_t* q, uint8_t mb, uint32_t v) { + if (v < 24) { q[0] = mb | v; } + else if (v < 256) { q[0] = mb | 24; q[1] = v; } + else if (v < 65536) { q[0] = mb | 25; q[1] = v >> 8; q[2] = v; } + else { q[0] = mb | 26; q[1] = v >> 24; q[2] = v >> 16; q[3] = v >> 8; q[4] = v; } +} +static void jc_head(JcBuf* b, uint8_t mb, uint32_t v) { + uint8_t q[5]; + jc_head_at(q, mb, v); + jc_put(b, q, jc_headw(v)); +} + +static void jc_dict(JcDict* d, const char* s, size_t n) { + d->len = 0; + size_t i = 0; + while (i <= n && d->len < 128) { + size_t j = i; + while (j < n && s[j] != '\n') j += 1; + if (j > i || j < n) { d->p[d->len] = s + i; d->n[d->len] = j - i; d->len += 1; } + i = j + 1; + } +} + +static int jc_find(JcDict* d, const uint8_t* s, size_t n) { + for (int i = 0; i < d->len; i += 1) { + if (d->n[i] == n && memcmp(d->p[i], s, n) == 0) return i; + } + return -1; +} + +static void jc_ws(Jc* c) { + while (c->i < c->n && (c->s[c->i] == ' ' || c->s[c->i] == '\t' || c->s[c->i] == '\n' || c->s[c->i] == '\r')) c->i += 1; +} + +static void jc_utf8(JcBuf* b, uint32_t cp) { + if (cp < 0x80) jc_byte(b, cp); + else if (cp < 0x800) { jc_byte(b, 0xC0 | (cp >> 6)); jc_byte(b, 0x80 | (cp & 63)); } + else if (cp < 0x10000) { jc_byte(b, 0xE0 | (cp >> 12)); jc_byte(b, 0x80 | ((cp >> 6) & 63)); jc_byte(b, 0x80 | (cp & 63)); } + else { jc_byte(b, 0xF0 | (cp >> 18)); jc_byte(b, 0x80 | ((cp >> 12) & 63)); jc_byte(b, 0x80 | ((cp >> 6) & 63)); jc_byte(b, 0x80 | (cp & 63)); } +} + +static int jc_hex4(Jc* c, uint32_t* v) { + if (c->i + 4 > c->n) return 0; + uint32_t x = 0; + for (int k = 0; k < 4; k += 1) { + char h = c->s[c->i + k]; + x <<= 4; + if (h >= '0' && h <= '9') x |= h - '0'; + else if (h >= 'a' && h <= 'f') x |= h - 'a' + 10; + else if (h >= 'A' && h <= 'F') x |= h - 'A' + 10; + else return 0; + } + c->i += 4; + *v = x; + return 1; +} + +// a string's text (after its opening quote) into c->tmp, unescaped +static int jc_string(Jc* c) { + c->tmp.n = 0; + while (c->i < c->n) { + char ch = c->s[c->i++]; + if (ch == '"') return !c->tmp.bad; + if (ch != '\\') { jc_byte(&c->tmp, (uint8_t)ch); continue; } + if (c->i >= c->n) return 0; + char e = c->s[c->i++]; + switch (e) { + case '"': jc_byte(&c->tmp, '"'); break; + case '\\': jc_byte(&c->tmp, '\\'); break; + case '/': jc_byte(&c->tmp, '/'); break; + case 'b': jc_byte(&c->tmp, 8); break; + case 'f': jc_byte(&c->tmp, 12); break; + case 'n': jc_byte(&c->tmp, 10); break; + case 'r': jc_byte(&c->tmp, 13); break; + case 't': jc_byte(&c->tmp, 9); break; + case 'u': { + uint32_t v; + // each \uXXXX is one character, a surrogate's half too, as Bend's + // lexer reads it (core/json.bend's Lex.uni) + if (!jc_hex4(c, &v)) return 0; + jc_utf8(&c->tmp, v); + break; + } + default: return 0; + } + } + return 0; +} + +static void jc_text(JcBuf* b, const uint8_t* s, size_t n) { + jc_head(b, 0x60, (uint32_t)n); + jc_put(b, s, n); +} + +// digits only, 1..10 of them, no leading zero, below 2^32 +static int jc_pos(const char* s, size_t n, uint32_t* v) { + if (n < 1 || n > 10) return 0; + if (s[0] == '0' && n > 1) return 0; + uint64_t x = 0; + for (size_t k = 0; k < n; k += 1) { + if (s[k] < '0' || s[k] > '9') return 0; + x = x * 10 + (uint64_t)(s[k] - '0'); + } + if (x > 0xFFFFFFFFull) return 0; + *v = (uint32_t)x; + return 1; +} + +static void jc_number(Jc* c, JcBuf* b) { + size_t a = c->i; + while (c->i < c->n) { + char ch = c->s[c->i]; + if ((ch >= '0' && ch <= '9') || ch == '-' || ch == '+' || ch == '.' || ch == 'e' || ch == 'E') c->i += 1; + else break; + } + const char* r = c->s + a; + size_t n = c->i - a; + uint32_t v; + if (n > 0 && r[0] == '-') { + if (jc_pos(r + 1, n - 1, &v) && v > 0) { jc_head(b, 0x20, v - 1); return; } + } else if (jc_pos(r, n, &v)) { + jc_head(b, 0x00, v); + return; + } + jc_byte(b, 0xC7); + jc_text(b, (const uint8_t*)r, n); +} + +static int jc_value(Jc* c, JcBuf* b); + +// a container: its items written after room for the longest head, then the +// head put in front of them in its shortest form +static int jc_items(Jc* c, JcBuf* b, int map) { + size_t at = b->n; + jc_room(b, 5); + if (b->bad) return 0; + b->n += 5; + uint32_t count = 0; + jc_ws(c); + if (c->i < c->n && c->s[c->i] == (map ? '}' : ']')) { + c->i += 1; + } else { + for (;;) { + jc_ws(c); + if (map) { + if (c->i >= c->n || c->s[c->i] != '"') return 0; + c->i += 1; + if (!jc_string(c)) return 0; + int k = jc_find(c->keys, c->tmp.p, c->tmp.n); + if (k >= 0) jc_head(b, 0x00, (uint32_t)k); + else jc_text(b, c->tmp.p, c->tmp.n); + jc_ws(c); + if (c->i >= c->n || c->s[c->i] != ':') return 0; + c->i += 1; + } + if (!jc_value(c, b)) return 0; + count += 1; + jc_ws(c); + if (c->i >= c->n) return 0; + char ch = c->s[c->i++]; + if (ch == ',') continue; + if (ch == (map ? '}' : ']')) break; + return 0; + } + } + size_t w = jc_headw(count); + memmove(b->p + at + w, b->p + at + 5, b->n - at - 5); + b->n -= 5 - w; + jc_head_at(b->p + at, map ? 0xA0 : 0x80, count); + return !b->bad; +} + +static int jc_value(Jc* c, JcBuf* b) { + if (++c->depth > 512) return 0; + jc_ws(c); + int ok = 0; + if (c->i >= c->n) ok = 0; + else { + char ch = c->s[c->i]; + if (ch == '{') { c->i += 1; ok = jc_items(c, b, 1); } + else if (ch == '[') { c->i += 1; ok = jc_items(c, b, 0); } + else if (ch == '"') { + c->i += 1; + ok = jc_string(c); + if (ok) { + int w = jc_find(c->words, c->tmp.p, c->tmp.n); + if (w >= 0) { jc_byte(b, 0xC6); jc_head(b, 0x00, (uint32_t)w); } + else jc_text(b, c->tmp.p, c->tmp.n); + } + } + else if (c->i + 4 <= c->n && memcmp(c->s + c->i, "true", 4) == 0) { c->i += 4; jc_byte(b, 0xF5); ok = 1; } + else if (c->i + 5 <= c->n && memcmp(c->s + c->i, "false", 5) == 0) { c->i += 5; jc_byte(b, 0xF4); ok = 1; } + else if (c->i + 4 <= c->n && memcmp(c->s + c->i, "null", 4) == 0) { c->i += 4; jc_byte(b, 0xF6); ok = 1; } + else if (ch == '-' || (ch >= '0' && ch <= '9')) { jc_number(c, b); ok = 1; } + } + c->depth -= 1; + return ok && !b->bad; +} + +// the WebSocket frame (FIN, binary) of text's CBOR, after `pre` bytes left +// for the caller; 0 when out of memory +static int jc_frame(const char* text, size_t n, const char* ks, size_t kn, const char* ws, size_t wn, JcBuf* out, size_t pre) { + JcDict keys, words; + jc_dict(&keys, ks, kn); + jc_dict(&words, ws, wn); + JcBuf body = {0}; + Jc c = { text, 0, n, &body, {0}, &keys, &words, 0 }; + int ok = jc_value(&c, &body); + jc_ws(&c); + if (!ok || c.i != n) { body.n = 0; body.bad = 0; jc_byte(&body, 0xF6); } + free(c.tmp.p); + if (body.bad) { free(body.p); return 0; } + uint8_t h[10]; + size_t hn; + h[0] = 0x82; + if (body.n < 126) { h[1] = body.n; hn = 2; } + else if (body.n < 65536) { h[1] = 126; h[2] = body.n >> 8; h[3] = body.n; hn = 4; } + else { h[1] = 127; h[2] = h[3] = h[4] = h[5] = 0; h[6] = body.n >> 24; h[7] = body.n >> 16; h[8] = body.n >> 8; h[9] = body.n; hn = 10; } + out->p = malloc(pre + hn + body.n); + if (!out->p) { free(body.p); return 0; } + memcpy(out->p + pre, h, hn); + memcpy(out->p + pre + hn, body.p, body.n); + out->n = pre + hn + body.n; + free(body.p); + return 1; +} + +#endif + +#ifdef CID_JSON_CBOR_FRAME + +Term json_cbor_frame_run(Env e, Term* f, IoWork* w) { + u64 tn, kn, wn; + char* t = io_cstr(e, f[0], &tn); + char* k = io_cstr(e, f[1], &kn); + char* ws = io_cstr(e, f[2], &wn); + JcBuf out = {0}; + int ok = jc_frame(t, tn, k, kn, ws, wn, &out, 0); + free(t); free(k); free(ws); + Term r = ok ? host_bytes(e, out.p, out.n) : term_pak(CID_NIL, 0); + free(out.p); + return r; +} + +static void __attribute__((constructor)) json_cbor_frame_use(void) { + io_eff(CID_JSON_CBOR_FRAME, json_cbor_frame_run, 0); +} + +#endif + +#ifdef CID_SOCK_SEND_CBOR + +// as sock_send_until_more: data holds the deadline and span, then the frame +static Term sock_send_cbor_more(Env e, IoWork* w) { + int fd = (int)w->hand; + u64 deadline, span; + memcpy(&deadline, w->data, sizeof deadline); + memcpy(&span, w->data + sizeof deadline, sizeof span); + while (w->code == 0 && (u64)w->made < w->size) { + ssize_t n = host_write(fd, w->data + w->made, w->size - (u64)w->made); + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + if (io_tick() >= deadline) { w->code = ETIMEDOUT; break; } + return io_wait_on(w, fd, POLLOUT, deadline, sock_send_cbor_more); + } + w->made += io_sys_end(w, n); + if (n > 0) { + deadline = io_tick() + span; + memcpy(w->data, &deadline, sizeof deadline); + } + } + if (w->code != 0) shutdown(fd, SHUT_RDWR); + Term r = w->code != 0 ? io_fail(e, w->code, NULL) : io_done(e, host_unit()); + free(w->data); + return io_tup(e, io_hand(w->hand), r); +} + +Term sock_send_cbor_run(Env e, Term* f, IoWork* w) { + u64 tn, kn, wn; + char* t = io_cstr(e, f[1], &tn); + char* k = io_cstr(e, f[2], &kn); + char* ws = io_cstr(e, f[3], &wn); + u64 span = (u64)f[4] * 1000000ull; + u64 deadline = io_tick() + span; + JcBuf out = {0}; + size_t pre = sizeof deadline + sizeof span; + int ok = jc_frame(t, tn, k, kn, ws, wn, &out, pre); + free(t); free(k); free(ws); + w->hand = (intptr_t)io_hand_v(f[0]); + w->code = 0; + if (!ok) return io_tup(e, io_hand(w->hand), io_fail(e, ENOMEM, NULL)); + memcpy(out.p, &deadline, sizeof deadline); + memcpy(out.p + sizeof deadline, &span, sizeof span); + w->data = io_mem(out.p); + w->size = out.n; + w->made = pre; + return sock_send_cbor_more(e, w); +} + +static void __attribute__((constructor)) sock_send_cbor_use(void) { + io_eff(CID_SOCK_SEND_CBOR, sock_send_cbor_run, 0); +} + +#endif diff --git a/src/server/effects/host.js b/src/server/effects/host.js index 19703d8d8e..0832846749 100644 --- a/src/server/effects/host.js +++ b/src/server/effects/host.js @@ -295,3 +295,100 @@ function sys_exec(path, args) { function sys_exe_path() { return process.argv[1] ?? ""; } + +// Json.cbor_frame and Sock.send_cbor: JSON text to one binary WebSocket +// frame of its CBOR, as core/cbor.bend's Cbor.encode over J.Json.parse (see +// host.c): plain 32-bit integers as major 0/1, other numbers as tag 7 over +// their text, dictionary words as tag 6, dictionary keys as their index. +function jc_frame(text, keys, words) { + const ks = keys.split("\n"), ws = words.split("\n"); + const enc = new TextEncoder(); + const out = []; + const head = (mb, v) => { + if (v < 24) out.push(mb | v); + else if (v < 256) out.push(mb | 24, v); + else if (v < 65536) out.push(mb | 25, v >> 8, v & 255); + else out.push(mb | 26, (v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255); + }; + const txt = (s) => { const b = enc.encode(s); head(0x60, b.length); for (const x of b) out.push(x); }; + const pos = (r) => /^(0|[1-9][0-9]{0,9})$/.test(r) && Number(r) <= 0xffffffff ? Number(r) : -1; + let i = 0; + const wsp = () => { while (i < text.length && " \t\n\r".includes(text[i])) i += 1; }; + const str = () => { + let s = ""; + while (i < text.length) { + const c = text[i++]; + if (c === '"') return s; + if (c !== "\\") { s += c; continue; } + const e = text[i++]; + if (e === "u") { s += String.fromCharCode(parseInt(text.slice(i, i + 4), 16)); i += 4; } + else s += ({ '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" })[e] ?? (() => { throw 0; })(); + } + throw 0; + }; + const val = () => { + wsp(); + const c = text[i]; + if (c === "{" || c === "[") { + i += 1; + const map = c === "{"; + const at = out.length; + const items = []; + wsp(); + if (text[i] === (map ? "}" : "]")) { i += 1; } + else for (;;) { + const mark = out.length; + if (map) { + wsp(); + if (text[i] !== '"') throw 0; + i += 1; + const k = str(); + const n = ks.indexOf(k); + if (n >= 0) head(0, n); else txt(k); + wsp(); + if (text[i++] !== ":") throw 0; + } + val(); + items.push(out.splice(mark)); + wsp(); + const d = text[i++]; + if (d === ",") continue; + if (d === (map ? "}" : "]")) break; + throw 0; + } + head(map ? 0xa0 : 0x80, items.length); + for (const it of items) for (const x of it) out.push(x); + return at; + } + if (c === '"') { i += 1; const s = str(); const n = ws.indexOf(s); if (n >= 0) { out.push(0xc6); head(0, n); } else txt(s); return; } + if (text.startsWith("true", i)) { i += 4; out.push(0xf5); return; } + if (text.startsWith("false", i)) { i += 5; out.push(0xf4); return; } + if (text.startsWith("null", i)) { i += 4; out.push(0xf6); return; } + const m = /^[-+0-9.eE]+/.exec(text.slice(i, i + 64)); + if (!m) throw 0; + const r = m[0]; + i += r.length; + if (r[0] === "-") { const v = pos(r.slice(1)); if (v > 0) { head(0x20, v - 1); return; } } + else { const v = pos(r); if (v >= 0) { head(0, v); return; } } + out.push(0xc7); + txt(r); + }; + try { val(); wsp(); if (i !== text.length) throw 0; } catch { out.length = 0; out.push(0xf6); } + const n = out.length; + const h = n < 126 ? [0x82, n] : n < 65536 ? [0x82, 126, n >> 8, n & 255] : [0x82, 127, 0, 0, 0, 0, (n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]; + return h.concat(out); +} + +function json_cbor_frame(text, keys, words) { + let xs = { $: "Nil" }; + const b = jc_frame(text, keys, words); + for (let k = b.length - 1; k >= 0; k -= 1) xs = { $: "Con", head: b[k], tail: xs }; + return xs; +} + +function sock_send_cbor(socket, text, keys, words, ms, k) { + let xs = { $: "Nil" }; + const b = jc_frame(text, keys, words); + for (let j = b.length - 1; j >= 0; j -= 1) xs = { $: "Con", head: b[j], tail: xs }; + return sock_send_until(socket, xs, ms, k); +} diff --git a/src/server/host.bend b/src/server/host.bend index f535a4026e..c0319d98c5 100644 --- a/src/server/host.bend +++ b/src/server/host.bend @@ -32,6 +32,21 @@ def Sock.send_until(sock: Socket, data: List<&2, U32>, ms: U32) -> import "./effects/host.c" import "./effects/host.js" +# A message's JSON text as one binary WebSocket frame of its CBOR, sent with +# a deadline as send_until: the same bytes as core/http.bend's Ws.cbor, made +# in C straight from the text (Bend's encoder parsed it into a tree and built +# a list cell per byte, and a long log's join went out at ~1.5 MB/s). keys +# and words are Cbor.keys() and Cbor.words(), each joined by newlines. +def Sock.send_cbor(sock: Socket, text: String, keys: String, words: String, ms: U32) -> + IO(Socket & Result<&1, &1, U32 & String, Unit>): + import "./effects/host.c" + import "./effects/host.js" + +# the frame send_cbor sends, as bytes (for tests) +def Json.cbor_frame(text: String, keys: String, words: String) -> IO(List<&2, U32>): + import "./effects/host.c" + import "./effects/host.js" + def Sock.send_text(sock: Socket, text: String) -> IO(Socket & Result<&1, &1, U32 & String, Unit>): import "./effects/host.c" diff --git a/src/server/server.bend b/src/server/server.bend index 41af5328c2..24c87b7d4c 100644 --- a/src/server/server.bend +++ b/src/server/server.bend @@ -954,12 +954,24 @@ def Conn.put(s: Socket, +bs: List<&2, U32>, +out: Chan(Wire), k: Socket -> Nat - # a join chunk's frames (OB.Items.texts: each within the byte bound), put # out one by one; to the writer's count they are one frame of no bytes, as # the hub offered it (it charged none) +# the wire's dictionaries as host.c's send_cbor takes them +def Conn.keys() -> String: + String.join(CB.Cbor.keys(), "\n") + +def Conn.words() -> String: + String.join(CB.Cbor.words(), "\n") + +# a chunk's frame out: its CBOR made and sent in C (H.Sock.send_cbor, the +# same bytes as W.Ws.cbor): Bend's encoder sent a long log's join at ~1.5 MB/s +def Conn.out.cbor(s: Socket, +t: String, +out: Chan(Wire), k: Socket -> IO(Unit)) -> IO(Unit): + IO.bind(Socket & Result<&1, &1, U32 & String, Unit>, Unit, H.Sock.send_cbor(s, t, Conn.keys(), Conn.words(), Conn.deadline()), r => Conn.sent.k(r, out, k)) + def Conn.items(ts: List<&2, String>, s: Socket, +out: Chan(Wire), k: Socket -> Nat -> IO(Unit)) -> IO(Unit): match ts: case Nil{}: k(s, 0n) case +t <> rest: - Conn.out(s, W.Ws.cbor(t), out, s2 => Conn.items(rest, s2, out, k)) + Conn.out.cbor(s, t, out, s2 => Conn.items(rest, s2, out, k)) def Conn.write(m: Maybe<&1, Wire>, s: Socket, +out: Chan(Wire), k: Socket -> Nat -> IO(Unit)) -> IO(Unit): match m: diff --git a/test/native/cbor_frame_test.bend b/test/native/cbor_frame_test.bend new file mode 100644 index 0000000000..dbfb79ce7f --- /dev/null +++ b/test/native/cbor_frame_test.bend @@ -0,0 +1,85 @@ +import Base +import ../../src/server/host.bend as H +import ../../src/core/http.bend as W +import ../../src/core/cbor.bend as CB +import ../../src/server/kicad.bend as K +import ../../src/core/diff.bend as DF + +# host.c's Json.cbor_frame (the bytes Sock.send_cbor sends) against Bend's +# W.Ws.cbor, the same frame: every line of an event log wrapped as a join +# chunk, and edge cases (numbers at the edges of 32 bits, escapes, words). +# scripts/build-app.sh test/native/cbor_frame_test.bend build/cbor_frame_test +# build/cbor_frame_test EVENTS.jsonl + +def Cf.keys() -> String: + String.join(CB.Cbor.keys(), "\n") + +def Cf.words() -> String: + String.join(CB.Cbor.words(), "\n") + +def Cf.same(a: List<&2, U32>, b: List<&2, U32>) -> Bool: + match a b: + case Nil{} Nil{}: + True{} + case x <> s y <> t: + Bool.and(U32.is_eq(x, y), Cf.same(s, t)) + case _ _: + False{} + +def Cf.one(+t: String) -> IO(Nat): + do IO: + got : List<&2, U32> <- H.Json.cbor_frame(t, Cf.keys(), Cf.words()) + return Bool.pick(Nat, Cf.same(got, W.Ws.cbor(t)), 0n, 1n) + +def Cf.report(+bad: Nat, +t: String) -> IO(Unit): + Bool.pick(IO(Unit), Nat.is_eq(bad, 0n), IO.pure(Unit, Unit{}), IO.print("DIFF " ++ String.take(t, 300n))) + +def Cf.then(+b: Nat, +t: String, +bad: Nat, k: Nat -> IO(Nat)) -> IO(Nat): + IO.bind(Unit, Nat, Cf.report(b, t), u => k(Nat.add(bad, b))) + +def Cf.all(ts: List<&2, String>, +bad: Nat) -> IO(Nat): + match ts: + case Nil{}: + IO.pure(Nat, bad) + case +t <> rest: + IO.bind(Nat, Nat, Cf.one(t), b => Cf.then(b, t, bad, n => Cf.all(rest, n))) + +def Cf.wrap.k(empty: Bool, +l: String, rest: List<&2, String>) -> List<&2, String>: + match empty: + case True{}: + rest + case False{}: + ("{\"t\":\"changes\",\"boot\":true,\"items\":[" ++ l ++ "]}") <> rest + +def Cf.wrap(ls: List<&2, String>) -> List<&2, String>: + match ls: + case Nil{}: + Nil{} + case +l <> rest: + Cf.wrap.k(String.is_empty(l), l, Cf.wrap(rest)) + +def Cf.edges() -> List<&2, String>: + ["0", "-0", "-1", "7", "23", "24", "255", "256", "65535", "65536", "4294967295", "4294967296", "-4294967295", "-4294967296", "007", "1.5", "-2.25", "1e3", "12345678901", + "\"\"", "\"MessagePosted\"", "\"t\"", "\"a\\\"b\\\\c\\/d\\n\\t\\r\\b\\f\"", "\"\\u00e9\\u4e2d\\ud83d\\ude00\"", "\"é中😀\"", "true", "false", "null", + "[]", "{}", "[1,[2,[3,{}]]]", "{\"$\":\"SettingSet\",\"key\":\"x\",\"value\":\"y\",\"at\":1791104049}", "{\"zzz\":1,\"t\":\"log\"}", + " { \"a\" : [ 1 , 2 ] } "] + +def Cf.path(xs: List) -> String: + match xs: + case Nil{}: + "" + case +p <> Nil{}: + p + case _ <> t: + Cf.path(t) + +def main() -> IO(Unit): + do IO: + args : List <- IO.args() + +path : String = Cf.path(args) + e : Nat <- Cf.all(Cf.edges(), 0n) + IO.print("edge cases: " ++ Nat.show(e) ++ " differ of " ++ Nat.show(List.length(&2, String, Cf.edges()))) + text : String <- K.Io.text(path) + +ls : List<&2, String> = Cf.wrap(DF.Diff.lines(text)) + d : Nat <- Cf.all(ls, 0n) + IO.print("log lines: " ++ Nat.show(d) ++ " differ of " ++ Nat.show(List.length(&2, String, ls))) From 6fea08b7285c63b681503757fdb7939761ac1d50 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:45:55 +0300 Subject: [PATCH 04/10] The tour waits for a long log's last chunk A hub sends a long log in chunks, and the tour took the first one for the whole log: with "onboard.done" in a later chunk it came up over the page for seconds on every load. It now also waits for the join to end ("@boot" clear). Laws onboard_join_waits, onboard_join_shows. Co-Authored-By: Claude Opus 5.5 (1M context) --- LAWS.bend | 11 +++++++++++ PROOF.bend | 6 ++++++ src/core/client.bend | 4 +++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/LAWS.bend b/LAWS.bend index 995b9e57e1..4d09bc9616 100644 --- a/LAWS.bend +++ b/LAWS.bend @@ -5667,6 +5667,17 @@ def OnbUi.phone() -> Hb.Hubs: law onboard_live_log_shows: {U.Tour.at(OnbUi.live()) == 0n : Nat} +# a long log comes in chunks: the tour waits for the last, which may hold +# the "onboard.done" that keeps it shut +def OnbUi.chunk() -> U.Ui: + U.Ui.recv.b(0n, U.Ui.init(1n), U.Boot.demo("log", True{}, False{}, [C.Change.encode(M.ProjectCreated{"p1", "p", "/p", 1n})])) + +law onboard_join_waits: + {U.Tour.at(OnbUi.chunk()) == OBD.Ob.count() : Nat} + +law onboard_join_shows: + {U.Tour.at(U.Ui.recv.b(1n, OnbUi.chunk(), U.Boot.demo("changes", False{}, False{}, Nil{}))) == 0n : Nat} + law onboard_cache_unsynced: {U.Tour.at(U.Ui.unsync(OnbUi.live())) == OBD.Ob.count() : Nat} diff --git a/PROOF.bend b/PROOF.bend index bb41e3274c..e6bb39dc90 100644 --- a/PROOF.bend +++ b/PROOF.bend @@ -7407,6 +7407,12 @@ def Laws.onboard_waits(done): def Laws.onboard_live_log_shows(): {==} +def Laws.onboard_join_waits(): + {==} + +def Laws.onboard_join_shows(): + {==} + def Laws.onboard_cache_unsynced(): {==} diff --git a/src/core/client.bend b/src/core/client.bend index e753e9ccca..8516db5f11 100644 --- a/src/core/client.bend +++ b/src/core/client.bend @@ -7012,8 +7012,10 @@ def Emb.act(+u: Ui, +action: String, +value: String) -> Act: def Tour.done(+st: M.State) -> Bool: String.eq(M.Setting.get(st, "onboard.done", ""), "1") +# ... and, on a long log sent in chunks, until its last chunk has come +# ("@boot" clear): the first said nothing of the "onboard.done" at its end def Tour.ready(+u: Ui) -> Bool: - Bool.and(Nat.is_gt(Ui.seq(u), 0n), String.eq(Ui.scratch(u, "@sync"), "1")) + Bool.and(Bool.and(Nat.is_gt(Ui.seq(u), 0n), String.eq(Ui.scratch(u, "@sync"), "1")), String.is_empty(Ui.scratch(u, "@boot"))) def Tour.at(+u: Ui) -> Nat: OB.Ob.at(Tour.ready(u), Tour.done(Ui.state(u)), Ui.scratch(u, "@tour")) From 3802ac901bcf1bb7079ac9cfd0e41d652e1287c7 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:45:55 +0300 Subject: [PATCH 05/10] Long threads: todos and subagents fold and scroll; the web keeps its log and types fast - The todo card's head opens and shuts it, and a long list ends in "Show all N" / "Show fewer" (client.bend's Todos.*, all four clients): the web and phones scroll every step within a third of the screen, the window shows what fits and scrolls with the wheel. - The subagents panel never takes more than a third of the screen and scrolls: on the web (it did), on iOS (it did not), on the window by rows under the wheel (nui.bend's Pane, the info's pane:). - The web keeps the hub's log frames in IndexedDB (localStorage's 5 MB dropped any long log, so every load fetched it all) and folds them again on a reload before it connects: a 15 MB log reloads in 1.5 s. - A keystroke redraws only the composer and the asks, not the page (app.bend's composer/asks): 280 ms a key on a long thread became 9. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 5 +- .../main/java/dev/backplane/mobile/Screen.kt | 7 +- .../src/main/java/dev/backplane/mobile/Ui.kt | 17 ++- mobile/ios/Backplane/Screen.swift | 6 + mobile/ios/Backplane/ThreadParts.swift | 55 ++++--- mobile/ios/Backplane/Views.swift | 26 +++- src/app/layout.bend | 122 +++++++++++---- src/app/nui.bend | 23 ++- src/core/client.bend | 48 ++++++ src/mobile/screen.bend | 16 +- src/web/app.bend | 8 + src/web/host.js | 144 +++++++++--------- src/web/style.css | 8 +- src/web/view.bend | 48 +++++- 14 files changed, 386 insertions(+), 147 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59fc4be8cb..4043b6e21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,9 +57,10 @@ When using Bend: - `src/core/lightbox.bend`: the image lightbox (pure: zoom clamped and eased, cursor-anchored, drag pan, what closes it; laws `lightbox_*`). The web keeps its `Lb` in host.js and calls app.bend's `lb_*`; the window keeps it as text in the Ui scratch (`@lb`, `@lb.v`, nui.bend's `Lbx`) and main.bend's `Lbv` draws it with `Bitmap.view`. - `src/core/emb.bend`: terminals in the chat (pure: an agent's `terminal_open` makes an Act of tone `term`; its key `x::`, snapshots `Emb.snap`, the agent's `` note, live or inactive `Emb.live`: pinned, or until the person's next message or 30 minutes; laws `emb_*`). The hub's terminal actor keeps each one's screen (`ETerm` in src/server/term.bend) for `terminal_read`, `term.snap` and the next message's note, and stops unpinned ones then; an inactive one is a gray row whose click restarts it (`term.restart`), a pinned one (setting `emb.pin:`) sits at the top with the subagents. All four clients (`TL.emb`/`Ew.over`/`Lay.pins` in the window, `View.emb` on the web, `embs` for phones). See `docs/terminal.md`. Tests: `test/emb_test.bend`, `bun test/tools/emb_e2e.ts`. - `src/core/vt.bend`: the terminal emulator (VT100/xterm: bytes in, styled rows, cursor, replies, key and paste bytes out); `src/server/pty.bend` + `effects/pty.c` run a shell on a pty whose master is a `Socket`. See `docs/terminal.md`. -- `src/web/`: the web client for other devices (Tailscale, phone). View logic is Bend; `host.js` only touches the DOM, canvas and socket, and holds no product logic. Its viewer pane has tabs (Board, Schematic, 3D, Mech, Files, Diff, Browser; `View.tabs`, action `view-kind`, sheets `view-sheet`) and watches the source on screen like the phones (client.bend's `Solid.want`/`Solid.sync`, kicad.watch; 3D is the board's model, kind 2: its body, mask, silk and pads with its parts; tracks and zones are left out); plots skip Bend: `plot2d.js` draws boards and schematics (Canvas2D), `solid.js` models (WebGL). The pane's left edge resizes it (`View.split`; host.js sets `--viewer-w`, kept in localStorage, the thread keeps 360 px; a double-click resets). `solid.js`'s camera is the phones' free orbit (`Orbit`: the model under the pointer follows a drag; right/middle or shift/ctrl drag moves it, alt rolls; the wheel zooms at the pointer within 1/60 and 20 times the fitted distance); `wheelKind` tells a touchpad's two-finger scroll (moves, in 3D and 2D) from a mouse wheel (zooms) and a pinch (ctrl+wheel, zooms); touch screens turn with one finger and move, pinch and twist with two. Test: `bun test/tools/orbit_test.ts`. +- `src/web/`: the web client for other devices (Tailscale, phone). View logic is Bend; `host.js` only touches the DOM, canvas and socket, and holds no product logic. Frames are decoded in JavaScript straight into Bend's Json tree (`wire.js`, shared with the phones' bridge; the dictionaries come from app.bend's `cbor_keys`/`cbor_words`; `bun test/tools/wire_js_test.ts HOST:PORT TOKEN` checks it against Bend's decoder on a live hub). The page keeps the hub's log frames as they came, in IndexedDB (`backplane-log`, a log from since 0 clears them), and folds them again on a reload before it connects. A keystroke redraws only the composer and the asks (app.bend's `composer`/`asks`, view.bend's `View.composer.u`/`View.asks.u`, the same nodes the page draws), never the whole page. Its viewer pane has tabs (Board, Schematic, 3D, Mech, Files, Diff, Browser; `View.tabs`, action `view-kind`, sheets `view-sheet`) and watches the source on screen like the phones (client.bend's `Solid.want`/`Solid.sync`, kicad.watch; 3D is the board's model, kind 2: its body, mask, silk and pads with its parts; tracks and zones are left out); plots skip Bend: `plot2d.js` draws boards and schematics (Canvas2D), `solid.js` models (WebGL). The pane's left edge resizes it (`View.split`; host.js sets `--viewer-w`, kept in localStorage, the thread keeps 360 px; a double-click resets). `solid.js`'s camera is the phones' free orbit (`Orbit`: the model under the pointer follows a drag; right/middle or shift/ctrl drag moves it, alt rolls; the wheel zooms at the pointer within 1/60 and 20 times the fitted distance); `wheelKind` tells a touchpad's two-finger scroll (moves, in 3D and 2D) from a mouse wheel (zooms) and a pinch (ctrl+wheel, zooms); touch screens turn with one finger and move, pinch and twist with two. Test: `bun test/tools/orbit_test.ts`. - A project's KiCad files (server/kicad.bend's `Files.read`, used by the prompt, every viewer and Open in KiCad): `.backplane.json`'s `project` (a `.kicad_pro` path), `pcb` and `schematic` win; else the `.kicad_pro` in the root, else the shallowest found up to four folders down (hidden folders, backups and node_modules skipped). Every one found is listed to the agent (`Prompt.projects`), which is told to make the one it works on canonical. Test: `test/native/kicad_files.bend`. -- The todo card shows `Todo.fit()` steps from the one before the first not done, with a line for the done steps before and the steps after (`Todo.rows`, laws `todo_rows_*`), on all four clients. +- The todo card shows `Todo.fit()` steps from the one before the first not done, with a line for the done steps before and the steps after (`Todo.rows`, laws `todo_rows_*`), on all four clients. Its head opens and shuts it, and a long list ends in "Show all N"/"Show fewer" (client.bend's `Todos.open`/`Todos.all`/`Todos.rows`/`Todos.more`, the `fold` action with keys `todos:` and `todos.all:`); the web and phones scroll every step within about a third of the screen, the window shows what half its height holds and scrolls the rest with the wheel. +- The window's scrolling panes (the subagents panel, the todo card) scroll by rows: a pane's hit `pane` carries `|`, a wheel notch over it moves the info's `pane:` by one (nui.bend's `Pane.*`), and the layout draws the rows from there that fit (layout.bend's `Pane.off`, `Subs.vis`/`Sa.fit`, at most a third of the window for the subagents). - `test/`: Bend test programs (`bend test/x.bend`), run by `scripts/test.sh`. - `src/mobile/`: the phone apps' client in Bend: `hubs.bend` (every paired hub at once: a `Ui` each, one merged screen, actions routed by the `host:port|id` their value carries; laws `hubs_*`), `screen.bend` (the shared `Ui` → a screen model as JSON), `notify.bend` (turn-end alerts and the Dynamic Island, laws `alert_*`), `picker.bend` (the project picker: one path field from `~/`, a tapped folder fills it in, over core's `Proj`; laws `mpick_*`, test `test/mpicker_test.bend`), `bots.bend` (the cats and rooms in the list, a bot's tabs, a room's posts; a cat is named by its key `look:mood` and the apps ask `Backplane.cat(key)` for each rig once; the bot, room and forms are the focused hub's, left unqualified; test `test/mbots_test.bend`), and `bridge.js`, which carries strings between them and the native app. - `src/core/plot.bend` (pure: board/schematic items → chunks the phones draw, delta against what a phone holds, the wire and child-process formats; laws `plot_*`), `src/server/plot.bend` (the actor that watches files for phones and parses each in a `--plot` child process), `src/mobile/view.bend` (which source the phone's viewer asks for, and which tapped piece it inspects; laws `view_*`, `pick_*`). The phones draw plots on the GPU (Metal `mobile/ios/Backplane/PlotView.swift`, GLES 3 `.../PlotView.kt`); plot messages bypass the Bend bridge. Tests: `test/plot_test.bend`, `test/tools/plot_e2e.ts` (against a running hub), `test/native/plot_bench.bend`. diff --git a/mobile/android/app/src/main/java/dev/backplane/mobile/Screen.kt b/mobile/android/app/src/main/java/dev/backplane/mobile/Screen.kt index fba20699ba..fa86efffac 100644 --- a/mobile/android/app/src/main/java/dev/backplane/mobile/Screen.kt +++ b/mobile/android/app/src/main/java/dev/backplane/mobile/Screen.kt @@ -230,7 +230,9 @@ data class QButton(val label: String, val action: String, val value: String) data class QueueRow(val msg: String, val text: String, val tag: String, val buttons: List) // the agent's todo list: "Todo 2/5" and a line per step data class TodoLine(val text: String, val status: String) -data class Todos(val head: String, val lines: List) +// the todo card: open, and a tap on its head sends fold with toggle; more: +// a long list's last line ("Show all 24", "Show fewer"; "" when it fits) +data class Todos(val head: String, val lines: List, val open: Boolean = true, val toggle: String = "", val more: String = "", val moreValue: String = "") data class ThreadView( val id: String, val title: String, val branch: String, val state: String, @@ -468,7 +470,8 @@ private fun threadOf(o: JSONObject) = ThreadView( QueueRow(it.optString("msg"), it.optString("text"), it.optString("tag"), it.optJSONArray("buttons").map { b -> QButton(b.optString("label"), b.optString("action"), b.optString("value")) }) }, - o.optJSONObject("todos")?.let { td -> Todos(td.optString("head"), td.optJSONArray("lines").map { TodoLine(it.optString("text"), it.optString("status")) }) }, + o.optJSONObject("todos")?.let { td -> Todos(td.optString("head"), td.optJSONArray("lines").map { TodoLine(it.optString("text"), it.optString("status")) }, + td.optBoolean("open", true), td.optString("toggle"), td.optString("more"), td.optString("moreValue")) }, ) private fun choices(a: JSONArray?) = a.map { Choice(it.optString("label"), it.optString("value"), it.optBoolean("on")) } diff --git a/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt b/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt index 1ab92ccefa..c4dcab4969 100644 --- a/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt +++ b/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -888,9 +889,19 @@ fun ThreadScreen(m: AppModel, s: Screen, t: ThreadView, below: (@Composable () - for (a in t.asks) Box(Modifier.padding(bottom = 8.dp)) { AskCard(m, a) } t.todos?.let { td -> Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { - Text(td.head, style = MaterialTheme.typography.labelMedium) - for (l in td.lines) Text(l.text, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (l.status == "completed") MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface) + // the head opens and shuts the card + Row(Modifier.fillMaxWidth().then(if (td.toggle.isNotEmpty()) Modifier.clickable { m.act("fold", td.toggle) } else Modifier), + verticalAlignment = Alignment.CenterVertically) { + Text(td.head, Modifier.weight(1f), style = MaterialTheme.typography.labelMedium) + Text(if (td.open) "▾" else "▸", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline) + } + // every step scrolls within a third of the screen + Column(Modifier.heightIn(max = 220.dp).verticalScroll(rememberScrollState())) { + for (l in td.lines) Text(l.text, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (l.status == "completed") MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface) + } + if (td.more.isNotEmpty()) Text(td.more, Modifier.clickable { m.act("fold", td.moreValue) }.padding(vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) } } for (q in t.queue) Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { diff --git a/mobile/ios/Backplane/Screen.swift b/mobile/ios/Backplane/Screen.swift index 6fceecaa58..f7873823c6 100644 --- a/mobile/ios/Backplane/Screen.swift +++ b/mobile/ios/Backplane/Screen.swift @@ -370,6 +370,12 @@ struct TodoLine: Decodable { struct Todos: Decodable { let head: String let lines: [TodoLine] + // open, and a tap on the head sends fold with toggle; more: a long + // list's last line ("Show all 24", "Show fewer"; "" when it fits) + let open: Bool? + let toggle: String? + let more: String? + let moreValue: String? } struct QueueRow: Decodable { diff --git a/mobile/ios/Backplane/ThreadParts.swift b/mobile/ios/Backplane/ThreadParts.swift index 0eae1c3cdc..c295ed6ff1 100644 --- a/mobile/ios/Backplane/ThreadParts.swift +++ b/mobile/ios/Backplane/ThreadParts.swift @@ -294,35 +294,42 @@ struct SubsView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - ForEach(subs.rows) { r in - Button { model.act(r.act, r.value) } label: { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Rectangle().fill(subColor(r.state)).frame(width: r.live ? 8 : 6, height: r.live ? 8 : 6) - Text(r.agent ? (r.open ? "▾" : "▸") : r.who).font(.caption2).foregroundStyle(.secondary).lineLimit(1) - Text(r.title).lineLimit(1) - Spacer(minLength: 0) - if let c = r.cancel, !c.isEmpty { - Button("Stop") { model.act(c, r.id) }.font(.caption2).foregroundStyle(PhaseColor.warn).buttonStyle(.plain) - } - Text(r.state).font(.caption2).foregroundStyle(subColor(r.state)) - } - if !r.doing.isEmpty { - Text(r.doing).font(.caption2).foregroundStyle(PhaseColor.accent).lineLimit(1).padding(.leading, 16) - } - ForEach(Array(r.steps.enumerated()), id: \.offset) { _, st in - HStack(spacing: 6) { - if !st.kind.isEmpty { Text(st.kind).foregroundStyle(.tertiary) } - Text(st.text).foregroundStyle(.secondary).lineLimit(1) + // many subagents scroll within a third of the screen + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(subs.rows) { r in + Button { model.act(r.act, r.value) } label: { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Rectangle().fill(subColor(r.state)).frame(width: r.live ? 8 : 6, height: r.live ? 8 : 6) + Text(r.agent ? (r.open ? "▾" : "▸") : r.who).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + Text(r.title).lineLimit(1) + Spacer(minLength: 0) + if let c = r.cancel, !c.isEmpty { + Button("Stop") { model.act(c, r.id) }.font(.caption2).foregroundStyle(PhaseColor.warn).buttonStyle(.plain) + } + Text(r.state).font(.caption2).foregroundStyle(subColor(r.state)) + } + if !r.doing.isEmpty { + Text(r.doing).font(.caption2).foregroundStyle(PhaseColor.accent).lineLimit(1).padding(.leading, 16) + } + ForEach(Array(r.steps.enumerated()), id: \.offset) { _, st in + HStack(spacing: 6) { + if !st.kind.isEmpty { Text(st.kind).foregroundStyle(.tertiary) } + Text(st.text).foregroundStyle(.secondary).lineLimit(1) + } + .font(.caption2.monospaced()) + .padding(.leading, 16) + } } - .font(.caption2.monospaced()) - .padding(.leading, 16) + .contentShape(.rect) } + .buttonStyle(.plain) } - .contentShape(.rect) } - .buttonStyle(.plain) } + .frame(maxHeight: 260) + .fixedSize(horizontal: false, vertical: true) } .padding(10) .background(Color(.secondarySystemBackground)) diff --git a/mobile/ios/Backplane/Views.swift b/mobile/ios/Backplane/Views.swift index 955aca4568..9d0d51f9de 100644 --- a/mobile/ios/Backplane/Views.swift +++ b/mobile/ios/Backplane/Views.swift @@ -794,9 +794,29 @@ struct ThreadScreen: View { } if let td = thread.todos { VStack(alignment: .leading, spacing: 2) { - Text(td.head).bold() - ForEach(Array(td.lines.enumerated()), id: \.offset) { _, l in - Text(l.text).lineLimit(1).foregroundStyle(l.status == "completed" ? .secondary : .primary) + // the head opens and shuts the card + Button { if let t = td.toggle, !t.isEmpty { model.act("fold", t) } } label: { + HStack { + Text(td.head).bold() + Spacer(minLength: 0) + Image(systemName: (td.open ?? true) ? "chevron.down" : "chevron.right").foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // every step scrolls within a third of the screen + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(td.lines.enumerated()), id: \.offset) { _, l in + Text(l.text).lineLimit(1).foregroundStyle(l.status == "completed" ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(maxHeight: 220) + .fixedSize(horizontal: false, vertical: true) + if let more = td.more, !more.isEmpty { + Button(more) { model.act("fold", td.moreValue ?? "") }.buttonStyle(.borderless) } } .font(.caption) diff --git a/src/app/layout.bend b/src/app/layout.bend index fb13bbc251..b9c040d546 100644 --- a/src/app/layout.bend +++ b/src/app/layout.bend @@ -2448,6 +2448,44 @@ def Sa.sum(rs: List<&2, SB.SubRow>) -> U32: case +r <> rest: (Sa.h(r) + Sa.sum(rest) : U32) +# A pane that scrolls by rows (the subagents panel, the todo card): the +# first row shown is the info's "pane:" (nui.bend's Pane, the wheel +# over the pane's "pane" hit), and the rows from there that fit its height. +def Pane.off(+info: Map<&2, String>, +key: String) -> Nat: + Maybe.default(&2, Nat, Nat.read(U.Str.get(info, "pane:" ++ key)), 0n) + +def Pane.clamp(+off: Nat, +n: Nat) -> Nat: + Bool.pick(Nat, Nat.is_lt(off, n), off, Nat.sub(n, 1n)) + +# "rows 4–9 of 12" when not all of them show, else "" +def Pane.range(+from: Nat, +shown: Nat, +n: Nat) -> String: + Bool.pick(String, Nat.is_lt(shown, n), " · " ++ Nat.show(1n+from) ++ "–" ++ Nat.show(Nat.add(from, shown)) ++ " of " ++ Nat.show(n), "") + +# the subagent rows from the first shown that fit room (at least one): a row +# that does not fit leaves no room for any after it +def Sa.fit.ok(+h: U32, +room: U32, +first: Bool) -> Bool: + Bool.or(U32.is_le(h, room), first) + +def Sa.fit.put(ok: Bool, r: SB.SubRow, rest: List<&2, SB.SubRow>) -> List<&2, SB.SubRow>: + match ok: + case True{}: + r <> rest + case False{}: + Nil{} + +def Sa.fit(rs: List<&2, SB.SubRow>, +room: U32, +first: Bool) -> List<&2, SB.SubRow>: + match rs: + case Nil{}: + Nil{} + case +r <> rest: + Sa.fit.put(Sa.fit.ok(Sa.h(r), room, first), r, + Sa.fit(rest, Bool.pick(U32, Sa.fit.ok(Sa.h(r), room, first), (room - U32.min(Sa.h(r), room) : U32), 0), False{})) + +# the panel's rows on screen: open ones from the scrolled-to row, as many as +# a third of the window holds +def Subs.vis(+all: List<&2, SB.SubRow>, +open: Bool, +off: Nat, +h: U32) -> List<&2, SB.SubRow>: + Sa.fit(List.drop(&2, SB.SubRow, SB.Subs.panel.rows(open, all), off), (h / 3 : U32), True{}) + # the panel's box: its head, and its rows when open def Subs.box.h(+rs: List<&2, SB.SubRow>, +open: Bool) -> U32: (Sa.sum(rs) + Bool.pick(U32, open, 36, 32) : U32) @@ -2455,28 +2493,31 @@ def Subs.box.h(+rs: List<&2, SB.SubRow>, +open: Bool) -> U32: # the subagent viewer: every task this thread delegated and each of the # agent's own subagents, what each does now, and the steps of those open. # Its head opens and shuts it; once all is done it folds to the head -# (subs.bend's panel) -def Lay.subs.k(some: Bool, +ty: Ty, +all: List<&2, SB.SubRow>, +open: Bool, +th: String, +x0: U32, +x1: U32, +y: U32) -> Out: +# (subs.bend's panel). Never taller than a third of the window: the wheel +# over it scrolls its rows (Pane) +def Lay.subs.k(some: Bool, +ty: Ty, +all: List<&2, SB.SubRow>, +rs: List<&2, SB.SubRow>, +open: Bool, +th: String, +off: Nat, +x0: U32, +x1: U32, +y: U32) -> Out: match some: case True{}: - +rs = SB.Subs.panel.rows(open, all) + +n = List.length(&2, SB.SubRow, SB.Subs.panel.rows(open, all)) +on = Nat.is_gt(SB.Subs.live(all), 0n) - +word = SB.Subs.panel.word(all) + +word = {SB.Subs.panel.word(all) ++ Pane.range(off, List.length(&2, SB.SubRow, rs), n) : String} + +bh = Subs.box.h(rs, open) Out.all([ - Out.ops(Lay.box(D.Clip.all(), x0, y, (x1 - x0 : U32), Subs.box.h(rs, open), 8, T.Th.panel(), Bool.pick(U32, on, T.Th.accent(), T.Th.line()))), + Out.ops(Lay.box(D.Clip.all(), x0, y, (x1 - x0 : U32), bh, 8, T.Th.panel(), Bool.pick(U32, on, T.Th.accent(), T.Th.line()))), Out.ops(Ty.text(ty, 2n, "Subagents", D.Clip.all(), (x0 + 12 : U32), y, 32, Bool.pick(U32, on, T.Th.text(), T.Th.faint()))), Out.ops(Ty.text(ty, 2n, word, D.Clip.all(), (x1 - Ty.width(ty, 2n, word) - 36 : U32), y, 32, Bool.pick(U32, on, T.Th.accent(), T.Th.faint()))), Out.ops(Lay.icon(ty, Bool.pick(String, open, "chevron-down", "chevron-right"), D.Clip.all(), (x1 - 28 : U32), (y + 8 : U32), T.Th.faint())), Out{Nil{}, [Lay.hit(x0, y, (x1 - x0 : U32), 30, "fold", SB.Subs.panel.ask(open, th))]}, - Sa.rows(ty, rs, x0, x1, (y + 30 : U32))]) + Sa.rows(ty, rs, x0, x1, (y + 30 : U32)), + Out{Nil{}, [Lay.hit(x0, y, (x1 - x0 : U32), bh, "pane", "subs:" ++ th ++ "|" ++ Nat.show(Nat.sub(n, 1n)))]}]) case False{}: Out.none() -def Lay.subs(+ty: Ty, +all: List<&2, SB.SubRow>, +open: Bool, +th: String, +x0: U32, +x1: U32, +y: U32) -> Out: - Lay.subs.k(Bool.not(List.is_empty(&2, SB.SubRow, all)), ty, all, open, th, x0, x1, y) +def Lay.subs(+ty: Ty, +all: List<&2, SB.SubRow>, +rs: List<&2, SB.SubRow>, +open: Bool, +th: String, +off: Nat, +x0: U32, +x1: U32, +y: U32) -> Out: + Lay.subs.k(Bool.not(List.is_empty(&2, SB.SubRow, all)), ty, all, rs, open, th, off, x0, x1, y) -def Subs.height(+all: List<&2, SB.SubRow>, +open: Bool) -> U32: - Bool.pick(U32, List.is_empty(&2, SB.SubRow, all), 0, (Subs.box.h(SB.Subs.panel.rows(open, all), open) + 12 : U32)) +def Subs.height(+all: List<&2, SB.SubRow>, +rs: List<&2, SB.SubRow>, +open: Bool) -> U32: + Bool.pick(U32, List.is_empty(&2, SB.SubRow, all), 0, (Subs.box.h(rs, open) + 12 : U32)) def Lay.parent.k(+ty: Ty, +p: String, +st: M.State, +x0: U32, +y: U32) -> Out: +label = "← delegated by " ++ U.Tasks.title(st, p) @@ -2613,13 +2654,13 @@ def Lay.qrows(qs: List<&2, M.QItem>, +ty: Ty, +i: Nat, +n: Nat, +run: Bool, +mor def Lay.qn(+qs: List<&2, M.QItem>) -> Nat: Bool.pick(Nat, Nat.is_gt(List.length(&2, M.QItem, qs), U.Qu.shown()), U.Qu.shown(), List.length(&2, M.QItem, qs)) -# The todo list above the queue: its head, then its rows (TD.Todo.rows: -# the steps around the one being done, and a line for the rest) -def Lay.tdn(+ts: List<&2, TD.Todo>) -> Nat: - List.length(&2, TD.TRow, TD.Todo.rows(ts)) - -def Lay.tdh(+ts: List<&2, TD.Todo>) -> U32: - Bool.pick(U32, List.is_empty(&2, TD.Todo, ts), 0, ((U32.from_nat(Lay.tdn(ts)) * 22 : U32) + 34 : U32)) +# The todo list above the queue: its head, then its rows (client.bend's +# Todos.rows: the steps around the one being done and a line for the rest, +# every step as many as fit, or none shut), then a long list's last line +# ("Show all 24", "Show fewer"). The head and that line are clicks (fold). +def Lay.tdh(+ts: List<&2, TD.Todo>, +rs: List<&2, TD.TRow>, +more: String) -> U32: + Bool.pick(U32, List.is_empty(&2, TD.Todo, ts), 0, + ((U32.from_nat(List.length(&2, TD.TRow, rs)) * 22 : U32) + 34 + Bool.pick(U32, String.is_empty(more), 0, 22) : U32)) def Lay.tdlines(rs: List<&2, TD.TRow>, +ty: Ty, +x0: U32, +x1: U32, +y: U32) -> List<&2, R.Op>: match rs: @@ -2630,11 +2671,30 @@ def Lay.tdlines(rs: List<&2, TD.TRow>, +ty: Ty, +x0: U32, +x1: U32, +y: U32) -> Bool.pick(U32, String.eq(TD.TRow.status(r), "completed"), T.Th.faint(), Bool.pick(U32, String.eq(TD.TRow.status(r), "in_progress"), T.Th.text(), T.Th.dim()))), Lay.tdlines(rest, ty, x0, x1, (y + 22 : U32))) -def Lay.todos(+ty: Ty, +ts: List<&2, TD.Todo>, +x0: U32, +x1: U32, +bottom: U32) -> Out: - Out.when(Bool.not(List.is_empty(&2, TD.Todo, ts)), - Out.ops(List.concat(&2, R.Op, [Lay.box(D.Clip.all(), x0, (bottom - Lay.tdh(ts) : U32), (x1 - x0 : U32), (Lay.tdh(ts) - 4 : U32), 0, T.Th.raise(), T.Th.line()), - Ty.text(ty, 2n, TD.Todo.head(ts), D.Clip.all(), (x0 + 12 : U32), (bottom - Lay.tdh(ts) + 2 : U32), 26, T.Th.soft()), - Lay.tdlines(TD.Todo.rows(ts), ty, x0, x1, (bottom - Lay.tdh(ts) + 28 : U32))]))) +# how many steps the window shows of every step: what half its height holds +def Lay.tdcap(+h: U32) -> Nat: + U32.to_nat(U32.max((U32.shr(h) / 22 : U32), 6)) + +def Lay.todos.k(some: Bool, +ty: Ty, +ts: List<&2, TD.Todo>, +rs: List<&2, TD.TRow>, +more: String, +open: Bool, +all: Bool, +th: String, +off: Nat, +n: Nat, +x0: U32, +x1: U32, +bottom: U32) -> Out: + match some: + case True{}: + +hh = Lay.tdh(ts, rs, more) + +top = {(bottom - hh : U32) : U32} + +ry = {(top + 28 + (U32.from_nat(List.length(&2, TD.TRow, rs)) * 22 : U32) : U32) : U32} + Out.all([ + Out.ops(List.concat(&2, R.Op, [Lay.box(D.Clip.all(), x0, top, (x1 - x0 : U32), (hh - 4 : U32), 0, T.Th.raise(), T.Th.line()), + Ty.text(ty, 2n, TD.Todo.head(ts) ++ Pane.range(off, List.length(&2, TD.TRow, rs), n), D.Clip.all(), (x0 + 12 : U32), (top + 2 : U32), 26, T.Th.soft()), + Lay.icon(ty, Bool.pick(String, open, "chevron-down", "chevron-right"), D.Clip.all(), (x1 - 28 : U32), (top + 6 : U32), T.Th.faint()), + Lay.tdlines(rs, ty, x0, x1, (top + 28 : U32)), + Ty.text(ty, 2n, more, D.Clip.all(), (x0 + 12 : U32), ry, 22, T.Th.accent())])), + Out{Nil{}, List.concat(&2, S.Hit, [[Lay.hit(x0, top, (x1 - x0 : U32), 28, "fold", U.Todos.toggle(open, th))], + Bool.pick(List<&2, S.Hit>, String.is_empty(more), Nil{}, [Lay.hit(x0, ry, (x1 - x0 : U32), 22, "fold", U.Todos.more.ask(all, th))]), + [Lay.hit(x0, top, (x1 - x0 : U32), hh, "pane", "todos:" ++ th ++ "|" ++ Nat.show(Nat.sub(n, 1n)))]])}]) + case False{}: + Out.none() + +def Lay.todos(+ty: Ty, +ts: List<&2, TD.Todo>, +rs: List<&2, TD.TRow>, +more: String, +open: Bool, +all: Bool, +th: String, +off: Nat, +n: Nat, +x0: U32, +x1: U32, +bottom: U32) -> Out: + Lay.todos.k(Bool.not(List.is_empty(&2, TD.Todo, ts)), ty, ts, rs, more, open, all, th, off, n, x0, x1, bottom) # the queue's height above the composer def Lay.qh(+qs: List<&2, M.QItem>) -> U32: @@ -4774,15 +4834,23 @@ def Lay.thread.k(+ty: Ty, +memo: TLMemo, t: M.Thread, +draft: String, +held: Str +cx1 = {(cx0 + cw : U32) : U32} +ts = SB.Subs.rows(st, pics, sel) +sopen = SB.Subs.panel.open(pics, sel, ts) + +soff = Pane.clamp(Pane.off(pics, "subs:" ++ sel), List.length(&2, SB.SubRow, SB.Subs.panel.rows(sopen, ts))) + +svis = Subs.vis(ts, sopen, soff, h) # the row under the header: the parent's link +pl = {Bool.pick(U32, String.is_empty(U.Tasks.parent(st, sel)), 0, 32) : U32} +pins = U.Ec.pins(ec, st, sel) - +top = {(52 + hy + pl + Subs.height(ts, sopen) + Pins.height(pins) : U32) : U32} + +top = {(52 + hy + pl + Subs.height(ts, svis, sopen) + Pins.height(pins) : U32) : U32} +asks = U.Asks.open(st, sel) +ah = Asks.h(ty, asks, (cw - 32 : U32)) +qs = M.Queue.items(st, sel) +tds = U.Todos.shown(st, sel, Lay.running(turn)) - +qh = {(Lay.qh(qs) + Lay.tdh(tds) : U32) : U32} + +tdo = U.Todos.open(pics, sel) + +tda = U.Todos.all(pics, sel) + +tdall = U.Todos.rows(pics, sel, U.Todos.cap.all(), tds) + +tdoff = Pane.clamp(Pane.off(pics, "todos:" ++ sel), List.length(&2, TD.TRow, tdall)) + +tdr = List.take(&2, TD.TRow, List.drop(&2, TD.TRow, tdall, tdoff), Lay.tdcap(h)) + +tdm = {Bool.pick(String, tdo, U.Todos.more(tda, tds), "") : String} + +qh = {(Lay.qh(qs) + Lay.tdh(tds, tdr, tdm) : U32) : U32} # the composer grows by eh, and all above it moves up +ls = Comp.wrap(ty, draft, cw) +eh = Comp.eh(List.length(&2, String, ls)) @@ -4795,11 +4863,11 @@ def Lay.thread.k(+ty: Ty, +memo: TLMemo, t: M.Thread, +draft: String, +held: Str cx0, cx1, top, (h - ah - qh - eh - bh : U32), sc, tsel, Out.none()) Out.all([Lay.header(ty, M.Thread{id, p, ti, c, a, turn, pe, ar, o, sa, ua, pi, en, br, wt, pr, mo, rm, im, se, de}, st, x0, x1, pa, term, M.Snooze.on(now, U.Side.until(st, sel)), del, hy), Ren.field.if(String.eq(ren, sel), ty, rtext, focus, blink, caret, mark, x0, x1, hy), Lay.parent(ty, st, sel, cx0, (58 + hy : U32)), - Lay.subs(ty, ts, sopen, sel, cx0, cx1, (60 + hy + pl : U32)), - Lay.pins(ty, ec, pins, cx0, cx1, (60 + hy + pl + Subs.height(ts, sopen) : U32), fkey, blink), + Lay.subs(ty, ts, svis, sopen, sel, soff, cx0, cx1, (60 + hy + pl : U32)), + Lay.pins(ty, ec, pins, cx0, cx1, (60 + hy + pl + Subs.height(ts, svis, sopen) : U32), fkey, blink), Lay.composer.a(ty, draft, ls, held, caret, mark, focus, blink, cx0, cx1, h, U.Follow.button(st, sel, Lay.running(turn), stop, draft), U.Follow.act(stop), U.Effort.label(U.Models.label(pr, mo), U.Effort.of(st, sel)) ++ U.Usage.tail(st, sel), rs, atts, up, path), Lay.queued(ty, qs, Lay.running(turn), cx0, cx1, (h - eh : U32)), - Lay.todos(ty, tds, cx0, cx1, (h - eh - 124 - Lay.qh(qs) : U32)), + Lay.todos(ty, tds, tdr, tdm, tdo, tda, sel, tdoff, List.length(&2, TD.TRow, tdall), cx0, cx1, (h - eh - 124 - Lay.qh(qs) : U32)), Lay.asks(ty, asks, draft, cx0, cx1, (h - 132 - ah - qh - eh - bh + 4 : U32)), Btw.card.if(Bool.not(String.is_empty(bq)), ty, bq, ba, cx0, cx1, (h - 132 - qh - eh - bh + 4 : U32)), Lay.picker(picking, ty, U.Models.menu(info, pr), mo, info, pr, cx0, cx1, (h - 128 - eh : U32), (60 + hy : U32), U.Chip.all(info, pr, mo), U.Effort.of(st, sel), moff)]) diff --git a/src/app/nui.bend b/src/app/nui.bend index 87a5597c9b..1bfd74a40c 100644 --- a/src/app/nui.bend +++ b/src/app/nui.bend @@ -2119,6 +2119,20 @@ def Set.spin(none: Bool, +v: String, +n: Nui, +x: U32, +y: U32, +dir: U32) -> St case False{}: Step{Nui.with_ui(n, U.Ui.scratch_set(Nui.ui(n), "@set.sc", Set.scroll(U.Ui.scratch(Nui.ui(n), "@set.sc"), v, dir))), Nil{}, True{}} +# A pane that scrolls by rows (the subagents panel, the todo card): its hit +# "pane" carries "|", and a wheel notch over it moves the first +# row shown by one, within 0..last, kept in the info as "pane:" (the +# layout reads it as Pane.off). Elsewhere the wheel goes on as before. +def Pane.at(+v: String, +i: Nat) -> String: + Maybe.default(&2, String, List.get(&2, String, String.split(v, '|'), i), "") + +def Pane.to(+cur: Nat, +last: Nat, +dir: U32) -> Nat: + Bool.pick(Nat, U32.is_eq(dir, 0), Nat.sub(cur, 1n), Bool.pick(Nat, Nat.is_lt(cur, last), 1n+cur, last)) + +def Pane.step(+n: Nui, +key: String, +last: Nat, +dir: U32) -> Step: + Step{Nui.with_ui(n, U.Ui.info_set(Nui.ui(n), "pane:" ++ key, + Nat.show(Pane.to(Maybe.default(&2, Nat, Nat.read(U.Ui.info(Nui.ui(n), "pane:" ++ key)), 0n), last, dir)))), Nil{}, True{}} + # the wheel over the open picker scrolls its models, elsewhere as before def Menu.spin(none: Bool, +v: String, +n: Nui, +x: U32, +y: U32, +dir: U32) -> Step: match none: @@ -2127,6 +2141,13 @@ def Menu.spin(none: Bool, +v: String, +n: Nui, +x: U32, +y: U32, +dir: U32) -> S case False{}: Step{Nui.with_ui(n, U.Ui.scratch_set(Nui.ui(n), "@menu.off", Menu.scroll(v, dir))), Nil{}, True{}} +def Pane.spin(none: Bool, +v: String, +n: Nui, +x: U32, +y: U32, +dir: U32) -> Step: + match none: + case True{}: + Menu.spin(String.is_empty(Menu.box(Nui.hits(n), x, y)), Menu.box(Nui.hits(n), x, y), n, x, y, dir) + case False{}: + Pane.step(n, Pane.at(v, 0n), Maybe.default(&2, Nat, Nat.read(Pane.at(v, 1n)), 0n), dir) + def Nui.ms_of(n: Nui) -> Nat: match n: case Nui{_, _, _, _, _, _, _, _, _, _, _, ms, _}: @@ -2842,7 +2863,7 @@ def Nui.event(+n: Nui, e: W.WinEv) -> Step: case W.WMove{+x, +y, +mods}: Nui.move(Nui.dragging(n), n, x, y, mods) case W.WWheel{+x, +y, +dir}: - Menu.spin(String.is_empty(Menu.box(Nui.hits(n), x, y)), Menu.box(Nui.hits(n), x, y), n, x, y, dir) + Pane.spin(String.is_empty(Hit.value_at(Nui.hits(n), "pane", x, y)), Hit.value_at(Nui.hits(n), "pane", x, y), n, x, y, dir) case W.WSize{w, h}: Nui.resize(n, w, h) case W.WExpose{}: diff --git a/src/core/client.bend b/src/core/client.bend index 8516db5f11..a191bbf054 100644 --- a/src/core/client.bend +++ b/src/core/client.bend @@ -4649,6 +4649,54 @@ def Todos.pick(show: Bool, ts: List<&2, TD.Todo>) -> List<&2, TD.Todo>: def Todos.shown(+st: M.State, +th: String, +run: Bool) -> List<&2, TD.Todo>: Todos.pick(TD.Todo.shown(Todos.of(st, th), run), Todos.of(st, th)) +# The card's two controls, on all four clients: its head opens or shuts it +# (the "fold" action, key todos:; open until shut), and a list too +# long for the card ends in a line that shows every step, or the fitted +# few again (key todos.all:). A client that scrolls the card shows +# every step; the window, which cannot, as many as cap leaves room for. +def Todos.key(+th: String) -> String: + "todos:" ++ th + +def Todos.all.key(+th: String) -> String: + "todos.all:" ++ th + +def Todos.open(+info: Map<&2, String>, +th: String) -> Bool: + FO.Fold.open(FO.Fold.user(info, Todos.key(th)), True{}) + +def Todos.all(+info: Map<&2, String>, +th: String) -> Bool: + FO.Fold.open(FO.Fold.user(info, Todos.all.key(th)), False{}) + +# what a click on the head asks, and on the last line +def Todos.toggle(+open: Bool, +th: String) -> String: + FO.Fold.ask(open, Todos.key(th)) + +def Todos.more.ask(+all: Bool, +th: String) -> String: + FO.Fold.ask(all, Todos.all.key(th)) + +# the last line's words: "Show all 24" or "Show fewer", "" when the list fits +def Todos.more(+all: Bool, +ts: List<&2, TD.Todo>) -> String: + Bool.pick(String, Nat.is_gt(List.length(&2, TD.Todo, ts), TD.Todo.fit()), Bool.pick(String, all, "Show fewer", "Show all " ++ Nat.show(List.length(&2, TD.Todo, ts))), "") + +def Todos.rows.all(+cap: Nat, +ts: List<&2, TD.Todo>) -> List<&2, TD.TRow>: + TD.Todo.rows.k(Nat.is_gt(List.length(&2, TD.Todo, ts), cap), ts, List.length(&2, TD.Todo, ts), cap, TD.Todo.open(ts)) + +def Todos.rows.k(open: Bool, all: Bool, +cap: Nat, +ts: List<&2, TD.Todo>) -> List<&2, TD.TRow>: + match open all: + case False{} _: + Nil{} + case True{} True{}: + Todos.rows.all(cap, ts) + case True{} False{}: + TD.Todo.rows(ts) + +# the card's rows: none shut, every step (up to cap) shown all, else the fitted few +def Todos.rows(+info: Map<&2, String>, +th: String, +cap: Nat, +ts: List<&2, TD.Todo>) -> List<&2, TD.TRow>: + Todos.rows.k(Todos.open(info, th), Todos.all(info, th), cap, ts) + +# a client that scrolls the card shows every step +def Todos.cap.all() -> Nat: + 100000n + # how many queued rows show (the rest are counted on the last) def Qu.shown() -> Nat: 4n diff --git a/src/mobile/screen.bend b/src/mobile/screen.bend index 9f30bfb31b..44382df5e1 100644 --- a/src/mobile/screen.bend +++ b/src/mobile/screen.bend @@ -576,9 +576,17 @@ def Mob.tdlines(rs: List<&2, TD.TRow>) -> List<&2, J.Json>: case +r <> rest: J.Json.obj([J.KV{"text", J.Json.str(TD.TRow.text(r))}, J.KV{"status", J.Json.str(TD.TRow.status(r))}]) <> Mob.tdlines(rest) -# the agent's todo list (U.Todos.shown), or null while hidden -def Mob.todos(+ts: List<&2, TD.Todo>) -> J.Json: - Bool.pick(J.Json, List.is_empty(&2, TD.Todo, ts), J.Null{}, J.Json.obj([J.KV{"head", J.Json.str(TD.Todo.head(ts))}, J.KV{"lines", J.Json.arr(Mob.tdlines(TD.Todo.rows(ts)))}])) +# the agent's todo list (U.Todos.shown), or null while hidden: its head, +# whether it is open and what a tap on the head sends (fold, with toggle), +# its lines (every step once shown all: the app scrolls them), and a long +# list's last line ("Show all 24", "Show fewer"; "" when it fits) and its value +def Mob.todos.k(+open: Bool, +all: Bool, +th: String, +ts: List<&2, TD.Todo>, +info: Map<&2, String>) -> J.Json: + J.Json.obj([J.KV{"head", J.Json.str(TD.Todo.head(ts))}, J.KV{"open", J.Json.bool(open)}, J.KV{"toggle", J.Json.str(U.Todos.toggle(open, th))}, + J.KV{"lines", J.Json.arr(Mob.tdlines(U.Todos.rows(info, th, U.Todos.cap.all(), ts)))}, + J.KV{"more", J.Json.str(Bool.pick(String, open, U.Todos.more(all, ts), ""))}, J.KV{"moreValue", J.Json.str(U.Todos.more.ask(all, th))}]) + +def Mob.todos(+info: Map<&2, String>, +th: String, +ts: List<&2, TD.Todo>) -> J.Json: + Bool.pick(J.Json, List.is_empty(&2, TD.Todo, ts), J.Null{}, Mob.todos.k(U.Todos.open(info, th), U.Todos.all(info, th), th, ts, info)) # Asks: what the agent waits on the user for. Each button sends "answer" # with its value; "primary" marks the one to lead with @@ -854,7 +862,7 @@ def Mob.thread(u: U.Ui, t: M.Thread, pend: List<&2, String>, vw: J.Json, +atts: J.KV{"asks", J.Json.arr(Mob.asks(U.Asks.open(st, id), U.Str.get(drafts, sel)))}, J.KV{"queued", J.Json.str(M.Queue.of(st, id))}, J.KV{"queue", J.Json.arr(Mob.queue(M.Queue.items(st, id), True{}, V.View.running(turn)))}, - J.KV{"todos", Mob.todos(U.Todos.shown(st, id, V.View.running(turn)))}, + J.KV{"todos", Mob.todos(nf, id, U.Todos.shown(st, id, V.View.running(turn)))}, J.KV{"live", J.Json.arr(Mob.nodes(D.Md.render(U.Str.get(live, id))))}, J.KV{"working", J.Json.str(Bool.pick(String, String.is_empty(U.Str.get(live, id)), M.Phase.label(ph), ""))}, J.KV{"phase", J.Json.str(M.Phase.name(ph))}, diff --git a/src/web/app.bend b/src/web/app.bend index e4d77482ad..13e25ac620 100644 --- a/src/web/app.bend +++ b/src/web/app.bend @@ -106,6 +106,14 @@ def origin(u: U.Ui) -> String: U.Ui.info(u, "origin") # The wire: CBOR bytes in and out (the host only moves them) +# the selected thread's composer and asks as the page draws them: host.js +# redraws only these for a keystroke (view.bend's View.composer.u) +def composer(u: U.Ui) -> D.Node: + V.View.composer.u(u) + +def asks(u: U.Ui) -> D.Node: + V.View.asks.u(u) + # the wire's dictionaries, for host.js's own decoder (wire.js) def cbor_keys() -> List<&2, String>: C.Cbor.keys() diff --git a/src/web/host.js b/src/web/host.js index 29b347f886..737b70507b 100644 --- a/src/web/host.js +++ b/src/web/host.js @@ -10,27 +10,6 @@ import * as Solid from "./solid.js"; import * as Plot2d from "./plot2d.js"; import * as Wire from "./wire.js"; -// JSON <-> Bend Json -// ------------------ - -function toJson(v) { - if (v === null || v === undefined) return { $: "Null" }; - if (typeof v === "boolean") return { $: "Flag", value: v }; - if (typeof v === "number") return { $: "Num", raw: String(v) }; - if (typeof v === "string") return { $: "Str", text: v }; - if (Array.isArray(v)) { - let items = { $: "End" }; - for (let i = v.length - 1; i >= 0; i -= 1) items = { $: "Item", head: toJson(v[i]), tail: items }; - return { $: "Arr", items }; - } - let fields = { $: "End" }; - const keys = Object.keys(v); - for (let i = keys.length - 1; i >= 0; i -= 1) { - fields = { $: "Field", key: keys[i], value: toJson(v[keys[i]]), tail: fields }; - } - return { $: "Obj", fields }; -} - // bytes <-> Bend List function toList(u8) { let xs = { $: "Nil" }; @@ -253,6 +232,25 @@ function later() { } } +// A keystroke in the composer changes only the composer and the asks (an +// answer with the draft): those two are redrawn alone (app.bend's composer +// and asks, the same nodes the page draws), not the whole page, which on a +// long thread took 280 ms a key. Anything else draws the page as before. +let partly = false; +function laterPart() { + if (queued || partly) return; + partly = true; + requestAnimationFrame(() => { + partly = false; + if (queued) return; + const c = document.querySelector(".thread-view .composer"); + if (!c || !c.__v) { render(); return; } + patch(c, App.composer(ui)); + const a = document.getElementById("asks"); + if (a && a.__v) patch(a, App.asks(ui)); + }); +} + function copy(text) { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text); @@ -344,7 +342,8 @@ function dispatch(action, value) { const did = r.cmds && r.cmds.$ === "Con"; run(r.cmds); deskCheck(); - later(); + if (action === "draft") laterPart(); + else later(); return did; } @@ -870,64 +869,60 @@ const token = (() => { if (token) document.cookie = `bp_token=${token}; path=/; SameSite=Strict`; // The event log this page holds, kept across reloads: a reload shows it at -// once and the socket then brings only what is new (since=, origin=). Raw -// server items only; the page state is rebuilt from them by app.bend. -const CACHE = "backplane-log"; -let cache = (() => { +// once and the socket then brings only what is new (since=, origin=). The +// hub's log frames are kept as they came (their CBOR bytes, one record each, +// in IndexedDB: a long log is many MB, past what localStorage holds), and a +// reload folds them again as if they had just arrived. A log from the start +// (since 0: a new page, or another log at the hub) clears what was kept. +const FRAMES = "frames"; +let db = null; +const opened = new Promise((done) => { try { - return JSON.parse(localStorage.getItem(CACHE) ?? "null"); + const r = indexedDB.open("backplane-log", 1); + r.onupgradeneeded = () => r.result.createObjectStore(FRAMES, { autoIncrement: true }); + r.onsuccess = () => { db = r.result; done(); }; + r.onerror = () => done(); + r.onblocked = () => done(); } catch { - return null; + done(); } -})(); +}); +try { localStorage.removeItem("backplane-log"); } catch {} // the copy kept before -// The log grows in place and is written out at most every 2 s (and when -// the page is hidden or left), not stringified whole for every message; -// a reload before a write catches up from the server (since). Over quota, -// the page keeps its copy in memory and drops only the stored one. -let dirty = false; -let flushing = null; +// the number in field k of a frame's top object, as Bend's Json holds it +function field(j, k) { + for (let c = j && j.fields; c && c.$ === "Field"; c = c.tail) if (c.key === k) return c.value && c.value.raw; + return undefined; +} -function flush() { - if (flushing !== null) { - clearTimeout(flushing); - flushing = null; - } - if (!dirty || !cache) return; - dirty = false; +function keep(kind, j, bytes) { + if (!db || (kind !== "log" && kind !== "changes")) return; try { - localStorage.setItem(CACHE, JSON.stringify(cache)); - } catch { - try { localStorage.removeItem(CACHE); } catch {} // over quota: start from the server next time - } + const tx = db.transaction(FRAMES, "readwrite"); + const st = tx.objectStore(FRAMES); + if (kind === "log" && Number(field(j, "since") ?? 0) === 0) st.clear(); + st.add(bytes.slice().buffer); + } catch {} } -function keep(msg) { - if (msg.t === "log") { - if (msg.since > 0 && cache) { - for (const x of msg.items) cache.items.push(x); - } else { - cache = { origin: msg.origin ?? "", items: msg.items }; - } - } else if (msg.t === "changes" && cache) { - for (const x of msg.items) cache.items.push(x); - } else { - return; +// the kept frames, folded in order before the page first connects +const restored = opened.then(() => new Promise((done) => { + if (!db) return done(); + try { + const r = db.transaction(FRAMES, "readonly").objectStore(FRAMES).getAll(); + r.onsuccess = () => { + try { + for (const buf of r.result ?? []) ui = App.recv(ui, Wire.cbor(new Uint8Array(buf), KEYS, WORDS)).ui; + // a saved log is not this connection's: the hub's own log says when + if ((r.result ?? []).length) ui = App.unsync(ui); + } catch {} + done(); + }; + r.onerror = () => done(); + } catch { + done(); } - dirty = true; - if (flushing === null) flushing = setTimeout(flush, 2000); -} - -addEventListener("pagehide", flush); -document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden") flush(); -}); - -if (cache && Array.isArray(cache.items)) { - ui = App.recv(ui, toJson({ t: "log", since: 0, origin: cache.origin, items: cache.items })).ui; - // a saved log is not this connection's: the hub's own log says when - ui = App.unsync(ui); -} +})); function connect() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; @@ -969,7 +964,7 @@ function connect() { // shows or gives commands const t = App.kind(j); const vis = App.shows(ui, j); - if (t === "log" || t === "changes") keep(Wire.plain(j)); + keep(t, j, bytes); const r = App.recv(ui, j); ui = r.ui; let acts = false; @@ -1014,8 +1009,11 @@ setInterval(() => { later(); }, 30000); -connect(); render(); +restored.then(() => { + render(); + connect(); +}); // the app shell works offline where the browser allows it (HTTPS or localhost) if ("serviceWorker" in navigator && window.isSecureContext) { diff --git a/src/web/style.css b/src/web/style.css index 1b963f3ec4..811fe7fbbe 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -182,7 +182,7 @@ button.fold span { overflow: hidden; text-overflow: ellipsis; white-space: nowra .composer .skills .desc { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .composer textarea:focus, .add input:focus { outline: none; border-color: var(--accent); } .thread-view.dropping .composer { outline: 2px dashed var(--accent); outline-offset: -4px; background: var(--raise); } -.subs { border: 1px solid var(--line); background: var(--panel); max-width: 860px; position: sticky; top: 0; z-index: 2; max-height: 40vh; overflow-y: auto; } +.subs { border: 1px solid var(--line); background: var(--panel); max-width: 860px; position: sticky; top: 0; z-index: 2; max-height: 30vh; overflow-y: auto; overscroll-behavior: contain; } .subs.busy { border-color: var(--accent); } .subs-head { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; color: var(--faint); font-size: 11px; background: none; border: 0; text-align: left; cursor: pointer; } .subs-head:hover { color: var(--text); } @@ -380,7 +380,11 @@ button.chip:hover { border-color: var(--bad); color: var(--bad); } .ask-btns { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } .todos { display: flex; flex-direction: column; gap: 2px; margin: 0 16px 4px; padding: 6px 10px; border: 1px solid var(--line); background: var(--raise); font-size: 12px; } .todos:empty { display: none; } -.todos .head { color: var(--soft, inherit); margin-bottom: 2px; } .todos .todo.completed { color: var(--faint); } .todos .todo.pending { color: var(--dim, inherit); } +.todos .head { display: flex; align-items: center; gap: 6px; width: 100%; color: var(--soft, inherit); margin-bottom: 2px; background: none; border: 0; padding: 0; text-align: left; cursor: pointer; font-size: inherit; } +.todos .head svg { margin-left: auto; color: var(--faint); } +.todos .more { align-self: flex-start; background: none; border: 0; padding: 2px 0; color: var(--accent); cursor: pointer; font-size: inherit; } +/* every step: the card scrolls, never taller than a third of the page */ +.todos.all { max-height: 34vh; overflow-y: auto; } .todos .todo.completed { color: var(--faint); } .todos .todo.pending { color: var(--dim, inherit); } .queue { display: flex; flex-direction: column; gap: 4px; margin: 0 16px; } .queue:empty { display: none; } .queued { display: flex; gap: 8px; align-items: center; padding: 4px 6px 4px 10px; border: 1px solid var(--line); background: var(--raise); font-size: 12px; } diff --git a/src/web/view.bend b/src/web/view.bend index ef55f1244d..b0665d242d 100644 --- a/src/web/view.bend +++ b/src/web/view.bend @@ -629,8 +629,20 @@ def View.tdlines(rs: List<&2, TD.TRow>) -> List<&2, D.Node>: D.Dom.span("todo " ++ TD.TRow.status(r), TD.TRow.text(r)) <> View.tdlines(rest) # the agent's todo list above the queue (U.Todos.shown) -def View.todos(+ts: List<&2, TD.Todo>) -> D.Node: - D.El{"div", "", [D.At{"class", "todos"}], Bool.pick(List<&2, D.Node>, List.is_empty(&2, TD.Todo, ts), Nil{}, D.Dom.span("head", TD.Todo.head(ts)) <> View.tdlines(TD.Todo.rows(ts)))} +# the todo card (client.bend's Todos): its head opens or shuts it, and a +# long list ends in a line that shows every step or the fitted few again +def View.todo.more(+text: String, +ask: String) -> List<&2, D.Node>: + Bool.pick(List<&2, D.Node>, String.is_empty(text), Nil{}, [D.Dom.button("more", "fold", ask, text, text)]) + +def View.todos.k(+open: Bool, +all: Bool, +th: String, +ts: List<&2, TD.Todo>, +info: Map<&2, String>) -> List<&2, D.Node>: + WI.WIcon.after("head", "fold", U.Todos.toggle(open, th), Bool.pick(String, open, "Hide the steps", "Show the steps"), TD.Todo.head(ts), + Bool.pick(String, open, "chevron-down", "chevron-right"), 14) + <> List.append(&2, D.Node, View.tdlines(U.Todos.rows(info, th, U.Todos.cap.all(), ts)), + Bool.pick(List<&2, D.Node>, open, View.todo.more(U.Todos.more(all, ts), U.Todos.more.ask(all, th)), Nil{})) + +def View.todos(+info: Map<&2, String>, +th: String, +ts: List<&2, TD.Todo>) -> D.Node: + D.El{"div", "", [D.At{"class", Bool.pick(String, U.Todos.all(info, th), "todos all", "todos")}], Bool.pick(List<&2, D.Node>, List.is_empty(&2, TD.Todo, ts), Nil{}, + View.todos.k(U.Todos.open(info, th), U.Todos.all(info, th), th, ts, info))} # what waits for a later turn (stored on the server), next first, each # with its buttons (U.Qu.buttons) @@ -1250,6 +1262,31 @@ def View.top(hd: List<&2, D.Node>, t: M.Thread, +st: M.State, +panel: Bool, snoo case h <> r: h <> r +# The two parts of a thread's page that follow its draft: the composer and +# the asks (an answer with the draft). The page draws them with these, and +# host.js redraws only them for a keystroke (app.bend's composer and asks): +# rebuilding a long thread's page for every key took 280 ms a key. +def View.composer.t(+st: M.State, +sel: String, +drafts: Map<&2, String>, +op: Map<&2, Bool>, +nf: Map<&2, String>, +turn: M.TurnState, +pr: String, +mo: String, + +atts: List<&2, String>, +up: String, vo: List<&2, D.Node>, +mic: String) -> D.Node: + +stop = U.Follow.stop(View.running(turn), U.Str.get(drafts, sel), atts) + View.composer(U.Str.get(drafts, sel), U.Follow.button(st, sel, View.running(turn), stop, U.Str.get(drafts, sel)), U.Follow.act(stop), U.Models.label(pr, mo), U.Usage.tail(st, sel), U.Flag.get(op, "models"), U.Models.menu(U.Str.get(nf, "models"), pr), mo, U.Str.get(nf, "models"), pr, + U.Chip.all(U.Str.get(nf, "models"), pr, mo), U.Effort.of(st, sel), U.Skills.of(sel, drafts, nf), atts, up, vo, mic) + +def View.asks.box(+st: M.State, +id: String, +draft: String) -> D.Node: + D.El{"div", "asks", [D.At{"class", "asks"}, D.At{"id", "asks"}], View.asks(U.Asks.open(st, id), draft)} + +# the same two from the Ui alone (app.bend's composer and asks) +def View.composer.k(u: U.Ui, t: M.Thread, +atts: List<&2, String>, +up: String, vo: List<&2, D.Node>, +mic: String) -> D.Node: + match u t: + case U.Ui{+st, +sel, +drafts, _, +op, _, _, _, _, _, _, _, _, _, +nf, _, _} M.Thread{_, _, _, _, _, +turn, _, _, _, _, _, _, _, _, _, +pr, +mo, _, _, _, _}: + View.composer.t(st, sel, drafts, op, nf, turn, pr, mo, atts, up, vo, mic) + +def View.composer.u(+u: U.Ui) -> D.Node: + View.composer.k(u, U.Ui.thread(U.Ui.state(u), U.Ui.sel(u)), U.Ui.atts(u, U.Ui.sel(u)), U.Ui.uploading(u, U.Ui.sel(u)), D.Dom.cat(View.btw(u), View.voice(u, U.Ui.sel(u))), View.mic.of(u, U.Ui.sel(u))) + +def View.asks.u(+u: U.Ui) -> D.Node: + View.asks.box(U.Ui.state(u), U.Ui.sel(u), U.Ui.draft_text(u)) + def View.thread.p(u: U.Ui, t: M.Thread, pend: List<&2, String>, +atts: List<&2, String>, +up: String, +term: Bool, hd: List<&2, D.Node>, vo: List<&2, D.Node>, +mic: String) -> D.Node: match u t: case U.Ui{+st, +sel, +drafts, +live, +op, _, +now, _, panel, _, _, _, _, _, +nf, _, +tr} M.Thread{+id, p, ti, c, a, +turn, pe, ar, o, sa, ua, pi, en, br, wt, +pr, +mo, rm, im, se, de}: @@ -1261,11 +1298,10 @@ def View.thread.p(u: U.Ui, t: M.Thread, pend: List<&2, String>, +atts: List<&2, D.Dom.cat(View.parent(st, U.Tasks.parent(st, id)), D.Dom.cat([D.El{"div", "work", [D.At{"class", "work"}], D.Dom.cat(View.subs(nf, id, SB.Subs.rows(st, nf, id)), View.pins(U.Ec.pins(U.Ec.make(tr, st, id, now), st, id), U.Ec.make(tr, st, id, now)))}], D.Dom.cat(Nil{}, D.Dom.cat(View.paged(drafts, M.State.mine(st, id), st, id, nf, View.running(turn), U.Ec.make(tr, st, id, now)), D.Dom.cat(View.sending(pend), View.live.k(String.is_empty(U.Str.get(live, id)), U.Side.phase(st, U.Ui.thread(st, id)), U.Str.get(live, id)))))))}, - D.El{"div", "asks", [D.At{"class", "asks"}], View.asks(U.Asks.open(st, id), U.Str.get(drafts, sel))}, - View.todos(U.Todos.shown(st, id, View.running(turn))), + View.asks.box(st, id, U.Str.get(drafts, sel)), + View.todos(nf, id, U.Todos.shown(st, id, View.running(turn))), View.queued(M.Queue.items(st, id), View.running(turn)), - View.composer(U.Str.get(drafts, sel), U.Follow.button(st, sel, View.running(turn), stop, U.Str.get(drafts, sel)), U.Follow.act(stop), U.Models.label(pr, mo), U.Usage.tail(st, sel), U.Flag.get(op, "models"), U.Models.menu(U.Str.get(nf, "models"), pr), mo, U.Str.get(nf, "models"), pr, - U.Chip.all(U.Str.get(nf, "models"), pr, mo), U.Effort.of(st, sel), U.Skills.of(sel, drafts, nf), atts, up, vo, mic)]), + View.composer.t(st, sel, drafts, op, nf, turn, pr, mo, atts, up, vo, mic)]), D.Dom.when(term, View.term(U.Term.get(tr, id))))} def View.thread.h(+u: U.Ui, t: M.Thread, hd: List<&2, D.Node>) -> D.Node: From 1a860448ef1f695f13dc53f61f6a2f8e7c1aeaa7 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:18:37 +0300 Subject: [PATCH 06/10] Hub: a join's frames split and joined in C, and its log measured once a window A join of a 28 MB log took 10-14 s and a 101 MB one 23 s, with the C encoder already in place: - Boot.k walked the whole log's length for every 64-line chunk (11 ms a chunk on 43k lines, so a join was quadratic). It now walks it only when the next 2000-line window is due (Boot.k.rest). - The writer measured each line three times and joined a frame's lines in Bend, a character at a time. Sock.send_items takes the WItems lines and does Items.texts.s's split, the join and the CBOR in C (host.c's ji_*). Now 1.9 s and 1.8 s. cbor_frame_test compares Json.items_frames with Items.texts.s through Ws.cbor on every 64-line chunk of a log, with the writer's limits and with tight ones that split them: 0 differ on the live log (681 chunks) and the stress log (274). Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/server/effects/host.c | 147 ++++++++++++++++++++++++++++++- src/server/effects/host.js | 39 ++++++++ src/server/host.bend | 16 ++++ src/server/server.bend | 29 ++++-- test/native/cbor_frame_test.bend | 62 ++++++++++++- 6 files changed, 282 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4043b6e21a..6b0c8fd03d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,7 +167,7 @@ Headless UI checks: start Xvfb on `:77`, run `DISPLAY=:77 BACKPLANE_SNAP=/tmp/sn - In JS, a Bend value is objects nested once per list cell (tens of thousands deep for a log). Anything recursive over it overflows the engine's stack: `JSON.parse` with a reviver lost the phones' kept state at every launch. bridge.js writes the state flat (`flat`/`unflat`, no recursion); time a launch with `test/tools/launch_bench.ts` (`BUN_JSC_useJIT=0`). - Base's `String.split` recurses once per character too: `Img.paths` split every long reply that named an image that way, the phones' JS engine ran out of stack inside `recv`, and the hub's whole frame was lost (a message sent then never showed in that thread). `Img.words` cuts words in one tail-recursive walk and drops a word over `Img.long()`. Replay a real log through bridge.js under the phone's limits to find these: `BUN_JSC_useJIT=0 BUN_JSC_maxPerThreadStackUsage=4194304`. - Base's `String.to_lower`/`to_upper` recurse once per character outside a tail call. On a long message they overflowed the phones' V8 sandbox, which crashes natively (no RangeError) and took the Android app with it. Never lower a whole message text; look at the few characters you need (`Img.may` in images.bend). Android now starts the client again in QuickJS if the sandbox dies (`Engine.eval`, `Core`'s `onReset`). Replay a phone launch on real data at a small stack to find these: `node --stack-size=500` with `test/tools/launch_bench.ts`. -- A join's chunks are encoded and sent in C (`H.Sock.send_cbor`, host.c's `jc_*`: JSON text straight to the same CBOR bytes as `W.Ws.cbor`, checked line by line on real logs by `test/native/cbor_frame_test.bend EVENTS.jsonl`): Bend's encoder sent a 100 MB log at ~1.5 MB/s. Live frames still go through `Ws.cbor` (the writer counts their bytes). +- A join's chunks are split, joined, encoded and sent in C (`H.Sock.send_items`, host.c's `ji_*` over `jc_*`: the writer's `WItems` lines as `OB.Items.texts.s` frames them, JSON text straight to the same CBOR bytes as `W.Ws.cbor`, checked line by line and chunk by chunk on real logs by `test/native/cbor_frame_test.bend EVENTS.jsonl`): Bend's encoder sent a 100 MB log at ~1.5 MB/s, and measuring and joining each frame's lines in Bend cost more than encoding. Live frames still go through `Ws.cbor` (the writer counts their bytes). The hub walks the log's length only when a join's next 2000-line window is due (`Boot.k.rest`), not per 64-line chunk: per chunk it made a join quadratic. - The hub must not read the text of log lines (`String.length`, joining, parsing) per chunk or per frame: a log line is shared, and reading a shared String's characters cost ~1 µs a character (a join's 128 KB chunk took 200 ms on the hub, and every other client waited). Count lines, hand the list to the writer (`WItems`), and let it put the frame together once (core/outbox.bend, server.bend's `Hub.boot`). - The event loop polls sockets only when no fiber can run: a fiber that always has work (a join's chunks back to back, a reader decoding a ping flood) starves every request. Space repeated work out (`Self.post.later`) and keep per-frame work O(frame): `Ws.decode` once checked `List.length` of the whole buffer per frame (`Ws.has` now), so 20000 pings froze the hub for 7 s. `bun test/tools/stall_e2e.ts` measures both. - The hub runs on macOS too (release target darwin-arm64: `backplane-serve` as `backplane`, no window; release.yml's `emit-darwin` emits its C and the web client on Linux, `scripts/build.sh emit`, and `build-darwin` compiles them, `BACKPLANE_PREBUILT=1`: bend's emit needs more memory than a macOS runner has). Shell it runs must not assume GNU or Linux: macOS has no `timeout` (use `Port.tmo()`'s `tmo SECS CMD` in a script, or `P.Proc.run("sh", Port.timeout([SECS, CMD, ...]))`; src/core/port.bend), no `setsid` (perl's `POSIX::setsid`), no `realpath -e`, `find -printf`, `stat -c` or `/proc`. Try a change to such a script on a Mac (`hs-mac-mini` has bend and bun; `scripts/build.sh && scripts/smoke.sh` there). diff --git a/src/server/effects/host.c b/src/server/effects/host.c index 957c934f32..f407e19284 100644 --- a/src/server/effects/host.c +++ b/src/server/effects/host.c @@ -907,7 +907,7 @@ static void __attribute__((constructor)) sys_exec_use(void) { // are the same (test/native/cbor_frame_test.bend). Bend's parser is lenient // about malformed text (it skips commas and colons) and this one is not. -#if defined(CID_SOCK_SEND_CBOR) || defined(CID_JSON_CBOR_FRAME) +#if defined(CID_SOCK_SEND_CBOR) || defined(CID_JSON_CBOR_FRAME) || defined(CID_SOCK_SEND_ITEMS) || defined(CID_JSON_ITEMS_FRAMES) typedef struct { uint8_t* p; size_t n, cap; int bad; } JcBuf; typedef struct { const char* p[128]; size_t n[128]; int len; } JcDict; @@ -1163,6 +1163,101 @@ static int jc_frame(const char* text, size_t n, const char* ks, size_t kn, const #endif +// A join frame's lines (core/outbox.bend's Items.texts.s) as the binary +// WebSocket frames of their CBOR, back to back: the first frame is hd, then +// plain (or split when the lines take more frames), the lines joined by +// commas, then tl; each later one a "changes" frame with "boot": true, the +// very last "boot": false when ends. A frame takes at least one line, then +// lines while they fit in `bytes` characters, `most` at most (Join.count.at: +// a line is counted in code points, as String.length counts it). The lines +// are read once here: in Bend each was measured three times and joined, a +// character at a time, for every frame. + +typedef struct { char* p; u64 n; u64 cps; } JiLine; + +static u64 ji_cps(const char* p, u64 n) { + u64 c = 0; + for (u64 i = 0; i < n; i += 1) c += ((uint8_t)p[i] & 0xC0) != 0x80; + return c; +} + +static u64 ji_count(JiLine* ls, u64 i, u64 n, u64 most, u64 bytes) { + if (i >= n) return 0; + u64 r = ls[i].cps > bytes ? 0 : bytes - ls[i].cps, c = 1; + u64 left = most > 0 ? most - 1 : 0; + for (u64 j = i + 1; j < n && left > 0; j += 1, left -= 1) { + if (ls[j].cps > r) break; + r -= ls[j].cps; + c += 1; + } + return c; +} + +static void ji_join(JcBuf* t, JiLine* ls, u64 i, u64 c) { + for (u64 j = i; j < i + c; j += 1) { + if (j > i) jc_byte(t, ','); + jc_put(t, ls[j].p, ls[j].n); + } +} + +// one frame's text out as its WebSocket frame, appended to out +static int ji_put(JcBuf* t, const char* ks, u64 kn, const char* ws, u64 wn, JcBuf* out) { + if (t->bad) return 0; + JcBuf f = {0}; + int ok = jc_frame((const char*)t->p, t->n, ks, kn, ws, wn, &f, 0); + if (ok) jc_put(out, f.p, f.n); + free(f.p); + t->n = 0; + return ok && !out->bad; +} + +static int ji_frames(const char* hd, u64 hn, const char* pl, u64 pn, const char* sp, u64 sn, JiLine* ls, u64 n, + const char* tl, u64 tn, int ends, u64 most, u64 bytes, const char* ks, u64 kn, const char* ws, u64 wn, JcBuf* out) { + JcBuf t = {0}; + u64 c = ji_count(ls, 0, n, most, bytes); + jc_put(&t, hd, hn); + if (c >= n) jc_put(&t, pl, pn); else jc_put(&t, sp, sn); + ji_join(&t, ls, 0, c); + jc_put(&t, tl, tn); + int ok = ji_put(&t, ks, kn, ws, wn, out); + for (u64 i = c; ok && i < n; i += c) { + c = ji_count(ls, i, n, most, bytes); + const char* h = ends && i + c >= n ? "{\"t\":\"changes\",\"boot\":false,\"items\":[" : "{\"t\":\"changes\",\"boot\":true,\"items\":["; + jc_put(&t, h, strlen(h)); + ji_join(&t, ls, i, c); + jc_put(&t, "]}", 2); + ok = ji_put(&t, ks, kn, ws, wn, out); + } + free(t.p); + return ok; +} + +// f[at..]: hd, plain, split, lines, tl, ends, most, bytes, keys, words +static int ji_run(Env e, Term* f, JcBuf* out) { + u64 hn, pn, sn, tn, kn, wn, cap = 64, n = 0; + char* hd = io_cstr(e, f[0], &hn); + char* pl = io_cstr(e, f[1], &pn); + char* sp = io_cstr(e, f[2], &sn); + JiLine* ls = (JiLine*)io_mem(malloc(cap * sizeof(JiLine))); + Term xs = f[3]; + while (term_aux(xs) == CID_CON) { + Term fb[2]; + spare_free(e, cls_fit(2), ctr_take(e, xs, 2, fb)); + if (n >= cap) { cap *= 2; ls = (JiLine*)io_mem(realloc(ls, cap * sizeof(JiLine))); } + ls[n].p = io_cstr(e, fb[0], &ls[n].n); + ls[n].cps = ji_cps(ls[n].p, ls[n].n); + n += 1; + xs = fb[1]; + } + char* tl = io_cstr(e, f[4], &tn); + char* k = io_cstr(e, f[8], &kn); + char* ws = io_cstr(e, f[9], &wn); + int ok = ji_frames(hd, hn, pl, pn, sp, sn, ls, n, tl, tn, (u32)f[5] != 0, (u64)(u32)f[6], (u64)(u32)f[7], k, kn, ws, wn, out); + for (u64 i = 0; i < n; i += 1) free(ls[i].p); + free(ls); free(hd); free(pl); free(sp); free(tl); free(k); free(ws); + return ok; +} + #ifdef CID_JSON_CBOR_FRAME Term json_cbor_frame_run(Env e, Term* f, IoWork* w) { @@ -1184,7 +1279,7 @@ static void __attribute__((constructor)) json_cbor_frame_use(void) { #endif -#ifdef CID_SOCK_SEND_CBOR +#if defined(CID_SOCK_SEND_CBOR) || defined(CID_SOCK_SEND_ITEMS) // as sock_send_until_more: data holds the deadline and span, then the frame static Term sock_send_cbor_more(Env e, IoWork* w) { @@ -1210,6 +1305,10 @@ static Term sock_send_cbor_more(Env e, IoWork* w) { return io_tup(e, io_hand(w->hand), r); } +#endif + +#ifdef CID_SOCK_SEND_CBOR + Term sock_send_cbor_run(Env e, Term* f, IoWork* w) { u64 tn, kn, wn; char* t = io_cstr(e, f[1], &tn); @@ -1237,3 +1336,47 @@ static void __attribute__((constructor)) sock_send_cbor_use(void) { } #endif + +#ifdef CID_JSON_ITEMS_FRAMES + +Term json_items_frames_run(Env e, Term* f, IoWork* w) { + JcBuf out = {0}; + int ok = ji_run(e, f, &out); + Term r = ok ? host_bytes(e, out.p, out.n) : term_pak(CID_NIL, 0); + free(out.p); + return r; +} + +static void __attribute__((constructor)) json_items_frames_use(void) { + io_eff(CID_JSON_ITEMS_FRAMES, json_items_frames_run, 0); +} + +#endif + +#ifdef CID_SOCK_SEND_ITEMS + +// as sock_send_cbor: data holds the deadline and span, then the frames +Term sock_send_items_run(Env e, Term* f, IoWork* w) { + u64 span = (u64)f[11] * 1000000ull; + u64 deadline = io_tick() + span; + size_t pre = sizeof deadline + sizeof span; + JcBuf out = {0}; + jc_room(&out, pre); + out.n = pre; + int ok = !out.bad && ji_run(e, f + 1, &out); + w->hand = (intptr_t)io_hand_v(f[0]); + w->code = 0; + if (!ok) { free(out.p); return io_tup(e, io_hand(w->hand), io_fail(e, ENOMEM, NULL)); } + memcpy(out.p, &deadline, sizeof deadline); + memcpy(out.p + sizeof deadline, &span, sizeof span); + w->data = io_mem(out.p); + w->size = out.n; + w->made = pre; + return sock_send_cbor_more(e, w); +} + +static void __attribute__((constructor)) sock_send_items_use(void) { + io_eff(CID_SOCK_SEND_ITEMS, sock_send_items_run, 0); +} + +#endif diff --git a/src/server/effects/host.js b/src/server/effects/host.js index 0832846749..910d46c897 100644 --- a/src/server/effects/host.js +++ b/src/server/effects/host.js @@ -386,6 +386,45 @@ function json_cbor_frame(text, keys, words) { return xs; } +// Json.items_frames and Sock.send_items: a join frame's lines as +// outbox.bend's Items.texts.s splits them, each frame's CBOR (see host.c) +function ji_frames(hd, plain, split, lines, tl, ends, most, bytes, keys, words) { + const ls = []; + for (let x = lines; x.$ === "Con"; x = x.tail) ls.push(x.head); + const cps = ls.map((l) => [...l].length); + const count = (i) => { + if (i >= ls.length) return 0; + let r = Math.max(0, bytes - cps[i]), c = 1; + for (let j = i + 1, left = Math.max(0, most - 1); j < ls.length && left > 0; j += 1, left -= 1) { + if (cps[j] > r) break; + r -= cps[j]; c += 1; + } + return c; + }; + let c = count(0); + const out = jc_frame(hd + (c >= ls.length ? plain : split) + ls.slice(0, c).join(",") + tl, keys, words); + for (let i = c; i < ls.length; i += c) { + c = count(i); + const last = ends !== 0 && i + c >= ls.length; + for (const b of jc_frame(`{"t":"changes","boot":${last ? "false" : "true"},"items":[` + ls.slice(i, i + c).join(",") + "]}", keys, words)) out.push(b); + } + return out; +} + +function json_items_frames(hd, plain, split, lines, tl, ends, most, bytes, keys, words) { + let xs = { $: "Nil" }; + const b = ji_frames(hd, plain, split, lines, tl, ends, most, bytes, keys, words); + for (let k = b.length - 1; k >= 0; k -= 1) xs = { $: "Con", head: b[k], tail: xs }; + return xs; +} + +function sock_send_items(socket, hd, plain, split, lines, tl, ends, most, bytes, keys, words, ms, k) { + let xs = { $: "Nil" }; + const b = ji_frames(hd, plain, split, lines, tl, ends, most, bytes, keys, words); + for (let j = b.length - 1; j >= 0; j -= 1) xs = { $: "Con", head: b[j], tail: xs }; + return sock_send_until(socket, xs, ms, k); +} + function sock_send_cbor(socket, text, keys, words, ms, k) { let xs = { $: "Nil" }; const b = jc_frame(text, keys, words); diff --git a/src/server/host.bend b/src/server/host.bend index c0319d98c5..34c792f72e 100644 --- a/src/server/host.bend +++ b/src/server/host.bend @@ -47,6 +47,22 @@ def Json.cbor_frame(text: String, keys: String, words: String) -> IO(List<&2, U3 import "./effects/host.c" import "./effects/host.js" +# A join frame's lines (core/outbox.bend's Items.texts.s: hd, plain or +# split, the lines, tl; then "changes" frames, the last one's "boot" false +# when ends is 1; at most `most` lines and `bytes` characters a frame) as +# the binary WebSocket frames of their CBOR, sent with a deadline as +# send_cbor. The lines are measured, joined and encoded in C: Bend measured +# each line three times and joined them a character at a time per frame. +def Sock.send_items(sock: Socket, hd: String, plain: String, split: String, lines: List<&2, String>, tl: String, ends: U32, most: U32, bytes: U32, keys: String, words: String, ms: U32) -> + IO(Socket & Result<&1, &1, U32 & String, Unit>): + import "./effects/host.c" + import "./effects/host.js" + +# the frames send_items sends, as bytes (for tests) +def Json.items_frames(hd: String, plain: String, split: String, lines: List<&2, String>, tl: String, ends: U32, most: U32, bytes: U32, keys: String, words: String) -> IO(List<&2, U32>): + import "./effects/host.c" + import "./effects/host.js" + def Sock.send_text(sock: Socket, text: String) -> IO(Socket & Result<&1, &1, U32 & String, Unit>): import "./effects/host.c" diff --git a/src/server/server.bend b/src/server/server.bend index 24c87b7d4c..329fc6a40d 100644 --- a/src/server/server.bend +++ b/src/server/server.bend @@ -966,12 +966,13 @@ def Conn.words() -> String: def Conn.out.cbor(s: Socket, +t: String, +out: Chan(Wire), k: Socket -> IO(Unit)) -> IO(Unit): IO.bind(Socket & Result<&1, &1, U32 & String, Unit>, Unit, H.Sock.send_cbor(s, t, Conn.keys(), Conn.words(), Conn.deadline()), r => Conn.sent.k(r, out, k)) -def Conn.items(ts: List<&2, String>, s: Socket, +out: Chan(Wire), k: Socket -> Nat -> IO(Unit)) -> IO(Unit): - match ts: - case Nil{}: - k(s, 0n) - case +t <> rest: - Conn.out.cbor(s, t, out, s2 => Conn.items(rest, s2, out, k)) +# a join frame's lines out as OB.Items.texts.s splits them, each frame's +# CBOR: measured, joined, encoded and sent in C (H.Sock.send_items, the same +# bytes, test/native/cbor_frame_test.bend) +def Conn.out.items(s: Socket, +hd: String, +pl: String, +sp: String, +xs: List<&2, String>, +tl: String, +ends: Bool, +out: Chan(Wire), k: Socket -> Nat -> IO(Unit)) -> IO(Unit): + IO.bind(Socket & Result<&1, &1, U32 & String, Unit>, Unit, + H.Sock.send_items(s, hd, pl, sp, xs, tl, Bool.pick(U32, ends, 1, 0), U32.from_nat(OB.Join.most()), U32.from_nat(OB.Join.bytes()), Conn.keys(), Conn.words(), Conn.deadline()), + r => Conn.sent.k(r, out, s2 => k(s2, 0n))) def Conn.write(m: Maybe<&1, Wire>, s: Socket, +out: Chan(Wire), k: Socket -> Nat -> IO(Unit)) -> IO(Unit): match m: @@ -986,7 +987,7 @@ def Conn.write(m: Maybe<&1, Wire>, s: Socket, +out: Chan(Wire), k: Socket -> Nat case Some{WState{_, _}}: k(s, 0n) case Some{WItems{hd, pl, sp, xs, tl, ends}}: - Conn.items(OB.Items.texts.s(hd, pl, sp, xs, tl, ends, OB.Join.most(), OB.Join.bytes()), s, out, k) + Conn.out.items(s, hd, pl, sp, xs, tl, ends, out, k) case None{}: Socket.close(s) @@ -6554,11 +6555,21 @@ def Boot.go(log: Bool, +c: Client, h: Hub, +n: Nat, +id: U32) -> IO(Hub): case False{} Hub{st, f, ls, cl, as, inbox, +cfg, note}: Boot.far.k(List.get(&2, FR.In, Far.mirrors(cfg), OB.Cr.fl(Boot.r(c))), Hub{st, f, ls, cl, as, inbox, cfg, note}, c, id) -def Boot.k(due: Bool, +c: Client, h: Hub, +id: U32) -> IO(Hub): - match due h: +# the log's length is walked only when the next window is due: a chunk +# with lines left over takes them (Boot.src), and walking a long log for +# every 64 lines made a join quadratic (11 ms a chunk on 43k lines) +def Boot.k.rest(empty: Bool, +c: Client, h: Hub, +id: U32) -> IO(Hub): + match empty h: case True{} Hub{st, f, +ls, cl, as, inbox, cfg, note}: Boot.go(Nat.is_lt(OB.Cr.at(Boot.r(c)), List.length(&2, String, ls)), c, Hub{st, f, ls, cl, as, inbox, cfg, note}, List.length(&2, String, ls), id) case False{} _: + Boot.go(True{}, c, h, 0n, id) + +def Boot.k(due: Bool, +c: Client, h: Hub, +id: U32) -> IO(Hub): + match due: + case True{}: + Boot.k.rest(List.is_empty(&2, String, OB.Cr.rest(Boot.r(c))), c, h, id) + case False{}: IO.pure(Hub, h) def Boot.m(m: Maybe<&2, Client>, h: Hub, +id: U32, +gen: U32) -> IO(Hub): diff --git a/test/native/cbor_frame_test.bend b/test/native/cbor_frame_test.bend index dbfb79ce7f..8d88911d47 100644 --- a/test/native/cbor_frame_test.bend +++ b/test/native/cbor_frame_test.bend @@ -4,10 +4,15 @@ import ../../src/core/http.bend as W import ../../src/core/cbor.bend as CB import ../../src/server/kicad.bend as K import ../../src/core/diff.bend as DF +import ../../src/core/outbox.bend as OB # host.c's Json.cbor_frame (the bytes Sock.send_cbor sends) against Bend's # W.Ws.cbor, the same frame: every line of an event log wrapped as a join # chunk, and edge cases (numbers at the edges of 32 bits, escapes, words). +# Then Json.items_frames (the bytes Sock.send_items sends) against +# OB.Items.texts.s's frames through W.Ws.cbor: the log's lines in the hub's +# chunks of 64, with the writer's limits and with tight ones that split +# them, the last frame's "boot" both ways. # scripts/build-app.sh test/native/cbor_frame_test.bend build/cbor_frame_test # build/cbor_frame_test EVENTS.jsonl @@ -73,13 +78,68 @@ def Cf.path(xs: List) -> String: case _ <> t: Cf.path(t) +def Cf.hd() -> String: + "{\"t\":\"log\",\"since\":0,\"origin\":\"x\",\"version\":\"v\"" + +def Cf.flat(fs: List<&2, String>) -> List<&2, U32>: + match fs: + case Nil{}: + Nil{} + case +t <> rest: + List.append(&2, U32, W.Ws.cbor(t), Cf.flat(rest)) + +def Cf.items.one(+xs: List<&2, String>, +ends: Bool, +most: Nat, +bytes: Nat) -> IO(Nat): + do IO: + got : List<&2, U32> <- H.Json.items_frames(Cf.hd(), ",\"items\":[", ",\"more\":true,\"items\":[", xs, "]}", Bool.pick(U32, ends, 1, 0), U32.from_nat(most), U32.from_nat(bytes), Cf.keys(), Cf.words()) + return Bool.pick(Nat, Cf.same(got, Cf.flat(OB.Items.texts.s(Cf.hd(), ",\"items\":[", ",\"more\":true,\"items\":[", xs, "]}", ends, most, bytes))), 0n, 1n) + +def Cf.groups(fuel: Nat, +ls: List<&2, String>, +n: Nat) -> List<&2, List<&2, String>>: + match fuel ls: + case _ Nil{}: + Nil{} + case 0n _: + [ls] + case 1n+f _: + List.take(&2, String, ls, n) <> Cf.groups(f, List.drop(&2, String, ls, n), n) + +def Cf.items.all(gs: List<&2, List<&2, String>>, +i: Nat, +most: Nat, +bytes: Nat, +bad: Nat) -> IO(Nat): + match gs: + case Nil{}: + IO.pure(Nat, bad) + case +g <> rest: + IO.bind(Nat, Nat, Cf.items.one(g, Nat.is_eq(Nat.mod(i, 2n), 0n), most, bytes), b => Cf.items.all(rest, 1n+i, most, bytes, Nat.add(bad, b))) + +def Cf.solid.k(empty: Bool, +l: String, rest: List<&2, String>) -> List<&2, String>: + match empty: + case True{}: + rest + case False{}: + l <> rest + +def Cf.solid(ls: List<&2, String>) -> List<&2, String>: + match ls: + case Nil{}: + Nil{} + case +l <> rest: + Cf.solid.k(String.is_empty(l), l, Cf.solid(rest)) + +def Cf.items.report(+what: String, +bad: Nat, +n: Nat) -> IO(Unit): + IO.print("items, " ++ what ++ ": " ++ Nat.show(bad) ++ " differ of " ++ Nat.show(n)) + def main() -> IO(Unit): do IO: args : List <- IO.args() +path : String = Cf.path(args) e : Nat <- Cf.all(Cf.edges(), 0n) IO.print("edge cases: " ++ Nat.show(e) ++ " differ of " ++ Nat.show(List.length(&2, String, Cf.edges()))) - text : String <- K.Io.text(path) + +text : String <- K.Io.text(path) +ls : List<&2, String> = Cf.wrap(DF.Diff.lines(text)) d : Nat <- Cf.all(ls, 0n) IO.print("log lines: " ++ Nat.show(d) ++ " differ of " ++ Nat.show(List.length(&2, String, ls))) + +gs : List<&2, List<&2, String>> = Cf.groups(List.length(&2, String, ls), Cf.solid(DF.Diff.lines(text)), 64n) + a : Nat <- Cf.items.all([Nil{}, Cf.edges(), Cf.edges()], 0n, 3n, 40n, 0n) + Cf.items.report("edge cases", a, 3n) + b : Nat <- Cf.items.all(gs, 0n, OB.Join.most(), OB.Join.bytes(), 0n) + Cf.items.report("log chunks", b, List.length(&2, List<&2, String>, gs)) + c : Nat <- Cf.items.all(gs, 1n, 7n, 4000n, 0n) + Cf.items.report("log chunks, tight", c, List.length(&2, List<&2, String>, gs)) From 14f09cd6522df0bbfca072b2c5ae26bd23e9f7c1 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:07:21 +0300 Subject: [PATCH 07/10] A thread with thousands of entries opens on the web again The terminals' walks over a thread's entries (client.bend's Emb.views.go, emb.bend's Emb.pins.go, Emb.loose, Emb.any, Emb.sum) built their result around the recursive call, one stack frame an entry. Emb.sync runs after every log and view, so on a thread of ~9000 entries (with 12000 subagent lines) every action overflowed the web's stack and the thread could not be opened. Each is now a tail call onto a reversed accumulator (Emb.sum keeps the first hit, as before). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/core/client.bend | 14 +++++++++---- src/core/emb.bend | 50 ++++++++++++++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/core/client.bend b/src/core/client.bend index a191bbf054..86e8e92690 100644 --- a/src/core/client.bend +++ b/src/core/client.bend @@ -6932,13 +6932,19 @@ def Emb.views.k(is_t: Bool, +s: M.State, +e: M.Entry, +last: Nat, +now: Nat, res case False{}: rest -# what the thread's terminals look like now (key, live, pinned, size) -def Emb.views.go(es: List<&2, M.Entry>, +s: M.State, +last: Nat, +now: Nat) -> List<&2, EM.EV>: +# what the thread's terminals look like now (key, live, pinned, size): a +# tail call onto a reversed accumulator, as core/emb.bend's walks (built +# around the recursive call, a thread of thousands of entries overflowed +# the web's and phones' stack on every action, and it could not be opened) +def Emb.views.acc(es: List<&2, M.Entry>, +s: M.State, +last: Nat, +now: Nat, acc: List<&2, EM.EV>) -> List<&2, EM.EV>: match es: case Nil{}: - Nil{} + acc case +e <> t: - Emb.views.k(EM.Emb.is(e), s, e, last, now, Emb.views.go(t, s, last, now)) + Emb.views.acc(t, s, last, now, Emb.views.k(EM.Emb.is(e), s, e, last, now, acc)) + +def Emb.views.go(es: List<&2, M.Entry>, +s: M.State, +last: Nat, +now: Nat) -> List<&2, EM.EV>: + List.reverse(&2, EM.EV, Emb.views.acc(es, s, last, now, Nil{})) def Emb.views(+u: Ui, +th: String) -> List<&2, EM.EV>: Emb.views.go(M.State.mine(Ui.state(u), th), Ui.state(u), EM.Emb.last_user(M.State.mine(Ui.state(u), th), 0n), Side.now(u)) diff --git a/src/core/emb.bend b/src/core/emb.bend index 47eb06b4ce..0e2f23b7fa 100644 --- a/src/core/emb.bend +++ b/src/core/emb.bend @@ -299,12 +299,19 @@ def Emb.pin.of(+s: M.State, +e: M.Entry) -> Bool: case _: False{} -def Emb.pins.go(es: List<&2, M.Entry>, +s: M.State, +last: Nat, +now: Nat) -> List<&2, EV>: +# (each walk over a thread's entries is a tail call onto a reversed +# accumulator: a thread holds thousands, and a walk that built its result +# around the recursive call overflowed the web's and phones' JS stack, so a +# long thread could not be opened) +def Emb.pins.acc(es: List<&2, M.Entry>, +s: M.State, +last: Nat, +now: Nat, acc: List<&2, EV>) -> List<&2, EV>: match es: case Nil{}: - Nil{} + acc case +e <> t: - Emb.pins.k(Emb.pin.of(s, e), s, e, last, now, Emb.pins.go(t, s, last, now)) + Emb.pins.acc(t, s, last, now, Emb.pins.k(Emb.pin.of(s, e), s, e, last, now, acc)) + +def Emb.pins.go(es: List<&2, M.Entry>, +s: M.State, +last: Nat, +now: Nat) -> List<&2, EV>: + List.reverse(&2, EV, Emb.pins.acc(es, s, last, now, Nil{})) # a thread's pinned terminals def Emb.pins(+s: M.State, +th: String, +now: Nat) -> List<&2, EV>: @@ -326,19 +333,25 @@ def Emb.keys.k(hit: Bool, +e: M.Entry, rest: List<&2, String>) -> List<&2, Strin # the keys of a thread's terminals that are not pinned (they stop when the # person sends the next message) -def Emb.loose(es: List<&2, M.Entry>, +s: M.State) -> List<&2, String>: +def Emb.loose.acc(es: List<&2, M.Entry>, +s: M.State, acc: List<&2, String>) -> List<&2, String>: match es: case Nil{}: - Nil{} + acc case +e <> t: - Emb.keys.k(Bool.and(Emb.is(e), Bool.not(Emb.pin.of(s, e))), e, Emb.loose(t, s)) + Emb.loose.acc(t, s, Emb.keys.k(Bool.and(Emb.is(e), Bool.not(Emb.pin.of(s, e))), e, acc)) -def Emb.any(es: List<&2, M.Entry>) -> Bool: +def Emb.loose(es: List<&2, M.Entry>, +s: M.State) -> List<&2, String>: + List.reverse(&2, String, Emb.loose.acc(es, s, Nil{})) + +def Emb.any.go(es: List<&2, M.Entry>, +found: Bool) -> Bool: match es: case Nil{}: - False{} + found case +e <> t: - Bool.or(Emb.is(e), Emb.any(t)) + Emb.any.go(t, Bool.or(found, Emb.is(e))) + +def Emb.any(es: List<&2, M.Entry>) -> Bool: + Emb.any.go(es, False{}) def Emb.sum.of(+e: M.Entry) -> String: match e: @@ -361,10 +374,21 @@ def Emb.hit(+e: M.Entry, +id: String) -> Bool: case _: False{} -# the summary of terminal id among es ("" when there is none) -def Emb.sum(es: List<&2, M.Entry>, +id: String) -> String: +def Emb.sum.pick(got: Bool, +hit: Bool, +e: M.Entry, +acc: String) -> String: + match got: + case True{}: + acc + case False{}: + Emb.sum.k(hit, e, acc) + +def Emb.sum.go(es: List<&2, M.Entry>, +id: String, +got: Bool, +acc: String) -> String: match es: case Nil{}: - "" + acc case +e <> t: - Emb.sum.k(Emb.hit(e, id), e, Emb.sum(t, id)) + Emb.sum.go(t, id, Bool.or(got, Emb.hit(e, id)), Emb.sum.pick(got, Emb.hit(e, id), e, acc)) + +# the summary of terminal id among es, the first that has it ("" when +# there is none) +def Emb.sum(es: List<&2, M.Entry>, +id: String) -> String: + Emb.sum.go(es, id, False{}, "") From ff1a056d244b7fd55b88d28055b47a7b2358f570 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:07:21 +0300 Subject: [PATCH 08/10] Scrolling up never stalls on a page of entries the timeline does not draw A page of earlier entries is 60 entries, and a run of todo steps or a fold's calls draws nothing new. At the very top of the timeline no scroll event comes after it, so the web never asked for the next page; Android's trigger was keyed by the first entry and did not run again; iOS kept its "asked" mark until the first entry changed, so it never asked again. - Web: after a render still within two screens of the top with the mark there, the next page is asked for (host.js's earlierCheck). - Android: the trigger is keyed by how many are left out too, and asks when the person next scrolls. - iOS: a change in how many are left out with the same first entry clears the mark and, still at the top, asks again. On the stress log's heaviest thread, wheeling up reached the first message in 15 s; before, it stopped at the first page. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/src/main/java/dev/backplane/mobile/Ui.kt | 12 +++++++++--- mobile/ios/Backplane/Views.swift | 10 ++++++++++ src/web/host.js | 10 +++++++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt b/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt index c4dcab4969..83eb34c9c8 100644 --- a/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt +++ b/mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt @@ -90,6 +90,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier @@ -132,6 +133,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first @Composable fun App(m: AppModel) { @@ -973,10 +975,14 @@ fun ThreadScreen(m: AppModel, s: Screen, t: ThreadView, below: (@Composable () - t.parent?.let { p -> item(key = "parent") { EntryRow(m, p) { shown = it } } } // scrolled up to the top while earlier entries are left out: they // are shown, no button (the list keeps its place by entry key) - if (t.earlier > 0) item(key = "earlier:" + (t.entries.firstOrNull()?.id ?: "")) { - // only when the person scrolled here (not as the list opens) + // (keyed by how many are left out too: a page of entries the list + // does not draw, todo steps or a fold's calls, moves nothing, and + // the next is asked for when the person scrolls on) + if (t.earlier > 0) item(key = "earlier:" + (t.entries.firstOrNull()?.id ?: "") + ":" + t.earlier) { + // only when the person scrolls here (not as the list opens) LaunchedEffect(Unit) { - if (list.isScrollInProgress) { keepAt = t.entries.firstOrNull()?.id; m.act("earlier", "") } + snapshotFlow { list.isScrollInProgress }.first { it } + keepAt = t.entries.firstOrNull()?.id; m.act("earlier", "") } Spacer(Modifier.size(1.dp)) } diff --git a/mobile/ios/Backplane/Views.swift b/mobile/ios/Backplane/Views.swift index 9d0d51f9de..b5f564e021 100644 --- a/mobile/ios/Backplane/Views.swift +++ b/mobile/ios/Backplane/Views.swift @@ -696,6 +696,8 @@ struct ThreadScreen: View { @State private var shown: Shown? // the entry that was first when earlier ones were asked for @State private var keepAt: String? + // where the earlier mark is (its top in the timeline) + @State private var markY: CGFloat = -10000 // the entry a jump showed, tinted for a moment @State private var lit: String? // the file menu this dialog let go of @@ -718,6 +720,7 @@ struct ThreadScreen: View { if let n = thread.earlier, n > 0 { Color.clear.frame(height: 1).background(GeometryReader { g in Color.clear.onChange(of: g.frame(in: .named("timeline")).minY) { _, y in + markY = y if y > -400 && keepAt == nil { keepAt = thread.entries.first?.id model.act("earlier", "") @@ -770,6 +773,13 @@ struct ThreadScreen: View { .onChange(of: thread.entries.first?.id) { if let k = keepAt { proxy.scrollTo(k, anchor: .top); keepAt = nil } } + // a page of entries the timeline does not draw (todo steps, a + // fold's calls) moves nothing: still at the top, the next is asked for + .onChange(of: thread.earlier) { + guard let k = keepAt, k == thread.entries.first?.id else { return } + keepAt = nil + if (thread.earlier ?? 0) > 0 && markY > -400 { keepAt = k; model.act("earlier", "") } + } // the design history's way back: the entry scrolled to and tinted, // the tint fading out (after the rows the jump opened are laid out) .onChange(of: model.jump) { _, j in diff --git a/src/web/host.js b/src/web/host.js index 737b70507b..c212853560 100644 --- a/src/web/host.js +++ b/src/web/host.js @@ -218,6 +218,10 @@ function render() { Solid.mount(document.getElementById("solid")); Plot2d.mount(document.getElementById("plot")); earlierAsked = false; + // a page of entries the timeline does not draw (todo steps, a fold's + // calls) moves nothing and, at the very top, no scroll comes to ask for + // the next: still near the top after a render, it is asked for + if (tl2 && tl2.querySelector("[data-earlier]") && tl2.scrollTop <= tl2.clientHeight * 2) requestAnimationFrame(() => earlierCheck(document.getElementById("timeline"))); if (focus) { document.getElementById(focus)?.focus(); focus = null; @@ -770,14 +774,14 @@ document.addEventListener("click", (e) => { }); // the timeline scrolled within two screens of its top while earlier // entries are left out (view.bend's mark): ask for them, once a render -document.addEventListener("scroll", (e) => { - const tl = e.target; +function earlierCheck(tl) { if (earlierAsked || !(tl instanceof Element) || tl.id !== "timeline") return; const mark = tl.querySelector("[data-earlier]"); if (!mark || tl.scrollTop > tl.clientHeight * 2) return; earlierAsked = true; dispatch("earlier", mark.getAttribute("data-earlier") ?? ""); -}, true); +} +document.addEventListener("scroll", (e) => earlierCheck(e.target), true); document.addEventListener("click", (e) => { if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey) return; From 28686fc2417b387b4e8c4ef478d6b727f01f43e9 Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:13:30 +0300 Subject: [PATCH 09/10] Phones: a thread whose tool calls wrote long summaries opens Mob.act chose between a terminal's row and any other activity's with Bool.pick, which builds both: every tool call's summary was cut as a terminal's title, by String.split, which recurses once per character. On a long thread a long summary overflowed the phone client's stack when the thread was opened. Mob.act.k matches instead, and Emb.part, Emb.title and Emb.cmd cut lines with DF.Diff.lines / DF.Diff.first (tail calls, the same lines as String.split on "\n"). Replayed through bridge.js under node --stack-size=500: the live log's longest thread (9000 entries, 12000 subagent lines) opens in 251 ms and pages back through all 86 pages; before, opening it overflowed. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/core/emb.bend | 10 +++++++--- src/mobile/screen.bend | 15 +++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/core/emb.bend b/src/core/emb.bend index 0e2f23b7fa..51e3d63e1e 100644 --- a/src/core/emb.bend +++ b/src/core/emb.bend @@ -2,6 +2,7 @@ import Base import ./vt.bend as VT import ./utf8.bend as U8 import ./model.bend as M +import ./diff.bend as DF # Terminals in the chat (pure). An agent opens one with the terminal_open # tool: a program on a pty, started in the thread's folder, shown in the @@ -72,11 +73,14 @@ def Emb.rows(+n: U32) -> U32: def Emb.summary(+title: String, +cols: U32, +rows: U32, +cmd: String) -> String: title ++ "\n" ++ U32.show(cols) ++ "x" ++ U32.show(rows) ++ "\n" ++ cmd +# (lines cut by DF.Diff.lines and the title by DF.Diff.first, tail calls: +# String.split recurses once per character, and a long summary overflowed +# the phones' stack) def Emb.part(+summary: String, +i: Nat) -> String: - Maybe.default(&2, String, List.get(&2, String, String.split(summary, '\n'), i), "") + Maybe.default(&2, String, List.get(&2, String, DF.Diff.lines(summary), i), "") def Emb.title(+summary: String) -> String: - Emb.part(summary, 0n) + DF.Diff.first(summary) def Emb.size(+summary: String, +i: Nat) -> String: Maybe.default(&2, String, List.get(&2, String, String.split(Emb.part(summary, 1n), 'x'), i), "") @@ -89,7 +93,7 @@ def Emb.tall(+summary: String) -> U32: # the command (it may hold newlines of its own) def Emb.cmd(+summary: String) -> String: - String.join(List.drop(&2, String, String.split(summary, '\n'), 2n), "\n") + String.join(List.drop(&2, String, DF.Diff.lines(summary), 2n), "\n") # The screen as text, and as bytes # -------------------------------- diff --git a/src/mobile/screen.bend b/src/mobile/screen.bend index 44382df5e1..4b951e39e2 100644 --- a/src/mobile/screen.bend +++ b/src/mobile/screen.bend @@ -366,6 +366,16 @@ def Mob.imgs.list(+s: String) -> List<&2, String>: def Mob.imgs(+dr: Map<&2, String>, +id: String) -> List<&2, J.Json>: Mob.urls(Mob.imgs.list(String.drop(U.Str.get(dr, Mob.imgs.key(id)), 1n))) +# a terminal's row or any other activity's: matched, not Bool.pick (which +# built both, so every tool call's summary was cut as a terminal's title) +def Mob.act.k(term: Bool, +id: String, +th: String, +tone: String, +kind: String, +summary: String) -> J.Json: + match term: + case True{}: + J.Json.obj([J.KV{"id", J.Json.str(id)}, J.KV{"kind", J.Json.str("emb")}, J.KV{"key", J.Json.str(EM.Emb.key(th, id))}, J.KV{"text", J.Json.str(EM.Emb.title(summary))}]) + case False{}: + J.Json.obj([J.KV{"id", J.Json.str(id)}, J.KV{"kind", J.Json.str("act")}, J.KV{"tone", J.Json.str(tone)}, + J.KV{"label", J.Json.str(kind)}, J.KV{"text", J.Json.str(summary)}]) + # a tool call or other activity; a design step (core/hist.bend) is a row # that shows it in the viewer ("hist-show" with its value) def Mob.act(step: Bool, e: M.Entry) -> J.Json: @@ -373,10 +383,7 @@ def Mob.act(step: Bool, e: M.Entry) -> J.Json: case True{} M.Act{+id, _, _, _, _, _}: J.Json.obj([J.KV{"id", J.Json.str(id)}, J.KV{"kind", J.Json.str("step")}, J.KV{"text", J.Json.str(HS.Hist.row(e))}, J.KV{"value", J.Json.str(id)}]) case _ M.Act{+id, +th, +tone, +kind, +summary, _}: - Bool.pick(J.Json, String.eq(tone, EM.Emb.tone()), - J.Json.obj([J.KV{"id", J.Json.str(id)}, J.KV{"kind", J.Json.str("emb")}, J.KV{"key", J.Json.str(EM.Emb.key(th, id))}, J.KV{"text", J.Json.str(EM.Emb.title(summary))}]), - J.Json.obj([J.KV{"id", J.Json.str(id)}, J.KV{"kind", J.Json.str("act")}, J.KV{"tone", J.Json.str(tone)}, - J.KV{"label", J.Json.str(kind)}, J.KV{"text", J.Json.str(summary)}])) + Mob.act.k(String.eq(tone, EM.Emb.tone()), id, th, tone, kind, summary) case _ M.Msg{+id, _, _, text, _}: J.Json.obj([J.KV{"id", J.Json.str(id)}, J.KV{"kind", J.Json.str("act")}, J.KV{"tone", J.Json.str("")}, J.KV{"label", J.Json.str("")}, J.KV{"text", J.Json.str(text)}]) From e3f2f25f9c8cffa03f15f97d79dc6ca2e809de1b Mon Sep 17 00:00:00 2001 From: jlcjak <206324213+jlcjak@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:15:09 +0300 Subject: [PATCH 10/10] Test: the todo card's fold and show-all, and the window's panes by the wheel test/panes_test.bend: Todos.open/all/rows/more through the fold action (open by default, the head shuts and opens it, Show all gives every step, Show fewer goes back, per thread), and nui.bend's Pane.step moving a pane's info offset a row a notch within its rows, layout.bend's Pane.clamp and Pane.range. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/panes_test.bend | 77 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 test/panes_test.bend diff --git a/test/panes_test.bend b/test/panes_test.bend new file mode 100644 index 0000000000..23357de954 --- /dev/null +++ b/test/panes_test.bend @@ -0,0 +1,77 @@ +import Base +import ./check.bend as C +import ../src/core/client.bend as U +import ../src/core/todo.bend as TD +import ../src/app/nui.bend as N +import ../src/app/layout.bend as L + +# Long threads' panes: the todo card opens and shuts with its head and shows +# every step on "Show all" (client.bend's Todos), and the window's scrolling +# panes (the subagents panel, the todo card) move by a row a wheel notch +# within their rows (nui.bend's Pane, layout.bend's Pane). + +def todos.go(n: Nat, +i: Nat, acc: List<&2, TD.Todo>) -> List<&2, TD.Todo>: + match n: + case 0n: + acc + case 1n+k: + todos.go(k, 1n+i, TD.Todo{Nat.show(i), "step " ++ Nat.show(i), "", Bool.pick(String, Nat.is_lt(i, 3n), "completed", "pending")} <> acc) + +# n steps, the first three done +def todos(+n: Nat) -> List<&2, TD.Todo>: + List.reverse(&2, TD.Todo, todos.go(n, 0n, Nil{})) + +def ui.of(a: U.Act) -> U.Ui: + match a: + case U.Act{u, _}: + u + +def fold(+u: U.Ui, +v: String) -> U.Ui: + ui.of(U.Ui.act(u, "fold", v)) + +def nui.of(s: N.Step) -> N.Nui: + match s: + case N.Step{n, _, _}: + n + +def bool(b: Bool) -> String: + Bool.pick(String, b, "true", "false") + +def rows(+u: U.Ui, +ts: List<&2, TD.Todo>) -> String: + Nat.show(List.length(&2, TD.TRow, U.Todos.rows(U.Ui.infos(u), "t1", U.Todos.cap.all(), ts))) + +# a notch down over a pane, then where it is +def notch(+n: N.Nui, +last: Nat, +dir: U32) -> N.Nui: + nui.of(N.Pane.step(n, "subs:t1", last, dir)) + +def at(+n: N.Nui) -> String: + Nat.show(L.Pane.off(U.Ui.infos(N.Nui.ui(n)), "subs:t1")) + +def main() -> IO(Unit): + do IO: + +u : U.Ui = U.Ui.init(0n) + +many : List<&2, TD.Todo> = todos(200n) + C.Check.str("a short list has no last line", U.Todos.more(False{}, todos(4n)), "") + C.Check.str("a long one offers every step", U.Todos.more(False{}, many), "Show all 200") + C.Check.str("shown all, it offers fewer", U.Todos.more(True{}, many), "Show fewer") + C.Check.str("the card starts open", bool(U.Todos.open(U.Ui.infos(u), "t1")), "true") + C.Check.str("and shows the fitted few", rows(u, many), "8") + C.Check.str("its head shuts it", bool(U.Todos.open(U.Ui.infos(fold(u, U.Todos.toggle(True{}, "t1"))), "t1")), "false") + C.Check.str("shut, it shows no steps", rows(fold(u, U.Todos.toggle(True{}, "t1")), many), "0") + C.Check.str("and opens again", bool(U.Todos.open(U.Ui.infos(fold(fold(u, U.Todos.toggle(True{}, "t1")), U.Todos.toggle(False{}, "t1"))), "t1")), "true") + C.Check.str("show all shows every step", rows(fold(u, U.Todos.more.ask(False{}, "t1")), many), "200") + C.Check.str("show fewer goes back", rows(fold(fold(u, U.Todos.more.ask(False{}, "t1")), U.Todos.more.ask(True{}, "t1")), many), "8") + C.Check.str("another thread's card is its own", bool(U.Todos.open(U.Ui.infos(fold(u, U.Todos.toggle(True{}, "t2"))), "t1")), "true") + C.Check.str("a pane's hit value: key", N.Pane.at("subs:t1|7", 0n), "subs:t1") + C.Check.str("a pane's hit value: its last row", N.Pane.at("subs:t1|7", 1n), "7") + +n : N.Nui = N.Nui.init(u, 1400, 860) + C.Check.str("a pane starts at its first row", at(n), "0") + C.Check.str("a notch down moves it a row", at(notch(n, 7n, 1)), "1") + C.Check.str("three notches, three rows", at(notch(notch(notch(n, 7n, 1), 7n, 1), 7n, 1)), "3") + C.Check.str("a notch up comes back", at(notch(notch(notch(n, 7n, 1), 7n, 1), 7n, 0)), "1") + C.Check.str("up at the top stays there", at(notch(n, 7n, 0)), "0") + C.Check.str("it stops at the last row", at(notch(notch(notch(n, 2n, 1), 2n, 1), 2n, 1)), "2") + C.Check.str("an offset past the rows is clamped", Nat.show(L.Pane.clamp(9n, 4n)), "3") + C.Check.str("an offset within them stays", Nat.show(L.Pane.clamp(2n, 4n)), "2") + C.Check.str("the head says which rows show", L.Pane.range(3n, 4n, 12n), " · 4–7 of 12") + C.Check.str("and nothing when all do", L.Pane.range(0n, 12n, 12n), "")