From df89472d3bba71c19e1f8b4825d08a082e249e6a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 10 Aug 2026 00:05:43 +0000 Subject: [PATCH] feat(herd): the mosh bar under a plain attach too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode attach ` had the same shape of problem the workspace did: once the agent has the keyboard, the "detach with Ctrl-b d" line printed before handover is gone with its first repaint, and there is nothing on screen to replace it. So an attached session now grows the same one-line prompt along the bottom for as long as you are there. It is taken away again on the way out, so a member is a plain member when nobody is looking at it. That teardown is not tidiness: `kill` ends a member by killing its pane, and a session still holding a bar would outlive the member it was named for and keep answering to its name on the roster. `killSession` now also takes the room when the bar is all that is left of it, and `sweepBars` clears what a crashed client could not — skipping any session with a client attached, because that bar is someone else's way out. The jump key had to stop naming a pane. It was bound to the workspace's bar by id, which is the wrong answer from inside an attached member and becomes wronger every time a bar is rebuilt. It is now `select-pane -t {bottom-right}`: in the workspace and under an attach alike, the bar is the bottom row, so one binding finds it in both. The bar asks where it is rather than assuming, and `show` means the right thing in each place — swap the content pane in the workspace, switch the client under an attach, giving that member a bar before you land in it so you never arrive somewhere with no way back out. One thing that only a real client showed: tmux scales panes proportionally on resize, so a bar built against an 80x24 window came back THREE rows tall the moment a client attached at 100x30. Nothing outside the pane can predict when that happens, but the pane gets a resize event for it, so the bar now keeps itself one row. There is a test that stretches the window and fails without the fix. Co-Authored-By: Claude Opus 5 --- README.md | 7 ++ src/cli-schema.mjs | 7 +- src/herd-bar.mjs | 133 +++++++++++++++++++++++++- src/herd-cli.mjs | 27 +++++- src/herd-workspace.mjs | 31 +----- src/herd.mjs | 12 +++ test/herd-bar.test.mjs | 177 ++++++++++++++++++++++++++++++++++- test/herd-workspace.test.mjs | 9 +- 8 files changed, 363 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index b293644..a052ae6 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,13 @@ 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. +**`moshcode attach ` gets the bar too.** A session you attach to directly +grows the same one-line prompt along the bottom for as long as you are there, +and it is taken away again when you detach — so a member is a plain member when +nobody is looking at it. `show ` from that bar switches you to another +member (and gives that one a bar before you land in it). The bar is the bottom +row either way, which is why one key finds it in both places. + 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 15263ce..0487778 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -115,10 +115,13 @@ export const CORE_CLI_COMMANDS = [ name: "attach", group: "runtime", description: "attach this terminal to a herd session", - synopsis: [["moshcode attach ", "detach again with Ctrl-b d (or Ctrl-] without tmux)"]], + synopsis: [["moshcode attach ", "F12 for the mosh bar · Ctrl-b d detaches (Ctrl-] without tmux)"]], examples: [["moshcode attach api", ""]], seeAlso: ["ps", "herd", "kill"], - note: "detaching leaves the session running; ending it is `moshcode kill`. " + note: "under tmux the session gets a one-line mosh bar along the bottom for as long as you are " + + "attached, so the way out is on screen even when the agent has the keyboard: F12 reaches it, " + + "Esc goes back, `detach` leaves. it is taken away again when you detach. " + + "detaching leaves the session running; ending it is `moshcode kill`. " + "the whole herd shares one tmux server, so from inside any session Ctrl-b s picks another, " + "Ctrl-b ) and Ctrl-b ( step through them, and Ctrl-b L goes back to the last one — " + "no switcher under the no-tmux fallback, where Ctrl-] detaches instead.", diff --git a/src/herd-bar.mjs b/src/herd-bar.mjs index 0fae5ce..7da1153 100644 --- a/src/herd-bar.mjs +++ b/src/herd-bar.mjs @@ -54,6 +54,90 @@ export function paneRoles(target, { runner = spawnSync } = {}) { return roles; } +/** How a 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`; +} + +/** The window a pane lives in, as a target string. */ +export function ownTarget({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) { + if (!me) return null; + const r = tmux(["display-message", "-p", "-t", me, "#{session_name}:#{window_index}"], { runner }); + return r.ok ? r.stdout.trim() || null : null; +} + +/** + * Put a bar at the bottom of `target`, or find the one already there. + * + * Idempotent, because both the workspace and every attach want one and neither + * should care which of them got there first. + */ +export function ensureBar(target, { runner = spawnSync, command = null } = {}) { + const existing = paneRoles(target, { runner }).bar; + if (existing) return { paneId: existing.paneId, created: false }; + const made = tmux( + ["split-window", "-t", target, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command], + { runner }, + ); + if (!made.ok) return { paneId: null, created: false }; + const paneId = made.stdout.trim().split("\n")[0]; + if (!paneId) return { paneId: null, created: false }; + tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner }); + return { paneId, created: true }; +} + +/** + * Bind the key that reaches the bar. + * + * `{bottom-right}` rather than a pane id, so one binding serves the workspace + * and every attached session — in both, the bar is the bottom row. A pane id + * would have pinned the key to whichever bar happened to be built last. + * + * The root table is what makes it work at all: tmux claims the key before the + * pane's application ever sees it, which is the whole point when the pane holds + * an agent that has taken the keyboard. + */ +export function bindJumpKey({ runner = spawnSync } = {}) { + return tmux(["bind-key", "-n", BAR_KEY, "select-pane", "-t", "{bottom-right}"], { runner }).ok; +} + +/** Drop the bar from a window, leaving whatever else is in it alone. */ +export function removeBar(target, { runner = spawnSync } = {}) { + const roles = paneRoles(target, { runner }); + if (!roles.bar || !roles.content) return false; + return tmux(["kill-pane", "-t", roles.bar.paneId], { runner }).ok; +} + +/** + * Take the bar back out of every session nobody is looking at. + * + * A bar left behind is not cosmetic: `kill` ends a member by killing its pane, + * so a session holding a leftover bar outlives the member it was named for and + * keeps showing up on the roster. Detaching cleans up after itself, but a + * crashed client cannot, so this also runs on the way in. + * + * Sessions with a client attached are skipped — someone else is using that bar. + */ +export function sweepBars({ runner = spawnSync, except = null } = {}) { + const r = tmux(["list-panes", "-a", "-F", + "#{session_name}\t#{window_index}\t#{pane_title}\t#{session_attached}"], { runner }); + if (!r.ok) return 0; + const windows = new Map(); + for (const line of r.stdout.split("\n")) { + const [session, window, title, attached] = line.split("\t"); + if (!session || session === except || attached !== "0") continue; + const key = `${session}:${window}`; + const seen = windows.get(key) || { bars: 0, others: 0 }; + if (title === BAR_TITLE) seen.bars += 1; else seen.others += 1; + windows.set(key, seen); + } + let removed = 0; + for (const [target, seen] of windows) { + if (seen.bars && seen.others && removeBar(target, { runner })) removed += 1; + } + return removed; +} + /* --------------------------------------------------------------- line editing */ /** @@ -118,10 +202,14 @@ export async function herdBar({ stdin = process.stdin, stdout = process.stdout, runner = spawnSync, - target = "herd:ui", + target = null, run = null, } = {}) { const me = process.env.TMUX_PANE; + // The bar runs in the workspace AND under a plain attach, so it asks where it + // is rather than assuming. Everything below keys off that one answer. + const here = target || ownTarget({ runner, me }) || "herd:ui"; + const inWorkspace = () => !!paneRoles(here, { runner }).sidebar; const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options)); let line = ""; @@ -146,10 +234,26 @@ export async function herdBar({ }; /** Give the keyboard back to whatever is on screen. */ const toContent = () => { - const roles = paneRoles(target, { runner }); + const roles = paneRoles(here, { runner }); if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner }); }; + /** + * `show` means two different things and both are right. + * + * In the workspace it swaps the content pane. Under a plain attach there is + * no content pane to swap, so it switches the client to that member — and + * gives that member a bar first, or you would arrive somewhere with no way + * back out, which is the bug this whole thing exists to fix. + */ + const showElsewhere = async (name) => { + const { paneIndex } = await import("./herd.mjs"); + const found = paneIndex({ runner }).get(name); + if (!found) return false; + ensureBar(`${found.session}:${found.windowId}`, { runner, command: barCommand() }); + return tmux(["switch-client", "-t", found.session], { runner }).ok; + }; + const submit = async () => { const typed = line; line = ""; @@ -160,8 +264,13 @@ export async function herdBar({ 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 }); + let okShown = false; + if (name && inWorkspace()) { + const { showMember } = await import("./herd-workspace.mjs"); + okShown = showMember(name, { runner, me }); + } else if (name) { + okShown = await showElsewhere(name); + } if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; } collapse(); draw(); toContent(); return true; @@ -173,8 +282,24 @@ export async function herdBar({ return true; }; + /** + * Keep the bar one row. + * + * tmux scales panes proportionally when the window resizes, so a bar built + * before a client attached came back three rows tall once one did — the pane + * was created against an 80x24 window and stretched to fit 100x30. Nothing + * outside can predict when that happens, but the bar gets a resize event for + * it, so the bar is the thing that fixes it. + */ + const keepThin = () => { + if (open) return; + tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner }); + }; + try { stdin.setRawMode?.(true); } catch { /* not a tty */ } stdin.resume(); + stdout.on?.("resize", () => { keepThin(); draw(); }); + keepThin(); draw(); await new Promise((resolve) => { diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs index 5eb9527..3bfaefa 100644 --- a/src/herd-cli.mjs +++ b/src/herd-cli.mjs @@ -10,7 +10,7 @@ import path from "node:path"; import { attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET, - herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt, + herdDir, killSession, listSessions, paneIndex, readManifest, rememberSession, sendKeys, sendPrompt, slugifyName, startSession, stopRuntime, substrateNote, validName, NAME_RE, } from "./herd.mjs"; import { clearReport, reportState, STATES, withState } from "./herd-state.mjs"; @@ -328,9 +328,32 @@ export async function herdAttach(argv, { write = console.log } = {}) { // this whole feature is someone quitting a session they meant to leave // running, and the only defence is telling them the key first. const substrate = detectSubstrate(); - write(info(substrate === "tmux" ? "detach with Ctrl-b d — the session keeps running." : "detach with Ctrl-] — the session keeps running.")); + + // Give the session a mosh bar, so the way out is on screen the whole time + // rather than in a line that the agent's first repaint scrolls away. Only a + // member sitting in its own session: a tiled one shares a window with its + // neighbours and would be handing them a footer they did not ask for. + const bar = await import("./herd-bar.mjs"); + let barTarget = null; + if (substrate === "tmux") { + bar.sweepBars({ runner: undefined, except: "herd" }); + const found = paneIndex().get(name); + if (found && found.session === name) { + barTarget = `${found.session}:${found.windowId}`; + bar.ensureBar(barTarget, { command: bar.barCommand() }); + bar.bindJumpKey({}); + } + } + + write(info(substrate === "tmux" + ? `detach with Ctrl-b d — the session keeps running.${barTarget ? ` ${bar.BAR_KEY} for the mosh bar.` : ""}` + : "detach with Ctrl-] — the session keeps running.")); const result = await attachSession(name, { substrate }); + // Take it back out on the way through, so a member is a member again: `kill` + // ends one by killing its pane, and a session still holding a bar would + // outlive the member and keep its name on the roster. + if (barTarget) bar.removeBar(barTarget, {}); if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; } const after = findSession(name); diff --git a/src/herd-workspace.mjs b/src/herd-workspace.mjs index 9441a5f..349f2ab 100644 --- a/src/herd-workspace.mjs +++ b/src/herd-workspace.mjs @@ -21,7 +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 { BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, barCommand, bindJumpKey, ensureBar, paneRoles } from "./herd-bar.mjs"; import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs"; export const WORKSPACE = "herd"; @@ -82,34 +82,11 @@ 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. - */ +/** Add the one-line mosh prompt under the content, and the key that reaches it. */ 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]; + const { paneId } = ensureBar(TARGET, { runner, command }); 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 }); + bindJumpKey({ runner }); tmux(["select-pane", "-t", `${TARGET}.0`], { runner }); return paneId; } diff --git a/src/herd.mjs b/src/herd.mjs index 169bb4a..d05463d 100644 --- a/src/herd.mjs +++ b/src/herd.mjs @@ -629,7 +629,19 @@ export function killSession(name, { substrate = detectSubstrate(), runner = spaw if (substrate === "tmux") { // kill-pane, not kill-session: a tiled member shares its session with // every other tiled member, and killing that would take the lot. + const found = paneIndex({ runner }).get(name); const r = tmux(["kill-pane", "-t", target(name, { runner })], { runner }); + // A member being attached to has a mosh bar under it, and the bar would + // hold the session open after its member is gone — an empty room still + // answering to the dead member's name on the roster. If that is all that is + // left, take the room too. + if (r.ok && found) { + const left = tmux(["list-panes", "-t", found.session, "-F", "#{pane_title}"], { runner }); + const titles = left.ok ? left.stdout.split("\n").filter(Boolean) : []; + if (titles.length && titles.every((t) => t === "mosh-bar")) { + tmux(["kill-session", "-t", found.session], { runner }); + } + } forgetSession(name); return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") }; } diff --git a/test/herd-bar.test.mjs b/test/herd-bar.test.mjs index 66b4230..868aee8 100644 --- a/test/herd-bar.test.mjs +++ b/test/herd-bar.test.mjs @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; import { BAR_HEIGHT, BAR_KEY, BAR_TITLE, HINT, SIDEBAR_TITLE, - editLine, helpLines, paneRoles, renderPrompt, resolveCommand, + editLine, ensureBar, helpLines, paneRoles, removeBar, renderPrompt, resolveCommand, sweepBars, } from "../src/herd-bar.mjs"; const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); @@ -144,6 +144,181 @@ test("the sidebar and the bar are told apart by title, not position", (t) => { } }); +/* ------------------------------------------------------- keeping it tidy */ + +/** A tmux stand-in that answers each subcommand from a script. */ +const fakeTmux = (answers) => { + const calls = []; + const runner = (_cmd, args) => { + const verb = args[2]; // after -L + calls.push(args.slice(2)); + const answer = answers[verb]; + const stdout = typeof answer === "function" ? answer(args) : (answer ?? ""); + return { status: 0, stdout, stderr: "" }; + }; + return { runner, calls }; +}; + +test("sweepBars leaves alone any session someone is attached to", () => { + // A bar belongs to whoever is looking at it. Reaping one out from under + // another terminal would take away the only way out that terminal has. + const { runner, calls } = fakeTmux({ + "list-panes": (args) => (args.includes("-a") + ? [ + `watched\t0\tapi\t1`, + `watched\t0\t${BAR_TITLE}\t1`, + `idle\t0\tweb\t0`, + `idle\t0\t${BAR_TITLE}\t0`, + ].join("\n") + : [`%1\tweb`, `%2\t${BAR_TITLE}`].join("\n")), + "kill-pane": "", + }); + const removed = sweepBars({ runner }); + assert.equal(removed, 1, "only the unattached session's bar comes out"); + const killed = calls.filter((c) => c[0] === "kill-pane"); + assert.equal(killed.length, 1); +}); + +test("sweepBars skips the workspace, whose bar is permanent", () => { + const { runner } = fakeTmux({ + "list-panes": (args) => (args.includes("-a") + ? [`herd\t0\tapi\t0`, `herd\t0\t${BAR_TITLE}\t0`].join("\n") + : ""), + }); + assert.equal(sweepBars({ runner, except: "herd" }), 0); +}); + +test("a window that is only a bar is left as it is", () => { + // Nothing to give the pane back to: killing the last pane would take the + // session, and sweeping is meant to be a tidy-up, not a kill. + const { runner } = fakeTmux({ + "list-panes": (args) => (args.includes("-a") + ? [`stale\t0\t${BAR_TITLE}\t0`].join("\n") + : `%9\t${BAR_TITLE}`), + }); + assert.equal(sweepBars({ runner }), 0); +}); + +test("removeBar refuses to empty a window", () => { + const { runner, calls } = fakeTmux({ "list-panes": `%9\t${BAR_TITLE}`, "kill-pane": "" }); + assert.equal(removeBar("solo:0", { runner }), false); + assert.equal(calls.filter((c) => c[0] === "kill-pane").length, 0); +}); + +test("ensureBar does not add a second bar", () => { + const { runner, calls } = fakeTmux({ "list-panes": `%1\tapi\n%2\t${BAR_TITLE}` }); + const result = ensureBar("api:0", { runner, command: "true" }); + assert.deepEqual(result, { paneId: "%2", created: false }); + assert.equal(calls.filter((c) => c[0] === "split-window").length, 0, "it must not split again"); +}); + +test("a bar under an attached member goes on and comes off cleanly", (t) => { + if (!hasTmux) { t.skip("no tmux on this machine"); return; } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-bar-test-")); + const socket = `moshcode-barlive-${process.pid}`; + const env = { ...process.env, MOSHCODE_HERD_DIR: dir, MOSHCODE_HERD_SOCKET: socket, MOSHCODE_HERD: "tmux" }; + + try { + const script = ` + const herd = await import(${JSON.stringify(path.join(ROOT, "src", "herd.mjs"))}); + const bar = await import(${JSON.stringify(path.join(ROOT, "src", "herd-bar.mjs"))}); + + for (const n of ["api", "web"]) { + herd.startSession({ name: n, engine: "test", bin: "sh", + args: ["-c", "echo MARK-" + n + "; while read x; do :; done"], cwd: process.cwd() }); + } + await new Promise((r) => setTimeout(r, 900)); + + const at = (n) => { const f = herd.paneIndex({}).get(n); return f && f.session + ":" + f.windowId; }; + const heightOf = (n) => herd.tmux(["list-panes", "-t", at(n), "-F", "#{pane_title}:#{pane_height}"]).stdout + .trim().split("\\n").find((l) => l.startsWith(n + ":")); + + const before = heightOf("api"); + bar.ensureBar(at("api"), { command: "sh -c 'while read x; do :; done'" }); + const withBar = herd.tmux(["list-panes", "-t", at("api"), "-F", "#{pane_title}"]).stdout.trim().split("\\n"); + const twice = bar.ensureBar(at("api"), { command: "sh -c 'while read x; do :; done'" }); + + // the member is untouched by the pane arriving beside it + const stillThere = herd.capture("api", { lines: 20 }).includes("MARK-api"); + + bar.removeBar(at("api"), {}); + const after = heightOf("api"); + + // and with a bar still up, killing the member must not leave the session + bar.ensureBar(at("web"), { command: "sh -c 'while read x; do :; done'" }); + herd.killSession("web"); + await new Promise((r) => setTimeout(r, 300)); + const sessions = herd.tmux(["list-sessions", "-F", "#{session_name}"]).stdout.trim().split("\\n").filter(Boolean); + + console.log(JSON.stringify({ before, withBar, twiceCreated: twice.created, stillThere, after, sessions })); + `; + const run = spawnSync(process.execPath, ["--input-type=module", "-e", script], { env, encoding: "utf8", cwd: ROOT }); + assert.equal(run.status, 0, `bar round trip crashed: ${run.stderr}`); + const out = JSON.parse(run.stdout.trim().split("\n").pop()); + + assert.ok(out.withBar.includes(BAR_TITLE), "the bar must actually arrive"); + assert.equal(out.twiceCreated, false, "a second attach must reuse the bar, not stack another"); + assert.equal(out.stillThere, true, "the member keeps its scrollback while the bar is up"); + // The member gives up a row for the bar and gets it back afterwards; if it + // does not, every attach shrinks the session a little more. + assert.equal(out.after, out.before, `member did not get its height back: ${out.before} → ${out.after}`); + // The orphan: a session outliving the member it was named for. + assert.ok(!out.sessions.includes("web"), `killing a member with a bar left ${JSON.stringify(out.sessions)}`); + assert.ok(out.sessions.includes("api"), "the other member must be untouched"); + } finally { + spawnSync("tmux", ["-L", socket, "kill-server"], { encoding: "utf8" }); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("the bar stays one row when the window is resized under it", (t) => { + if (!hasTmux) { t.skip("no tmux on this machine"); return; } + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-barsize-test-")); + const socket = `moshcode-barsize-${process.pid}`; + const env = { ...process.env, MOSHCODE_HERD_DIR: dir, MOSHCODE_HERD_SOCKET: socket, MOSHCODE_HERD: "tmux" }; + + try { + const script = ` + const herd = await import(${JSON.stringify(path.join(ROOT, "src", "herd.mjs"))}); + const bar = await import(${JSON.stringify(path.join(ROOT, "src", "herd-bar.mjs"))}); + const cli = ${JSON.stringify(path.join(ROOT, "bin", "moshcode.mjs"))}; + + herd.startSession({ name: "api", engine: "test", bin: "sh", + args: ["-c", "while read x; do :; done"], cwd: process.cwd() }); + await new Promise((r) => setTimeout(r, 800)); + + const f = herd.paneIndex({}).get("api"); + const at = f.session + ":" + f.windowId; + herd.tmux(["resize-window", "-t", at, "-x", "80", "-y", "24"]); + bar.ensureBar(at, { command: process.execPath + " " + cli + " herd bar" }); + await new Promise((r) => setTimeout(r, 2000)); + const small = herd.tmux(["list-panes", "-t", at, "-F", "#{pane_title}:#{pane_height}"]).stdout.trim(); + + // the resize that used to stretch it + herd.tmux(["resize-window", "-t", at, "-x", "120", "-y", "48"]); + await new Promise((r) => setTimeout(r, 2000)); + const big = herd.tmux(["list-panes", "-t", at, "-F", "#{pane_title}:#{pane_height}"]).stdout.trim(); + const shown = herd.tmux(["capture-pane", "-p", "-t", + bar.paneRoles(at, {}).bar.paneId]).stdout.trim(); + + console.log(JSON.stringify({ small, big, shown })); + `; + const run = spawnSync(process.execPath, ["--input-type=module", "-e", script], { env, encoding: "utf8", cwd: ROOT }); + assert.equal(run.status, 0, `bar resize check crashed: ${run.stderr}`); + const out = JSON.parse(run.stdout.trim().split("\n").pop()); + + const height = (state) => state.split("\n").find((l) => l.startsWith(`${BAR_TITLE}:`))?.split(":")[1]; + assert.equal(height(out.small), "1", `bar was not one row to begin with: ${out.small}`); + // tmux scales panes proportionally on resize, which stretched the bar to + // three rows the first time a client attached at a different size. + assert.equal(height(out.big), "1", `bar stretched on resize: ${out.big}`); + assert.match(out.shown, /mosh/, "and it must still be drawing the prompt afterwards"); + } finally { + spawnSync("tmux", ["-L", socket, "kill-server"], { encoding: "utf8" }); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + 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. diff --git a/test/herd-workspace.test.mjs b/test/herd-workspace.test.mjs index 011a2b7..4577524 100644 --- a/test/herd-workspace.test.mjs +++ b/test/herd-workspace.test.mjs @@ -140,11 +140,12 @@ test("swapping the content pane leaves the sidebar in place", (t) => { 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. + // One binding, and it must resolve the bar positionally: the same key has + // to work in the workspace and under a plain attach, and a pane id would + // have pinned it to whichever bar was built last. 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"); + assert.match(out.jumpKey[0], /select-pane/, "the jump key must land on the bar"); + assert.match(out.jumpKey[0], /\{bottom-right\}/, "the jump key must not be pinned to one pane id"); } finally { spawnSync("tmux", ["-L", socket, "kill-server"], { encoding: "utf8" }); fs.rmSync(dir, { recursive: true, force: true });