@hellix-ai/cubes 0.1.5 → 0.2.1

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.
@@ -7,7 +7,7 @@
7
7
  // working / ready / waiting-with-a-reason), in the three dialects terminals understand:
8
8
  //
9
9
  // • OSC 0 TITLE - every terminal. `<glyph> <session title>`, the convention Claude Code itself uses:
10
- // a spinner while working, ✳ when idle, ✋ when it is waiting for you.
10
+ // ● while working, ✳ when idle, ✋ when it is waiting for you. Static: an animated glyph churns the tab.
11
11
  // • BEL + OSC 9 - the generic "this tab wants you": a bell marks the tab / bounces the dock almost
12
12
  // everywhere, and OSC 9 raises a desktop notification in iTerm2, Ghostty, WezTerm, kitty, Warp.
13
13
  // • OSC 777 `warp://cli-agent` - Warp's structured agent protocol, the same events and payload as
@@ -19,7 +19,7 @@
19
19
  // would corrupt both. `StreamFilter` tracks where the stream is, and the caller only writes ours when
20
20
  // the stream is between sequences.
21
21
  export const WARP_PLUGIN_COMPAT = "2.3.0"; // the claude-code-warp release whose event set this mirrors
22
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
+ const WORKING = "●";
23
23
  const IDLE = "✳";
24
24
  const WAITING = "✋";
25
25
  /** Title push / pop (xterm window ops): our title is set on top of yours and yours comes back after. */
@@ -30,6 +30,22 @@ export function clean(s, max = 120) {
30
30
  const t = s.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
31
31
  return t.length > max ? t.slice(0, max - 1) + "…" : t;
32
32
  }
33
+ /** Largest OSC 52 we will hold (base64): a copy, not a file transfer. Past it the copy is dropped. */
34
+ const CLIP_MAX = 4 * 1024 * 1024;
35
+ /** The text an OSC 52 body (`c;<base64>`) carries, or null for a READ request (`?`), which we never
36
+ * answer - a session asking for the person's clipboard gets nothing. */
37
+ export function decodeOsc52(body) {
38
+ const semi = body.indexOf(";");
39
+ const data = semi >= 0 ? body.slice(semi + 1) : body;
40
+ if (!data || data === "?")
41
+ return null;
42
+ try {
43
+ return Buffer.from(data, "base64").toString("utf8");
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
33
49
  /**
34
50
  * Passes the session's output through, minus the two things that would fight us for the title -
35
51
  * OSC 0/1/2 (set title) and CSI 22/23 t (title push/pop, which the session's tmux sends on attach and
@@ -37,10 +53,20 @@ export function clean(s, max = 120) {
37
53
  * safe to write a sequence of our own.
38
54
  */
39
55
  export class StreamFilter {
56
+ onClipboard;
40
57
  st = "ground";
41
58
  seq = []; // the CSI or OSC-prefix being decided
42
59
  utf8Left = 0;
43
60
  oscEsc = false; // saw ESC inside a string, waiting for '\'
61
+ clip = [];
62
+ /**
63
+ * `onClipboard`: when given, an OSC 52 (set clipboard) is taken OUT of the stream and its text
64
+ * handed over instead - the CLI puts it on the person's clipboard itself, which works whether or not
65
+ * their terminal honours OSC 52. Without it, OSC 52 passes through like any other OSC.
66
+ */
67
+ constructor(onClipboard) {
68
+ this.onClipboard = onClipboard;
69
+ }
44
70
  get safe() {
45
71
  return this.st === "ground" && this.utf8Left === 0;
46
72
  }
@@ -112,10 +138,12 @@ export class StreamFilter {
112
138
  const ends = b === 0x07 || b === 0x1b;
113
139
  if (b === 0x3b || ends || this.seq.length > 8) {
114
140
  const num = String.fromCharCode(...this.seq.slice(2));
115
- const title = /^[012]$/.test(num);
141
+ const clip = num === "52" && !!this.onClipboard;
142
+ const title = /^[012]$/.test(num) || clip; // both are ours, neither reaches the terminal
116
143
  if (!title)
117
144
  out.push(...this.seq);
118
145
  this.seq = [];
146
+ this.clip = [];
119
147
  if (ends) {
120
148
  if (b === 0x07) {
121
149
  if (!title)
@@ -125,14 +153,14 @@ export class StreamFilter {
125
153
  else {
126
154
  if (!title)
127
155
  out.push(b);
128
- this.st = title ? "oscDrop" : "str";
156
+ this.st = clip ? "oscClip" : title ? "oscDrop" : "str";
129
157
  this.oscEsc = true;
130
158
  }
131
159
  }
132
160
  else {
133
161
  if (!title)
134
162
  out.push(b);
135
- this.st = title ? "oscDrop" : "str";
163
+ this.st = clip ? "oscClip" : title ? "oscDrop" : "str";
136
164
  this.oscEsc = false;
137
165
  }
138
166
  }
@@ -141,6 +169,20 @@ export class StreamFilter {
141
169
  }
142
170
  break;
143
171
  }
172
+ case "oscClip": {
173
+ if (this.oscEsc && b === 0x5c) {
174
+ this.endClip();
175
+ break;
176
+ } // ST = ESC \
177
+ if (b === 0x07) {
178
+ this.endClip();
179
+ break;
180
+ }
181
+ this.oscEsc = b === 0x1b;
182
+ if (!this.oscEsc && this.clip.length < CLIP_MAX)
183
+ this.clip.push(b);
184
+ break;
185
+ }
144
186
  case "oscDrop":
145
187
  case "str": {
146
188
  const keep = this.st === "str";
@@ -160,6 +202,16 @@ export class StreamFilter {
160
202
  }
161
203
  return Uint8Array.from(out);
162
204
  }
205
+ endClip() {
206
+ this.st = "ground";
207
+ this.oscEsc = false;
208
+ // Buffer, not String.fromCharCode(...bytes): a spread of a large copy overflows the argument limit.
209
+ const body = this.clip.length < CLIP_MAX ? Buffer.from(this.clip).toString("latin1") : "";
210
+ this.clip = [];
211
+ const text = body ? decodeOsc52(body) : null;
212
+ if (text)
213
+ this.onClipboard?.(text);
214
+ }
163
215
  }
164
216
  function stateOf(r) {
165
217
  if (r.status !== "running")
@@ -192,20 +244,30 @@ export function warpProtocol(env) {
192
244
  export class TermStatus {
193
245
  emit;
194
246
  state = null;
195
- frame = 0;
196
247
  shown = "";
197
248
  row;
198
249
  warp;
250
+ inTmux;
199
251
  constructor(row, emit, env = process.env) {
200
252
  this.emit = emit;
201
253
  this.row = row;
202
254
  this.warp = warpProtocol(env);
255
+ this.inTmux = !!env.TMUX;
203
256
  }
204
257
  title() {
205
- const glyph = this.state === "working" ? SPINNER[this.frame % SPINNER.length]
206
- : this.state === "waiting" ? WAITING : IDLE;
258
+ const glyph = this.state === "working" ? WORKING : this.state === "waiting" ? WAITING : IDLE;
207
259
  return `${glyph} ${clean(this.row.title || "Cubes session", 80)}`;
208
260
  }
261
+ flashTimer = null;
262
+ /** A moment's word in the tab title ("✓ Copied 42 characters"), then the title it had. */
263
+ flash(text, ms = 1500) {
264
+ if (this.flashTimer)
265
+ clearTimeout(this.flashTimer);
266
+ this.emit(`\x1b]0;${clean(text, 80)}\x07`);
267
+ this.shown = "";
268
+ this.flashTimer = setTimeout(() => { this.flashTimer = null; this.setTitle(); }, ms);
269
+ this.flashTimer.unref?.();
270
+ }
209
271
  /** Only when it changed: the activity is re-read every few seconds, and most reads change nothing. */
210
272
  setTitle() {
211
273
  const t = this.title();
@@ -214,6 +276,15 @@ export class TermStatus {
214
276
  this.shown = t;
215
277
  this.emit(`\x1b]0;${t}\x07`);
216
278
  }
279
+ /**
280
+ * Notifications (OSC 9, OSC 777) are for the terminal the PERSON sees. Inside tmux - the split view
281
+ * runs everything in one - tmux would swallow them, so they travel as tmux's DCS passthrough (every
282
+ * ESC doubled), which reaches the outer terminal when `allow-passthrough` is on (the split view turns
283
+ * it on). The bell needs none of this: tmux forwards a bell by itself.
284
+ */
285
+ outward(seq) {
286
+ return this.inTmux ? `\x1bPtmux;${seq.replace(/\x1b/g, "\x1b\x1b")}\x1b\\` : seq;
287
+ }
217
288
  warpEvent(event, extra = {}) {
218
289
  if (this.warp === null)
219
290
  return;
@@ -221,7 +292,7 @@ export class TermStatus {
221
292
  v: this.warp, agent: "claude", event, session_id: this.row.id, cwd: "",
222
293
  project: clean(this.row.title || "cubes", 60), ...extra,
223
294
  });
224
- this.emit(`\x1b]777;notify;warp://cli-agent;${body}\x07`);
295
+ this.emit(this.outward(`\x1b]777;notify;warp://cli-agent;${body}\x07`));
225
296
  }
226
297
  /** Feed the latest row. The first call only establishes the state - nobody needs to be told that a
227
298
  * session they are attaching to right now is waiting for them. */
@@ -260,16 +331,8 @@ export class TermStatus {
260
331
  else if (!row.muted_at) {
261
332
  // Bell first (marks the tab nearly everywhere), then the desktop notification where supported.
262
333
  // "Cubes" leads the text so it can never be read as ConEmu's numeric OSC 9 sub-commands.
263
- this.emit(`\x07\x1b]9;Cubes · ${clean(row.title || "session", 60)}: ${summary}\x07`);
334
+ this.emit(`\x07${this.outward(`\x1b]9;Cubes · ${clean(row.title || "session", 60)}: ${summary}\x07`)}`);
264
335
  }
265
336
  }
266
337
  }
267
- /** Advance the working spinner. Returns false when there is nothing to animate. */
268
- tick() {
269
- if (this.state !== "working")
270
- return false;
271
- this.frame++;
272
- this.setTitle();
273
- return true;
274
- }
275
338
  }
@@ -0,0 +1,98 @@
1
+ // `cubes` with no arguments: the split view. A local tmux on its own socket holds two panes - the
2
+ // session list on the left, the session on the right - so layout, resizing, mouse focus and the
3
+ // terminal emulation of the right pane are tmux's, which is the battle-tested part. Our two processes
4
+ // (`cubes _list`, `cubes _pane`) only draw and talk to the API.
5
+ //
6
+ // Run it again and you are back where you were: the tmux outlives the terminal that opened it.
7
+ import { spawnSync } from "node:child_process";
8
+ import { CliError, currentSession } from "../auth.js";
9
+ import { selfCommand, SESSION, SOCKET, tmux, tmuxVersion } from "./tmux.js";
10
+ const MIN_TMUX = 3.2;
11
+ /** Every option is set on OUR socket only, so none of this reaches a tmux the person already uses. */
12
+ const OPTIONS = [
13
+ ["mouse", "on"],
14
+ ["status", "off"],
15
+ // ⚠ NO PREFIX. tmux's Ctrl-B is Claude Code's "run in background", and the session's own tmux (the
16
+ // one in the pod) has none either, so nothing here may eat a keystroke. Focus moves by mouse, by
17
+ // Ctrl-] from the session pane, and by Enter/Tab from the list.
18
+ ["prefix", "None"],
19
+ ["prefix2", "None"],
20
+ // Escape must reach Claude Code at once: tmux's default 500 ms makes every Esc feel broken.
21
+ ["escape-time", "10"],
22
+ ["focus-events", "on"],
23
+ ["pane-border-status", "top"],
24
+ ["pane-border-format", " #{?#{@cubes_header},#{@cubes_header},#{pane_title}} "],
25
+ ["pane-border-style", "fg=colour238"],
26
+ ["pane-active-border-style", "fg=colour62"],
27
+ // The outer terminal's tab follows the session on the right, whichever pane has focus.
28
+ ["set-titles", "on"],
29
+ ["set-titles-string", "#{?#{@cubes_tab},#{@cubes_tab},Cubes}"],
30
+ // So a session's bell and notifications reach the terminal the person is looking at.
31
+ ["bell-action", "any"],
32
+ ["visual-bell", "off"],
33
+ ["allow-passthrough", "on"],
34
+ ];
35
+ function attachClient() {
36
+ const env = { ...process.env };
37
+ delete env.TMUX; // inside the person's own tmux: nest on purpose rather than refuse
38
+ const r = spawnSync("tmux", ["-L", SOCKET, "attach-session", "-t", SESSION], { stdio: "inherit", env });
39
+ return r.status ?? 0;
40
+ }
41
+ function build(org) {
42
+ const cols = process.stdout.columns ?? 160;
43
+ const rows = process.stdout.rows ?? 48;
44
+ const env = org ? ["-e", `CUBES_ORG=${org}`] : [];
45
+ const created = tmux(["new-session", "-d", "-s", SESSION, "-x", String(cols), "-y", String(rows), ...env,
46
+ "-P", "-F", "#{pane_id}", selfCommand(["_list"])]);
47
+ if (created.code !== 0)
48
+ throw new CliError(`could not start tmux: ${created.err || created.code}`);
49
+ const left = created.out;
50
+ for (const [k, v] of OPTIONS)
51
+ tmux(["set-option", "-g", "-q", k, v]);
52
+ // ⚠ The same fix the session's own tmux carries (server Tmux.clientDefaults): without `ech`, tmux
53
+ // draws gaps as real spaces. Warp left stale letters in every gap drawn with ECH. A SERVER option,
54
+ // read when a client attaches, so it is set before ours does.
55
+ tmux(["set-option", "-s", "terminal-overrides[90]", "*:ech@"]);
56
+ tmux(["set-option", "-w", "-t", SESSION, "-q", "remain-on-exit", "on"]);
57
+ // The list gets about a third, within reason: enough for a title, never most of the screen.
58
+ const listW = Math.max(32, Math.min(56, Math.round(cols * 0.3)));
59
+ const right = tmux(["split-window", "-h", "-t", left, "-l", String(Math.max(20, cols - listW - 1)), ...env,
60
+ "-P", "-F", "#{pane_id}", selfCommand(["_pane"])]);
61
+ if (right.code !== 0)
62
+ throw new CliError(`could not split the window: ${right.err || right.code}`);
63
+ tmux(["set-option", "-t", SESSION, "@cubes_left", left]);
64
+ tmux(["set-option", "-t", SESSION, "@cubes_right", right.out]);
65
+ tmux(["select-pane", "-t", left]);
66
+ }
67
+ /**
68
+ * Bring a dead pane back. `remain-on-exit` keeps a crashed pane on screen (with what it printed)
69
+ * instead of letting the layout collapse; reopening `cubes` restarts whichever of the two died.
70
+ */
71
+ function heal() {
72
+ const panes = tmux(["list-panes", "-t", SESSION, "-F", "#{pane_id} #{pane_dead}"]).out.split("\n");
73
+ const left = tmux(["show-options", "-v", "-q", "-t", SESSION, "@cubes_left"]).out;
74
+ for (const line of panes) {
75
+ const [id, dead] = line.split(" ");
76
+ if (dead !== "1" || !id)
77
+ continue;
78
+ tmux(["respawn-pane", "-k", "-t", id, selfCommand(id === left ? ["_list"] : ["_pane"])]);
79
+ }
80
+ }
81
+ export async function launchTui(opts) {
82
+ const v = tmuxVersion();
83
+ if (v === null) {
84
+ throw new CliError("the split view runs inside tmux, which is not installed - `brew install tmux` (macOS) or "
85
+ + "`apt install tmux`. `cubes attach` works without it.");
86
+ }
87
+ if (v < MIN_TMUX)
88
+ throw new CliError(`the split view needs tmux ${MIN_TMUX} or newer (this is ${v}) - upgrade it, or use \`cubes attach\``);
89
+ // Fail here, with a sentence, rather than in two panes at once.
90
+ await currentSession({ org: opts.org }).token();
91
+ if (tmux(["has-session", "-t", SESSION]).code === 0) {
92
+ heal();
93
+ tmux(["set-option", "-s", "terminal-overrides[90]", "*:ech@"]); // a layout built by an older cubes
94
+ }
95
+ else
96
+ build(opts.org);
97
+ return attachClient();
98
+ }
@@ -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
+ }