@hellix-ai/cubes 0.1.4 → 0.2.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 +25 -3
- package/dist/attach.js +79 -65
- package/dist/cli.js +63 -55
- package/dist/cube.js +7 -3
- package/dist/resume.js +47 -0
- package/dist/sessions.js +16 -5
- package/dist/termstatus.js +276 -0
- package/dist/tui/launch.js +98 -0
- package/dist/tui/listApp.js +347 -0
- package/dist/tui/model.js +117 -0
- package/dist/tui/paneApp.js +156 -0
- package/dist/tui/term.js +133 -0
- package/dist/tui/tmux.js +49 -0
- package/package.json +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Telling YOUR terminal what the attached session is doing: the tab title, a "needs you" signal, and
|
|
2
|
+
// Warp's per-tab agent status.
|
|
3
|
+
//
|
|
4
|
+
// Running Claude Code locally, a terminal tab shows its title and whether it is working or waiting.
|
|
5
|
+
// Attached through `cubes`, the program on the other end is tmux in a pod, which says nothing about
|
|
6
|
+
// either. So the CLI says it, from the server's own activity signal (the hooks every session reports:
|
|
7
|
+
// working / ready / waiting-with-a-reason), in the three dialects terminals understand:
|
|
8
|
+
//
|
|
9
|
+
// • OSC 0 TITLE - every terminal. `<glyph> <session title>`, the convention Claude Code itself uses:
|
|
10
|
+
// ● while working, ✳ when idle, ✋ when it is waiting for you. Static: an animated glyph churns the tab.
|
|
11
|
+
// • BEL + OSC 9 - the generic "this tab wants you": a bell marks the tab / bounces the dock almost
|
|
12
|
+
// everywhere, and OSC 9 raises a desktop notification in iTerm2, Ghostty, WezTerm, kitty, Warp.
|
|
13
|
+
// • OSC 777 `warp://cli-agent` - Warp's structured agent protocol, the same events and payload as
|
|
14
|
+
// warpdotdev/claude-code-warp sends from Claude Code's hooks (the remote program IS Claude Code).
|
|
15
|
+
// Gated exactly as that plugin gates it, and used INSTEAD of BEL+OSC 9 there, not as well.
|
|
16
|
+
//
|
|
17
|
+
// ⚠ NOTHING HERE MAY BE WRITTEN MID-SEQUENCE. The session's output arrives in arbitrary chunks, so a
|
|
18
|
+
// chunk can end halfway through an escape sequence or a UTF-8 character; a title written into that gap
|
|
19
|
+
// would corrupt both. `StreamFilter` tracks where the stream is, and the caller only writes ours when
|
|
20
|
+
// the stream is between sequences.
|
|
21
|
+
export const WARP_PLUGIN_COMPAT = "2.3.0"; // the claude-code-warp release whose event set this mirrors
|
|
22
|
+
const WORKING = "●";
|
|
23
|
+
const IDLE = "✳";
|
|
24
|
+
const WAITING = "✋";
|
|
25
|
+
/** Title push / pop (xterm window ops): our title is set on top of yours and yours comes back after. */
|
|
26
|
+
export const TITLE_PUSH = "\x1b[22;0t";
|
|
27
|
+
export const TITLE_POP = "\x1b]0;\x07\x1b[23;0t"; // clear first, for terminals with no title stack
|
|
28
|
+
/** Nothing the server hands us may reach the terminal as a control character. */
|
|
29
|
+
export function clean(s, max = 120) {
|
|
30
|
+
const t = s.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
|
|
31
|
+
return t.length > max ? t.slice(0, max - 1) + "…" : t;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Passes the session's output through, minus the two things that would fight us for the title -
|
|
35
|
+
* OSC 0/1/2 (set title) and CSI 22/23 t (title push/pop, which the session's tmux sends on attach and
|
|
36
|
+
* exit and which would unbalance our own push/pop) - and knows at every chunk boundary whether it is
|
|
37
|
+
* safe to write a sequence of our own.
|
|
38
|
+
*/
|
|
39
|
+
export class StreamFilter {
|
|
40
|
+
st = "ground";
|
|
41
|
+
seq = []; // the CSI or OSC-prefix being decided
|
|
42
|
+
utf8Left = 0;
|
|
43
|
+
oscEsc = false; // saw ESC inside a string, waiting for '\'
|
|
44
|
+
get safe() {
|
|
45
|
+
return this.st === "ground" && this.utf8Left === 0;
|
|
46
|
+
}
|
|
47
|
+
feed(chunk) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const b of chunk) {
|
|
50
|
+
switch (this.st) {
|
|
51
|
+
case "ground":
|
|
52
|
+
if (b === 0x1b) {
|
|
53
|
+
this.st = "esc";
|
|
54
|
+
this.seq = [b];
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (this.utf8Left > 0 && (b & 0xc0) === 0x80)
|
|
58
|
+
this.utf8Left--;
|
|
59
|
+
else if ((b & 0xe0) === 0xc0)
|
|
60
|
+
this.utf8Left = 1;
|
|
61
|
+
else if ((b & 0xf0) === 0xe0)
|
|
62
|
+
this.utf8Left = 2;
|
|
63
|
+
else if ((b & 0xf8) === 0xf0)
|
|
64
|
+
this.utf8Left = 3;
|
|
65
|
+
else
|
|
66
|
+
this.utf8Left = 0;
|
|
67
|
+
out.push(b);
|
|
68
|
+
break;
|
|
69
|
+
case "esc":
|
|
70
|
+
if (b === 0x1b) {
|
|
71
|
+
out.push(0x1b);
|
|
72
|
+
this.seq = [b];
|
|
73
|
+
break;
|
|
74
|
+
} // a bare ESC, then a new sequence
|
|
75
|
+
this.seq.push(b);
|
|
76
|
+
if (b === 0x5b) {
|
|
77
|
+
this.st = "csi";
|
|
78
|
+
break;
|
|
79
|
+
} // [
|
|
80
|
+
if (b === 0x5d) {
|
|
81
|
+
this.st = "osc";
|
|
82
|
+
break;
|
|
83
|
+
} // ]
|
|
84
|
+
if (b === 0x50 || b === 0x58 || b === 0x5e || b === 0x5f) { // P X ^ _ : DCS SOS PM APC
|
|
85
|
+
out.push(...this.seq);
|
|
86
|
+
this.st = "str";
|
|
87
|
+
this.oscEsc = false;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (b >= 0x20 && b <= 0x2f)
|
|
91
|
+
break; // intermediate bytes, the final follows
|
|
92
|
+
out.push(...this.seq);
|
|
93
|
+
this.st = "ground";
|
|
94
|
+
break;
|
|
95
|
+
case "csi":
|
|
96
|
+
this.seq.push(b);
|
|
97
|
+
if (b >= 0x40 && b <= 0x7e) {
|
|
98
|
+
const body = String.fromCharCode(...this.seq.slice(2, -1));
|
|
99
|
+
const drop = b === 0x74 && /^2[23](;|$)/.test(body); // CSI 22/23 ... t
|
|
100
|
+
if (!drop)
|
|
101
|
+
out.push(...this.seq);
|
|
102
|
+
this.st = "ground";
|
|
103
|
+
}
|
|
104
|
+
else if (this.seq.length > 64) { // not a CSI anyone sends; stop holding it
|
|
105
|
+
out.push(...this.seq);
|
|
106
|
+
this.st = "ground";
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
case "osc": {
|
|
110
|
+
// Hold only until the number is known: 0/1/2 is a title and is dropped, anything else
|
|
111
|
+
// (hyperlinks, clipboard, notifications) streams through untouched.
|
|
112
|
+
const ends = b === 0x07 || b === 0x1b;
|
|
113
|
+
if (b === 0x3b || ends || this.seq.length > 8) {
|
|
114
|
+
const num = String.fromCharCode(...this.seq.slice(2));
|
|
115
|
+
const title = /^[012]$/.test(num);
|
|
116
|
+
if (!title)
|
|
117
|
+
out.push(...this.seq);
|
|
118
|
+
this.seq = [];
|
|
119
|
+
if (ends) {
|
|
120
|
+
if (b === 0x07) {
|
|
121
|
+
if (!title)
|
|
122
|
+
out.push(b);
|
|
123
|
+
this.st = "ground";
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
if (!title)
|
|
127
|
+
out.push(b);
|
|
128
|
+
this.st = title ? "oscDrop" : "str";
|
|
129
|
+
this.oscEsc = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
if (!title)
|
|
134
|
+
out.push(b);
|
|
135
|
+
this.st = title ? "oscDrop" : "str";
|
|
136
|
+
this.oscEsc = false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
this.seq.push(b);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "oscDrop":
|
|
145
|
+
case "str": {
|
|
146
|
+
const keep = this.st === "str";
|
|
147
|
+
if (keep)
|
|
148
|
+
out.push(b);
|
|
149
|
+
if (this.oscEsc && b === 0x5c) {
|
|
150
|
+
this.st = "ground";
|
|
151
|
+
this.oscEsc = false;
|
|
152
|
+
break;
|
|
153
|
+
} // ST = ESC \
|
|
154
|
+
this.oscEsc = b === 0x1b;
|
|
155
|
+
if (b === 0x07 && !this.oscEsc)
|
|
156
|
+
this.st = "ground"; // BEL ends an OSC (and is harmless in DCS)
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return Uint8Array.from(out);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function stateOf(r) {
|
|
165
|
+
if (r.status !== "running")
|
|
166
|
+
return "idle";
|
|
167
|
+
if (r.activity_status === "working")
|
|
168
|
+
return "working";
|
|
169
|
+
if (r.activity_status === "waiting")
|
|
170
|
+
return "waiting";
|
|
171
|
+
return "idle";
|
|
172
|
+
}
|
|
173
|
+
/** Warp's structured notifications, exactly as warpdotdev/claude-code-warp decides it may use them. */
|
|
174
|
+
export function warpProtocol(env) {
|
|
175
|
+
const v = Number.parseInt(env.WARP_CLI_AGENT_PROTOCOL_VERSION ?? "", 10);
|
|
176
|
+
if (!Number.isFinite(v) || v < 1)
|
|
177
|
+
return null;
|
|
178
|
+
const client = env.WARP_CLIENT_VERSION ?? "";
|
|
179
|
+
if (!client)
|
|
180
|
+
return null;
|
|
181
|
+
// Builds that advertised the protocol before they could render it.
|
|
182
|
+
const broken = client.includes("stable") ? "v0.2026.03.25.08.24.stable_05"
|
|
183
|
+
: client.includes("preview") ? "v0.2026.03.25.08.24.preview_05" : "";
|
|
184
|
+
if (broken && !(client > broken))
|
|
185
|
+
return null;
|
|
186
|
+
return Math.min(1, v);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Turns successive reads of the session row into terminal sequences. `emit` must deliver them only at
|
|
190
|
+
* a safe point in the stream (see StreamFilter); this class only decides WHAT to say and WHEN.
|
|
191
|
+
*/
|
|
192
|
+
export class TermStatus {
|
|
193
|
+
emit;
|
|
194
|
+
state = null;
|
|
195
|
+
shown = "";
|
|
196
|
+
row;
|
|
197
|
+
warp;
|
|
198
|
+
inTmux;
|
|
199
|
+
constructor(row, emit, env = process.env) {
|
|
200
|
+
this.emit = emit;
|
|
201
|
+
this.row = row;
|
|
202
|
+
this.warp = warpProtocol(env);
|
|
203
|
+
this.inTmux = !!env.TMUX;
|
|
204
|
+
}
|
|
205
|
+
title() {
|
|
206
|
+
const glyph = this.state === "working" ? WORKING : this.state === "waiting" ? WAITING : IDLE;
|
|
207
|
+
return `${glyph} ${clean(this.row.title || "Cubes session", 80)}`;
|
|
208
|
+
}
|
|
209
|
+
/** Only when it changed: the activity is re-read every few seconds, and most reads change nothing. */
|
|
210
|
+
setTitle() {
|
|
211
|
+
const t = this.title();
|
|
212
|
+
if (t === this.shown)
|
|
213
|
+
return;
|
|
214
|
+
this.shown = t;
|
|
215
|
+
this.emit(`\x1b]0;${t}\x07`);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Notifications (OSC 9, OSC 777) are for the terminal the PERSON sees. Inside tmux - the split view
|
|
219
|
+
* runs everything in one - tmux would swallow them, so they travel as tmux's DCS passthrough (every
|
|
220
|
+
* ESC doubled), which reaches the outer terminal when `allow-passthrough` is on (the split view turns
|
|
221
|
+
* it on). The bell needs none of this: tmux forwards a bell by itself.
|
|
222
|
+
*/
|
|
223
|
+
outward(seq) {
|
|
224
|
+
return this.inTmux ? `\x1bPtmux;${seq.replace(/\x1b/g, "\x1b\x1b")}\x1b\\` : seq;
|
|
225
|
+
}
|
|
226
|
+
warpEvent(event, extra = {}) {
|
|
227
|
+
if (this.warp === null)
|
|
228
|
+
return;
|
|
229
|
+
const body = JSON.stringify({
|
|
230
|
+
v: this.warp, agent: "claude", event, session_id: this.row.id, cwd: "",
|
|
231
|
+
project: clean(this.row.title || "cubes", 60), ...extra,
|
|
232
|
+
});
|
|
233
|
+
this.emit(this.outward(`\x1b]777;notify;warp://cli-agent;${body}\x07`));
|
|
234
|
+
}
|
|
235
|
+
/** Feed the latest row. The first call only establishes the state - nobody needs to be told that a
|
|
236
|
+
* session they are attaching to right now is waiting for them. */
|
|
237
|
+
update(row) {
|
|
238
|
+
this.row = row;
|
|
239
|
+
const next = stateOf(row);
|
|
240
|
+
const first = this.state === null;
|
|
241
|
+
const prev = this.state;
|
|
242
|
+
// Only the step INTO waiting. Claude Code reports one wait in several hook events (measured: the
|
|
243
|
+
// question itself, then "Claude needs your permission" for the same question a moment later), and
|
|
244
|
+
// each moves activity_at, so keying on it rang twice for one thing to answer.
|
|
245
|
+
const newWait = next === "waiting" && prev !== "waiting";
|
|
246
|
+
this.state = next;
|
|
247
|
+
this.setTitle();
|
|
248
|
+
if (first) {
|
|
249
|
+
this.warpEvent("session_start", { plugin_version: WARP_PLUGIN_COMPAT });
|
|
250
|
+
if (next === "working")
|
|
251
|
+
this.warpEvent("prompt_submit", { query: "" });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (next === "working" && prev !== "working")
|
|
255
|
+
this.warpEvent("prompt_submit", { query: "" });
|
|
256
|
+
if (next === "idle" && prev === "working") {
|
|
257
|
+
this.warpEvent("stop", { query: "", response: clean(row.activity_message ?? "", 200), transcript_path: "" });
|
|
258
|
+
}
|
|
259
|
+
if (newWait) {
|
|
260
|
+
const summary = clean(row.activity_message || "Waiting for you", 120);
|
|
261
|
+
if (this.warp !== null) {
|
|
262
|
+
if (row.activity_reason === "permission") {
|
|
263
|
+
this.warpEvent("permission_request", { summary, tool_name: "", tool_input: {} });
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
this.warpEvent("idle_prompt", { summary });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else if (!row.muted_at) {
|
|
270
|
+
// Bell first (marks the tab nearly everywhere), then the desktop notification where supported.
|
|
271
|
+
// "Cubes" leads the text so it can never be read as ConEmu's numeric OSC 9 sub-commands.
|
|
272
|
+
this.emit(`\x07${this.outward(`\x1b]9;Cubes · ${clean(row.title || "session", 60)}: ${summary}\x07`)}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// `cubes` with no arguments: the split view. A local tmux on its own socket holds two panes - the
|
|
2
|
+
// session list on the left, the session on the right - so layout, resizing, mouse focus and the
|
|
3
|
+
// terminal emulation of the right pane are tmux's, which is the battle-tested part. Our two processes
|
|
4
|
+
// (`cubes _list`, `cubes _pane`) only draw and talk to the API.
|
|
5
|
+
//
|
|
6
|
+
// Run it again and you are back where you were: the tmux outlives the terminal that opened it.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { CliError, currentSession } from "../auth.js";
|
|
9
|
+
import { selfCommand, SESSION, SOCKET, tmux, tmuxVersion } from "./tmux.js";
|
|
10
|
+
const MIN_TMUX = 3.2;
|
|
11
|
+
/** Every option is set on OUR socket only, so none of this reaches a tmux the person already uses. */
|
|
12
|
+
const OPTIONS = [
|
|
13
|
+
["mouse", "on"],
|
|
14
|
+
["status", "off"],
|
|
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.
|
|
18
|
+
["prefix", "None"],
|
|
19
|
+
["prefix2", "None"],
|
|
20
|
+
// Escape must reach Claude Code at once: tmux's default 500 ms makes every Esc feel broken.
|
|
21
|
+
["escape-time", "10"],
|
|
22
|
+
["focus-events", "on"],
|
|
23
|
+
["pane-border-status", "top"],
|
|
24
|
+
["pane-border-format", " #{?#{@cubes_header},#{@cubes_header},#{pane_title}} "],
|
|
25
|
+
["pane-border-style", "fg=colour238"],
|
|
26
|
+
["pane-active-border-style", "fg=colour62"],
|
|
27
|
+
// The outer terminal's tab follows the session on the right, whichever pane has focus.
|
|
28
|
+
["set-titles", "on"],
|
|
29
|
+
["set-titles-string", "#{?#{@cubes_tab},#{@cubes_tab},Cubes}"],
|
|
30
|
+
// So a session's bell and notifications reach the terminal the person is looking at.
|
|
31
|
+
["bell-action", "any"],
|
|
32
|
+
["visual-bell", "off"],
|
|
33
|
+
["allow-passthrough", "on"],
|
|
34
|
+
];
|
|
35
|
+
function attachClient() {
|
|
36
|
+
const env = { ...process.env };
|
|
37
|
+
delete env.TMUX; // inside the person's own tmux: nest on purpose rather than refuse
|
|
38
|
+
const r = spawnSync("tmux", ["-L", SOCKET, "attach-session", "-t", SESSION], { stdio: "inherit", env });
|
|
39
|
+
return r.status ?? 0;
|
|
40
|
+
}
|
|
41
|
+
function build(org) {
|
|
42
|
+
const cols = process.stdout.columns ?? 160;
|
|
43
|
+
const rows = process.stdout.rows ?? 48;
|
|
44
|
+
const env = org ? ["-e", `CUBES_ORG=${org}`] : [];
|
|
45
|
+
const created = tmux(["new-session", "-d", "-s", SESSION, "-x", String(cols), "-y", String(rows), ...env,
|
|
46
|
+
"-P", "-F", "#{pane_id}", selfCommand(["_list"])]);
|
|
47
|
+
if (created.code !== 0)
|
|
48
|
+
throw new CliError(`could not start tmux: ${created.err || created.code}`);
|
|
49
|
+
const left = created.out;
|
|
50
|
+
for (const [k, v] of OPTIONS)
|
|
51
|
+
tmux(["set-option", "-g", "-q", k, v]);
|
|
52
|
+
// ⚠ The same fix the session's own tmux carries (server Tmux.clientDefaults): without `ech`, tmux
|
|
53
|
+
// draws gaps as real spaces. Warp left stale letters in every gap drawn with ECH. A SERVER option,
|
|
54
|
+
// read when a client attaches, so it is set before ours does.
|
|
55
|
+
tmux(["set-option", "-s", "terminal-overrides[90]", "*:ech@"]);
|
|
56
|
+
tmux(["set-option", "-w", "-t", SESSION, "-q", "remain-on-exit", "on"]);
|
|
57
|
+
// The list gets about a third, within reason: enough for a title, never most of the screen.
|
|
58
|
+
const listW = Math.max(32, Math.min(56, Math.round(cols * 0.3)));
|
|
59
|
+
const right = tmux(["split-window", "-h", "-t", left, "-l", String(Math.max(20, cols - listW - 1)), ...env,
|
|
60
|
+
"-P", "-F", "#{pane_id}", selfCommand(["_pane"])]);
|
|
61
|
+
if (right.code !== 0)
|
|
62
|
+
throw new CliError(`could not split the window: ${right.err || right.code}`);
|
|
63
|
+
tmux(["set-option", "-t", SESSION, "@cubes_left", left]);
|
|
64
|
+
tmux(["set-option", "-t", SESSION, "@cubes_right", right.out]);
|
|
65
|
+
tmux(["select-pane", "-t", left]);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Bring a dead pane back. `remain-on-exit` keeps a crashed pane on screen (with what it printed)
|
|
69
|
+
* instead of letting the layout collapse; reopening `cubes` restarts whichever of the two died.
|
|
70
|
+
*/
|
|
71
|
+
function heal() {
|
|
72
|
+
const panes = tmux(["list-panes", "-t", SESSION, "-F", "#{pane_id} #{pane_dead}"]).out.split("\n");
|
|
73
|
+
const left = tmux(["show-options", "-v", "-q", "-t", SESSION, "@cubes_left"]).out;
|
|
74
|
+
for (const line of panes) {
|
|
75
|
+
const [id, dead] = line.split(" ");
|
|
76
|
+
if (dead !== "1" || !id)
|
|
77
|
+
continue;
|
|
78
|
+
tmux(["respawn-pane", "-k", "-t", id, selfCommand(id === left ? ["_list"] : ["_pane"])]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function launchTui(opts) {
|
|
82
|
+
const v = tmuxVersion();
|
|
83
|
+
if (v === null) {
|
|
84
|
+
throw new CliError("the split view runs inside tmux, which is not installed - `brew install tmux` (macOS) or "
|
|
85
|
+
+ "`apt install tmux`. `cubes attach` works without it.");
|
|
86
|
+
}
|
|
87
|
+
if (v < MIN_TMUX)
|
|
88
|
+
throw new CliError(`the split view needs tmux ${MIN_TMUX} or newer (this is ${v}) - upgrade it, or use \`cubes attach\``);
|
|
89
|
+
// Fail here, with a sentence, rather than in two panes at once.
|
|
90
|
+
await currentSession({ org: opts.org }).token();
|
|
91
|
+
if (tmux(["has-session", "-t", SESSION]).code === 0) {
|
|
92
|
+
heal();
|
|
93
|
+
tmux(["set-option", "-s", "terminal-overrides[90]", "*:ech@"]); // a layout built by an older cubes
|
|
94
|
+
}
|
|
95
|
+
else
|
|
96
|
+
build(opts.org);
|
|
97
|
+
return attachClient();
|
|
98
|
+
}
|