From 960b6cf4df898af60a8ec90f35c4f49ee25f73f2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 08:32:39 +0000 Subject: [PATCH] feat(herd): port the sidebar to hqtui, as a folding tree "are we using hqtui.com library for this tui? it looks pretty old and outdated. upgrade it to hqtui" moshcode did not use hqtui at all: zero references in src/ or bin/, and the TUI is hand-rolled escape sequences throughout. So this is a port, not a version bump. @profullstack/hqtui joins as a dependency at ^0.7.0, a house package with zero runtime dependencies. SCOPE. The herd sidebar, which is the surface Anthony is looking at when he complains, and nothing else. src/herd-workspace.mjs keeps the tmux half and only the tmux half; src/herd-sidebar.mjs is the new view. The right-hand pane is still a real tmux pane running a real agent, because no renderer can substitute for one. WHAT THE LIBRARY BUYS. Three bugs came out of the hand-rolled shape and none of them were ever about herds: - the click map was a second source of truth. sidebarRows() numbered the lines and renderSidebar() printed them, and when they drifted every click landed on the row below the pointer. hqtui's tree reports the row it drew each node on, so the map is built by the thing that draws it. - there was no hover, so a click had to be spent selecting, which is where the double-click came from. onHoverRow plus hovered is now the whole of it. - the restore path was escape sequences only. hqtui's Terminal restores on SIGINT, SIGTERM and an uncaught error by default. A FOLDING TREE, IN PLACE. The herd is herds containing members, which is a tree, and the 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, meaning shown and given the keyboard. Nothing is swapped out for a different screen. Actions are real buttons with onPress. Neither known trap is tripped: collapseBorders only merges where two bordered siblings touch and there is nothing here to merge, so it is off with the reasoning written down; and no panel carries both a title and a subtitle, which is where near-identical panels truncate differently. The tests are the part worth having. renderToScreen draws the real view headlessly and then presses real cells, so "is the thing that says api the thing that opens api" is answered by clicking the cell that says api, rather than by two pieces of code agreeing about a line number. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + pnpm-lock.yaml | 10 + src/herd-cli.mjs | 2 +- src/herd-sidebar.mjs | 334 ++++++++++++++++++++++++++ src/herd-workspace.mjs | 302 +---------------------- test/herd-sidebar-click.test.mjs | 400 ++++++++++++++++--------------- test/herd-workspace.test.mjs | 51 +--- 7 files changed, 582 insertions(+), 518 deletions(-) create mode 100644 src/herd-sidebar.mjs 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", () => {