moshcode 0.34.0 → 0.36.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 +76 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +12 -0
- package/src/herd-cli.mjs +20 -7
- package/src/herd-tile.mjs +168 -0
- package/src/herd-ui.mjs +318 -0
- package/src/herd-workspace.mjs +292 -0
- package/src/herd.mjs +82 -12
package/README.md
CHANGED
|
@@ -125,6 +125,82 @@ moshcode attach api # step in; Ctrl-b d steps back out
|
|
|
125
125
|
moshcode kill api # end it
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
### Everything on screen at once
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
moshcode herd tile
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
┌─ work ──────────────────┬─ logs ──────────────────┐
|
|
136
|
+
│ $ npm test │ tailing deploy.log │
|
|
137
|
+
│ ✓ 1492 passing │ 12:04 build ok │
|
|
138
|
+
├─ api ───────────────────┴─────────────────────────┤
|
|
139
|
+
│ claude — Do you want to proceed? │
|
|
140
|
+
│ ❯ 1. Yes 2. No │
|
|
141
|
+
└───────────────────────────────────────────────────┘
|
|
142
|
+
herd S:shell A:agent X:stop B:pop out z:zoom
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Every member becomes a tile in one window. Click a tile to focus it, `Ctrl-b z`
|
|
146
|
+
to blow it up full-screen and again to come back, `Ctrl-b d` to leave the lot
|
|
147
|
+
running. Start and stop without leaving:
|
|
148
|
+
|
|
149
|
+
| key | |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `Ctrl-b S` | new shell tile |
|
|
152
|
+
| `Ctrl-b A` | new claude tile |
|
|
153
|
+
| `Ctrl-b X` | stop the focused tile |
|
|
154
|
+
| `Ctrl-b B` | pop it out into its own session |
|
|
155
|
+
| `Ctrl-b z` | zoom / unzoom |
|
|
156
|
+
|
|
157
|
+
`moshcode herd untile` puts them all back in their own sessions. Tiling is just
|
|
158
|
+
a view — the processes never restart, and a tiled member stays on `moshcode ps`
|
|
159
|
+
and answers `read`, `prompt` and `wait` exactly as before.
|
|
160
|
+
|
|
161
|
+
Needs tmux. The `script(1)` fallback gives each session its own pty with no way
|
|
162
|
+
to lay them out together, so it says so and points at the list instead.
|
|
163
|
+
|
|
164
|
+
### The workspace
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
moshcode herd ui
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
┌ herd ──────┬─ api ─────────────────────────────┐
|
|
172
|
+
│ herd │ │
|
|
173
|
+
│ │ claude │
|
|
174
|
+
│ MAIN │ Do you want to proceed? │
|
|
175
|
+
│ ▸ ! api │ ❯ 1. Yes │
|
|
176
|
+
│ · work │ 2. No │
|
|
177
|
+
│ SCRATCH │ │
|
|
178
|
+
│ · logs │ │
|
|
179
|
+
│ │ │
|
|
180
|
+
│ ACTIONS │ │
|
|
181
|
+
│ + shell │ │
|
|
182
|
+
│ + agent │ │
|
|
183
|
+
│ ✕ stop │ │
|
|
184
|
+
│ ⊞ tile │ │
|
|
185
|
+
│ ← detach │ │
|
|
186
|
+
└────────────┴───────────────────────────────────┘
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Members and actions down the left, the selected member's **real terminal** on
|
|
190
|
+
the right. Click a member to show it; click an action to start a shell, start an
|
|
191
|
+
agent, or stop the selected one. `q` detaches and leaves everything running.
|
|
192
|
+
|
|
193
|
+
The right-hand pane is not a picture of a session — it *is* the session's pane,
|
|
194
|
+
moved in. tmux's model is session → window → pane, so moving between *windows*
|
|
195
|
+
cannot keep anything on screen; but `join-pane` moves a running pane into an
|
|
196
|
+
existing window, so swapping only the content pane leaves the sidebar untouched.
|
|
197
|
+
Both panes keep their process and their scrollback because tmux is moving the
|
|
198
|
+
real thing, not redrawing it.
|
|
199
|
+
|
|
200
|
+
Group sessions with `--herd <name>` when you start them; anything without one is
|
|
201
|
+
in `main`. Without tmux there is nothing to swap panes with, so `herd ui` falls
|
|
202
|
+
back to a plain clickable list.
|
|
203
|
+
|
|
128
204
|
### A workspace: a few shells and an agent
|
|
129
205
|
|
|
130
206
|
This is what most people actually want — a couple of shells to work in and an
|
package/package.json
CHANGED
package/src/cli-schema.mjs
CHANGED
|
@@ -79,6 +79,8 @@ export const CORE_CLI_COMMANDS = [
|
|
|
79
79
|
verbs: "HERD_VERBS",
|
|
80
80
|
flags: [["--json", "machine-readable, on every verb", ""]],
|
|
81
81
|
examples: [
|
|
82
|
+
["moshcode herd ui", "the clickable list — start here"],
|
|
83
|
+
["", ""],
|
|
82
84
|
["# a workspace: two shells and an agent, none of which die with this terminal", ""],
|
|
83
85
|
["moshcode herd shell --name work", "a plain $SHELL you can come back to"],
|
|
84
86
|
["moshcode herd shell --name logs", "another one"],
|
|
@@ -736,6 +738,16 @@ export const HERD_VERBS = [
|
|
|
736
738
|
["--agent", "autonomous mode — bypasses the engine's approvals", ""],
|
|
737
739
|
["--json", "machine-readable", ""],
|
|
738
740
|
] },
|
|
741
|
+
{ name: "tile", description: "every member on screen at once, in a tiled window",
|
|
742
|
+
synopsis: [["moshcode herd tile [herd]", "click a tile to focus · Ctrl-b z zooms · Ctrl-b d leaves them running"]],
|
|
743
|
+
flags: [],
|
|
744
|
+
examples: [["moshcode herd tile", "all of them"], ["moshcode herd tile scratch", "one herd"]] },
|
|
745
|
+
{ name: "untile", description: "put tiled members back in their own sessions",
|
|
746
|
+
synopsis: [["moshcode herd untile", ""]] },
|
|
747
|
+
{ name: "ui", description: "sidebar of members and actions, selected one beside it",
|
|
748
|
+
synopsis: [["moshcode herd ui", "click a member to show it · s shell · a agent · x stop · q detach"]],
|
|
749
|
+
flags: [],
|
|
750
|
+
examples: [["moshcode herd ui", "the workspace — start here"]] },
|
|
739
751
|
{ name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
|
|
740
752
|
synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
|
|
741
753
|
flags: [
|
package/src/herd-cli.mjs
CHANGED
|
@@ -130,13 +130,15 @@ export function herdStart(argv, { write = console.log } = {}) {
|
|
|
130
130
|
const substrate = requireSubstrate(write);
|
|
131
131
|
if (!substrate) return EXIT.usage;
|
|
132
132
|
|
|
133
|
-
const flags = { name: null, cwd: process.cwd(), agent: false, json: false };
|
|
133
|
+
const flags = { name: null, cwd: process.cwd(), agent: false, json: false, herd: "main" };
|
|
134
134
|
const rest = [];
|
|
135
135
|
for (let i = 0; i < argv.length; i++) {
|
|
136
136
|
const a = argv[i];
|
|
137
137
|
if (a === "--name") flags.name = argv[++i];
|
|
138
138
|
else if (a.startsWith("--name=")) flags.name = a.slice(7);
|
|
139
139
|
else if (a === "--cwd") flags.cwd = path.resolve(argv[++i] || ".");
|
|
140
|
+
else if (a === "--herd") flags.herd = slugifyName(argv[++i]);
|
|
141
|
+
else if (a.startsWith("--herd=")) flags.herd = slugifyName(a.slice(7));
|
|
140
142
|
else if (a === "--agent") flags.agent = true;
|
|
141
143
|
else if (a === "--json") flags.json = true;
|
|
142
144
|
else rest.push(a);
|
|
@@ -168,10 +170,10 @@ export function herdStart(argv, { write = console.log } = {}) {
|
|
|
168
170
|
write(err(String(started.error?.message || started.error)));
|
|
169
171
|
return EXIT.usage;
|
|
170
172
|
}
|
|
171
|
-
rememberSession(name, { agent: flags.agent });
|
|
173
|
+
rememberSession(name, { agent: flags.agent, herd: flags.herd });
|
|
172
174
|
|
|
173
175
|
if (flags.json) {
|
|
174
|
-
write(JSON.stringify({ name, engine: key, cwd: flags.cwd, substrate, agent: flags.agent }, null, 2));
|
|
176
|
+
write(JSON.stringify({ name, engine: key, herd: flags.herd, cwd: flags.cwd, substrate, agent: flags.agent }, null, 2));
|
|
175
177
|
return EXIT.matched;
|
|
176
178
|
}
|
|
177
179
|
write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`));
|
|
@@ -203,7 +205,7 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
|
|
|
203
205
|
const substrate = requireSubstrate(write);
|
|
204
206
|
if (!substrate) return EXIT.usage;
|
|
205
207
|
|
|
206
|
-
const flags = { name: null, cwd: process.cwd(), json: false };
|
|
208
|
+
const flags = { name: null, cwd: process.cwd(), json: false, herd: "main" };
|
|
207
209
|
const command = [];
|
|
208
210
|
let afterSeparator = false;
|
|
209
211
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -215,6 +217,8 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
|
|
|
215
217
|
else if (a === "--name") flags.name = argv[++i];
|
|
216
218
|
else if (a.startsWith("--name=")) flags.name = a.slice(7);
|
|
217
219
|
else if (a === "--cwd") flags.cwd = path.resolve(argv[++i] || ".");
|
|
220
|
+
else if (a === "--herd") flags.herd = slugifyName(argv[++i]);
|
|
221
|
+
else if (a.startsWith("--herd=")) flags.herd = slugifyName(a.slice(7));
|
|
218
222
|
else if (a === "--json") flags.json = true;
|
|
219
223
|
else command.push(a);
|
|
220
224
|
}
|
|
@@ -242,9 +246,10 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
|
|
|
242
246
|
write(err(String(started.error?.message || started.error)));
|
|
243
247
|
return EXIT.usage;
|
|
244
248
|
}
|
|
249
|
+
rememberSession(name, { herd: flags.herd });
|
|
245
250
|
|
|
246
251
|
if (flags.json) {
|
|
247
|
-
write(JSON.stringify({ name, engine: label, cwd: flags.cwd, substrate }, null, 2));
|
|
252
|
+
write(JSON.stringify({ name, engine: label, herd: flags.herd, cwd: flags.cwd, substrate }, null, 2));
|
|
248
253
|
return EXIT.matched;
|
|
249
254
|
}
|
|
250
255
|
write(ok(`${bone(name)} — ${label} running in the herd. the prompt is yours.`));
|
|
@@ -281,8 +286,8 @@ export function splitDetachArgs(args = []) {
|
|
|
281
286
|
export function herdPs(argv, { write = console.log } = {}) {
|
|
282
287
|
const rows = roster();
|
|
283
288
|
if (argv.includes("--json")) {
|
|
284
|
-
write(JSON.stringify(rows.map(({ name, engine, state, authority, cwd, age, alive, attached, substrate }) => ({
|
|
285
|
-
name, engine, state, authority, cwd, ageMs: age, alive, attached, substrate,
|
|
289
|
+
write(JSON.stringify(rows.map(({ name, engine, herd, state, authority, cwd, age, alive, attached, substrate }) => ({
|
|
290
|
+
name, engine, herd, state, authority, cwd, ageMs: age, alive, attached, substrate,
|
|
286
291
|
})), null, 2));
|
|
287
292
|
return EXIT.matched;
|
|
288
293
|
}
|
|
@@ -718,6 +723,14 @@ export function herdStop(argv, { write = console.log } = {}) {
|
|
|
718
723
|
// ---------------------------------------------------------------------------
|
|
719
724
|
|
|
720
725
|
const VERBS = {
|
|
726
|
+
// Lazy import: the UI pulls in escape-sequence machinery and only matters
|
|
727
|
+
// when someone asks for it, and herd-ui imports roster() from this file.
|
|
728
|
+
// `ui` is the sidebar workspace; the old modal list lives on as the fallback
|
|
729
|
+
// inside it for machines with no tmux to swap panes on.
|
|
730
|
+
ui: async (argv, options) => (await import("./herd-workspace.mjs")).herdUi(argv, options),
|
|
731
|
+
sidebar: async (argv, options) => (await import("./herd-workspace.mjs")).herdSidebar(options),
|
|
732
|
+
tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
|
|
733
|
+
untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
|
|
721
734
|
ps: herdPs, list: herdPs, status: herdStatus,
|
|
722
735
|
start: herdStart, run: herdRun, shell: herdShell,
|
|
723
736
|
attach: herdAttach, kill: herdKill, prune: herdPrune,
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// `moshcode herd tile` — every member on screen at once, in a tiled window.
|
|
2
|
+
//
|
|
3
|
+
// The clickable list was modal: you saw the herd OR a session, never both, and
|
|
4
|
+
// nothing on it could start or stop anything. This is the other shape — all
|
|
5
|
+
// members visible, click one to focus it, Ctrl-b z to blow it up, keys to start
|
|
6
|
+
// and stop without leaving.
|
|
7
|
+
//
|
|
8
|
+
// It is tmux doing the work, not us. `join-pane` moves a running pane out of
|
|
9
|
+
// its own session and into a shared window with its content and process intact;
|
|
10
|
+
// `select-layout tiled` arranges them; `break-pane` puts one back. Every pane
|
|
11
|
+
// stays a real terminal the whole time, which is the thing a hand-rolled
|
|
12
|
+
// side-by-side renderer cannot promise.
|
|
13
|
+
//
|
|
14
|
+
// This is why a member is identified by its pane title rather than its session
|
|
15
|
+
// (see paneIndex in herd.mjs): a tiled member's session no longer exists, and
|
|
16
|
+
// the roster, `read`, `prompt` and `wait` all have to keep working anyway.
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
|
|
20
|
+
import { acid, ash, err, info, ok } from "./ui.mjs";
|
|
21
|
+
|
|
22
|
+
/** The window every tiled member is gathered into. */
|
|
23
|
+
export const TILE_SESSION = "tile";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Keys bound inside the tiled window.
|
|
27
|
+
*
|
|
28
|
+
* Bound on moshcode's own server, which starts from no config, so this
|
|
29
|
+
* overrides nobody's preference. They are deliberately shifted letters: tmux's
|
|
30
|
+
* own lowercase bindings (z zoom, x kill, arrows, o next) still work, and this
|
|
31
|
+
* only adds what tmux has no opinion about.
|
|
32
|
+
*/
|
|
33
|
+
export function tileBindings({ moshcode = "moshcode" } = {}) {
|
|
34
|
+
return [
|
|
35
|
+
// Start: a shell, or an agent, as a new tile in the same directory.
|
|
36
|
+
["S", `split-window -c "#{pane_current_path}" ; select-layout tiled`],
|
|
37
|
+
["A", `split-window -c "#{pane_current_path}" ${moshcode} agents claude ; select-layout tiled`],
|
|
38
|
+
// Stop, without the confirm-before-kill prompt tmux puts on lowercase x —
|
|
39
|
+
// this is a tile you are looking at, not a blind target.
|
|
40
|
+
["X", "kill-pane ; select-layout tiled"],
|
|
41
|
+
// Send the focused member back to a session of its own.
|
|
42
|
+
["B", "break-pane"],
|
|
43
|
+
// Re-tile after any manual resize.
|
|
44
|
+
["T", "select-layout tiled"],
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const STATUS_LEFT = ` ${" "}#[bold]herd#[default] `;
|
|
49
|
+
const STATUS_RIGHT = " S:shell A:agent X:stop B:pop out z:zoom T:re-tile ";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Gather every live member into one tiled window and attach to it.
|
|
53
|
+
*
|
|
54
|
+
* Members already in the tile window are left alone, so running this twice is
|
|
55
|
+
* not destructive and picks up anything started since.
|
|
56
|
+
*/
|
|
57
|
+
export async function herdTile(argv = [], { write = console.log, spawner = spawn, runner = spawnSync } = {}) {
|
|
58
|
+
const substrate = detectSubstrate();
|
|
59
|
+
if (substrate !== "tmux") {
|
|
60
|
+
write(err("tiling needs tmux."));
|
|
61
|
+
write(info(substrate === "pty"
|
|
62
|
+
? "the script(1) fallback has one pty per session and no way to lay them out together — `moshcode herd ui` still works."
|
|
63
|
+
: "install tmux, or use `moshcode ps`."));
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const wanted = argv.find((a) => !a.startsWith("-")) || null;
|
|
68
|
+
const manifest = readManifest();
|
|
69
|
+
const panes = paneIndex({ runner });
|
|
70
|
+
const members = [...panes.entries()]
|
|
71
|
+
.filter(([name]) => name in manifest.sessions)
|
|
72
|
+
.filter(([name]) => !wanted || (manifest.sessions[name].herd || "main") === wanted);
|
|
73
|
+
|
|
74
|
+
if (!members.length) {
|
|
75
|
+
write(info("nothing to tile — start something with `moshcode herd shell` or `moshcode agents claude -d`."));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The window everything lands in. Created from the first member rather than
|
|
80
|
+
// as an empty shell, so the tile has no spare pane sitting in it doing
|
|
81
|
+
// nothing. If it already exists we just join into it.
|
|
82
|
+
const existing = tmux(["has-session", "-t", TILE_SESSION], { runner });
|
|
83
|
+
if (!existing.ok) {
|
|
84
|
+
const [firstName, first] = members[0];
|
|
85
|
+
const made = tmux(["new-session", "-d", "-s", TILE_SESSION, "-n", "herd"], { runner });
|
|
86
|
+
if (!made.ok) { write(err(made.stderr.trim() || "could not create the tile window")); return 1; }
|
|
87
|
+
tmux(["join-pane", "-s", first.paneId, "-t", `${TILE_SESSION}:herd`], { runner });
|
|
88
|
+
// new-session made a placeholder pane; the first member replaced nothing,
|
|
89
|
+
// so drop the placeholder now that there is something real beside it.
|
|
90
|
+
const inWindow = tmux(["list-panes", "-t", `${TILE_SESSION}:herd`, "-F", "#{pane_id}\t#{pane_title}"], { runner });
|
|
91
|
+
for (const line of inWindow.stdout.split("\n")) {
|
|
92
|
+
const [paneId, title] = line.split("\t");
|
|
93
|
+
if (paneId && title !== firstName && !manifest.sessions[title]) {
|
|
94
|
+
tmux(["kill-pane", "-t", paneId], { runner });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let joined = 0;
|
|
100
|
+
for (const [, pane] of members) {
|
|
101
|
+
if (pane.session === TILE_SESSION) continue; // already on the layout
|
|
102
|
+
const r = tmux(["join-pane", "-s", pane.paneId, "-t", `${TILE_SESSION}:herd`], { runner });
|
|
103
|
+
if (r.ok) joined++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [key, command] of tileBindings()) {
|
|
107
|
+
tmux(["bind-key", "-T", "prefix", key, ...command.split(" ")], { runner });
|
|
108
|
+
}
|
|
109
|
+
tmux(["set-option", "-t", TILE_SESSION, "mouse", "on"], { runner });
|
|
110
|
+
tmux(["set-option", "-t", TILE_SESSION, "pane-border-status", "top"], { runner });
|
|
111
|
+
// The border carries the member's name, so a tiled screen is readable without
|
|
112
|
+
// a legend anywhere else.
|
|
113
|
+
tmux(["set-option", "-t", TILE_SESSION, "pane-border-format", " #{pane_title} "], { runner });
|
|
114
|
+
tmux(["set-option", "-t", TILE_SESSION, "status-left-length", "20"], { runner });
|
|
115
|
+
tmux(["set-option", "-t", TILE_SESSION, "status-right-length", "80"], { runner });
|
|
116
|
+
tmux(["set-option", "-t", TILE_SESSION, "status-left", STATUS_LEFT], { runner });
|
|
117
|
+
tmux(["set-option", "-t", TILE_SESSION, "status-right", STATUS_RIGHT], { runner });
|
|
118
|
+
tmux(["select-layout", "-t", `${TILE_SESSION}:herd`, "tiled"], { runner });
|
|
119
|
+
|
|
120
|
+
write(ok(`tiling ${members.length} member${members.length === 1 ? "" : "s"}${joined ? "" : " (already laid out)"} — Ctrl-b d to leave them running`));
|
|
121
|
+
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
let child;
|
|
124
|
+
try { child = spawner("tmux", ["-L", HERD_SOCKET, "attach-session", "-t", TILE_SESSION], { stdio: "inherit" }); }
|
|
125
|
+
catch (error) { write(err(String(error.message || error))); resolve(1); return; }
|
|
126
|
+
child.on("error", (error) => { write(err(String(error.message || error))); resolve(1); });
|
|
127
|
+
child.on("exit", () => {
|
|
128
|
+
write(info(`detached — everything is still running. ${acid("moshcode ps")} · ${acid("moshcode herd tile")}`));
|
|
129
|
+
resolve(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Put every tiled member back into a session of its own.
|
|
136
|
+
*
|
|
137
|
+
* The inverse of tiling, and worth having explicitly: a layout is a view, and
|
|
138
|
+
* anyone who wants their sessions back should not have to know that
|
|
139
|
+
* `break-pane` is the word for it.
|
|
140
|
+
*/
|
|
141
|
+
export function herdUntile(argv = [], { write = console.log, runner = spawnSync } = {}) {
|
|
142
|
+
if (detectSubstrate() !== "tmux") { write(err("tiling needs tmux.")); return 1; }
|
|
143
|
+
const manifest = readManifest();
|
|
144
|
+
const tiled = [...paneIndex({ runner }).entries()]
|
|
145
|
+
.filter(([name, pane]) => pane.session === TILE_SESSION && name in manifest.sessions);
|
|
146
|
+
if (!tiled.length) { write(info("nothing is tiled.")); return 0; }
|
|
147
|
+
|
|
148
|
+
// `break-pane` moves a pane into a new *window*, always in a session that
|
|
149
|
+
// already exists — its `-t` is a destination window, not a name to create.
|
|
150
|
+
// Getting a pane into a session of its own is therefore the same dance as
|
|
151
|
+
// tiling, run backwards: make the session, join the pane into it, then drop
|
|
152
|
+
// the placeholder pane the new session was born with.
|
|
153
|
+
let restored = 0;
|
|
154
|
+
for (const [name, pane] of tiled) {
|
|
155
|
+
const made = tmux(["new-session", "-d", "-s", name, "-n", name], { runner });
|
|
156
|
+
if (!made.ok) { write(err(`${name}: ${made.stderr.trim() || "could not recreate its session"}`)); continue; }
|
|
157
|
+
const placeholder = tmux(["list-panes", "-t", name, "-F", "#{pane_id}"], { runner }).stdout.trim();
|
|
158
|
+
const joined = tmux(["join-pane", "-s", pane.paneId, "-t", `${name}:${name}`], { runner });
|
|
159
|
+
if (!joined.ok) { write(err(`${name}: ${joined.stderr.trim() || "could not move it back"}`)); continue; }
|
|
160
|
+
if (placeholder) tmux(["kill-pane", "-t", placeholder], { runner });
|
|
161
|
+
restored++;
|
|
162
|
+
}
|
|
163
|
+
// Nothing is left in it, and an empty tile window on the roster is confusing.
|
|
164
|
+
tmux(["kill-session", "-t", TILE_SESSION], { runner });
|
|
165
|
+
write(ok(`${restored} member${restored === 1 ? "" : "s"} back in their own sessions.`));
|
|
166
|
+
write(info(`${ash("attach one with")} ${acid("moshcode attach <name>")}`));
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
package/src/herd-ui.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// `moshcode herd ui` — the clickable list of herds and their members.
|
|
2
|
+
//
|
|
3
|
+
// The roster answers "what is running" in a line each, which is right for a
|
|
4
|
+
// pipe and wrong for a person with five sessions who wants to get into one of
|
|
5
|
+
// them. This is the same information as a place you can point at: herds as
|
|
6
|
+
// headings, members under them, click one to go in, detach to come back.
|
|
7
|
+
//
|
|
8
|
+
// WHY THIS AND NOT A SIDEBAR. tmux's model is session > window > pane, and a
|
|
9
|
+
// pane belongs to exactly one window — so nothing can stay on screen across a
|
|
10
|
+
// switch. A list pinned beside a live session is therefore impossible with tmux
|
|
11
|
+
// underneath, and doing it anyway would mean rendering every session ourselves
|
|
12
|
+
// from `capture-pane` polls: no real cursor, no real mouse inside the agent,
|
|
13
|
+
// full-screen UIs flickering at the refresh rate. That is rebuilding herdr, and
|
|
14
|
+
// worse. So this list hands the whole terminal to a *real* attach and takes it
|
|
15
|
+
// back on detach — you lose side-by-side, you keep a real terminal.
|
|
16
|
+
//
|
|
17
|
+
// No dependencies, for the same reason as everything else here: moshcode
|
|
18
|
+
// installs by untarring a release and running node. Alternate screen, SGR mouse
|
|
19
|
+
// reporting and raw-mode keys are a few escape sequences, and every one of them
|
|
20
|
+
// is undone in a single restore path so a crash cannot leave a terminal with no
|
|
21
|
+
// cursor and the mouse captured.
|
|
22
|
+
import { attachSession, detectSubstrate, substrateNote } from "./herd.mjs";
|
|
23
|
+
import { roster } from "./herd-cli.mjs";
|
|
24
|
+
import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
|
|
25
|
+
|
|
26
|
+
/** The herd a session has no opinion about. */
|
|
27
|
+
export const DEFAULT_HERD = "main";
|
|
28
|
+
|
|
29
|
+
const ESC = {
|
|
30
|
+
altOn: "\x1b[?1049h", altOff: "\x1b[?1049l",
|
|
31
|
+
hideCursor: "\x1b[?25l", showCursor: "\x1b[?25h",
|
|
32
|
+
// 1000 = report button press/release, 1006 = SGR encoding, which is the only
|
|
33
|
+
// one that survives past column 95 — the older scheme packs coordinates into
|
|
34
|
+
// single bytes and simply cannot express a click on a wide terminal.
|
|
35
|
+
mouseOn: "\x1b[?1000h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1000l",
|
|
36
|
+
clear: "\x1b[2J\x1b[H",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Group the roster into herds.
|
|
41
|
+
*
|
|
42
|
+
* Sessions carry their herd in the manifest; anything written before herds
|
|
43
|
+
* existed has none, and lands in `main` rather than in a group called
|
|
44
|
+
* "undefined".
|
|
45
|
+
*/
|
|
46
|
+
export function groupByHerd(sessions) {
|
|
47
|
+
const herds = new Map();
|
|
48
|
+
for (const session of sessions) {
|
|
49
|
+
const key = session.herd || DEFAULT_HERD;
|
|
50
|
+
if (!herds.has(key)) herds.set(key, []);
|
|
51
|
+
herds.get(key).push(session);
|
|
52
|
+
}
|
|
53
|
+
return [...herds.entries()]
|
|
54
|
+
.sort(([a], [b]) => (a === DEFAULT_HERD ? -1 : b === DEFAULT_HERD ? 1 : a.localeCompare(b)))
|
|
55
|
+
.map(([name, members]) => ({ name, members: members.sort((a, b) => a.name.localeCompare(b.name)) }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Flatten the groups into the rows the screen actually shows, so a click at
|
|
60
|
+
* line N and the highlighted row are the same lookup rather than two pieces of
|
|
61
|
+
* arithmetic that can disagree.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* How many lines render() prints before the first group heading — the title and
|
|
65
|
+
* the blank under it.
|
|
66
|
+
*
|
|
67
|
+
* Shared by render() and layout() rather than written twice, because the two
|
|
68
|
+
* disagreeing is not a cosmetic bug: layout() is the click map, so a
|
|
69
|
+
* one-line drift silently sends every click to the row below the one under the
|
|
70
|
+
* pointer. A test pins them together.
|
|
71
|
+
*/
|
|
72
|
+
export const HEADER_LINES = 2;
|
|
73
|
+
|
|
74
|
+
export function layout(groups, { top = HEADER_LINES + 1 } = {}) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
for (const group of groups) {
|
|
77
|
+
rows.push({ kind: "herd", herd: group.name, count: group.members.length });
|
|
78
|
+
for (const session of group.members) rows.push({ kind: "session", session, herd: group.name });
|
|
79
|
+
rows.push({ kind: "gap" });
|
|
80
|
+
}
|
|
81
|
+
if (rows.length) rows.pop(); // no trailing gap
|
|
82
|
+
return rows.map((row, i) => ({ ...row, line: top + i }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
|
|
86
|
+
|
|
87
|
+
const paint = (state, text) =>
|
|
88
|
+
state === "blocked" ? amber(text)
|
|
89
|
+
: state === "working" ? acid(text)
|
|
90
|
+
: state === "done" ? bone(text)
|
|
91
|
+
: state === "gone" ? danger(text)
|
|
92
|
+
: ash(text);
|
|
93
|
+
|
|
94
|
+
/** One frame. Pure, so the whole screen can be asserted in a test. */
|
|
95
|
+
export function render(rows, { selected = 0, width = 80, substrate = "tmux" } = {}) {
|
|
96
|
+
const out = [];
|
|
97
|
+
const total = rows.filter((r) => r.kind === "session").length;
|
|
98
|
+
const blocked = rows.filter((r) => r.kind === "session" && r.session.state === "blocked").length;
|
|
99
|
+
out.push(` ${bone("moshcode herd")}${ash(` ${total} member${total === 1 ? "" : "s"}`)}${blocked ? amber(` ${blocked} waiting on you`) : ""}`);
|
|
100
|
+
out.push("");
|
|
101
|
+
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
if (row.kind === "gap") { out.push(""); continue; }
|
|
104
|
+
if (row.kind === "herd") {
|
|
105
|
+
out.push(` ${ash(row.herd.toUpperCase())} ${dim(`(${row.count})`)}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const s = row.session;
|
|
109
|
+
const isSelected = rows[selected] === row;
|
|
110
|
+
const cursor = isSelected ? acid("▸") : " ";
|
|
111
|
+
const mark = paint(s.state, MARK[s.state] || "?");
|
|
112
|
+
const name = isSelected ? bone(s.name.padEnd(12)) : ash(s.name.padEnd(12));
|
|
113
|
+
const engine = ash(String(s.engine).padEnd(10));
|
|
114
|
+
const state = paint(s.state, s.state.padEnd(8));
|
|
115
|
+
const cwd = dim(tilde(s.cwd || "").slice(0, Math.max(10, width - 48)));
|
|
116
|
+
out.push(` ${cursor} ${mark} ${name} ${engine} ${state} ${cwd}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!total) {
|
|
120
|
+
out.push(` ${ash("the herd is empty.")}`);
|
|
121
|
+
out.push(` ${ash("start something with")} ${acid("moshcode herd shell")} ${ash("or")} ${acid("moshcode agents claude -d")}`);
|
|
122
|
+
}
|
|
123
|
+
out.push("");
|
|
124
|
+
// Two lines, grouped by what they do, and the way *back* stated before you
|
|
125
|
+
// ever go in. The old single crammed line never mentioned it at all, so
|
|
126
|
+
// clicking into a session was a one-way door as far as the screen was
|
|
127
|
+
// concerned.
|
|
128
|
+
out.push(` ${ash("move")} ${dim("click · ↑↓ · wheel")} ${ash("open")} ${dim("enter or double-click")}`);
|
|
129
|
+
out.push(` ${ash("back")} ${dim("Ctrl-b d from inside")} ${ash("also")} ${dim("t tile all · r refresh · q quit")}`);
|
|
130
|
+
if (substrate !== "tmux") out.push(` ${dim(substrateNote(substrate) || "")}`);
|
|
131
|
+
return out.join("\r\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tilde = (p) => {
|
|
135
|
+
const home = process.env.HOME || "";
|
|
136
|
+
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decode one SGR mouse report: ESC [ < button ; col ; row (M press | m release).
|
|
141
|
+
*
|
|
142
|
+
* Only presses matter here, and only button 0 (left) and the wheel. Returning
|
|
143
|
+
* null for everything else keeps the caller from acting on a release, which
|
|
144
|
+
* would otherwise fire every click twice.
|
|
145
|
+
*/
|
|
146
|
+
export function parseMouse(sequence) {
|
|
147
|
+
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(sequence);
|
|
148
|
+
if (!m) return null;
|
|
149
|
+
const [, button, col, row, kind] = m;
|
|
150
|
+
if (kind === "m") return null; // release
|
|
151
|
+
const b = Number(button);
|
|
152
|
+
if (b === 64) return { kind: "wheel", direction: -1 };
|
|
153
|
+
if (b === 65) return { kind: "wheel", direction: 1 };
|
|
154
|
+
if (b !== 0) return null;
|
|
155
|
+
return { kind: "click", col: Number(col), row: Number(row) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Every mouse report in a chunk, so a fast click-drag cannot desync the parser. */
|
|
159
|
+
export function parseInput(buffer) {
|
|
160
|
+
const events = [];
|
|
161
|
+
const text = String(buffer);
|
|
162
|
+
const mouse = /\x1b\[<\d+;\d+;\d+[Mm]/g;
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = mouse.exec(text))) {
|
|
165
|
+
const parsed = parseMouse(match[0]);
|
|
166
|
+
if (parsed) events.push(parsed);
|
|
167
|
+
}
|
|
168
|
+
if (events.length) return events;
|
|
169
|
+
for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "t", "j", "k"]) {
|
|
170
|
+
if (text.includes(key)) events.push({ kind: "key", key });
|
|
171
|
+
}
|
|
172
|
+
return events;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Move the selection to the next/previous *session* row, skipping headings. */
|
|
176
|
+
export function moveSelection(rows, from, delta) {
|
|
177
|
+
const selectable = rows.map((r, i) => (r.kind === "session" ? i : -1)).filter((i) => i >= 0);
|
|
178
|
+
if (!selectable.length) return from;
|
|
179
|
+
const at = selectable.indexOf(from);
|
|
180
|
+
if (at < 0) return selectable[0];
|
|
181
|
+
return selectable[Math.min(selectable.length - 1, Math.max(0, at + delta))];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The interactive list.
|
|
186
|
+
*
|
|
187
|
+
* Everything the terminal had is restored through one `restore()`, wired to
|
|
188
|
+
* normal exit and to the signals that otherwise kill a raw-mode process — a
|
|
189
|
+
* crash here must not leave someone with no cursor, no echo, and the mouse
|
|
190
|
+
* still captured by a program that is gone.
|
|
191
|
+
*/
|
|
192
|
+
export async function herdUi({
|
|
193
|
+
stdin = process.stdin,
|
|
194
|
+
stdout = process.stdout,
|
|
195
|
+
attach = attachSession,
|
|
196
|
+
read = roster,
|
|
197
|
+
refreshMs = 2000,
|
|
198
|
+
} = {}) {
|
|
199
|
+
const substrate = detectSubstrate();
|
|
200
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
201
|
+
stdout.write("moshcode herd ui needs an interactive terminal — try `moshcode ps`\n");
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let rows = layout(groupByHerd(read()));
|
|
206
|
+
let selected = rows.findIndex((r) => r.kind === "session");
|
|
207
|
+
if (selected < 0) selected = 0;
|
|
208
|
+
let done = false;
|
|
209
|
+
let restored = false;
|
|
210
|
+
|
|
211
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
212
|
+
const restore = () => {
|
|
213
|
+
if (restored) return;
|
|
214
|
+
restored = true;
|
|
215
|
+
stdout.write(ESC.mouseOff + ESC.showCursor + ESC.altOff);
|
|
216
|
+
try { stdin.setRawMode?.(wasRaw); } catch { /* already gone */ }
|
|
217
|
+
stdin.pause();
|
|
218
|
+
};
|
|
219
|
+
const enter = () => {
|
|
220
|
+
restored = false;
|
|
221
|
+
stdout.write(ESC.altOn + ESC.hideCursor + ESC.mouseOn);
|
|
222
|
+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
223
|
+
stdin.resume();
|
|
224
|
+
};
|
|
225
|
+
const onSignal = () => { restore(); process.exit(130); };
|
|
226
|
+
process.on("exit", restore);
|
|
227
|
+
process.on("SIGINT", onSignal);
|
|
228
|
+
process.on("SIGTERM", onSignal);
|
|
229
|
+
|
|
230
|
+
const draw = () => {
|
|
231
|
+
if (done) return;
|
|
232
|
+
stdout.write(ESC.clear + render(rows, { selected, width: stdout.columns || 80, substrate }));
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const refresh = () => {
|
|
236
|
+
const previous = rows[selected];
|
|
237
|
+
rows = layout(groupByHerd(read()));
|
|
238
|
+
// Keep the highlight on the same session across a refresh, not on the same
|
|
239
|
+
// line number — a session finishing above it would otherwise slide the
|
|
240
|
+
// selection onto something else just as someone pressed enter.
|
|
241
|
+
const again = rows.findIndex((r) => r.kind === "session" && r.session?.name === previous?.session?.name);
|
|
242
|
+
selected = again >= 0 ? again : Math.max(0, rows.findIndex((r) => r.kind === "session"));
|
|
243
|
+
draw();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
enter();
|
|
247
|
+
draw();
|
|
248
|
+
const timer = setInterval(refresh, refreshMs);
|
|
249
|
+
const onResize = () => draw();
|
|
250
|
+
stdout.on("resize", onResize);
|
|
251
|
+
|
|
252
|
+
const openSelected = async () => {
|
|
253
|
+
const row = rows[selected];
|
|
254
|
+
if (row?.kind !== "session" || !row.session.alive) return;
|
|
255
|
+
// Give the terminal back before handing it to tmux, and take it again after
|
|
256
|
+
// — attaching inside our alternate screen with the mouse captured would put
|
|
257
|
+
// the agent's own mouse handling in a fight with ours.
|
|
258
|
+
restore();
|
|
259
|
+
await attach(row.session.name, { substrate });
|
|
260
|
+
if (done) return;
|
|
261
|
+
enter();
|
|
262
|
+
refresh();
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
await new Promise((resolve) => {
|
|
266
|
+
const onData = async (buf) => {
|
|
267
|
+
for (const event of parseInput(buf)) {
|
|
268
|
+
if (event.kind === "key" && (event.key === "q" || event.key === "\x03")) {
|
|
269
|
+
done = true;
|
|
270
|
+
resolve();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (event.kind === "key" && (event.key === "\x1b[A" || event.key === "k")) selected = moveSelection(rows, selected, -1);
|
|
274
|
+
else if (event.kind === "key" && (event.key === "\x1b[B" || event.key === "j")) selected = moveSelection(rows, selected, 1);
|
|
275
|
+
else if (event.kind === "key" && event.key === "r") { refresh(); continue; }
|
|
276
|
+
else if (event.kind === "key" && event.key === "t") {
|
|
277
|
+
// The list and the tiled layout are two views of one herd, so getting
|
|
278
|
+
// between them should not mean quitting and typing another command.
|
|
279
|
+
restore();
|
|
280
|
+
const { herdTile } = await import("./herd-tile.mjs");
|
|
281
|
+
await herdTile([], { write: (s) => process.stdout.write(`${s}\n`) });
|
|
282
|
+
if (done) return;
|
|
283
|
+
enter();
|
|
284
|
+
refresh();
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
else if (event.kind === "wheel") selected = moveSelection(rows, selected, event.direction);
|
|
288
|
+
else if (event.kind === "click") {
|
|
289
|
+
const hit = rows.find((r) => r.kind === "session" && r.line === event.row);
|
|
290
|
+
if (!hit) continue;
|
|
291
|
+
const index = rows.indexOf(hit);
|
|
292
|
+
// A single click SELECTS; only a second click on the row already
|
|
293
|
+
// selected opens it. Opening on first click made one stray click a
|
|
294
|
+
// one-way trip into a session, which is most of why this felt bad to
|
|
295
|
+
// navigate — you could not point at a row to read it.
|
|
296
|
+
const opening = index === selected;
|
|
297
|
+
selected = index;
|
|
298
|
+
draw();
|
|
299
|
+
if (opening) await openSelected();
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
else if (event.kind === "key") { await openSelected(); continue; }
|
|
303
|
+
draw();
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
stdin.on("data", onData);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
clearInterval(timer);
|
|
310
|
+
stdout.off("resize", onResize);
|
|
311
|
+
stdin.off("data", () => {});
|
|
312
|
+
restore();
|
|
313
|
+
process.off("exit", restore);
|
|
314
|
+
process.off("SIGINT", onSignal);
|
|
315
|
+
process.off("SIGTERM", onSignal);
|
|
316
|
+
stdout.write("\n");
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// `moshcode herd ui` — a sidebar of members and actions, with the selected
|
|
2
|
+
// member's real terminal beside it.
|
|
3
|
+
//
|
|
4
|
+
// This replaces the modal list, which was the wrong answer to the question.
|
|
5
|
+
// The list showed you the herd OR a session and never both, so getting into one
|
|
6
|
+
// was a one-way trip and nothing on the list could start or stop anything.
|
|
7
|
+
//
|
|
8
|
+
// HOW THE SIDEBAR SURVIVES A SWITCH. tmux's model is session > window > pane,
|
|
9
|
+
// and a pane belongs to exactly one window — which is why moving between
|
|
10
|
+
// *windows* cannot keep anything on screen. But `join-pane` moves a running
|
|
11
|
+
// pane into an existing window, so swapping only the *content* pane leaves the
|
|
12
|
+
// sidebar untouched. Selecting a member parks the current content pane back
|
|
13
|
+
// into a session of its own and joins the new one in; both keep their processes
|
|
14
|
+
// and their scrollback, because tmux is moving the real pane rather than
|
|
15
|
+
// redrawing a picture of it.
|
|
16
|
+
//
|
|
17
|
+
// Two processes, therefore: the launcher below builds the window and attaches,
|
|
18
|
+
// and `herdSidebar` is what runs *inside* the left pane doing the swapping.
|
|
19
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
|
|
22
|
+
import { roster } from "./herd-cli.mjs";
|
|
23
|
+
import { groupByHerd, parseInput } from "./herd-ui.mjs";
|
|
24
|
+
import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
|
|
25
|
+
|
|
26
|
+
export const WORKSPACE = "herd";
|
|
27
|
+
export const WINDOW = "ui";
|
|
28
|
+
export const TARGET = `${WORKSPACE}:${WINDOW}`;
|
|
29
|
+
const SIDEBAR_WIDTH = 26;
|
|
30
|
+
|
|
31
|
+
/** The rows in the sidebar that are not members. */
|
|
32
|
+
export const ACTIONS = [
|
|
33
|
+
{ key: "s", label: "+ shell", run: "shell" },
|
|
34
|
+
{ key: "a", label: "+ agent", run: "agent" },
|
|
35
|
+
{ key: "x", label: "✕ stop", run: "stop" },
|
|
36
|
+
{ key: "t", label: "⊞ tile all", run: "tile" },
|
|
37
|
+
{ key: "q", label: "← detach", run: "detach" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/* -------------------------------------------------------------- the layout */
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the window and attach to it.
|
|
44
|
+
*
|
|
45
|
+
* Falls back to the plain list where there is no tmux, because the swap this
|
|
46
|
+
* is built on is a tmux operation and the script(1) substrate has one pty per
|
|
47
|
+
* session with no way to put two of them side by side.
|
|
48
|
+
*/
|
|
49
|
+
export async function herdUi(argv = [], { write = console.log, spawner = spawn, runner = spawnSync } = {}) {
|
|
50
|
+
const substrate = detectSubstrate();
|
|
51
|
+
if (substrate !== "tmux") {
|
|
52
|
+
const { herdUi: list } = await import("./herd-ui.mjs");
|
|
53
|
+
return list({});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const existing = tmux(["has-session", "-t", WORKSPACE], { runner });
|
|
57
|
+
if (!existing.ok) {
|
|
58
|
+
const self = process.argv[1];
|
|
59
|
+
const sidebar = `${process.execPath} ${self} herd sidebar`;
|
|
60
|
+
const made = tmux(["new-session", "-d", "-s", WORKSPACE, "-n", WINDOW, sidebar], { runner });
|
|
61
|
+
if (!made.ok) { write(err(made.stderr.trim() || "could not open the workspace")); return 1; }
|
|
62
|
+
// The sidebar is the "main" pane of a main-vertical layout, which is what
|
|
63
|
+
// pins it to the left at a fixed width while the content pane takes the
|
|
64
|
+
// rest and follows the terminal when it resizes.
|
|
65
|
+
tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
|
|
66
|
+
tmux(["set-option", "-t", WORKSPACE, "mouse", "on"], { runner });
|
|
67
|
+
tmux(["set-option", "-t", WORKSPACE, "status", "off"], { runner });
|
|
68
|
+
tmux(["set-option", "-t", WORKSPACE, "pane-border-status", "top"], { runner });
|
|
69
|
+
tmux(["set-option", "-t", WORKSPACE, "pane-border-format", " #{pane_title} "], { runner });
|
|
70
|
+
tmux(["select-pane", "-t", `${TARGET}.0`, "-T", "herd"], { runner });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let child;
|
|
75
|
+
try { child = spawner("tmux", ["-L", HERD_SOCKET, "attach-session", "-t", WORKSPACE], { stdio: "inherit" }); }
|
|
76
|
+
catch (error) { write(err(String(error.message || error))); resolve(1); return; }
|
|
77
|
+
child.on("error", (error) => { write(err(String(error.message || error))); resolve(1); });
|
|
78
|
+
child.on("exit", () => {
|
|
79
|
+
write(info(`detached — everything is still running. ${acid("moshcode ps")} · ${acid("moshcode herd ui")}`));
|
|
80
|
+
resolve(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ------------------------------------------------------------- the swapping */
|
|
86
|
+
|
|
87
|
+
/** The content pane currently on the right, if there is one. */
|
|
88
|
+
export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
89
|
+
const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner });
|
|
90
|
+
if (!r.ok) return null;
|
|
91
|
+
for (const line of r.stdout.split("\n")) {
|
|
92
|
+
const [paneId, title] = line.split("\t");
|
|
93
|
+
if (!paneId || paneId === me) continue;
|
|
94
|
+
return { paneId, title };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Send a pane back to a session of its own.
|
|
101
|
+
*
|
|
102
|
+
* `break-pane` cannot do this: its `-t` is a destination window that has to
|
|
103
|
+
* exist already, not a name to create. So it is the join dance backwards —
|
|
104
|
+
* make the session, move the pane in, drop the placeholder the session was
|
|
105
|
+
* born with.
|
|
106
|
+
*/
|
|
107
|
+
export function parkPane(paneId, name, { runner = spawnSync } = {}) {
|
|
108
|
+
if (!name) return false;
|
|
109
|
+
const made = tmux(["new-session", "-d", "-s", name, "-n", name], { runner });
|
|
110
|
+
if (!made.ok && !/duplicate session/i.test(made.stderr || "")) return false;
|
|
111
|
+
const placeholder = made.ok
|
|
112
|
+
? tmux(["list-panes", "-t", name, "-F", "#{pane_id}"], { runner }).stdout.trim().split("\n")[0]
|
|
113
|
+
: null;
|
|
114
|
+
const joined = tmux(["join-pane", "-s", paneId, "-t", `${name}:${name}`], { runner });
|
|
115
|
+
if (!joined.ok) return false;
|
|
116
|
+
if (placeholder) tmux(["kill-pane", "-t", placeholder], { runner });
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Put `name` in the content pane, parking whatever was there. */
|
|
121
|
+
export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
122
|
+
const current = contentPane({ runner, me });
|
|
123
|
+
if (current?.title === name) return true; // already showing
|
|
124
|
+
const panes = paneIndex({ runner });
|
|
125
|
+
const wanted = panes.get(name);
|
|
126
|
+
if (!wanted) return false;
|
|
127
|
+
|
|
128
|
+
if (current) parkPane(current.paneId, current.title, { runner });
|
|
129
|
+
const joined = tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
|
|
130
|
+
if (!joined.ok) return false;
|
|
131
|
+
tmux(["select-layout", "-t", TARGET, "main-vertical"], { runner });
|
|
132
|
+
// main-vertical resets the main pane's width from the option, so re-assert it
|
|
133
|
+
// after every swap or the sidebar creeps wider each time.
|
|
134
|
+
tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
|
|
135
|
+
tmux(["select-pane", "-t", me], { runner });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* --------------------------------------------------------------- the render */
|
|
140
|
+
|
|
141
|
+
const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
|
|
142
|
+
const paintState = (state, text) =>
|
|
143
|
+
state === "blocked" ? amber(text)
|
|
144
|
+
: state === "working" ? acid(text)
|
|
145
|
+
: state === "done" ? bone(text)
|
|
146
|
+
: state === "gone" ? danger(text)
|
|
147
|
+
: ash(text);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The sidebar's rows, and the line each one sits on — one list so a click and
|
|
151
|
+
* the highlight cannot disagree (the bug that made the first list send every
|
|
152
|
+
* click to the row below the pointer).
|
|
153
|
+
*/
|
|
154
|
+
export function sidebarRows(sessions) {
|
|
155
|
+
const rows = [{ kind: "title" }, { kind: "gap" }];
|
|
156
|
+
for (const group of groupByHerd(sessions)) {
|
|
157
|
+
rows.push({ kind: "herd", herd: group.name });
|
|
158
|
+
for (const session of group.members) rows.push({ kind: "session", session });
|
|
159
|
+
}
|
|
160
|
+
rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" });
|
|
161
|
+
for (const action of ACTIONS) rows.push({ kind: "action", action });
|
|
162
|
+
return rows.map((row, i) => ({ ...row, line: i + 1 }));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH } = {}) {
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const row of rows) {
|
|
168
|
+
if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; }
|
|
169
|
+
if (row.kind === "gap") { out.push(""); continue; }
|
|
170
|
+
if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; }
|
|
171
|
+
if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; }
|
|
172
|
+
if (row.kind === "session") {
|
|
173
|
+
const s = row.session;
|
|
174
|
+
const here = s.name === showing ? acid("▸") : " ";
|
|
175
|
+
const label = s.name.slice(0, width - 7);
|
|
176
|
+
const text = s.name === selected ? bone(label) : ash(label);
|
|
177
|
+
out.push(`${here} ${paintState(s.state, MARK[s.state] || "?")} ${text}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const isSel = row.action.key === selected;
|
|
181
|
+
out.push(` ${isSel ? bone(row.action.label) : ash(row.action.label)}`);
|
|
182
|
+
}
|
|
183
|
+
return out.join("\r\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* -------------------------------------------------------- the sidebar itself */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Runs inside the left pane. Draws the list, and turns a click into a swap.
|
|
190
|
+
*
|
|
191
|
+
* It does not take the alternate screen: it *is* a pane, and the pane is the
|
|
192
|
+
* screen. Mouse reporting is enabled for this program specifically, which tmux
|
|
193
|
+
* forwards rather than consuming once an application asks for it.
|
|
194
|
+
*/
|
|
195
|
+
export async function herdSidebar({
|
|
196
|
+
stdin = process.stdin, stdout = process.stdout, read = roster, refreshMs = 2000, runner = spawnSync,
|
|
197
|
+
} = {}) {
|
|
198
|
+
const me = process.env.TMUX_PANE;
|
|
199
|
+
let sessions = read();
|
|
200
|
+
let rows = sidebarRows(sessions);
|
|
201
|
+
let selected = sessions[0]?.name || ACTIONS[0].key;
|
|
202
|
+
let showing = null;
|
|
203
|
+
|
|
204
|
+
const draw = () => {
|
|
205
|
+
stdout.write("\x1b[2J\x1b[H" + renderSidebar(rows, { selected, showing }));
|
|
206
|
+
};
|
|
207
|
+
const refresh = () => {
|
|
208
|
+
sessions = read();
|
|
209
|
+
rows = sidebarRows(sessions);
|
|
210
|
+
const current = contentPane({ runner, me });
|
|
211
|
+
showing = current?.title || null;
|
|
212
|
+
draw();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Open on something rather than an empty right-hand side.
|
|
216
|
+
const first = sessions.find((s) => s.alive);
|
|
217
|
+
if (first) { showMember(first.name, { runner, me }); showing = first.name; }
|
|
218
|
+
|
|
219
|
+
stdout.write("\x1b[?1000h\x1b[?1006h\x1b[?25l");
|
|
220
|
+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
221
|
+
stdin.resume();
|
|
222
|
+
const restore = () => stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h");
|
|
223
|
+
process.on("exit", restore);
|
|
224
|
+
|
|
225
|
+
draw();
|
|
226
|
+
const timer = setInterval(refresh, refreshMs);
|
|
227
|
+
|
|
228
|
+
const act = async (what) => {
|
|
229
|
+
if (what === "detach") { tmux(["detach-client"], { runner }); return; }
|
|
230
|
+
if (what === "tile") {
|
|
231
|
+
const { herdTile } = await import("./herd-tile.mjs");
|
|
232
|
+
await herdTile([], { write: () => {}, spawner: () => ({ on: (e, cb) => e === "exit" && cb(0) }) });
|
|
233
|
+
refresh();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (what === "stop") {
|
|
237
|
+
const target = sessions.find((s) => s.name === selected);
|
|
238
|
+
if (!target) return;
|
|
239
|
+
const { killSession } = await import("./herd.mjs");
|
|
240
|
+
killSession(target.name);
|
|
241
|
+
refresh();
|
|
242
|
+
const next = read().find((s) => s.alive);
|
|
243
|
+
if (next) { showMember(next.name, { runner, me }); }
|
|
244
|
+
refresh();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// shell / agent: start it detached, then bring it into the content pane so
|
|
248
|
+
// the thing you just asked for is the thing you are looking at.
|
|
249
|
+
const { herdShell, herdStart } = await import("./herd-cli.mjs");
|
|
250
|
+
let created = null;
|
|
251
|
+
const capture = (line) => { const m = /^\S*\s*(\S+)\s+—/.exec(String(line).replace(/\x1b\[[0-9;]*m/g, "")); if (m) created = m[1]; };
|
|
252
|
+
if (what === "shell") herdShell([], { write: capture });
|
|
253
|
+
else herdStart(["claude", "--agent"], { write: capture });
|
|
254
|
+
refresh();
|
|
255
|
+
if (created) { showMember(created, { runner, me }); refresh(); }
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
await new Promise((resolve) => {
|
|
259
|
+
stdin.on("data", async (buf) => {
|
|
260
|
+
for (const event of parseInput(buf)) {
|
|
261
|
+
if (event.kind === "click") {
|
|
262
|
+
const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action"));
|
|
263
|
+
if (!hit) continue;
|
|
264
|
+
if (hit.kind === "session") {
|
|
265
|
+
selected = hit.session.name;
|
|
266
|
+
if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; }
|
|
267
|
+
draw();
|
|
268
|
+
} else {
|
|
269
|
+
selected = hit.action.key;
|
|
270
|
+
draw();
|
|
271
|
+
await act(hit.action.run);
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (event.kind !== "key") continue;
|
|
276
|
+
const action = ACTIONS.find((a) => a.key === event.key);
|
|
277
|
+
if (action) { await act(action.run); if (action.run === "detach") { resolve(); return; } continue; }
|
|
278
|
+
if (event.key === "\x03") { resolve(); return; }
|
|
279
|
+
const names = sessions.filter((s) => s.alive).map((s) => s.name);
|
|
280
|
+
const at = names.indexOf(selected);
|
|
281
|
+
if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected;
|
|
282
|
+
if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected;
|
|
283
|
+
if (event.key === "\r" || event.key === "\n") { showMember(selected, { runner, me }); showing = selected; }
|
|
284
|
+
draw();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
clearInterval(timer);
|
|
290
|
+
restore();
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
package/src/herd.mjs
CHANGED
|
@@ -257,6 +257,15 @@ export function tmuxStartPlan({ name, cwd, command }) {
|
|
|
257
257
|
"-f", "/dev/null",
|
|
258
258
|
"new-session", "-d", "-s", name, "-c", cwd, command,
|
|
259
259
|
";", "set-option", "-t", name, "remain-on-exit", "on",
|
|
260
|
+
// Mouse on, so a click selects a pane and the status line's window list is
|
|
261
|
+
// clickable once you are inside. This server is moshcode's and starts from
|
|
262
|
+
// no config, so it is not overriding a preference anyone expressed.
|
|
263
|
+
";", "set-option", "-t", name, "mouse", "on",
|
|
264
|
+
// The pane's title is the member's durable handle — it survives being
|
|
265
|
+
// moved into a tiled window and back, which the session name does not.
|
|
266
|
+
// Set in the same invocation as the rest so a fast-exiting command cannot
|
|
267
|
+
// finish before it lands.
|
|
268
|
+
";", "select-pane", "-t", name, "-T", name,
|
|
260
269
|
];
|
|
261
270
|
}
|
|
262
271
|
|
|
@@ -416,9 +425,11 @@ function ptyWrite(name, data) {
|
|
|
416
425
|
/** Names the substrate says are live right now. */
|
|
417
426
|
export function liveNames({ substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
418
427
|
if (substrate === "tmux") {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
428
|
+
// Panes, not sessions: a tiled member's session is gone but the member is
|
|
429
|
+
// very much alive. Only names the manifest knows about count, so a stray
|
|
430
|
+
// pane title on the server cannot invent a roster entry.
|
|
431
|
+
const known = new Set(Object.keys(readManifest().sessions));
|
|
432
|
+
return [...paneIndex({ runner }).keys()].filter((name) => known.has(name));
|
|
422
433
|
}
|
|
423
434
|
if (substrate === "pty") {
|
|
424
435
|
// A finished session is still one the runtime has: it stays on the roster
|
|
@@ -459,6 +470,42 @@ export function sessionExited(name, { substrate = detectSubstrate(), runner = sp
|
|
|
459
470
|
* meant a fork per field per row, so a herd of six cost thirteen processes to
|
|
460
471
|
* draw one screen. tmux will format the whole server in one pass.
|
|
461
472
|
*/
|
|
473
|
+
/**
|
|
474
|
+
* Where every member's pane actually is, keyed by member name.
|
|
475
|
+
*
|
|
476
|
+
* A member is a *pane*, not a session. It starts life as the only pane in a
|
|
477
|
+
* session of the same name, but `herd tile` moves panes into one window to lay
|
|
478
|
+
* them out side by side — and a session whose last pane leaves is gone. Keying
|
|
479
|
+
* off session names would make every tiled member vanish from the roster, from
|
|
480
|
+
* `read`, from `prompt` and from `wait`, which is a steep price for a layout.
|
|
481
|
+
*
|
|
482
|
+
* The pane's *title* is the durable handle: it is set at creation, it travels
|
|
483
|
+
* with the pane through join-pane and break-pane, and tmux will report it from
|
|
484
|
+
* anywhere on the server.
|
|
485
|
+
*/
|
|
486
|
+
export function paneIndex({ runner = spawnSync } = {}) {
|
|
487
|
+
const r = tmux(["list-panes", "-a", "-F", "#{pane_title}\t#{pane_id}\t#{session_name}\t#{window_id}\t#{pane_dead}"], { runner });
|
|
488
|
+
const index = new Map();
|
|
489
|
+
if (!r.ok) return index;
|
|
490
|
+
for (const line of r.stdout.split("\n")) {
|
|
491
|
+
if (!line.trim()) continue;
|
|
492
|
+
const [title, paneId, session, windowId, dead] = line.split("\t");
|
|
493
|
+
if (!title) continue;
|
|
494
|
+
index.set(title, { paneId, session, windowId, dead: dead.trim() === "1" });
|
|
495
|
+
}
|
|
496
|
+
return index;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The tmux target for a member: its pane id when we can find one, else its
|
|
501
|
+
* session name. The fallback matters for a session created before pane titles
|
|
502
|
+
* were set, which would otherwise become unreachable after an upgrade.
|
|
503
|
+
*/
|
|
504
|
+
export function target(name, { runner = spawnSync, index } = {}) {
|
|
505
|
+
const found = (index || paneIndex({ runner })).get(name);
|
|
506
|
+
return found ? found.paneId : name;
|
|
507
|
+
}
|
|
508
|
+
|
|
462
509
|
function tmuxSnapshot({ runner = spawnSync } = {}) {
|
|
463
510
|
const sessions = tmux(["list-sessions", "-F", "#{session_name}\t#{session_attached}"], { runner });
|
|
464
511
|
const attached = new Map();
|
|
@@ -536,7 +583,7 @@ export function startSession({
|
|
|
536
583
|
/** The last `lines` rows of a session's screen — what the classifier reads. */
|
|
537
584
|
export function capture(name, { lines = 60, substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
538
585
|
if (substrate === "tmux") {
|
|
539
|
-
const r = tmux(["capture-pane", "-p", "-t", name, "-S", `-${Math.max(0, lines)}`], { runner });
|
|
586
|
+
const r = tmux(["capture-pane", "-p", "-t", target(name, { runner }), "-S", `-${Math.max(0, lines)}`], { runner });
|
|
540
587
|
return r.ok ? r.stdout.replace(/\n+$/, "") : "";
|
|
541
588
|
}
|
|
542
589
|
if (substrate === "pty") return ptyCapture(name, lines);
|
|
@@ -546,7 +593,7 @@ export function capture(name, { lines = 60, substrate = detectSubstrate(), runne
|
|
|
546
593
|
/** Raw key relay. `keys` is passed through to tmux's own key vocabulary. */
|
|
547
594
|
export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
548
595
|
if (substrate === "tmux") {
|
|
549
|
-
const r = tmux(["send-keys", "-t", name, ...(Array.isArray(keys) ? keys : [keys])], { runner });
|
|
596
|
+
const r = tmux(["send-keys", "-t", target(name, { runner }), ...(Array.isArray(keys) ? keys : [keys])], { runner });
|
|
550
597
|
return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "send-keys failed") };
|
|
551
598
|
}
|
|
552
599
|
if (substrate === "pty") {
|
|
@@ -567,9 +614,10 @@ export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = s
|
|
|
567
614
|
*/
|
|
568
615
|
export function sendPrompt(name, text, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
569
616
|
if (substrate === "tmux") {
|
|
570
|
-
const
|
|
617
|
+
const pane = target(name, { runner });
|
|
618
|
+
const typed = tmux(["send-keys", "-t", pane, "-l", String(text)], { runner });
|
|
571
619
|
if (!typed.ok) return { ok: false, error: new Error(typed.stderr.trim() || "send-keys failed") };
|
|
572
|
-
const entered = tmux(["send-keys", "-t",
|
|
620
|
+
const entered = tmux(["send-keys", "-t", pane, "Enter"], { runner });
|
|
573
621
|
return entered.ok ? { ok: true } : { ok: false, error: new Error(entered.stderr.trim() || "send-keys failed") };
|
|
574
622
|
}
|
|
575
623
|
if (substrate === "pty") return ptyWrite(name, `${String(text)}\r`);
|
|
@@ -579,7 +627,9 @@ export function sendPrompt(name, text, { substrate = detectSubstrate(), runner =
|
|
|
579
627
|
/** End a session and forget it. */
|
|
580
628
|
export function killSession(name, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
581
629
|
if (substrate === "tmux") {
|
|
582
|
-
|
|
630
|
+
// kill-pane, not kill-session: a tiled member shares its session with
|
|
631
|
+
// every other tiled member, and killing that would take the lot.
|
|
632
|
+
const r = tmux(["kill-pane", "-t", target(name, { runner })], { runner });
|
|
583
633
|
forgetSession(name);
|
|
584
634
|
return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
|
|
585
635
|
}
|
|
@@ -626,9 +676,19 @@ export async function attachSession(name, {
|
|
|
626
676
|
stdout = process.stdout,
|
|
627
677
|
} = {}) {
|
|
628
678
|
if (substrate === "tmux") {
|
|
679
|
+
// A member that has been tiled shares a window with its neighbours, so
|
|
680
|
+
// attaching has to select its pane and zoom it — otherwise you land on
|
|
681
|
+
// whichever pane happened to have focus, at a quarter of the screen.
|
|
682
|
+
const found = paneIndex().get(name);
|
|
683
|
+
const argv = found
|
|
684
|
+
? ["attach-session", "-t", found.session,
|
|
685
|
+
";", "select-window", "-t", found.windowId,
|
|
686
|
+
";", "select-pane", "-t", found.paneId,
|
|
687
|
+
";", "resize-pane", "-Z", "-t", found.paneId]
|
|
688
|
+
: ["attach-session", "-t", name];
|
|
629
689
|
return new Promise((resolve) => {
|
|
630
690
|
let child;
|
|
631
|
-
try { child = spawner("tmux", tmuxArgs(
|
|
691
|
+
try { child = spawner("tmux", tmuxArgs(argv), { stdio: "inherit", env }); }
|
|
632
692
|
catch (error) { resolve({ ok: false, error }); return; }
|
|
633
693
|
child.on("error", (error) => resolve({ ok: false, error }));
|
|
634
694
|
child.on("exit", (code, signal) => resolve({ ok: code === 0, code, signal }));
|
|
@@ -722,24 +782,34 @@ export function ptyAttachSession(name, { stdin = process.stdin, stdout = process
|
|
|
722
782
|
*/
|
|
723
783
|
export function listSessions({ substrate = detectSubstrate(), runner = spawnSync, now = Date.now() } = {}) {
|
|
724
784
|
const manifest = readManifest();
|
|
785
|
+
const panes = substrate === "tmux" ? paneIndex({ runner }) : null;
|
|
725
786
|
const snapshot = substrate === "tmux" ? tmuxSnapshot({ runner }) : null;
|
|
726
|
-
const live = new Set(
|
|
787
|
+
const live = new Set(panes
|
|
788
|
+
? [...panes.keys()].filter((name) => name in manifest.sessions)
|
|
789
|
+
: liveNames({ substrate, runner }));
|
|
727
790
|
const names = [...new Set([...live, ...Object.keys(manifest.sessions)])].sort();
|
|
728
791
|
return names.map((name) => {
|
|
729
792
|
const meta = manifest.sessions[name] || {};
|
|
730
793
|
const alive = live.has(name);
|
|
731
794
|
const exited = !alive ? null
|
|
732
|
-
:
|
|
795
|
+
: panes ? Boolean(panes.get(name)?.dead)
|
|
733
796
|
: sessionExited(name, { substrate, runner });
|
|
734
797
|
return {
|
|
735
798
|
name,
|
|
736
799
|
engine: meta.engine || "?",
|
|
800
|
+
// Sessions started before herds existed have none. They belong to `main`
|
|
801
|
+
// rather than to a group rendered as "undefined".
|
|
802
|
+
herd: meta.herd || "main",
|
|
737
803
|
cwd: meta.cwd || "",
|
|
738
804
|
created: meta.created || null,
|
|
739
805
|
age: meta.created ? now - meta.created : null,
|
|
740
806
|
alive,
|
|
741
807
|
exited,
|
|
742
|
-
|
|
808
|
+
// Where it currently sits — null when it is in its own session, the
|
|
809
|
+
// window it was tiled into otherwise. The UI needs this to know whether
|
|
810
|
+
// a member is already on a layout somewhere.
|
|
811
|
+
window: alive && panes ? panes.get(name)?.windowId || null : null,
|
|
812
|
+
attached: alive && snapshot ? snapshot.attached.get(panes?.get(name)?.session) || 0 : 0,
|
|
743
813
|
substrate: meta.substrate || substrate,
|
|
744
814
|
};
|
|
745
815
|
});
|