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
43 changes: 41 additions & 2 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,16 @@ export const CORE_CLI_COMMANDS = [
group: "runtime",
description: "run agent sessions that outlive this terminal",
synopsis: [
["moshcode herd", "the roster — same as moshcode ps"],
["moshcode herd", "open the workspace, or the roster when there is no terminal to open it in"],
["moshcode herd <verb> [args…]", "drive one session"],
],
verbs: "HERD_VERBS",
flags: [["--json", "machine-readable, on every verb", ""]],
examples: [
["moshcode herd ui", "the clickable list — start here"],
["moshcode herd", "the workspace: members down the left, one of them live on the right"],
["moshcode herd ui", "the same thing, said out loud"],
["moshcode herd | cat", "into a pipe it is the roster instead, so scripts keep working"],
["moshcode herd --json", "and that roster as data"],
["", ""],
["# a workspace: two shells and an agent, none of which die with this terminal", ""],
["moshcode herd shell --name work", "a plain $SHELL you can come back to"],
Expand All @@ -91,17 +94,53 @@ export const CORE_CLI_COMMANDS = [
["moshcode ps", "all three, and which one is blocked"],
["moshcode attach api", "step in · Ctrl-b s switches · Ctrl-b d leaves it running"],
["", ""],
["# every member on screen at once, instead of one at a time", ""],
["moshcode herd tile", "one tiled window · z zooms one · B pops it back out"],
["moshcode herd tile api", "only the members in the api herd"],
["moshcode herd untile", "each of them back in a session of its own"],
["", ""],
["# the bar: the command line that survives being inside an agent", ""],
["moshcode herd bar", "a one-row mosh prompt under the content pane"],
["F12", "reaches it from anywhere, including from inside claude"],
["", ""],
["# an agent moshcode has no install spec for", ""],
["moshcode herd run --name cur -- cursor-agent", "anything at all runs in the herd"],
["", ""],
["# driving one without attaching", ""],
["moshcode herd prompt api \"run the tests\" --wait", "hand it work, block until it lands"],
["moshcode herd read api --lines 40", "read its screen"],
["moshcode herd send-keys api C-c", "the keys themselves, when a prompt is not what is wanted"],
["moshcode herd wait api --timeout 20m", "block here until it stops working"],
["moshcode herd kill api", "end it · --all ends the herd"],
["", ""],
["# what it is costing", ""],
["moshcode herd cost", "per session, read out of the engines' own logs"],
["moshcode herd cost api --since 6h", "one member, this afternoon"],
["moshcode herd cost --watch 30", "re-read every 30s while a swarm runs"],
["", ""],
["# what happened while you slept", ""],
["moshcode herd hooks install claude", "state from the engine, not from its screen"],
["moshcode herd doctor", "which engines report, which are still being guessed at"],
["moshcode herd tasks api", "every prompt, and how long each one waited on you"],
["moshcode herd log api --since 12h", "the same run, as a transcript"],
["moshcode herd stats", "how much of the night was spent blocked on you"],
["moshcode herd watch --notify", "tell me when one of them stops and wants something"],
["", ""],
["# a swarm is a herd with a plan, and the same verbs reach into it", ""],
["moshcode swarm \"port the parser to rust\" --agents 4 --herd port", "fan out"],
["moshcode herd ps --json | jq -r '.[] | select(.herd == \"port\") | .name'", "its members"],
["moshcode herd read port-2 --lines 60", "look inside one of them, without attaching"],
["moshcode fleet tree", "the same run as a fleet, with the ledger under it"],
["", ""],
["# agents that are not on this machine", ""],
["moshcode herd remote add research https://agents.do-ai.run/…/production", "a deployed agent, same verbs"],
["moshcode herd prompt research \"summarise the RFC\"", "which is the point: one surface, wherever it runs"],
["moshcode herd serve --port 7717", "and this box answering the same verbs for someone else"],
["", ""],
["# after a reboot", ""],
["moshcode herd restore", "start again everything the manifest remembers"],
["moshcode herd prune", "forget the ones that are never coming back"],
["moshcode herd wait --all", "then block until the herd is quiet"],
],
seeAlso: ["ps", "attach", "wait", "restore", "start"],
note: "`start` is for the engines moshcode installs; `run` and `shell` take anything else, "
Expand Down
38 changes: 38 additions & 0 deletions src/herd-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1788,8 +1788,46 @@ const VERBS = {
remote: herdRemote, serve: herdServe, eval: herdEval,
};

/**
* Can a full-screen UI safely take this terminal?
*
* Three questions, and all three have to be yes. `--json` and a named verb are
* handled by the caller; this is only about the surface the answer goes to.
*
* `write` is the interesting one. herdCommand takes an injected writer, and
* everything that injects one is a place where output is CAPTURED rather than
* shown: the mosh bar renders herd output into a one-row pane, tests collect it
* into an array, and a caller collecting lines cannot be handed a program that
* paints the whole screen and waits for a click. So a writer that is not the
* default is treated exactly like a pipe.
*/
export function canOpenUi({ write = console.log, stdout = process.stdout } = {}) {
return write === console.log && Boolean(stdout.isTTY) && Boolean(process.stdin.isTTY);
}

/** The verbs that hand this terminal to something full screen. */
export const TERMINAL_VERBS = new Set(["ui", "sidebar", "bar", "tile", "attach"]);

/**
* Does this argv take the terminal? The pit asks before dispatching, because a
* readline interface still holding stdin fights tmux for every keystroke, the
* same reason `/attach` and `/ssh shell` close it first. Bare `herd` is on this
* list exactly when it would open the workspace, which is the same question
* canOpenUi answers.
*/
export function takesTerminal(argv = [], options = {}) {
const [verb] = argv;
if (!verb) return canOpenUi(options);
return TERMINAL_VERBS.has(verb);
}

export async function herdCommand(argv = [], { write = console.log } = {}) {
const [verb, ...rest] = argv;
// Bare `moshcode herd` opens the workspace. Someone typing the noun with no
// verb wants to SEE the herd, and the roster is the answer to that question
// only for something that cannot show them a herd: a pipe, a CI log, the
// bar. `moshcode ps` is still the roster, and still one word.
if (!verb && canOpenUi({ write })) return VERBS.ui([], { write });
if (!verb || verb === "--json") return herdPs(argv, { write });
const run = VERBS[verb];
if (!run) {
Expand Down
56 changes: 43 additions & 13 deletions src/herd-ui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ const ESC = {
// 1000 = report button press/release, 1006 = SGR encoding, which is the only
// one that survives past column 95 — the older scheme packs coordinates into
// single bytes and simply cannot express a click on a wide terminal.
mouseOn: "\x1b[?1000h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1000l",
// 1003 as well, so the pointer's position is reported while no button is
// held. Without it there is no hover, and with no hover a click has to be
// spent moving the highlight before a second one can open anything.
mouseOn: "\x1b[?1000h\x1b[?1003h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1003l\x1b[?1000l",
clear: "\x1b[2J\x1b[H",
};

Expand Down Expand Up @@ -125,7 +128,7 @@ export function render(rows, { selected = 0, width = 80, substrate = "tmux" } =
// ever go in. The old single crammed line never mentioned it at all, so
// clicking into a session was a one-way door as far as the screen was
// concerned.
out.push(` ${ash("move")} ${dim("click · ↑↓ · wheel")} ${ash("open")} ${dim("enter or double-click")}`);
out.push(` ${ash("move")} ${dim("hover · ↑↓ · wheel")} ${ash("open")} ${dim("one click, or enter")}`);
out.push(` ${ash("back")} ${dim("Ctrl-b d from inside")} ${ash("also")} ${dim("t tile all · r refresh · q quit")}`);
if (substrate !== "tmux") out.push(` ${dim(substrateNote(substrate) || "")}`);
return out.join("\r\n");
Expand All @@ -151,12 +154,29 @@ export function parseMouse(sequence) {
const b = Number(button);
if (b === 64) return { kind: "wheel", direction: -1 };
if (b === 65) return { kind: "wheel", direction: 1 };
// Bit 5 (32) means "this is motion, not a press". Only a program that asked
// for 1002 or 1003 ever sees these, so decoding them here costs the list
// nothing and is the whole of what a hover highlight needs: the sidebar turns
// motion reporting on and lights the row the pointer is over.
if (b & 32) return { kind: "move", col: Number(col), row: Number(row) };
if (b !== 0) return null;
return { kind: "click", col: Number(col), row: Number(row) };
}

/** Every mouse report in a chunk, so a fast click-drag cannot desync the parser. */
export function parseInput(buffer) {
/** The keys the list reads. A surface with different shortcuts passes its own. */
export const LIST_KEYS = ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "t", "j", "k"];

/**
* Every mouse report in a chunk, so a fast click-drag cannot desync the parser.
*
* `keys` is a parameter rather than the constant it used to be because the
* sidebar advertises shortcuts the list has never had (`s`, `a`, `x`), and a
* shared hard-coded list meant those rows printed a key that did nothing: the
* sidebar's own handler was reached for a letter this function never emitted.
* Passing the set in keeps one parser without giving one surface the other's
* bindings.
*/
export function parseInput(buffer, { keys = LIST_KEYS } = {}) {
const events = [];
const text = String(buffer);
const mouse = /\x1b\[<\d+;\d+;\d+[Mm]/g;
Expand All @@ -166,7 +186,7 @@ export function parseInput(buffer) {
if (parsed) events.push(parsed);
}
if (events.length) return events;
for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "t", "j", "k"]) {
for (const key of keys) {
if (text.includes(key)) events.push({ kind: "key", key });
}
return events;
Expand Down Expand Up @@ -285,18 +305,28 @@ export async function herdUi({
continue;
}
else if (event.kind === "wheel") selected = moveSelection(rows, selected, event.direction);
else if (event.kind === "move") {
// Hover. This is what pointing at a row to read it looks like, and it
// is the reason a click no longer has to be spent on selecting: the
// highlight follows the pointer for free.
const over = rows.find((r) => r.kind === "session" && r.line === event.row);
if (!over) continue;
const index = rows.indexOf(over);
if (index === selected) continue;
selected = index;
draw();
continue;
}
else if (event.kind === "click") {
const hit = rows.find((r) => r.kind === "session" && r.line === event.row);
if (!hit) continue;
const index = rows.indexOf(hit);
// A single click SELECTS; only a second click on the row already
// selected opens it. Opening on first click made one stray click a
// one-way trip into a session, which is most of why this felt bad to
// navigate — you could not point at a row to read it.
const opening = index === selected;
selected = index;
// ONE click opens it. This used to select on the first click and open
// on a second click of the same row, which is the double-click
// affordance Anthony rejected outright in diskpush 0.7.0. Hover above
// does the browsing that argument was really about.
selected = rows.indexOf(hit);
draw();
if (opening) await openSelected();
await openSelected();
continue;
}
else if (event.kind === "key") { await openSelected(); continue; }
Expand Down
Loading
Loading