Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,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:<thread>:<id>`, snapshots `Emb.snap`, the agent's `<terminals>` 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:<key>`) 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:<thread>` and `todos.all:<thread>`); 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 `<key>|<last>`, a wheel notch over it moves the info's `pane:<key>` 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`.
Expand Down Expand Up @@ -169,6 +170,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 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).
Expand Down
11 changes: 11 additions & 0 deletions LAWS.bend
Original file line number Diff line number Diff line change
Expand Up @@ -5789,6 +5789,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}

Expand Down
6 changes: 6 additions & 0 deletions PROOF.bend
Original file line number Diff line number Diff line change
Expand Up @@ -7500,6 +7500,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():
{==}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<QButton>)
// 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<TodoLine>)
// 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<TodoLine>, 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,
Expand Down Expand Up @@ -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")) }
Expand Down
29 changes: 23 additions & 6 deletions mobile/android/app/src/main/java/dev/backplane/mobile/Ui.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,6 +91,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
Expand Down Expand Up @@ -132,6 +134,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) {
Expand Down Expand Up @@ -890,9 +893,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) {
Expand Down Expand Up @@ -964,10 +977,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))
}
Expand Down
6 changes: 6 additions & 0 deletions mobile/ios/Backplane/Screen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading