@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.
- package/README.md +22 -3
- package/dist/attach.js +47 -68
- package/dist/cli.js +68 -56
- package/dist/clipboard.js +32 -1
- package/dist/cube.js +7 -3
- package/dist/resume.js +47 -0
- package/dist/sessions.js +16 -5
- package/dist/termstatus.js +81 -18
- package/dist/tui/launch.js +98 -0
- package/dist/tui/listApp.js +347 -0
- package/dist/tui/model.js +117 -0
- package/dist/tui/paneApp.js +162 -0
- package/dist/tui/term.js +133 -0
- package/dist/tui/tmux.js +49 -0
- package/package.json +1 -1
package/dist/termstatus.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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" ?
|
|
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
|
|
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
|
+
}
|