@bivy/bivy 0.20.1-staging.6 → 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 +64 -1
- package/bin/cli-commands.mjs +3 -0
- package/bin/guides/talk-to-the-user.md +7 -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/agent-instructions.js +2 -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 +30 -9
- package/dist/session/session-namer.js +8 -2
- package/dist/session/session-tokens.js +1 -1
- package/package.json +1 -1
- package/web/assets/{AutomationsView-C5FTAk5H.js → AutomationsView-BnHdmp7P.js} +2 -2
- package/web/assets/{ChangesCard-Bq3EQIem.js → ChangesCard-sGQlkM2d.js} +1 -1
- package/web/assets/{LibraryView-DrOvEcbv.js → LibraryView-DYUo2624.js} +1 -1
- package/web/assets/{ProviderConnect-B5UJ6o1x.js → ProviderConnect-DB-gwOwe.js} +1 -1
- package/web/assets/Settings-VYk0Et0A.js +10 -0
- package/web/assets/{Sheet-Db4Mndim.js → Sheet-BmAB1Fu_.js} +1 -1
- package/web/assets/{Terminal-CvIBOJzq.js → Terminal-Dkr504wc.js} +1 -1
- package/web/assets/{VoiceSettings-96Bha24X.js → VoiceSettings-DaR6ba3A.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-D2Y5sPRp.js → abnfDiagram-VCTEODGH-CZTMfHPQ.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-MiVMMOHz.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-BRYXZYo0.js → architectureDiagram-5GKGNRK7-BBR7u-Nc.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-BbZ6-1wB.js → blockDiagram-I7D4REHJ-DU1iTe41.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-CvOQdXuZ.js → c4Diagram-7LVT6UL2-DoY25VIT.js} +1 -1
- package/web/assets/channel-m_2Ky5Co.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-BI825_ZV.js → chunk-4HAMMTFA-CwLS5ngk.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-CxSd6Zxl.js → chunk-75Z2AOVW-FHeNbdLy.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-1-mK49JE.js → chunk-DU6HZSFF-CUNfwm6b.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-jlR5FL7E.js → chunk-F27PBJKO-BT9LzzRz.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-BBnwhF5K.js → chunk-GMAD6QVW-BUr3PoNi.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-CHUTVqWw.js → chunk-GVQU2GXP-Dwa-Ue8e.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-D0BbcDj6.js → chunk-IMKFNOWR-CBemkwVh.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-DvDZiWe-.js → chunk-L3NEJ4N5-AGhZr4q5.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-JHuvasbP.js → chunk-OSK3NFVY-CxTtDDij.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-wjuBF4Pd.js → chunk-P2QGCYS3-DGmss7hg.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-CngK4WUA.js → chunk-POPQ4Y6H-nsxQTUJd.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-CTRtPw_P.js → chunk-PWAF6VOD-CFEkzIZ2.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-ABe0cu9b.js → chunk-SHT3W25Y-BArb8KoG.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-cnVb9wRh.js → chunk-SVP7TREG-CEFJfpYl.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-DfKefWT6.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-6Eym2I8q.js → cynefin-OW5HDTMX-BPd4eJmp.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-DMcNShU2.js → cynefinDiagram-5FMLGOSQ-rm271vYO.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-jyxO30Ra.js → dagre-GXQ25YYZ-a4DVMh7o.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-DyJSgUWQ.js → diagram-S7CK7UJ4-CR4kj8JQ.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-Bu-awgYc.js → diagram-UQ7AKVKN-BVAidz_h.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-CDG8WcFV.js → diagram-VSXAHHWV-P_KshIhr.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-eJK6IXRm.js → diagram-VX7I27RA-CfR4HucM.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-4bfg0YVc.js → diagram-Z3DM3KII-CnxFHDS4.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-DLpKX0PA.js → ebnfDiagram-PWID7BFC-0lvDIevQ.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-C9XoVCNz.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-6GH5Yrgv.js → ganttDiagram-EL5Y4UJY-DcP58ot2.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-DFBb9ZSe.js → gitGraph-4MIJSDKK-DMgkg1Fa.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-DVuclljM.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-DS--l7eH.js → info-A6RAGUB7-Bwm7-KTz.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-A9tXXovL.js → infoDiagram-27XIBGKW-PpNSZSrV.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-C3bulOe4.js → ishikawaDiagram-5VMMS53U-DXh6yXna.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-PFpzT1t0.js → journeyDiagram-3NMN7TZE-DFEj5jkB.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-C96gUxZE.js → kanban-definition-UXKFOSKX-C5Mvo17h.js} +1 -1
- package/web/assets/{line-Bq-4Ddcs.js → line-CqUbcJrv.js} +1 -1
- package/web/assets/{mermaid-parser.core-BQCW99Ok.js → mermaid-parser.core-BzUSMP_7.js} +3 -3
- package/web/assets/{mermaid.core-B30Wokvl.js → mermaid.core-CooqV3Py.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-ClwtCWm0.js → mindmap-definition-YA3MSWOX-CvsfS7bG.js} +1 -1
- package/web/assets/{mount-B_ShSdyJ.js → mount-BD-g3eYn.js} +12 -12
- package/web/assets/{packet-AYTQ26CC-YkeLgzv0.js → packet-AYTQ26CC-BzXGhTo4.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-CaCibFki.js → pegDiagram-XKGWAZYB-BvdN2RIR.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-OyMK5OfZ.js → pie-WAS4IAKB-CNH8JQby.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-DfGlDcKu.js → pieDiagram-E7YTZNPT-C6LABPqT.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-C8ugAM99.js → quadrantDiagram-AXDQQJYC-D2ebcGIP.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-DPu0k7LD.js → radar-RG4KPBEZ-B7uFPzC9.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-Co59jOcB.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-COh170ve.js → railroadDiagram-O6MQD6OU-vJc9tRJP.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-Dh-wxNt2.js → requirementDiagram-BXWQKSXE-CVLUnEfL.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-BHOSWXWw.js → sankeyDiagram-P5KCCOFB-DkHI3ZIh.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-ik5jL4SR.js → sequenceDiagram-WJ2MYXX4-BgosmpaZ.js} +1 -1
- package/web/assets/{stateDiagram-D77RDMKH-Be31rRv9.js → stateDiagram-D77RDMKH-BZ5Zqme7.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DtyGmcgM.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-DBK3hVdk.js → swimlanes-42K2YHIH-rkMg8KEw.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-CbnaKO-d.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-DlW5MrNZ.js → timeline-definition-24CTP7MA-_z_HJLWC.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-CfAW_qLZ.js → treeView-Q6P3EWNA-jR5Z1Fmf.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-QxQMH5Y8.js → treemap-WGGIJYW6-OtJhfdUS.js} +1 -1
- package/web/assets/useStore-CaPvUEwc.js +27 -0
- package/web/assets/{vennDiagram-4TSXK5OY-D91iwoPB.js → vennDiagram-4TSXK5OY-BFTbjE-l.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-hq1e1BaT.js → wardley-WFR3VGLG-BV0ly0l7.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-BDiSY_RF.js → wardleyDiagram-VM6X3IG4-DYvCqNRg.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-DX1lbV1q.js → xychartDiagram-S5SC5T6Z-CaYy8yTC.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/web/assets/Settings-BV1LvpeB.js +0 -10
- package/web/assets/architecture-7GRP2DOG-BgbAf7AC.js +0 -1
- package/web/assets/channel-Ccxgwn49.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-B_nUbchH.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-B_nUbchH.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-DB9bp7xg.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-Bt84xHsk.js +0 -1
- package/web/assets/index-Dk7VU5lG.css +0 -1
- package/web/assets/index-H1S-0dr5.js +0 -3
- package/web/assets/railroad-abnf-HS5TGJTU-Cw5NEYkY.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-nB_gqNFI.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-2XU5RRzN.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DV2_zzd4.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-D_QAhSyB.js +0 -8
- package/web/assets/useStore-BxmpBglW.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(
|
|
@@ -2554,6 +2580,30 @@ async function cmdSuggest(args = []) {
|
|
|
2554
2580
|
console.log(c.green("Suggested in the chat. The user can start it in one tap."));
|
|
2555
2581
|
}
|
|
2556
2582
|
|
|
2583
|
+
// `bivy title "<title>" [--session <id>]` — rename the session the agent runs in.
|
|
2584
|
+
async function cmdTitle(args = []) {
|
|
2585
|
+
const usage = 'Usage: bivy title "<title>" [--session <id>] [--json]';
|
|
2586
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2587
|
+
console.log(`${usage}\n\nRename this session in the user's session list. Use a short title that says what the work is, when the first message made a poor title or the work changed direction. --json prints {"ok","title"}.`);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
const json = wantsJson(args);
|
|
2591
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2592
|
+
const sessionFlag = args.indexOf("--session");
|
|
2593
|
+
const title = args.filter((a, i) => !a.startsWith("-") && !(sessionFlag >= 0 && i === sessionFlag + 1)).join(" ").trim();
|
|
2594
|
+
const sessionId = resolveAttachSessionId({ sessionFlag: sessionFlag >= 0 ? args[sessionFlag + 1] : undefined, env: process.env });
|
|
2595
|
+
if (!title) return fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2596
|
+
if (!sessionId) return fail({ code: "no_session", message: "No session id.", hint: "Run inside an agent session ($BIVY_SESSION_ID) or pass --session <id>.", next: "bivy sessions --json", exit: EXIT.usage });
|
|
2597
|
+
const config = loadConfig();
|
|
2598
|
+
if (!(await ensureNodeRunning(config))) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node at ${url(config)}.`, next: "bivy status", exit: EXIT.unavailable });
|
|
2599
|
+
const res = await sessionPost(config, sessionId, "title", { title }).catch((error) => error);
|
|
2600
|
+
if (res instanceof Error) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable });
|
|
2601
|
+
const body = await res.json().catch(() => ({}));
|
|
2602
|
+
if (!res.ok) return fail(sessionHttpError("Title", res.status, body));
|
|
2603
|
+
if (json) { console.log(JSON.stringify(body)); return; }
|
|
2604
|
+
console.log(c.green(`Renamed this session to "${body.title}".`));
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2557
2607
|
// Flags shared by the session commands: `--name value` pairs and bare switches,
|
|
2558
2608
|
// with every other word collected as positional text.
|
|
2559
2609
|
function parseSessionArgs(args, valueFlags) {
|
|
@@ -5290,6 +5340,13 @@ async function cmdUpdate(args = []) {
|
|
|
5290
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.");
|
|
5291
5341
|
return;
|
|
5292
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
|
+
}
|
|
5293
5350
|
// Inside a Bivy web/PWA terminal the shell is a child of the node's own
|
|
5294
5351
|
// process, so `restartService()` — the final step of an update — tears down
|
|
5295
5352
|
// this very terminal. Run inline and you lose all output the moment it lands.
|
|
@@ -6204,6 +6261,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
6204
6261
|
case "suggest":
|
|
6205
6262
|
await cmdSuggest(args);
|
|
6206
6263
|
break;
|
|
6264
|
+
case "title":
|
|
6265
|
+
await cmdTitle(args);
|
|
6266
|
+
break;
|
|
6207
6267
|
case "notify":
|
|
6208
6268
|
await cmdNotify(args);
|
|
6209
6269
|
break;
|
|
@@ -6387,6 +6447,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
|
|
|
6387
6447
|
case "link":
|
|
6388
6448
|
await cmdLinkPhone(args);
|
|
6389
6449
|
break;
|
|
6450
|
+
case "tui":
|
|
6451
|
+
await cmdTui(args);
|
|
6452
|
+
break;
|
|
6390
6453
|
case "tailscale":
|
|
6391
6454
|
await cmdTailscale(args);
|
|
6392
6455
|
break;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -48,6 +48,8 @@ export const COMMANDS = [
|
|
|
48
48
|
tools: [{ name: "notify_user", description: "Send the user a message: a card in the chat, and a push to their phone when nobody has the app open (the push names the session, not the text). Use it when long work finishes, you are blocked, or something needs their attention. At most one push a minute per session.", input: { message: { type: "string", description: "What to tell the user.", required: true }, urgent: { type: "boolean", description: "Push even while the user has the app open." } }, argv: ["notify", { arg: "message" }, { when: "urgent", flag: "--urgent" }] }] },
|
|
49
49
|
{ name: "ask", group: "session", scope: "session", json: true, usage: 'ask "<question>" [--option A --option B] [--async]', summary: "Ask the user a question and wait for the answer", subcommands: ["status", "wait"],
|
|
50
50
|
tools: [{ name: "ask_user", description: "Ask the user a question in the chat and wait for their answer (their phone gets a push). Offer 2-8 options, or none for a free-text answer; they can always write their own. Returns {status, answer}: status is answered, dismissed (decide yourself) or expired.", input: { question: { type: "string", description: "The question.", required: true }, options: { type: "array", items: { type: "string" }, description: "Choices to pick from (2-8)." }, multiple: { type: "boolean", description: "Allow several choices." }, header: { type: "string", description: "A short label for the card." }, timeout_seconds: { type: "number", description: "How long to wait (default 600)." } }, argv: ["ask", { arg: "question" }, { flag: "--option", from: "options" }, { when: "multiple", flag: "--multi" }, { flag: "--header", from: "header" }, { flag: "--timeout", from: "timeout_seconds" }] }] },
|
|
51
|
+
{ name: "title", group: "session", scope: "session", json: true, usage: 'title "<title>"', summary: "Rename this session",
|
|
52
|
+
tools: [{ name: "set_session_title", description: "Rename this session in the user's session list. Use a short title (under 60 characters) that says what the work is, when the first message made a poor title or the work changed direction.", input: { title: { type: "string", description: "The new title.", required: true } }, argv: ["title", { arg: "title" }] }] },
|
|
51
53
|
{ name: "suggest", group: "session", scope: "session", json: true, usage: 'suggest "<task>" [--title "label"] [--run here|subagents|new]', summary: "Propose a task the user can start in one tap",
|
|
52
54
|
tools: [{ name: "suggest_task", description: "Propose a next step the user can start in one tap: in this session, through your sub-agents, or in a parallel session with its own copy of the project. Write the task as a complete instruction with paths relative to the project root. Post one per idea instead of listing them.", input: { task: { type: "string", description: "The complete instruction.", required: true }, title: { type: "string", description: "A short label for the card." }, run: { type: "string", enum: ["here", "subagents", "new"], description: "Where you recommend running it: here (builds on this conversation), subagents (independent tasks you can split across your own sub-agents; only if you have them), or new (bigger independent work in its own session). Defaults: here for one card, new for several." } }, argv: ["suggest", { arg: "task" }, { flag: "--title", from: "title" }, { flag: "--run", from: "run" }] }] },
|
|
53
55
|
{ name: "app", group: "session", scope: "session", json: true, usage: "app <publish|shot|present|share|run|…>", summary: "Live previews: publish a web/terminal/desktop app, screenshot it, present it", subcommands: ["publish", "list", "remove", "shot", "present", "share", "notes", "run", "click", "type", "key", "scroll", "drag", "move", "menu"],
|
|
@@ -63,6 +65,7 @@ export const COMMANDS = [
|
|
|
63
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" },
|
|
64
66
|
{ name: "exec", group: "sessions", scope: "node", json: true, usage: 'exec "<prompt>" [--agent …] [--session …]', summary: "One-shot headless session: prints the answer to stdout" },
|
|
65
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" },
|
|
66
69
|
{ name: "sessions", aliases: ["ls"], group: "sessions", scope: "node", json: true, usage: "sessions [--json]", summary: "List recent sessions (live and saved) and resume one" },
|
|
67
70
|
{ name: "resume", group: "sessions", scope: "node", usage: "resume [n|id]", summary: "Resume a session directly (default: most recent)" },
|
|
68
71
|
{ name: "kill", group: "sessions", scope: "node", usage: "kill <id> [--delete]", summary: "Stop a session or terminal" },
|
|
@@ -41,6 +41,13 @@ instead of a bulleted list.
|
|
|
41
41
|
| `subagents` | Independent tasks you can split across your own sub-agents and report back on. Only if you have sub-agents. |
|
|
42
42
|
| `new` | Bigger independent work the user will want to follow, review or merge on its own. Default for several cards. |
|
|
43
43
|
|
|
44
|
+
## Name the session: title
|
|
45
|
+
|
|
46
|
+
bivy title "Fix login redirect loop"
|
|
47
|
+
|
|
48
|
+
The session list shows a title taken from the first message. When that doesn't
|
|
49
|
+
say what the work is, or the work changes direction, rename it. Keep it short.
|
|
50
|
+
|
|
44
51
|
## Is anyone there?
|
|
45
52
|
|
|
46
53
|
`bivy context --json` reports `userConnected` (some device has the app open) and
|
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
|
+
|