diff --git a/package.json b/package.json index d4556d48..cd579b90 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@moshcoder/moshpit-dns": "^0.5.0" }, "dependencies": { + "@profullstack/hqtui": "^0.7.0", "@profullstack/synconfig": "^0.1.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 675a42db..4ecd2207 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@profullstack/hqtui': + specifier: ^0.7.0 + version: 0.7.0 '@profullstack/synconfig': specifier: ^0.1.3 version: 0.1.3 @@ -23,6 +26,11 @@ packages: engines: {node: '>=20'} hasBin: true + '@profullstack/hqtui@0.7.0': + resolution: {integrity: sha512-2sE5RK+7jhIEAVOH8KdasUs8NOK8HkDen0vmijvFlEzZLwSl3Z6sQUihUmuqUUEKcu+qVi7eo4950chosbtgcA==} + engines: {bun: '>=1.1', node: '>=22.6'} + hasBin: true + '@profullstack/synconfig@0.1.3': resolution: {integrity: sha512-Ty/90ibgu2DVlablDeoFdItcobK+c8cMQFvuTfJOUlCfyvQZWoPjgt0JHPwTvq+ynOQ4i1ivBQ3K3PY/S4rObA==} engines: {node: '>=22'} @@ -31,4 +39,6 @@ snapshots: '@moshcoder/moshpit-dns@0.5.0': {} + '@profullstack/hqtui@0.7.0': {} + '@profullstack/synconfig@0.1.3': {} diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs index 019a6df0..bc545b24 100644 --- a/src/herd-cli.mjs +++ b/src/herd-cli.mjs @@ -1766,7 +1766,7 @@ const VERBS = { // `ui` is the sidebar workspace; the old modal list lives on as the fallback // inside it for machines with no tmux to swap panes on. ui: async (argv, options) => (await import("./herd-workspace.mjs")).herdUi(argv, options), - sidebar: async (argv, options) => (await import("./herd-workspace.mjs")).herdSidebar(options), + sidebar: async (argv, options) => (await import("./herd-sidebar.mjs")).herdSidebar(options), bar: async (argv, options) => (await import("./herd-bar.mjs")).herdBar(options), tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options), untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options), diff --git a/src/herd-sidebar.mjs b/src/herd-sidebar.mjs new file mode 100644 index 00000000..01fbaca7 --- /dev/null +++ b/src/herd-sidebar.mjs @@ -0,0 +1,334 @@ +// The herd sidebar, on hqtui. +// +// WHAT THIS REPLACES. The sidebar used to be hand-rolled escape sequences: a +// string of rows joined with \r\n, a hand-written SGR mouse parser, a hand-kept +// map of "which line is which row", and a restore path that had to remember +// every mode it had turned on. Three separate bugs came out of that shape and +// none of them were about the herd: +// +// - the click map and the screen were two pieces of code that had to agree, +// and when they drifted every click landed on the row below the pointer; +// - mouse motion was never decoded at all, so there was no hover, so a click +// had to be spent moving the highlight before a second one could open +// anything, which is the double-click Anthony rejected outright; +// - the restore path was escape sequences only, so a throw anywhere left the +// pane in raw mode with the mouse still captured. +// +// hqtui answers all three as properties of the library rather than as things +// this file has to keep getting right. The tree widget reports the row it drew +// each node on, so the click map IS the screen by construction. `onHoverRow` +// plus `hovered` is the hover. And the terminal is restored on SIGINT, SIGTERM +// and an uncaught error by the Terminal itself. +// +// WHAT IT DOES NOT REPLACE. The right-hand side is a real tmux pane running a +// real agent, and no renderer can substitute for that: it has a real cursor, a +// real mouse inside the agent, and its own full-screen UI. So this owns the +// LEFT pane only, and the pane swapping underneath it is still the tmux work in +// herd-workspace.mjs, untouched. +// +// A FOLDING TREE, NOT A LIST THAT GETS REPLACED. The herd is herds containing +// members, which is a tree, and Anthony's standing expectation for a pane of +// things-containing-things is that it unfolds in place on ONE click with the +// row under the pointer lit. Clicking a herd folds it; clicking a member opens +// it. Nothing is ever swapped out for a different screen. +import { spawnSync } from "node:child_process"; + +import { roster } from "./herd-cli.mjs"; +import { tmux } from "./herd.mjs"; +import { groupByHerd } from "./herd-ui.mjs"; +import { BAR_KEY } from "./herd-bar.mjs"; +import { ACTIONS, TARGET, contentPane, focusContent, pinTitles, showMember } from "./herd-workspace.mjs"; + +/** + * The moshcoding palette, as hqtui colours. + * + * The same hexes src/ui.mjs paints with. They are repeated rather than imported + * because ui.mjs exports painters (string in, escape-wrapped string out) and + * hqtui wants colour values it can put in a cell's attributes; one of the two + * has to be written twice and a hex is the smaller thing to duplicate. + */ +export const PALETTE = { + acid: "#9EF01A", + bone: "#EEF2E8", + ash: "#8B938A", + danger: "#FF4D3D", + amber: "#FFD53D", +}; + +const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" }; +const STATE_COLOR = { + blocked: PALETTE.amber, + working: PALETTE.acid, + done: PALETTE.bone, + gone: PALETTE.danger, +}; + +/** + * One member's state, as the two columns that sit to the right of its name. + * + * The trailing "?" is PRD 0019's: a state a regex guessed off a screen scrape + * must not look like one the run itself reported. Not on `unknown`, whose mark + * is already "?" and which is self-evidently nobody's report. + */ +export function stateCell(session) { + const mark = MARK[session.state] || "?"; + const guess = session.confidence === "inferred" && session.state !== "unknown" ? "?" : ""; + return { text: `${mark}${guess}`, width: 2, align: "right", color: STATE_COLOR[session.state] || PALETTE.ash }; +} + +/** + * The tree, and the flat list of what each of its rows means. + * + * The two are built in one pass and in the same order hqtui flattens an + * expanded tree in (parent, then its children, depth first), so `rows[i]` + * describes the node at flat index `i`. That correspondence is the whole click + * map: the old sidebar kept it by hand and it drifted. + */ +export function herdNodes(sessions, { collapsed = new Set(), showing = null, selected = null } = {}) { + const nodes = []; + const rows = []; + for (const group of groupByHerd(sessions)) { + const expanded = !collapsed.has(group.name); + const node = { + label: `${group.name.toUpperCase()} (${group.members.length})`, + color: PALETTE.ash, + expanded, + children: [], + }; + nodes.push(node); + rows.push({ kind: "herd", herd: group.name }); + for (const session of group.members) { + node.children.push({ + // The marker for "this is the one on screen" is part of the label + // rather than another column: at 26 columns a member has about twenty + // for its name once the tree guides have taken three, and a column + // that is blank on every row but one is not worth one of them. + label: `${session.name === showing ? "▸" : " "}${session.name}`, + color: session.name === selected ? PALETTE.bone : PALETTE.ash, + values: [stateCell(session)], + }); + rows.push({ kind: "session", herd: group.name, session }); + } + // A herd with nothing in it still has to be foldable, and a node with an + // empty `children` array is drawn as a leaf. Leaving it undefined says the + // same thing and does not lie about being expandable. + if (!node.children.length) delete node.children; + } + return { nodes, rows }; +} + +/** + * Everything the sidebar's view needs, with nothing in it that touches a + * terminal, so a test can render a frame and click on it. + * + * `hit` maps a screen row inside the tree widget to a flat index. It is filled + * in by the tree's own `onRow` callback as it draws, which is the only place + * that knows where the visible window starts, and read back by `onSelectRow`, + * which is told a row counted from the top of that window. + */ +export function sidebarView(state, handlers = {}) { + const { onOpen = () => {}, onFold = () => {}, onHover = () => {}, onAction = () => {}, onScroll = () => {} } = handlers; + return ({ ui }) => { + const { nodes, rows } = herdNodes(state.sessions, state); + const hit = new Map(); + ui.box({ padding: { left: 1, right: 1 } }, (box) => { + box.heading("herd", { size: 1 }); + box.tree({ + nodes, + guides: true, + guideColor: PALETTE.ash, + selected: rows.findIndex((r) => r.kind === "session" && r.session.name === state.selected), + hovered: state.hovered, + offset: state.offset, + followSelection: true, + scrollbar: true, + onRow: (node, index, y) => hit.set(y, index), + onScroll, + onHoverRow: (visible) => onHover(visible == null ? -1 : (hit.get(visible) ?? -1)), + onSelectRow: (visible) => { + const index = hit.get(visible); + if (index == null) return; + const row = rows[index]; + if (!row) return; + // ONE click. A herd folds in place; a member opens. Nothing here + // needs a second press to mean what it looked like it meant. + if (row.kind === "herd") onFold(row.herd); + else onOpen(row.session); + }, + }); + box.spacer(1); + box.divider({ label: "actions" }); + // The shortcut sits in a column of its own rather than two spaces after + // a label, so five rows of different lengths read as a key map instead of + // a ragged edge. + const widest = Math.max(...ACTIONS.map((a) => [...a.label].length)); + for (const action of ACTIONS) { + box.button({ + label: `${action.label.padEnd(widest + 2)}${action.key}`, + align: "left", + variant: "ghost", + onPress: () => onAction(action.run), + }); + } + if (state.error) { + box.spacer(1); + box.text(state.error, { fg: PALETTE.danger, size: 2 }); + } + }); + ui.statusBar({ + items: [{ key: "click", label: "open" }, { key: BAR_KEY, label: "bar" }], + keyStyle: "caps", + size: 1, + }); + }; +} + +/** + * Runs inside the left pane. + * + * `create` is the seam: the real one builds an hqtui App on this terminal, and + * a test passes something that renders headlessly. Everything below it is state + * and tmux calls, which is what this file is actually responsible for. + */ +export async function herdSidebar({ + read = roster, + runner = spawnSync, + refreshMs = 2000, + create = null, +} = {}) { + const me = process.env.TMUX_PANE; + const state = { + sessions: [], + collapsed: new Set(), + selected: null, + showing: null, + hovered: -1, + offset: 0, + error: "", + }; + + // Every tmux call this file makes goes through here. hqtui restores the + // terminal on an uncaught error, so a throw is no longer destructive, but it + // would still end the sidebar; a `join-pane` failing because a pane died + // between two refreshes is ordinary and must cost a line of red instead. + const guard = (what, fn) => { + try { state.error = ""; return fn(); } + catch (thrown) { state.error = `${what}: ${String(thrown?.message || thrown).split("\n")[0]}`; return null; } + }; + + const reload = () => guard("roster", () => { + state.sessions = read(); + if (!state.selected || !state.sessions.some((s) => s.name === state.selected)) { + state.selected = state.sessions[0]?.name || null; + } + state.showing = contentPane({ runner, me })?.title || null; + }); + + guard("workspace", () => pinTitles(TARGET, { runner })); + reload(); + + // Open on something rather than an empty right-hand side. Shown but NOT + // focused: the keyboard belongs to the sidebar until someone asks for the + // agent, or the workspace would start with every key going somewhere the + // user has not looked at yet. + if (!state.showing) { + const first = state.sessions.find((s) => s.alive); + if (first && guard("open", () => showMember(first.name, { runner, me }))) state.showing = first.name; + } + + const open = (session) => { + state.selected = session.name; + if (!session.alive) { state.error = `${session.name} is not running`; return; } + const shown = guard("open", () => showMember(session.name, { runner, me })); + if (!shown) { state.error = state.error || `could not open ${session.name}`; return; } + state.showing = session.name; + // Showing it and handing it the keyboard are one act, which is what makes + // this one click rather than two. + guard("focus", () => focusContent({ runner, me })); + }; + + const fold = (herd) => { + if (state.collapsed.has(herd)) state.collapsed.delete(herd); + else state.collapsed.add(herd); + }; + + const act = async (what) => { + if (what === "detach") { guard("detach", () => tmux(["detach-client"], { runner })); return true; } + if (what === "tile") { + const { herdTile } = await import("./herd-tile.mjs"); + await herdTile([], { write: () => {}, spawner: () => ({ on: (e, cb) => e === "exit" && cb(0) }) }); + reload(); + return false; + } + if (what === "stop") { + const target = state.sessions.find((s) => s.name === state.selected); + if (!target) { state.error = "nothing selected to stop"; return false; } + const { killSession } = await import("./herd.mjs"); + guard("stop", () => killSession(target.name, { runner })); + reload(); + const next = state.sessions.find((s) => s.alive); + if (next) open(next); + return false; + } + const { herdShell, herdStart } = await import("./herd-cli.mjs"); + let created = null; + const capture = (line) => { + const m = /^\S*\s*(\S+)\s+—/.exec(String(line).replace(/\x1b\[[0-9;]*m/g, "")); + if (m) created = m[1]; + }; + guard("start", () => (what === "shell" ? herdShell([], { write: capture }) : herdStart(["claude", "--agent"], { write: capture }))); + reload(); + const born = state.sessions.find((s) => s.name === created); + if (born) open(born); + return false; + }; + + const app = create + ? await create() + : await (await import("@profullstack/hqtui")).createApp({ + // `q` is the detach action, not a bare quit: leaving the sidebar should + // leave the herd running and say so, which act("detach") does. + quitKeys: ["ctrl+c"], + // A 26-column pane with one column of content. Collapsing only merges + // where two BORDERED siblings touch, so there is nothing here for it to + // merge and turning it on would only cost a repaint. + collapseBorders: false, + mouse: true, + }); + + const view = sidebarView(state, { + onOpen: (session) => { open(session); app.invalidate(); }, + onFold: (herd) => { fold(herd); app.invalidate(); }, + onHover: (index) => { + if (index === state.hovered) return; // do not repaint per cell of a drag + state.hovered = index; + app.invalidate(); + }, + onAction: (what) => { act(what).then((over) => { if (over) app.stop(); else app.invalidate(); }); }, + onScroll: (delta) => { state.offset = Math.max(0, state.offset + delta); app.invalidate(); }, + }); + app.render(view); + + app.on("key", (event) => { + const action = ACTIONS.find((a) => a.key === event.key); + if (action) { act(action.run).then((over) => { if (over) app.stop(); else app.invalidate(); }); return; } + const alive = state.sessions.filter((s) => s.alive); + const at = alive.findIndex((s) => s.name === state.selected); + if (event.key === "up" || event.key === "k") state.selected = alive[Math.max(0, at - 1)]?.name || state.selected; + else if (event.key === "down" || event.key === "j") state.selected = alive[Math.min(alive.length - 1, at + 1)]?.name || state.selected; + else if (event.key === "enter" || event.key === "space") { + const chosen = state.sessions.find((s) => s.name === state.selected); + if (chosen) open(chosen); + } else return; + app.invalidate(); + }); + + // A hover left behind when the pointer moves off the tree and onto the + // actions is deliberately not chased. The widget only hears about the pointer + // while it is inside itself, and the alternative is a second hit region over + // the whole pane whose only job is to un-light a row nobody is looking at. + // Moving back over the tree corrects it on the first cell. + const timer = setInterval(() => { reload(); app.invalidate(); }, refreshMs); + try { await app.start(); } finally { clearInterval(timer); } + return 0; +} diff --git a/src/herd-workspace.mjs b/src/herd-workspace.mjs index 0efc8a63..6a7e6ecb 100644 --- a/src/herd-workspace.mjs +++ b/src/herd-workspace.mjs @@ -15,16 +15,22 @@ // redrawing a picture of it. // // Two processes, therefore: the launcher below builds the window and attaches, -// and `herdSidebar` is what runs *inside* the left pane doing the swapping. +// and `herd sidebar` is what runs *inside* the left pane doing the swapping. +// +// This file is the tmux half and only the tmux half: which pane is which, how +// one is parked and another joined in, and how a member is stopped from +// renaming itself out of its own identity while it is here. What the left pane +// LOOKS like lives in src/herd-sidebar.mjs, which is an hqtui app. The split is +// the point of that port: the drawing, the mouse decoding, the hover and the +// terminal restore are all somebody else's solved problems, and none of them +// were ever about herds. import { spawn, spawnSync } from "node:child_process"; import { - HERD_SOCKET, detectSubstrate, paneIndex, readManifest, slugifyName, tmux, tmuxCanPinTitle, validName, + HERD_SOCKET, detectSubstrate, paneIndex, 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, reverse } from "./ui.mjs"; +import { BAR_TITLE, SIDEBAR_TITLE, barCommand, bindJumpKey, ensureBar, paneRoles } from "./herd-bar.mjs"; +import { acid, err, info } from "./ui.mjs"; export const WORKSPACE = "herd"; export const WINDOW = "ui"; @@ -213,287 +219,3 @@ export function focusContent({ runner = spawnSync, me = process.env.TMUX_PANE } return true; } -/* --------------------------------------------------------------- the render */ - -const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" }; -const paintState = (state, text) => - state === "blocked" ? amber(text) - : state === "working" ? acid(text) - : state === "done" ? bone(text) - : state === "gone" ? danger(text) - : ash(text); - -/** - * The sidebar's rows, and the line each one sits on — one list so a click and - * the highlight cannot disagree (the bug that made the first list send every - * click to the row below the pointer). - */ -export function sidebarRows(sessions) { - const rows = [{ kind: "title" }, { kind: "gap" }]; - for (const group of groupByHerd(sessions)) { - rows.push({ kind: "herd", herd: group.name }); - for (const session of group.members) rows.push({ kind: "session", session }); - } - rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" }); - for (const action of ACTIONS) rows.push({ kind: "action", action }); - // 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: "click or ↵ ▸ open" }); - rows.push({ kind: "hint", text: `${BAR_KEY} ▸ mosh bar` }); - return rows.map((row, i) => ({ ...row, line: i + 1 })); -} - -/** - * 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; } - if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; } - if (row.kind === "hint") { out.push(` ${dim(row.text)}`); continue; } - if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; } - if (row.kind === "session") { - const s = row.session; - const here = s.name === showing ? acid("▸") : " "; - // 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(lit(row.line, `${here} ${paintState(s.state, MARK[s.state] || "?")}${guess} ${text}`)); - continue; - } - 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 = []; - 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 = () => { - 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 reload = () => { - sessions = read(); - rows = sidebarRows(sessions); - 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) { - try { if (showMember(first.name, { runner, me })) showing = first.name; } - catch (thrown) { say(thrown); } - } - - // 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(); - - // 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); - - const act = async (what) => { - if (what === "detach") { tmux(["detach-client"], { runner }); return; } - if (what === "tile") { - const { herdTile } = await import("./herd-tile.mjs"); - await herdTile([], { write: () => {}, spawner: () => ({ on: (e, cb) => e === "exit" && cb(0) }) }); - refresh(); - return; - } - if (what === "stop") { - const target = sessions.find((s) => s.name === selected); - if (!target) { error = "nothing selected to stop"; draw(); return; } - const { killSession } = await import("./herd.mjs"); - killSession(target.name, { runner }); - refresh(); - const next = read().find((s) => s.alive); - if (next) showMember(next.name, { runner, me }); - refresh(); - return; - } - // shell / agent: start it detached, then bring it into the content pane so - // the thing you just asked for is the thing you are looking at. - const { herdShell, herdStart } = await import("./herd-cli.mjs"); - let created = null; - const capture = (line) => { const m = /^\S*\s*(\S+)\s+—/.exec(String(line).replace(/\x1b\[[0-9;]*m/g, "")); if (m) created = m[1]; }; - if (what === "shell") herdShell([], { write: capture }); - else herdStart(["claude", "--agent"], { write: capture }); - 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) => { - 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; - } - // 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/test/herd-sidebar-click.test.mjs b/test/herd-sidebar-click.test.mjs index f420d543..5111b71d 100644 --- a/test/herd-sidebar-click.test.mjs +++ b/test/herd-sidebar-click.test.mjs @@ -1,19 +1,19 @@ -// The sidebar's input handler: the crash it used to be, and the one click it -// now takes to open an agent. +// The herd sidebar, on hqtui: what the pane shows, and what a click on a cell +// of it actually does. // -// 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. +// These render the real view headlessly and then press screen cells, which is +// the whole reason the port was worth doing. The hand-rolled sidebar kept a +// click map beside the renderer and the two could drift; here the question "is +// the thing that says `api` the thing that opens api" is answered by pressing +// the cell that says `api`. 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"; +import { renderToScreen } from "@profullstack/hqtui"; + // 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 @@ -22,214 +22,251 @@ 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 { herdNodes, herdSidebar, sidebarView, stateCell } = await import("../src/herd-sidebar.mjs"); +const { ACTIONS, parkPane } = await import("../src/herd-workspace.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)); +const settle = () => new Promise((r) => setTimeout(r, 20)); -/** 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 state object with the defaults the view expects, plus whatever a test wants. */ +const stateOf = (sessions, extra = {}) => ({ + sessions, collapsed: new Set(), selected: null, showing: null, hovered: -1, offset: 0, error: "", ...extra, +}); -/** - * 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) }; +/** Render the view and give back the screen plus what each handler was told. */ +function paint(state, { width = 26, height = 30 } = {}) { + const seen = { opened: [], folded: [], hovered: [], acted: [], scrolled: [] }; + const view = sidebarView(state, { + onOpen: (s) => seen.opened.push(s.name), + onFold: (h) => seen.folded.push(h), + onHover: (i) => seen.hovered.push(i), + onAction: (a) => seen.acted.push(a), + onScroll: (d) => seen.scrolled.push(d), + }); + return { screen: renderToScreen(view, { width, height }), seen }; } -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 tree is the map */ -/* --------------------------------------------------------------- the crash */ +test("clicking the cell that says a member's name opens that member", () => { + const { screen, seen } = paint(stateOf([member("api"), member("web")])); + const at = screen.find("web"); + assert.ok(at, `the sidebar never drew "web":\n${screen.text()}`); + assert.equal(screen.click(at.x, at.y), true, "no hit region covers the member row"); + assert.deepEqual(seen.opened, ["web"], "the click opened something else"); + assert.deepEqual(seen.folded, [], "a member is not a fold"); +}); -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; }); +test("one click, not two: the first press opens it", () => { + // Anthony rejected the two-click idiom outright in diskpush 0.7.0 ("i had to + // double click that was odd"). hqtui reports `clicks`, and nothing here + // reads it. + const { screen, seen } = paint(stateOf([member("api")])); + const at = screen.find("api"); + screen.click(at.x, at.y, { clicks: 1 }); + assert.deepEqual(seen.opened, ["api"]); +}); - d.stdin.emit("data", click(rowFor(d, "api").line)); - await settle(); +test("clicking a herd folds it in place instead of replacing the screen", () => { + const sessions = [member("api"), member("logs", { herd: "scratch" })]; + const first = paint(stateOf(sessions)); + const at = first.screen.find("SCRATCH"); + assert.ok(at, `no herd heading:\n${first.screen.text()}`); + first.screen.click(at.x, at.y); + assert.deepEqual(first.seen.folded, ["scratch"]); + assert.deepEqual(first.seen.opened, [], "folding a herd must not open anything"); - assert.equal(rejected, null, "the throw must not escape as an unhandled rejection"); - await quit(d); + // And folded, its member is gone from the tree while the herd itself stays. + const folded = paint(stateOf(sessions, { collapsed: new Set(["scratch"]) })); + assert.equal(folded.screen.contains("SCRATCH"), true, "the herd itself must stay on screen"); + assert.equal(folded.screen.contains("logs"), false, "a folded herd still shows its members"); + assert.equal(folded.screen.contains("api"), true, "folding one herd hid another"); }); -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 row under the pointer is the row a click would take", () => { + const { screen, seen } = paint(stateOf([member("api"), member("web")])); + const at = screen.find("web"); + assert.equal(screen.hover(at.x, at.y), true, "no region answers a hover"); + const hoveredIndex = seen.hovered.at(-1); + // Prove it by drawing again with that hover and pressing the same cell. + const again = paint(stateOf([member("api"), member("web")], { hovered: hoveredIndex })); + again.screen.click(at.x, at.y); + assert.deepEqual(again.seen.opened, ["web"], "the lit row and the clicked row disagree"); }); -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`); +test("the flat index a click resolves to is the row hqtui drew", () => { + // herdNodes builds the tree and the meaning of each row in one pass, in the + // order hqtui flattens an expanded tree in. If those two orders ever part, + // every click below the first herd lands on its neighbour. + const sessions = [member("api"), member("web"), member("logs", { herd: "scratch" })]; + const { rows } = herdNodes(sessions); + assert.deepEqual( + rows.map((r) => (r.kind === "herd" ? `#${r.herd}` : r.session.name)), + ["#main", "api", "web", "#scratch", "logs"], + ); + const { screen, seen } = paint(stateOf(sessions)); + for (const [index, row] of rows.entries()) { + const needle = row.kind === "herd" ? row.herd.toUpperCase() : row.session.name; + const at = screen.find(needle); + seen.hovered.length = 0; + screen.hover(at.x, at.y); + assert.equal(seen.hovered.at(-1), index, `${needle} reports flat index ${seen.hovered.at(-1)}, not ${index}`); } }); -/* ---------------------------------------------------------------- one click */ +/* --------------------------------------------------------------- the actions */ -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(); +test("every action is a button you can press, and the key is printed beside it", () => { + const { screen, seen } = paint(stateOf([member("api")])); + for (const action of ACTIONS) { + const at = screen.find(action.label); + assert.ok(at, `${action.label} is not on screen:\n${screen.text()}`); + assert.equal(screen.click(at.x, at.y), true, `${action.label} is not clickable`); + assert.match(screen.line(at.y), new RegExp(`${action.key}\\s*$`), `${action.label} does not show its key`); + } + assert.deepEqual(seen.acted, ACTIONS.map((a) => a.run)); +}); - 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); +/* ------------------------------------------------------------- what it shows */ + +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. + assert.equal(stateCell(member("a", { state: "idle" })).text, "·"); + assert.equal(stateCell(member("a", { state: "idle", confidence: "inferred" })).text, "·?"); + // Not on `unknown`, whose mark is already "?": two marks for one fact. + assert.equal(stateCell(member("a", { state: "unknown", confidence: "inferred" })).text, "?"); }); -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("the member on screen is marked, and the empty herd still offers the actions", () => { + const shown = paint(stateOf([member("api"), member("web")], { showing: "web" })); + const at = shown.screen.find("web"); + assert.match(shown.screen.line(at.y), /▸\s*web/, "the member in the content pane is not marked"); + + const empty = paint(stateOf([])); + assert.equal(empty.screen.contains("+ shell"), true, "an empty herd has no way to create the first member"); }); -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("a failure is shown on the sidebar rather than swallowed", () => { + const { screen } = paint(stateOf([member("api")], { error: "open: can't find pane" })); + assert.equal(screen.contains("can't find pane"), true, `the reason is not on screen:\n${screen.text()}`); }); -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(); +/* ------------------------------------------- the sidebar wired to a fake app */ + +/** + * An App stand-in. It records the view and the key listeners, so a test can + * render a real frame and press a real cell against the real tmux plumbing. + */ +function fakeApp() { + let view = null; + let finish = null; + const keys = []; + return { + frames: 0, + render(fn) { view = fn; }, + on(event, cb) { if (event === "key") keys.push(cb); return () => {}; }, + invalidate() { this.frames++; }, + stop() { finish?.(); }, + start() { return new Promise((r) => { finish = r; }); }, + screen(options = {}) { return renderToScreen(view, { width: 26, height: 30, ...options }); }, + key(event) { for (const cb of keys) cb(event); }, + }; +} + +/** tmux answering the shapes showMember and contentPane actually read. */ +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: "" }; +}; + +async function running({ sessions = [member("api"), member("web")], answer = panes } = {}) { + const calls = []; + const app = fakeApp(); + const runner = (cmd, args) => { calls.push(args); return answer(args, calls); }; + const done = herdSidebar({ read: () => sessions, runner, refreshMs: 1_000_000, create: async () => app }); 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)); + return { app, calls, done, stop: async () => { app.stop(); await done; } }; +} + +test("a click on a member joins its pane in and hands it the keyboard", async () => { + const r = await running(); + r.calls.length = 0; + const screen = r.app.screen(); + const at = screen.find("web"); + screen.click(at.x, at.y); 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"); + assert.ok(r.calls.some((a) => a.includes("join-pane") && a.includes("%2")), "the member's pane was never joined in"); + assert.ok( + r.calls.some((a) => a[2] === "select-pane" && a.includes("%9")), + "the content pane never got the keyboard, so the click only half-opened it", + ); + await r.stop(); }); -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("a tmux call that throws paints the reason instead of ending the sidebar", async () => { + // The crash this whole surface was rebuilt around: the old handler was async + // with no catch, so one throw out of tmux was an unhandled rejection and Node + // ended the process. + let rejected = null; + const r = await running({ answer: () => { throw new TypeError("Cannot read properties of undefined"); } }); + r.done.catch((e) => { rejected = e; }); + const screen = r.app.screen(); + const at = screen.find("api"); + screen.click(at.x, at.y); + await settle(); + + assert.equal(rejected, null, "the throw escaped as an unhandled rejection"); + assert.match(r.app.screen().text(), /Cannot read properties|could not open/, "nothing said why the click did nothing"); + await r.stop(); }); -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)); +test("a member that is not running says so rather than half-opening", async () => { + const r = await running({ sessions: [member("api"), member("dead", { alive: false, state: "gone" })] }); + r.calls.length = 0; + const screen = r.app.screen(); + const at = screen.find("dead"); + screen.click(at.x, at.y); 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); + assert.equal(r.calls.filter((a) => a.includes("join-pane")).length, 0); + assert.match(r.app.screen().text(), /not running/); + await r.stop(); }); -/* --------------------------------------------------- the sidebar's own keys */ +test("enter opens the selected member, so the mouse is not the only way in", async () => { + const r = await running(); + r.calls.length = 0; + r.app.key({ key: "down" }); + r.app.key({ key: "enter" }); + await settle(); + assert.ok( + r.calls.some((a) => a.includes("join-pane") && a.includes("%2")), + "enter did not open the member the keyboard had selected", + ); + await r.stop(); +}); -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`, - ); - } +test("the action keys the sidebar prints are the keys it answers", async () => { + // The hand-rolled version printed s / a / x beside its actions and its shared + // input parser only ever emitted the plain list's keys, so three of the five + // shortcuts did nothing at all. + const r = await running(); + r.calls.length = 0; + r.app.key({ key: "x" }); // stop, on the selected member + await settle(); + assert.ok(r.calls.some((a) => a.includes("kill-pane")), "x never reached the selected member"); + await r.stop(); }); /* ----------------------------------------------------------- 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. + // window option that stops them does not travel with a joined pane. const calls = []; const runner = (cmd, args) => { calls.push(args); @@ -238,22 +275,7 @@ test("a pane whose title is not a legal session name can still be parked", () => } return { status: 0, stdout: "%7\n", stderr: "" }; }; - assert.equal(parkPane("%3", "anthony@dev:~/src/moshcode", { runner }), true); + assert.equal(parkPane("%3", "anthony@dev:~/src/moshcoder/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", - ); -}); diff --git a/test/herd-workspace.test.mjs b/test/herd-workspace.test.mjs index 4577524d..d1a29b4f 100644 --- a/test/herd-workspace.test.mjs +++ b/test/herd-workspace.test.mjs @@ -1,5 +1,5 @@ -// The sidebar workspace: the row/line agreement a click depends on, and the -// pane swap that has to leave the sidebar alone. +// The sidebar workspace: the tmux pane swap that has to leave the sidebar +// alone, and the actions the sidebar draws from. import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; @@ -8,53 +8,28 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { ACTIONS, renderSidebar, sidebarRows, WINDOW, WORKSPACE } from "../src/herd-workspace.mjs"; +import { ACTIONS, WINDOW, WORKSPACE } from "../src/herd-workspace.mjs"; const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const hasTmux = (() => { try { return spawnSync("tmux", ["-V"], { encoding: "utf8" }).status === 0; } catch { return false; } })(); -const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); -const member = (name, extra = {}) => ({ - name, engine: "claude", herd: "main", state: "idle", cwd: "/x", alive: true, ...extra, -}); -/* ------------------------------------------------ the click map is the screen */ - -test("every clickable row renders on exactly the line it claims", () => { - // Same guarantee the list needed, for the same reason: these line numbers ARE - // the click map, so a one-line drift sends every click to its neighbour. - const rows = sidebarRows([member("api"), member("work", { engine: "shell" }), member("logs", { herd: "scratch" })]); - const lines = strip(renderSidebar(rows, { selected: "api", showing: "api" })).split("\r\n"); - - for (const row of rows.filter((r) => r.kind === "session" || r.kind === "action")) { - const rendered = lines[row.line - 1]; - const label = row.kind === "session" ? row.session.name : row.action.label; - assert.ok(rendered !== undefined, `line ${row.line} for ${label} is off the end`); - assert.ok( - rendered.includes(row.kind === "session" ? row.session.name : row.action.label.split(" ").pop()), - `${label} claims line ${row.line}, which renders as ${JSON.stringify(rendered)}`, - ); - } -}); +/* ----------------------------------------------------------------- actions */ + +// What the sidebar LOOKS like moved to src/herd-sidebar.mjs when it was ported +// to hqtui, and the tests that pinned a row to the line it rendered on went +// with it: the tree widget reports the row it drew each node on, so the click +// map is the screen by construction rather than by agreement between two +// pieces of code here. See test/herd-sidebar-click.test.mjs, which presses +// screen cells. What is left in this file is the tmux half. test("actions are always present, even with an empty herd", () => { // The sidebar has to be able to CREATE the first member; a herd with nothing // in it and no actions would be a dead end. - const rows = sidebarRows([]); - const actions = rows.filter((r) => r.kind === "action"); - assert.equal(actions.length, ACTIONS.length); - assert.match(strip(renderSidebar(rows, {})), /\+ shell/); -}); - -test("the member being shown is marked differently from the one selected", () => { - // Selecting and showing are separate: you can move the highlight around - // without the right-hand pane changing under you. - const rows = sidebarRows([member("api"), member("web")]); - const text = strip(renderSidebar(rows, { selected: "web", showing: "api" })); - const apiLine = text.split("\r\n").find((l) => l.includes("api")); - assert.match(apiLine, /▸/, "the shown member carries the marker"); + assert.ok(ACTIONS.some((a) => a.run === "shell")); + assert.ok(ACTIONS.some((a) => a.run === "agent")); }); test("every action has a single-key shortcut and none collide", () => {