@hellix-ai/cubes 0.1.4 → 0.2.0

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.
@@ -0,0 +1,347 @@
1
+ // The left pane of the split view: every session, grouped and nested, with its state, filterable,
2
+ // and one keystroke (or click) from being open on the right.
3
+ import { makeApi } from "../api.js";
4
+ import { currentSession } from "../auth.js";
5
+ import { age, listSessions, LIVE } from "../sessions.js";
6
+ import { buildItems, statusOf, treePrefix } from "./model.js";
7
+ import { ESC, fit, parseKeys, printable, strWidth } from "./term.js";
8
+ import { fmtText, getSessionOption, selfCommand, setPaneOption, setSessionOption, SESSION, tmux } from "./tmux.js";
9
+ const POLL_MS = 3000;
10
+ const SELECTED_BG = 237;
11
+ const ACCENT = 62;
12
+ /** As many WHOLE key hints as fit, in order of how much they are needed - never one cut in half -
13
+ * and the way out always last. */
14
+ export function hints(width) {
15
+ const all = ["⏎ open", "l live", "m mine", "/ find", "^] back here", "←→ fold", "r refresh"];
16
+ const quit = "q quit";
17
+ let out = "";
18
+ for (const h of all) {
19
+ const next = out ? `${out} ${h}` : h;
20
+ if (strWidth(`${next} ${quit}`) > width)
21
+ break;
22
+ out = next;
23
+ }
24
+ return out ? `${out} ${quit}` : quit;
25
+ }
26
+ export async function runList() {
27
+ const session = currentSession();
28
+ const api = makeApi(session);
29
+ const out = process.stdout;
30
+ const st = {
31
+ rows: [], loaded: false, error: null, filters: { live: false, mine: false, text: "" }, searching: false,
32
+ expanded: new Map(), selectedId: null, top: 0, current: getSessionOption("@cubes_current"), flash: null,
33
+ };
34
+ let items = [];
35
+ let flashTimer = null;
36
+ const restore = () => out.write(ESC.mouseOff + ESC.showCursor + ESC.altOff);
37
+ process.on("exit", restore);
38
+ for (const sig of ["SIGTERM", "SIGHUP"])
39
+ process.on(sig, () => process.exit(0));
40
+ if (process.stdin.isTTY)
41
+ process.stdin.setRawMode(true);
42
+ out.write(ESC.altOn + ESC.hideCursor + ESC.mouseOn);
43
+ const sessionItems = () => items.filter((i) => i.kind === "session");
44
+ const selIndex = () => items.findIndex((i) => i.kind === "session" && i.row.id === st.selectedId);
45
+ const rebuild = () => {
46
+ items = buildItems(st.rows, st.filters, st.expanded);
47
+ if (selIndex() < 0) {
48
+ const first = sessionItems()[0];
49
+ st.selectedId = first ? first.row.id : null;
50
+ }
51
+ };
52
+ const flash = (msg) => {
53
+ st.flash = msg;
54
+ if (flashTimer)
55
+ clearTimeout(flashTimer);
56
+ flashTimer = setTimeout(() => { st.flash = null; render(); }, 2500);
57
+ render();
58
+ };
59
+ // ── rendering ──────────────────────────────────────────────────────────────────────────────────
60
+ const chip = (label, on) => on ? `${ESC.bg(ACCENT)}${ESC.fg(255)} ${label} ${ESC.reset}` : `${ESC.dim} ${label} ${ESC.reset}`;
61
+ const sessionLine = (it, w, selected) => {
62
+ const r = it.row;
63
+ const s = statusOf(r);
64
+ const mark = r.id === st.current ? `${ESC.fg(ACCENT)}▌${ESC.reset}` : " ";
65
+ const tree = treePrefix(it);
66
+ const exp = it.children > 0 ? (it.expanded ? "▾" : "▸") : " ";
67
+ const right = w >= 34 ? (LIVE.has(r.status) ? s.label : age(r.ended_at ?? r.created_at)) : "";
68
+ const fixed = 1 + strWidth(tree) + 1 + 2; // mark, tree, expander, glyph + space
69
+ const titleW = Math.max(4, w - fixed - (right ? strWidth(right) + 1 : 0));
70
+ const title = fit(printable(r.title || "(untitled)"), titleW);
71
+ const bg = selected ? ESC.bg(SELECTED_BG) : "";
72
+ const body = `${bg}${ESC.dim}${tree}${ESC.reset}${bg}${exp}${ESC.fg(s.color)}${s.glyph}${ESC.reset}${bg} ` +
73
+ `${s.attention ? ESC.bold : ""}${title}${ESC.reset}${bg}${right ? ` ${ESC.fg(s.color)}${right}` : ""}`;
74
+ return `${mark}${body}${ESC.reset}`;
75
+ };
76
+ function render() {
77
+ const w = out.columns ?? 40;
78
+ const h = out.rows ?? 20;
79
+ const viewH = Math.max(1, h - 2);
80
+ const lines = [];
81
+ // Line 1: the filters, and the count they leave.
82
+ const search = st.searching || st.filters.text
83
+ ? ` ${ESC.fg(ACCENT)}/${ESC.reset}${printable(st.filters.text)}${st.searching ? `${ESC.inverse} ${ESC.reset}` : ""}`
84
+ : ` ${ESC.dim}/ find${ESC.reset}`;
85
+ lines.push(`${chip("live", st.filters.live)} ${chip("mine", st.filters.mine)}${search}`);
86
+ // The list, scrolled to keep the selection in view.
87
+ const sel = selIndex();
88
+ if (sel >= 0) {
89
+ if (sel < st.top)
90
+ st.top = sel;
91
+ if (sel >= st.top + viewH)
92
+ st.top = sel - viewH + 1;
93
+ }
94
+ st.top = Math.max(0, Math.min(st.top, Math.max(0, items.length - viewH)));
95
+ for (let i = 0; i < viewH; i++) {
96
+ const it = items[st.top + i];
97
+ if (!it) {
98
+ lines.push("");
99
+ continue;
100
+ }
101
+ if (it.kind === "section") {
102
+ const label = ` ${it.title} (${it.count}) `;
103
+ lines.push(`${ESC.dim}──${ESC.reset}${ESC.bold}${fit(printable(label), Math.min(w - 2, strWidth(label)))}${ESC.reset}` +
104
+ `${ESC.dim}${"─".repeat(Math.max(0, w - 2 - strWidth(label)))}${ESC.reset}`);
105
+ }
106
+ else {
107
+ lines.push(sessionLine(it, w, it.row.id === st.selectedId));
108
+ }
109
+ }
110
+ if (!items.length) {
111
+ const msg = !st.loaded ? "loading sessions…" : st.error ? "" : "nothing matches - l / m / find narrow the list";
112
+ lines[2] = ` ${ESC.dim}${fit(msg, w - 2)}${ESC.reset}`;
113
+ }
114
+ // Last line: what went wrong, or what the keys do.
115
+ const foot = st.flash ?? (st.error ? `⚠ ${st.error}` : st.searching
116
+ ? "type to filter · ⏎ keep · esc clear"
117
+ : hints(w));
118
+ lines.push(`${st.error && !st.flash ? ESC.fg(203) : ESC.dim}${fit(foot, w)}${ESC.reset}`);
119
+ let frame = ESC.syncOn + ESC.home;
120
+ lines.forEach((l, i) => { frame += `${ESC.at(i + 1, 1)}\x1b[2K${l}`; });
121
+ out.write(frame + ESC.syncOff);
122
+ updateHeader();
123
+ }
124
+ let lastHeader = "";
125
+ function updateHeader() {
126
+ const live = st.rows.filter((r) => LIVE.has(r.status)).length;
127
+ const needs = st.rows.filter((r) => statusOf(r).attention).length;
128
+ const org = session.org ? ` · ${fmtText(printable(session.org))}` : "";
129
+ const h = `#[bold]Cubes#[nobold]${org} · ${live} live${needs ? ` · #[fg=colour214]${needs} need you#[default]` : ""}`;
130
+ if (h !== lastHeader) {
131
+ lastHeader = h;
132
+ setPaneOption("@cubes_header", h);
133
+ }
134
+ }
135
+ // ── actions ────────────────────────────────────────────────────────────────────────────────────
136
+ const move = (delta) => {
137
+ const list = sessionItems();
138
+ if (!list.length)
139
+ return;
140
+ const at = Math.max(0, list.findIndex((i) => i.row.id === st.selectedId));
141
+ const next = list[Math.max(0, Math.min(list.length - 1, at + delta))];
142
+ st.selectedId = next.row.id;
143
+ };
144
+ const open = (id) => {
145
+ const right = getSessionOption("@cubes_right");
146
+ if (!right) {
147
+ flash("the session pane is gone - run cubes again");
148
+ return;
149
+ }
150
+ const r = tmux(["respawn-pane", "-k", "-t", right, selfCommand(["_pane", id])]);
151
+ if (r.code !== 0) {
152
+ flash(`could not open it: ${r.err || r.code}`);
153
+ return;
154
+ }
155
+ tmux(["select-pane", "-t", right]);
156
+ st.current = id;
157
+ setSessionOption("@cubes_current", id);
158
+ };
159
+ const toggleFold = (id, open) => {
160
+ const it = sessionItems().find((i) => i.row.id === id);
161
+ if (!it || it.children === 0)
162
+ return false;
163
+ st.expanded.set(id, open ?? !it.expanded);
164
+ return true;
165
+ };
166
+ const goParent = () => {
167
+ const it = sessionItems().find((i) => i.row.id === st.selectedId);
168
+ if (it?.row.parent_session_id && items.some((i) => i.kind === "session" && i.row.id === it.row.parent_session_id)) {
169
+ st.selectedId = it.row.parent_session_id;
170
+ }
171
+ };
172
+ function onKey(k) {
173
+ if (st.searching) {
174
+ if (k.kind === "char")
175
+ st.filters.text += k.ch;
176
+ else if (k.kind === "backspace")
177
+ st.filters.text = [...st.filters.text].slice(0, -1).join("");
178
+ else if (k.kind === "enter" || k.kind === "down")
179
+ st.searching = false;
180
+ else if (k.kind === "escape" || k.kind === "ctrl-c") {
181
+ st.searching = false;
182
+ st.filters.text = "";
183
+ }
184
+ rebuild();
185
+ return;
186
+ }
187
+ const viewH = Math.max(1, (out.rows ?? 20) - 2);
188
+ switch (k.kind) {
189
+ case "up":
190
+ move(-1);
191
+ break;
192
+ case "down":
193
+ move(1);
194
+ break;
195
+ case "pgup":
196
+ move(-viewH);
197
+ break;
198
+ case "pgdn":
199
+ move(viewH);
200
+ break;
201
+ case "home":
202
+ move(-1e9);
203
+ break;
204
+ case "end":
205
+ move(1e9);
206
+ break;
207
+ case "enter":
208
+ if (st.selectedId)
209
+ open(st.selectedId);
210
+ break;
211
+ case "tab":
212
+ case "ctrl-]": {
213
+ const right = getSessionOption("@cubes_right");
214
+ if (right)
215
+ tmux(["select-pane", "-t", right]);
216
+ break;
217
+ }
218
+ case "right":
219
+ if (st.selectedId && !toggleFold(st.selectedId, true)) { /* a leaf: nothing to open */ }
220
+ break;
221
+ case "left":
222
+ if (!(st.selectedId && toggleFold(st.selectedId, false)))
223
+ goParent();
224
+ break;
225
+ case "escape":
226
+ if (st.filters.text) {
227
+ st.filters.text = "";
228
+ rebuild();
229
+ }
230
+ break;
231
+ case "ctrl-l":
232
+ out.write(ESC.clear);
233
+ break;
234
+ case "mouse":
235
+ onMouse(k);
236
+ break;
237
+ case "char":
238
+ switch (k.ch) {
239
+ case "j":
240
+ move(1);
241
+ break;
242
+ case "k":
243
+ move(-1);
244
+ break;
245
+ case "g":
246
+ move(-1e9);
247
+ break;
248
+ case "G":
249
+ move(1e9);
250
+ break;
251
+ case " ":
252
+ if (st.selectedId)
253
+ toggleFold(st.selectedId);
254
+ break;
255
+ case "l":
256
+ st.filters.live = !st.filters.live;
257
+ break;
258
+ case "m":
259
+ st.filters.mine = !st.filters.mine;
260
+ break;
261
+ case "/":
262
+ st.searching = true;
263
+ break;
264
+ case "r":
265
+ void refresh(true);
266
+ break;
267
+ case "d":
268
+ tmux(["detach-client", "-s", SESSION]);
269
+ break;
270
+ case "q":
271
+ tmux(["kill-session", "-t", SESSION]);
272
+ break;
273
+ }
274
+ break;
275
+ }
276
+ rebuild();
277
+ }
278
+ function onMouse(k) {
279
+ if (k.release)
280
+ return;
281
+ if (k.button === 64) {
282
+ move(-3);
283
+ return;
284
+ } // wheel up
285
+ if (k.button === 65) {
286
+ move(3);
287
+ return;
288
+ } // wheel down
289
+ if (k.button !== 0)
290
+ return;
291
+ if (k.y === 1) { // the filter line
292
+ if (k.x <= 6)
293
+ st.filters.live = !st.filters.live;
294
+ else if (k.x <= 13)
295
+ st.filters.mine = !st.filters.mine;
296
+ else
297
+ st.searching = true;
298
+ return;
299
+ }
300
+ const it = items[st.top + k.y - 2];
301
+ if (!it || it.kind !== "session")
302
+ return;
303
+ // A click on the fold arrow folds; anywhere else on the row opens it.
304
+ const arrowCol = 1 + strWidth(treePrefix(it)) + 1;
305
+ if (it.children > 0 && k.x === arrowCol) {
306
+ st.selectedId = it.row.id;
307
+ toggleFold(it.row.id);
308
+ return;
309
+ }
310
+ st.selectedId = it.row.id;
311
+ open(it.row.id);
312
+ }
313
+ // ── data ───────────────────────────────────────────────────────────────────────────────────────
314
+ let inFlight = false;
315
+ async function refresh(announce = false) {
316
+ if (inFlight)
317
+ return;
318
+ inFlight = true;
319
+ try {
320
+ st.rows = await listSessions(api, { all: true });
321
+ st.loaded = true;
322
+ st.error = null;
323
+ if (announce)
324
+ flash(`${st.rows.length} sessions`);
325
+ }
326
+ catch (e) {
327
+ st.error = e.message;
328
+ }
329
+ finally {
330
+ inFlight = false;
331
+ // The pane on the right says which session it holds; it can change without us (a reconnect).
332
+ st.current = getSessionOption("@cubes_current");
333
+ rebuild();
334
+ render();
335
+ }
336
+ }
337
+ process.stdin.on("data", (b) => {
338
+ for (const k of parseKeys(b.toString("utf8")))
339
+ onKey(k);
340
+ render();
341
+ });
342
+ out.on("resize", render);
343
+ render();
344
+ await refresh();
345
+ setInterval(() => void refresh(), POLL_MS);
346
+ return new Promise(() => { });
347
+ }
@@ -0,0 +1,117 @@
1
+ // What the split view's list shows: the sessions as a tree (a spawned session under the session that
2
+ // spawned it), inside the same sections `cubes ls` and the web use, narrowed by the filters - all pure,
3
+ // so the whole of it is testable without a terminal.
4
+ import { LIVE, sectionize } from "../sessions.js";
5
+ /** One status vocabulary for the list, the header and the tab: the server's lifecycle + activity. */
6
+ export function statusOf(r) {
7
+ switch (r.status) {
8
+ case "running":
9
+ if (r.activity_status === "working")
10
+ return { glyph: "●", color: 75, label: "working", attention: false };
11
+ if (r.activity_status === "waiting")
12
+ return { glyph: "●", color: 214, label: "needs you", attention: true };
13
+ return { glyph: "●", color: 78, label: "idle", attention: false };
14
+ case "starting": return { glyph: "◌", color: 80, label: "starting", attention: false };
15
+ case "stranded": return { glyph: "○", color: 203, label: "stranded", attention: false };
16
+ case "cancelled": return { glyph: "○", color: 242, label: "ended", attention: false };
17
+ default: return { glyph: "○", color: 242, label: r.status, attention: false };
18
+ }
19
+ }
20
+ export function matches(r, f) {
21
+ if (f.live && !LIVE.has(r.status))
22
+ return false;
23
+ if (f.mine && r.created_by_me !== true)
24
+ return false;
25
+ const q = f.text.trim().toLowerCase();
26
+ if (q && !(r.title ?? "").toLowerCase().includes(q) && !r.id.toLowerCase().startsWith(q)
27
+ && !(r.group_name ?? "").toLowerCase().includes(q))
28
+ return false;
29
+ return true;
30
+ }
31
+ const startOf = (r) => r.started_at ?? r.ended_at ?? r.created_at ?? "";
32
+ const order = (a, b) => {
33
+ const la = LIVE.has(a.row.status);
34
+ const lb = LIVE.has(b.row.status);
35
+ if (la !== lb)
36
+ return la ? -1 : 1;
37
+ return startOf(b.row).localeCompare(startOf(a.row));
38
+ };
39
+ /**
40
+ * Build the visible list.
41
+ *
42
+ * ⚠ A SPAWNED SESSION LIVES UNDER ITS PARENT, NOT IN ITS OWN GROUP. The tree is what says who started
43
+ * what; a child filed into a different section would lose its parent and read as a stray root. A
44
+ * child whose parent is not in the page (paged out, deleted) is shown as a root rather than hidden.
45
+ *
46
+ * ⚠ A FILTER KEEPS THE PATH TO A MATCH. A live child under an exited parent is still reachable with
47
+ * `live` on - the parent stays as the way to it, and it is drawn expanded so the match is visible.
48
+ *
49
+ * `expandedOverride` is what the person toggled; without one, a parent starts expanded when anything
50
+ * under it is live (the work in progress is the thing to see) and collapsed otherwise.
51
+ */
52
+ export function buildItems(rows, f, expandedOverride) {
53
+ const byId = new Map(rows.map((r) => [r.id, { row: r, kids: [] }]));
54
+ const roots = [];
55
+ for (const n of byId.values()) {
56
+ const parent = n.row.parent_session_id ? byId.get(n.row.parent_session_id) : undefined;
57
+ if (parent && parent !== n)
58
+ parent.kids.push(n);
59
+ else
60
+ roots.push(n);
61
+ }
62
+ const keep = new Map(); // node matches, or something under it does
63
+ const anyLive = new Map();
64
+ const visit = (n) => {
65
+ n.kids.sort(order);
66
+ let k = matches(n.row, f);
67
+ let live = LIVE.has(n.row.status);
68
+ for (const c of n.kids) {
69
+ visit(c);
70
+ if (keep.get(c.row.id))
71
+ k = true;
72
+ if (anyLive.get(c.row.id))
73
+ live = true;
74
+ }
75
+ keep.set(n.row.id, k);
76
+ anyLive.set(n.row.id, live);
77
+ };
78
+ roots.forEach(visit);
79
+ const filtering = f.live || f.mine || f.text.trim() !== "";
80
+ const visibleRoots = roots.filter((n) => keep.get(n.row.id)).map((n) => n.row);
81
+ const items = [];
82
+ const sections = sectionize(visibleRoots);
83
+ const bare = sections.length === 1 && sections[0].title === "Ungrouped";
84
+ for (const sec of sections) {
85
+ const nodes = sec.rows.map((r) => byId.get(r.id));
86
+ if (!bare)
87
+ items.push({ kind: "section", title: sec.title, count: countVisible(nodes, keep) });
88
+ const walk = (n, depth, lasts) => {
89
+ const kids = n.kids.filter((c) => keep.get(c.row.id));
90
+ const kidMatch = filtering && kids.length > 0;
91
+ const expanded = expandedOverride.get(n.row.id) ?? (kidMatch || n.kids.some((c) => anyLive.get(c.row.id) === true));
92
+ items.push({ kind: "session", row: n.row, depth, lasts, children: kids.length, expanded: expanded && kids.length > 0 });
93
+ if (expanded)
94
+ kids.forEach((c, i) => walk(c, depth + 1, [...lasts, i === kids.length - 1]));
95
+ };
96
+ nodes.forEach((n) => walk(n, 0, []));
97
+ }
98
+ return items;
99
+ }
100
+ function countVisible(nodes, keep) {
101
+ let c = 0;
102
+ const go = (n) => { if (keep.get(n.row.id)) {
103
+ c++;
104
+ n.kids.forEach(go);
105
+ } };
106
+ nodes.forEach(go);
107
+ return c;
108
+ }
109
+ /** The tree guide in front of a child: `│ ` / blank per ancestor level, then `├─`/`└─`. */
110
+ export function treePrefix(item) {
111
+ if (item.depth === 0)
112
+ return "";
113
+ let s = "";
114
+ for (let i = 0; i < item.lasts.length - 1; i++)
115
+ s += item.lasts[i] ? " " : "│ ";
116
+ return s + (item.lasts[item.lasts.length - 1] ? "└─" : "├─");
117
+ }
@@ -0,0 +1,156 @@
1
+ // The right pane of the split view: the session itself, under a header (tmux's pane border) that says
2
+ // which one it is - name, group, runner, state - and stays current while you work in it.
3
+ //
4
+ // The list replaces this process (`respawn-pane -k`) to open another session, so it only ever holds
5
+ // one. When an attach ends it SAYS so and waits: there is no automatic reconnect, by decision ("never
6
+ // keep retrying to reattach a session") - `r` reconnects when the person chooses to.
7
+ import { makeApi } from "../api.js";
8
+ import { attach } from "../attach.js";
9
+ import { currentSession } from "../auth.js";
10
+ import { readClipboardImage } from "../clipboard.js";
11
+ import { CubeSpinner, cubeSize } from "../cube.js";
12
+ import { ensureRunning } from "../resume.js";
13
+ import { statusOf } from "./model.js";
14
+ import { ESC, fit, parseKeys, printable, strWidth } from "./term.js";
15
+ import { fmtText, getSessionOption, setPaneOption, setSessionOption, tmux } from "./tmux.js";
16
+ const out = process.stdout;
17
+ const focusList = () => {
18
+ const left = getSessionOption("@cubes_left");
19
+ if (left)
20
+ tmux(["select-pane", "-t", left]);
21
+ };
22
+ /** The header: `● Title · Group · runner · state`, as a tmux format (styles via #[...]). */
23
+ export function headerFor(r, override) {
24
+ const s = override ?? statusOf({ status: r.status ?? "running", activity_status: r.activity_status ?? null });
25
+ const parts = [
26
+ `#[fg=colour${s.color}]${s.glyph}#[default] #[bold]${fmtText(printable(r.title || r.id.slice(0, 8)))}#[nobold]`,
27
+ fmtText(printable(r.group_name || r.package_name || "no group")),
28
+ fmtText(printable(r.runner_name || "-")),
29
+ `#[fg=colour${s.color}]${s.label}#[default]`,
30
+ ];
31
+ return parts.join(" #[fg=colour240]·#[default] ");
32
+ }
33
+ function setHeader(r, override) {
34
+ setPaneOption("@cubes_header", headerFor(r, override));
35
+ const s = override ?? statusOf({ status: r.status ?? "running", activity_status: r.activity_status ?? null });
36
+ setSessionOption("@cubes_tab", `${s.glyph} ${printable(r.title || "Cubes session")}`);
37
+ }
38
+ /** Read keys until one is accepted. Raw mode on for as long as we wait. */
39
+ function waitKey(accept) {
40
+ return new Promise((resolve) => {
41
+ if (process.stdin.isTTY)
42
+ process.stdin.setRawMode(true);
43
+ const onData = (b) => {
44
+ for (const k of parseKeys(b.toString("utf8"))) {
45
+ if (!accept(k))
46
+ continue;
47
+ process.stdin.off("data", onData);
48
+ process.stdin.pause();
49
+ resolve(k);
50
+ return;
51
+ }
52
+ };
53
+ process.stdin.on("data", onData);
54
+ process.stdin.resume();
55
+ });
56
+ }
57
+ /** A few centred lines, nothing else on screen. */
58
+ function message(lines) {
59
+ const w = out.columns ?? 80;
60
+ const h = out.rows ?? 24;
61
+ const top = Math.max(0, Math.floor((h - lines.length) / 2));
62
+ let s = ESC.hideCursor + ESC.home + ESC.clear;
63
+ lines.forEach((l, i) => {
64
+ const t = fit(l.text, Math.min(w - 2, strWidth(l.text)));
65
+ const col = Math.max(1, Math.floor((w - strWidth(t)) / 2) + 1);
66
+ s += `${ESC.at(top + i + 1, col)}${l.style ?? ""}${t}${ESC.reset}`;
67
+ });
68
+ out.write(s);
69
+ }
70
+ /** Where to put the cube so it sits in the middle of this pane. */
71
+ function cubePlacement() {
72
+ const w = out.columns ?? 80;
73
+ const h = out.rows ?? 24;
74
+ const size = cubeSize(w, h);
75
+ if (!size)
76
+ return { indent: 2, top: 0 };
77
+ return { indent: Math.max(2, Math.floor((w - size.w) / 2)), top: Math.max(0, Math.floor((h - size.h - 2) / 2)) };
78
+ }
79
+ async function welcome() {
80
+ setSessionOption("@cubes_current", "");
81
+ setSessionOption("@cubes_tab", "Cubes");
82
+ setPaneOption("@cubes_header", "#[bold]Cubes#[nobold] #[fg=colour240]·#[default] pick a session on the left");
83
+ let spin = null;
84
+ const draw = () => {
85
+ spin?.stop();
86
+ const { indent, top } = cubePlacement();
87
+ out.write(ESC.hideCursor + ESC.home + ESC.clear + "\n".repeat(top));
88
+ spin = new CubeSpinner(out, "Pick a session on the left", process.env, indent);
89
+ };
90
+ draw();
91
+ out.on("resize", draw);
92
+ for (;;) {
93
+ await waitKey((k) => k.kind === "ctrl-]" || k.kind === "escape" || k.kind === "tab");
94
+ focusList();
95
+ }
96
+ }
97
+ const OUTCOME_WORDS = {
98
+ ended: { head: "Session ended", key: "resume it", color: 242 },
99
+ disconnected: { head: "Disconnected", key: "reconnect", color: 214 },
100
+ refused: { head: "The server refused the attach", key: "try again", color: 203 },
101
+ detached: { head: "Detached", key: "attach again", color: 242 },
102
+ };
103
+ async function sessionPane(id) {
104
+ const api = makeApi(currentSession());
105
+ setSessionOption("@cubes_current", id);
106
+ let row = { id, title: null, status: "running", created_at: "" };
107
+ setHeader(row, { glyph: "◌", color: 80, label: "connecting" });
108
+ for (;;) {
109
+ let result;
110
+ try {
111
+ row = await api.get(`/sessions/${encodeURIComponent(id)}`);
112
+ setHeader(row);
113
+ const { indent, top } = cubePlacement();
114
+ out.write(ESC.home + ESC.clear + "\n".repeat(top));
115
+ row = await ensureRunning(api, row, true, out, indent);
116
+ setHeader(row);
117
+ result = await attach({
118
+ api,
119
+ host: currentSession().host,
120
+ openWs: (url, protocols) => new WebSocket(url, protocols),
121
+ stdin: process.stdin,
122
+ stdout: out,
123
+ stderr: out,
124
+ readClipboardImage: () => readClipboardImage(),
125
+ }, row, {
126
+ onDetachKey: focusList,
127
+ status: { onRow: (r) => { row = { ...row, ...r }; setHeader(row); } },
128
+ });
129
+ }
130
+ catch (e) {
131
+ result = { code: 1, message: e.message, outcome: "disconnected" };
132
+ }
133
+ const words = OUTCOME_WORDS[result.outcome];
134
+ setHeader(row, { glyph: "○", color: words.color, label: words.head.toLowerCase() });
135
+ message([
136
+ { text: words.head, style: ESC.bold + ESC.fg(words.color) },
137
+ { text: result.message, style: ESC.dim },
138
+ { text: "" },
139
+ { text: `r ${words.key} ^] back to the list` },
140
+ ]);
141
+ // Wait for the person: `r` goes round again, ^] / Esc / Tab hand focus back to the list.
142
+ for (;;) {
143
+ const k = await waitKey((k) => (k.kind === "char" && (k.ch === "r" || k.ch === "R")) || k.kind === "enter"
144
+ || k.kind === "ctrl-]" || k.kind === "escape" || k.kind === "tab");
145
+ if (k.kind === "char" || k.kind === "enter")
146
+ break;
147
+ focusList();
148
+ }
149
+ }
150
+ }
151
+ export async function runPane(id) {
152
+ for (const sig of ["SIGTERM", "SIGHUP"])
153
+ process.on(sig, () => process.exit(0));
154
+ process.on("exit", () => out.write(ESC.showCursor));
155
+ return id ? sessionPane(id) : welcome();
156
+ }