moshcode 0.36.0 → 0.38.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 CHANGED
@@ -183,13 +183,49 @@ moshcode herd ui
183
183
  │ ✕ stop │ │
184
184
  │ ⊞ tile │ │
185
185
  │ ← detach │ │
186
- └────────────┴───────────────────────────────────┘
186
+ │ │ │
187
+ │ enter ▸ … │ │
188
+ │ F12 ▸ … │ │
189
+ ├────────────┴───────────────────────────────────┤
190
+ │ mosh ▸ ps · start claude · show <n> · detach │
191
+ └────────────────────────────────────────────────┘
187
192
  ```
188
193
 
189
194
  Members and actions down the left, the selected member's **real terminal** on
190
195
  the right. Click a member to show it; click an action to start a shell, start an
191
196
  agent, or stop the selected one. `q` detaches and leaves everything running.
192
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
+ **`moshcode attach <name>` gets the bar too.** A session you attach to directly
223
+ grows the same one-line prompt along the bottom for as long as you are there,
224
+ and it is taken away again when you detach — so a member is a plain member when
225
+ nobody is looking at it. `show <name>` from that bar switches you to another
226
+ member (and gives that one a bar before you land in it). The bar is the bottom
227
+ row either way, which is why one key finds it in both places.
228
+
193
229
  The right-hand pane is not a picture of a session — it *is* the session's pane,
194
230
  moved in. tmux's model is session → window → pane, so moving between *windows*
195
231
  cannot keep anything on screen; but `join-pane` moves a running pane into an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -115,10 +115,13 @@ export const CORE_CLI_COMMANDS = [
115
115
  name: "attach",
116
116
  group: "runtime",
117
117
  description: "attach this terminal to a herd session",
118
- synopsis: [["moshcode attach <name>", "detach again with Ctrl-b d (or Ctrl-] without tmux)"]],
118
+ synopsis: [["moshcode attach <name>", "F12 for the mosh bar · Ctrl-b d detaches (Ctrl-] without tmux)"]],
119
119
  examples: [["moshcode attach api", ""]],
120
120
  seeAlso: ["ps", "herd", "kill"],
121
- note: "detaching leaves the session running; ending it is `moshcode kill`. "
121
+ note: "under tmux the session gets a one-line mosh bar along the bottom for as long as you are "
122
+ + "attached, so the way out is on screen even when the agent has the keyboard: F12 reaches it, "
123
+ + "Esc goes back, `detach` leaves. it is taken away again when you detach. "
124
+ + "detaching leaves the session running; ending it is `moshcode kill`. "
122
125
  + "the whole herd shares one tmux server, so from inside any session Ctrl-b s picks another, "
123
126
  + "Ctrl-b ) and Ctrl-b ( step through them, and Ctrl-b L goes back to the last one — "
124
127
  + "no switcher under the no-tmux fallback, where Ctrl-] detaches instead.",
@@ -745,9 +748,17 @@ export const HERD_VERBS = [
745
748
  { name: "untile", description: "put tiled members back in their own sessions",
746
749
  synopsis: [["moshcode herd untile", ""]] },
747
750
  { 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"]],
751
+ synopsis: [["moshcode herd ui", "click a member to show it · click it again to type in it · F12 for the mosh bar"]],
749
752
  flags: [],
750
753
  examples: [["moshcode herd ui", "the workspace — start here"]] },
754
+ { name: "bar", description: "the one-line mosh prompt under the session (runs inside the workspace)",
755
+ synopsis: [["moshcode herd bar", "F12 reaches it from inside an agent · Esc goes back · detach leaves"]],
756
+ flags: [],
757
+ examples: [
758
+ ["F12", "jump to the bar from anywhere, even mid-agent"],
759
+ ["start claude", "another agent, without leaving this one"],
760
+ ["show api", "put a different member on screen"],
761
+ ] },
751
762
  { name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
752
763
  synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
753
764
  flags: [
@@ -0,0 +1,321 @@
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
+ /** How a bar pane starts itself. Separated so tests can run a stand-in. */
58
+ export function barCommand(self = process.argv[1]) {
59
+ return `${process.execPath} ${self} herd bar`;
60
+ }
61
+
62
+ /** The window a pane lives in, as a target string. */
63
+ export function ownTarget({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
64
+ if (!me) return null;
65
+ const r = tmux(["display-message", "-p", "-t", me, "#{session_name}:#{window_index}"], { runner });
66
+ return r.ok ? r.stdout.trim() || null : null;
67
+ }
68
+
69
+ /**
70
+ * Put a bar at the bottom of `target`, or find the one already there.
71
+ *
72
+ * Idempotent, because both the workspace and every attach want one and neither
73
+ * should care which of them got there first.
74
+ */
75
+ export function ensureBar(target, { runner = spawnSync, command = null } = {}) {
76
+ const existing = paneRoles(target, { runner }).bar;
77
+ if (existing) return { paneId: existing.paneId, created: false };
78
+ const made = tmux(
79
+ ["split-window", "-t", target, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
80
+ { runner },
81
+ );
82
+ if (!made.ok) return { paneId: null, created: false };
83
+ const paneId = made.stdout.trim().split("\n")[0];
84
+ if (!paneId) return { paneId: null, created: false };
85
+ tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner });
86
+ return { paneId, created: true };
87
+ }
88
+
89
+ /**
90
+ * Bind the key that reaches the bar.
91
+ *
92
+ * `{bottom-right}` rather than a pane id, so one binding serves the workspace
93
+ * and every attached session — in both, the bar is the bottom row. A pane id
94
+ * would have pinned the key to whichever bar happened to be built last.
95
+ *
96
+ * The root table is what makes it work at all: tmux claims the key before the
97
+ * pane's application ever sees it, which is the whole point when the pane holds
98
+ * an agent that has taken the keyboard.
99
+ */
100
+ export function bindJumpKey({ runner = spawnSync } = {}) {
101
+ return tmux(["bind-key", "-n", BAR_KEY, "select-pane", "-t", "{bottom-right}"], { runner }).ok;
102
+ }
103
+
104
+ /** Drop the bar from a window, leaving whatever else is in it alone. */
105
+ export function removeBar(target, { runner = spawnSync } = {}) {
106
+ const roles = paneRoles(target, { runner });
107
+ if (!roles.bar || !roles.content) return false;
108
+ return tmux(["kill-pane", "-t", roles.bar.paneId], { runner }).ok;
109
+ }
110
+
111
+ /**
112
+ * Take the bar back out of every session nobody is looking at.
113
+ *
114
+ * A bar left behind is not cosmetic: `kill` ends a member by killing its pane,
115
+ * so a session holding a leftover bar outlives the member it was named for and
116
+ * keeps showing up on the roster. Detaching cleans up after itself, but a
117
+ * crashed client cannot, so this also runs on the way in.
118
+ *
119
+ * Sessions with a client attached are skipped — someone else is using that bar.
120
+ */
121
+ export function sweepBars({ runner = spawnSync, except = null } = {}) {
122
+ const r = tmux(["list-panes", "-a", "-F",
123
+ "#{session_name}\t#{window_index}\t#{pane_title}\t#{session_attached}"], { runner });
124
+ if (!r.ok) return 0;
125
+ const windows = new Map();
126
+ for (const line of r.stdout.split("\n")) {
127
+ const [session, window, title, attached] = line.split("\t");
128
+ if (!session || session === except || attached !== "0") continue;
129
+ const key = `${session}:${window}`;
130
+ const seen = windows.get(key) || { bars: 0, others: 0 };
131
+ if (title === BAR_TITLE) seen.bars += 1; else seen.others += 1;
132
+ windows.set(key, seen);
133
+ }
134
+ let removed = 0;
135
+ for (const [target, seen] of windows) {
136
+ if (seen.bars && seen.others && removeBar(target, { runner })) removed += 1;
137
+ }
138
+ return removed;
139
+ }
140
+
141
+ /* --------------------------------------------------------------- line editing */
142
+
143
+ /**
144
+ * One keystroke against the current line. Pure, so the editor is testable
145
+ * without a terminal — the bar itself is then only plumbing.
146
+ */
147
+ export function editLine(line, key) {
148
+ if (key === "\r" || key === "\n") return { line, action: "submit" };
149
+ if (key === "\x1b") return { line: "", action: "escape" };
150
+ if (key === "\x03") return { line: "", action: "escape" }; // Ctrl-C
151
+ if (key === "\x15") return { line: "", action: "edit" }; // Ctrl-U
152
+ if (key === "\x17") return { line: line.replace(/\S+\s*$/, ""), action: "edit" }; // Ctrl-W
153
+ if (key === "\x7f" || key === "\b") return { line: line.slice(0, -1), action: "edit" };
154
+ if (key.length === 1 && key >= " " && key !== "\x7f") return { line: line + key, action: "edit" };
155
+ return { line, action: "none" };
156
+ }
157
+
158
+ /**
159
+ * What a typed line means.
160
+ *
161
+ * `attach` deliberately becomes `show`. Running the real attach from in here
162
+ * would start a tmux client inside the client already showing this pane, which
163
+ * tmux refuses — and the thing the word means in a workspace is "put it in the
164
+ * content pane" anyway.
165
+ */
166
+ export function resolveCommand(input) {
167
+ const argv = String(input || "").trim().split(/\s+/).filter(Boolean);
168
+ if (!argv.length) return { kind: "empty", argv: [] };
169
+ const [verb, ...rest] = argv;
170
+ if (verb === "detach" || verb === "exit" || verb === "quit") return { kind: "detach", argv: rest };
171
+ if (verb === "show" || verb === "attach" || verb === "fg") return { kind: "show", argv: rest };
172
+ if (verb === "help" || verb === "?") return { kind: "help", argv: rest };
173
+ if (verb === "clear") return { kind: "clear", argv: rest };
174
+ return { kind: "herd", argv };
175
+ }
176
+
177
+ /** The prompt line. The hint is what makes the way out discoverable at rest. */
178
+ export function renderPrompt(line, { cols = 80, showHint = true } = {}) {
179
+ const prompt = `${acid("mosh")} ${bone("▸")} `;
180
+ if (!line && showHint) return `${prompt}${ash(HINT.slice(0, Math.max(0, cols - 8)))}`;
181
+ return `${prompt}${line}`;
182
+ }
183
+
184
+ export function helpLines() {
185
+ return [
186
+ "the bar takes any moshcode herd verb:",
187
+ " ps the roster start claude new agent",
188
+ " show <name> put it on screen shell new shell",
189
+ " kill <name> end one tile all at once",
190
+ " read <name> its last screen prompt <n> <text> type into it",
191
+ "",
192
+ `${BAR_KEY} comes back here from anywhere · Esc returns to the session · detach leaves`,
193
+ ];
194
+ }
195
+
196
+ /* ----------------------------------------------------------------- the bar */
197
+
198
+ /**
199
+ * Runs inside the one-line pane at the bottom of the workspace.
200
+ */
201
+ export async function herdBar({
202
+ stdin = process.stdin,
203
+ stdout = process.stdout,
204
+ runner = spawnSync,
205
+ target = null,
206
+ run = null,
207
+ } = {}) {
208
+ const me = process.env.TMUX_PANE;
209
+ // The bar runs in the workspace AND under a plain attach, so it asks where it
210
+ // is rather than assuming. Everything below keys off that one answer.
211
+ const here = target || ownTarget({ runner, me }) || "herd:ui";
212
+ const inWorkspace = () => !!paneRoles(here, { runner }).sidebar;
213
+ const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options));
214
+
215
+ let line = "";
216
+ let open = false;
217
+
218
+ const cols = () => stdout.columns || 80;
219
+ const collapse = () => {
220
+ if (!open) return;
221
+ open = false;
222
+ tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
223
+ };
224
+ const expand = (rows) => {
225
+ open = true;
226
+ tmux(["resize-pane", "-t", me, "-y", String(Math.min(BAR_OPEN_HEIGHT, rows + 2))], { runner });
227
+ };
228
+ const draw = () => {
229
+ stdout.write(`\x1b[2J\x1b[H${renderPrompt(line, { cols: cols() })}`);
230
+ };
231
+ const show = (lines) => {
232
+ expand(lines.length);
233
+ stdout.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n${renderPrompt("", { cols: cols(), showHint: false })}`);
234
+ };
235
+ /** Give the keyboard back to whatever is on screen. */
236
+ const toContent = () => {
237
+ const roles = paneRoles(here, { runner });
238
+ if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner });
239
+ };
240
+
241
+ /**
242
+ * `show` means two different things and both are right.
243
+ *
244
+ * In the workspace it swaps the content pane. Under a plain attach there is
245
+ * no content pane to swap, so it switches the client to that member — and
246
+ * gives that member a bar first, or you would arrive somewhere with no way
247
+ * back out, which is the bug this whole thing exists to fix.
248
+ */
249
+ const showElsewhere = async (name) => {
250
+ const { paneIndex } = await import("./herd.mjs");
251
+ const found = paneIndex({ runner }).get(name);
252
+ if (!found) return false;
253
+ ensureBar(`${found.session}:${found.windowId}`, { runner, command: barCommand() });
254
+ return tmux(["switch-client", "-t", found.session], { runner }).ok;
255
+ };
256
+
257
+ const submit = async () => {
258
+ const typed = line;
259
+ line = "";
260
+ const command = resolveCommand(typed);
261
+ if (command.kind === "empty") { collapse(); draw(); toContent(); return true; }
262
+ if (command.kind === "clear") { collapse(); draw(); return true; }
263
+ if (command.kind === "help") { show(helpLines()); return true; }
264
+ if (command.kind === "detach") { tmux(["detach-client"], { runner }); return false; }
265
+ if (command.kind === "show") {
266
+ const [name] = command.argv;
267
+ let okShown = false;
268
+ if (name && inWorkspace()) {
269
+ const { showMember } = await import("./herd-workspace.mjs");
270
+ okShown = showMember(name, { runner, me });
271
+ } else if (name) {
272
+ okShown = await showElsewhere(name);
273
+ }
274
+ if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; }
275
+ collapse(); draw(); toContent();
276
+ return true;
277
+ }
278
+ const out = [];
279
+ await herdCommand(command.argv, { write: (s) => out.push(...String(s).split("\n")) });
280
+ if (out.length) show(out);
281
+ else { collapse(); draw(); }
282
+ return true;
283
+ };
284
+
285
+ /**
286
+ * Keep the bar one row.
287
+ *
288
+ * tmux scales panes proportionally when the window resizes, so a bar built
289
+ * before a client attached came back three rows tall once one did — the pane
290
+ * was created against an 80x24 window and stretched to fit 100x30. Nothing
291
+ * outside can predict when that happens, but the bar gets a resize event for
292
+ * it, so the bar is the thing that fixes it.
293
+ */
294
+ const keepThin = () => {
295
+ if (open) return;
296
+ tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
297
+ };
298
+
299
+ try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
300
+ stdin.resume();
301
+ stdout.on?.("resize", () => { keepThin(); draw(); });
302
+ keepThin();
303
+ draw();
304
+
305
+ await new Promise((resolve) => {
306
+ stdin.on("data", async (buf) => {
307
+ for (const key of String(buf)) {
308
+ const next = editLine(line, key);
309
+ line = next.line;
310
+ if (next.action === "submit") {
311
+ if (!(await submit())) { resolve(); return; }
312
+ continue;
313
+ }
314
+ if (next.action === "escape") { collapse(); draw(); toContent(); continue; }
315
+ if (next.action === "edit") { if (open) { collapse(); } draw(); }
316
+ }
317
+ });
318
+ });
319
+
320
+ return 0;
321
+ }
package/src/herd-cli.mjs CHANGED
@@ -10,7 +10,7 @@ import path from "node:path";
10
10
 
