moshcode 0.35.0 → 0.37.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 +94 -21
- package/package.json +1 -1
- package/src/cli-schema.mjs +18 -3
- package/src/herd-bar.mjs +196 -0
- package/src/herd-cli.mjs +9 -3
- package/src/herd-tile.mjs +168 -0
- package/src/herd-ui.mjs +26 -4
- package/src/herd-workspace.mjs +366 -0
- package/src/herd.mjs +75 -12
package/README.md
CHANGED
|
@@ -125,37 +125,110 @@ moshcode attach api # step in; Ctrl-b d steps back out
|
|
|
125
125
|
moshcode kill api # end it
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
###
|
|
128
|
+
### Everything on screen at once
|
|
129
129
|
|
|
130
130
|
```sh
|
|
131
|
-
moshcode herd
|
|
131
|
+
moshcode herd tile
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
```
|
|
135
|
-
|
|
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:
|
|
136
148
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 |
|
|
140
156
|
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
143
160
|
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
146
163
|
|
|
147
|
-
|
|
148
|
-
sessions with `--herd <name>` when you start them; anything without one is in
|
|
149
|
-
`main`.
|
|
164
|
+
### The workspace
|
|
150
165
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
│ enter ▸ … │ │
|
|
188
|
+
│ F12 ▸ … │ │
|
|
189
|
+
├────────────┴───────────────────────────────────┤
|
|
190
|
+
│ mosh ▸ ps · start claude · show <n> · detach │
|
|
191
|
+
└────────────────────────────────────────────────┘
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Members and actions down the left, the selected member's **real terminal** on
|
|
195
|
+
the right. Click a member to show it; click an action to start a shell, start an
|
|
196
|
+
agent, or stop the selected one. `q` detaches and leaves everything running.
|
|
197
|
+
|
|
198
|
+
Click the member that is already on screen — or press Enter — and the keyboard
|
|
199
|
+
goes to it, so you are typing at the agent itself.
|
|
200
|
+
|
|
201
|
+
### The mosh bar
|
|
202
|
+
|
|
203
|
+
The row along the bottom is a mosh prompt, and it is always there. **F12** jumps
|
|
204
|
+
to it from anywhere, including from inside an agent that has taken the keyboard,
|
|
205
|
+
which makes it the way out of a session you cannot otherwise leave. Esc goes
|
|
206
|
+
back to the session; `detach` leaves with everything still running.
|
|
207
|
+
|
|
208
|
+
It takes any `moshcode herd` verb, so you can start a second agent without
|
|
209
|
+
leaving the first:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
mosh ▸ start claude # another agent, now on screen
|
|
213
|
+
mosh ▸ show api # put a different member up
|
|
214
|
+
mosh ▸ ps # the roster, over the session, then out of the way
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`attach` means `show` here — in a workspace the word means "put it in the
|
|
218
|
+
content pane", and the real attach would be a tmux client inside a tmux client.
|
|
219
|
+
Output grows the bar over the content for as long as you are reading it, then it
|
|
220
|
+
collapses back to one row.
|
|
221
|
+
|
|
222
|
+
The right-hand pane is not a picture of a session — it *is* the session's pane,
|
|
223
|
+
moved in. tmux's model is session → window → pane, so moving between *windows*
|
|
224
|
+
cannot keep anything on screen; but `join-pane` moves a running pane into an
|
|
225
|
+
existing window, so swapping only the content pane leaves the sidebar untouched.
|
|
226
|
+
Both panes keep their process and their scrollback because tmux is moving the
|
|
227
|
+
real thing, not redrawing it.
|
|
228
|
+
|
|
229
|
+
Group sessions with `--herd <name>` when you start them; anything without one is
|
|
230
|
+
in `main`. Without tmux there is nothing to swap panes with, so `herd ui` falls
|
|
231
|
+
back to a plain clickable list.
|
|
159
232
|
|
|
160
233
|
### A workspace: a few shells and an agent
|
|
161
234
|
|
package/package.json
CHANGED
package/src/cli-schema.mjs
CHANGED
|
@@ -738,9 +738,24 @@ export const HERD_VERBS = [
|
|
|
738
738
|
["--agent", "autonomous mode — bypasses the engine's approvals", ""],
|
|
739
739
|
["--json", "machine-readable", ""],
|
|
740
740
|
] },
|
|
741
|
-
{ name: "
|
|
742
|
-
synopsis: [["moshcode herd
|
|
743
|
-
flags: []
|
|
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 · click it again to type in it · F12 for the mosh bar"]],
|
|
749
|
+
flags: [],
|
|
750
|
+
examples: [["moshcode herd ui", "the workspace — start here"]] },
|
|
751
|
+
{ name: "bar", description: "the one-line mosh prompt under the session (runs inside the workspace)",
|
|
752
|
+
synopsis: [["moshcode herd bar", "F12 reaches it from inside an agent · Esc goes back · detach leaves"]],
|
|
753
|
+
flags: [],
|
|
754
|
+
examples: [
|
|
755
|
+
["F12", "jump to the bar from anywhere, even mid-agent"],
|
|
756
|
+
["start claude", "another agent, without leaving this one"],
|
|
757
|
+
["show api", "put a different member on screen"],
|
|
758
|
+
] },
|
|
744
759
|
{ name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
|
|
745
760
|
synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
|
|
746
761
|
flags: [
|
package/src/herd-bar.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// `moshcode herd bar` — a one-line mosh prompt pinned under the session.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The workspace put a real agent in the content pane, which is
|
|
4
|
+
// the point of it — but a real agent takes the keyboard. Click into claude and
|
|
5
|
+
// every sidebar key stops working, and with tmux's status line off there is
|
|
6
|
+
// nothing on screen that says how to get back out. You are looking at one agent
|
|
7
|
+
// with no visible way to leave it, which is exactly the complaint.
|
|
8
|
+
//
|
|
9
|
+
// A status line would have been the small fix: one line of text that never goes
|
|
10
|
+
// away. But a line you can only read answers "how do I get out" and nothing
|
|
11
|
+
// else — you still cannot start a second agent without leaving first. So the
|
|
12
|
+
// line takes input. It is the same surface as the CLI (every `moshcode herd`
|
|
13
|
+
// verb works here) which means the escape hatch and the command line are one
|
|
14
|
+
// thing rather than two.
|
|
15
|
+
//
|
|
16
|
+
// The bar is one row until it has something to say, then it grows over the
|
|
17
|
+
// content, then it collapses again. Output has to go somewhere, and stealing
|
|
18
|
+
// rows from the agent for a moment is cheaper than a pane that is mostly empty.
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
import { tmux } from "./herd.mjs";
|
|
22
|
+
import { acid, ash, bone } from "./ui.mjs";
|
|
23
|
+
|
|
24
|
+
export const BAR_TITLE = "mosh-bar";
|
|
25
|
+
export const SIDEBAR_TITLE = "herd";
|
|
26
|
+
export const BAR_HEIGHT = 1;
|
|
27
|
+
export const BAR_OPEN_HEIGHT = 14;
|
|
28
|
+
|
|
29
|
+
/** The key that reaches the bar from anywhere, including from inside an agent. */
|
|
30
|
+
export const BAR_KEY = "F12";
|
|
31
|
+
|
|
32
|
+
export const HINT = "ps · start claude · show <n> · kill <n> · detach · help";
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------- pane geometry */
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which pane is which, by title.
|
|
38
|
+
*
|
|
39
|
+
* Titles rather than indexes or ids: a pane keeps its title across `join-pane`,
|
|
40
|
+
* which is the whole reason the workspace can move panes around at all, and
|
|
41
|
+
* indexes shift every time one arrives or leaves.
|
|
42
|
+
*/
|
|
43
|
+
export function paneRoles(target, { runner = spawnSync } = {}) {
|
|
44
|
+
const roles = { sidebar: null, content: null, bar: null };
|
|
45
|
+
const r = tmux(["list-panes", "-t", target, "-F", "#{pane_id}\t#{pane_title}"], { runner });
|
|
46
|
+
if (!r.ok) return roles;
|
|
47
|
+
for (const line of r.stdout.split("\n")) {
|
|
48
|
+
const [paneId, title] = line.split("\t");
|
|
49
|
+
if (!paneId) continue;
|
|
50
|
+
if (title === BAR_TITLE) roles.bar = { paneId, title };
|
|
51
|
+
else if (title === SIDEBAR_TITLE) roles.sidebar = { paneId, title };
|
|
52
|
+
else roles.content = { paneId, title };
|
|
53
|
+
}
|
|
54
|
+
return roles;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* --------------------------------------------------------------- line editing */
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One keystroke against the current line. Pure, so the editor is testable
|
|
61
|
+
* without a terminal — the bar itself is then only plumbing.
|
|
62
|
+
*/
|
|
63
|
+
export function editLine(line, key) {
|
|
64
|
+
if (key === "\r" || key === "\n") return { line, action: "submit" };
|
|
65
|
+
if (key === "\x1b") return { line: "", action: "escape" };
|
|
66
|
+
if (key === "\x03") return { line: "", action: "escape" }; // Ctrl-C
|
|
67
|
+
if (key === "\x15") return { line: "", action: "edit" }; // Ctrl-U
|
|
68
|
+
if (key === "\x17") return { line: line.replace(/\S+\s*$/, ""), action: "edit" }; // Ctrl-W
|
|
69
|
+
if (key === "\x7f" || key === "\b") return { line: line.slice(0, -1), action: "edit" };
|
|
70
|
+
if (key.length === 1 && key >= " " && key !== "\x7f") return { line: line + key, action: "edit" };
|
|
71
|
+
return { line, action: "none" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a typed line means.
|
|
76
|
+
*
|
|
77
|
+
* `attach` deliberately becomes `show`. Running the real attach from in here
|
|
78
|
+
* would start a tmux client inside the client already showing this pane, which
|
|
79
|
+
* tmux refuses — and the thing the word means in a workspace is "put it in the
|
|
80
|
+
* content pane" anyway.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveCommand(input) {
|
|
83
|
+
const argv = String(input || "").trim().split(/\s+/).filter(Boolean);
|
|
84
|
+
if (!argv.length) return { kind: "empty", argv: [] };
|
|
85
|
+
const [verb, ...rest] = argv;
|
|
86
|
+
if (verb === "detach" || verb === "exit" || verb === "quit") return { kind: "detach", argv: rest };
|
|
87
|
+
if (verb === "show" || verb === "attach" || verb === "fg") return { kind: "show", argv: rest };
|
|
88
|
+
if (verb === "help" || verb === "?") return { kind: "help", argv: rest };
|
|
89
|
+
if (verb === "clear") return { kind: "clear", argv: rest };
|
|
90
|
+
return { kind: "herd", argv };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The prompt line. The hint is what makes the way out discoverable at rest. */
|
|
94
|
+
export function renderPrompt(line, { cols = 80, showHint = true } = {}) {
|
|
95
|
+
const prompt = `${acid("mosh")} ${bone("▸")} `;
|
|
96
|
+
if (!line && showHint) return `${prompt}${ash(HINT.slice(0, Math.max(0, cols - 8)))}`;
|
|
97
|
+
return `${prompt}${line}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function helpLines() {
|
|
101
|
+
return [
|
|
102
|
+
"the bar takes any moshcode herd verb:",
|
|
103
|
+
" ps the roster start claude new agent",
|
|
104
|
+
" show <name> put it on screen shell new shell",
|
|
105
|
+
" kill <name> end one tile all at once",
|
|
106
|
+
" read <name> its last screen prompt <n> <text> type into it",
|
|
107
|
+
"",
|
|
108
|
+
`${BAR_KEY} comes back here from anywhere · Esc returns to the session · detach leaves`,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ----------------------------------------------------------------- the bar */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs inside the one-line pane at the bottom of the workspace.
|
|
116
|
+
*/
|
|
117
|
+
export async function herdBar({
|
|
118
|
+
stdin = process.stdin,
|
|
119
|
+
stdout = process.stdout,
|
|
120
|
+
runner = spawnSync,
|
|
121
|
+
target = "herd:ui",
|
|
122
|
+
run = null,
|
|
123
|
+
} = {}) {
|
|
124
|
+
const me = process.env.TMUX_PANE;
|
|
125
|
+
const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options));
|
|
126
|
+
|
|
127
|
+
let line = "";
|
|
128
|
+
let open = false;
|
|
129
|
+
|
|
130
|
+
const cols = () => stdout.columns || 80;
|
|
131
|
+
const collapse = () => {
|
|
132
|
+
if (!open) return;
|
|
133
|
+
open = false;
|
|
134
|
+
tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
|
|
135
|
+
};
|
|
136
|
+
const expand = (rows) => {
|
|
137
|
+
open = true;
|
|
138
|
+
tmux(["resize-pane", "-t", me, "-y", String(Math.min(BAR_OPEN_HEIGHT, rows + 2))], { runner });
|
|
139
|
+
};
|
|
140
|
+
const draw = () => {
|
|
141
|
+
stdout.write(`\x1b[2J\x1b[H${renderPrompt(line, { cols: cols() })}`);
|
|
142
|
+
};
|
|
143
|
+
const show = (lines) => {
|
|
144
|
+
expand(lines.length);
|
|
145
|
+
stdout.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n${renderPrompt("", { cols: cols(), showHint: false })}`);
|
|
146
|
+
};
|
|
147
|
+
/** Give the keyboard back to whatever is on screen. */
|
|
148
|
+
const toContent = () => {
|
|
149
|
+
const roles = paneRoles(target, { runner });
|
|
150
|
+
if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const submit = async () => {
|
|
154
|
+
const typed = line;
|
|
155
|
+
line = "";
|
|
156
|
+
const command = resolveCommand(typed);
|
|
157
|
+
if (command.kind === "empty") { collapse(); draw(); toContent(); return true; }
|
|
158
|
+
if (command.kind === "clear") { collapse(); draw(); return true; }
|
|
159
|
+
if (command.kind === "help") { show(helpLines()); return true; }
|
|
160
|
+
if (command.kind === "detach") { tmux(["detach-client"], { runner }); return false; }
|
|
161
|
+
if (command.kind === "show") {
|
|
162
|
+
const [name] = command.argv;
|
|
163
|
+
const { showMember } = await import("./herd-workspace.mjs");
|
|
164
|
+
const okShown = name && showMember(name, { runner, me });
|
|
165
|
+
if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; }
|
|
166
|
+
collapse(); draw(); toContent();
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
const out = [];
|
|
170
|
+
await herdCommand(command.argv, { write: (s) => out.push(...String(s).split("\n")) });
|
|
171
|
+
if (out.length) show(out);
|
|
172
|
+
else { collapse(); draw(); }
|
|
173
|
+
return true;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
177
|
+
stdin.resume();
|
|
178
|
+
draw();
|
|
179
|
+
|
|
180
|
+
await new Promise((resolve) => {
|
|
181
|
+
stdin.on("data", async (buf) => {
|
|
182
|
+
for (const key of String(buf)) {
|
|
183
|
+
const next = editLine(line, key);
|
|
184
|
+
line = next.line;
|
|
185
|
+
if (next.action === "submit") {
|
|
186
|
+
if (!(await submit())) { resolve(); return; }
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (next.action === "escape") { collapse(); draw(); toContent(); continue; }
|
|
190
|
+
if (next.action === "edit") { if (open) { collapse(); } draw(); }
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
package/src/herd-cli.mjs
CHANGED
|
@@ -178,7 +178,7 @@ export function herdStart(argv, { write = console.log } = {}) {
|
|
|
178
178
|
}
|
|
179
179
|
write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`));
|
|
180
180
|
if (flags.agent) write(warn("agent mode: native approvals are bypassed or auto-approved."));
|
|
181
|
-
write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
181
|
+
write(info(`workspace: ${acid("moshcode herd ui")} · attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
182
182
|
const note = substrateNote(substrate);
|
|
183
183
|
if (note) write(info(note));
|
|
184
184
|
return EXIT.matched;
|
|
@@ -253,7 +253,7 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
|
|
|
253
253
|
return EXIT.matched;
|
|
254
254
|
}
|
|
255
255
|
write(ok(`${bone(name)} — ${label} running in the herd. the prompt is yours.`));
|
|
256
|
-
write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
256
|
+
write(info(`workspace: ${acid("moshcode herd ui")} · attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
257
257
|
return EXIT.matched;
|
|
258
258
|
}
|
|
259
259
|
|
|
@@ -725,7 +725,13 @@ export function herdStop(argv, { write = console.log } = {}) {
|
|
|
725
725
|
const VERBS = {
|
|
726
726
|
// Lazy import: the UI pulls in escape-sequence machinery and only matters
|
|
727
727
|
// when someone asks for it, and herd-ui imports roster() from this file.
|
|
728
|
-
ui
|
|
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
|
+
bar: async (argv, options) => (await import("./herd-bar.mjs")).herdBar(options),
|
|
733
|
+
tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
|
|
734
|
+
untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
|
|
729
735
|
ps: herdPs, list: herdPs, status: herdStatus,
|
|
730
736
|
start: herdStart, run: herdRun, shell: herdShell,
|
|
731
737
|
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
CHANGED
|
@@ -121,7 +121,12 @@ export function render(rows, { selected = 0, width = 80, substrate = "tmux" } =
|
|
|
121
121
|
out.push(` ${ash("start something with")} ${acid("moshcode herd shell")} ${ash("or")} ${acid("moshcode agents claude -d")}`);
|
|
122
122
|
}
|
|
123
123
|
out.push("");
|
|
124
|
-
|
|
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")}`);
|
|
125
130
|
if (substrate !== "tmux") out.push(` ${dim(substrateNote(substrate) || "")}`);
|
|
126
131
|
return out.join("\r\n");
|
|
127
132
|
}
|
|
@@ -161,7 +166,7 @@ export function parseInput(buffer) {
|
|
|
161
166
|
if (parsed) events.push(parsed);
|
|
162
167
|
}
|
|
163
168
|
if (events.length) return events;
|
|
164
|
-
for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "j", "k"]) {
|
|
169
|
+
for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "t", "j", "k"]) {
|
|
165
170
|
if (text.includes(key)) events.push({ kind: "key", key });
|
|
166
171
|
}
|
|
167
172
|
return events;
|
|
@@ -268,13 +273,30 @@ export async function herdUi({
|
|
|
268
273
|
if (event.kind === "key" && (event.key === "\x1b[A" || event.key === "k")) selected = moveSelection(rows, selected, -1);
|
|
269
274
|
else if (event.kind === "key" && (event.key === "\x1b[B" || event.key === "j")) selected = moveSelection(rows, selected, 1);
|
|
270
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
|
+
}
|
|
271
287
|
else if (event.kind === "wheel") selected = moveSelection(rows, selected, event.direction);
|
|
272
288
|
else if (event.kind === "click") {
|
|
273
289
|
const hit = rows.find((r) => r.kind === "session" && r.line === event.row);
|
|
274
290
|
if (!hit) continue;
|
|
275
|
-
|
|
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;
|
|
276
298
|
draw();
|
|
277
|
-
await openSelected();
|
|
299
|
+
if (opening) await openSelected();
|
|
278
300
|
continue;
|
|
279
301
|
}
|
|
280
302
|
else if (event.kind === "key") { await openSelected(); continue; }
|
|
@@ -0,0 +1,366 @@
|
|
|
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 { BAR_HEIGHT, BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, paneRoles } from "./herd-bar.mjs";
|
|
25
|
+
import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
|
|
26
|
+
|
|
27
|
+
export const WORKSPACE = "herd";
|
|
28
|
+
export const WINDOW = "ui";
|
|
29
|
+
export const TARGET = `${WORKSPACE}:${WINDOW}`;
|
|
30
|
+
const SIDEBAR_WIDTH = 26;
|
|
31
|
+
|
|
32
|
+
/** The rows in the sidebar that are not members. */
|
|
33
|
+
export const ACTIONS = [
|
|
34
|
+
{ key: "s", label: "+ shell", run: "shell" },
|
|
35
|
+
{ key: "a", label: "+ agent", run: "agent" },
|
|
36
|
+
{ key: "x", label: "✕ stop", run: "stop" },
|
|
37
|
+
{ key: "t", label: "⊞ tile all", run: "tile" },
|
|
38
|
+
{ key: "q", label: "← detach", run: "detach" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/* -------------------------------------------------------------- the layout */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the window and attach to it.
|
|
45
|
+
*
|
|
46
|
+
* Falls back to the plain list where there is no tmux, because the swap this
|
|
47
|
+
* is built on is a tmux operation and the script(1) substrate has one pty per
|
|
48
|
+
* session with no way to put two of them side by side.
|
|
49
|
+
*/
|
|
50
|
+
export async function herdUi(argv = [], { write = console.log, spawner = spawn, runner = spawnSync } = {}) {
|
|
51
|
+
const substrate = detectSubstrate();
|
|
52
|
+
if (substrate !== "tmux") {
|
|
53
|
+
const { herdUi: list } = await import("./herd-ui.mjs");
|
|
54
|
+
return list({});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const existing = tmux(["has-session", "-t", WORKSPACE], { runner });
|
|
58
|
+
if (!existing.ok) {
|
|
59
|
+
const self = process.argv[1];
|
|
60
|
+
const sidebar = `${process.execPath} ${self} herd sidebar`;
|
|
61
|
+
const made = tmux(["new-session", "-d", "-s", WORKSPACE, "-n", WINDOW, sidebar], { runner });
|
|
62
|
+
if (!made.ok) { write(err(made.stderr.trim() || "could not open the workspace")); return 1; }
|
|
63
|
+
tmux(["set-option", "-t", WORKSPACE, "mouse", "on"], { runner });
|
|
64
|
+
tmux(["set-option", "-t", WORKSPACE, "status", "off"], { runner });
|
|
65
|
+
tmux(["set-option", "-t", WORKSPACE, "pane-border-status", "top"], { runner });
|
|
66
|
+
tmux(["set-option", "-t", WORKSPACE, "pane-border-format", " #{pane_title} "], { runner });
|
|
67
|
+
tmux(["select-pane", "-t", `${TARGET}.0`, "-T", SIDEBAR_TITLE], { runner });
|
|
68
|
+
buildBar({ runner });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
let child;
|
|
73
|
+
try { child = spawner("tmux", ["-L", HERD_SOCKET, "attach-session", "-t", WORKSPACE], { stdio: "inherit" }); }
|
|
74
|
+
catch (error) { write(err(String(error.message || error))); resolve(1); return; }
|
|
75
|
+
child.on("error", (error) => { write(err(String(error.message || error))); resolve(1); });
|
|
76
|
+
child.on("exit", () => {
|
|
77
|
+
write(info(`detached — everything is still running. ${acid("moshcode ps")} · ${acid("moshcode herd ui")}`));
|
|
78
|
+
resolve(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ------------------------------------------------------------------ the bar */
|
|
84
|
+
|
|
85
|
+
/** How the bar pane starts itself. Separated so tests can run a stand-in. */
|
|
86
|
+
export function barCommand(self = process.argv[1]) {
|
|
87
|
+
return `${process.execPath} ${self} herd bar`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add the one-line mosh prompt under the content, and the key that reaches it.
|
|
92
|
+
*
|
|
93
|
+
* The binding goes in tmux's root table, so it is claimed before the pane's
|
|
94
|
+
* application ever sees it — that is what makes it work from inside an agent
|
|
95
|
+
* that has taken the keyboard, which is the case the bar exists for. It also
|
|
96
|
+
* switches the client first, so it is a way out of a member you attached to
|
|
97
|
+
* directly and not only of the workspace.
|
|
98
|
+
*/
|
|
99
|
+
export function buildBar({ runner = spawnSync, command = barCommand() } = {}) {
|
|
100
|
+
const made = tmux(
|
|
101
|
+
["split-window", "-t", TARGET, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
|
|
102
|
+
{ runner },
|
|
103
|
+
);
|
|
104
|
+
if (!made.ok) return null;
|
|
105
|
+
const paneId = made.stdout.trim().split("\n")[0];
|
|
106
|
+
if (!paneId) return null;
|
|
107
|
+
tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner });
|
|
108
|
+
// One string, not separate arguments: a bare ";" argument ends the bind-key
|
|
109
|
+
// command itself, so tmux binds the first command and runs the second once,
|
|
110
|
+
// now. That silently produced a key that switched sessions and did nothing
|
|
111
|
+
// else — the binding has to arrive as a single command sequence.
|
|
112
|
+
tmux(["bind-key", "-n", BAR_KEY, `switch-client -t ${WORKSPACE} ; select-pane -t ${paneId}`], { runner });
|
|
113
|
+
tmux(["select-pane", "-t", `${TARGET}.0`], { runner });
|
|
114
|
+
return paneId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ------------------------------------------------------------- the swapping */
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The content pane — the one that is neither the sidebar nor the bar.
|
|
121
|
+
*
|
|
122
|
+
* Excluding by title rather than "any pane that is not me": the bar made that
|
|
123
|
+
* shortcut wrong, and wrong here means a swap parks the bar into a session
|
|
124
|
+
* named after it and the prompt vanishes off the bottom of the screen.
|
|
125
|
+
*/
|
|
126
|
+
export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
127
|
+
const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner });
|
|
128
|
+
if (!r.ok) return null;
|
|
129
|
+
for (const line of r.stdout.split("\n")) {
|
|
130
|
+
const [paneId, title] = line.split("\t");
|
|
131
|
+
if (!paneId || paneId === me) continue;
|
|
132
|
+
if (title === BAR_TITLE || title === SIDEBAR_TITLE) continue;
|
|
133
|
+
return { paneId, title };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Send a pane back to a session of its own.
|
|
140
|
+
*
|
|
141
|
+
* `break-pane` cannot do this: its `-t` is a destination window that has to
|
|
142
|
+
* exist already, not a name to create. So it is the join dance backwards —
|
|
143
|
+
* make the session, move the pane in, drop the placeholder the session was
|
|
144
|
+
* born with.
|
|
145
|
+
*/
|
|
146
|
+
export function parkPane(paneId, name, { runner = spawnSync } = {}) {
|
|
147
|
+
if (!name) return false;
|
|
148
|
+
const made = tmux(["new-session", "-d", "-s", name, "-n", name], { runner });
|
|
149
|
+
if (!made.ok && !/duplicate session/i.test(made.stderr || "")) return false;
|
|
150
|
+
const placeholder = made.ok
|
|
151
|
+
? tmux(["list-panes", "-t", name, "-F", "#{pane_id}"], { runner }).stdout.trim().split("\n")[0]
|
|
152
|
+
: null;
|
|
153
|
+
const joined = tmux(["join-pane", "-s", paneId, "-t", `${name}:${name}`], { runner });
|
|
154
|
+
if (!joined.ok) return false;
|
|
155
|
+
if (placeholder) tmux(["kill-pane", "-t", placeholder], { runner });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Put `name` in the content pane, parking whatever was there. */
|
|
160
|
+
export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
161
|
+
const current = contentPane({ runner, me });
|
|
162
|
+
if (current?.title === name) return true; // already showing
|
|
163
|
+
const panes = paneIndex({ runner });
|
|
164
|
+
const wanted = panes.get(name);
|
|
165
|
+
if (!wanted) return false;
|
|
166
|
+
|
|
167
|
+
if (current) parkPane(current.paneId, current.title, { runner });
|
|
168
|
+
|
|
169
|
+
// Split the SIDEBAR rather than laying the window out.
|
|
170
|
+
//
|
|
171
|
+
// `select-layout main-vertical` was the obvious way to do this and it is the
|
|
172
|
+
// wrong one once a footer exists: it owns every pane in the window, so it
|
|
173
|
+
// dragged the bar into the right-hand column and gave it an equal share, and
|
|
174
|
+
// putting it back was a second fight every swap. Splitting the sidebar
|
|
175
|
+
// touches only the region above the footer, which leaves the bar a full-width
|
|
176
|
+
// row at the bottom and needs no correction afterwards.
|
|
177
|
+
const roles = paneRoles(TARGET, { runner });
|
|
178
|
+
const anchor = roles.sidebar?.paneId || me;
|
|
179
|
+
const joined = anchor
|
|
180
|
+
? tmux(["join-pane", "-h", "-s", wanted.paneId, "-t", anchor], { runner })
|
|
181
|
+
: tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
|
|
182
|
+
if (!joined.ok) return false;
|
|
183
|
+
if (anchor) tmux(["resize-pane", "-t", anchor, "-x", String(SIDEBAR_WIDTH)], { runner });
|
|
184
|
+
tmux(["select-pane", "-t", me], { runner });
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Hand the keyboard to the session on screen. */
|
|
189
|
+
export function focusContent({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
190
|
+
const current = contentPane({ runner, me });
|
|
191
|
+
if (!current) return false;
|
|
192
|
+
tmux(["select-pane", "-t", current.paneId], { runner });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* --------------------------------------------------------------- the render */
|
|
197
|
+
|
|
198
|
+
const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
|
|
199
|
+
const paintState = (state, text) =>
|
|
200
|
+
state === "blocked" ? amber(text)
|
|
201
|
+
: state === "working" ? acid(text)
|
|
202
|
+
: state === "done" ? bone(text)
|
|
203
|
+
: state === "gone" ? danger(text)
|
|
204
|
+
: ash(text);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The sidebar's rows, and the line each one sits on — one list so a click and
|
|
208
|
+
* the highlight cannot disagree (the bug that made the first list send every
|
|
209
|
+
* click to the row below the pointer).
|
|
210
|
+
*/
|
|
211
|
+
export function sidebarRows(sessions) {
|
|
212
|
+
const rows = [{ kind: "title" }, { kind: "gap" }];
|
|
213
|
+
for (const group of groupByHerd(sessions)) {
|
|
214
|
+
rows.push({ kind: "herd", herd: group.name });
|
|
215
|
+
for (const session of group.members) rows.push({ kind: "session", session });
|
|
216
|
+
}
|
|
217
|
+
rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" });
|
|
218
|
+
for (const action of ACTIONS) rows.push({ kind: "action", action });
|
|
219
|
+
// The two keys that stop the workspace being a one-way trip, on screen at all
|
|
220
|
+
// times. Everything else here is discoverable by looking; these are not.
|
|
221
|
+
rows.push({ kind: "gap" });
|
|
222
|
+
rows.push({ kind: "hint", text: "enter ▸ type in it" });
|
|
223
|
+
rows.push({ kind: "hint", text: `${BAR_KEY} ▸ mosh bar` });
|
|
224
|
+
return rows.map((row, i) => ({ ...row, line: i + 1 }));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH } = {}) {
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const row of rows) {
|
|
230
|
+
if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; }
|
|
231
|
+
if (row.kind === "gap") { out.push(""); continue; }
|
|
232
|
+
if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; }
|
|
233
|
+
if (row.kind === "hint") { out.push(` ${dim(row.text)}`); continue; }
|
|
234
|
+
if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; }
|
|
235
|
+
if (row.kind === "session") {
|
|
236
|
+
const s = row.session;
|
|
237
|
+
const here = s.name === showing ? acid("▸") : " ";
|
|
238
|
+
const label = s.name.slice(0, width - 7);
|
|
239
|
+
const text = s.name === selected ? bone(label) : ash(label);
|
|
240
|
+
out.push(`${here} ${paintState(s.state, MARK[s.state] || "?")} ${text}`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const isSel = row.action.key === selected;
|
|
244
|
+
out.push(` ${isSel ? bone(row.action.label) : ash(row.action.label)}`);
|
|
245
|
+
}
|
|
246
|
+
return out.join("\r\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* -------------------------------------------------------- the sidebar itself */
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Runs inside the left pane. Draws the list, and turns a click into a swap.
|
|
253
|
+
*
|
|
254
|
+
* It does not take the alternate screen: it *is* a pane, and the pane is the
|
|
255
|
+
* screen. Mouse reporting is enabled for this program specifically, which tmux
|
|
256
|
+
* forwards rather than consuming once an application asks for it.
|
|
257
|
+
*/
|
|
258
|
+
export async function herdSidebar({
|
|
259
|
+
stdin = process.stdin, stdout = process.stdout, read = roster, refreshMs = 2000, runner = spawnSync,
|
|
260
|
+
} = {}) {
|
|
261
|
+
const me = process.env.TMUX_PANE;
|
|
262
|
+
let sessions = read();
|
|
263
|
+
let rows = sidebarRows(sessions);
|
|
264
|
+
let selected = sessions[0]?.name || ACTIONS[0].key;
|
|
265
|
+
let showing = null;
|
|
266
|
+
|
|
267
|
+
const draw = () => {
|
|
268
|
+
stdout.write("\x1b[2J\x1b[H" + renderSidebar(rows, { selected, showing }));
|
|
269
|
+
};
|
|
270
|
+
const refresh = () => {
|
|
271
|
+
sessions = read();
|
|
272
|
+
rows = sidebarRows(sessions);
|
|
273
|
+
const current = contentPane({ runner, me });
|
|
274
|
+
showing = current?.title || null;
|
|
275
|
+
draw();
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Open on something rather than an empty right-hand side.
|
|
279
|
+
const first = sessions.find((s) => s.alive);
|
|
280
|
+
if (first) { showMember(first.name, { runner, me }); showing = first.name; }
|
|
281
|
+
|
|
282
|
+
stdout.write("\x1b[?1000h\x1b[?1006h\x1b[?25l");
|
|
283
|
+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
284
|
+
stdin.resume();
|
|
285
|
+
const restore = () => stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h");
|
|
286
|
+
process.on("exit", restore);
|
|
287
|
+
|
|
288
|
+
draw();
|
|
289
|
+
const timer = setInterval(refresh, refreshMs);
|
|
290
|
+
|
|
291
|
+
const act = async (what) => {
|
|
292
|
+
if (what === "detach") { tmux(["detach-client"], { runner }); return; }
|
|
293
|
+
if (what === "tile") {
|
|
294
|
+
const { herdTile } = await import("./herd-tile.mjs");
|
|
295
|
+
await herdTile([], { write: () => {}, spawner: () => ({ on: (e, cb) => e === "exit" && cb(0) }) });
|
|
296
|
+
refresh();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (what === "stop") {
|
|
300
|
+
const target = sessions.find((s) => s.name === selected);
|
|
301
|
+
if (!target) return;
|
|
302
|
+
const { killSession } = await import("./herd.mjs");
|
|
303
|
+
killSession(target.name);
|
|
304
|
+
refresh();
|
|
305
|
+
const next = read().find((s) => s.alive);
|
|
306
|
+
if (next) { showMember(next.name, { runner, me }); }
|
|
307
|
+
refresh();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// shell / agent: start it detached, then bring it into the content pane so
|
|
311
|
+
// the thing you just asked for is the thing you are looking at.
|
|
312
|
+
const { herdShell, herdStart } = await import("./herd-cli.mjs");
|
|
313
|
+
let created = null;
|
|
314
|
+
const capture = (line) => { const m = /^\S*\s*(\S+)\s+—/.exec(String(line).replace(/\x1b\[[0-9;]*m/g, "")); if (m) created = m[1]; };
|
|
315
|
+
if (what === "shell") herdShell([], { write: capture });
|
|
316
|
+
else herdStart(["claude", "--agent"], { write: capture });
|
|
317
|
+
refresh();
|
|
318
|
+
if (created) { showMember(created, { runner, me }); refresh(); }
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
await new Promise((resolve) => {
|
|
322
|
+
stdin.on("data", async (buf) => {
|
|
323
|
+
for (const event of parseInput(buf)) {
|
|
324
|
+
if (event.kind === "click") {
|
|
325
|
+
const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action"));
|
|
326
|
+
if (!hit) continue;
|
|
327
|
+
if (hit.kind === "session") {
|
|
328
|
+
// First click browses. Clicking the one already on screen hands it
|
|
329
|
+
// the keyboard — the same second-click-opens idiom as the list, and
|
|
330
|
+
// the only way to reach an agent without using the mouse on it.
|
|
331
|
+
const already = hit.session.name === showing;
|
|
332
|
+
selected = hit.session.name;
|
|
333
|
+
if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; }
|
|
334
|
+
draw();
|
|
335
|
+
if (already) focusContent({ runner, me });
|
|
336
|
+
} else {
|
|
337
|
+
selected = hit.action.key;
|
|
338
|
+
draw();
|
|
339
|
+
await act(hit.action.run);
|
|
340
|
+
}
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (event.kind !== "key") continue;
|
|
344
|
+
const action = ACTIONS.find((a) => a.key === event.key);
|
|
345
|
+
if (action) { await act(action.run); if (action.run === "detach") { resolve(); return; } continue; }
|
|
346
|
+
if (event.key === "\x03") { resolve(); return; }
|
|
347
|
+
const names = sessions.filter((s) => s.alive).map((s) => s.name);
|
|
348
|
+
const at = names.indexOf(selected);
|
|
349
|
+
if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected;
|
|
350
|
+
if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected;
|
|
351
|
+
if (event.key === "\r" || event.key === "\n") {
|
|
352
|
+
showMember(selected, { runner, me });
|
|
353
|
+
showing = selected;
|
|
354
|
+
draw();
|
|
355
|
+
focusContent({ runner, me });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
draw();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
clearInterval(timer);
|
|
364
|
+
restore();
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
package/src/herd.mjs
CHANGED
|
@@ -261,6 +261,11 @@ export function tmuxStartPlan({ name, cwd, command }) {
|
|
|
261
261
|
// clickable once you are inside. This server is moshcode's and starts from
|
|
262
262
|
// no config, so it is not overriding a preference anyone expressed.
|
|
263
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,
|
|
264
269
|
];
|
|
265
270
|
}
|
|
266
271
|
|
|
@@ -420,9 +425,11 @@ function ptyWrite(name, data) {
|
|
|
420
425
|
/** Names the substrate says are live right now. */
|
|
421
426
|
export function liveNames({ substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
422
427
|
if (substrate === "tmux") {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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));
|
|
426
433
|
}
|
|
427
434
|
if (substrate === "pty") {
|
|
428
435
|
// A finished session is still one the runtime has: it stays on the roster
|
|
@@ -463,6 +470,42 @@ export function sessionExited(name, { substrate = detectSubstrate(), runner = sp
|
|
|
463
470
|
* meant a fork per field per row, so a herd of six cost thirteen processes to
|
|
464
471
|
* draw one screen. tmux will format the whole server in one pass.
|
|
465
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
|
+
|
|
466
509
|
function tmuxSnapshot({ runner = spawnSync } = {}) {
|
|
467
510
|
const sessions = tmux(["list-sessions", "-F", "#{session_name}\t#{session_attached}"], { runner });
|
|
468
511
|
const attached = new Map();
|
|
@@ -540,7 +583,7 @@ export function startSession({
|
|
|
540
583
|
/** The last `lines` rows of a session's screen — what the classifier reads. */
|
|
541
584
|
export function capture(name, { lines = 60, substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
542
585
|
if (substrate === "tmux") {
|
|
543
|
-
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 });
|
|
544
587
|
return r.ok ? r.stdout.replace(/\n+$/, "") : "";
|
|
545
588
|
}
|
|
546
589
|
if (substrate === "pty") return ptyCapture(name, lines);
|
|
@@ -550,7 +593,7 @@ export function capture(name, { lines = 60, substrate = detectSubstrate(), runne
|
|
|
550
593
|
/** Raw key relay. `keys` is passed through to tmux's own key vocabulary. */
|
|
551
594
|
export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
552
595
|
if (substrate === "tmux") {
|
|
553
|
-
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 });
|
|
554
597
|
return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "send-keys failed") };
|
|
555
598
|
}
|
|
556
599
|
if (substrate === "pty") {
|
|
@@ -571,9 +614,10 @@ export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = s
|
|
|
571
614
|
*/
|
|
572
615
|
export function sendPrompt(name, text, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
573
616
|
if (substrate === "tmux") {
|
|
574
|
-
const
|
|
617
|
+
const pane = target(name, { runner });
|
|
618
|
+
const typed = tmux(["send-keys", "-t", pane, "-l", String(text)], { runner });
|
|
575
619
|
if (!typed.ok) return { ok: false, error: new Error(typed.stderr.trim() || "send-keys failed") };
|
|
576
|
-
const entered = tmux(["send-keys", "-t",
|
|
620
|
+
const entered = tmux(["send-keys", "-t", pane, "Enter"], { runner });
|
|
577
621
|
return entered.ok ? { ok: true } : { ok: false, error: new Error(entered.stderr.trim() || "send-keys failed") };
|
|
578
622
|
}
|
|
579
623
|
if (substrate === "pty") return ptyWrite(name, `${String(text)}\r`);
|
|
@@ -583,7 +627,9 @@ export function sendPrompt(name, text, { substrate = detectSubstrate(), runner =
|
|
|
583
627
|
/** End a session and forget it. */
|
|
584
628
|
export function killSession(name, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
|
|
585
629
|
if (substrate === "tmux") {
|
|
586
|
-
|
|
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 });
|
|
587
633
|
forgetSession(name);
|
|
588
634
|
return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
|
|
589
635
|
}
|
|
@@ -630,9 +676,19 @@ export async function attachSession(name, {
|
|
|
630
676
|
stdout = process.stdout,
|
|
631
677
|
} = {}) {
|
|
632
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];
|
|
633
689
|
return new Promise((resolve) => {
|
|
634
690
|
let child;
|
|
635
|
-
try { child = spawner("tmux", tmuxArgs(
|
|
691
|
+
try { child = spawner("tmux", tmuxArgs(argv), { stdio: "inherit", env }); }
|
|
636
692
|
catch (error) { resolve({ ok: false, error }); return; }
|
|
637
693
|
child.on("error", (error) => resolve({ ok: false, error }));
|
|
638
694
|
child.on("exit", (code, signal) => resolve({ ok: code === 0, code, signal }));
|
|
@@ -726,14 +782,17 @@ export function ptyAttachSession(name, { stdin = process.stdin, stdout = process
|
|
|
726
782
|
*/
|
|
727
783
|
export function listSessions({ substrate = detectSubstrate(), runner = spawnSync, now = Date.now() } = {}) {
|
|
728
784
|
const manifest = readManifest();
|
|
785
|
+
const panes = substrate === "tmux" ? paneIndex({ runner }) : null;
|
|
729
786
|
const snapshot = substrate === "tmux" ? tmuxSnapshot({ runner }) : null;
|
|
730
|
-
const live = new Set(
|
|
787
|
+
const live = new Set(panes
|
|
788
|
+
? [...panes.keys()].filter((name) => name in manifest.sessions)
|
|
789
|
+
: liveNames({ substrate, runner }));
|
|
731
790
|
const names = [...new Set([...live, ...Object.keys(manifest.sessions)])].sort();
|
|
732
791
|
return names.map((name) => {
|
|
733
792
|
const meta = manifest.sessions[name] || {};
|
|
734
793
|
const alive = live.has(name);
|
|
735
794
|
const exited = !alive ? null
|
|
736
|
-
:
|
|
795
|
+
: panes ? Boolean(panes.get(name)?.dead)
|
|
737
796
|
: sessionExited(name, { substrate, runner });
|
|
738
797
|
return {
|
|
739
798
|
name,
|
|
@@ -746,7 +805,11 @@ export function listSessions({ substrate = detectSubstrate(), runner = spawnSync
|
|
|
746
805
|
age: meta.created ? now - meta.created : null,
|
|
747
806
|
alive,
|
|
748
807
|
exited,
|
|
749
|
-
|
|
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,
|
|
750
813
|
substrate: meta.substrate || substrate,
|
|
751
814
|
};
|
|
752
815
|
});
|