@hellix-ai/cubes 0.2.1 → 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 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
- - **Keys:** `⏎` (or a click) opens a session · `Ctrl-]` in a session jumps back to the list · `l` live only
27
- · `m` mine only · `/` find · `←` `→` fold a session's children · `q` quits (sessions keep running).
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
 
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;
@@ -218,10 +218,6 @@ export function attach(deps, session, opts = {}) {
218
218
  function onData(chunk) {
219
219
  for (const a of filter.feed(chunk)) {
220
220
  if (a.kind === "detach") {
221
- if (opts.onDetachKey) {
222
- opts.onDetachKey();
223
- continue;
224
- }
225
221
  finish(0, "detached", "detached");
226
222
  return;
227
223
  }
package/dist/cli.js CHANGED
@@ -49,8 +49,9 @@ selecting). Claude Code's own copy, like the c on its login URL, lands there too
49
49
 
50
50
  If the connection drops, nothing reconnects on its own: you are told why, and r reconnects.
51
51
 
52
- In the split view: ⏎ or a click opens a session, Ctrl-] in a session jumps back to the list, l / m
53
- show only live / your sessions, / finds, ←→ fold a session's children, q quits (sessions keep running).
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).
54
55
 
55
56
  Attaching takes the keyboard from any browser tab on the same session (it becomes read-only) and
56
57
  fits the session to this window. --view watches without taking it.
@@ -288,7 +289,7 @@ async function main(argv) {
288
289
  }
289
290
  // No command at all, in a terminal: the split view. Piped or scripted, still the help.
290
291
  if (!a.cmd && !a.flags.has("help") && isTTY())
291
- return launchTui({ org: str(a.flags, "org") });
292
+ return launchTui({ org: str(a.flags, "org"), version: VERSION });
292
293
  if (!a.cmd || a.cmd === "help" || a.flags.has("help")) {
293
294
  process.stdout.write(HELP);
294
295
  return a.cmd || a.flags.has("help") ? 0 : 1;
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, chosen because neither Claude Code nor
7
- // tmux binds it, so taking it costs the session nothing.
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
- constructor(opts = { pasteImage: true }) {
31
- this.opts = opts;
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
@@ -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 by mouse, by
17
- // Ctrl-] from the session pane, and by Enter/Tab from the list.
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(["set-option", "-s", "terminal-overrides[90]", "*:ech@"]); // a layout built by an older cubes
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
  }
@@ -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 = ["⏎ open", "l live", "m mine", "/ find", "^] back here", "←→ fold", "r refresh"];
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
- const right = getSessionOption("@cubes_right");
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 opens it.
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;
@@ -1,8 +1,9 @@
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 open another session, so it only ever holds
5
- // one. When an attach ends it SAYS so and waits: there is no automatic reconnect, by decision ("never
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";
@@ -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
  }
@@ -125,7 +127,7 @@ async function sessionPane(id) {
125
127
  readClipboardImage: () => readClipboardImage(),
126
128
  clipboard: copyCmd ? { write: (text) => writeClipboard(copyCmd, text) } : null,
127
129
  }, row, {
128
- onDetachKey: focusList,
130
+ detachKey: false, // ^] is Claude Code's; F1 (tmux) is the way back to the list
129
131
  onCopied: (text) => {
130
132
  setPaneOption("@cubes_header", `#[fg=colour78]✓ Copied ${[...text].length} characters to your clipboard#[default]`);
131
133
  setTimeout(() => setHeader(row), 1500);
@@ -142,9 +144,10 @@ async function sessionPane(id) {
142
144
  { text: words.head, style: ESC.bold + ESC.fg(words.color) },
143
145
  { text: result.message, style: ESC.dim },
144
146
  { text: "" },
145
- { text: `r ${words.key} ^] back to the list` },
147
+ { text: `r ${words.key} F1 back to the list` },
146
148
  ]);
147
- // Wait for the person: `r` goes round again, ^] / Esc / Tab hand focus back to the list.
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.
148
151
  for (;;) {
149
152
  const k = await waitKey((k) => (k.kind === "char" && (k.ch === "r" || k.ch === "R")) || k.kind === "enter"
150
153
  || k.kind === "ctrl-]" || k.kind === "escape" || k.kind === "tab");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellix-ai/cubes",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Attach to your Cubes sessions from your own terminal (Warp, iTerm, Ghostty, anything).",
5
5
  "type": "module",
6
6
  "bin": {