11
11
  import {
12
12
  attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET,
13
- herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt,
13
+ herdDir, killSession, listSessions, paneIndex, readManifest, rememberSession, sendKeys, sendPrompt,
14
14
  slugifyName, startSession, stopRuntime, substrateNote, validName, NAME_RE,
15
15
  } from "./herd.mjs";
16
16
  import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
@@ -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
 
@@ -328,9 +328,32 @@ export async function herdAttach(argv, { write = console.log } = {}) {
328
328
  // this whole feature is someone quitting a session they meant to leave
329
329
  // running, and the only defence is telling them the key first.
330
330
  const substrate = detectSubstrate();
331
- write(info(substrate === "tmux" ? "detach with Ctrl-b d — the session keeps running." : "detach with Ctrl-] — the session keeps running."));
331
+
332
+ // Give the session a mosh bar, so the way out is on screen the whole time
333
+ // rather than in a line that the agent's first repaint scrolls away. Only a
334
+ // member sitting in its own session: a tiled one shares a window with its
335
+ // neighbours and would be handing them a footer they did not ask for.
336
+ const bar = await import("./herd-bar.mjs");
337
+ let barTarget = null;
338
+ if (substrate === "tmux") {
339
+ bar.sweepBars({ runner: undefined, except: "herd" });
340
+ const found = paneIndex().get(name);
341
+ if (found && found.session === name) {
342
+ barTarget = `${found.session}:${found.windowId}`;
343
+ bar.ensureBar(barTarget, { command: bar.barCommand() });
344
+ bar.bindJumpKey({});
345
+ }
346
+ }
347
+
348
+ write(info(substrate === "tmux"
349
+ ? `detach with Ctrl-b d — the session keeps running.${barTarget ? ` ${bar.BAR_KEY} for the mosh bar.` : ""}`
350
+ : "detach with Ctrl-] — the session keeps running."));
332
351
 
333
352
  const result = await attachSession(name, { substrate });
353
+ // Take it back out on the way through, so a member is a member again: `kill`
354
+ // ends one by killing its pane, and a session still holding a bar would
355
+ // outlive the member and keep its name on the roster.
356
+ if (barTarget) bar.removeBar(barTarget, {});
334
357
  if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; }
