From 82774d3919f523557447e8ad9edc0856800675fa Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 08:24:42 +0000 Subject: [PATCH] fix(herd): the sidebar crash, one click to open, and `herd` opens the UI Four things Anthony reported about `moshcode herd`. THE CRASH. "it crashed when i click on agent in sidebar". Three causes, all in the click path: 1. `paneIndex()` in src/herd.mjs read `dead.trim()` on a list-panes line that may have fewer fields than the format string asked for. Every click runs it through showMember(). Reproduced with a stand-in tmux: TypeError: Cannot read properties of undefined (reading 'trim') at paneIndex (src/herd.mjs:604) at showMember (src/herd-workspace.mjs:140) 2. The stdin handler was `async` with no try/catch, so anything thrown under it became an unhandled promise rejection, and Node ends the process on one. There is no unhandledRejection handler anywhere in src/ or bin/, so nothing caught it. 3. The pin that stops a member renaming its own pane is a WINDOW option, and the whole trick this file is built on is moving panes between windows, so the pin never travelled. claude and a login shell both emit OSC 2 the moment they arrive: the member vanished from paneIndex, and the next click tried to park it under a session name containing ":" and ".", which tmux refuses. The pane could not leave, the next one joined in anyway, and the window ended up with two content panes and a sidebar squeezed to nothing. Reproduced live, and it is what "it crashed" looks like on screen. The restore path made all of this worse: it wrote escape sequences and never lifted raw mode, so a death here left the pane with no echo, no cursor and the mouse still captured. Escape sequences are undone by the next full-screen program; a terminal with no echo is not. Fixed: paneIndex reads the field defensively, the handler body is guarded and paints the reason on the sidebar instead of dying, the workspace window pins titles, parkPane falls back to a slug so a renamed pane can always leave, and one idempotent restore puts back raw mode as well as the escapes, wired to exit, SIGINT, SIGTERM and SIGHUP. ONE CLICK. A click on a member now opens it: shown AND given the keyboard. It used to browse on the first click and open on a second click of the same row, which is the double-click affordance Anthony rejected in diskpush 0.7.0. Hover does the browsing that argument was about: 1003 motion reporting lights the row under the pointer. The same change in the plain list, which carried the same idiom. Enter still works, and the sidebar's own s/a/x keys now reach the handler at all, which they never did: the shared parser only ever emitted the list's keys. Clicking an action no longer overwrites the member selection, which is why "stop" could never stop anything. BARE `herd` OPENS THE UI. Someone typing the noun with no verb wants to see the herd. `--json`, a pipe, a non-tty and an injected writer all keep the roster, because a full-screen UI must never be launched into something that is collecting lines. The pit closes readline first, like it does for /attach, or tmux and readline fight over every keystroke. CONFIDENCE. PRD 0019 gave every state a confidence, and a sidebar that draws a guess and a report identically puts back the lie the heartbeat exists to stop. Inferred states carry the roster's "?". More worked examples in `herd help`, covering the UI, tiling, the bar, cost, swarm and fleet, hooks, tasks, remote agents, restore after a reboot, and driving an agent without attaching. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli-schema.mjs | 43 ++++- src/herd-cli.mjs | 38 +++++ src/herd-ui.mjs | 56 +++++-- src/herd-workspace.mjs | 280 ++++++++++++++++++++++++------- src/herd.mjs | 10 +- src/tui.mjs | 21 ++- src/ui.mjs | 11 ++ test/herd-default-verb.test.mjs | 52 ++++++ test/herd-sidebar-click.test.mjs | 259 ++++++++++++++++++++++++++++ 9 files changed, 686 insertions(+), 84 deletions(-) create mode 100644 test/herd-default-verb.test.mjs create mode 100644 test/herd-sidebar-click.test.mjs diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 9ca262ed..abc698f5 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -76,13 +76,16 @@ export const CORE_CLI_COMMANDS = [ group: "runtime", description: "run agent sessions that outlive this terminal", synopsis: [ - ["moshcode herd", "the roster — same as moshcode ps"], + ["moshcode herd", "open the workspace, or the roster when there is no terminal to open it in"], ["moshcode herd [args…]", "drive one session"], ], verbs: "HERD_VERBS", flags: [["--json", "machine-readable, on every verb", ""]], examples: [ - ["moshcode herd ui", "the clickable list — start here"], + ["moshcode herd", "the workspace: members down the left, one of them live on the right"], + ["moshcode herd ui", "the same thing, said out loud"], + ["moshcode herd | cat", "into a pipe it is the roster instead, so scripts keep working"], + ["moshcode herd --json", "and that roster as data"], ["", ""], ["# a workspace: two shells and an agent, none of which die with this terminal", ""], ["moshcode herd shell --name work", "a plain $SHELL you can come back to"], @@ -91,17 +94,53 @@ export const CORE_CLI_COMMANDS = [ ["moshcode ps", "all three, and which one is blocked"], ["moshcode attach api", "step in · Ctrl-b s switches · Ctrl-b d leaves it running"], ["", ""], + ["# every member on screen at once, instead of one at a time", ""], + ["moshcode herd tile", "one tiled window · z zooms one · B pops it back out"], + ["moshcode herd tile api", "only the members in the api herd"], + ["moshcode herd untile", "each of them back in a session of its own"], + ["", ""], + ["# the bar: the command line that survives being inside an agent", ""], + ["moshcode herd bar", "a one-row mosh prompt under the content pane"], + ["F12", "reaches it from anywhere, including from inside claude"], + ["", ""], ["# an agent moshcode has no install spec for", ""], ["moshcode herd run --name cur -- cursor-agent", "anything at all runs in the herd"], ["", ""], ["# driving one without attaching", ""], ["moshcode herd prompt api \"run the tests\" --wait", "hand it work, block until it lands"], ["moshcode herd read api --lines 40", "read its screen"], + ["moshcode herd send-keys api C-c", "the keys themselves, when a prompt is not what is wanted"], + ["moshcode herd wait api --timeout 20m", "block here until it stops working"], + ["moshcode herd kill api", "end it · --all ends the herd"], + ["", ""], + ["# what it is costing", ""], + ["moshcode herd cost", "per session, read out of the engines' own logs"], + ["moshcode herd cost api --since 6h", "one member, this afternoon"], + ["moshcode herd cost --watch 30", "re-read every 30s while a swarm runs"], ["", ""], ["# what happened while you slept", ""], ["moshcode herd hooks install claude", "state from the engine, not from its screen"], + ["moshcode herd doctor", "which engines report, which are still being guessed at"], ["moshcode herd tasks api", "every prompt, and how long each one waited on you"], + ["moshcode herd log api --since 12h", "the same run, as a transcript"], + ["moshcode herd stats", "how much of the night was spent blocked on you"], + ["moshcode herd watch --notify", "tell me when one of them stops and wants something"], + ["", ""], + ["# a swarm is a herd with a plan, and the same verbs reach into it", ""], + ["moshcode swarm \"port the parser to rust\" --agents 4 --herd port", "fan out"], + ["moshcode herd ps --json | jq -r '.[] | select(.herd == \"port\") | .name'", "its members"], + ["moshcode herd read port-2 --lines 60", "look inside one of them, without attaching"], + ["moshcode fleet tree", "the same run as a fleet, with the ledger under it"], + ["", ""], + ["# agents that are not on this machine", ""], ["moshcode herd remote add research https://agents.do-ai.run/…/production", "a deployed agent, same verbs"], + ["moshcode herd prompt research \"summarise the RFC\"", "which is the point: one surface, wherever it runs"], + ["moshcode herd serve --port 7717", "and this box answering the same verbs for someone else"], + ["", ""], + ["# after a reboot", ""], + ["moshcode herd restore", "start again everything the manifest remembers"], + ["moshcode herd prune", "forget the ones that are never coming back"], + ["moshcode herd wait --all", "then block until the herd is quiet"], ], seeAlso: ["ps", "attach", "wait", "restore", "start"], note: "`start` is for the engines moshcode installs; `run` and `shell` take anything else, " diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs index 353d3c47..019a6df0 100644 --- a/src/herd-cli.mjs +++ b/src/herd-cli.mjs @@ -1788,8 +1788,46 @@ const VERBS = { remote: herdRemote, serve: herdServe, eval: herdEval, }; +/** + * Can a full-screen UI safely take this terminal? + * + * Three questions, and all three have to be yes. `--json` and a named verb are + * handled by the caller; this is only about the surface the answer goes to. + * + * `write` is the interesting one. herdCommand takes an injected writer, and + * everything that injects one is a place where output is CAPTURED rather than + * shown: the mosh bar renders herd output into a one-row pane, tests collect it + * into an array, and a caller collecting lines cannot be handed a program that + * paints the whole screen and waits for a click. So a writer that is not the + * default is treated exactly like a pipe. + */ +export function canOpenUi({ write = console.log, stdout = process.stdout } = {}) { + return write === console.log && Boolean(stdout.isTTY) && Boolean(process.stdin.isTTY); +} + +/** The verbs that hand this terminal to something full screen. */ +export const TERMINAL_VERBS = new Set(["ui", "sidebar", "bar", "tile", "attach"]); + +/** + * Does this argv take the terminal? The pit asks before dispatching, because a + * readline interface still holding stdin fights tmux for every keystroke, the + * same reason `/attach` and `/ssh shell` close it first. Bare `herd` is on this + * list exactly when it would open the workspace, which is the same question + * canOpenUi answers. + */ +export function takesTerminal(argv = [], options = {}) { + const [verb] = argv; + if (!verb) return canOpenUi(options); + return TERMINAL_VERBS.has(verb); +} + export async function herdCommand(argv = [], { write = console.log } = {}) { const [verb, ...rest] = argv; + // Bare `moshcode herd` opens the workspace. Someone typing the noun with no + // verb wants to SEE the herd, and the roster is the answer to that question + // only for something that cannot show them a herd: a pipe, a CI log, the + // bar. `moshcode ps` is still the roster, and still one word. + if (!verb && canOpenUi({ write })) return VERBS.ui([], { write }); if (!verb || verb === "--json") return herdPs(argv, { write }); const run = VERBS[verb]; if (!run) { diff --git a/src/herd-ui.mjs b/src/herd-ui.mjs index 1e706ebd..2193e643 100644 --- a/src/herd-ui.mjs +++ b/src/herd-ui.mjs @@ -32,7 +32,10 @@ const ESC = { // 1000 = report button press/release, 1006 = SGR encoding, which is the only // one that survives past column 95 — the older scheme packs coordinates into // single bytes and simply cannot express a click on a wide terminal. - mouseOn: "\x1b[?1000h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1000l", + // 1003 as well, so the pointer's position is reported while no button is + // held. Without it there is no hover, and with no hover a click has to be + // spent moving the highlight before a second one can open anything. + mouseOn: "\x1b[?1000h\x1b[?1003h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1003l\x1b[?1000l", clear: "\x1b[2J\x1b[H", }; @@ -125,7 +128,7 @@ export function render(rows, { selected = 0, width = 80, substrate = "tmux" } = // ever go in. The old single crammed line never mentioned it at all, so // clicking into a session was a one-way door as far as the screen was // concerned. - out.push(` ${ash("move")} ${dim("click · ↑↓ · wheel")} ${ash("open")} ${dim("enter or double-click")}`); + out.push(` ${ash("move")} ${dim("hover · ↑↓ · wheel")} ${ash("open")} ${dim("one click, or enter")}`); out.push(` ${ash("back")} ${dim("Ctrl-b d from inside")} ${ash("also")} ${dim("t tile all · r refresh · q quit")}`); if (substrate !== "tmux") out.push(` ${dim(substrateNote(substrate) || "")}`); return out.join("\r\n"); @@ -151,12 +154,29 @@ export function parseMouse(sequence) { const b = Number(button); if (b === 64) return { kind: "wheel", direction: -1 }; if (b === 65) return { kind: "wheel", direction: 1 }; + // Bit 5 (32) means "this is motion, not a press". Only a program that asked + // for 1002 or 1003 ever sees these, so decoding them here costs the list + // nothing and is the whole of what a hover highlight needs: the sidebar turns + // motion reporting on and lights the row the pointer is over. + if (b & 32) return { kind: "move", col: Number(col), row: Number(row) }; if (b !== 0) return null; return { kind: "click", col: Number(col), row: Number(row) }; } -/** Every mouse report in a chunk, so a fast click-drag cannot desync the parser. */ -export function parseInput(buffer) { +/** The keys the list reads. A surface with different shortcuts passes its own. */ +export const LIST_KEYS = ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "t", "j", "k"]; + +/** + * Every mouse report in a chunk, so a fast click-drag cannot desync the parser. + * + * `keys` is a parameter rather than the constant it used to be because the + * sidebar advertises shortcuts the list has never had (`s`, `a`, `x`), and a + * shared hard-coded list meant those rows printed a key that did nothing: the + * sidebar's own handler was reached for a letter this function never emitted. + * Passing the set in keeps one parser without giving one surface the other's + * bindings. + */ +export function parseInput(buffer, { keys = LIST_KEYS } = {}) { const events = []; const text = String(buffer); const mouse = /\x1b\[<\d+;\d+;\d+[Mm]/g; @@ -166,7 +186,7 @@ export function parseInput(buffer) { if (parsed) events.push(parsed); } if (events.length) return events; - for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "t", "j", "k"]) { + for (const key of keys) { if (text.includes(key)) events.push({ kind: "key", key }); } return events; @@ -285,18 +305,28 @@ export async function herdUi({ continue; } else if (event.kind === "wheel") selected = moveSelection(rows, selected, event.direction); + else if (event.kind === "move") { + // Hover. This is what pointing at a row to read it looks like, and it + // is the reason a click no longer has to be spent on selecting: the + // highlight follows the pointer for free. + const over = rows.find((r) => r.kind === "session" && r.line === event.row); + if (!over) continue; + const index = rows.indexOf(over); + if (index === selected) continue; + selected = index; + draw(); + continue; + } else if (event.kind === "click") { const hit = rows.find((r) => r.kind === "session" && r.line === event.row); if (!hit) continue; - const index = rows.indexOf(hit); - // A single click SELECTS; only a second click on the row already - // selected opens it. Opening on first click made one stray click a - // one-way trip into a session, which is most of why this felt bad to - // navigate — you could not point at a row to read it. - const opening = index === selected; - selected = index; + // ONE click opens it. This used to select on the first click and open + // on a second click of the same row, which is the double-click + // affordance Anthony rejected outright in diskpush 0.7.0. Hover above + // does the browsing that argument was really about. + selected = rows.indexOf(hit); draw(); - if (opening) await openSelected(); + await openSelected(); continue; } else if (event.kind === "key") { await openSelected(); continue; } diff --git a/src/herd-workspace.mjs b/src/herd-workspace.mjs index 349f2abb..0efc8a63 100644 --- a/src/herd-workspace.mjs +++ b/src/herd-workspace.mjs @@ -18,11 +18,13 @@ // and `herdSidebar` is what runs *inside* the left pane doing the swapping. import { spawn, spawnSync } from "node:child_process"; -import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs"; +import { + HERD_SOCKET, detectSubstrate, paneIndex, readManifest, slugifyName, tmux, tmuxCanPinTitle, validName, +} from "./herd.mjs"; import { roster } from "./herd-cli.mjs"; import { groupByHerd, parseInput } from "./herd-ui.mjs"; import { BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, barCommand, bindJumpKey, ensureBar, paneRoles } from "./herd-bar.mjs"; -import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs"; +import { acid, amber, ash, bone, danger, dim, err, info, reverse } from "./ui.mjs"; export const WORKSPACE = "herd"; export const WINDOW = "ui"; @@ -67,6 +69,9 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn, tmux(["select-pane", "-t", `${TARGET}.0`, "-T", SIDEBAR_TITLE], { runner }); buildBar({ runner }); } + // Outside the `if`, deliberately: a workspace built by an older moshcode is + // still sitting in the tmux server and would otherwise never get this. + pinTitles(TARGET, { runner }); return new Promise((resolve) => { let child; @@ -91,6 +96,34 @@ export function buildBar({ runner = spawnSync, command = barCommand() } = {}) { return paneId; } +/* ------------------------------------------------------------ pane identity */ + +/** + * Stop a member renaming itself out of its own identity once it is in here. + * + * Every member is addressed by its PANE TITLE. paneIndex keys on it, the + * border prints it, and parkPane uses it as the session name to park under. + * startSession pins the title with `allow-set-title off`, but that option is + * per WINDOW, and the whole trick this file is built on is moving a pane out of + * that window into this one. The pin does not travel with the pane. + * + * So the first thing claude or a login shell did after being joined in was emit + * OSC 2 and rename its own pane to something like `anthony@dev:~/src/moshcode`. + * From that moment the member was invisible to paneIndex, and the next click + * tried to park it under a "session name" containing `:` and `.`, which tmux + * reads as target separators and refuses. The pane could not leave, the new one + * arrived anyway, and the window ended up with two content panes and a squeezed + * sidebar. That is what "it crashed when I clicked an agent" looks like. + * + * tmux gained `allow-set-title` in 3.5, so this is a no-op on 3.4 (which is + * what Ubuntu 24.04 ships); parkPane below covers that case instead of relying + * on it. + */ +export function pinTitles(target = TARGET, { runner = spawnSync } = {}) { + if (!tmuxCanPinTitle({ runner })) return false; + return tmux(["set-option", "-w", "-t", target, "allow-set-title", "off"], { runner }).ok; +} + /* ------------------------------------------------------------- the swapping */ /** @@ -121,13 +154,23 @@ export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = * born with. */ export function parkPane(paneId, name, { runner = spawnSync } = {}) { - if (!name) return false; - const made = tmux(["new-session", "-d", "-s", name, "-n", name], { runner }); + if (!paneId) return false; + // A pane whose title is not a legal session name still has to be able to + // leave. On tmux 3.4 there is no `allow-set-title`, so a member CAN rename + // itself in here, and `new-session -s "anthony@dev:~/src"` fails outright: + // tmux reads `:` and `.` as target separators. Before this, that failure left + // the pane sitting in the workspace while the next one joined in beside it: + // two content panes and a sidebar squeezed to nothing. Parking it under a + // slug is strictly better than leaving it wedged: the pane keeps its title, + // so paneIndex and the roster still find the member by the only name they + // ever knew it by. + const session = validName(name) ? name : slugifyName(name || "parked"); + const made = tmux(["new-session", "-d", "-s", session, "-n", session], { runner }); if (!made.ok && !/duplicate session/i.test(made.stderr || "")) return false; const placeholder = made.ok - ? tmux(["list-panes", "-t", name, "-F", "#{pane_id}"], { runner }).stdout.trim().split("\n")[0] + ? tmux(["list-panes", "-t", session, "-F", "#{pane_id}"], { runner }).stdout.trim().split("\n")[0] : null; - const joined = tmux(["join-pane", "-s", paneId, "-t", `${name}:${name}`], { runner }); + const joined = tmux(["join-pane", "-s", paneId, "-t", `${session}:${session}`], { runner }); if (!joined.ok) return false; if (placeholder) tmux(["kill-pane", "-t", placeholder], { runner }); return true; @@ -196,13 +239,23 @@ export function sidebarRows(sessions) { // The two keys that stop the workspace being a one-way trip, on screen at all // times. Everything else here is discoverable by looking; these are not. rows.push({ kind: "gap" }); - rows.push({ kind: "hint", text: "enter ▸ type in it" }); + rows.push({ kind: "hint", text: "click or ↵ ▸ open" }); rows.push({ kind: "hint", text: `${BAR_KEY} ▸ mosh bar` }); return rows.map((row, i) => ({ ...row, line: i + 1 })); } -export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH } = {}) { +/** + * One frame. + * + * `hovered` is a LINE number rather than a name because the pointer is over a + * position on the screen, not over a member: there is nothing else it could + * mean, and looking the row up by line is the same lookup a click does, so the + * highlight and the click can never disagree about which row is under the + * pointer. + */ +export function renderSidebar(rows, { selected, showing, hovered = null, error = "", width = SIDEBAR_WIDTH } = {}) { const out = []; + const lit = (line, text) => (line === hovered ? reverse(text) : text); for (const row of rows) { if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; } if (row.kind === "gap") { out.push(""); continue; } @@ -212,55 +265,129 @@ export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH } if (row.kind === "session") { const s = row.session; const here = s.name === showing ? acid("▸") : " "; - const label = s.name.slice(0, width - 7); + // PRD 0019 gave every state a confidence. A state a regex guessed off a + // screen scrape and a state the run itself reported must not look + // identical here, or the sidebar quietly re-tells the confident lie the + // heartbeat exists to stop. Same mark the roster uses, for the same + // reason: one convention, learned once. + // Not on `unknown`, which already prints "?" as its state: "??" is two + // marks for one fact, and a state nobody can name is self-evidently not + // one anything reported. + const guess = s.confidence === "inferred" && s.state !== "unknown" ? dim("?") : " "; + const label = s.name.slice(0, width - 8); const text = s.name === selected ? bone(label) : ash(label); - out.push(`${here} ${paintState(s.state, MARK[s.state] || "?")} ${text}`); + out.push(lit(row.line, `${here} ${paintState(s.state, MARK[s.state] || "?")}${guess} ${text}`)); continue; } - const isSel = row.action.key === selected; - out.push(` ${isSel ? bone(row.action.label) : ash(row.action.label)}`); + out.push(lit(row.line, ` ${ash(row.action.label)}`)); } + if (error) out.push("", ` ${danger(String(error).slice(0, width - 2))}`); return out.join("\r\n"); } + /* -------------------------------------------------------- the sidebar itself */ +/** The keys the sidebar reads, which are not the keys the plain list reads. */ +export const SIDEBAR_KEYS = [ + "\x1b[A", "\x1b[B", "\r", "\n", "\x03", "j", "k", + ...ACTIONS.map((a) => a.key), +]; + /** * Runs inside the left pane. Draws the list, and turns a click into a swap. * * It does not take the alternate screen: it *is* a pane, and the pane is the * screen. Mouse reporting is enabled for this program specifically, which tmux * forwards rather than consuming once an application asks for it. + * + * WHY THE WHOLE BODY IS INSIDE A GUARD. Every interesting thing this does is a + * spawnSync out to tmux, and a click runs half a dozen of them. The input + * handler is async, so before this a throw from any one of them became an + * unhandled promise rejection, which Node treats as fatal. The process died + * mid-click, and because the only thing that put the terminal back was a + * write of escape sequences, RAW MODE was never lifted: the pane was left with + * no echo, no cursor and the mouse still captured by a program that was gone. + * A tmux call failing is ordinary (a pane dies between two refreshes and every + * `-t` naming it starts returning "can't find pane"); it must cost you a line + * of red in the sidebar, never the sidebar. */ export async function herdSidebar({ stdin = process.stdin, stdout = process.stdout, read = roster, refreshMs = 2000, runner = spawnSync, } = {}) { const me = process.env.TMUX_PANE; - let sessions = read(); - let rows = sidebarRows(sessions); - let selected = sessions[0]?.name || ACTIONS[0].key; + let sessions = []; + let rows = []; + let selected = null; let showing = null; + let hovered = null; + let error = ""; + + // Nothing above the guard, so a throw out of the very first roster read is + // handled the same way as one out of the hundredth click. + const say = (thrown) => { error = String(thrown?.message || thrown || "").split("\n")[0].slice(0, 60); }; const draw = () => { - stdout.write("\x1b[2J\x1b[H" + renderSidebar(rows, { selected, showing })); + try { stdout.write("\x1b[2J\x1b[H" + renderSidebar(rows, { selected, showing, hovered, error })); } + catch { /* the pane went away mid-frame; the exit path still runs */ } }; - const refresh = () => { + const reload = () => { sessions = read(); rows = sidebarRows(sessions); - const current = contentPane({ runner, me }); - showing = current?.title || null; + if (!selected || !sessions.some((s) => s.name === selected)) selected = sessions[0]?.name || null; + }; + const refresh = () => { + try { + reload(); + showing = contentPane({ runner, me })?.title || null; + error = ""; + } catch (thrown) { + // A timer callback is the other way a throw here kills the process: it is + // not inside the awaited promise at all, so no catch downstream can see + // it. This one has to hold. + say(thrown); + } draw(); }; + // Members joined into this window have to keep their names (see pinTitles). + try { pinTitles(TARGET, { runner }); reload(); } catch (thrown) { say(thrown); } + // Open on something rather than an empty right-hand side. const first = sessions.find((s) => s.alive); - if (first) { showMember(first.name, { runner, me }); showing = first.name; } + if (first) { + try { if (showMember(first.name, { runner, me })) showing = first.name; } + catch (thrown) { say(thrown); } + } - stdout.write("\x1b[?1000h\x1b[?1006h\x1b[?25l"); + // 1003 as well as 1000: 1000 reports presses only, and a hover highlight + // needs motion. It is a 26-column pane, so the traffic this adds is a few + // bytes per pointer move and the redraw is skipped unless the row changed. + stdout.write("\x1b[?1000h\x1b[?1003h\x1b[?1006h\x1b[?25l"); + const wasRaw = Boolean(stdin.isRaw); try { stdin.setRawMode?.(true); } catch { /* not a tty */ } stdin.resume(); - const restore = () => stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h"); + + // ONE restore, idempotent, and it puts back everything that was changed + // rather than only the escape sequences. The old one left raw mode on, which + // is the half that makes a crash here destructive: escape sequences are + // undone by the next full-screen program to run, a terminal with no echo is + // not. + let restored = false; + const restore = () => { + if (restored) return; + restored = true; + try { stdout.write("\x1b[?1006l\x1b[?1003l\x1b[?1000l\x1b[?25h"); } catch { /* gone */ } + try { stdin.setRawMode?.(wasRaw); } catch { /* gone */ } + try { stdin.pause(); } catch { /* gone */ } + }; + const onSignal = () => { restore(); process.exit(130); }; + // `exit` covers a clean return and an uncaught throw; the signals cover the + // ways a pane is torn down from outside, which do not run exit handlers. process.on("exit", restore); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + process.on("SIGHUP", onSignal); draw(); const timer = setInterval(refresh, refreshMs); @@ -275,12 +402,12 @@ export async function herdSidebar({ } if (what === "stop") { const target = sessions.find((s) => s.name === selected); - if (!target) return; + if (!target) { error = "nothing selected to stop"; draw(); return; } const { killSession } = await import("./herd.mjs"); - killSession(target.name); + killSession(target.name, { runner }); refresh(); const next = read().find((s) => s.alive); - if (next) { showMember(next.name, { runner, me }); } + if (next) showMember(next.name, { runner, me }); refresh(); return; } @@ -292,52 +419,81 @@ export async function herdSidebar({ if (what === "shell") herdShell([], { write: capture }); else herdStart(["claude", "--agent"], { write: capture }); refresh(); - if (created) { showMember(created, { runner, me }); refresh(); } + if (created) { selected = created; showMember(created, { runner, me }); refresh(); } + }; + + /** Show a member and hand it the keyboard. The whole of what "open" means. */ + const open = (name) => { + selected = name; + if (!showMember(name, { runner, me })) { error = `could not open ${name}`; draw(); return; } + showing = name; + error = ""; + draw(); + focusContent({ runner, me }); }; await new Promise((resolve) => { - stdin.on("data", async (buf) => { - for (const event of parseInput(buf)) { - if (event.kind === "click") { - const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action")); - if (!hit) continue; - if (hit.kind === "session") { - // First click browses. Clicking the one already on screen hands it - // the keyboard — the same second-click-opens idiom as the list, and - // the only way to reach an agent without using the mouse on it. - const already = hit.session.name === showing; - selected = hit.session.name; - if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; } - draw(); - if (already) focusContent({ runner, me }); - } else { - selected = hit.action.key; - draw(); - await act(hit.action.run); - } - continue; - } - if (event.kind !== "key") continue; - const action = ACTIONS.find((a) => a.key === event.key); - if (action) { await act(action.run); if (action.run === "detach") { resolve(); return; } continue; } - if (event.key === "\x03") { resolve(); return; } - const names = sessions.filter((s) => s.alive).map((s) => s.name); - const at = names.indexOf(selected); - if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected; - if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected; - if (event.key === "\r" || event.key === "\n") { - showMember(selected, { runner, me }); - showing = selected; - draw(); - focusContent({ runner, me }); - continue; + const handle = async (event) => { + if (event.kind === "move") { + // Redraw only when the row under the pointer actually changes, or a + // pointer dragged across the pane would repaint the sidebar per cell. + const over = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action")); + const line = over ? over.line : null; + if (line !== hovered) { hovered = line; draw(); } + return false; + } + if (event.kind === "click") { + const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action")); + if (!hit) return false; + if (hit.kind === "session") { + // ONE click opens it. The old behaviour was "first click browses, + // clicking the one already on screen opens it", which is the + // double-click affordance Anthony rejected in diskpush 0.7.0 ("i had + // to double click that was odd"). A pointer that has to be told twice + // is not a pointer. Browsing without opening is what hover is for + // now, and it costs no click at all. + if (!hit.session.alive) { error = `${hit.session.name} is not running`; selected = hit.session.name; draw(); return false; } + open(hit.session.name); + return false; } - draw(); + // Actions do NOT move the member selection. They used to, which is why + // clicking "stop" could never stop anything: it set `selected` to the + // action's key and then looked for a member by that name. + await act(hit.action.run); + return hit.action.run === "detach"; } + if (event.kind !== "key") return false; + const action = ACTIONS.find((a) => a.key === event.key); + if (action) { await act(action.run); return action.run === "detach"; } + if (event.key === "\x03") return true; + const names = sessions.filter((s) => s.alive).map((s) => s.name); + const at = names.indexOf(selected); + if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected; + if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected; + // The keyboard path to the same thing a click does, kept because reaching + // for the mouse to get into an agent is not always possible over ssh. + if ((event.key === "\r" || event.key === "\n") && selected) { open(selected); return false; } + draw(); + return false; + }; + + stdin.on("data", (buf) => { + // The catch IS the fix. See the note on this function: without it a throw + // out of any tmux call below is an unhandled rejection and the process is + // gone, terminal and all. + (async () => { + for (const event of parseInput(buf, { keys: SIDEBAR_KEYS })) { + if (await handle(event)) { resolve(); return; } + } + })().catch((thrown) => { say(thrown); draw(); }); }); }); clearInterval(timer); restore(); + process.off("exit", restore); + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + process.off("SIGHUP", onSignal); return 0; } diff --git a/src/herd.mjs b/src/herd.mjs index 764aa92d..1d866d69 100644 --- a/src/herd.mjs +++ b/src/herd.mjs @@ -600,8 +600,14 @@ export function paneIndex({ runner = spawnSync } = {}) { for (const line of r.stdout.split("\n")) { if (!line.trim()) continue; const [title, paneId, session, windowId, dead] = line.split("\t"); - if (!title) continue; - index.set(title, { paneId, session, windowId, dead: dead.trim() === "1" }); + if (!title || !paneId) continue; + // `dead` is read defensively rather than as `dead.trim()`, and the reason is + // worth the line: every caller of this runs it inside an async handler with + // no try/catch, so one short line out of tmux does not print a warning, it + // throws a TypeError that becomes an unhandled promise rejection and takes + // the whole process down. A format string that loses a field is a cosmetic + // problem; it must never be a fatal one. + index.set(title, { paneId, session, windowId, dead: String(dead ?? "").trim() === "1" }); } return index; } diff --git a/src/tui.mjs b/src/tui.mjs index 21856de3..fa3e39df 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -39,7 +39,7 @@ import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs"; import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs"; import { openNewTab } from "./tabs.mjs"; import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, mergeAliases, removeAlias, setAlias } from "./aliases.mjs"; -import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs"; +import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs, takesTerminal } from "./herd-cli.mjs"; import { detectSubstrate, substrateNote } from "./herd.mjs"; const PROMPT = () => acid("mosh ") + dim("▸ "); @@ -1134,10 +1134,21 @@ export async function tui() { rl = mkrl(); continue; } - // The herd (PRD 0009). These never close the readline interface, because - // that is the entire point of them: the pit keeps its prompt while the - // sessions run somewhere that outlives it. - if (cmd === "herd") { await herdCommand(rest); continue; } + // The herd (PRD 0009). Most of these never close the readline interface, + // because that is the entire point of them: the pit keeps its prompt while + // the sessions run somewhere that outlives it. + // + // The exceptions are the verbs that paint the whole screen, and bare + // `/herd`, which is now one of them. Those hand the terminal to tmux, so + // readline has to let go of stdin first or the two fight over every + // keystroke, exactly as with `/attach` below. + if (cmd === "herd") { + if (!takesTerminal(rest)) { await herdCommand(rest); continue; } + rl.close(); + await herdCommand(rest); + rl = mkrl(); + continue; + } if (cmd === "ps") { await herdCommand(["ps", ...rest]); continue; } if (cmd === "swarm") { const { swarmCommand } = await import("./swarm.mjs"); diff --git a/src/ui.mjs b/src/ui.mjs index 7d846fda..5c8f0c17 100644 --- a/src/ui.mjs +++ b/src/ui.mjs @@ -24,6 +24,17 @@ export const danger = rgb(255, 77, 61); export const amber = rgb(255, 213, 61); export const spotify = rgb(29, 185, 84); export const dim = wrap(2, 22); +// The row under the pointer. Reverse video rather than another hue, because a +// hover has to read as "this one" against a list where colour already means +// something else (a member's state). +// +// Not gated on useColor, unlike everything above it, and the reason is the +// point of the attribute: reverse video is not a colour, it is the only channel +// a full-screen pane has for saying where the pointer is. Honouring NO_COLOR +// here would not make the output plainer, it would delete the feedback. The +// only caller is a program that is already writing cursor and mouse escapes to +// a real terminal, so there is no pipe for this to leak into. +export const reverse = (s) => `\x1b[7m${s}\x1b[27m`; export const ok = (s) => acid("✓ ") + s; export const err = (s) => danger("✗ ") + s; diff --git a/test/herd-default-verb.test.mjs b/test/herd-default-verb.test.mjs new file mode 100644 index 00000000..8752418d --- /dev/null +++ b/test/herd-default-verb.test.mjs @@ -0,0 +1,52 @@ +// Bare `moshcode herd` opens the workspace. +// +// The load-bearing part is not the new default, it is the three guards on it. +// A full-screen UI launched into a pipe, a CI log or a captured writer hangs +// forever on input that is never coming, so every one of those has to keep the +// roster it has always had. +import test from "node:test"; +import assert from "node:assert/strict"; + +import { canOpenUi, takesTerminal } from "../src/herd-cli.mjs"; + +test("a captured writer never gets a full-screen UI", () => { + // herdCommand's `write` is injected by the mosh bar (a one-row pane), by the + // hooks that render herd output into another surface, and by tests. Every one + // of those collects lines; none of them can be handed a program that paints + // the screen and waits for a click. + const lines = []; + assert.equal(canOpenUi({ write: (l) => lines.push(l), stdout: { isTTY: true } }), false); +}); + +test("a pipe gets the roster, not the workspace", () => { + assert.equal(canOpenUi({ stdout: { isTTY: false } }), false); + assert.equal(canOpenUi({ stdout: {} }), false); +}); + +test("a real terminal with the default writer opens the workspace", () => { + // stdin has to be a tty as well: `moshcode herd < /dev/null` on a terminal + // would otherwise open a UI whose input stream is already at EOF. + const real = process.stdout.isTTY && process.stdin.isTTY; + assert.equal(canOpenUi({ stdout: { isTTY: true } }), Boolean(real)); +}); + +test("--json is never the workspace, however good the terminal is", async () => { + const { herdCommand } = await import("../src/herd-cli.mjs"); + const lines = []; + const code = await herdCommand(["--json"], { write: (l) => lines.push(l) }); + assert.equal(code, 0); + assert.doesNotThrow(() => JSON.parse(lines.join("\n")), "--json stopped being json"); +}); + +test("the pit is told which herd verbs take the terminal", () => { + // The pit's readline holds stdin, and tmux and readline cannot both have it. + // This is the list `/herd` checks before deciding whether to close it. + for (const verb of ["ui", "sidebar", "bar", "tile", "attach"]) { + assert.equal(takesTerminal([verb]), true, `${verb} paints the screen`); + } + for (const verb of ["ps", "cost", "prompt", "read", "tasks", "wait"]) { + assert.equal(takesTerminal([verb]), false, `${verb} answers in place`); + } + // Bare `herd` takes the terminal exactly when it would open the workspace. + assert.equal(takesTerminal([], { stdout: { isTTY: false } }), false); +}); diff --git a/test/herd-sidebar-click.test.mjs b/test/herd-sidebar-click.test.mjs new file mode 100644 index 00000000..f420d543 --- /dev/null +++ b/test/herd-sidebar-click.test.mjs @@ -0,0 +1,259 @@ +// The sidebar's input handler: the crash it used to be, and the one click it +// now takes to open an agent. +// +// These drive `herdSidebar` directly with a fake stdin and a stand-in for tmux, +// because the thing under test is not what the screen looks like, it is what +// happens when a shell-out fails halfway through a click. That was the whole +// bug: the handler is async, so one throw out of tmux became an unhandled +// promise rejection and Node ended the process, leaving the pane in raw mode +// with the mouse still captured. +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Every herd module reads these, and the heartbeat classifier reads +// OPENFLEET_HOME as well, so both are pointed at a scratch directory before the +// modules under test are imported. A test that stops a member would otherwise +// edit the manifest of whatever is actually running on this box. +const SCRATCH = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-sidebar-test-")); +process.env.MOSHCODE_HERD_DIR = path.join(SCRATCH, "herd"); +process.env.OPENFLEET_HOME = path.join(SCRATCH, "fleet"); + +const { ACTIONS, SIDEBAR_KEYS, herdSidebar, parkPane, renderSidebar, sidebarRows } = + await import("../src/herd-workspace.mjs"); +const { parseInput, parseMouse } = await import("../src/herd-ui.mjs"); + +const member = (name, extra = {}) => ({ + name, engine: "claude", herd: "main", state: "idle", cwd: "/x", alive: true, confidence: "known", ...extra, +}); +const strip = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, ""); +const click = (line) => Buffer.from(`\x1b[<0;3;${line}M`); +const move = (line) => Buffer.from(`\x1b[<35;3;${line}M`); +const settle = () => new Promise((r) => setTimeout(r, 30)); + +/** tmux answering the two list-panes shapes the sidebar actually reads. */ +const panes = (args) => { + if (args.includes("-a")) return { status: 0, stdout: "api\t%1\tapi\t@1\t0\nweb\t%2\tweb\t@2\t0\n", stderr: "" }; + if (args[2] === "list-panes") return { status: 0, stdout: "%9\tapi\n", stderr: "" }; + return { status: 0, stdout: "", stderr: "" }; +}; + +/** + * A sidebar wired to fakes, plus the tmux argv it produced. + * + * `answer` gets the tmux argv and returns a spawnSync-shaped result, so a test + * can make one specific call fail without stubbing the module graph. + */ +function drive({ sessions = [member("api"), member("web")], answer = panes } = {}) { + const calls = []; + const frames = []; + const stdin = new EventEmitter(); + stdin.setRawMode = (on) => { stdin.rawSetTo = on; }; + stdin.resume = () => {}; + stdin.pause = () => { stdin.paused = true; }; + const stdout = { write: (s) => { frames.push(String(s)); return true; } }; + const runner = (cmd, args) => { calls.push(args); return answer(args, calls); }; + const done = herdSidebar({ stdin, stdout, read: () => sessions, refreshMs: 1_000_000, runner }); + return { calls, frames, stdin, stdout, done, rows: sidebarRows(sessions) }; +} + +const quit = async (d) => { d.stdin.emit("data", Buffer.from("\x03")); await d.done; }; +const rowFor = (d, name) => d.rows.find((r) => r.kind === "session" && r.session.name === name); + +/* --------------------------------------------------------------- the crash */ + +test("a click that throws inside tmux does not end the process", async () => { + // The exact shape of the original crash: something under the click throws, + // the handler is async, and the rejection is nobody's. The promise the + // sidebar hands back must still be pending afterwards, not rejected. + const d = drive({ answer: () => { throw new TypeError("Cannot read properties of undefined (reading 'trim')"); } }); + let rejected = null; + d.done.catch((e) => { rejected = e; }); + + d.stdin.emit("data", click(rowFor(d, "api").line)); + await settle(); + + assert.equal(rejected, null, "the throw must not escape as an unhandled rejection"); + await quit(d); +}); + +test("the reason a click failed is shown rather than swallowed", async () => { + const d = drive({ answer: () => { throw new Error("no current client"); } }); + d.stdin.emit("data", click(rowFor(d, "api").line)); + await settle(); + const last = strip(d.frames[d.frames.length - 1]); + assert.match(last, /no current client|could not open/, `the sidebar said nothing: ${JSON.stringify(last)}`); + await quit(d); +}); + +test("the terminal is put back the way it was found, raw mode included", async () => { + // The destructive half of the old crash. Escape sequences are undone by the + // next full-screen program to run; a terminal left with no echo is not. + const d = drive(); + await settle(); + assert.equal(d.stdin.rawSetTo, true, "the sidebar takes raw mode while it runs"); + await quit(d); + assert.equal(d.stdin.rawSetTo, false, "and gives it back"); + assert.equal(d.stdin.paused, true, "and stops reading"); + const all = d.frames.join(""); + for (const off of ["\x1b[?1006l", "\x1b[?1003l", "\x1b[?1000l", "\x1b[?25h"]) { + assert.ok(all.includes(off), `${JSON.stringify(off)} was never sent`); + } +}); + +/* ---------------------------------------------------------------- one click */ + +test("one click on a member opens it: shown AND given the keyboard", async () => { + // Anthony rejected the two-click idiom outright (diskpush 0.7.0, "i had to + // double click that was odd"). A single click has to do the whole thing. + const d = drive(); + await settle(); + d.calls.length = 0; + d.stdin.emit("data", click(rowFor(d, "web").line)); + await settle(); + + assert.ok( + d.calls.some((a) => a.includes("join-pane") && a.includes("%2")), + "the clicked member's pane was never joined in", + ); + // focusContent is the second half of "open", and it is what the old code only + // did on a SECOND click of the same row. + assert.ok( + d.calls.some((a) => a[2] === "select-pane" && a.includes("%9")), + "the keyboard was never handed to the content pane", + ); + await quit(d); +}); + +test("enter opens the selected member, so the mouse is not the only way in", async () => { + const d = drive(); + await settle(); + d.calls.length = 0; + d.stdin.emit("data", Buffer.from("j")); // api is already on screen; move to web + await settle(); + d.stdin.emit("data", Buffer.from("\r")); + await settle(); + assert.ok( + d.calls.some((a) => a.includes("join-pane") && a.includes("%2")), + "enter did not open the member the keyboard had selected", + ); + await quit(d); +}); + +test("clicking a member that is not running says so instead of half-opening it", async () => { + const sessions = [member("api"), member("dead", { alive: false, state: "gone" })]; + const d = drive({ sessions }); + await settle(); + d.calls.length = 0; + d.stdin.emit("data", click(rowFor(d, "dead").line)); + await settle(); + assert.equal(d.calls.filter((a) => a.includes("join-pane")).length, 0); + assert.match(strip(d.frames[d.frames.length - 1]), /not running/); + await quit(d); +}); + +test("clicking an action does not steal the member selection", async () => { + // The bug that made "stop" unable to stop anything: clicking it set the + // selection to the action's own key, and then looked for a member by that + // name. Nothing is ever called "x", so nothing was ever stopped. + const d = drive(); + await settle(); + const stop = d.rows.find((r) => r.kind === "action" && r.action.run === "stop"); + d.calls.length = 0; + d.stdin.emit("data", click(stop.line)); + await settle(); + assert.ok(d.calls.some((a) => a.includes("kill-pane")), "stop never reached the selected member"); + await quit(d); +}); + +/* -------------------------------------------------------------------- hover */ + +test("motion reports are decoded, and light the row under the pointer", () => { + // 1003 reports motion with bit 5 of the button field set. Without this a + // hover is impossible and a click has to be spent moving the highlight, + // which is how the double-click crept in. + assert.deepEqual(parseMouse("\x1b[<35;3;7M"), { kind: "move", col: 3, row: 7 }); + assert.deepEqual(parseInput(move(7)), [{ kind: "move", col: 3, row: 7 }]); + + const rows = sidebarRows([member("api"), member("web")]); + const target = rows.find((r) => r.kind === "session" && r.session.name === "web"); + const painted = renderSidebar(rows, { selected: "api", showing: "api", hovered: target.line }); + const lines = painted.split("\r\n"); + assert.match(lines[target.line - 1], /\x1b\[7m/, "the hovered row is not lit"); + assert.doesNotMatch(lines[target.line - 2], /\x1b\[7m/, "only one row may be lit at a time"); +}); + +test("a hover over a row that is not clickable lights nothing", () => { + const rows = sidebarRows([member("api")]); + const heading = rows.find((r) => r.kind === "heading"); + assert.doesNotMatch(renderSidebar(rows, { hovered: heading.line }), /\x1b\[7m/); +}); + +test("the pointer moving over the sidebar redraws it", async () => { + const d = drive(); + await settle(); + const before = d.frames.length; + d.stdin.emit("data", move(rowFor(d, "web").line)); + await settle(); + assert.ok(d.frames.length > before, "a hover drew nothing"); + // And moving within the same row must not repaint, or dragging across the + // pane redraws it once per cell. + const after = d.frames.length; + d.stdin.emit("data", move(rowFor(d, "web").line)); + await settle(); + assert.equal(d.frames.length, after, "the same row was redrawn twice"); + await quit(d); +}); + +/* --------------------------------------------------- the sidebar's own keys */ + +test("every action's advertised key actually reaches the handler", () => { + // The sidebar prints s / a / x beside its actions, and the shared parser only + // ever emitted the LIST's keys, so three of the five did nothing at all. + for (const action of ACTIONS) { + assert.ok(SIDEBAR_KEYS.includes(action.key), `${action.key} is advertised but never read`); + assert.deepEqual( + parseInput(Buffer.from(action.key), { keys: SIDEBAR_KEYS }), + [{ kind: "key", key: action.key }], + `${action.key} is not decoded`, + ); + } +}); + +/* ----------------------------------------------------------- pane identity */ + +test("a pane whose title is not a legal session name can still be parked", () => { + // claude and a login shell both rename their own pane via OSC 2, and the + // window option that stops them does not travel with a joined pane. Before + // this, `new-session -s "anthony@dev:~/src"` failed and the pane was left + // wedged in the workspace beside the one that had just arrived. + const calls = []; + const runner = (cmd, args) => { + calls.push(args); + if (args[2] === "new-session" && /[:.]/.test(String(args[args.indexOf("-s") + 1]))) { + return { status: 1, stdout: "", stderr: "bad session name\n" }; + } + return { status: 0, stdout: "%7\n", stderr: "" }; + }; + assert.equal(parkPane("%3", "anthony@dev:~/src/moshcode", { runner }), true); + const made = calls.find((a) => a[2] === "new-session"); + assert.doesNotMatch(String(made[made.indexOf("-s") + 1]), /[:.]/, "parked under a name tmux cannot address"); +}); + +/* ------------------------------------------------------------- confidence */ + +test("a guessed state does not look like a reported one", () => { + // PRD 0019. The heartbeat exists so the roster stops stating guesses as + // facts; a sidebar that renders both identically puts the lie straight back. + const rows = sidebarRows([member("sure"), member("guess", { confidence: "inferred" })]); + const lines = strip(renderSidebar(rows, {})).split("\r\n"); + assert.match(lines.find((l) => l.includes("guess")), /\?/, "an inferred state carries no mark"); + assert.doesNotMatch( + lines.find((l) => l.includes("sure")).replace("sure", ""), + /\?/, + "a known state must not be marked as a guess", + ); +});