@bivy/bivy 0.20.1-staging.7 → 0.20.1-staging.8
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.
- package/bin/bivy.mjs +37 -1
- package/bin/cli-commands.mjs +1 -0
- package/bin/install-kind.mjs +20 -0
- package/bin/tui/app.mjs +189 -0
- package/bin/tui/model.mjs +112 -0
- package/bin/tui/render.mjs +164 -0
- package/bin/tui/text.mjs +72 -0
- package/bin/tui/transcript.mjs +54 -0
- package/dist/apps/styles.css +1 -1
- package/dist/apps/tokens.css +40 -0
- package/dist/machine-theme.js +118 -0
- package/dist/node-update.js +16 -0
- package/dist/server.js +15 -1
- package/package.json +1 -1
- package/web/assets/{AutomationsView-BJ3gBIGf.js → AutomationsView-BnHdmp7P.js} +2 -2
- package/web/assets/{ChangesCard-BzRQTCVs.js → ChangesCard-sGQlkM2d.js} +1 -1
- package/web/assets/{LibraryView-C6mluzPl.js → LibraryView-DYUo2624.js} +1 -1
- package/web/assets/{ProviderConnect-BK6oEwlu.js → ProviderConnect-DB-gwOwe.js} +1 -1
- package/web/assets/Settings-VYk0Et0A.js +10 -0
- package/web/assets/{Sheet-YqJCwCtR.js → Sheet-BmAB1Fu_.js} +1 -1
- package/web/assets/{Terminal-CWjLg3i2.js → Terminal-Dkr504wc.js} +1 -1
- package/web/assets/{VoiceSettings-DVlH29qG.js → VoiceSettings-DaR6ba3A.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-DR5nREn3.js → abnfDiagram-VCTEODGH-CZTMfHPQ.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-MiVMMOHz.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-BTtzbbP7.js → architectureDiagram-5GKGNRK7-BBR7u-Nc.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-B02cXkpO.js → blockDiagram-I7D4REHJ-DU1iTe41.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-DuMEAvRa.js → c4Diagram-7LVT6UL2-DoY25VIT.js} +1 -1
- package/web/assets/channel-m_2Ky5Co.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-C3z4iXsh.js → chunk-4HAMMTFA-CwLS5ngk.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-BaELqtNa.js → chunk-75Z2AOVW-FHeNbdLy.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-BBrnNwn_.js → chunk-DU6HZSFF-CUNfwm6b.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-Bfuzd8Q8.js → chunk-F27PBJKO-BT9LzzRz.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-B0Np7gv0.js → chunk-GMAD6QVW-BUr3PoNi.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-CiZeRFSW.js → chunk-GVQU2GXP-Dwa-Ue8e.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-DJGDP5h1.js → chunk-IMKFNOWR-CBemkwVh.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-u67SBNSZ.js → chunk-L3NEJ4N5-AGhZr4q5.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-DJUfOWEW.js → chunk-OSK3NFVY-CxTtDDij.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-CYmch4EI.js → chunk-P2QGCYS3-DGmss7hg.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-BOjVOrGx.js → chunk-POPQ4Y6H-nsxQTUJd.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-ChmjsLTV.js → chunk-PWAF6VOD-CFEkzIZ2.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-BU57QSGG.js → chunk-SHT3W25Y-BArb8KoG.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-3okF7bFJ.js → chunk-SVP7TREG-CEFJfpYl.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-DervhtUR.js → chunk-TICWLB2K-ByqSjpyA.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-BM6g8SUg.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-BM6g8SUg.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-BpkHeQ4U.js → cynefin-OW5HDTMX-BPd4eJmp.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-BY9V3rN7.js → cynefinDiagram-5FMLGOSQ-rm271vYO.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-DAmjU9c2.js → dagre-GXQ25YYZ-a4DVMh7o.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-BE7RKay-.js → diagram-S7CK7UJ4-CR4kj8JQ.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-CkizMHlG.js → diagram-UQ7AKVKN-BVAidz_h.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-BELxVnhK.js → diagram-VSXAHHWV-P_KshIhr.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-C3wu2cVu.js → diagram-VX7I27RA-CfR4HucM.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-CYX5IZ30.js → diagram-Z3DM3KII-CnxFHDS4.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-CgyMDCw8.js → ebnfDiagram-PWID7BFC-0lvDIevQ.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-CC1ZIoBl.js → erDiagram-RLTQ6QDP-B5onTaVV.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-DviokIMu.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-BtFCDXEP.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-CLUlox-U.js → ganttDiagram-EL5Y4UJY-DcP58ot2.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-B_yZBnXo.js → gitGraph-4MIJSDKK-DMgkg1Fa.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-DNGTGoq6.js → gitGraphDiagram-WWUBYQGX-ChZQlU2S.js} +1 -1
- package/web/assets/index-CsC63U16.css +1 -0
- package/web/assets/index-emCm7_bF.js +3 -0
- package/web/assets/{info-A6RAGUB7-O_QhqiG0.js → info-A6RAGUB7-Bwm7-KTz.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-CfBbmLPf.js → infoDiagram-27XIBGKW-PpNSZSrV.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-BJj25HdR.js → ishikawaDiagram-5VMMS53U-DXh6yXna.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-D547gNvv.js → journeyDiagram-3NMN7TZE-DFEj5jkB.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-BziklmYw.js → kanban-definition-UXKFOSKX-C5Mvo17h.js} +1 -1
- package/web/assets/{line-CjB_oA8i.js → line-CqUbcJrv.js} +1 -1
- package/web/assets/{mermaid-parser.core-Bj5-yX0d.js → mermaid-parser.core-BzUSMP_7.js} +3 -3
- package/web/assets/{mermaid.core-CWsczu0T.js → mermaid.core-CooqV3Py.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-BqsBs-D4.js → mindmap-definition-YA3MSWOX-CvsfS7bG.js} +1 -1
- package/web/assets/{mount-BlTXC3tw.js → mount-BD-g3eYn.js} +12 -12
- package/web/assets/{packet-AYTQ26CC-B8U6sMom.js → packet-AYTQ26CC-BzXGhTo4.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-BXV85Hj-.js → pegDiagram-XKGWAZYB-BvdN2RIR.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-CD71AvCo.js → pie-WAS4IAKB-CNH8JQby.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-DNq8_A1r.js → pieDiagram-E7YTZNPT-C6LABPqT.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-ZNiXqFdJ.js → quadrantDiagram-AXDQQJYC-D2ebcGIP.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-CpvF1rs-.js → radar-RG4KPBEZ-B7uFPzC9.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-BIfz4cd-.js → railroad-74A4TZTK-CE9pdZ9o.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-C_bKzcjL.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-BzPEt-Wn.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-CeL6qyBT.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-Fao_FZJL.js → railroadDiagram-O6MQD6OU-vJc9tRJP.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-QnxN_viD.js → requirementDiagram-BXWQKSXE-CVLUnEfL.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-DDhcKN50.js → sankeyDiagram-P5KCCOFB-DkHI3ZIh.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-bVK0aV05.js → sequenceDiagram-WJ2MYXX4-BgosmpaZ.js} +1 -1
- package/web/assets/{stateDiagram-D77RDMKH-CZiiW4Zy.js → stateDiagram-D77RDMKH-BZ5Zqme7.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DtyGmcgM.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-CYgOds43.js → swimlanes-42K2YHIH-rkMg8KEw.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-CbnaKO-d.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-CnVLFUDs.js → timeline-definition-24CTP7MA-_z_HJLWC.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-D6CxJQkK.js → treeView-Q6P3EWNA-jR5Z1Fmf.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-DFCHLN8D.js → treemap-WGGIJYW6-OtJhfdUS.js} +1 -1
- package/web/assets/useStore-CaPvUEwc.js +27 -0
- package/web/assets/{vennDiagram-4TSXK5OY-CX5aQZNu.js → vennDiagram-4TSXK5OY-BFTbjE-l.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-BCPDkPQh.js → wardley-WFR3VGLG-BV0ly0l7.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-Cf0fwWD7.js → wardleyDiagram-VM6X3IG4-DYvCqNRg.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-DalYEP3d.js → xychartDiagram-S5SC5T6Z-CaYy8yTC.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/web/assets/Settings-CG236bpZ.js +0 -10
- package/web/assets/architecture-7GRP2DOG-Mvf9rpFA.js +0 -1
- package/web/assets/channel-BETA4C1-.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-DG1LzruI.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-DG1LzruI.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-YiXMtkrP.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-UVK9K8aR.js +0 -1
- package/web/assets/index-D-0NDZis.js +0 -3
- package/web/assets/index-Dk7VU5lG.css +0 -1
- package/web/assets/railroad-abnf-HS5TGJTU-oAj9W4Xw.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-B5J5VpFh.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-DpqDcjfx.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-B9Vt7f0v.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-CfED88ct.js +0 -8
- package/web/assets/useStore-DcIB7b1-.js +0 -27
package/bin/bivy.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { removeInstallAndState } from "./uninstall-paths.mjs";
|
|
|
42
42
|
import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
|
|
43
43
|
import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
|
|
44
44
|
import { resolveAttachSessionId } from "./attach-session-id.mjs";
|
|
45
|
-
import { detectInstallKind as classifyInstallKind, npmGlobalPrefix } from "./install-kind.mjs";
|
|
45
|
+
import { detectInstallKind as classifyInstallKind, managedInstall, npmGlobalPrefix } from "./install-kind.mjs";
|
|
46
46
|
import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
|
|
47
47
|
import { COMMANDS, EXIT, cliError, completionWords, describeCli, describeCommand, describeTools, renderHelp, resolveCommand, subcommandTable, suggestCommands, toolArgv, wantsJson } from "./cli-commands.mjs";
|
|
48
48
|
|
|
@@ -77,6 +77,7 @@ process.env.BIVY_DATA_DIR = appDir;
|
|
|
77
77
|
// - "npx" ephemeral `npx bivy` run (repoRoot under an npm _npx cache)
|
|
78
78
|
// - "npm-global" `npm i -g @bivy/bivy` (repoRoot is below node_modules/@bivy)
|
|
79
79
|
// - "packaged" install.sh tarball tree (user-owned, self-preserving)
|
|
80
|
+
// - "managed" a system package (the AUR's `bivy`), updated by its manager
|
|
80
81
|
function detectInstallKind() {
|
|
81
82
|
return classifyInstallKind(repoRoot);
|
|
82
83
|
}
|
|
@@ -2133,6 +2134,31 @@ async function fetchJson(baseUrl, pathName, token) {
|
|
|
2133
2134
|
// --json machine-readable list, no prompt
|
|
2134
2135
|
// --limit/-n N cap how many saved sessions to show (default: unlimited — all of them)
|
|
2135
2136
|
// <n> | <id> select non-interactively (index in the list, or a session id)
|
|
2137
|
+
// `bivy tui` — every session on this machine and your direct nodes in one
|
|
2138
|
+
// terminal view: live transcripts, approvals, messages to the agent.
|
|
2139
|
+
async function cmdTui(args = []) {
|
|
2140
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2141
|
+
console.log("Usage: bivy tui\n\nA terminal view of the sessions on this machine and on the nodes you added with 'bivy nodes add': live transcripts, approvals and messages to the agent, all from the keyboard. Press ? inside for keys.");
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2145
|
+
console.error(c.red("bivy tui needs an interactive terminal."));
|
|
2146
|
+
process.exitCode = EXIT.usage;
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
const config = loadConfig();
|
|
2150
|
+
if (!(await ensureNodeRunning(config))) {
|
|
2151
|
+
console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
|
|
2152
|
+
process.exitCode = EXIT.unavailable;
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
const token = await localDeviceToken(config);
|
|
2156
|
+
const info = await fetchJson(url(config), "/api/node/info", token).catch(() => ({}));
|
|
2157
|
+
const direct = Object.entries(loadNodes().nodes).map(([name, node]) => ({ name, base: node.url, token: node.token }));
|
|
2158
|
+
const { runTui } = await import("./tui/app.mjs");
|
|
2159
|
+
await runTui({ machines: [{ name: info?.name || "this machine", base: url(config), token }, ...direct] });
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2136
2162
|
async function cmdSessions(args = [], opts = {}) {
|
|
2137
2163
|
if (args.includes("-h") || args.includes("--help")) {
|
|
2138
2164
|
console.log(
|
|
@@ -5314,6 +5340,13 @@ async function cmdUpdate(args = []) {
|
|
|
5314
5340
|
console.log("Usage: bivy update [--force|--no-wait] [--staging|--stable|--channel <name>]\n\nUpdate Bivy + install deps + restart service. Stays on the release channel recorded at install time (default 'latest'); --staging/--stable/--channel switch channels and are remembered for future updates. Waits for active sessions to finish a turn first; --force/--no-wait skips the wait. See 'bivy update:log' for the last run's output.");
|
|
5315
5341
|
return;
|
|
5316
5342
|
}
|
|
5343
|
+
const managed = managedInstall(repoRoot);
|
|
5344
|
+
if (managed) {
|
|
5345
|
+
console.log(`Bivy is installed by ${managed.manager} here, so it updates through it${managed.update ? `: ${c.cyan(managed.update)}` : "."}`);
|
|
5346
|
+
console.log(c.dim("Then run 'bivy restart' to pick up the new version."));
|
|
5347
|
+
process.exitCode = EXIT.failed;
|
|
5348
|
+
return;
|
|
5349
|
+
}
|
|
5317
5350
|
// Inside a Bivy web/PWA terminal the shell is a child of the node's own
|
|
5318
5351
|
// process, so `restartService()` — the final step of an update — tears down
|
|
5319
5352
|
// this very terminal. Run inline and you lose all output the moment it lands.
|
|
@@ -6414,6 +6447,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
|
|
|
6414
6447
|
case "link":
|
|
6415
6448
|
await cmdLinkPhone(args);
|
|
6416
6449
|
break;
|
|
6450
|
+
case "tui":
|
|
6451
|
+
await cmdTui(args);
|
|
6452
|
+
break;
|
|
6417
6453
|
case "tailscale":
|
|
6418
6454
|
await cmdTailscale(args);
|
|
6419
6455
|
break;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -65,6 +65,7 @@ export const COMMANDS = [
|
|
|
65
65
|
{ name: "run", group: "sessions", scope: "node", usage: "run <agent> [--chat] [--name …] [--model …] [--node …] [--workspace …]", summary: "Run an agent (native CLI/TUI) as a session the app can see" },
|
|
66
66
|
{ name: "exec", group: "sessions", scope: "node", json: true, usage: 'exec "<prompt>" [--agent …] [--session …]', summary: "One-shot headless session: prints the answer to stdout" },
|
|
67
67
|
{ name: "send", group: "sessions", scope: "node", json: true, usage: 'send <id> "<message>"', summary: "Send a prompt to an existing session and stream the reply" },
|
|
68
|
+
{ name: "tui", group: "sessions", scope: "node", usage: "tui", summary: "Sessions across your machines in the terminal: transcripts, approvals, messages" },
|
|
68
69
|
{ name: "sessions", aliases: ["ls"], group: "sessions", scope: "node", json: true, usage: "sessions [--json]", summary: "List recent sessions (live and saved) and resume one" },
|
|
69
70
|
{ name: "resume", group: "sessions", scope: "node", usage: "resume [n|id]", summary: "Resume a session directly (default: most recent)" },
|
|
70
71
|
{ name: "kill", group: "sessions", scope: "node", usage: "kill <id> [--delete]", summary: "Stop a session or terminal" },
|
package/bin/install-kind.mjs
CHANGED
|
@@ -3,9 +3,29 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
+
/** The file a system package (pacman, …) writes into the install it owns. */
|
|
7
|
+
export const MANAGED_INSTALL_FILE = ".bivy-install.json";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `{ manager, update }` when a system package manager owns this install, so
|
|
11
|
+
* Bivy must not replace its files; null otherwise.
|
|
12
|
+
*/
|
|
13
|
+
export function managedInstall(repoRoot, readFileSync = fs.readFileSync) {
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(readFileSync(path.join(repoRoot, MANAGED_INSTALL_FILE), "utf8"));
|
|
16
|
+
if (typeof data?.manager === "string" && data.manager) {
|
|
17
|
+
return { manager: data.manager, update: typeof data.update === "string" ? data.update : "" };
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
/* not managed */
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
/** Classify a Bivy package root without assuming unscoped npm package layout. */
|
|
7
26
|
export function detectInstallKind(repoRoot, existsSync = fs.existsSync) {
|
|
8
27
|
if (existsSync(path.join(repoRoot, ".git"))) return "git";
|
|
28
|
+
if (existsSync(path.join(repoRoot, MANAGED_INSTALL_FILE))) return "managed";
|
|
9
29
|
|
|
10
30
|
// npm installs an unscoped package at node_modules/name and a scoped package
|
|
11
31
|
// at node_modules/@scope/name. Bivy uses the latter layout.
|
package/bin/tui/app.mjs
ADDED
|
@@ -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
|
+
|
package/bin/tui/text.mjs
ADDED
|
@@ -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
|
+
}
|