335
358
 
336
359
  const after = findSession(name);
@@ -729,6 +752,7 @@ const VERBS = {
729
752
  // inside it for machines with no tmux to swap panes on.
730
753
  ui: async (argv, options) => (await import("./herd-workspace.mjs")).herdUi(argv, options),
731
754
  sidebar: async (argv, options) => (await import("./herd-workspace.mjs")).herdSidebar(options),
755
+ bar: async (argv, options) => (await import("./herd-bar.mjs")).herdBar(options),
732
756
  tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
733
757
  untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
734
758
  ps: herdPs, list: herdPs, status: herdStatus,
@@ -21,6 +21,7 @@ import { spawn, spawnSync } from "node:child_process";
21
21
  import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
22
22
  import { roster } from "./herd-cli.mjs";
23
23
  import { groupByHerd, parseInput } from "./herd-ui.mjs";
24
+ import { BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, barCommand, bindJumpKey, ensureBar, paneRoles } from "./herd-bar.mjs";
24
25
  import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
25
26
 
26
27
  export const WORKSPACE = "herd";
@@ -59,15 +60,12 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn,
59
60
  const sidebar = `${process.execPath} ${self} herd sidebar`;
60
61
  const made = tmux(["new-session", "-d", "-s", WORKSPACE, "-n", WINDOW, sidebar], { runner });
61
62
  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
63
  tmux(["set-option", "-t", WORKSPACE, "mouse", "on"], { runner });
67
64
  tmux(["set-option", "-t", WORKSPACE, "status", "off"], { runner });
68
65
  tmux(["set-option", "-t", WORKSPACE, "pane-border-status", "top"], { runner });
69
66
  tmux(["set-option", "-t", WORKSPACE, "pane-border-format", " #{pane_title} "], { runner });
70
- tmux(["select-pane", "-t", `${TARGET}.0`, "-T", "herd"], { runner });
67
+ tmux(["select-pane", "-t", `${TARGET}.0`, "-T", SIDEBAR_TITLE], { runner });
68
+ buildBar({ runner });
71
69
  }
