@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 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
- let stream = new StreamFilter();
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
+ }
@@ -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")
@@ -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();
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
5
  "type": "module",
6
6
  "bin": {