@hellix-ai/cubes 0.2.0 → 0.2.2
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 +14 -2
- package/dist/attach.js +16 -7
- package/dist/cli.js +14 -4
- package/dist/clipboard.js +32 -1
- package/dist/keys.js +7 -6
- package/dist/termstatus.js +65 -3
- package/dist/tui/launch.js +39 -5
- package/dist/tui/listApp.js +18 -8
- package/dist/tui/paneApp.js +16 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,8 +23,15 @@ use is never touched), so close the terminal and run `cubes` again to land right
|
|
|
23
23
|
- **The list** is grouped like the web's Sessions page, with a session's spawned sessions nested under it
|
|
24
24
|
and a coloured dot for its state: working, idle, **needs you**, starting, ended.
|
|
25
25
|
- **Above the session**, a header says which one it is: name · group · runner · state.
|
|
26
|
-
- **
|
|
27
|
-
|
|
26
|
+
- **Browsing:** `⏎` (or a click) shows a session on the right and leaves you in the list, so `↑` `↓` and
|
|
27
|
+
`⏎` again show the next one.
|
|
28
|
+
- **`F1` switches** between the list and the session, from either side. It is the one single key Claude
|
|
29
|
+
Code does not use (in a session every other key, `Ctrl-]` included, goes to Claude). On a MacBook the
|
|
30
|
+
top row sends it with `fn` held, unless *Use F1, F2, etc. keys as standard function keys* is on. From
|
|
31
|
+
the list, `⏎` on the session already showing (or `Tab`) goes in too, and a click on either side moves
|
|
32
|
+
you there.
|
|
33
|
+
- **Other keys:** `l` live only · `m` mine only · `/` find · `←` `→` fold a session's children · `q` quits
|
|
34
|
+
(sessions keep running).
|
|
28
35
|
|
|
29
36
|
## While attached
|
|
30
37
|
|
|
@@ -35,6 +42,11 @@ Every key goes to the session, exactly as if Claude Code were running locally, e
|
|
|
35
42
|
| `Ctrl-]` | detach. The session keeps running. |
|
|
36
43
|
| `Ctrl-V` | paste the image on your clipboard: it is uploaded to the session and its path typed in (macOS, and Linux with `wl-paste` or `xclip`). With no image on the clipboard, Ctrl-V goes through unchanged. |
|
|
37
44
|
|
|
45
|
+
**Copying.** Drag over text in a session and it is copied the moment you let go - the session's tmux
|
|
46
|
+
does the selecting (so there is no selection left for Cmd+C), and the CLI puts the text on your
|
|
47
|
+
clipboard. Claude Code's own copy (the `c` on its login URL) lands there too. On Linux this needs
|
|
48
|
+
`wl-copy`, `xclip` or `xsel`.
|
|
49
|
+
|
|
38
50
|
**One keyboard per session.** Attaching takes the keyboard from any browser tab open on the same session
|
|
39
51
|
(that tab becomes read-only, with a Take over button) and fits the session to your window. If someone
|
|
40
52
|
takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
|
package/dist/attach.js
CHANGED
|
@@ -46,7 +46,7 @@ export function wsUrlFor(host, id) {
|
|
|
46
46
|
}
|
|
47
47
|
export function attach(deps, session, opts = {}) {
|
|
48
48
|
const { api, stdin, stdout, stderr } = deps;
|
|
49
|
-
const filter = new InputFilter({ pasteImage: !opts.view });
|
|
49
|
+
const filter = new InputFilter({ pasteImage: !opts.view, detach: opts.detachKey !== false });
|
|
50
50
|
const enc = new TextEncoder();
|
|
51
51
|
return new Promise((resolve) => {
|
|
52
52
|
let ws = null;
|
|
@@ -64,7 +64,20 @@ export function attach(deps, session, opts = {}) {
|
|
|
64
64
|
let pasting = false;
|
|
65
65
|
// Our own sequences (title, bell, notifications) wait here until the session's stream is between
|
|
66
66
|
// escape sequences - see termstatus.ts. A fresh filter per connection: a replay starts clean.
|
|
67
|
-
|
|
67
|
+
// ⚠ ONLY THE KEYBOARD COPIES. The session's output reaches every viewer, so a `c` pressed in a
|
|
68
|
+
// browser tab on the same session would otherwise overwrite THIS person's clipboard too. The web
|
|
69
|
+
// terminal draws the same line (Terminal.tsx, its OSC 52 handler).
|
|
70
|
+
const onClipboard = deps.clipboard ? (text) => {
|
|
71
|
+
if (role !== "writer")
|
|
72
|
+
return;
|
|
73
|
+
void deps.clipboard.write(text).then((ok) => {
|
|
74
|
+
if (!ok || done)
|
|
75
|
+
return;
|
|
76
|
+
status?.flash(`✓ Copied ${[...text].length} characters`);
|
|
77
|
+
opts.onCopied?.(text);
|
|
78
|
+
});
|
|
79
|
+
} : undefined;
|
|
80
|
+
let stream = new StreamFilter(onClipboard);
|
|
68
81
|
let injections = [];
|
|
69
82
|
let status = null;
|
|
70
83
|
let pollTimer = null;
|
|
@@ -205,10 +218,6 @@ export function attach(deps, session, opts = {}) {
|
|
|
205
218
|
function onData(chunk) {
|
|
206
219
|
for (const a of filter.feed(chunk)) {
|
|
207
220
|
if (a.kind === "detach") {
|
|
208
|
-
if (opts.onDetachKey) {
|
|
209
|
-
opts.onDetachKey();
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
221
|
finish(0, "detached", "detached");
|
|
213
222
|
return;
|
|
214
223
|
}
|
|
@@ -264,7 +273,7 @@ export function attach(deps, session, opts = {}) {
|
|
|
264
273
|
const sock = deps.openWs(wsUrlFor(deps.host, session.id), [TTY_SUBPROTOCOL, ticket]);
|
|
265
274
|
sock.binaryType = "arraybuffer";
|
|
266
275
|
ws = sock;
|
|
267
|
-
stream = new StreamFilter();
|
|
276
|
+
stream = new StreamFilter(onClipboard);
|
|
268
277
|
askedTakeover = false;
|
|
269
278
|
takeoverPending = false;
|
|
270
279
|
sock.onopen = () => {
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
10
10
|
import { makeApi } from "./api.js";
|
|
11
11
|
import { attach, RESTORE_TERMINAL } from "./attach.js";
|
|
12
12
|
import { CliError, currentSession, discover, loginFromTokens, normalizeHost, orgsOf, pollDevice, revoke, startDevice, whoOf, } from "./auth.js";
|
|
13
|
-
import { readClipboardImage } from "./clipboard.js";
|
|
13
|
+
import { clipboardWriter, readClipboardImage, writeClipboard } from "./clipboard.js";
|
|
14
14
|
import { CubeSpinner } from "./cube.js";
|
|
15
15
|
import { ensureRunning } from "./resume.js";
|
|
16
16
|
import { launchTui } from "./tui/launch.js";
|
|
@@ -44,10 +44,14 @@ While attached, every key goes to the session except:
|
|
|
44
44
|
Ctrl-] detach (the session keeps running)
|
|
45
45
|
Ctrl-V paste the image on your clipboard (uploaded, then its path is typed in)
|
|
46
46
|
|
|
47
|
+
To copy, drag over text: it is on your clipboard the moment you let go (the session's tmux does the
|
|
48
|
+
selecting). Claude Code's own copy, like the c on its login URL, lands there too.
|
|
49
|
+
|
|
47
50
|
If the connection drops, nothing reconnects on its own: you are told why, and r reconnects.
|
|
48
51
|
|
|
49
|
-
In the split view: ⏎ or a click
|
|
50
|
-
|
|
52
|
+
In the split view: ⏎ or a click shows a session on the right and leaves you in the list, so you can
|
|
53
|
+
keep browsing; F1 switches between the list and the session (a second ⏎ goes in too). l / m show
|
|
54
|
+
only live / your sessions, / finds, ←→ fold a session's children, q quits (sessions keep running).
|
|
51
55
|
|
|
52
56
|
Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
|
|
53
57
|
fits the session to this window. --view watches without taking it.
|
|
@@ -241,6 +245,7 @@ async function cmdAttach(a) {
|
|
|
241
245
|
stdout: process.stdout,
|
|
242
246
|
stderr: process.stderr,
|
|
243
247
|
readClipboardImage: () => readClipboardImage(),
|
|
248
|
+
clipboard: copier(),
|
|
244
249
|
}, row, { view, signal: ac.signal, status: a.flags.has("no-status") ? false : undefined });
|
|
245
250
|
if (result.outcome !== "disconnected" || ac.signal.aborted) {
|
|
246
251
|
process.stderr.write(`[cubes] ${result.message}\n`);
|
|
@@ -252,6 +257,11 @@ async function cmdAttach(a) {
|
|
|
252
257
|
row = await api.get(`/sessions/${encodeURIComponent(row.id)}`);
|
|
253
258
|
}
|
|
254
259
|
}
|
|
260
|
+
/** What the session copies goes to this machine's clipboard, when it has a way to write one. */
|
|
261
|
+
export function copier() {
|
|
262
|
+
const cmd = clipboardWriter();
|
|
263
|
+
return cmd ? { write: (text) => writeClipboard(cmd, text) } : null;
|
|
264
|
+
}
|
|
255
265
|
/** One keystroke, raw: true when it is one of `yes`. */
|
|
256
266
|
function keyIs(prompt, yes) {
|
|
257
267
|
if (!process.stdin.isTTY)
|
|
@@ -279,7 +289,7 @@ async function main(argv) {
|
|
|
279
289
|
}
|
|
280
290
|
// No command at all, in a terminal: the split view. Piped or scripted, still the help.
|
|
281
291
|
if (!a.cmd && !a.flags.has("help") && isTTY())
|
|
282
|
-
return launchTui({ org: str(a.flags, "org") });
|
|
292
|
+
return launchTui({ org: str(a.flags, "org"), version: VERSION });
|
|
283
293
|
if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
|
|
284
294
|
process.stdout.write(HELP);
|
|
285
295
|
return a.cmd || a.flags.has("help") ? 0 : 1;
|
package/dist/clipboard.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// macOS: AppleScript coerces whatever the pasteboard holds (a screenshot, an image copied from a
|
|
4
4
|
// browser) to PNG and writes it to a temp file. Linux: wl-paste on Wayland, xclip on X11. Anything
|
|
5
5
|
// else, or no image there, is null - and the caller then sends Ctrl-V through untouched.
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
6
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
@@ -65,3 +65,34 @@ export async function readClipboardImage(platform = process.platform, env = proc
|
|
|
65
65
|
return linux(env);
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
|
+
// ── WRITING: what the session copies (OSC 52) lands on THIS machine's clipboard ─────────────────────
|
|
69
|
+
/** The command that writes the clipboard here, or null when there is none to use. */
|
|
70
|
+
export function clipboardWriter(platform = process.platform, env = process.env) {
|
|
71
|
+
const has = (cmd) => spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" }).status === 0;
|
|
72
|
+
if (platform === "darwin")
|
|
73
|
+
return ["pbcopy"];
|
|
74
|
+
if (platform === "win32")
|
|
75
|
+
return ["clip"];
|
|
76
|
+
if (platform === "linux") {
|
|
77
|
+
if (env.WAYLAND_DISPLAY && has("wl-copy"))
|
|
78
|
+
return ["wl-copy"];
|
|
79
|
+
if (env.DISPLAY && has("xclip"))
|
|
80
|
+
return ["xclip", "-selection", "clipboard", "-in"];
|
|
81
|
+
if (env.DISPLAY && has("xsel"))
|
|
82
|
+
return ["xsel", "--clipboard", "--input"];
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
export function writeClipboard(cmd, text) {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
try {
|
|
89
|
+
const child = spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "ignore", "ignore"] });
|
|
90
|
+
child.on("error", () => resolve(false));
|
|
91
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
92
|
+
child.stdin.end(text);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
resolve(false);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
package/dist/keys.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// Ctrl-C all reach `claude` exactly as they would locally.
|
|
4
4
|
//
|
|
5
5
|
// Two bytes are ours:
|
|
6
|
-
// • Ctrl-] (0x1d) DETACH - the telnet/`docker attach` key
|
|
7
|
-
//
|
|
6
|
+
// • Ctrl-] (0x1d) DETACH - the telnet/`docker attach` key. ⚠ Claude Code now binds it too ("open
|
|
7
|
+
// artifact", 2.1.x), so taking it costs the session that; the split view, which has F1 for
|
|
8
|
+
// leaving, sends it through (`detach: false`).
|
|
8
9
|
// • Ctrl-V (0x16) PASTE AN IMAGE - Claude Code's own image-paste key. Locally it reads the
|
|
9
10
|
// clipboard of the machine it runs on; here that is a pod with no clipboard, so the CLI
|
|
10
11
|
// reads YOURS, uploads it, and types the path (what the web terminal does on paste).
|
|
@@ -25,10 +26,10 @@ function matchAt(buf, i, seq) {
|
|
|
25
26
|
return true;
|
|
26
27
|
}
|
|
27
28
|
export class InputFilter {
|
|
28
|
-
opts;
|
|
29
29
|
inPaste = false;
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
opts;
|
|
31
|
+
constructor(opts = {}) {
|
|
32
|
+
this.opts = { pasteImage: opts.pasteImage ?? true, detach: opts.detach ?? true };
|
|
32
33
|
}
|
|
33
34
|
feed(chunk) {
|
|
34
35
|
const out = [];
|
|
@@ -51,7 +52,7 @@ export class InputFilter {
|
|
|
51
52
|
continue;
|
|
52
53
|
}
|
|
53
54
|
const b = chunk[i];
|
|
54
|
-
if (b === DETACH_BYTE) {
|
|
55
|
+
if (b === DETACH_BYTE && this.opts.detach) {
|
|
55
56
|
flush(i);
|
|
56
57
|
out.push({ kind: "detach" });
|
|
57
58
|
return out; // whatever was typed after the detach key is not the session's
|
package/dist/termstatus.js
CHANGED
|
@@ -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")
|
|
@@ -206,6 +258,16 @@ export class TermStatus {
|
|
|
206
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();
|
package/dist/tui/launch.js
CHANGED
|
@@ -13,8 +13,8 @@ const OPTIONS = [
|
|
|
13
13
|
["mouse", "on"],
|
|
14
14
|
["status", "off"],
|
|
15
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
|
|
17
|
-
//
|
|
16
|
+
// one in the pod) has none either, so nothing here may eat a keystroke Claude Code uses. Focus moves
|
|
17
|
+
// by mouse, by the SWITCH key below, and by Tab / a second Enter from the list.
|
|
18
18
|
["prefix", "None"],
|
|
19
19
|
["prefix2", "None"],
|
|
20
20
|
// Escape must reach Claude Code at once: tmux's default 500 ms makes every Esc feel broken.
|
|
@@ -32,13 +32,29 @@ const OPTIONS = [
|
|
|
32
32
|
["visual-bell", "off"],
|
|
33
33
|
["allow-passthrough", "on"],
|
|
34
34
|
];
|
|
35
|
+
/**
|
|
36
|
+
* The key that moves focus between the list and the session, from either side. Bound in tmux's root
|
|
37
|
+
* table so it works whatever the pane is doing (attached, resuming, showing "Disconnected").
|
|
38
|
+
*
|
|
39
|
+
* ⚠ It never reaches the session, so it must be a key Claude Code does not use - and it uses every
|
|
40
|
+
* plain key (Esc interrupts, Tab, Shift-Tab, the arrows, Enter, any character a prompt can hold) and
|
|
41
|
+
* most Ctrl ones, Ctrl-] included ("open artifact", 2.1.x; checked against its default keybindings).
|
|
42
|
+
* F1 is the one single key left: Claude Code binds no function key. On a MacBook the top row sends F1
|
|
43
|
+
* with fn held, unless "Use F1, F2, etc. keys as standard function keys" is on.
|
|
44
|
+
*/
|
|
45
|
+
const SWITCH_KEYS = ["F1"];
|
|
46
|
+
function bindKeys() {
|
|
47
|
+
// `:.+` is the other pane: there are exactly two.
|
|
48
|
+
for (const key of SWITCH_KEYS)
|
|
49
|
+
tmux(["bind-key", "-n", key, "select-pane", "-t", ":.+"]);
|
|
50
|
+
}
|
|
35
51
|
function attachClient() {
|
|
36
52
|
const env = { ...process.env };
|
|
37
53
|
delete env.TMUX; // inside the person's own tmux: nest on purpose rather than refuse
|
|
38
54
|
const r = spawnSync("tmux", ["-L", SOCKET, "attach-session", "-t", SESSION], { stdio: "inherit", env });
|
|
39
55
|
return r.status ?? 0;
|
|
40
56
|
}
|
|
41
|
-
function build(org) {
|
|
57
|
+
function build(org, version) {
|
|
42
58
|
const cols = process.stdout.columns ?? 160;
|
|
43
59
|
const rows = process.stdout.rows ?? 48;
|
|
44
60
|
const env = org ? ["-e", `CUBES_ORG=${org}`] : [];
|
|
@@ -49,6 +65,7 @@ function build(org) {
|
|
|
49
65
|
const left = created.out;
|
|
50
66
|
for (const [k, v] of OPTIONS)
|
|
51
67
|
tmux(["set-option", "-g", "-q", k, v]);
|
|
68
|
+
bindKeys();
|
|
52
69
|
// ⚠ The same fix the session's own tmux carries (server Tmux.clientDefaults): without `ech`, tmux
|
|
53
70
|
// draws gaps as real spaces. Warp left stale letters in every gap drawn with ECH. A SERVER option,
|
|
54
71
|
// read when a client attaches, so it is set before ours does.
|
|
@@ -62,6 +79,7 @@ function build(org) {
|
|
|
62
79
|
throw new CliError(`could not split the window: ${right.err || right.code}`);
|
|
63
80
|
tmux(["set-option", "-t", SESSION, "@cubes_left", left]);
|
|
64
81
|
tmux(["set-option", "-t", SESSION, "@cubes_right", right.out]);
|
|
82
|
+
tmux(["set-option", "-t", SESSION, "@cubes_version", version]);
|
|
65
83
|
tmux(["select-pane", "-t", left]);
|
|
66
84
|
}
|
|
67
85
|
/**
|
|
@@ -78,6 +96,21 @@ function heal() {
|
|
|
78
96
|
tmux(["respawn-pane", "-k", "-t", id, selfCommand(id === left ? ["_list"] : ["_pane"])]);
|
|
79
97
|
}
|
|
80
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* A layout an older cubes built keeps running that version's list (the tmux outlives an upgrade), so
|
|
101
|
+
* bring it up to date: settings and keys, and a fresh list. The session pane is left alone - restarting
|
|
102
|
+
* it would reconnect, and resume a session that has ended - and runs the new code from the next open.
|
|
103
|
+
*/
|
|
104
|
+
function upgradeLayout(version) {
|
|
105
|
+
for (const [k, v] of OPTIONS)
|
|
106
|
+
tmux(["set-option", "-g", "-q", k, v]);
|
|
107
|
+
bindKeys();
|
|
108
|
+
tmux(["set-option", "-s", "terminal-overrides[90]", "*:ech@"]);
|
|
109
|
+
const left = tmux(["show-options", "-v", "-q", "-t", SESSION, "@cubes_left"]).out;
|
|
110
|
+
if (left)
|
|
111
|
+
tmux(["respawn-pane", "-k", "-t", left, selfCommand(["_list"])]);
|
|
112
|
+
tmux(["set-option", "-t", SESSION, "@cubes_version", version]);
|
|
113
|
+
}
|
|
81
114
|
export async function launchTui(opts) {
|
|
82
115
|
const v = tmuxVersion();
|
|
83
116
|
if (v === null) {
|
|
@@ -90,9 +123,10 @@ export async function launchTui(opts) {
|
|
|
90
123
|
await currentSession({ org: opts.org }).token();
|
|
91
124
|
if (tmux(["has-session", "-t", SESSION]).code === 0) {
|
|
92
125
|
heal();
|
|
93
|
-
tmux(["
|
|
126
|
+
if (tmux(["show-options", "-v", "-q", "-t", SESSION, "@cubes_version"]).out !== opts.version)
|
|
127
|
+
upgradeLayout(opts.version);
|
|
94
128
|
}
|
|
95
129
|
else
|
|
96
|
-
build(opts.org);
|
|
130
|
+
build(opts.org, opts.version);
|
|
97
131
|
return attachClient();
|
|
98
132
|
}
|
package/dist/tui/listApp.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// The left pane of the split view: every session, grouped and nested, with its state, filterable,
|
|
2
2
|
// and one keystroke (or click) from being open on the right.
|
|
3
|
+
//
|
|
4
|
+
// Opening SHOWS the session and leaves the keyboard here, so you can keep browsing: Enter on the next
|
|
5
|
+
// one shows that instead. Going into the session is its own step - Tab, F1, or Enter again on the one
|
|
6
|
+
// already showing.
|
|
3
7
|
import { makeApi } from "../api.js";
|
|
4
8
|
import { currentSession } from "../auth.js";
|
|
5
9
|
import { age, listSessions, LIVE } from "../sessions.js";
|
|
@@ -12,7 +16,7 @@ const ACCENT = 62;
|
|
|
12
16
|
/** As many WHOLE key hints as fit, in order of how much they are needed - never one cut in half -
|
|
13
17
|
* and the way out always last. */
|
|
14
18
|
export function hints(width) {
|
|
15
|
-
const all = ["⏎
|
|
19
|
+
const all = ["⏎ show", "F1 switch", "l live", "m mine", "/ find", "←→ fold", "r refresh"];
|
|
16
20
|
const quit = "q quit";
|
|
17
21
|
let out = "";
|
|
18
22
|
for (const h of all) {
|
|
@@ -141,7 +145,17 @@ export async function runList() {
|
|
|
141
145
|
const next = list[Math.max(0, Math.min(list.length - 1, at + delta))];
|
|
142
146
|
st.selectedId = next.row.id;
|
|
143
147
|
};
|
|
148
|
+
const goIn = () => {
|
|
149
|
+
const right = getSessionOption("@cubes_right");
|
|
150
|
+
if (right)
|
|
151
|
+
tmux(["select-pane", "-t", right]);
|
|
152
|
+
};
|
|
153
|
+
/** Show `id` on the right; the keyboard stays in the list. The one already showing is gone INTO. */
|
|
144
154
|
const open = (id) => {
|
|
155
|
+
if (id === st.current) {
|
|
156
|
+
goIn();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
145
159
|
const right = getSessionOption("@cubes_right");
|
|
146
160
|
if (!right) {
|
|
147
161
|
flash("the session pane is gone - run cubes again");
|
|
@@ -152,7 +166,6 @@ export async function runList() {
|
|
|
152
166
|
flash(`could not open it: ${r.err || r.code}`);
|
|
153
167
|
return;
|
|
154
168
|
}
|
|
155
|
-
tmux(["select-pane", "-t", right]);
|
|
156
169
|
st.current = id;
|
|
157
170
|
setSessionOption("@cubes_current", id);
|
|
158
171
|
};
|
|
@@ -209,12 +222,9 @@ export async function runList() {
|
|
|
209
222
|
open(st.selectedId);
|
|
210
223
|
break;
|
|
211
224
|
case "tab":
|
|
212
|
-
case "ctrl-]":
|
|
213
|
-
|
|
214
|
-
if (right)
|
|
215
|
-
tmux(["select-pane", "-t", right]);
|
|
225
|
+
case "ctrl-]":
|
|
226
|
+
goIn();
|
|
216
227
|
break;
|
|
217
|
-
}
|
|
218
228
|
case "right":
|
|
219
229
|
if (st.selectedId && !toggleFold(st.selectedId, true)) { /* a leaf: nothing to open */ }
|
|
220
230
|
break;
|
|
@@ -300,7 +310,7 @@ export async function runList() {
|
|
|
300
310
|
const it = items[st.top + k.y - 2];
|
|
301
311
|
if (!it || it.kind !== "session")
|
|
302
312
|
return;
|
|
303
|
-
// A click on the fold arrow folds; anywhere else on the row
|
|
313
|
+
// A click on the fold arrow folds; anywhere else on the row shows it (and on the one showing, goes in).
|
|
304
314
|
const arrowCol = 1 + strWidth(treePrefix(it)) + 1;
|
|
305
315
|
if (it.children > 0 && k.x === arrowCol) {
|
|
306
316
|
st.selectedId = it.row.id;
|
package/dist/tui/paneApp.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// The right pane of the split view: the session itself, under a header (tmux's pane border) that says
|
|
2
2
|
// which one it is - name, group, runner, state - and stays current while you work in it.
|
|
3
3
|
//
|
|
4
|
-
// The list replaces this process (`respawn-pane -k`) to
|
|
5
|
-
// one
|
|
4
|
+
// The list replaces this process (`respawn-pane -k`) to show another session, so it only ever holds
|
|
5
|
+
// one - and it does that while the keyboard stays in the list, so this pane must never need focus to
|
|
6
|
+
// get going. When an attach ends it SAYS so and waits: there is no automatic reconnect, by decision ("never
|
|
6
7
|
// keep retrying to reattach a session") - `r` reconnects when the person chooses to.
|
|
7
8
|
import { makeApi } from "../api.js";
|
|
8
9
|
import { attach } from "../attach.js";
|
|
9
10
|
import { currentSession } from "../auth.js";
|
|
10
|
-
import { readClipboardImage } from "../clipboard.js";
|
|
11
|
+
import { clipboardWriter, readClipboardImage, writeClipboard } from "../clipboard.js";
|
|
11
12
|
import { CubeSpinner, cubeSize } from "../cube.js";
|
|
12
13
|
import { ensureRunning } from "../resume.js";
|
|
13
14
|
import { statusOf } from "./model.js";
|
|
@@ -19,7 +20,7 @@ const focusList = () => {
|
|
|
19
20
|
if (left)
|
|
20
21
|
tmux(["select-pane", "-t", left]);
|
|
21
22
|
};
|
|
22
|
-
/** The header: `● Title · Group · runner · state`, as a tmux format (styles via #[...]). */
|
|
23
|
+
/** The header: `● Title · Group · runner · state · F1 list`, as a tmux format (styles via #[...]). */
|
|
23
24
|
export function headerFor(r, override) {
|
|
24
25
|
const s = override ?? statusOf({ status: r.status ?? "running", activity_status: r.activity_status ?? null });
|
|
25
26
|
const parts = [
|
|
@@ -27,6 +28,7 @@ export function headerFor(r, override) {
|
|
|
27
28
|
fmtText(printable(r.group_name || r.package_name || "no group")),
|
|
28
29
|
fmtText(printable(r.runner_name || "-")),
|
|
29
30
|
`#[fg=colour${s.color}]${s.label}#[default]`,
|
|
31
|
+
"#[fg=colour240]F1 list#[default]", // the way back, where you look when you want it
|
|
30
32
|
];
|
|
31
33
|
return parts.join(" #[fg=colour240]·#[default] ");
|
|
32
34
|
}
|
|
@@ -102,6 +104,7 @@ const OUTCOME_WORDS = {
|
|
|
102
104
|
};
|
|
103
105
|
async function sessionPane(id) {
|
|
104
106
|
const api = makeApi(currentSession());
|
|
107
|
+
const copyCmd = clipboardWriter();
|
|
105
108
|
setSessionOption("@cubes_current", id);
|
|
106
109
|
let row = { id, title: null, status: "running", created_at: "" };
|
|
107
110
|
setHeader(row, { glyph: "◌", color: 80, label: "connecting" });
|
|
@@ -122,8 +125,13 @@ async function sessionPane(id) {
|
|
|
122
125
|
stdout: out,
|
|
123
126
|
stderr: out,
|
|
124
127
|
readClipboardImage: () => readClipboardImage(),
|
|
128
|
+
clipboard: copyCmd ? { write: (text) => writeClipboard(copyCmd, text) } : null,
|
|
125
129
|
}, row, {
|
|
126
|
-
|
|
130
|
+
detachKey: false, // ^] is Claude Code's; F1 (tmux) is the way back to the list
|
|
131
|
+
onCopied: (text) => {
|
|
132
|
+
setPaneOption("@cubes_header", `#[fg=colour78]✓ Copied ${[...text].length} characters to your clipboard#[default]`);
|
|
133
|
+
setTimeout(() => setHeader(row), 1500);
|
|
134
|
+
},
|
|
127
135
|
status: { onRow: (r) => { row = { ...row, ...r }; setHeader(row); } },
|
|
128
136
|
});
|
|
129
137
|
}
|
|
@@ -136,9 +144,10 @@ async function sessionPane(id) {
|
|
|
136
144
|
{ text: words.head, style: ESC.bold + ESC.fg(words.color) },
|
|
137
145
|
{ text: result.message, style: ESC.dim },
|
|
138
146
|
{ text: "" },
|
|
139
|
-
{ text: `r ${words.key}
|
|
147
|
+
{ text: `r ${words.key} F1 back to the list` },
|
|
140
148
|
]);
|
|
141
|
-
// Wait for the person: `r` goes round again
|
|
149
|
+
// Wait for the person: `r` goes round again; Esc / Tab / ^] hand focus back to the list (as does
|
|
150
|
+
// F1, which tmux takes before it gets here). Nothing is attached now, so no key is Claude's.
|
|
142
151
|
for (;;) {
|
|
143
152
|
const k = await waitKey((k) => (k.kind === "char" && (k.ch === "r" || k.ch === "R")) || k.kind === "enter"
|
|
144
153
|
|| k.kind === "ctrl-]" || k.kind === "escape" || k.kind === "tab");
|