72
70
 
73
71
  return new Promise((resolve) => {
@@ -82,15 +80,33 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn,
82
80
  });
83
81
  }
84
82
 
83
+ /* ------------------------------------------------------------------ the bar */
84
+
85
+ /** Add the one-line mosh prompt under the content, and the key that reaches it. */
86
+ export function buildBar({ runner = spawnSync, command = barCommand() } = {}) {
87
+ const { paneId } = ensureBar(TARGET, { runner, command });
88
+ if (!paneId) return null;
89
+ bindJumpKey({ runner });
90
+ tmux(["select-pane", "-t", `${TARGET}.0`], { runner });
91
+ return paneId;
92
+ }
93
+
85
94
  /* ------------------------------------------------------------- the swapping */
86
95
 
87
- /** The content pane currently on the right, if there is one. */
96
+ /**
97
+ * The content pane — the one that is neither the sidebar nor the bar.
98
+ *
99
+ * Excluding by title rather than "any pane that is not me": the bar made that
100
+ * shortcut wrong, and wrong here means a swap parks the bar into a session
101
+ * named after it and the prompt vanishes off the bottom of the screen.
102
+ */
88
103
  export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
89
104
  const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner });
90
105
  if (!r.ok) return null;
91
106
  for (const line of r.stdout.split("\n")) {
92
107
  const [paneId, title] = line.split("\t");
93
108
  if (!paneId || paneId === me) continue;
109
+ if (title === BAR_TITLE || title === SIDEBAR_TITLE) continue;
94
110
  return { paneId, title };
95
111
  }
96
112
  return null;
@@ -126,16 +142,34 @@ export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PAN
126
142
  if (!wanted) return false;
127
143
 
128
144
  if (current) parkPane(current.paneId, current.title, { runner });
129
- const joined = tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
145
+
146
+ // Split the SIDEBAR rather than laying the window out.
147
+ //
148
+ // `select-layout main-vertical` was the obvious way to do this and it is the
149
+ // wrong one once a footer exists: it owns every pane in the window, so it
150
+ // dragged the bar into the right-hand column and gave it an equal share, and
151
+ // putting it back was a second fight every swap. Splitting the sidebar
152
+ // touches only the region above the footer, which leaves the bar a full-width
153
+ // row at the bottom and needs no correction afterwards.
154
+ const roles = paneRoles(TARGET, { runner });
155
+ const anchor = roles.sidebar?.paneId || me;
156
+ const joined = anchor
157
+ ? tmux(["join-pane", "-h", "-s", wanted.paneId, "-t", anchor], { runner })
158
+ : tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
130
159
  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 });
160
+ if (anchor) tmux(["resize-pane", "-t", anchor, "-x", String(SIDEBAR_WIDTH)], { runner });
135
161
  tmux(["select-pane", "-t", me], { runner });
136
162
  return true;
137
163
  }
