@bivy/bivy 0.20.0 → 0.20.1-staging.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +5 -0
  2. package/bin/access-view.mjs +43 -0
  3. package/bin/bivy.mjs +300 -18
  4. package/bin/cli-commands.mjs +7 -2
  5. package/bin/guides/talk-to-the-user.md +19 -3
  6. package/bin/install-kind.mjs +20 -0
  7. package/bin/tailscale.mjs +70 -0
  8. package/bin/tui/app.mjs +189 -0
  9. package/bin/tui/model.mjs +112 -0
  10. package/bin/tui/render.mjs +164 -0
  11. package/bin/tui/text.mjs +72 -0
  12. package/bin/tui/transcript.mjs +54 -0
  13. package/dist/access.js +72 -0
  14. package/dist/agent-instructions.js +8 -3
  15. package/dist/apps/listeners.js +31 -3
  16. package/dist/apps/service.js +8 -1
  17. package/dist/apps/styles.css +16 -2
  18. package/dist/apps/tokens.css +40 -0
  19. package/dist/auth.js +31 -4
  20. package/dist/controllers/app-commands.js +7 -2
  21. package/dist/machine-theme.js +118 -0
  22. package/dist/node-update.js +16 -0
  23. package/dist/pair-codes.js +41 -0
  24. package/dist/protocol/client-command-routes.js +2 -0
  25. package/dist/protocol/client-command-schemas.js +3 -1
  26. package/dist/remote/direct.js +24 -0
  27. package/dist/remote/index.js +2 -0
  28. package/dist/remote/tailnet.js +44 -0
  29. package/dist/server.js +165 -18
  30. package/dist/session/run-terminal.js +6 -2
  31. package/dist/session/session-namer.js +8 -2
  32. package/dist/session/session-tokens.js +1 -1
  33. package/dist/session/suggestions.js +5 -1
  34. package/dist/web-app.js +55 -0
  35. package/package.json +2 -2
  36. package/web/apple-touch-icon.png +0 -0
  37. package/web/assets/AutomationsView-CjTCAznJ.js +51 -0
  38. package/web/assets/ChangesCard-CBV_oCpv.js +4 -0
  39. package/web/assets/LibraryView-tZ-SLR74.js +2 -0
  40. package/web/assets/ProviderConnect-di0nwwfS.js +1 -0
  41. package/web/assets/ReadinessChecklist-Bbgq2GQ8.js +1 -0
  42. package/web/assets/Settings-wr27-Utd.js +10 -0
  43. package/web/assets/Sheet-DyGDs8Ap.js +2 -0
  44. package/web/assets/StatusDot-dpbztK53.js +1 -0
  45. package/web/assets/Terminal-B1njb2Cr.css +1 -0
  46. package/web/assets/Terminal-BnewuT2I.js +96 -0
  47. package/web/assets/VoiceSettings-gkjVXlwh.js +1 -0
  48. package/web/assets/abnfDiagram-VCTEODGH-xPExYTbq.js +1 -0
  49. package/web/assets/arc-Pry-7G1_.js +1 -0
  50. package/web/assets/architecture-7GRP2DOG-C2VA8oJp.js +1 -0
  51. package/web/assets/architectureDiagram-5GKGNRK7-DY9aKTXO.js +36 -0
  52. package/web/assets/array-BifhSqXX.js +1 -0
  53. package/web/assets/blockDiagram-I7D4REHJ-oAtHEG_S.js +129 -0
  54. package/web/assets/c4Diagram-7LVT6UL2-DAW5aIn-.js +38 -0
  55. package/web/assets/channel-DfQdWzMf.js +1 -0
  56. package/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  57. package/web/assets/chunk-4HAMMTFA-k55Imkrb.js +62 -0
  58. package/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  59. package/web/assets/chunk-75Z2AOVW-9BoEka2V.js +2 -0
  60. package/web/assets/chunk-DU6HZSFF-v3xjhwis.js +127 -0
  61. package/web/assets/chunk-F27PBJKO-ukCww9iX.js +1 -0
  62. package/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
  63. package/web/assets/chunk-GMAD6QVW-eEDdT117.js +72 -0
  64. package/web/assets/chunk-GVQU2GXP-BJ7btc9s.js +1 -0
  65. package/web/assets/chunk-IMKFNOWR-Cmcy1775.js +231 -0
  66. package/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  67. package/web/assets/chunk-L3NEJ4N5-BjMoq7Ow.js +1 -0
  68. package/web/assets/chunk-OSK3NFVY-03uwrVwP.js +10 -0
  69. package/web/assets/chunk-P2QGCYS3-C3poXv15.js +1 -0
  70. package/web/assets/chunk-POPQ4Y6H-DmAcZLrU.js +1 -0
  71. package/web/assets/chunk-PWAF6VOD-Bi69-MYG.js +1 -0
  72. package/web/assets/chunk-SHT3W25Y-DBVBaWqA.js +168 -0
  73. package/web/assets/chunk-SVP7TREG-DA4UOsHJ.js +88 -0
  74. package/web/assets/chunk-TICWLB2K-CcUmJxSS.js +206 -0
  75. package/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
  76. package/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  77. package/web/assets/classDiagram-ZZMXUADV-DDfEWWro.js +1 -0
  78. package/web/assets/classDiagram-v2-VYDZK3BY-DDfEWWro.js +1 -0
  79. package/web/assets/common-DvIV9UK2.js +5 -0
  80. package/web/assets/cose-bilkent-JH36ORCC-BIqBF2fY.js +1 -0
  81. package/web/assets/cynefin-OW5HDTMX-B-p1iHVa.js +1 -0
  82. package/web/assets/cynefinDiagram-5FMLGOSQ-D3t5rjlb.js +62 -0
  83. package/web/assets/cytoscape.esm-NgwPT4ez.js +321 -0
  84. package/web/assets/dagre-C-rnJpZF.js +1 -0
  85. package/web/assets/dagre-GXQ25YYZ-CAVvMffj.js +4 -0
  86. package/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  87. package/web/assets/diagram-S7CK7UJ4-5avxKhVt.js +30 -0
  88. package/web/assets/diagram-UQ7AKVKN-Dre36bGM.js +41 -0
  89. package/web/assets/diagram-VSXAHHWV-DiIGVyBf.js +3 -0
  90. package/web/assets/diagram-VX7I27RA-J24keGuv.js +24 -0
  91. package/web/assets/diagram-Z3DM3KII-CO6mpfCn.js +24 -0
  92. package/web/assets/dist-v5Q1xZ2K.js +1 -0
  93. package/web/assets/ebnfDiagram-PWID7BFC-Cj8nHw-p.js +1 -0
  94. package/web/assets/erDiagram-RLTQ6QDP-DCnatJWn.js +99 -0
  95. package/web/assets/eventmodeling-NTZA5JFV-CUmkoHPv.js +1 -0
  96. package/web/assets/flowDiagram-HODETNUW-CAMIZvHT.js +1 -0
  97. package/web/assets/ganttDiagram-EL5Y4UJY-CnoyY5yJ.js +292 -0
  98. package/web/assets/gitGraph-4MIJSDKK-5QxKBdhV.js +1 -0
  99. package/web/assets/gitGraphDiagram-WWUBYQGX-m72whYP5.js +106 -0
  100. package/web/assets/graphlib-DS17s2tU.js +1 -0
  101. package/web/assets/index-D36pVYmX.css +1 -0
  102. package/web/assets/index-DiYWjsXW.js +3 -0
  103. package/web/assets/info-A6RAGUB7-D0yTLuOD.js +1 -0
  104. package/web/assets/infoDiagram-27XIBGKW-DhMqaJK2.js +2 -0
  105. package/web/assets/init-C-OQMol4.js +1 -0
  106. package/web/assets/ishikawaDiagram-5VMMS53U-D43D69Lw.js +70 -0
  107. package/web/assets/journeyDiagram-3NMN7TZE-BGvdefN8.js +139 -0
  108. package/web/assets/jsx-runtime-NZYk81nU.js +1 -0
  109. package/web/assets/kanban-definition-UXKFOSKX-DTLCjXTM.js +89 -0
  110. package/web/assets/katex-ZlcWpGUi.js +257 -0
  111. package/web/assets/line-D3e4bvOi.js +1 -0
  112. package/web/assets/linear-SzVmGSw4.js +1 -0
  113. package/web/assets/mermaid-parser.core-hF3j4BfN.js +7 -0
  114. package/web/assets/mermaid.core-Dt4GvTcF.js +44 -0
  115. package/web/assets/mindmap-definition-YA3MSWOX-kGjt1kFy.js +96 -0
  116. package/web/assets/mount-BjlUftih.css +1 -0
  117. package/web/assets/mount-DFFc-s3e.js +27 -0
  118. package/web/assets/ordinal-BDEzSJ7C.js +1 -0
  119. package/web/assets/packet-AYTQ26CC-uWunEOCP.js +1 -0
  120. package/web/assets/path-fybaL0A-.js +1 -0
  121. package/web/assets/pegDiagram-XKGWAZYB-DHUvSUTF.js +1 -0
  122. package/web/assets/pie-WAS4IAKB-CDjHTH5e.js +1 -0
  123. package/web/assets/pieDiagram-E7YTZNPT-BXmHg_oo.js +39 -0
  124. package/web/assets/quadrantDiagram-AXDQQJYC-wrGDXaUj.js +7 -0
  125. package/web/assets/radar-RG4KPBEZ-iriLOEHZ.js +1 -0
  126. package/web/assets/railroad-74A4TZTK-Bji_D229.js +1 -0
  127. package/web/assets/railroad-abnf-HS5TGJTU-C2rjIz3_.js +1 -0
  128. package/web/assets/railroad-ebnf-LZEXJU2U-D1IwbAAk.js +1 -0
  129. package/web/assets/railroad-peg-WCYAUIDC-D6AkTvV0.js +1 -0
  130. package/web/assets/railroadDiagram-O6MQD6OU-DaMvYvYd.js +1 -0
  131. package/web/assets/requirementDiagram-BXWQKSXE-CSk6pyHt.js +84 -0
  132. package/web/assets/rolldown-runtime-hePW80VL.js +1 -0
  133. package/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  134. package/web/assets/sankeyDiagram-P5KCCOFB-CDYh3o0x.js +40 -0
  135. package/web/assets/sequenceDiagram-WJ2MYXX4-Cebdnnr3.js +162 -0
  136. package/web/assets/settingsRoute-D0Hlh8NA.js +1 -0
  137. package/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  138. package/web/assets/src-B6xuSHsQ.js +1 -0
  139. package/web/assets/stateDiagram-D77RDMKH-B9UCBm56.js +1 -0
  140. package/web/assets/stateDiagram-v2-MP3YSRHH-Btx-OjtR.js +1 -0
  141. package/web/assets/swimlanes-42K2YHIH-zH5kmbnV.js +1 -0
  142. package/web/assets/swimlanesDiagram-VR7AAH4N-CZInbEeM.js +8 -0
  143. package/web/assets/timeline-definition-24CTP7MA-D2tCs5Ws.js +120 -0
  144. package/web/assets/treeView-Q6P3EWNA-BCo4q2Xs.js +1 -0
  145. package/web/assets/treemap-WGGIJYW6-C2Xuxw-p.js +1 -0
  146. package/web/assets/useStore-DECYZPZP.js +27 -0
  147. package/web/assets/vennDiagram-4TSXK5OY-CMD5TuHM.js +34 -0
  148. package/web/assets/wardley-WFR3VGLG-0MDv_diU.js +1 -0
  149. package/web/assets/wardleyDiagram-VM6X3IG4-BOQSNZzn.js +78 -0
  150. package/web/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  151. package/web/assets/xychartDiagram-S5SC5T6Z-B79lzllh.js +7 -0
  152. package/web/fonts/OFL.txt +90 -0
  153. package/web/fonts/newsreader-italic.woff2 +0 -0
  154. package/web/fonts/newsreader-roman.woff2 +0 -0
  155. package/web/icon-192.png +0 -0
  156. package/web/icon-512.png +0 -0
  157. package/web/icon.svg +16 -0
  158. package/web/index.html +36 -0
  159. package/web/manifest.webmanifest +1 -0
  160. package/web/runtime-config.js +3 -0
  161. package/web/sw.js +1 -0
  162. package/web/tent-logo.png +0 -0
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Pure helpers for `bivy tailscale`: read `tailscale status --json` and
4
+ // `tailscale serve status --json`, and build the `tailscale serve` arguments.
5
+ // No process spawning here, so each decision is testable on its own.
6
+
7
+ /** The one HTTPS port Bivy claims on the machine's tailnet name. */
8
+ export const SERVE_HTTPS_PORT = 443;
9
+
10
+ /**
11
+ * `{ running, hostname, owner }` from `tailscale status --json` output. `owner`
12
+ * is the login of the user this machine belongs to, or null for a tagged
13
+ * machine (owned by tags, not a person).
14
+ */
15
+ export function parseTailscaleStatus(text) {
16
+ let status;
17
+ try { status = JSON.parse(text); } catch { return { running: false, hostname: null, owner: null }; }
18
+ const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
19
+ const tagged = Array.isArray(status?.Self?.Tags) && status.Self.Tags.length > 0;
20
+ const login = status?.User?.[String(status?.Self?.UserID)]?.LoginName;
21
+ const owner = !tagged && typeof login === "string" && login.includes("@") ? login : null;
22
+ return { running: status?.BackendState === "Running", hostname, owner };
23
+ }
24
+
25
+ /** Where `tailscale serve` should send the machine's HTTPS traffic. */
26
+ export function serveTarget(port) {
27
+ return `http://127.0.0.1:${port}`;
28
+ }
29
+
30
+ export function serveOnArgs(port) {
31
+ return ["serve", "--bg", `--https=${SERVE_HTTPS_PORT}`, serveTarget(port)];
32
+ }
33
+
34
+ export function serveOffArgs() {
35
+ return ["serve", `--https=${SERVE_HTTPS_PORT}`, "off"];
36
+ }
37
+
38
+ /**
39
+ * What already sits on `<hostname>:443`, from `tailscale serve status --json`:
40
+ * `funnel` when it is public (Tailscale Funnel), and the proxy target of `/`
41
+ * when something is served there. Bivy refuses to share a Funnel'd name, and
42
+ * refuses to replace a target that isn't its own.
43
+ */
44
+ export function serveState(text, hostname) {
45
+ let status;
46
+ try { status = JSON.parse(text || "{}"); } catch { status = {}; }
47
+ const key = `${hostname}:${SERVE_HTTPS_PORT}`;
48
+ const handlers = status?.Web?.[key]?.Handlers ?? {};
49
+ return {
50
+ funnel: status?.AllowFunnel?.[key] === true,
51
+ target: typeof handlers["/"]?.Proxy === "string" ? handlers["/"].Proxy : null,
52
+ otherPaths: Object.keys(handlers).filter((p) => p !== "/"),
53
+ };
54
+ }
55
+
56
+ /** Why `bivy tailscale` can't take `<hostname>:443`, or null when it can. */
57
+ export function serveConflict(state, port) {
58
+ if (state.funnel) return "Tailscale Funnel is on for this machine's HTTPS name, which would put Bivy on the public internet. Turn it off first: tailscale funnel reset";
59
+ if (state.target && state.target !== serveTarget(port)) return `tailscale serve already sends https:// on this machine to ${state.target}. Remove it first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
60
+ if (state.otherPaths.length) return `tailscale serve already has paths on this machine's HTTPS name (${state.otherPaths.join(", ")}). Remove them first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
61
+ return null;
62
+ }
63
+
64
+ /** A one-line hint for a failed `tailscale serve`, from its stderr. */
65
+ export function serveFailureHint(stderr) {
66
+ const text = String(stderr ?? "");
67
+ if (/access denied|permission denied|must be root|operator/i.test(text)) return "Let your user manage Tailscale once: sudo tailscale set --operator=$USER";
68
+ if (/https.*not enabled|enable https|certificates/i.test(text)) return "Turn on HTTPS certificates for your tailnet (admin console → DNS → HTTPS Certificates), then run this again.";
69
+ return null;
70
+ }
@@ -0,0 +1,189 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // `bivy tui`: the terminal around model.mjs and render.mjs. It polls each
4
+ // machine's node API, keeps the selected session's transcript current, turns
5
+ // keys into effects and draws only the lines that changed.
6
+
7
+ import { initialState, reduceKey, selectedSession, sessionKey, withData, isWorking } from "./model.mjs";
8
+ import { renderFrame } from "./render.mjs";
9
+ import { transcriptEntries } from "./transcript.mjs";
10
+
11
+ const POLL_MS = 1500;
12
+ const SPIN_MS = 120;
13
+
14
+ const KEYS = {
15
+ "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left",
16
+ "\x1bOA": "up", "\x1bOB": "down", "\x1bOC": "right", "\x1bOD": "left",
17
+ "\x1b[5~": "pageup", "\x1b[6~": "pagedown",
18
+ "\r": "enter", "\n": "enter", "\t": "tab", "\x7f": "backspace", "\b": "backspace",
19
+ "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d", "\x15": "ctrl-u", " ": "space",
20
+ };
21
+
22
+ /** Raw terminal input as key names; a paste arrives as its characters. */
23
+ export function parseKeys(data) {
24
+ const text = String(data);
25
+ if (KEYS[text]) return [KEYS[text]];
26
+ if (text.startsWith("\x1b")) return [];
27
+ return [...text].map((ch) => KEYS[ch] ?? ch).filter((k) => k.length > 1 || k >= " ");
28
+ }
29
+
30
+ async function call(machine, method, path, body) {
31
+ const res = await fetch(`${machine.base}${path}`, {
32
+ method,
33
+ headers: { "content-type": "application/json", ...(machine.token ? { authorization: `Bearer ${machine.token}` } : {}) },
34
+ ...(body ? { body: JSON.stringify(body) } : {}),
35
+ signal: AbortSignal.timeout(method === "GET" ? 4000 : 15000),
36
+ });
37
+ const data = await res.json().catch(() => ({}));
38
+ if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
39
+ return data;
40
+ }
41
+
42
+ /** Sessions and approvals from every machine; one that doesn't answer is marked offline. */
43
+ async function snapshot(machines) {
44
+ const results = await Promise.all(machines.map(async (machine) => {
45
+ try {
46
+ const [sessions, approvals] = await Promise.all([call(machine, "GET", "/api/sessions"), call(machine, "GET", "/api/approvals")]);
47
+ return {
48
+ machine: { name: machine.name, online: true },
49
+ sessions: (Array.isArray(sessions) ? sessions : []).map((s) => ({
50
+ machine: machine.name,
51
+ id: s.id,
52
+ name: s.name || s.firstMessage || "",
53
+ agent: s.agentName || s.agent || "",
54
+ agentName: s.agentName || "",
55
+ status: s.sessionState?.displayStatus ?? s.status ?? "",
56
+ needsAction: Boolean(s.needsAction),
57
+ open: Boolean(s.open),
58
+ branch: s.branch,
59
+ lastActivityAt: s.lastActivityAt || s.updatedAt,
60
+ })),
61
+ approvals: (Array.isArray(approvals) ? approvals : []).map((a) => ({ ...a, machine: machine.name })),
62
+ };
63
+ } catch {
64
+ return { machine: { name: machine.name, online: false }, sessions: [], approvals: [] };
65
+ }
66
+ }));
67
+ return {
68
+ machines: results.map((r) => r.machine),
69
+ sessions: results.flatMap((r) => r.sessions),
70
+ approvals: results.flatMap((r) => r.approvals),
71
+ };
72
+ }
73
+
74
+ export async function runTui({ machines, stdin = process.stdin, stdout = process.stdout }) {
75
+ const byName = new Map(machines.map((m) => [m.name, m]));
76
+ let state = initialState();
77
+ let tick = 0;
78
+ let previous = [];
79
+ // Transcripts by session key: raw messages plus the node's history cursor.
80
+ const histories = new Map();
81
+ let loadingKey = null;
82
+
83
+ const view = () => {
84
+ const session = selectedSession(state);
85
+ const key = session ? sessionKey(session) : "";
86
+ const history = histories.get(key);
87
+ return { entries: history ? transcriptEntries(history.messages) : [], loading: loadingKey === key && !history };
88
+ };
89
+
90
+ const draw = (full = false) => {
91
+ const lines = renderFrame(state, view(), { width: stdout.columns || 80, height: stdout.rows || 24, tick });
92
+ let out = "";
93
+ lines.forEach((line, i) => { if (full || line !== previous[i]) out += `\x1b[${i + 1};1H${line}\x1b[0m`; });
94
+ previous = lines;
95
+ if (out) stdout.write(out);
96
+ };
97
+
98
+ const loadHistory = async (session) => {
99
+ const key = sessionKey(session);
100
+ const machine = byName.get(session.machine);
101
+ const have = histories.get(key);
102
+ const query = new URLSearchParams({ sessionId: session.id, ...(have ? { have: String(have.count), haveToken: have.hash } : {}) });
103
+ try {
104
+ const event = await call(machine, "GET", `/api/session/history?${query}`);
105
+ const messages = event.mode === "append" && have ? [...have.messages, ...(event.messages ?? [])] : event.messages ?? [];
106
+ histories.set(key, { messages, count: event.count ?? messages.length, hash: event.historyHash });
107
+ } catch (error) {
108
+ state = { ...state, notice: `Couldn't load the transcript: ${error.message}` };
109
+ } finally {
110
+ if (loadingKey === key) loadingKey = null;
111
+ }
112
+ };
113
+
114
+ const refresh = async () => {
115
+ // Read `state` only after the await: keys pressed meanwhile must survive.
116
+ const data = await snapshot(machines);
117
+ state = withData(state, data);
118
+ const session = selectedSession(state);
119
+ // Only sessions already open on their machine are followed; opening a closed one is a keypress.
120
+ if (session?.open) await loadHistory(session);
121
+ draw();
122
+ };
123
+
124
+ const run = async (effect) => {
125
+ const machine = effect.session ? byName.get(effect.session.machine) : effect.approval ? byName.get(effect.approval.machine) : null;
126
+ try {
127
+ if (effect.type === "open") {
128
+ loadingKey = sessionKey(effect.session);
129
+ draw();
130
+ await loadHistory(effect.session);
131
+ } else if (effect.type === "prompt") {
132
+ await call(machine, "POST", "/api/session/prompt", { sessionId: effect.session.id, text: effect.text });
133
+ state = { ...state, notice: "" };
134
+ } else if (effect.type === "answer") {
135
+ await call(machine, "POST", `/api/approvals/${encodeURIComponent(effect.approval.id)}/${effect.approve ? "approve" : "reject"}`, {});
136
+ state = { ...state, notice: effect.approve ? `Approved ${effect.approval.toolName}.` : `Rejected ${effect.approval.toolName}.` };
137
+ } else if (effect.type === "stop") {
138
+ await call(machine, "POST", "/api/session/abort", { sessionId: effect.session.id });
139
+ state = { ...state, notice: "Stopping…" };
140
+ }
141
+ } catch (error) {
142
+ state = { ...state, notice: error.message };
143
+ }
144
+ await refresh();
145
+ };
146
+
147
+ return new Promise((resolve) => {
148
+ stdout.write("\x1b[?1049h\x1b[?25l\x1b[2J");
149
+ stdin.setRawMode?.(true);
150
+ stdin.resume();
151
+ const poll = setInterval(() => { void refresh(); }, POLL_MS);
152
+ const spin = setInterval(() => {
153
+ if (!state.sessions.some(isWorking)) return;
154
+ tick++;
155
+ draw();
156
+ }, SPIN_MS);
157
+ const onResize = () => draw(true);
158
+ const quit = () => {
159
+ clearInterval(poll);
160
+ clearInterval(spin);
161
+ stdout.off("resize", onResize);
162
+ stdin.off("data", onData);
163
+ stdin.setRawMode?.(false);
164
+ stdin.pause();
165
+ stdout.write("\x1b[?25h\x1b[?1049l");
166
+ resolve();
167
+ };
168
+ const onData = (data) => {
169
+ for (const key of parseKeys(data)) {
170
+ const before = selectedSession(state);
171
+ const [next, effect] = reduceKey(state, key);
172
+ state = next;
173
+ if (effect?.type === "quit") return quit();
174
+ if (effect) void run(effect);
175
+ // Moving onto an open session shows its transcript straight away.
176
+ const after = selectedSession(state);
177
+ if (after && after !== before && after.open && !histories.has(sessionKey(after))) {
178
+ loadingKey = sessionKey(after);
179
+ void loadHistory(after).then(() => draw());
180
+ }
181
+ }
182
+ draw();
183
+ };
184
+ stdin.on("data", onData);
185
+ stdout.on("resize", onResize);
186
+ draw(true);
187
+ void refresh();
188
+ });
189
+ }
@@ -0,0 +1,112 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // `bivy tui` state and keys, with no I/O: a key press returns the next state and
4
+ // at most one effect for the caller to carry out (answer an approval, send a
5
+ // prompt, open or stop a session, quit).
6
+
7
+ export function initialState() {
8
+ return {
9
+ machines: [],
10
+ sessions: [],
11
+ approvals: [],
12
+ selectedKey: null,
13
+ focus: "sessions",
14
+ mode: "normal",
15
+ filter: "",
16
+ input: "",
17
+ scroll: 0,
18
+ notice: "",
19
+ };
20
+ }
21
+
22
+ export const sessionKey = (s) => `${s.machine}/${s.id}`;
23
+
24
+ const WORKING = new Set(["working", "streaming", "running", "thinking"]);
25
+ export const isWorking = (s) => WORKING.has(String(s?.status ?? "").toLowerCase());
26
+
27
+ /** The first approval still waiting on this session, if any. */
28
+ export function pendingApproval(state, session) {
29
+ if (!session) return null;
30
+ return state.approvals.find((a) => a.machine === session.machine && a.sessionId === session.id && a.status === "pending") ?? null;
31
+ }
32
+
33
+ const rank = (state, s) => (pendingApproval(state, s) || s.needsAction ? 0 : isWorking(s) ? 1 : 2);
34
+
35
+ /** Sessions to list: filtered, those waiting on you first, then working, then newest. */
36
+ export function visibleSessions(state) {
37
+ const needle = state.filter.toLowerCase();
38
+ return state.sessions
39
+ .filter((s) => !needle || `${s.name} ${s.agent} ${s.machine} ${s.branch ?? ""}`.toLowerCase().includes(needle))
40
+ .sort((a, b) => rank(state, a) - rank(state, b) || String(b.lastActivityAt ?? "").localeCompare(String(a.lastActivityAt ?? "")));
41
+ }
42
+
43
+ export function selectedSession(state) {
44
+ const list = visibleSessions(state);
45
+ return list.find((s) => sessionKey(s) === state.selectedKey) ?? list[0] ?? null;
46
+ }
47
+
48
+ function move(state, delta) {
49
+ const list = visibleSessions(state);
50
+ if (!list.length) return state;
51
+ const current = Math.max(0, list.findIndex((s) => sessionKey(s) === sessionKey(selectedSession(state))));
52
+ const next = Math.min(list.length - 1, Math.max(0, current + delta));
53
+ return { ...state, selectedKey: sessionKey(list[next]), scroll: 0 };
54
+ }
55
+
56
+ /** Replace what the machines reported, keeping the selection on the same session. */
57
+ export function withData(state, { machines, sessions, approvals }) {
58
+ const next = { ...state, machines, sessions, approvals };
59
+ const selected = selectedSession(next);
60
+ return { ...next, selectedKey: selected ? sessionKey(selected) : null };
61
+ }
62
+
63
+ /** Handle one key. Returns `[state, effect | null]`. */
64
+ export function reduceKey(state, key) {
65
+ if (key === "ctrl-c") return [state, { type: "quit" }];
66
+ const session = selectedSession(state);
67
+
68
+ if (state.mode === "help") return [{ ...state, mode: "normal" }, null];
69
+
70
+ if (state.mode === "filter" || state.mode === "prompt") {
71
+ if (key === "esc") return [{ ...state, mode: "normal", input: "", ...(state.mode === "filter" ? { filter: "" } : {}) }, null];
72
+ if (key === "enter") {
73
+ if (state.mode === "filter") return [{ ...state, mode: "normal", filter: state.input, input: "" }, null];
74
+ const text = state.input.trim();
75
+ const next = { ...state, mode: "normal", input: "", scroll: 0 };
76
+ return [next, text && session ? { type: "prompt", session, text } : null];
77
+ }
78
+ if (key === "backspace") {
79
+ const input = [...state.input].slice(0, -1).join("");
80
+ return [{ ...state, input, ...(state.mode === "filter" ? { filter: input } : {}) }, null];
81
+ }
82
+ if ([...key].length === 1 || key === "space") {
83
+ const input = state.input + (key === "space" ? " " : key);
84
+ return [{ ...state, input, ...(state.mode === "filter" ? { filter: input } : {}) }, null];
85
+ }
86
+ return [state, null];
87
+ }
88
+
89
+ const inTranscript = state.focus === "transcript";
90
+ switch (key) {
91
+ case "q": return [state, { type: "quit" }];
92
+ case "?": return [{ ...state, mode: "help" }, null];
93
+ case "tab": case "left": case "right": case "h": case "l":
94
+ return [{ ...state, focus: key === "h" || key === "left" ? "sessions" : key === "l" || key === "right" ? "transcript" : inTranscript ? "sessions" : "transcript" }, null];
95
+ case "j": case "down": return [inTranscript ? { ...state, scroll: Math.max(0, state.scroll - 1) } : move(state, 1), null];
96
+ case "k": case "up": return [inTranscript ? { ...state, scroll: state.scroll + 1 } : move(state, -1), null];
97
+ case "pagedown": case "ctrl-d": return [inTranscript ? { ...state, scroll: Math.max(0, state.scroll - 10) } : move(state, 10), null];
98
+ case "pageup": case "ctrl-u": return [inTranscript ? { ...state, scroll: state.scroll + 10 } : move(state, -10), null];
99
+ case "g": return [inTranscript ? { ...state, scroll: Number.MAX_SAFE_INTEGER } : move(state, -Infinity), null];
100
+ case "G": return [inTranscript ? { ...state, scroll: 0 } : move(state, Infinity), null];
101
+ case "/": return [{ ...state, mode: "filter", input: state.filter }, null];
102
+ case "esc": return [{ ...state, filter: "", notice: "" }, null];
103
+ case "enter": return [{ ...state, focus: "transcript" }, session && !session.open ? { type: "open", session } : null];
104
+ case "i": case "c": return session ? [{ ...state, mode: "prompt", input: "", focus: "transcript" }, null] : [state, null];
105
+ case "a": case "r": {
106
+ const approval = pendingApproval(state, session);
107
+ return approval ? [state, { type: "answer", approval, approve: key === "a" }] : [{ ...state, notice: "Nothing to approve here." }, null];
108
+ }
109
+ case "x": return session && isWorking(session) ? [state, { type: "stop", session }] : [state, null];
110
+ default: return [state, null];
111
+ }
112
+ }
@@ -0,0 +1,164 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // `bivy tui` frame: state in, exactly `height` lines of exactly `width` cells
4
+ // out. It uses the terminal's own 16 colors, so it follows the terminal theme.
5
+
6
+ import { fit, truncate, width as cells, wrap } from "./text.mjs";
7
+ import { isWorking, pendingApproval, selectedSession, visibleSessions } from "./model.mjs";
8
+
9
+ const esc = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
10
+ const bold = esc("1");
11
+ const dim = esc("2");
12
+ const inverse = esc("7");
13
+ const red = esc("31");
14
+ const green = esc("32");
15
+ const yellow = esc("33");
16
+ const blue = esc("34");
17
+ const cyan = esc("36");
18
+
19
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
20
+
21
+ /** Status glyphs; each also has a word in the session header for anyone not reading color. */
22
+ function glyph(state, session, tick) {
23
+ if (pendingApproval(state, session) || session.needsAction) return yellow("!");
24
+ if (isWorking(session)) return cyan(SPINNER[tick % SPINNER.length]);
25
+ if (/error|failed/i.test(session.status ?? "")) return red("✕");
26
+ // Narrow in every monospace font (unlike ● and ○), so the name never shifts.
27
+ return session.open ? green("•") : dim("·");
28
+ }
29
+
30
+ function statusWord(state, session) {
31
+ if (pendingApproval(state, session) || session.needsAction) return yellow("waiting on you");
32
+ if (isWorking(session)) return cyan("working");
33
+ return dim(session.open ? String(session.status || "idle") : "closed");
34
+ }
35
+
36
+ export function ago(iso, now) {
37
+ const ms = now - Date.parse(iso ?? "");
38
+ if (!Number.isFinite(ms)) return "";
39
+ const m = Math.round(ms / 60000);
40
+ if (m < 1) return "now";
41
+ if (m < 60) return `${m}m`;
42
+ const h = Math.round(m / 60);
43
+ return h < 48 ? `${h}h` : `${Math.round(h / 24)}d`;
44
+ }
45
+
46
+ /** A rounded box `w` wide and `h` tall around `body` lines, titled; bright border when focused. */
47
+ function box(title, body, w, h, focused) {
48
+ const paint = focused ? green : (text) => text;
49
+ const label = title ? ` ${truncate(title, w - 6)} ` : "";
50
+ const top = paint("╭─") + (focused ? bold(label) : label) + paint(`${"─".repeat(Math.max(0, w - 3 - cells(label)))}╮`);
51
+ const rows = [];
52
+ for (let i = 0; i < h - 2; i++) rows.push(paint("│") + fit(body[i] ?? "", w - 2) + paint("│"));
53
+ return [top, ...rows, paint(`╰${"─".repeat(w - 2)}╯`)];
54
+ }
55
+
56
+ function sessionRows(state, w, h, now, tick) {
57
+ const list = visibleSessions(state);
58
+ if (!list.length) return [dim(state.filter ? " No sessions match." : " No sessions yet. Start one with 'bivy run'.")];
59
+ const selected = selectedSession(state);
60
+ const index = Math.max(0, list.indexOf(selected));
61
+ const start = Math.max(0, Math.min(index - Math.floor(h / 2), list.length - h));
62
+ const multi = state.machines.length > 1;
63
+ return list.slice(start, start + h).map((s) => {
64
+ const right = `${multi ? `${s.machine} ` : ""}${ago(s.lastActivityAt, now)}`;
65
+ const name = fit(` ${glyph(state, s, tick)} ${s.name || s.id}`, Math.max(4, w - cells(right) - 2));
66
+ const row = `${name} ${dim(right)} `;
67
+ return s === selected ? (state.focus === "sessions" ? inverse(fit(row, w)) : bold(fit(row, w))) : row;
68
+ });
69
+ }
70
+
71
+ function transcriptLines(entries, agentName, w) {
72
+ const lines = [];
73
+ for (const entry of entries) {
74
+ if (entry.role === "tool") { lines.push(dim(` ▸ ${truncate(entry.text, w - 4)}`)); continue; }
75
+ lines.push("");
76
+ lines.push(entry.role === "user" ? bold(blue("you")) : bold(green(agentName)));
77
+ for (const line of wrap(entry.text, w - 2)) lines.push(` ${line}`);
78
+ }
79
+ return lines;
80
+ }
81
+
82
+ function approvalCard(approval, w) {
83
+ const head = `${yellow(bold("! Approve"))} ${bold(approval.toolName)}${approval.risk ? dim(` (${approval.risk})`) : ""}`;
84
+ const reason = wrap(String(approval.reason ?? ""), w - 4).slice(0, 3).map((l) => ` ${l}`);
85
+ return ["", head, ...reason, ` ${bold("a")} approve ${bold("r")} reject`];
86
+ }
87
+
88
+ function detailRows(state, view, w, h) {
89
+ const session = selectedSession(state);
90
+ if (!session) return [];
91
+ const header = [session.agent, state.machines.length > 1 ? session.machine : "", session.branch].filter(Boolean).join(" · ");
92
+ const top = [`${dim(header)}${header ? dim(" · ") : ""}${statusWord(state, session)}`];
93
+ const approval = pendingApproval(state, session);
94
+ const bottom = approval ? approvalCard(approval, w) : [];
95
+ if (state.mode === "prompt") bottom.push("", `${blue("›")} ${truncate(state.input, w - 4)}${inverse(" ")}`);
96
+ const room = Math.max(0, h - top.length - bottom.length);
97
+ let body;
98
+ if (!session.open && !view.entries?.length) {
99
+ body = ["", dim(" Closed. Press enter to open it.")];
100
+ } else if (view.loading) {
101
+ body = ["", dim(" Loading…")];
102
+ } else {
103
+ const lines = transcriptLines(view.entries ?? [], session.agentName || session.agent || "agent", w);
104
+ const scroll = Math.min(state.scroll, Math.max(0, lines.length - room));
105
+ body = lines.slice(Math.max(0, lines.length - room - scroll), lines.length - scroll);
106
+ }
107
+ return [...top, ...body, ...Array(Math.max(0, room - body.length)).fill(""), ...bottom].slice(0, h);
108
+ }
109
+
110
+ const HELP = [
111
+ ["j/k ↑/↓", "move, or scroll the transcript"],
112
+ ["tab h/l", "switch pane"],
113
+ ["enter", "open the session"],
114
+ ["i", "message the agent"],
115
+ ["a / r", "approve / reject what it's asking"],
116
+ ["x", "stop the current turn"],
117
+ ["/", "filter sessions"],
118
+ ["g / G", "top / bottom"],
119
+ ["q", "quit"],
120
+ ];
121
+
122
+ function keyBar(state) {
123
+ if (state.mode === "filter") return `${blue("/")}${state.input}${inverse(" ")} ${dim("enter keep · esc clear")}`;
124
+ if (state.mode === "prompt") return dim("enter send · esc cancel");
125
+ if (state.notice) return yellow(state.notice);
126
+ const keys = [["enter", "open"], ["i", "message"], ["a/r", "approve/reject"], ["x", "stop"], ["/", "filter"], ["?", "help"], ["q", "quit"]];
127
+ return keys.map(([k, label]) => `${bold(k)} ${dim(label)}`).join(" ");
128
+ }
129
+
130
+ /** The whole screen as `height` lines. `view` is the selected session's transcript: `{ entries, loading }`. */
131
+ export function renderFrame(state, view, { width, height, now = Date.now(), tick = 0 }) {
132
+ const waiting = state.sessions.filter((s) => pendingApproval(state, s) || s.needsAction).length;
133
+ const offline = state.machines.filter((m) => !m.online).map((m) => m.name);
134
+ const summary = [
135
+ `${state.machines.length} machine${state.machines.length === 1 ? "" : "s"}`,
136
+ `${state.sessions.length} session${state.sessions.length === 1 ? "" : "s"}`,
137
+ waiting ? yellow(`${waiting} waiting on you`) : "",
138
+ offline.length ? red(`${offline.join(", ")} offline`) : "",
139
+ ].filter(Boolean).join(dim(" · "));
140
+ const title = fit(`${inverse(bold(" bivy "))} ${summary}`, width);
141
+
142
+ const left = Math.max(24, Math.min(48, Math.floor(width * 0.36)));
143
+ const right = width - left;
144
+ const paneHeight = Math.max(3, height - 2);
145
+ const sessionsTitle = `Sessions${state.filter ? ` /${state.filter}` : ""}`;
146
+ const leftBox = box(sessionsTitle, sessionRows(state, left - 2, paneHeight - 2, now, tick), left, paneHeight, state.focus === "sessions");
147
+ const selected = selectedSession(state);
148
+ const rightBox = box(selected ? selected.name || selected.id : "", detailRows(state, view, right - 2, paneHeight - 2), right, paneHeight, state.focus === "transcript");
149
+
150
+ let lines = [title, ...leftBox.map((l, i) => l + rightBox[i]), fit(` ${keyBar(state)}`, width)];
151
+ if (state.mode === "help") {
152
+ const w = Math.min(width - 4, 52);
153
+ const body = HELP.map(([k, label]) => ` ${bold(k.padEnd(9))} ${label}`);
154
+ const overlay = box("Keys", body, w, body.length + 2, true);
155
+ const top = Math.max(1, Math.floor((height - overlay.length) / 2));
156
+ const x = Math.floor((width - w) / 2);
157
+ lines = lines.map((line, i) => {
158
+ const o = overlay[i - top];
159
+ return o ? fit(" ".repeat(x), x) + o + " ".repeat(Math.max(0, width - x - w)) : line;
160
+ });
161
+ }
162
+ return lines.slice(0, height);
163
+ }
164
+
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Terminal cell arithmetic for `bivy tui`: visible width of styled text,
4
+ // truncating, padding and wrapping it without splitting an escape sequence.
5
+
6
+ const ANSI = /\x1b\[[0-9;]*m/g;
7
+ // Wide (two-cell) characters: CJK, Hangul, fullwidth forms and most emoji.
8
+ const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{1f300}-\u{1f64f}\u{1f900}-\u{1f9ff}\u{20000}-\u{3fffd}]/u;
9
+ // Zero-width: combining marks, variation selectors, joiners.
10
+ const ZERO = /[\p{Mn}\p{Cf}]/u;
11
+
12
+ export const stripAnsi = (text) => String(text).replace(ANSI, "");
13
+
14
+ function charWidth(ch) {
15
+ if (ZERO.test(ch)) return 0;
16
+ return WIDE.test(ch) ? 2 : 1;
17
+ }
18
+
19
+ export function width(text) {
20
+ let w = 0;
21
+ for (const ch of stripAnsi(text)) w += charWidth(ch);
22
+ return w;
23
+ }
24
+
25
+ /** Split styled text into [escape | character] tokens. */
26
+ function tokens(text) {
27
+ return String(text).match(/\x1b\[[0-9;]*m|[\s\S]/gu) ?? [];
28
+ }
29
+
30
+ /** Cut to at most `max` cells, ending in "…" when anything was dropped. */
31
+ export function truncate(text, max) {
32
+ if (max <= 0) return "";
33
+ if (width(text) <= max) return text;
34
+ let out = "";
35
+ let w = 0;
36
+ for (const token of tokens(text)) {
37
+ if (token.startsWith("\x1b")) { out += token; continue; }
38
+ const cw = charWidth(token);
39
+ if (w + cw > max - 1) break;
40
+ out += token;
41
+ w += cw;
42
+ }
43
+ return `${out}…\x1b[0m`;
44
+ }
45
+
46
+ /** Exactly `size` cells: truncated, or padded with spaces. */
47
+ export function fit(text, size) {
48
+ const cut = truncate(text, size);
49
+ return cut + " ".repeat(Math.max(0, size - width(cut)));
50
+ }
51
+
52
+ /** Word-wrap plain text to `max` cells per line, hard-breaking long words. */
53
+ export function wrap(text, max) {
54
+ const lines = [];
55
+ for (const paragraph of String(text).replace(/\t/g, " ").split("\n")) {
56
+ let line = "";
57
+ for (const word of paragraph.split(/(\s+)/)) {
58
+ if (!word) continue;
59
+ if (width(line + word) <= max) { line += word; continue; }
60
+ if (line.trim()) lines.push(line.trimEnd());
61
+ line = /^\s+$/.test(word) ? "" : word;
62
+ while (width(line) > max) {
63
+ let head = "";
64
+ for (const ch of line) { if (width(head + ch) > max) break; head += ch; }
65
+ lines.push(head);
66
+ line = line.slice(head.length);
67
+ }
68
+ }
69
+ lines.push(line.trimEnd());
70
+ }
71
+ return lines;
72
+ }
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // A session's history messages as transcript entries for `bivy tui`: what the
4
+ // user said, what the agent said, and one line per tool call. Agents shape
5
+ // their messages differently, so this reads content blocks by kind, not by agent.
6
+
7
+ const TEXT = new Set(["text", "output_text", "input_text"]);
8
+ const TOOL_USE = new Set(["tool_use", "toolCall", "tool_call", "server_tool_use"]);
9
+ // Harness notes some runtimes persist as user text; never shown as the user's words.
10
+ const META = /^\s*(?:\[Request interrupted by user|<(?:task-notification|system-reminder)[\s>/])/;
11
+
12
+ function textOf(content) {
13
+ if (typeof content === "string") return content;
14
+ if (!Array.isArray(content)) return "";
15
+ return content.filter((b) => TEXT.has(b?.type)).map((b) => String(b.text ?? "")).join("\n");
16
+ }
17
+
18
+ /** A one-line summary of a tool call: its name and its most telling argument. */
19
+ export function toolSummary(block) {
20
+ const name = String(block?.name ?? block?.toolName ?? "tool");
21
+ const args = block?.input ?? block?.arguments ?? block?.args ?? {};
22
+ const input = typeof args === "string" ? (() => { try { return JSON.parse(args); } catch { return { value: args }; } })() : args;
23
+ const key = ["command", "cmd", "file_path", "path", "pattern", "query", "url", "description", "prompt"].find((k) => typeof input?.[k] === "string");
24
+ const detail = key ? input[key] : "";
25
+ return detail ? `${name} ${detail.replace(/\s+/g, " ").trim()}` : name;
26
+ }
27
+
28
+ /** `[{ role: "user"|"agent"|"tool", text }]` from `session.history` messages. */
29
+ export function transcriptEntries(messages = []) {
30
+ const entries = [];
31
+ // Runtimes may repeat a tool call in later messages as it progresses; show it once.
32
+ const seenTools = new Set();
33
+ for (const msg of messages) {
34
+ const role = String(msg?.role ?? "assistant").toLowerCase();
35
+ if (role === "toolresult" || role === "tool_result" || role === "tool") continue;
36
+ if (role === "user") {
37
+ const text = textOf(msg.content).trim();
38
+ if (text && !META.test(text)) entries.push({ role: "user", text });
39
+ continue;
40
+ }
41
+ if (role === "system") continue;
42
+ const blocks = Array.isArray(msg?.content) ? msg.content : [{ type: "text", text: textOf(msg?.content) }];
43
+ for (const block of blocks) {
44
+ if (TEXT.has(block?.type) && String(block.text ?? "").trim()) entries.push({ role: "agent", text: String(block.text).trim() });
45
+ else if (TOOL_USE.has(block?.type)) {
46
+ const id = block.id ?? block.toolCallId;
47
+ if (id && seenTools.has(id)) continue;
48
+ if (id) seenTools.add(id);
49
+ entries.push({ role: "tool", text: toolSummary(block) });
50
+ }
51
+ }
52
+ }
53
+ return entries;
54
+ }