moshcode 0.34.0 → 0.35.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 +32 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +5 -0
- package/src/herd-cli.mjs +15 -7
- package/src/herd-ui.mjs +296 -0
- package/src/herd.mjs +7 -0
package/README.md
CHANGED
|
@@ -125,6 +125,38 @@ moshcode attach api # step in; Ctrl-b d steps back out
|
|
|
125
125
|
moshcode kill api # end it
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
### The clickable list
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
moshcode herd ui
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
moshcode herd 3 members 1 waiting on you
|
|
136
|
+
|
|
137
|
+
MAIN (2)
|
|
138
|
+
▸ ! api claude blocked ~/src/coinpay
|
|
139
|
+
· work shell idle ~/src/coinpay
|
|
140
|
+
|
|
141
|
+
SCRATCH (1)
|
|
142
|
+
· logs shell idle ~/src/ugig.net
|
|
143
|
+
|
|
144
|
+
click or ↑↓ to choose · enter to go in · r refresh · q quit
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Click a member to go into it; detach and you land back on the list. Group
|
|
148
|
+
sessions with `--herd <name>` when you start them; anything without one is in
|
|
149
|
+
`main`.
|
|
150
|
+
|
|
151
|
+
It hands the terminal to a **real** attach rather than drawing the session
|
|
152
|
+
itself, so what you get inside is a genuine terminal — full colour, real cursor,
|
|
153
|
+
the agent's own mouse handling. The cost is that the list is not on screen at
|
|
154
|
+
the same time as the session. tmux's model is session → window → pane and a pane
|
|
155
|
+
belongs to exactly one window, so nothing can persist across a switch; a pinned
|
|
156
|
+
sidebar would mean rendering every session from polled snapshots and losing all
|
|
157
|
+
of the above. Once you are attached, `Ctrl-b s` is a clickable picker and the
|
|
158
|
+
status line is a clickable list.
|
|
159
|
+
|
|
128
160
|
### A workspace: a few shells and an agent
|
|
129
161
|
|
|
130
162
|
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,9 @@ export const HERD_VERBS = [
|
|
|
736
738
|
["--agent", "autonomous mode — bypasses the engine's approvals", ""],
|
|
737
739
|
["--json", "machine-readable", ""],
|
|
738
740
|
] },
|
|
741
|
+
{ name: "ui", description: "the clickable list of herds and their members",
|
|
742
|
+
synopsis: [["moshcode herd ui", "click or ↑↓ to choose, enter to go in, q to quit"]],
|
|
743
|
+
flags: [] },
|
|
739
744
|
{ name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
|
|
740
745
|
synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
|
|
741
746
|
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,9 @@ 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: async (argv, options) => (await import("./herd-ui.mjs")).herdUi(options),
|
|
721
729
|
ps: herdPs, list: herdPs, status: herdStatus,
|
|
722
730
|
start: herdStart, run: herdRun, shell: herdShell,
|
|
723
731
|
attach: herdAttach, kill: herdKill, prune: herdPrune,
|
package/src/herd-ui.mjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
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
|
+
out.push(` ${dim("click or ↑↓ to choose · enter to go in · r refresh · q quit")}`);
|
|
125
|
+
if (substrate !== "tmux") out.push(` ${dim(substrateNote(substrate) || "")}`);
|
|
126
|
+
return out.join("\r\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const tilde = (p) => {
|
|
130
|
+
const home = process.env.HOME || "";
|
|
131
|
+
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Decode one SGR mouse report: ESC [ < button ; col ; row (M press | m release).
|
|
136
|
+
*
|
|
137
|
+
* Only presses matter here, and only button 0 (left) and the wheel. Returning
|
|
138
|
+
* null for everything else keeps the caller from acting on a release, which
|
|
139
|
+
* would otherwise fire every click twice.
|
|
140
|
+
*/
|
|
141
|
+
export function parseMouse(sequence) {
|
|
142
|
+
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(sequence);
|
|
143
|
+
if (!m) return null;
|
|
144
|
+
const [, button, col, row, kind] = m;
|
|
145
|
+
if (kind === "m") return null; // release
|
|
146
|
+
const b = Number(button);
|
|
147
|
+
if (b === 64) return { kind: "wheel", direction: -1 };
|
|
148
|
+
if (b === 65) return { kind: "wheel", direction: 1 };
|
|
149
|
+
if (b !== 0) return null;
|
|
150
|
+
return { kind: "click", col: Number(col), row: Number(row) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every mouse report in a chunk, so a fast click-drag cannot desync the parser. */
|
|
154
|
+
export function parseInput(buffer) {
|
|
155
|
+
const events = [];
|
|
156
|
+
const text = String(buffer);
|
|
157
|
+
const mouse = /\x1b\[<\d+;\d+;\d+[Mm]/g;
|
|
158
|
+
let match;
|
|
159
|
+
while ((match = mouse.exec(text))) {
|
|
160
|
+
const parsed = parseMouse(match[0]);
|
|
161
|
+
if (parsed) events.push(parsed);
|
|
162
|
+
}
|
|
163
|
+
if (events.length) return events;
|
|
164
|
+
for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "j", "k"]) {
|
|
165
|
+
if (text.includes(key)) events.push({ kind: "key", key });
|
|
166
|
+
}
|
|
167
|
+
return events;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Move the selection to the next/previous *session* row, skipping headings. */
|
|
171
|
+
export function moveSelection(rows, from, delta) {
|
|
172
|
+
const selectable = rows.map((r, i) => (r.kind === "session" ? i : -1)).filter((i) => i >= 0);
|
|
173
|
+
if (!selectable.length) return from;
|
|
174
|
+
const at = selectable.indexOf(from);
|
|
175
|
+
if (at < 0) return selectable[0];
|
|
176
|
+
return selectable[Math.min(selectable.length - 1, Math.max(0, at + delta))];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The interactive list.
|
|
181
|
+
*
|
|
182
|
+
* Everything the terminal had is restored through one `restore()`, wired to
|
|
183
|
+
* normal exit and to the signals that otherwise kill a raw-mode process — a
|
|
184
|
+
* crash here must not leave someone with no cursor, no echo, and the mouse
|
|
185
|
+
* still captured by a program that is gone.
|
|
186
|
+
*/
|
|
187
|
+
export async function herdUi({
|
|
188
|
+
stdin = process.stdin,
|
|
189
|
+
stdout = process.stdout,
|
|
190
|
+
attach = attachSession,
|
|
191
|
+
read = roster,
|
|
192
|
+
refreshMs = 2000,
|
|
193
|
+
} = {}) {
|
|
194
|
+
const substrate = detectSubstrate();
|
|
195
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
196
|
+
stdout.write("moshcode herd ui needs an interactive terminal — try `moshcode ps`\n");
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let rows = layout(groupByHerd(read()));
|
|
201
|
+
let selected = rows.findIndex((r) => r.kind === "session");
|
|
202
|
+
if (selected < 0) selected = 0;
|
|
203
|
+
let done = false;
|
|
204
|
+
let restored = false;
|
|
205
|
+
|
|
206
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
207
|
+
const restore = () => {
|
|
208
|
+
if (restored) return;
|
|
209
|
+
restored = true;
|
|
210
|
+
stdout.write(ESC.mouseOff + ESC.showCursor + ESC.altOff);
|
|
211
|
+
try { stdin.setRawMode?.(wasRaw); } catch { /* already gone */ }
|
|
212
|
+
stdin.pause();
|
|
213
|
+
};
|
|
214
|
+
const enter = () => {
|
|
215
|
+
restored = false;
|
|
216
|
+
stdout.write(ESC.altOn + ESC.hideCursor + ESC.mouseOn);
|
|
217
|
+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
218
|
+
stdin.resume();
|
|
219
|
+
};
|
|
220
|
+
const onSignal = () => { restore(); process.exit(130); };
|
|
221
|
+
process.on("exit", restore);
|
|
222
|
+
process.on("SIGINT", onSignal);
|
|
223
|
+
process.on("SIGTERM", onSignal);
|
|
224
|
+
|
|
225
|
+
const draw = () => {
|
|
226
|
+
if (done) return;
|
|
227
|
+
stdout.write(ESC.clear + render(rows, { selected, width: stdout.columns || 80, substrate }));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const refresh = () => {
|
|
231
|
+
const previous = rows[selected];
|
|
232
|
+
rows = layout(groupByHerd(read()));
|
|
233
|
+
// Keep the highlight on the same session across a refresh, not on the same
|
|
234
|
+
// line number — a session finishing above it would otherwise slide the
|
|
235
|
+
// selection onto something else just as someone pressed enter.
|
|
236
|
+
const again = rows.findIndex((r) => r.kind === "session" && r.session?.name === previous?.session?.name);
|
|
237
|
+
selected = again >= 0 ? again : Math.max(0, rows.findIndex((r) => r.kind === "session"));
|
|
238
|
+
draw();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
enter();
|
|
242
|
+
draw();
|
|
243
|
+
const timer = setInterval(refresh, refreshMs);
|
|
244
|
+
const onResize = () => draw();
|
|
245
|
+
stdout.on("resize", onResize);
|
|
246
|
+
|
|
247
|
+
const openSelected = async () => {
|
|
248
|
+
const row = rows[selected];
|
|
249
|
+
if (row?.kind !== "session" || !row.session.alive) return;
|
|
250
|
+
// Give the terminal back before handing it to tmux, and take it again after
|
|
251
|
+
// — attaching inside our alternate screen with the mouse captured would put
|
|
252
|
+
// the agent's own mouse handling in a fight with ours.
|
|
253
|
+
restore();
|
|
254
|
+
await attach(row.session.name, { substrate });
|
|
255
|
+
if (done) return;
|
|
256
|
+
enter();
|
|
257
|
+
refresh();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
await new Promise((resolve) => {
|
|
261
|
+
const onData = async (buf) => {
|
|
262
|
+
for (const event of parseInput(buf)) {
|
|
263
|
+
if (event.kind === "key" && (event.key === "q" || event.key === "\x03")) {
|
|
264
|
+
done = true;
|
|
265
|
+
resolve();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (event.kind === "key" && (event.key === "\x1b[A" || event.key === "k")) selected = moveSelection(rows, selected, -1);
|
|
269
|
+
else if (event.kind === "key" && (event.key === "\x1b[B" || event.key === "j")) selected = moveSelection(rows, selected, 1);
|
|
270
|
+
else if (event.kind === "key" && event.key === "r") { refresh(); continue; }
|
|
271
|
+
else if (event.kind === "wheel") selected = moveSelection(rows, selected, event.direction);
|
|
272
|
+
else if (event.kind === "click") {
|
|
273
|
+
const hit = rows.find((r) => r.kind === "session" && r.line === event.row);
|
|
274
|
+
if (!hit) continue;
|
|
275
|
+
selected = rows.indexOf(hit);
|
|
276
|
+
draw();
|
|
277
|
+
await openSelected();
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
else if (event.kind === "key") { await openSelected(); continue; }
|
|
281
|
+
draw();
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
stdin.on("data", onData);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
clearInterval(timer);
|
|
288
|
+
stdout.off("resize", onResize);
|
|
289
|
+
stdin.off("data", () => {});
|
|
290
|
+
restore();
|
|
291
|
+
process.off("exit", restore);
|
|
292
|
+
process.off("SIGINT", onSignal);
|
|
293
|
+
process.off("SIGTERM", onSignal);
|
|
294
|
+
stdout.write("\n");
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
package/src/herd.mjs
CHANGED
|
@@ -257,6 +257,10 @@ 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",
|
|
260
264
|
];
|
|
261
265
|
}
|
|
262
266
|
|
|
@@ -734,6 +738,9 @@ export function listSessions({ substrate = detectSubstrate(), runner = spawnSync
|
|
|
734
738
|
return {
|
|
735
739
|
name,
|
|
736
740
|
engine: meta.engine || "?",
|
|
741
|
+
// Sessions started before herds existed have none. They belong to `main`
|
|
742
|
+
// rather than to a group rendered as "undefined".
|
|
743
|
+
herd: meta.herd || "main",
|
|
737
744
|
cwd: meta.cwd || "",
|
|
738
745
|
created: meta.created || null,
|
|
739
746
|
age: meta.created ? now - meta.created : null,
|