From 2412d332cbe331e1334e10be2a970e2eff11abbc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 9 Aug 2026 23:48:58 +0000 Subject: [PATCH] feat(herd): a one-line mosh prompt under the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace put a real agent in the content pane, which is the point of it — but a real agent takes the keyboard. Click into claude and every sidebar key stops working, and with tmux's status line off there is nothing on screen that says how to get back out. You are looking at one agent with no visible way to leave it. A status line would have been the small fix: one row of text that never goes away. But a row you can only read answers "how do I get out" and nothing else — you still cannot start a second agent without leaving the first. So the row takes input. It is the same surface as the CLI, which makes the escape hatch and the command line one thing rather than two. mosh ▸ ps · start claude · show · kill · detach · help F12 reaches it from anywhere, including from inside an agent that has taken the keyboard: the binding lives in tmux's root table, so it is claimed before the pane's application ever sees it. It switches the client first, so it is also a way out of a member you attached to directly. Esc goes back to the session; Enter on an empty line does too. Output grows the bar over the content while you read it, then it collapses back to one row. `attach` in the bar means `show`. The real attach would be a tmux client inside the client already drawing the pane, which tmux refuses, and in a workspace the word means "put it in the content pane" anyway. Two things this turned up, both caught by probing a live tmux rather than by reasoning about it: - `join-pane -b -t ` splits the BAR's cell, and the bar is one row: "create pane failed: pane too small". Dropping `select-layout main-vertical` for a plain split of the sidebar fixes it and is better anyway — main-vertical owns every pane in the window, so it dragged the footer into the right-hand column and gave it an equal share. Splitting the sidebar touches only the region above the footer, so the bar spans the full width and needs no correction after a swap. - `bind-key -n F12 cmd1 ";" cmd2` as separate argv elements binds cmd1 and runs cmd2 once, immediately. That produced a key which switched sessions and did nothing else. The chain has to arrive as one string. `contentPane()` excluded only itself, which was fine with two panes and wrong with three — a swap would have parked the bar. It now excludes by title, like everything else here. Also: clicking the member already on screen, or pressing Enter, hands it the keyboard — without that the workspace can only be watched. Co-Authored-By: Claude Opus 5 --- README.md | 31 +++++- src/cli-schema.mjs | 10 +- src/herd-bar.mjs | 196 +++++++++++++++++++++++++++++++++++ src/herd-cli.mjs | 5 +- src/herd-workspace.mjs | 98 +++++++++++++++--- test/herd-bar.test.mjs | 159 ++++++++++++++++++++++++++++ test/herd-workspace.test.mjs | 37 ++++++- 7 files changed, 518 insertions(+), 18 deletions(-) create mode 100644 src/herd-bar.mjs create mode 100644 test/herd-bar.test.mjs diff --git a/README.md b/README.md index 5a5635a..b293644 100644 --- a/README.md +++ b/README.md @@ -183,13 +183,42 @@ moshcode herd ui │ ✕ stop │ │ │ ⊞ tile │ │ │ ← detach │ │ -└────────────┴───────────────────────────────────┘ +│ │ │ +│ enter ▸ … │ │ +│ F12 ▸ … │ │ +├────────────┴───────────────────────────────────┤ +│ mosh ▸ ps · start claude · show · detach │ +└────────────────────────────────────────────────┘ ``` Members and actions down the left, the selected member's **real terminal** on the right. Click a member to show it; click an action to start a shell, start an agent, or stop the selected one. `q` detaches and leaves everything running. +Click the member that is already on screen — or press Enter — and the keyboard +goes to it, so you are typing at the agent itself. + +### The mosh bar + +The row along the bottom is a mosh prompt, and it is always there. **F12** jumps +to it from anywhere, including from inside an agent that has taken the keyboard, +which makes it the way out of a session you cannot otherwise leave. Esc goes +back to the session; `detach` leaves with everything still running. + +It takes any `moshcode herd` verb, so you can start a second agent without +leaving the first: + +``` +mosh ▸ start claude # another agent, now on screen +mosh ▸ show api # put a different member up +mosh ▸ ps # the roster, over the session, then out of the way +``` + +`attach` means `show` here — in a workspace the word means "put it in the +content pane", and the real attach would be a tmux client inside a tmux client. +Output grows the bar over the content for as long as you are reading it, then it +collapses back to one row. + The right-hand pane is not a picture of a session — it *is* the session's pane, moved in. tmux's model is session → window → pane, so moving between *windows* cannot keep anything on screen; but `join-pane` moves a running pane into an diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index db5da27..15263ce 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -745,9 +745,17 @@ export const HERD_VERBS = [ { name: "untile", description: "put tiled members back in their own sessions", synopsis: [["moshcode herd untile", ""]] }, { name: "ui", description: "sidebar of members and actions, selected one beside it", - synopsis: [["moshcode herd ui", "click a member to show it · s shell · a agent · x stop · q detach"]], + synopsis: [["moshcode herd ui", "click a member to show it · click it again to type in it · F12 for the mosh bar"]], flags: [], examples: [["moshcode herd ui", "the workspace — start here"]] }, + { name: "bar", description: "the one-line mosh prompt under the session (runs inside the workspace)", + synopsis: [["moshcode herd bar", "F12 reaches it from inside an agent · Esc goes back · detach leaves"]], + flags: [], + examples: [ + ["F12", "jump to the bar from anywhere, even mid-agent"], + ["start claude", "another agent, without leaving this one"], + ["show api", "put a different member on screen"], + ] }, { name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script", synopsis: [["moshcode herd run [--name ] -- ", "everything after -- is the command"]], flags: [ diff --git a/src/herd-bar.mjs b/src/herd-bar.mjs new file mode 100644 index 0000000..0fae5ce --- /dev/null +++ b/src/herd-bar.mjs @@ -0,0 +1,196 @@ +// `moshcode herd bar` — a one-line mosh prompt pinned under the session. +// +// WHY THIS EXISTS. The workspace put a real agent in the content pane, which is +// the point of it — but a real agent takes the keyboard. Click into claude and +// every sidebar key stops working, and with tmux's status line off there is +// nothing on screen that says how to get back out. You are looking at one agent +// with no visible way to leave it, which is exactly the complaint. +// +// A status line would have been the small fix: one line of text that never goes +// away. But a line you can only read answers "how do I get out" and nothing +// else — you still cannot start a second agent without leaving first. So the +// line takes input. It is the same surface as the CLI (every `moshcode herd` +// verb works here) which means the escape hatch and the command line are one +// thing rather than two. +// +// The bar is one row until it has something to say, then it grows over the +// content, then it collapses again. Output has to go somewhere, and stealing +// rows from the agent for a moment is cheaper than a pane that is mostly empty. +import { spawnSync } from "node:child_process"; + +import { tmux } from "./herd.mjs"; +import { acid, ash, bone } from "./ui.mjs"; + +export const BAR_TITLE = "mosh-bar"; +export const SIDEBAR_TITLE = "herd"; +export const BAR_HEIGHT = 1; +export const BAR_OPEN_HEIGHT = 14; + +/** The key that reaches the bar from anywhere, including from inside an agent. */ +export const BAR_KEY = "F12"; + +export const HINT = "ps · start claude · show · kill · detach · help"; + +/* ------------------------------------------------------------- pane geometry */ + +/** + * Which pane is which, by title. + * + * Titles rather than indexes or ids: a pane keeps its title across `join-pane`, + * which is the whole reason the workspace can move panes around at all, and + * indexes shift every time one arrives or leaves. + */ +export function paneRoles(target, { runner = spawnSync } = {}) { + const roles = { sidebar: null, content: null, bar: null }; + const r = tmux(["list-panes", "-t", target, "-F", "#{pane_id}\t#{pane_title}"], { runner }); + if (!r.ok) return roles; + for (const line of r.stdout.split("\n")) { + const [paneId, title] = line.split("\t"); + if (!paneId) continue; + if (title === BAR_TITLE) roles.bar = { paneId, title }; + else if (title === SIDEBAR_TITLE) roles.sidebar = { paneId, title }; + else roles.content = { paneId, title }; + } + return roles; +} + +/* --------------------------------------------------------------- line editing */ + +/** + * One keystroke against the current line. Pure, so the editor is testable + * without a terminal — the bar itself is then only plumbing. + */ +export function editLine(line, key) { + if (key === "\r" || key === "\n") return { line, action: "submit" }; + if (key === "\x1b") return { line: "", action: "escape" }; + if (key === "\x03") return { line: "", action: "escape" }; // Ctrl-C + if (key === "\x15") return { line: "", action: "edit" }; // Ctrl-U + if (key === "\x17") return { line: line.replace(/\S+\s*$/, ""), action: "edit" }; // Ctrl-W + if (key === "\x7f" || key === "\b") return { line: line.slice(0, -1), action: "edit" }; + if (key.length === 1 && key >= " " && key !== "\x7f") return { line: line + key, action: "edit" }; + return { line, action: "none" }; +} + +/** + * What a typed line means. + * + * `attach` deliberately becomes `show`. Running the real attach from in here + * would start a tmux client inside the client already showing this pane, which + * tmux refuses — and the thing the word means in a workspace is "put it in the + * content pane" anyway. + */ +export function resolveCommand(input) { + const argv = String(input || "").trim().split(/\s+/).filter(Boolean); + if (!argv.length) return { kind: "empty", argv: [] }; + const [verb, ...rest] = argv; + if (verb === "detach" || verb === "exit" || verb === "quit") return { kind: "detach", argv: rest }; + if (verb === "show" || verb === "attach" || verb === "fg") return { kind: "show", argv: rest }; + if (verb === "help" || verb === "?") return { kind: "help", argv: rest }; + if (verb === "clear") return { kind: "clear", argv: rest }; + return { kind: "herd", argv }; +} + +/** The prompt line. The hint is what makes the way out discoverable at rest. */ +export function renderPrompt(line, { cols = 80, showHint = true } = {}) { + const prompt = `${acid("mosh")} ${bone("▸")} `; + if (!line && showHint) return `${prompt}${ash(HINT.slice(0, Math.max(0, cols - 8)))}`; + return `${prompt}${line}`; +} + +export function helpLines() { + return [ + "the bar takes any moshcode herd verb:", + " ps the roster start claude new agent", + " show put it on screen shell new shell", + " kill end one tile all at once", + " read its last screen prompt type into it", + "", + `${BAR_KEY} comes back here from anywhere · Esc returns to the session · detach leaves`, + ]; +} + +/* ----------------------------------------------------------------- the bar */ + +/** + * Runs inside the one-line pane at the bottom of the workspace. + */ +export async function herdBar({ + stdin = process.stdin, + stdout = process.stdout, + runner = spawnSync, + target = "herd:ui", + run = null, +} = {}) { + const me = process.env.TMUX_PANE; + const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options)); + + let line = ""; + let open = false; + + const cols = () => stdout.columns || 80; + const collapse = () => { + if (!open) return; + open = false; + tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner }); + }; + const expand = (rows) => { + open = true; + tmux(["resize-pane", "-t", me, "-y", String(Math.min(BAR_OPEN_HEIGHT, rows + 2))], { runner }); + }; + const draw = () => { + stdout.write(`\x1b[2J\x1b[H${renderPrompt(line, { cols: cols() })}`); + }; + const show = (lines) => { + expand(lines.length); + stdout.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n${renderPrompt("", { cols: cols(), showHint: false })}`); + }; + /** Give the keyboard back to whatever is on screen. */ + const toContent = () => { + const roles = paneRoles(target, { runner }); + if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner }); + }; + + const submit = async () => { + const typed = line; + line = ""; + const command = resolveCommand(typed); + if (command.kind === "empty") { collapse(); draw(); toContent(); return true; } + if (command.kind === "clear") { collapse(); draw(); return true; } + if (command.kind === "help") { show(helpLines()); return true; } + if (command.kind === "detach") { tmux(["detach-client"], { runner }); return false; } + if (command.kind === "show") { + const [name] = command.argv; + const { showMember } = await import("./herd-workspace.mjs"); + const okShown = name && showMember(name, { runner, me }); + if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; } + collapse(); draw(); toContent(); + return true; + } + const out = []; + await herdCommand(command.argv, { write: (s) => out.push(...String(s).split("\n")) }); + if (out.length) show(out); + else { collapse(); draw(); } + return true; + }; + + try { stdin.setRawMode?.(true); } catch { /* not a tty */ } + stdin.resume(); + draw(); + + await new Promise((resolve) => { + stdin.on("data", async (buf) => { + for (const key of String(buf)) { + const next = editLine(line, key); + line = next.line; + if (next.action === "submit") { + if (!(await submit())) { resolve(); return; } + continue; + } + if (next.action === "escape") { collapse(); draw(); toContent(); continue; } + if (next.action === "edit") { if (open) { collapse(); } draw(); } + } + }); + }); + + return 0; +} diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs index 7855c6d..5eb9527 100644 --- a/src/herd-cli.mjs +++ b/src/herd-cli.mjs @@ -178,7 +178,7 @@ export function herdStart(argv, { write = console.log } = {}) { } write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`)); if (flags.agent) write(warn("agent mode: native approvals are bypassed or auto-approved.")); - write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`)); + write(info(`workspace: ${acid("moshcode herd ui")} · attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`)); const note = substrateNote(substrate); if (note) write(info(note)); return EXIT.matched; @@ -253,7 +253,7 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) { return EXIT.matched; } write(ok(`${bone(name)} — ${label} running in the herd. the prompt is yours.`)); - write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`)); + write(info(`workspace: ${acid("moshcode herd ui")} · attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`)); return EXIT.matched; } @@ -729,6 +729,7 @@ const VERBS = { // 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), + 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), ps: herdPs, list: herdPs, status: herdStatus, diff --git a/src/herd-workspace.mjs b/src/herd-workspace.mjs index ff4aed6..9441a5f 100644 --- a/src/herd-workspace.mjs +++ b/src/herd-workspace.mjs @@ -21,6 +21,7 @@ import { spawn, spawnSync } from "node:child_process"; import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs"; import { roster } from "./herd-cli.mjs"; import { groupByHerd, parseInput } from "./herd-ui.mjs"; +import { BAR_HEIGHT, BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, paneRoles } from "./herd-bar.mjs"; import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs"; export const WORKSPACE = "herd"; @@ -59,15 +60,12 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn, const sidebar = `${process.execPath} ${self} herd sidebar`; const made = tmux(["new-session", "-d", "-s", WORKSPACE, "-n", WINDOW, sidebar], { runner }); if (!made.ok) { write(err(made.stderr.trim() || "could not open the workspace")); return 1; } - // The sidebar is the "main" pane of a main-vertical layout, which is what - // pins it to the left at a fixed width while the content pane takes the - // rest and follows the terminal when it resizes. - tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner }); tmux(["set-option", "-t", WORKSPACE, "mouse", "on"], { runner }); tmux(["set-option", "-t", WORKSPACE, "status", "off"], { runner }); tmux(["set-option", "-t", WORKSPACE, "pane-border-status", "top"], { runner }); tmux(["set-option", "-t", WORKSPACE, "pane-border-format", " #{pane_title} "], { runner }); - tmux(["select-pane", "-t", `${TARGET}.0`, "-T", "herd"], { runner }); + tmux(["select-pane", "-t", `${TARGET}.0`, "-T", SIDEBAR_TITLE], { runner }); + buildBar({ runner }); } return new Promise((resolve) => { @@ -82,15 +80,56 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn, }); } +/* ------------------------------------------------------------------ the bar */ + +/** How the bar pane starts itself. Separated so tests can run a stand-in. */ +export function barCommand(self = process.argv[1]) { + return `${process.execPath} ${self} herd bar`; +} + +/** + * Add the one-line mosh prompt under the content, and the key that reaches it. + * + * The binding goes in tmux's root table, so it is claimed before the pane's + * application ever sees it — that is what makes it work from inside an agent + * that has taken the keyboard, which is the case the bar exists for. It also + * switches the client first, so it is a way out of a member you attached to + * directly and not only of the workspace. + */ +export function buildBar({ runner = spawnSync, command = barCommand() } = {}) { + const made = tmux( + ["split-window", "-t", TARGET, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command], + { runner }, + ); + if (!made.ok) return null; + const paneId = made.stdout.trim().split("\n")[0]; + if (!paneId) return null; + tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner }); + // One string, not separate arguments: a bare ";" argument ends the bind-key + // command itself, so tmux binds the first command and runs the second once, + // now. That silently produced a key that switched sessions and did nothing + // else — the binding has to arrive as a single command sequence. + tmux(["bind-key", "-n", BAR_KEY, `switch-client -t ${WORKSPACE} ; select-pane -t ${paneId}`], { runner }); + tmux(["select-pane", "-t", `${TARGET}.0`], { runner }); + return paneId; +} + /* ------------------------------------------------------------- the swapping */ -/** The content pane currently on the right, if there is one. */ +/** + * The content pane — the one that is neither the sidebar nor the bar. + * + * Excluding by title rather than "any pane that is not me": the bar made that + * shortcut wrong, and wrong here means a swap parks the bar into a session + * named after it and the prompt vanishes off the bottom of the screen. + */ export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) { const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner }); if (!r.ok) return null; for (const line of r.stdout.split("\n")) { const [paneId, title] = line.split("\t"); if (!paneId || paneId === me) continue; + if (title === BAR_TITLE || title === SIDEBAR_TITLE) continue; return { paneId, title }; } return null; @@ -126,16 +165,34 @@ export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PAN if (!wanted) return false; if (current) parkPane(current.paneId, current.title, { runner }); - const joined = tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner }); + + // Split the SIDEBAR rather than laying the window out. + // + // `select-layout main-vertical` was the obvious way to do this and it is the + // wrong one once a footer exists: it owns every pane in the window, so it + // dragged the bar into the right-hand column and gave it an equal share, and + // putting it back was a second fight every swap. Splitting the sidebar + // touches only the region above the footer, which leaves the bar a full-width + // row at the bottom and needs no correction afterwards. + const roles = paneRoles(TARGET, { runner }); + const anchor = roles.sidebar?.paneId || me; + const joined = anchor + ? tmux(["join-pane", "-h", "-s", wanted.paneId, "-t", anchor], { runner }) + : tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner }); if (!joined.ok) return false; - tmux(["select-layout", "-t", TARGET, "main-vertical"], { runner }); - // main-vertical resets the main pane's width from the option, so re-assert it - // after every swap or the sidebar creeps wider each time. - tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner }); + if (anchor) tmux(["resize-pane", "-t", anchor, "-x", String(SIDEBAR_WIDTH)], { runner }); tmux(["select-pane", "-t", me], { runner }); return true; } +/** Hand the keyboard to the session on screen. */ +export function focusContent({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) { + const current = contentPane({ runner, me }); + if (!current) return false; + tmux(["select-pane", "-t", current.paneId], { runner }); + return true; +} + /* --------------------------------------------------------------- the render */ const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" }; @@ -159,6 +216,11 @@ export function sidebarRows(sessions) { } 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: "enter ▸ type in it" }); + rows.push({ kind: "hint", text: `${BAR_KEY} ▸ mosh bar` }); return rows.map((row, i) => ({ ...row, line: i + 1 })); } @@ -168,6 +230,7 @@ export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH } 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; @@ -262,9 +325,14 @@ export async function herdSidebar({ const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action")); if (!hit) continue; if (hit.kind === "session") { + // First click browses. Clicking the one already on screen hands it + // the keyboard — the same second-click-opens idiom as the list, and + // the only way to reach an agent without using the mouse on it. + const already = hit.session.name === showing; selected = hit.session.name; if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; } draw(); + if (already) focusContent({ runner, me }); } else { selected = hit.action.key; draw(); @@ -280,7 +348,13 @@ export async function herdSidebar({ const at = names.indexOf(selected); if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected; if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected; - if (event.key === "\r" || event.key === "\n") { showMember(selected, { runner, me }); showing = selected; } + if (event.key === "\r" || event.key === "\n") { + showMember(selected, { runner, me }); + showing = selected; + draw(); + focusContent({ runner, me }); + continue; + } draw(); } }); diff --git a/test/herd-bar.test.mjs b/test/herd-bar.test.mjs new file mode 100644 index 0000000..66b4230 --- /dev/null +++ b/test/herd-bar.test.mjs @@ -0,0 +1,159 @@ +// The one-line mosh prompt under the session: the line editor, what a typed +// line means, and the geometry it has to keep. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + BAR_HEIGHT, BAR_KEY, BAR_TITLE, HINT, SIDEBAR_TITLE, + editLine, helpLines, paneRoles, renderPrompt, resolveCommand, +} from "../src/herd-bar.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, ""); + +/* --------------------------------------------------------------- the editor */ + +test("typing accumulates and backspace removes", () => { + let line = ""; + for (const key of "psx") line = editLine(line, key).line; + assert.equal(line, "psx"); + assert.equal(editLine(line, "\x7f").line, "ps"); +}); + +test("enter submits, escape abandons the line", () => { + assert.deepEqual(editLine("ps", "\r"), { line: "ps", action: "submit" }); + const escaped = editLine("half typed", "\x1b"); + assert.equal(escaped.action, "escape"); + assert.equal(escaped.line, "", "escape leaves nothing behind to be submitted later"); +}); + +test("Ctrl-C clears rather than killing the bar", () => { + // The bar is the way out of a stuck session. If Ctrl-C ended it, the reflex + // that people reach for first would remove the escape hatch. + const hit = editLine("kill api", "\x03"); + assert.equal(hit.action, "escape"); + assert.equal(hit.line, ""); +}); + +test("Ctrl-U clears the line and Ctrl-W drops the last word", () => { + assert.equal(editLine("start claude", "\x15").line, ""); + assert.equal(editLine("start claude", "\x17").line, "start "); +}); + +test("control bytes are never inserted as text", () => { + // A stray escape sequence from a resize would otherwise end up in the line + // and be submitted as a command. + for (const key of ["\x1b[A", "\x00", "\x1f"]) { + assert.equal(editLine("ps", key).line, "ps", `${JSON.stringify(key)} leaked into the line`); + } +}); + +/* -------------------------------------------------------------- the meaning */ + +test("attach means show, because a nested client is not a thing", () => { + // Running the real attach from inside the workspace would ask tmux to start a + // client inside the client already drawing this pane, which it refuses. In a + // workspace the word means "put it in the content pane". + for (const verb of ["attach", "show", "fg"]) { + assert.deepEqual(resolveCommand(`${verb} api`), { kind: "show", argv: ["api"] }); + } +}); + +test("detach, exit and quit all leave without killing anything", () => { + for (const verb of ["detach", "exit", "quit"]) { + assert.equal(resolveCommand(verb).kind, "detach"); + } +}); + +test("anything else is passed through to the herd CLI verbatim", () => { + assert.deepEqual(resolveCommand(" start claude --agent "), { + kind: "herd", argv: ["start", "claude", "--agent"], + }); + assert.equal(resolveCommand("ps").kind, "herd"); +}); + +test("an empty line is not a command", () => { + assert.equal(resolveCommand("").kind, "empty"); + assert.equal(resolveCommand(" ").kind, "empty"); +}); + +/* --------------------------------------------------------------- the prompt */ + +test("at rest the prompt shows the way out", () => { + // This is the whole point: the bar is the only thing on screen that is always + // visible, so the hint has to be on it when nothing is typed. + const rendered = strip(renderPrompt("", { cols: 80 })); + assert.match(rendered, /mosh/); + assert.ok(rendered.includes("detach"), `no way out offered: ${rendered}`); +}); + +test("the hint gives way to what is being typed", () => { + const rendered = strip(renderPrompt("start claude", { cols: 80 })); + assert.match(rendered, /start claude$/); + assert.ok(!rendered.includes(HINT), "the hint must not fight the input for the row"); +}); + +test("the prompt stays on one line at narrow widths", () => { + // It is a one-row pane. A prompt wider than the pane wraps, scrolls itself + // off, and the row goes blank. + for (const cols of [24, 40, 80]) { + const rendered = strip(renderPrompt("", { cols })); + assert.ok(rendered.length <= cols, `${rendered.length} chars in ${cols} columns`); + } +}); + +test("help names the key that gets you back here", () => { + assert.ok(helpLines().some((l) => l.includes(BAR_KEY)), "help must name the jump key"); +}); + +/* ----------------------------------------------------------------- geometry */ + +test("the sidebar and the bar are told apart by title, not position", (t) => { + if (!hasTmux) { t.skip("no tmux on this machine"); return; } + const socket = `moshcode-bartest-${process.pid}`; + const T = (...args) => spawnSync("tmux", ["-L", socket, ...args], { encoding: "utf8" }); + try { + T("-f", "/dev/null", "new-session", "-d", "-s", "herd", "-n", "ui", "sh -c 'while read x; do :; done'"); + T("select-pane", "-t", "herd:ui.0", "-T", SIDEBAR_TITLE); + T("split-window", "-t", "herd:ui", "-f", "-v", "-l", String(BAR_HEIGHT), "sh -c 'while read x; do :; done'"); + const bar = T("list-panes", "-t", "herd:ui", "-F", "#{pane_id}").stdout.trim().split("\n")[1]; + T("select-pane", "-t", bar, "-T", BAR_TITLE); + + const env = { ...process.env, MOSHCODE_HERD_SOCKET: socket }; + const script = ` + const bar = await import(${JSON.stringify(path.join(ROOT, "src", "herd-bar.mjs"))}); + console.log(JSON.stringify(bar.paneRoles("herd:ui", {}))); + `; + const run = spawnSync(process.execPath, ["--input-type=module", "-e", script], { env, encoding: "utf8", cwd: ROOT }); + assert.equal(run.status, 0, run.stderr); + const roles = JSON.parse(run.stdout.trim().split("\n").pop()); + assert.equal(roles.sidebar?.title, SIDEBAR_TITLE); + assert.equal(roles.bar?.title, BAR_TITLE); + assert.equal(roles.content, null, "with no member on screen there is no content pane"); + } finally { + spawnSync("tmux", ["-L", socket, "kill-server"], { encoding: "utf8" }); + } +}); + +test("paneRoles is a function of titles alone", () => { + // No tmux needed: a fake runner proves the classification rather than the + // plumbing, so the rule stays pinned on machines that skip the live test. + const runner = () => ({ + status: 0, + stdout: [`%1\t${SIDEBAR_TITLE}`, "%2\tapi", `%3\t${BAR_TITLE}`].join("\n"), + stderr: "", + }); + const roles = paneRoles("herd:ui", { runner }); + assert.equal(roles.sidebar.paneId, "%1"); + assert.equal(roles.content.paneId, "%2"); + assert.equal(roles.bar.paneId, "%3"); +}); diff --git a/test/herd-workspace.test.mjs b/test/herd-workspace.test.mjs index 5423f79..011a2b7 100644 --- a/test/herd-workspace.test.mjs +++ b/test/herd-workspace.test.mjs @@ -85,16 +85,29 @@ test("swapping the content pane leaves the sidebar in place", (t) => { herd.tmux(["new-session", "-d", "-s", ws.WORKSPACE, "-n", ws.WINDOW, "sh -c 'echo SIDEBAR; while read x; do :; done'"]); - herd.tmux(["set-option", "-t", ws.WORKSPACE, "main-pane-width", "26"]); const me = herd.tmux(["list-panes", "-t", ws.TARGET, "-F", "#{pane_id}"]).stdout.trim(); + herd.tmux(["select-pane", "-t", me, "-T", ${JSON.stringify("herd")}]); + herd.tmux(["resize-window", "-t", ws.TARGET, "-x", "100", "-y", "30"]); + ws.buildBar({ command: "sh -c 'echo BAR; while read x; do :; done'" }); const widths = []; + const footers = []; for (const name of ["alpha", "beta", "alpha"]) { ws.showMember(name, { me }); widths.push(herd.tmux(["list-panes", "-t", ws.TARGET, "-F", "#{pane_title}:#{pane_width}"]).stdout.trim().replace(/\\n/g, "|")); + footers.push(herd.tmux(["list-panes", "-t", ws.TARGET, + "-F", "#{pane_title}:#{pane_height}:#{pane_width}:#{pane_left}"]).stdout.trim() + .split("\\n").find((l) => l.startsWith("mosh-bar:")) || "MISSING"); } + const focused = ws.focusContent({ me }); + const active = herd.tmux(["list-panes", "-t", ws.TARGET, "-F", "#{pane_title}:#{pane_active}"]).stdout + .trim().split("\\n").find((l) => l.endsWith(":1")); console.log(JSON.stringify({ widths, + footers, + focused, + active, + jumpKey: herd.tmux(["list-keys", "-T", "root"]).stdout.split("\\n").filter((l) => l.includes("F12")), sidebarAlive: herd.tmux(["capture-pane", "-p", "-t", me]).stdout.includes("SIDEBAR"), roster: cli.roster().filter((s) => s.alive).map((s) => s.name).sort(), contentKept: herd.capture("beta", { lines: 10 }).includes("MARK-beta"), @@ -108,10 +121,30 @@ test("swapping the content pane leaves the sidebar in place", (t) => { // Neither member may be lost by being moved in and out of the workspace. assert.deepEqual(out.roster, ["alpha", "beta"]); assert.equal(out.contentKept, true, "a swapped-out member keeps its scrollback"); - // The sidebar must not creep wider each time main-vertical is reapplied. + // The sidebar must not creep wider each time the content is swapped. for (const state of out.widths) { assert.match(state, /:26\b/, `sidebar lost its width: ${state}`); } + + // The footer is the only thing on screen that is always visible, so losing + // it — or letting a swap give it half the window — is the whole bug back. + for (const footer of out.footers) { + const [, height, width, left] = footer.split(":"); + assert.notEqual(footer, "MISSING", "the bar did not survive a swap"); + assert.equal(height, "1", `the bar grew to ${height} rows: ${footer}`); + assert.equal(left, "0", "the bar must span from the left edge"); + assert.equal(width, "100", `the bar must span the full width, got ${width}`); + } + + // Enter has to reach the agent, or the workspace can only ever be watched. + assert.equal(out.focused, true); + assert.match(out.active, /^alpha:1$/, `focus went to ${out.active} instead of the session`); + + // A bare ";" argument ends bind-key instead of chaining, which bound a key + // that switched sessions and did nothing else. Both halves or it is broken. + assert.equal(out.jumpKey.length, 1, `expected one F12 binding, got ${out.jumpKey.length}`); + assert.match(out.jumpKey[0], /switch-client/); + assert.match(out.jumpKey[0], /select-pane/, "the jump key must also land on the bar"); } finally { spawnSync("tmux", ["-L", socket, "kill-server"], { encoding: "utf8" }); fs.rmSync(dir, { recursive: true, force: true });