|
| 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