@hellix-ai/cubes 0.2.1 → 0.3.0
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 +61 -4
- package/dist/api.js +44 -11
- package/dist/args.js +17 -5
- package/dist/attach.js +1 -5
- package/dist/cli.js +41 -4
- package/dist/conversationCmds.js +273 -0
- package/dist/form.js +309 -0
- package/dist/keys.js +7 -6
- package/dist/local.js +251 -0
- package/dist/prompt.js +117 -0
- package/dist/transfer.js +166 -0
- package/dist/tui/launch.js +39 -5
- package/dist/tui/listApp.js +18 -8
- package/dist/tui/paneApp.js +9 -6
- package/package.json +8 -2
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Asking questions in a terminal: line-based, numbered, Enter takes the default.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately not a full-screen picker. The session form runs before anything is attached, in
|
|
4
|
+
// whatever terminal the person has (Warp, a plain ssh, a CI log when piped), and a numbered list read
|
|
5
|
+
// with one line of input works in every one of them. Questions go to stderr so stdout stays clean.
|
|
6
|
+
//
|
|
7
|
+
// Behind an interface so the form can be driven by a script in tests.
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { CliError } from "./auth.js";
|
|
10
|
+
/** "1 3", "1,3", "2-4", "1, 3-5" -> zero-based indexes, or null when it does not parse. */
|
|
11
|
+
export function parseNumbers(answer, count) {
|
|
12
|
+
const out = new Set();
|
|
13
|
+
for (const part of answer.split(/[\s,]+/).filter(Boolean)) {
|
|
14
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(part);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
const a = Number(m[1]);
|
|
18
|
+
const b = m[2] ? Number(m[2]) : a;
|
|
19
|
+
if (a < 1 || b > count || a > b)
|
|
20
|
+
return null;
|
|
21
|
+
for (let i = a; i <= b; i++)
|
|
22
|
+
out.add(i - 1);
|
|
23
|
+
}
|
|
24
|
+
return [...out].sort((x, y) => x - y);
|
|
25
|
+
}
|
|
26
|
+
export function terminalPrompter(input = process.stdin, output = process.stderr, color = !process.env.NO_COLOR && !!output.isTTY) {
|
|
27
|
+
const dim = (t) => (color ? `\x1b[2m${t}\x1b[0m` : t);
|
|
28
|
+
const bold = (t) => (color ? `\x1b[1m${t}\x1b[0m` : t);
|
|
29
|
+
const ask = async (q) => {
|
|
30
|
+
const rl = createInterface({ input, output, terminal: input.isTTY ?? false });
|
|
31
|
+
// Ctrl-C at a question leaves the whole command, not just the question.
|
|
32
|
+
rl.on("SIGINT", () => { rl.close(); output.write("\n"); process.exit(130); });
|
|
33
|
+
try {
|
|
34
|
+
return (await rl.question(q)).trim();
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
// Ctrl-D (end of input) at a question: nothing more is coming, so stop here, plainly.
|
|
38
|
+
if (e.code === "ABORT_ERR")
|
|
39
|
+
throw new CliError("cancelled - nothing was started");
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
rl.close();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const list = (choices, marked) => {
|
|
47
|
+
const w = String(choices.length).length;
|
|
48
|
+
choices.forEach((c, i) => {
|
|
49
|
+
const mark = marked(i) ? "*" : " ";
|
|
50
|
+
output.write(` ${mark} ${String(i + 1).padStart(w)}) ${c.label}${c.hint ? ` ${dim(c.hint)}` : ""}\n`);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
async text(question, def) {
|
|
55
|
+
const a = await ask(`${bold(question)}${def ? ` ${dim(`[${def}]`)}` : ""}: `);
|
|
56
|
+
return a || def || "";
|
|
57
|
+
},
|
|
58
|
+
async confirm(question, def) {
|
|
59
|
+
for (;;) {
|
|
60
|
+
const a = (await ask(`${bold(question)} ${dim(def ? "[Y/n]" : "[y/N]")} `)).toLowerCase();
|
|
61
|
+
if (!a)
|
|
62
|
+
return def;
|
|
63
|
+
if (a === "y" || a === "yes")
|
|
64
|
+
return true;
|
|
65
|
+
if (a === "n" || a === "no")
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async select(question, choices, def = 0) {
|
|
70
|
+
if (!choices.length)
|
|
71
|
+
throw new CliError(`nothing to choose from for "${question}"`);
|
|
72
|
+
output.write(`${bold(question)}\n`);
|
|
73
|
+
list(choices, (i) => i === def);
|
|
74
|
+
for (;;) {
|
|
75
|
+
const a = await ask(` ${dim(`number, Enter for ${def + 1}`)}: `);
|
|
76
|
+
if (!a)
|
|
77
|
+
return choices[def].value;
|
|
78
|
+
const n = Number(a);
|
|
79
|
+
if (Number.isInteger(n) && n >= 1 && n <= choices.length)
|
|
80
|
+
return choices[n - 1].value;
|
|
81
|
+
output.write(` ${dim(`pick 1-${choices.length}`)}\n`);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async multi(question, choices, defs) {
|
|
85
|
+
output.write(`${bold(question)}\n`);
|
|
86
|
+
list(choices, (i) => defs.includes(i));
|
|
87
|
+
for (;;) {
|
|
88
|
+
const a = await ask(` ${dim(`numbers like "1 3" or "2-4", - for none, Enter keeps ${defs.length ? "the * ones" : "none"}`)}: `);
|
|
89
|
+
if (!a)
|
|
90
|
+
return defs.map((i) => choices[i].value);
|
|
91
|
+
if (a === "-")
|
|
92
|
+
return [];
|
|
93
|
+
const picked = parseNumbers(a, choices.length);
|
|
94
|
+
if (picked)
|
|
95
|
+
return picked.map((i) => choices[i].value);
|
|
96
|
+
output.write(` ${dim(`numbers between 1 and ${choices.length}`)}\n`);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
note(line) {
|
|
100
|
+
output.write(`${dim(line)}\n`);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** For `--yes` and for scripts: every question takes its default, and says so. */
|
|
105
|
+
export function defaultsPrompter(output = process.stderr) {
|
|
106
|
+
return {
|
|
107
|
+
text: async (_q, def) => def ?? "",
|
|
108
|
+
confirm: async (_q, def) => def,
|
|
109
|
+
select: async (q, choices, def = 0) => {
|
|
110
|
+
if (!choices.length)
|
|
111
|
+
throw new CliError(`nothing to choose from for "${q}"`);
|
|
112
|
+
return choices[def].value;
|
|
113
|
+
},
|
|
114
|
+
multi: async (_q, choices, defs) => defs.map((i) => choices[i].value),
|
|
115
|
+
note: (line) => { output.write(`${line}\n`); },
|
|
116
|
+
};
|
|
117
|
+
}
|
package/dist/transfer.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Moving a conversation between this computer and Cubes - the bytes half of `cubes import` and
|
|
2
|
+
// `cubes export`. No prompts and no printing here: progress is a callback, failures are thrown.
|
|
3
|
+
//
|
|
4
|
+
// UP: the server takes a conversation in windows (`chunkBytes`, which it states - Cloudflare refuses a
|
|
5
|
+
// request body over 100 MiB, and conversations are routinely bigger), each at an exact offset, so a
|
|
6
|
+
// window whose response was lost is simply sent again and a window the server did not expect is
|
|
7
|
+
// answered with where it actually is. The file is checksummed first and the server verifies it once the
|
|
8
|
+
// last byte lands.
|
|
9
|
+
//
|
|
10
|
+
// DOWN: one streamed GET with an exact Content-Length. It is written next to its destination and only
|
|
11
|
+
// renamed into place once every byte has arrived, so an interrupted export never leaves half a
|
|
12
|
+
// conversation where `claude --resume` would find it.
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import { open, rm, stat, truncate } from "node:fs/promises";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { mkdir } from "node:fs/promises";
|
|
17
|
+
import { ApiError } from "./api.js";
|
|
18
|
+
import { CliError } from "./auth.js";
|
|
19
|
+
import { isConversationId } from "./local.js";
|
|
20
|
+
const WINDOW = 1024 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* How much of a conversation is WHOLE LINES right now. A conversation Claude Code is still writing on
|
|
23
|
+
* this computer can end mid-line; the upload stops at the last newline, and the bytes before it never
|
|
24
|
+
* change (the file is only ever appended to), so the checksum taken first still holds at the end.
|
|
25
|
+
*/
|
|
26
|
+
export async function wholeLinesLength(path) {
|
|
27
|
+
const size = (await stat(path)).size;
|
|
28
|
+
const fh = await open(path, "r");
|
|
29
|
+
try {
|
|
30
|
+
const buf = Buffer.alloc(WINDOW);
|
|
31
|
+
for (let end = size; end > 0;) {
|
|
32
|
+
const from = Math.max(0, end - WINDOW);
|
|
33
|
+
const { bytesRead } = await fh.read(buf, 0, end - from, from);
|
|
34
|
+
const nl = buf.subarray(0, bytesRead).lastIndexOf(0x0a);
|
|
35
|
+
if (nl >= 0)
|
|
36
|
+
return from + nl + 1;
|
|
37
|
+
end = from;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await fh.close().catch(() => { });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function sha256Of(path, len) {
|
|
46
|
+
const h = createHash("sha256");
|
|
47
|
+
const fh = await open(path, "r");
|
|
48
|
+
try {
|
|
49
|
+
const buf = Buffer.alloc(4 * WINDOW);
|
|
50
|
+
for (let at = 0; at < len;) {
|
|
51
|
+
const { bytesRead } = await fh.read(buf, 0, Math.min(buf.length, len - at), at);
|
|
52
|
+
if (!bytesRead)
|
|
53
|
+
throw new CliError(`${path} got shorter while it was being read`);
|
|
54
|
+
h.update(buf.subarray(0, bytesRead));
|
|
55
|
+
at += bytesRead;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await fh.close().catch(() => { });
|
|
60
|
+
}
|
|
61
|
+
return h.digest("hex");
|
|
62
|
+
}
|
|
63
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
/** Upload one conversation file and return the import id POST /api/sessions takes. */
|
|
65
|
+
export async function uploadConversation(api, conv, onProgress = () => { }, opts = {}) {
|
|
66
|
+
const bytes = await wholeLinesLength(conv.path);
|
|
67
|
+
if (!bytes)
|
|
68
|
+
throw new CliError(`${conv.path} has no complete line in it - there is nothing to import`);
|
|
69
|
+
const sha256 = await sha256Of(conv.path, bytes);
|
|
70
|
+
const begin = await api.post("/session-imports", {
|
|
71
|
+
conversationId: conv.id, bytes, sha256, sourceCwd: conv.cwd,
|
|
72
|
+
});
|
|
73
|
+
const id = begin.id;
|
|
74
|
+
const chunk = Math.max(1, begin.chunkBytes);
|
|
75
|
+
const retries = opts.retries ?? 4;
|
|
76
|
+
const fh = await open(conv.path, "r");
|
|
77
|
+
try {
|
|
78
|
+
let at = 0;
|
|
79
|
+
onProgress(0, bytes);
|
|
80
|
+
while (at < bytes) {
|
|
81
|
+
const n = Math.min(chunk, bytes - at);
|
|
82
|
+
const buf = Buffer.alloc(n);
|
|
83
|
+
const { bytesRead } = await fh.read(buf, 0, n, at);
|
|
84
|
+
if (bytesRead !== n)
|
|
85
|
+
throw new CliError(`${conv.path} got shorter while it was being uploaded`);
|
|
86
|
+
for (let attempt = 0;; attempt++) {
|
|
87
|
+
try {
|
|
88
|
+
const v = await api.put(`/session-imports/${encodeURIComponent(id)}/content?offset=${at}`, undefined, { raw: buf });
|
|
89
|
+
at = v.received;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
// The server is somewhere else than we thought (a retried window it had already taken, or
|
|
94
|
+
// one it lost): carry on from where IT is.
|
|
95
|
+
const received = e instanceof ApiError && e.status === 409
|
|
96
|
+
? e.body?.received : undefined;
|
|
97
|
+
if (typeof received === "number") {
|
|
98
|
+
at = received;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
// Only a network failure or a server error is worth repeating; a refusal is final.
|
|
102
|
+
const transient = !(e instanceof ApiError) || e.status >= 500;
|
|
103
|
+
if (!transient || attempt >= retries)
|
|
104
|
+
throw e;
|
|
105
|
+
await sleep((opts.retryDelayMs ?? 1000) * (attempt + 1));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
onProgress(at, bytes);
|
|
109
|
+
}
|
|
110
|
+
return { importId: id, bytes };
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
await api.del(`/session-imports/${encodeURIComponent(id)}`).catch(() => undefined);
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await fh.close().catch(() => { });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Download a session's conversation. `targetFor` names where it will end up once its id is known (the
|
|
122
|
+
* server says it in a header), so the temporary file is written in the same directory.
|
|
123
|
+
*/
|
|
124
|
+
export async function downloadConversation(api, sessionId, targetFor, onProgress = () => { }) {
|
|
125
|
+
const res = await api.stream(`/sessions/${encodeURIComponent(sessionId)}/conversation`);
|
|
126
|
+
const conversationId = res.headers.get("x-conversation-id") ?? "";
|
|
127
|
+
if (!isConversationId(conversationId)) {
|
|
128
|
+
await res.body?.cancel().catch(() => undefined);
|
|
129
|
+
throw new CliError("the server did not say which conversation this is - is it older than this CLI?");
|
|
130
|
+
}
|
|
131
|
+
const total = Number(res.headers.get("content-length"));
|
|
132
|
+
const target = targetFor(conversationId);
|
|
133
|
+
await mkdir(dirname(target), { recursive: true });
|
|
134
|
+
const part = join(dirname(target), `.${conversationId}.cubes-export-${randomBytes(4).toString("hex")}.part`);
|
|
135
|
+
const fh = await open(part, "w", 0o600);
|
|
136
|
+
let got = 0;
|
|
137
|
+
try {
|
|
138
|
+
if (!res.body)
|
|
139
|
+
throw new CliError("the server sent no conversation");
|
|
140
|
+
onProgress(0, total);
|
|
141
|
+
for await (const piece of res.body) {
|
|
142
|
+
await fh.write(piece);
|
|
143
|
+
got += piece.length;
|
|
144
|
+
onProgress(got, total);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
await fh.close().catch(() => { });
|
|
149
|
+
await rm(part, { force: true });
|
|
150
|
+
throw e instanceof CliError ? e : new CliError(`the download was cut off after ${got} bytes: ${e.message}`);
|
|
151
|
+
}
|
|
152
|
+
await fh.close();
|
|
153
|
+
if (Number.isFinite(total) && got !== total) {
|
|
154
|
+
await rm(part, { force: true });
|
|
155
|
+
throw new CliError(`the download was cut off: ${got} of ${total} bytes arrived - nothing was written, try again`);
|
|
156
|
+
}
|
|
157
|
+
// The session may have been halfway through writing its last line when the snapshot was taken.
|
|
158
|
+
const whole = await wholeLinesLength(part);
|
|
159
|
+
if (whole !== got)
|
|
160
|
+
await truncate(part, whole);
|
|
161
|
+
if (!whole) {
|
|
162
|
+
await rm(part, { force: true });
|
|
163
|
+
throw new CliError("that session's conversation has no complete line in it yet");
|
|
164
|
+
}
|
|
165
|
+
return { conversationId, file: part, bytes: whole };
|
|
166
|
+
}
|
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,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
|
|
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";
|
|
@@ -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
|
-
|
|
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}
|
|
147
|
+
{ text: `r ${words.key} F1 back to the list` },
|
|
146
148
|
]);
|
|
147
|
-
// 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.
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hellix-ai/cubes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "The Cubes command line: your Cubes sessions in your own terminal (attach, a split view of all of them, image paste, clipboard, tab status and notifications), and Claude Code conversations moved between your computer and Cubes (import, export).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cubes": "dist/cli.js"
|
|
@@ -22,7 +22,13 @@
|
|
|
22
22
|
"keywords": [
|
|
23
23
|
"cubes",
|
|
24
24
|
"claude",
|
|
25
|
+
"claude-code",
|
|
25
26
|
"terminal",
|
|
27
|
+
"tmux",
|
|
28
|
+
"remote",
|
|
29
|
+
"sessions",
|
|
30
|
+
"import",
|
|
31
|
+
"export",
|
|
26
32
|
"cli"
|
|
27
33
|
],
|
|
28
34
|
"license": "UNLICENSED",
|