138
164
 
165
+ /** Hand the keyboard to the session on screen. */
166
+ export function focusContent({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
167
+ const current = contentPane({ runner, me });
168
+ if (!current) return false;
169
+ tmux(["select-pane", "-t", current.paneId], { runner });
170
+ return true;
171
+ }
172
+
139
173
  /* --------------------------------------------------------------- the render */
140
174
 
141
175
  const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
@@ -159,6 +193,11 @@ export function sidebarRows(sessions) {
159
193
  }
160
194
  rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" });
161
195
  for (const action of ACTIONS) rows.push({ kind: "action", action });
196
+ // The two keys that stop the workspace being a one-way trip, on screen at all
197
+ // times. Everything else here is discoverable by looking; these are not.
198
+ rows.push({ kind: "gap" });
199
+ rows.push({ kind: "hint", text: "enter ▸ type in it" });
200
+ rows.push({ kind: "hint", text: `${BAR_KEY} ▸ mosh bar` });
162
201
  return rows.map((row, i) => ({ ...row, line: i + 1 }));
163
202
  }
164
203
 
@@ -168,6 +207,7 @@ export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH }
168
207
  if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; }
169
208
  if (row.kind === "gap") { out.push(""); continue; }
170
209
  if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; }
210
+ if (row.kind === "hint") { out.push(` ${dim(row.text)}`); continue; }
171
211
  if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; }
172
212
  if (row.kind === "session") {
173
213
  const s = row.session;
@@ -262,9 +302,14 @@ export async function herdSidebar({
262
302
  const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action"));
263
303
  if (!hit) continue;
264
304
  if (hit.kind === "session") {
305
+ // First click browses. Clicking the one already on screen hands it
306
+ // the keyboard — the same second-click-opens idiom as the list, and
307
+ // the only way to reach an agent without using the mouse on it.
308
+ const already = hit.session.name === showing;
265
309
  selected = hit.session.name;
266
310
  if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; }
267
311
  draw();
312
+ if (already) focusContent({ runner, me });
268
313
  } else {
269
314
  selected = hit.action.key;
270
315
  draw();
@@ -280,7 +325,13 @@ export async function herdSidebar({
280
325
  const at = names.indexOf(selected);
281
326
  if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected;
282
327
  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; }
328
+ if (event.key === "\r" || event.key === "\n") {
329
+ showMember(selected, { runner, me });
330
+ showing = selected;
331
+ draw();
332
+ focusContent({ runner, me });
333
+ continue;
334
+ }
284
335
  draw();
285
336
  }
286
337
  });
package/src/herd.mjs CHANGED
@@ -629,7 +629,19 @@ export function killSession(name, { substrate = detectSubstrate(), runner = spaw
629
629
  if (substrate === "tmux") {
630
630
  // kill-pane, not kill-session: a tiled member shares its session with
631
631
  // every other tiled member, and killing that would take the lot.
632
+ const found = paneIndex({ runner }).get(name);
632
633
  const r = tmux(["kill-pane", "-t", target(name, { runner })], { runner });
634
+ // A member being attached to has a mosh bar under it, and the bar would
635
+ // hold the session open after its member is gone — an empty room still
636
+ // answering to the dead member's name on the roster. If that is all that is
637
+ // left, take the room too.
638
+ if (r.ok && found) {
639
+ const left = tmux(["list-panes", "-t", found.session, "-F", "#{pane_title}"], { runner });
640
+ const titles = left.ok ? left.stdout.split("\n").filter(Boolean) : [];
641
+ if (titles.length && titles.every((t) => t === "mosh-bar")) {
642
+ tmux(["kill-session", "-t", found.session], { runner });
643
+ }
644
+ }
633
645
  forgetSession(name);
634
646
  return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
635
647
  }