Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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 <name>` 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
Expand Down
7 changes: 5 additions & 2 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,13 @@ export const CORE_CLI_COMMANDS = [
name: "attach",
group: "runtime",
description: "attach this terminal to a herd session",
synopsis: [["moshcode attach <name>", "detach again with Ctrl-b d (or Ctrl-] without tmux)"]],
synopsis: [["moshcode attach <name>", "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.",
Expand Down
133 changes: 129 additions & 4 deletions src/herd-bar.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

/**
Expand Down Expand Up @@ -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 = "";
Expand All @@ -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 = "";
Expand All @@ -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;
Expand All @@ -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) => {
Expand Down
27 changes: 25 additions & 2 deletions src/herd-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 4 additions & 27 deletions src/herd-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 12 additions & 0 deletions src/herd.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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") };
}
Expand Down
Loading
Loading