@hellix-ai/cubes 0.2.0 → 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 +5 -0
- package/dist/attach.js +15 -2
- package/dist/cli.js +10 -1
- package/dist/clipboard.js +32 -1
- package/dist/termstatus.js +65 -3
- package/dist/tui/paneApp.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,11 @@ Every key goes to the session, exactly as if Claude Code were running locally, e
|
|
|
35
35
|
| `Ctrl-]` | detach. The session keeps running. |
|
|
36
36
|
| `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
37
|
|
|
38
|
+
**Copying.** Drag over text in a session and it is copied the moment you let go - the session's tmux
|
|
39
|
+
does the selecting (so there is no selection left for Cmd+C), and the CLI puts the text on your
|
|
40
|
+
clipboard. Claude Code's own copy (the `c` on its login URL) lands there too. On Linux this needs
|
|
41
|
+
`wl-copy`, `xclip` or `xsel`.
|
|
42
|
+
|
|
38
43
|
**One keyboard per session.** Attaching takes the keyboard from any browser tab open on the same session
|
|
39
44
|
(that tab becomes read-only, with a Take over button) and fits the session to your window. If someone
|
|
40
45
|
takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
|
package/dist/attach.js
CHANGED
|
@@ -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;
|
|
@@ -264,7 +277,7 @@ export function attach(deps, session, opts = {}) {
|
|
|
264
277
|
const sock = deps.openWs(wsUrlFor(deps.host, session.id), [TTY_SUBPROTOCOL, ticket]);
|
|
265
278
|
sock.binaryType = "arraybuffer";
|
|
266
279
|
ws = sock;
|
|
267
|
-
stream = new StreamFilter();
|
|
280
|
+
stream = new StreamFilter(onClipboard);
|
|
268
281
|
askedTakeover = false;
|
|
269
282
|
takeoverPending = false;
|
|
270
283
|
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,6 +44,9 @@ 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
52
|
In the split view: ⏎ or a click opens a session, Ctrl-] in a session jumps back to the list, l / m
|
|
@@ -241,6 +244,7 @@ async function cmdAttach(a) {
|
|
|
241
244
|
stdout: process.stdout,
|
|
242
245
|
stderr: process.stderr,
|
|
243
246
|
readClipboardImage: () => readClipboardImage(),
|
|
247
|
+
clipboard: copier(),
|
|
244
248
|
}, row, { view, signal: ac.signal, status: a.flags.has("no-status") ? false : undefined });
|
|
245
249
|
if (result.outcome !== "disconnected" || ac.signal.aborted) {
|
|
246
250
|
process.stderr.write(`[cubes] ${result.message}\n`);
|
|
@@ -252,6 +256,11 @@ async function cmdAttach(a) {
|
|
|
252
256
|
row = await api.get(`/sessions/${encodeURIComponent(row.id)}`);
|
|
253
257
|
}
|
|
254
258
|
}
|
|
259
|
+
/** What the session copies goes to this machine's clipboard, when it has a way to write one. */
|
|
260
|
+
export function copier() {
|
|
261
|
+
const cmd = clipboardWriter();
|
|
262
|
+
return cmd ? { write: (text) => writeClipboard(cmd, text) } : null;
|
|
263
|
+
}
|
|
255
264
|
/** One keystroke, raw: true when it is one of `yes`. */
|
|
256
265
|
function keyIs(prompt, yes) {
|
|
257
266
|
if (!process.stdin.isTTY)
|
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/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/paneApp.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { makeApi } from "../api.js";
|
|
8
8
|
import { attach } from "../attach.js";
|
|
9
9
|
import { currentSession } from "../auth.js";
|
|
10
|
-
import { readClipboardImage } from "../clipboard.js";
|
|
10
|
+
import { clipboardWriter, readClipboardImage, writeClipboard } from "../clipboard.js";
|
|
11
11
|
import { CubeSpinner, cubeSize } from "../cube.js";
|
|
12
12
|
import { ensureRunning } from "../resume.js";
|
|
13
13
|
import { statusOf } from "./model.js";
|
|
@@ -102,6 +102,7 @@ const OUTCOME_WORDS = {
|
|
|
102
102
|
};
|
|
103
103
|
async function sessionPane(id) {
|
|
104
104
|
const api = makeApi(currentSession());
|
|
105
|
+
const copyCmd = clipboardWriter();
|
|
105
106
|
setSessionOption("@cubes_current", id);
|
|
106
107
|
let row = { id, title: null, status: "running", created_at: "" };
|
|
107
108
|
setHeader(row, { glyph: "◌", color: 80, label: "connecting" });
|
|
@@ -122,8 +123,13 @@ async function sessionPane(id) {
|
|
|
122
123
|
stdout: out,
|
|
123
124
|
stderr: out,
|
|
124
125
|
readClipboardImage: () => readClipboardImage(),
|
|
126
|
+
clipboard: copyCmd ? { write: (text) => writeClipboard(copyCmd, text) } : null,
|
|
125
127
|
}, row, {
|
|
126
128
|
onDetachKey: focusList,
|
|
129
|
+
onCopied: (text) => {
|
|
130
|
+
setPaneOption("@cubes_header", `#[fg=colour78]✓ Copied ${[...text].length} characters to your clipboard#[default]`);
|
|
131
|
+
setTimeout(() => setHeader(row), 1500);
|
|
132
|
+
},
|
|
127
133
|
status: { onRow: (r) => { row = { ...row, ...r }; setHeader(row); } },
|
|
128
134
|
});
|
|
129
135
|
}
|