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 });