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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,42 @@ moshcode herd ui
│ ✕ stop │ │
│ ⊞ tile │ │
│ ← detach │ │
└────────────┴───────────────────────────────────┘
│ │ │
│ enter ▸ … │ │
│ F12 ▸ … │ │
├────────────┴───────────────────────────────────┤
│ mosh ▸ ps · start claude · show <n> · 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
Expand Down
10 changes: 9 additions & 1 deletion src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>] -- <command…>", "everything after -- is the command"]],
flags: [
Expand Down
196 changes: 196 additions & 0 deletions src/herd-bar.mjs
Original file line number Diff line number Diff line change
@@ -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 <n> · kill <n> · 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 <name> put it on screen shell new shell",
" kill <name> end one tile all at once",
" read <name> its last screen prompt <n> <text> 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;
}
5 changes: 3 additions & 2 deletions src/herd-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading