From 4b86dd3606c72aa216e54821e4503f6ff84eb104 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 10:52:50 -0400 Subject: [PATCH 1/7] Add btop-style agent activity custom sidebar example btop-agents.js lists every workspace with a braille sparkline of recent agent activity, a state glyph, a small progress meter, unread count and PR number, under a figlet header with a graph of busy workspaces. History is sampled per clock tick into per-workspace buckets inside the sidebar. Co-Authored-By: Claude Opus 5.5 (1M context) --- Examples/CustomSidebars/README.md | 8 + Examples/CustomSidebars/btop-agents.js | 356 ++++++++++++++++++ .../CustomSidebarValidationTests.swift | 4 +- docs/custom-sidebars.md | 3 + 4 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 Examples/CustomSidebars/btop-agents.js diff --git a/Examples/CustomSidebars/README.md b/Examples/CustomSidebars/README.md index 011f77aacc49..cc93539c26bc 100644 --- a/Examples/CustomSidebars/README.md +++ b/Examples/CustomSidebars/README.md @@ -31,5 +31,13 @@ cmux sidebar validate finder signals. - `finder.swift`: a macOS Finder-style workspace browser with a source list, selected workspace inspector, and tab list. +- `btop-agents.js`: agent activity in the spirit of btop. Each workspace shows + a braille sparkline of how busy its agents were over the last six minutes, + a state glyph (spinner while working, amber diamond when waiting for input), + a tiny progress meter, unread count and PR number. The header graphs busy + workspaces over twelve minutes. Click a row to select it, drag to reorder, + and use BUSY to hide quiet workspaces. Install with + `cp Examples/CustomSidebars/btop-agents.js ~/.config/cmux/sidebars/` and + `cmux sidebar select btop-agents`. See `docs/custom-sidebars.md` for the full authoring contract. diff --git a/Examples/CustomSidebars/btop-agents.js b/Examples/CustomSidebars/btop-agents.js new file mode 100644 index 000000000000..ba3d8cade6f0 --- /dev/null +++ b/Examples/CustomSidebars/btop-agents.js @@ -0,0 +1,356 @@ +// btop-agents: agent activity at a glance, btop style. Every workspace gets +// a braille sparkline of how busy its agents have been over the last few +// minutes, a state glyph, and a tiny progress meter; the header graphs how +// many workspaces are busy right now. Click a row to jump to the workspace, +// drag to reorder, click the ALL/BUSY chip to hide quiet workspaces. +// cp Examples/CustomSidebars/btop-agents.js ~/.config/cmux/sidebars/ +// cmux sidebar select btop-agents (or: cmux sidebar open btop-agents) +// +// History lives in this sidebar, sampled once per clock tick, so graphs start +// when the sidebar loads. Sessions that are already working backfill from +// their start time. One graph column is BUCKET seconds. + +const BUCKET = 15; // seconds per graph column (two columns per braille cell) +const ROW_CELLS = 12; // per-workspace sparkline: 24 columns = 6 minutes +const TOP_CELLS = 24; // header graph: 48 columns = 12 minutes +const KEEP = TOP_CELLS * 2; // buckets of history kept per workspace +const METER = 6; // progress meter cells +const MAX_ROWS = 40; + +// Theme tokens only (system colors adapt to light and dark). +const STATE = { + needs_input: { glyph: "◆", color: "orange", word: "input" }, + working: { glyph: "", color: "cyan", word: "run" }, + idle: { glyph: "●", color: "green", word: "idle" }, + ended: { glyph: "○", color: "tertiary", word: "done" }, + none: { glyph: "·", color: "quaternary", word: "" }, +}; +const RANK = { needs_input: 4, working: 3, idle: 2, ended: 1, none: 0 }; +const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; +// Graph gradient, bottom dot row to top (btop's cpu box, in system colors). +const ROW_HEAT = ["green", "green", "yellow", "orange"]; +const TOP_HEAT = ["teal", "teal", "green", "green", "yellow", "yellow", "orange", "red"]; +const FLOOR = "quaternary"; // the dotted baseline under an idle graph +const PR_COLOR = { open: "green", merged: "purple", closed: "red" }; + +// "agents" in the Calvin S figlet font. +const BANNER = [ + "┌─┐┌─┐┌─┐┌┐┌┌┬┐┌─┐", + "├─┤│ ┬├┤ │││ │ └─┐", + "┴ ┴└─┘└─┘┘└┘ ┴ └─┘", +]; +const BANNER_COLORS = ["cyan", "teal", "green"]; + +// --------------------------------------------------------------------------- +// Braille graphs. A cell is 2 columns x 4 dot rows; bit for each dot row, +// bottom to top, in the left and right column. +const LEFT_DOTS = [0x40, 0x04, 0x02, 0x01]; +const RIGHT_DOTS = [0x80, 0x20, 0x10, 0x08]; +const BLANK = String.fromCharCode(0x2800); + +// levels: one integer per column (0..lines*4). Returns lines top first; each +// line is 5 same-width layer strings: [floor, dot row 1 (bottom) .. dot row 4]. +// Layers stack in a ZStack so every dot row gets its own gradient color. +function brailleLayers(levels, lines) { + const out = []; + for (let line = lines - 1; line >= 0; line -= 1) { + const layers = ["", "", "", "", ""]; + for (let c = 0; c + 1 < levels.length; c += 2) { + const l = Math.max(0, Math.min(4, levels[c] - line * 4)); + const r = Math.max(0, Math.min(4, levels[c + 1] - line * 4)); + let floor = 0; + if (line === 0) { + if (l === 0) floor |= LEFT_DOTS[0]; + if (r === 0) floor |= RIGHT_DOTS[0]; + } + layers[0] += String.fromCharCode(0x2800 + floor); + for (let k = 0; k < 4; k += 1) { + const bits = (l > k ? LEFT_DOTS[k] : 0) | (r > k ? RIGHT_DOTS[k] : 0); + layers[k + 1] += String.fromCharCode(0x2800 + bits); + } + } + out.push(layers); + } + return out; +} + +function graphLine(layers, heat) { + return ZStack({ alignment: "leading" }, [ + Text(() => layers()[0]).font(10).monospaced().color(FLOOR), + ...[1, 2, 3, 4].map((k) => + Text(() => layers()[k]).font(10).monospaced().color(heat[k - 1])), + ]).fixedSize(); +} + +// --------------------------------------------------------------------------- +// History: wsId -> Map(bucket -> { n: samples, busy: samples with a working agent }). +const history = new Map(); +let lastSampled = -1; + +const list = (v) => (Array.isArray(v) ? v : []); +const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null); + +function backfill(h, agents, now) { + const first = Math.floor(now / BUCKET) - KEEP + 1; + for (const a of agents) { + const since = num(a.sinceEpoch); + if (a.status !== "working" || since === null || since >= now) continue; + for (let b = Math.max(first, Math.floor(since / BUCKET)); b < Math.floor(now / BUCKET); b += 1) { + h.set(b, { n: 1, busy: 1 }); + } + } +} + +function sample(workspaces, now) { + const bucket = Math.floor(now / BUCKET); + const live = new Set(); + for (const w of workspaces) { + live.add(w.id); + const agents = list(w.agents); + let h = history.get(w.id); + if (!h) { + h = new Map(); + history.set(w.id, h); + backfill(h, agents, now); + } + const slot = h.get(bucket) ?? { n: 0, busy: 0 }; + slot.n += 1; + if (agents.some((a) => a.status === "working")) slot.busy += 1; + h.set(bucket, slot); + for (const b of h.keys()) if (b <= bucket - KEEP) h.delete(b); + } + for (const id of Array.from(history.keys())) if (!live.has(id)) history.delete(id); +} + +// Share of each bucket an agent was working, newest last. +function duty(h, count, bucket) { + const out = []; + for (let b = bucket - count + 1; b <= bucket; b += 1) { + const s = h && h.get(b); + out.push(s && s.n ? s.busy / s.n : 0); + } + return out; +} + +// --------------------------------------------------------------------------- +// Row model. +function fmtAge(secs) { + const s = Math.max(0, Math.floor(secs)); + if (s < 60) return s + "s"; + const m = Math.floor(s / 60); + if (m < 60) return m + "m"; + const h = Math.floor(m / 60); + return h < 48 ? h + "h" : Math.floor(h / 24) + "d"; +} + +function lead(agents) { + let best = null; + for (const a of agents) { + if (!best || (RANK[a.status] ?? 0) > (RANK[best.status] ?? 0)) best = a; + } + return best; +} + +function rowModel(w, now) { + const agents = list(w.agents); + const top = lead(agents); + const state = top && STATE[top.status] ? top.status : "none"; + const bucket = Math.floor(now / BUCKET); + const levels = duty(history.get(w.id), ROW_CELLS * 2, bucket) + .map((d) => (d > 0 ? Math.max(1, Math.round(d * 4)) : 0)); + const progress = w.progress && num(w.progress.value) !== null + ? Math.max(0, Math.min(1, w.progress.value)) : null; + const since = top ? num(top.sinceEpoch) ?? num(top.lastActivityAt) : null; + const age = since !== null && now > 0 ? fmtAge(now - since) : ""; + const pr = w.pr && num(w.pr.number) !== null ? w.pr : null; + const running = agents.filter((a) => a.status === "working").length; + return { + id: w.id, + title: w.title || "untitled", + selected: !!w.selected, + tint: w.color || null, + state, + glyph: state === "working" ? SPINNER[now % SPINNER.length] : STATE[state].glyph, + count: running > 1 ? "×" + running : "", + unread: num(w.unread) || 0, + prLabel: pr ? "#" + pr.number : "", + prColor: pr ? PR_COLOR[pr.status] ?? "secondary" : "secondary", + prURL: pr ? pr.url || "" : "", + layers: brailleLayers(levels, 1)[0], + meterOn: progress === null ? "" : "■".repeat(Math.round(progress * METER)), + meterOff: progress === null ? "" : "■".repeat(METER - Math.round(progress * METER)), + meterText: progress === null ? "" : Math.round(progress * 100) + "%", + status: progress === null && state !== "none" ? STATE[state].word + " " + age : "", + busy: state === "working" || state === "needs_input" || (num(w.unread) || 0) > 0, + }; +} + +const [busyOnly, setBusyOnly] = signal(false); + +const snapshot = computed(() => { + const now = Math.floor(num(data.clock()?.epoch) ?? 0); + const workspaces = list(data.workspaces()); + if (now > 0 && now !== lastSampled) { + sample(workspaces, now); + lastSampled = now; + } + const rows = workspaces.map((w) => rowModel(w, now)); + + // Header graph: how many workspaces were busy, per bucket. + const bucket = Math.floor(now / BUCKET); + const sums = new Array(TOP_CELLS * 2).fill(0); + for (const h of history.values()) { + duty(h, TOP_CELLS * 2, bucket).forEach((d, i) => { sums[i] += d; }); + } + const peak = Math.max(...sums); + const scale = Math.max(4, Math.ceil(peak)); + const levels = sums.map((s) => (s > 0 ? Math.max(1, Math.round((s / scale) * 8)) : 0)); + + let working = 0, waiting = 0; + for (const w of workspaces) { + for (const a of list(w.agents)) { + if (a.status === "working") working += 1; + if (a.status === "needs_input") waiting += 1; + } + } + return { + now, + rows, + top: brailleLayers(levels, 2), + peak: Math.round(peak * 10) / 10, + working, + waiting, + busyCount: rows.filter((r) => r.busy).length, + }; +}); + +const shown = computed(() => { + const rows = snapshot().rows; + return (busyOnly() ? rows.filter((r) => r.busy || r.selected) : rows).slice(0, MAX_ROWS); +}); + +// A drop index counts visible rows; map it onto the full workspace order. +function move(id, index) { + const all = list(data.workspaces()).map((w) => w.id).filter((x) => x !== id); + const visible = shown().map((r) => r.id).filter((x) => x !== id); + let target = index; + if (visible.length < all.length) { + target = index < visible.length + ? all.indexOf(visible[index]) + : all.indexOf(visible[visible.length - 1]) + 1; + } + cmux("workspace.reorder", { workspace_id: id, index: Math.max(0, target) }); +} + +// --------------------------------------------------------------------------- +// Views. +function header() { + const spin = () => SPINNER[snapshot().now % SPINNER.length]; + return VStack({ spacing: 6 }, [ + HStack({ spacing: 8, alignment: "top" }, [ + VStack({ spacing: 0 }, BANNER.map((line, i) => + Text(line).font(10).monospaced().color(BANNER_COLORS[i]))), + Spacer({ minLength: 0 }), + VStack({ spacing: 1, alignment: "trailing" }, [ + Text(() => data.clock()?.time ?? "").font(10).monospaced().color("secondary"), + HStack({ spacing: 3 }, [ + Text(() => (snapshot().working ? spin() : "·")).font(10).monospaced() + .color(() => (snapshot().working ? STATE.working.color : "quaternary")), + Text(() => snapshot().working + " run").font(10).monospaced().color("secondary"), + ]), + HStack({ spacing: 3 }, [ + Text("◆").font(10).monospaced() + .color(() => (snapshot().waiting ? STATE.needs_input.color : "quaternary")), + Text(() => snapshot().waiting + " input").font(10).monospaced() + .color(() => (snapshot().waiting ? STATE.needs_input.color : "secondary")), + ]), + ]).fixedSize(), + ]), + VStack({ spacing: 0 }, [0, 1].map((line) => + graphLine(() => snapshot().top[line], line === 0 ? TOP_HEAT.slice(4) : TOP_HEAT.slice(0, 4)))), + HStack({ spacing: 4 }, [ + Text(Math.round((TOP_CELLS * 2 * BUCKET) / 60) + "m").font(9).monospaced().color("tertiary"), + Text(() => "busy workspaces, peak " + snapshot().peak).font(9).monospaced().color("tertiary") + .lineLimit(1), + Spacer({ minLength: 0 }), + Text("now").font(9).monospaced().color("tertiary"), + ]), + ]) + .frame({ maxWidth: "infinity" }); +} + +function chip(label, active, onTap) { + return Text(label).font(9).monospaced().weight("semibold") + .color(() => (active() ? "primary" : "tertiary")) + .paddingHorizontal(5).paddingVertical(1) + .cornerRadius(4) + .background(() => (active() ? "#7f7f7f33" : "clear")) + .hoverBackground("#7f7f7f26") + .onTap(onTap); +} + +function row(r) { + const color = () => STATE[r().state].color; + return HStack({ spacing: 6 }, [ + RoundedRectangle({ width: 3, height: 26, cornerRadius: 1.5 }) + .fill(() => (r().selected ? r().tint ?? "accent" : r().tint ? r().tint + "55" : "clear")), + VStack({ spacing: 2 }, [ + HStack({ spacing: 5 }, [ + Text(() => r().glyph).font(11).monospaced().color(color) + .frame({ width: 10, alignment: "center" }), + Text(() => r().title).font(12) + .weight(() => (r().selected ? "semibold" : "regular")) + .color(() => (r().state === "none" && !r().selected ? "secondary" : "primary")) + .lineLimit(1).truncation("tail"), + Text(() => r().count).font(9).monospaced().color(color), + Spacer({ minLength: 0 }), + Text(() => (r().unread ? "●" + r().unread : "")).font(9).monospaced().color("blue"), + Text(() => r().prLabel).font(9).monospaced().color(() => r().prColor) + .paddingHorizontal(2).cornerRadius(3).hoverBackground("#7f7f7f33") + .onTap(() => { if (r().prURL) openURL(r().prURL); }), + ]), + HStack({ spacing: 6 }, [ + graphLine(() => r().layers, ROW_HEAT), + Spacer({ minLength: 0 }), + HStack({ spacing: 0 }, [ + Text(() => r().meterOn).font(8).monospaced().color("accent"), + Text(() => r().meterOff).font(8).monospaced().color("quaternary"), + ]), + Text(() => r().meterText || r().status).font(9).monospaced() + .color(() => (r().meterText ? "secondary" : r().state === "needs_input" ? color() : "tertiary")) + .lineLimit(1), + ]).paddingLeading(15), + ]).frame({ maxWidth: "infinity" }), + ]) + .paddingHorizontal(6).paddingVertical(4) + .cornerRadius(6) + .background(() => (r().selected ? "#7f7f7f1f" : "clear")) + .hoverBackground("#7f7f7f26") + .frame({ maxWidth: "infinity" }) + .onTap(() => cmux("workspace.select", { workspace_id: r().id })) + .contextMenu([ + Button(() => (r().prLabel ? "Open PR " + r().prLabel : "No PR"), () => { + if (r().prURL) openURL(r().prURL); + }), + Button("Mark Read", () => cmux("workspace.action", { workspace_id: r().id, action: "mark_read" })), + ]); +} + +sidebar(() => + VStack({ spacing: 6 }, [ + header().paddingHorizontal(8).paddingTop(6), + HStack({ spacing: 4 }, [ + chip("ALL", () => !busyOnly(), () => setBusyOnly(false)), + chip("BUSY", busyOnly, () => setBusyOnly(true)), + Spacer({ minLength: 0 }), + Text(() => snapshot().busyCount + "/" + snapshot().rows.length + " busy") + .font(9).monospaced().color("tertiary"), + ]).paddingHorizontal(8), + Divider(), + Reorderable({ items: shown, key: (r) => r.id, onMove: move, spacing: 1 }, row), + Text(() => (shown().length ? "" : busyOnly() ? "all quiet" : "no workspaces")) + .font(10).monospaced().color("tertiary").paddingHorizontal(8), + Spacer(), + ]).paddingHorizontal(4), + { surface: "glass" } +) diff --git a/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift b/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift index 0ec33925f8c5..c6f860ffeaed 100644 --- a/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift +++ b/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift @@ -99,8 +99,8 @@ struct CustomSidebarValidationTests { let directory = examplesDirectory() let report = validator.validate(directory: directory, dataContext: Self.richSidebarContext) - #expect(report.names.sorted() == ["activity", "agents-board", "agents-cards", "agents-dense", "agents-focus", "agents-timeline", "clock", "compact", "finder", "focus", "kitchen-sink", "panel-info", "panel-sessions", "panel-subagents", "panel-todo", "ports", "status-board", "workspaces"]) - #expect(report.validCount == 18) + #expect(report.names.sorted() == ["activity", "agents-board", "agents-cards", "agents-dense", "agents-focus", "agents-timeline", "btop-agents", "clock", "compact", "finder", "focus", "kitchen-sink", "panel-info", "panel-sessions", "panel-subagents", "panel-todo", "ports", "status-board", "workspaces"]) + #expect(report.validCount == 19) #expect(report.errorCount == 0) } diff --git a/docs/custom-sidebars.md b/docs/custom-sidebars.md index b76ae9fd2165..8663f6d49289 100644 --- a/docs/custom-sidebars.md +++ b/docs/custom-sidebars.md @@ -253,6 +253,9 @@ The repo includes ready-to-copy sidebars in `Examples/CustomSidebars/`: review, progress, research, and done. - `finder.swift` shows a macOS Finder-style workspace browser with a source list, selected workspace details, and tabs. +- `btop-agents.js` is a btop-style agent activity list: a braille sparkline + of recent agent activity per workspace, state glyphs, a small progress meter, + and a header graph of busy workspaces. Install one from a cmux checkout: From 0e9f8c346216cc43dc084b3aac133e54c858698a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 11:03:33 -0400 Subject: [PATCH 2/7] Keep btop sidebar graphs readable on light sidebars System yellow braille dots were faint on a white sidebar; the heat ramps now go green to orange to red. Co-Authored-By: Claude Opus 5.5 (1M context) --- Examples/CustomSidebars/btop-agents.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Examples/CustomSidebars/btop-agents.js b/Examples/CustomSidebars/btop-agents.js index ba3d8cade6f0..c2bdbfc77f88 100644 --- a/Examples/CustomSidebars/btop-agents.js +++ b/Examples/CustomSidebars/btop-agents.js @@ -28,8 +28,9 @@ const STATE = { const RANK = { needs_input: 4, working: 3, idle: 2, ended: 1, none: 0 }; const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; // Graph gradient, bottom dot row to top (btop's cpu box, in system colors). -const ROW_HEAT = ["green", "green", "yellow", "orange"]; -const TOP_HEAT = ["teal", "teal", "green", "green", "yellow", "yellow", "orange", "red"]; +// No yellow: system yellow braille dots wash out on a light sidebar. +const ROW_HEAT = ["green", "green", "orange", "orange"]; +const TOP_HEAT = ["teal", "teal", "green", "green", "green", "orange", "orange", "red"]; const FLOOR = "quaternary"; // the dotted baseline under an idle graph const PR_COLOR = { open: "green", merged: "purple", closed: "red" }; From 3db3b6d1a22c2079d640878bfce5a22a278c4085 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 14:21:57 -0400 Subject: [PATCH 3/7] fix(example): bound btop history across clock changes --- Examples/CustomSidebars/btop-agents.js | 4 ++- tests/test_btop_agents_sidebar.py | 46 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/test_btop_agents_sidebar.py diff --git a/Examples/CustomSidebars/btop-agents.js b/Examples/CustomSidebars/btop-agents.js index c2bdbfc77f88..f441b42b1137 100644 --- a/Examples/CustomSidebars/btop-agents.js +++ b/Examples/CustomSidebars/btop-agents.js @@ -118,7 +118,9 @@ function sample(workspaces, now) { slot.n += 1; if (agents.some((a) => a.status === "working")) slot.busy += 1; h.set(bucket, slot); - for (const b of h.keys()) if (b <= bucket - KEEP) h.delete(b); + // Wall-clock corrections can move `bucket` backward. Drop future buckets + // too, otherwise every clock era leaves another KEEP entries behind. + for (const b of h.keys()) if (b <= bucket - KEEP || b > bucket) h.delete(b); } for (const id of Array.from(history.keys())) if (!live.has(id)) history.delete(id); } diff --git a/tests/test_btop_agents_sidebar.py b/tests/test_btop_agents_sidebar.py new file mode 100644 index 000000000000..d66a342c2572 --- /dev/null +++ b/tests/test_btop_agents_sidebar.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Stateful regressions for the btop-style custom sidebar example.""" + +from pathlib import Path +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "Examples" / "CustomSidebars" / "btop-agents.js" + + +class BtopAgentsSidebarTests(unittest.TestCase): + def test_history_stays_bounded_when_wall_clock_moves_backward(self): + source = SOURCE.read_text(encoding="utf-8") + history = "const history = new Map();" + source.split("const history = new Map();", 1)[1].split( + "// ---------------------------------------------------------------------------\n// Row model.", + 1, + )[0] + script = f""" +const BUCKET = 15; +const KEEP = 48; +{history} +const workspaces = [{{ id: "workspace", agents: [] }}]; +for (const era of [10_000, 1_000, 100]) {{ + for (let index = 0; index < KEEP; index += 1) {{ + sample(workspaces, era + index * BUCKET); + }} + if (history.get("workspace").size > KEEP) {{ + throw new Error(`history grew to ${{history.get("workspace").size}} entries`); + }} +}} +""" + + result = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() From 9d8428fd62522bc2407abcd9a8b473d742c17a1d Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 14:26:13 -0400 Subject: [PATCH 4/7] fix(example): cap btop workspace modeling --- Examples/CustomSidebars/btop-agents.js | 23 +++++++++++++++--- tests/test_btop_agents_sidebar.py | 32 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Examples/CustomSidebars/btop-agents.js b/Examples/CustomSidebars/btop-agents.js index f441b42b1137..dfe1b02eb9e4 100644 --- a/Examples/CustomSidebars/btop-agents.js +++ b/Examples/CustomSidebars/btop-agents.js @@ -188,11 +188,29 @@ function rowModel(w, now) { }; } +function workspaceIsBusy(w) { + return (num(w.unread) || 0) > 0 + || list(w.agents).some((a) => a.status === "working" || a.status === "needs_input"); +} + +function cappedWorkspaces(workspaces, onlyBusy) { + const candidates = onlyBusy + ? workspaces.filter((w) => workspaceIsBusy(w) || w.selected) + : workspaces; + const capped = candidates.slice(0, MAX_ROWS); + const selected = workspaces.find((w) => w.selected); + if (selected && !capped.some((w) => w.id === selected.id)) { + if (capped.length === MAX_ROWS) capped[MAX_ROWS - 1] = selected; + else capped.push(selected); + } + return capped; +} + const [busyOnly, setBusyOnly] = signal(false); const snapshot = computed(() => { const now = Math.floor(num(data.clock()?.epoch) ?? 0); - const workspaces = list(data.workspaces()); + const workspaces = cappedWorkspaces(list(data.workspaces()), busyOnly()); if (now > 0 && now !== lastSampled) { sample(workspaces, now); lastSampled = now; @@ -228,8 +246,7 @@ const snapshot = computed(() => { }); const shown = computed(() => { - const rows = snapshot().rows; - return (busyOnly() ? rows.filter((r) => r.busy || r.selected) : rows).slice(0, MAX_ROWS); + return snapshot().rows; }); // A drop index counts visible rows; map it onto the full workspace order. diff --git a/tests/test_btop_agents_sidebar.py b/tests/test_btop_agents_sidebar.py index d66a342c2572..6b3b228e6f06 100644 --- a/tests/test_btop_agents_sidebar.py +++ b/tests/test_btop_agents_sidebar.py @@ -41,6 +41,38 @@ def test_history_stays_bounded_when_wall_clock_moves_backward(self): self.assertEqual(result.returncode, 0, result.stderr) + def test_expensive_workspace_model_is_capped_before_rendering(self): + source = SOURCE.read_text(encoding="utf-8") + cap_functions = "function workspaceIsBusy" + source.split( + "function workspaceIsBusy", 1 + )[1].split("const [busyOnly", 1)[0] + script = f""" +const MAX_ROWS = 40; +const list = (v) => Array.isArray(v) ? v : []; +const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null; +{cap_functions} +const workspaces = Array.from({{ length: 1_000 }}, (_, index) => ({{ + id: `workspace-${{index}}`, + selected: index === 999, + unread: index % 2, + agents: [{{ status: index % 3 === 0 ? "working" : "idle" }}], +}})); +for (const onlyBusy of [false, true]) {{ + const capped = cappedWorkspaces(workspaces, onlyBusy); + if (capped.length > MAX_ROWS) throw new Error(`mode ${{onlyBusy}} returned ${{capped.length}} rows`); + if (!capped.some((w) => w.selected)) throw new Error(`mode ${{onlyBusy}} lost selection`); +}} +""" + + result = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + if __name__ == "__main__": unittest.main() From c298706674f70002d77e2734b60af8a2fa439a35 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 14:28:58 -0400 Subject: [PATCH 5/7] fix(example): retain btop history across filters --- Examples/CustomSidebars/btop-agents.js | 19 ++++------ tests/test_btop_agents_sidebar.py | 49 ++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/Examples/CustomSidebars/btop-agents.js b/Examples/CustomSidebars/btop-agents.js index dfe1b02eb9e4..4c880f4051e9 100644 --- a/Examples/CustomSidebars/btop-agents.js +++ b/Examples/CustomSidebars/btop-agents.js @@ -188,16 +188,8 @@ function rowModel(w, now) { }; } -function workspaceIsBusy(w) { - return (num(w.unread) || 0) > 0 - || list(w.agents).some((a) => a.status === "working" || a.status === "needs_input"); -} - -function cappedWorkspaces(workspaces, onlyBusy) { - const candidates = onlyBusy - ? workspaces.filter((w) => workspaceIsBusy(w) || w.selected) - : workspaces; - const capped = candidates.slice(0, MAX_ROWS); +function cappedWorkspaces(workspaces) { + const capped = workspaces.slice(0, MAX_ROWS); const selected = workspaces.find((w) => w.selected); if (selected && !capped.some((w) => w.id === selected.id)) { if (capped.length === MAX_ROWS) capped[MAX_ROWS - 1] = selected; @@ -210,7 +202,9 @@ const [busyOnly, setBusyOnly] = signal(false); const snapshot = computed(() => { const now = Math.floor(num(data.clock()?.epoch) ?? 0); - const workspaces = cappedWorkspaces(list(data.workspaces()), busyOnly()); + // History ownership is capped, but independent of the transient ALL/BUSY + // filter so hiding a newly quiet row does not erase its recent sparkline. + const workspaces = cappedWorkspaces(list(data.workspaces())); if (now > 0 && now !== lastSampled) { sample(workspaces, now); lastSampled = now; @@ -246,7 +240,8 @@ const snapshot = computed(() => { }); const shown = computed(() => { - return snapshot().rows; + const rows = snapshot().rows; + return busyOnly() ? rows.filter((r) => r.busy || r.selected) : rows; }); // A drop index counts visible rows; map it onto the full workspace order. diff --git a/tests/test_btop_agents_sidebar.py b/tests/test_btop_agents_sidebar.py index 6b3b228e6f06..5dd9d863a710 100644 --- a/tests/test_btop_agents_sidebar.py +++ b/tests/test_btop_agents_sidebar.py @@ -43,8 +43,8 @@ def test_history_stays_bounded_when_wall_clock_moves_backward(self): def test_expensive_workspace_model_is_capped_before_rendering(self): source = SOURCE.read_text(encoding="utf-8") - cap_functions = "function workspaceIsBusy" + source.split( - "function workspaceIsBusy", 1 + cap_functions = "function cappedWorkspaces" + source.split( + "function cappedWorkspaces", 1 )[1].split("const [busyOnly", 1)[0] script = f""" const MAX_ROWS = 40; @@ -57,11 +57,46 @@ def test_expensive_workspace_model_is_capped_before_rendering(self): unread: index % 2, agents: [{{ status: index % 3 === 0 ? "working" : "idle" }}], }})); -for (const onlyBusy of [false, true]) {{ - const capped = cappedWorkspaces(workspaces, onlyBusy); - if (capped.length > MAX_ROWS) throw new Error(`mode ${{onlyBusy}} returned ${{capped.length}} rows`); - if (!capped.some((w) => w.selected)) throw new Error(`mode ${{onlyBusy}} lost selection`); -}} +const capped = cappedWorkspaces(workspaces); +if (capped.length > MAX_ROWS) throw new Error(`returned ${{capped.length}} rows`); +if (!capped.some((w) => w.selected)) throw new Error("lost selection"); +""" + + result = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_busy_filter_does_not_evict_a_quiet_workspaces_history(self): + source = SOURCE.read_text(encoding="utf-8") + history = "const history = new Map();" + source.split( + "const history = new Map();", 1 + )[1].split( + "// ---------------------------------------------------------------------------\n// Row model.", + 1, + )[0] + cap_function = "function cappedWorkspaces" + source.split( + "function cappedWorkspaces", 1 + )[1].split("const [busyOnly", 1)[0] + script = f""" +const BUCKET = 15; +const KEEP = 48; +const MAX_ROWS = 40; +{history} +{cap_function} +const recent = {{ id: "recent", agents: [{{ status: "working", sinceEpoch: 1 }}] }}; +const selected = {{ id: "selected", selected: true, agents: [{{ status: "idle" }}] }}; +const workspaces = [recent, selected]; +sample(cappedWorkspaces(workspaces), 1_000); +recent.agents[0].status = "idle"; +// Passing the old BUSY argument is intentional: the fixed cap ignores +// visibility, while the buggy implementation filtered and evicted `recent`. +sample(cappedWorkspaces(workspaces, true), 1_015); +if (!history.has("recent")) throw new Error("BUSY mode erased recent history"); """ result = subprocess.run( From 6c3854cdaf2f6c4bac0eea96b5df6992131c5470 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 14:38:56 -0400 Subject: [PATCH 6/7] fix(example): bound btop history with LRU eviction --- Examples/CustomSidebars/btop-agents.js | 50 ++++++++++++----- tests/test_btop_agents_sidebar.py | 76 +++++++++++++++++++++----- 2 files changed, 98 insertions(+), 28 deletions(-) diff --git a/Examples/CustomSidebars/btop-agents.js b/Examples/CustomSidebars/btop-agents.js index 4c880f4051e9..0a1c903eeda1 100644 --- a/Examples/CustomSidebars/btop-agents.js +++ b/Examples/CustomSidebars/btop-agents.js @@ -102,16 +102,24 @@ function backfill(h, agents, now) { } } -function sample(workspaces, now) { +function oldestEvictableHistory(protectedIds) { + for (const id of history.keys()) if (!protectedIds.has(id)) return id; + return null; +} + +function sample(workspaces, now, liveIds = new Set(workspaces.map((w) => w.id))) { const bucket = Math.floor(now / BUCKET); - const live = new Set(); + const sampled = new Set(workspaces.map((w) => w.id)); + for (const id of Array.from(history.keys())) if (!liveIds.has(id)) history.delete(id); for (const w of workspaces) { - live.add(w.id); const agents = list(w.agents); let h = history.get(w.id); if (!h) { + if (history.size >= MAX_ROWS) { + const evicted = oldestEvictableHistory(sampled); + if (evicted !== null) history.delete(evicted); + } h = new Map(); - history.set(w.id, h); backfill(h, agents, now); } const slot = h.get(bucket) ?? { n: 0, busy: 0 }; @@ -121,8 +129,11 @@ function sample(workspaces, now) { // Wall-clock corrections can move `bucket` backward. Drop future buckets // too, otherwise every clock era leaves another KEEP entries behind. for (const b of h.keys()) if (b <= bucket - KEEP || b > bucket) h.delete(b); + // Map insertion order is the eviction order. Sampling makes this entry + // most-recently used without retaining more than MAX_ROWS histories. + history.delete(w.id); + history.set(w.id, h); } - for (const id of Array.from(history.keys())) if (!live.has(id)) history.delete(id); } // Share of each bucket an agent was working, newest last. @@ -188,9 +199,20 @@ function rowModel(w, now) { }; } -function cappedWorkspaces(workspaces) { - const capped = workspaces.slice(0, MAX_ROWS); - const selected = workspaces.find((w) => w.selected); +function workspaceIsBusy(w) { + return (num(w.unread) || 0) > 0 + || list(w.agents).some((a) => a.status === "working" || a.status === "needs_input"); +} + +function cappedWorkspaces(workspaces, onlyBusy) { + const capped = []; + let selected = null; + for (const w of workspaces) { + if (!selected && w.selected) selected = w; + if (capped.length < MAX_ROWS && (!onlyBusy || workspaceIsBusy(w) || w.selected)) { + capped.push(w); + } + } if (selected && !capped.some((w) => w.id === selected.id)) { if (capped.length === MAX_ROWS) capped[MAX_ROWS - 1] = selected; else capped.push(selected); @@ -202,11 +224,12 @@ const [busyOnly, setBusyOnly] = signal(false); const snapshot = computed(() => { const now = Math.floor(num(data.clock()?.epoch) ?? 0); - // History ownership is capped, but independent of the transient ALL/BUSY - // filter so hiding a newly quiet row does not erase its recent sparkline. - const workspaces = cappedWorkspaces(list(data.workspaces())); + const allWorkspaces = list(data.workspaces()); + // Filter before the cap so BUSY can surface active workspaces anywhere in + // the full order. Expensive sampling and row modeling remain capped. + const workspaces = cappedWorkspaces(allWorkspaces, busyOnly()); if (now > 0 && now !== lastSampled) { - sample(workspaces, now); + sample(workspaces, now, new Set(allWorkspaces.map((w) => w.id))); lastSampled = now; } const rows = workspaces.map((w) => rowModel(w, now)); @@ -240,8 +263,7 @@ const snapshot = computed(() => { }); const shown = computed(() => { - const rows = snapshot().rows; - return busyOnly() ? rows.filter((r) => r.busy || r.selected) : rows; + return snapshot().rows; }); // A drop index counts visible rows; map it onto the full workspace order. diff --git a/tests/test_btop_agents_sidebar.py b/tests/test_btop_agents_sidebar.py index 5dd9d863a710..083b11fdfdfd 100644 --- a/tests/test_btop_agents_sidebar.py +++ b/tests/test_btop_agents_sidebar.py @@ -20,6 +20,7 @@ def test_history_stays_bounded_when_wall_clock_moves_backward(self): script = f""" const BUCKET = 15; const KEEP = 48; +const MAX_ROWS = 40; {history} const workspaces = [{{ id: "workspace", agents: [] }}]; for (const era of [10_000, 1_000, 100]) {{ @@ -43,8 +44,8 @@ def test_history_stays_bounded_when_wall_clock_moves_backward(self): def test_expensive_workspace_model_is_capped_before_rendering(self): source = SOURCE.read_text(encoding="utf-8") - cap_functions = "function cappedWorkspaces" + source.split( - "function cappedWorkspaces", 1 + cap_functions = "function workspaceIsBusy" + source.split( + "function workspaceIsBusy", 1 )[1].split("const [busyOnly", 1)[0] script = f""" const MAX_ROWS = 40; @@ -54,12 +55,17 @@ def test_expensive_workspace_model_is_capped_before_rendering(self): const workspaces = Array.from({{ length: 1_000 }}, (_, index) => ({{ id: `workspace-${{index}}`, selected: index === 999, - unread: index % 2, - agents: [{{ status: index % 3 === 0 ? "working" : "idle" }}], + unread: 0, + agents: [{{ status: index >= 40 && index < 90 ? "working" : "idle" }}], }})); -const capped = cappedWorkspaces(workspaces); -if (capped.length > MAX_ROWS) throw new Error(`returned ${{capped.length}} rows`); -if (!capped.some((w) => w.selected)) throw new Error("lost selection"); +for (const onlyBusy of [false, true]) {{ + const capped = cappedWorkspaces(workspaces, onlyBusy); + if (capped.length > MAX_ROWS) throw new Error(`mode ${{onlyBusy}} returned ${{capped.length}} rows`); + if (!capped.some((w) => w.selected)) throw new Error(`mode ${{onlyBusy}} lost selection`); + if (onlyBusy && !capped.some((w) => w.id === "workspace-40")) {{ + throw new Error("BUSY mode hid active workspaces beyond the initial cap"); + }} +}} """ result = subprocess.run( @@ -71,7 +77,7 @@ def test_expensive_workspace_model_is_capped_before_rendering(self): self.assertEqual(result.returncode, 0, result.stderr) - def test_busy_filter_does_not_evict_a_quiet_workspaces_history(self): + def test_busy_filter_does_not_evict_quiet_workspace_history(self): source = SOURCE.read_text(encoding="utf-8") history = "const history = new Map();" + source.split( "const history = new Map();", 1 @@ -79,8 +85,8 @@ def test_busy_filter_does_not_evict_a_quiet_workspaces_history(self): "// ---------------------------------------------------------------------------\n// Row model.", 1, )[0] - cap_function = "function cappedWorkspaces" + source.split( - "function cappedWorkspaces", 1 + cap_function = "function workspaceIsBusy" + source.split( + "function workspaceIsBusy", 1 )[1].split("const [busyOnly", 1)[0] script = f""" const BUCKET = 15; @@ -91,11 +97,10 @@ def test_busy_filter_does_not_evict_a_quiet_workspaces_history(self): const recent = {{ id: "recent", agents: [{{ status: "working", sinceEpoch: 1 }}] }}; const selected = {{ id: "selected", selected: true, agents: [{{ status: "idle" }}] }}; const workspaces = [recent, selected]; -sample(cappedWorkspaces(workspaces), 1_000); +const liveIds = new Set(workspaces.map((w) => w.id)); +sample(cappedWorkspaces(workspaces, false), 1_000, liveIds); recent.agents[0].status = "idle"; -// Passing the old BUSY argument is intentional: the fixed cap ignores -// visibility, while the buggy implementation filtered and evicted `recent`. -sample(cappedWorkspaces(workspaces, true), 1_015); +sample(cappedWorkspaces(workspaces, true), 1_015, liveIds); if (!history.has("recent")) throw new Error("BUSY mode erased recent history"); """ @@ -108,6 +113,49 @@ def test_busy_filter_does_not_evict_a_quiet_workspaces_history(self): self.assertEqual(result.returncode, 0, result.stderr) + def test_history_evicts_oldest_offscreen_workspace_at_capacity(self): + source = SOURCE.read_text(encoding="utf-8") + history = "const history = new Map();" + source.split( + "const history = new Map();", 1 + )[1].split( + "// ---------------------------------------------------------------------------\n// Row model.", + 1, + )[0] + cap_functions = "function workspaceIsBusy" + source.split( + "function workspaceIsBusy", 1 + )[1].split("const [busyOnly", 1)[0] + script = f""" +const BUCKET = 15; +const KEEP = 48; +const MAX_ROWS = 40; +{history} +{cap_functions} +const workspaces = Array.from({{ length: 41 }}, (_, index) => ({{ + id: `workspace-${{index}}`, + selected: index === 40, + unread: 0, + agents: [{{ status: "idle" }}], +}})); +const liveIds = new Set(workspaces.map((w) => w.id)); +sample(cappedWorkspaces(workspaces, false), 1_000, liveIds); +workspaces[39].agents[0].status = "working"; +sample(cappedWorkspaces(workspaces, true), 1_015, liveIds); +if (history.size !== MAX_ROWS) throw new Error(`history size is ${{history.size}}`); +if (history.has("workspace-0")) throw new Error("oldest offscreen history was not evicted"); +if (!history.has("workspace-1")) throw new Error("eviction skipped the oldest history"); +if (!history.has("workspace-39")) throw new Error("newly visible history was not admitted"); +if (!history.has("workspace-40")) throw new Error("selected history was evicted"); +""" + + result = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + if __name__ == "__main__": unittest.main() From 18140bd135deb211f93c891e2ed1adce685e6323 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 14:54:22 -0400 Subject: [PATCH 7/7] fix(example): cache bounded btop workspace selection --- .github/workflows/ci-guards.yml | 4 ++++ Examples/CustomSidebars/btop-agents.js | 19 ++++++++++++++----- tests/test-execution.toml | 4 ++++ tests/test_btop_agents_sidebar.py | 15 +++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml index aecc849e11a8..c64252882d86 100644 --- a/.github/workflows/ci-guards.yml +++ b/.github/workflows/ci-guards.yml @@ -195,6 +195,10 @@ jobs: if: ${{ matrix.group == 'preflight' }} run: python3 tests/test_write_sidebar_extension_point.py + - name: Test btop agent sidebar example bounds + if: ${{ matrix.group == 'preflight' }} + run: python3 tests/test_btop_agents_sidebar.py + - name: Validate settings rows advertise supported cmux.json paths if: ${{ matrix.group == 'preflight' }} run: python3 tests/test_settings_configuration_review_paths.py diff --git a/Examples/CustomSidebars/btop-agents.js b/Examples/CustomSidebars/btop-agents.js index 0a1c903eeda1..ad2695b56fbf 100644 --- a/Examples/CustomSidebars/btop-agents.js +++ b/Examples/CustomSidebars/btop-agents.js @@ -222,14 +222,23 @@ function cappedWorkspaces(workspaces, onlyBusy) { const [busyOnly, setBusyOnly] = signal(false); +// Workspace selection changes only when workspace data or the ALL/BUSY mode +// changes. Keep the one-second clock out of this full-list pass; the clocked +// snapshot below should touch only the admitted rows and bounded history. +const workspaceSelection = computed(() => { + const allWorkspaces = list(data.workspaces()); + return { + workspaces: cappedWorkspaces(allWorkspaces, busyOnly()), + liveIds: new Set(allWorkspaces.map((w) => w.id)), + }; +}); + const snapshot = computed(() => { const now = Math.floor(num(data.clock()?.epoch) ?? 0); - const allWorkspaces = list(data.workspaces()); - // Filter before the cap so BUSY can surface active workspaces anywhere in - // the full order. Expensive sampling and row modeling remain capped. - const workspaces = cappedWorkspaces(allWorkspaces, busyOnly()); + const selection = workspaceSelection(); + const workspaces = selection.workspaces; if (now > 0 && now !== lastSampled) { - sample(workspaces, now, new Set(allWorkspaces.map((w) => w.id))); + sample(workspaces, now, selection.liveIds); lastSampled = now; } const rows = workspaces.map((w) => rowModel(w, now)); diff --git a/tests/test-execution.toml b/tests/test-execution.toml index c873713e20a0..2cfbe31943c4 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -1437,3 +1437,7 @@ lane = "linux-guard" [[test]] path = "tests/test_ci_guard_tests_emit_no_workflow_commands.py" lane = "linux-guard" + +[[test]] +path = "tests/test_btop_agents_sidebar.py" +lane = "linux-guard" diff --git a/tests/test_btop_agents_sidebar.py b/tests/test_btop_agents_sidebar.py index 083b11fdfdfd..acb09f577014 100644 --- a/tests/test_btop_agents_sidebar.py +++ b/tests/test_btop_agents_sidebar.py @@ -11,6 +11,21 @@ class BtopAgentsSidebarTests(unittest.TestCase): + def test_clocked_snapshot_does_not_rescan_the_full_workspace_list(self): + source = SOURCE.read_text(encoding="utf-8") + selection = source.split("const workspaceSelection = computed", 1)[1].split( + "const snapshot = computed", 1 + )[0] + snapshot = source.split("const snapshot = computed", 1)[1].split( + "const shown = computed", 1 + )[0] + + self.assertIn("data.workspaces()", selection) + self.assertIn("cappedWorkspaces", selection) + self.assertNotIn("data.workspaces()", snapshot) + self.assertNotIn("allWorkspaces.map", snapshot) + self.assertIn("workspaceSelection()", snapshot) + def test_history_stays_bounded_when_wall_clock_moves_backward(self): source = SOURCE.read_text(encoding="utf-8") history = "const history = new Map();" + source.split("const history = new Map();", 1)[1].split(