Skip to content

Commit 28deb20

Browse files
authored
feat(herd): replace herd ui with a sidebar workspace (#353)
1 parent db6e84c commit 28deb20

5 files changed

Lines changed: 457 additions & 29 deletions

File tree

README.md

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -161,37 +161,45 @@ and answers `read`, `prompt` and `wait` exactly as before.
161161
Needs tmux. The `script(1)` fallback gives each session its own pty with no way
162162
to lay them out together, so it says so and points at the list instead.
163163

164-
### The clickable list
164+
### The workspace
165165

166166
```sh
167167
moshcode herd ui
168168
```
169169

170170
```
171-
moshcode herd 3 members 1 waiting on you
172-
173-
MAIN (2)
174-
▸ ! api claude blocked ~/src/coinpay
175-
· work shell idle ~/src/coinpay
176-
177-
SCRATCH (1)
178-
· logs shell idle ~/src/ugig.net
179-
180-
click or ↑↓ to choose · enter to go in · r refresh · q quit
181-
```
182-
183-
Click a member to go into it; detach and you land back on the list. Group
184-
sessions with `--herd <name>` when you start them; anything without one is in
185-
`main`.
186-
187-
It hands the terminal to a **real** attach rather than drawing the session
188-
itself, so what you get inside is a genuine terminal — full colour, real cursor,
189-
the agent's own mouse handling. The cost is that the list is not on screen at
190-
the same time as the session. tmux's model is session → window → pane and a pane
191-
belongs to exactly one window, so nothing can persist across a switch; a pinned
192-
sidebar would mean rendering every session from polled snapshots and losing all
193-
of the above. Once you are attached, `Ctrl-b s` is a clickable picker and the
194-
status line is a clickable list.
171+
┌ herd ──────┬─ api ─────────────────────────────┐
172+
│ herd │ │
173+
│ │ claude │
174+
│ MAIN │ Do you want to proceed? │
175+
│ ▸ ! api │ ❯ 1. Yes │
176+
│ · work │ 2. No │
177+
│ SCRATCH │ │
178+
│ · logs │ │
179+
│ │ │
180+
│ ACTIONS │ │
181+
│ + shell │ │
182+
│ + agent │ │
183+
│ ✕ stop │ │
184+
│ ⊞ tile │ │
185+
│ ← detach │ │
186+
└────────────┴───────────────────────────────────┘
187+
```
188+
189+
Members and actions down the left, the selected member's **real terminal** on
190+
the right. Click a member to show it; click an action to start a shell, start an
191+
agent, or stop the selected one. `q` detaches and leaves everything running.
192+
193+
The right-hand pane is not a picture of a session — it *is* the session's pane,
194+
moved in. tmux's model is session → window → pane, so moving between *windows*
195+
cannot keep anything on screen; but `join-pane` moves a running pane into an
196+
existing window, so swapping only the content pane leaves the sidebar untouched.
197+
Both panes keep their process and their scrollback because tmux is moving the
198+
real thing, not redrawing it.
199+
200+
Group sessions with `--herd <name>` when you start them; anything without one is
201+
in `main`. Without tmux there is nothing to swap panes with, so `herd ui` falls
202+
back to a plain clickable list.
195203

196204
### A workspace: a few shells and an agent
197205

src/cli-schema.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -744,9 +744,10 @@ export const HERD_VERBS = [
744744
examples: [["moshcode herd tile", "all of them"], ["moshcode herd tile scratch", "one herd"]] },
745745
{ name: "untile", description: "put tiled members back in their own sessions",
746746
synopsis: [["moshcode herd untile", ""]] },
747-
{ name: "ui", description: "the clickable list of herds and their members",
748-
synopsis: [["moshcode herd ui", "click to select, again to open · t tiles them all · q quits"]],
749-
flags: [] },
747+
{ name: "ui", description: "sidebar of members and actions, selected one beside it",
748+
synopsis: [["moshcode herd ui", "click a member to show it · s shell · a agent · x stop · q detach"]],
749+
flags: [],
750+
examples: [["moshcode herd ui", "the workspace — start here"]] },
750751
{ name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
751752
synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
752753
flags: [

src/herd-cli.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,10 @@ export function herdStop(argv, { write = console.log } = {}) {
725725
const VERBS = {
726726
// Lazy import: the UI pulls in escape-sequence machinery and only matters
727727
// 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),
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),
729732
tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
730733
untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
731734
ps: herdPs, list: herdPs, status: herdStatus,

src/herd-workspace.mjs

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
// `moshcode herd ui` — a sidebar of members and actions, with the selected
2+
// member's real terminal beside it.
3+
//
4+
// This replaces the modal list, which was the wrong answer to the question.
5+
// The list showed you the herd OR a session and never both, so getting into one
6+
// was a one-way trip and nothing on the list could start or stop anything.
7+
//
8+
// HOW THE SIDEBAR SURVIVES A SWITCH. tmux's model is session > window > pane,
9+
// and a pane belongs to exactly one window — which is why moving between
10+
// *windows* cannot keep anything on screen. But `join-pane` moves a running
11+
// pane into an existing window, so swapping only the *content* pane leaves the
12+
// sidebar untouched. Selecting a member parks the current content pane back
13+
// into a session of its own and joins the new one in; both keep their processes
14+
// and their scrollback, because tmux is moving the real pane rather than
15+
// redrawing a picture of it.
16+
//
17+
// Two processes, therefore: the launcher below builds the window and attaches,
18+
// and `herdSidebar` is what runs *inside* the left pane doing the swapping.
19+
import { spawn, spawnSync } from "node:child_process";
20+
21+
import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
22+
import { roster } from "./herd-cli.mjs";
23+
import { groupByHerd, parseInput } from "./herd-ui.mjs";
24+
import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
25+
26+
export const WORKSPACE = "herd";
27+
export const WINDOW = "ui";
28+
export const TARGET = `${WORKSPACE}:${WINDOW}`;
29+
const SIDEBAR_WIDTH = 26;
30+
31+
/** The rows in the sidebar that are not members. */
32+
export const ACTIONS = [
33+
{ key: "s", label: "+ shell", run: "shell" },
34+
{ key: "a", label: "+ agent", run: "agent" },
35+
{ key: "x", label: "✕ stop", run: "stop" },
36+
{ key: "t", label: "⊞ tile all", run: "tile" },
37+
{ key: "q", label: "← detach", run: "detach" },
38+
];
39+
40+
/* -------------------------------------------------------------- the layout */
41+
42+
/**
43+
* Build the window and attach to it.
44+
*
45+
* Falls back to the plain list where there is no tmux, because the swap this
46+
* is built on is a tmux operation and the script(1) substrate has one pty per
47+
* session with no way to put two of them side by side.
48+
*/
49+
export async function herdUi(argv = [], { write = console.log, spawner = spawn, runner = spawnSync } = {}) {
50+
const substrate = detectSubstrate();
51+
if (substrate !== "tmux") {
52+
const { herdUi: list } = await import("./herd-ui.mjs");
53+
return list({});
54+
}
55+
56+
const existing = tmux(["has-session", "-t", WORKSPACE], { runner });
57+
if (!existing.ok) {
58+
const self = process.argv[1];
59+
const sidebar = `${process.execPath} ${self} herd sidebar`;
60+
const made = tmux(["new-session", "-d", "-s", WORKSPACE, "-n", WINDOW, sidebar], { runner });
61+
if (!made.ok) { write(err(made.stderr.trim() || "could not open the workspace")); return 1; }
62+
// The sidebar is the "main" pane of a main-vertical layout, which is what
63+
// pins it to the left at a fixed width while the content pane takes the
64+
// rest and follows the terminal when it resizes.
65+
tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
66+
tmux(["set-option", "-t", WORKSPACE, "mouse", "on"], { runner });
67+
tmux(["set-option", "-t", WORKSPACE, "status", "off"], { runner });
68+
tmux(["set-option", "-t", WORKSPACE, "pane-border-status", "top"], { runner });
69+
tmux(["set-option", "-t", WORKSPACE, "pane-border-format", " #{pane_title} "], { runner });
70+
tmux(["select-pane", "-t", `${TARGET}.0`, "-T", "herd"], { runner });
71+
}
72+
73+
return new Promise((resolve) => {
74+
let child;
75+
try { child = spawner("tmux", ["-L", HERD_SOCKET, "attach-session", "-t", WORKSPACE], { stdio: "inherit" }); }
76+
catch (error) { write(err(String(error.message || error))); resolve(1); return; }
77+
child.on("error", (error) => { write(err(String(error.message || error))); resolve(1); });
78+
child.on("exit", () => {
79+
write(info(`detached — everything is still running. ${acid("moshcode ps")} · ${acid("moshcode herd ui")}`));
80+
resolve(0);
81+
});
82+
});
83+
}
84+
85+
/* ------------------------------------------------------------- the swapping */
86+
87+
/** The content pane currently on the right, if there is one. */
88+
export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
89+
const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner });
90+
if (!r.ok) return null;
91+
for (const line of r.stdout.split("\n")) {
92+
const [paneId, title] = line.split("\t");
93+
if (!paneId || paneId === me) continue;
94+
return { paneId, title };
95+
}
96+
return null;
97+
}
98+
99+
/**
100+
* Send a pane back to a session of its own.
101+
*
102+
* `break-pane` cannot do this: its `-t` is a destination window that has to
103+
* exist already, not a name to create. So it is the join dance backwards —
104+
* make the session, move the pane in, drop the placeholder the session was
105+
* born with.
106+
*/
107+
export function parkPane(paneId, name, { runner = spawnSync } = {}) {
108+
if (!name) return false;
109+
const made = tmux(["new-session", "-d", "-s", name, "-n", name], { runner });
110+
if (!made.ok && !/duplicate session/i.test(made.stderr || "")) return false;
111+
const placeholder = made.ok
112+
? tmux(["list-panes", "-t", name, "-F", "#{pane_id}"], { runner }).stdout.trim().split("\n")[0]
113+
: null;
114+
const joined = tmux(["join-pane", "-s", paneId, "-t", `${name}:${name}`], { runner });
115+
if (!joined.ok) return false;
116+
if (placeholder) tmux(["kill-pane", "-t", placeholder], { runner });
117+
return true;
118+
}
119+
120+
/** Put `name` in the content pane, parking whatever was there. */
121+
export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
122+
const current = contentPane({ runner, me });
123+
if (current?.title === name) return true; // already showing
124+
const panes = paneIndex({ runner });
125+
const wanted = panes.get(name);
126+
if (!wanted) return false;
127+
128+
if (current) parkPane(current.paneId, current.title, { runner });
129+
const joined = tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
130+
if (!joined.ok) return false;
131+
tmux(["select-layout", "-t", TARGET, "main-vertical"], { runner });
132+
// main-vertical resets the main pane's width from the option, so re-assert it
133+
// after every swap or the sidebar creeps wider each time.
134+
tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
135+
tmux(["select-pane", "-t", me], { runner });
136+
return true;
137+
}
138+
139+
/* --------------------------------------------------------------- the render */
140+
141+
const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
142+
const paintState = (state, text) =>
143+
state === "blocked" ? amber(text)
144+
: state === "working" ? acid(text)
145+
: state === "done" ? bone(text)
146+
: state === "gone" ? danger(text)
147+
: ash(text);
148+
149+
/**
150+
* The sidebar's rows, and the line each one sits on — one list so a click and
151+
* the highlight cannot disagree (the bug that made the first list send every
152+
* click to the row below the pointer).
153+
*/
154+
export function sidebarRows(sessions) {
155+
const rows = [{ kind: "title" }, { kind: "gap" }];
156+
for (const group of groupByHerd(sessions)) {
157+
rows.push({ kind: "herd", herd: group.name });
158+
for (const session of group.members) rows.push({ kind: "session", session });
159+
}
160+
rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" });
161+
for (const action of ACTIONS) rows.push({ kind: "action", action });
162+
return rows.map((row, i) => ({ ...row, line: i + 1 }));
163+
}
164+
165+
export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH } = {}) {
166+
const out = [];
167+
for (const row of rows) {
168+
if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; }
169+
if (row.kind === "gap") { out.push(""); continue; }
170+
if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; }
171+
if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; }
172+
if (row.kind === "session") {
173+
const s = row.session;
174+
const here = s.name === showing ? acid("▸") : " ";
175+
const label = s.name.slice(0, width - 7);
176+
const text = s.name === selected ? bone(label) : ash(label);
177+
out.push(`${here} ${paintState(s.state, MARK[s.state] || "?")} ${text}`);
178+
continue;
179+
}
180+
const isSel = row.action.key === selected;
181+
out.push(` ${isSel ? bone(row.action.label) : ash(row.action.label)}`);
182+
}
183+
return out.join("\r\n");
184+
}
185+
186+
/* -------------------------------------------------------- the sidebar itself */
187+
188+
/**
189+
* Runs inside the left pane. Draws the list, and turns a click into a swap.
190+
*
191+
* It does not take the alternate screen: it *is* a pane, and the pane is the
192+
* screen. Mouse reporting is enabled for this program specifically, which tmux
193+
* forwards rather than consuming once an application asks for it.
194+
*/
195+
export async function herdSidebar({
196+
stdin = process.stdin, stdout = process.stdout, read = roster, refreshMs = 2000, runner = spawnSync,
197+
} = {}) {
198+
const me = process.env.TMUX_PANE;
199+
let sessions = read();
200+
let rows = sidebarRows(sessions);
201+
let selected = sessions[0]?.name || ACTIONS[0].key;
202+
let showing = null;
203+
204+
const draw = () => {
205+
stdout.write("\x1b[2J\x1b[H" + renderSidebar(rows, { selected, showing }));
206+
};
207+
const refresh = () => {
208+
sessions = read();
209+
rows = sidebarRows(sessions);
210+
const current = contentPane({ runner, me });
211+
showing = current?.title || null;
212+
draw();
213+
};
214+
215+
// Open on something rather than an empty right-hand side.
216+
const first = sessions.find((s) => s.alive);
217+
if (first) { showMember(first.name, { runner, me }); showing = first.name; }
218+
219+
stdout.write("\x1b[?1000h\x1b[?1006h\x1b[?25l");
220+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
221+
stdin.resume();
222+
const restore = () => stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h");
223+
process.on("exit", restore);
224+
225+
draw();
226+
const timer = setInterval(refresh, refreshMs);
227+
228+
const act = async (what) => {
229+
if (what === "detach") { tmux(["detach-client"], { runner }); return; }
230+
if (what === "tile") {
231+
const { herdTile } = await import("./herd-tile.mjs");
232+
await herdTile([], { write: () => {}, spawner: () => ({ on: (e, cb) => e === "exit" && cb(0) }) });
233+
refresh();
234+
return;
235+
}
236+
if (what === "stop") {
237+
const target = sessions.find((s) => s.name === selected);
238+
if (!target) return;
239+
const { killSession } = await import("./herd.mjs");
240+
killSession(target.name);
241+
refresh();
242+
const next = read().find((s) => s.alive);
243+
if (next) { showMember(next.name, { runner, me }); }
244+
refresh();
245+
return;
246+
}
247+
// shell / agent: start it detached, then bring it into the content pane so
248+
// the thing you just asked for is the thing you are looking at.
249+
const { herdShell, herdStart } = await import("./herd-cli.mjs");
250+
let created = null;
251+
const capture = (line) => { const m = /^\S*\s*(\S+)\s+/.exec(String(line).replace(/\x1b\[[0-9;]*m/g, "")); if (m) created = m[1]; };
252+
if (what === "shell") herdShell([], { write: capture });
253+
else herdStart(["claude", "--agent"], { write: capture });
254+
refresh();
255+
if (created) { showMember(created, { runner, me }); refresh(); }
256+
};
257+
258+
await new Promise((resolve) => {
259+
stdin.on("data", async (buf) => {
260+
for (const event of parseInput(buf)) {
261+
if (event.kind === "click") {
262+
const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action"));
263+
if (!hit) continue;
264+
if (hit.kind === "session") {
265+
selected = hit.session.name;
266+
if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; }
267+
draw();
268+
} else {
269+
selected = hit.action.key;
270+
draw();
271+
await act(hit.action.run);
272+
}
273+
continue;
274+
}
275+
if (event.kind !== "key") continue;
276+
const action = ACTIONS.find((a) => a.key === event.key);
277+
if (action) { await act(action.run); if (action.run === "detach") { resolve(); return; } continue; }
278+
if (event.key === "\x03") { resolve(); return; }
279+
const names = sessions.filter((s) => s.alive).map((s) => s.name);
280+
const at = names.indexOf(selected);
281+
if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected;
282+
if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected;
283+
if (event.key === "\r" || event.key === "\n") { showMember(selected, { runner, me }); showing = selected; }
284+
draw();
285+
}
286+
});
287+
});
288+
289+
clearInterval(timer);
290+
restore();
291+
return 0;
292+
}

0 commit comments

Comments
 (0)