moshcode 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/package.json +1 -1
- package/src/cli-schema.mjs +14 -3
- package/src/herd-bar.mjs +321 -0
- package/src/herd-cli.mjs +28 -4
- package/src/herd-workspace.mjs +63 -12
- package/src/herd.mjs +12 -0
package/README.md
CHANGED
|
@@ -183,13 +183,49 @@ moshcode herd ui
|
|
|
183
183
|
│ ✕ stop │ │
|
|
184
184
|
│ ⊞ tile │ │
|
|
185
185
|
│ ← detach │ │
|
|
186
|
-
|
|
186
|
+
│ │ │
|
|
187
|
+
│ enter ▸ … │ │
|
|
188
|
+
│ F12 ▸ … │ │
|
|
189
|
+
├────────────┴───────────────────────────────────┤
|
|
190
|
+
│ mosh ▸ ps · start claude · show <n> · detach │
|
|
191
|
+
└────────────────────────────────────────────────┘
|
|
187
192
|
```
|
|
188
193
|
|
|
189
194
|
Members and actions down the left, the selected member's **real terminal** on
|
|
190
195
|
the right. Click a member to show it; click an action to start a shell, start an
|
|
191
196
|
agent, or stop the selected one. `q` detaches and leaves everything running.
|
|
192
197
|
|
|
198
|
+
Click the member that is already on screen — or press Enter — and the keyboard
|
|
199
|
+
goes to it, so you are typing at the agent itself.
|
|
200
|
+
|
|
201
|
+
### The mosh bar
|
|
202
|
+
|
|
203
|
+
The row along the bottom is a mosh prompt, and it is always there. **F12** jumps
|
|
204
|
+
to it from anywhere, including from inside an agent that has taken the keyboard,
|
|
205
|
+
which makes it the way out of a session you cannot otherwise leave. Esc goes
|
|
206
|
+
back to the session; `detach` leaves with everything still running.
|
|
207
|
+
|
|
208
|
+
It takes any `moshcode herd` verb, so you can start a second agent without
|
|
209
|
+
leaving the first:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
mosh ▸ start claude # another agent, now on screen
|
|
213
|
+
mosh ▸ show api # put a different member up
|
|
214
|
+
mosh ▸ ps # the roster, over the session, then out of the way
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`attach` means `show` here — in a workspace the word means "put it in the
|
|
218
|
+
content pane", and the real attach would be a tmux client inside a tmux client.
|
|
219
|
+
Output grows the bar over the content for as long as you are reading it, then it
|
|
220
|
+
collapses back to one row.
|
|
221
|
+
|
|
222
|
+
**`moshcode attach <name>` gets the bar too.** A session you attach to directly
|
|
223
|
+
grows the same one-line prompt along the bottom for as long as you are there,
|
|
224
|
+
and it is taken away again when you detach — so a member is a plain member when
|
|
225
|
+
nobody is looking at it. `show <name>` from that bar switches you to another
|
|
226
|
+
member (and gives that one a bar before you land in it). The bar is the bottom
|
|
227
|
+
row either way, which is why one key finds it in both places.
|
|
228
|
+
|
|
193
229
|
The right-hand pane is not a picture of a session — it *is* the session's pane,
|
|
194
230
|
moved in. tmux's model is session → window → pane, so moving between *windows*
|
|
195
231
|
cannot keep anything on screen; but `join-pane` moves a running pane into an
|
package/package.json
CHANGED
package/src/cli-schema.mjs
CHANGED
|
@@ -115,10 +115,13 @@ export const CORE_CLI_COMMANDS = [
|
|
|
115
115
|
name: "attach",
|
|
116
116
|
group: "runtime",
|
|
117
117
|
description: "attach this terminal to a herd session",
|
|
118
|
-
synopsis: [["moshcode attach <name>", "
|
|
118
|
+
synopsis: [["moshcode attach <name>", "F12 for the mosh bar · Ctrl-b d detaches (Ctrl-] without tmux)"]],
|
|
119
119
|
examples: [["moshcode attach api", ""]],
|
|
120
120
|
seeAlso: ["ps", "herd", "kill"],
|
|
121
|
-
note: "
|
|
121
|
+
note: "under tmux the session gets a one-line mosh bar along the bottom for as long as you are "
|
|
122
|
+
+ "attached, so the way out is on screen even when the agent has the keyboard: F12 reaches it, "
|
|
123
|
+
+ "Esc goes back, `detach` leaves. it is taken away again when you detach. "
|
|
124
|
+
+ "detaching leaves the session running; ending it is `moshcode kill`. "
|
|
122
125
|
+ "the whole herd shares one tmux server, so from inside any session Ctrl-b s picks another, "
|
|
123
126
|
+ "Ctrl-b ) and Ctrl-b ( step through them, and Ctrl-b L goes back to the last one — "
|
|
124
127
|
+ "no switcher under the no-tmux fallback, where Ctrl-] detaches instead.",
|
|
@@ -745,9 +748,17 @@ export const HERD_VERBS = [
|
|
|
745
748
|
{ name: "untile", description: "put tiled members back in their own sessions",
|
|
746
749
|
synopsis: [["moshcode herd untile", ""]] },
|
|
747
750
|
{ name: "ui", description: "sidebar of members and actions, selected one beside it",
|
|
748
|
-
synopsis: [["moshcode herd ui", "click a member to show it ·
|
|
751
|
+
synopsis: [["moshcode herd ui", "click a member to show it · click it again to type in it · F12 for the mosh bar"]],
|
|
749
752
|
flags: [],
|
|
750
753
|
examples: [["moshcode herd ui", "the workspace — start here"]] },
|
|
754
|
+
{ name: "bar", description: "the one-line mosh prompt under the session (runs inside the workspace)",
|
|
755
|
+
synopsis: [["moshcode herd bar", "F12 reaches it from inside an agent · Esc goes back · detach leaves"]],
|
|
756
|
+
flags: [],
|
|
757
|
+
examples: [
|
|
758
|
+
["F12", "jump to the bar from anywhere, even mid-agent"],
|
|
759
|
+
["start claude", "another agent, without leaving this one"],
|
|
760
|
+
["show api", "put a different member on screen"],
|
|
761
|
+
] },
|
|
751
762
|
{ name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
|
|
752
763
|
synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
|
|
753
764
|
flags: [
|
package/src/herd-bar.mjs
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// `moshcode herd bar` — a one-line mosh prompt pinned under the session.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The workspace put a real agent in the content pane, which is
|
|
4
|
+
// the point of it — but a real agent takes the keyboard. Click into claude and
|
|
5
|
+
// every sidebar key stops working, and with tmux's status line off there is
|
|
6
|
+
// nothing on screen that says how to get back out. You are looking at one agent
|
|
7
|
+
// with no visible way to leave it, which is exactly the complaint.
|
|
8
|
+
//
|
|
9
|
+
// A status line would have been the small fix: one line of text that never goes
|
|
10
|
+
// away. But a line you can only read answers "how do I get out" and nothing
|
|
11
|
+
// else — you still cannot start a second agent without leaving first. So the
|
|
12
|
+
// line takes input. It is the same surface as the CLI (every `moshcode herd`
|
|
13
|
+
// verb works here) which means the escape hatch and the command line are one
|
|
14
|
+
// thing rather than two.
|
|
15
|
+
//
|
|
16
|
+
// The bar is one row until it has something to say, then it grows over the
|
|
17
|
+
// content, then it collapses again. Output has to go somewhere, and stealing
|
|
18
|
+
// rows from the agent for a moment is cheaper than a pane that is mostly empty.
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
import { tmux } from "./herd.mjs";
|
|
22
|
+
import { acid, ash, bone } from "./ui.mjs";
|
|
23
|
+
|
|
24
|
+
export const BAR_TITLE = "mosh-bar";
|
|
25
|
+
export const SIDEBAR_TITLE = "herd";
|
|
26
|
+
export const BAR_HEIGHT = 1;
|
|
27
|
+
export const BAR_OPEN_HEIGHT = 14;
|
|
28
|
+
|
|
29
|
+
/** The key that reaches the bar from anywhere, including from inside an agent. */
|
|
30
|
+
export const BAR_KEY = "F12";
|
|
31
|
+
|
|
32
|
+
export const HINT = "ps · start claude · show <n> · kill <n> · detach · help";
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------- pane geometry */
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Which pane is which, by title.
|
|
38
|
+
*
|
|
39
|
+
* Titles rather than indexes or ids: a pane keeps its title across `join-pane`,
|
|
40
|
+
* which is the whole reason the workspace can move panes around at all, and
|
|
41
|
+
* indexes shift every time one arrives or leaves.
|
|
42
|
+
*/
|
|
43
|
+
export function paneRoles(target, { runner = spawnSync } = {}) {
|
|
44
|
+
const roles = { sidebar: null, content: null, bar: null };
|
|
45
|
+
const r = tmux(["list-panes", "-t", target, "-F", "#{pane_id}\t#{pane_title}"], { runner });
|
|
46
|
+
if (!r.ok) return roles;
|
|
47
|
+
for (const line of r.stdout.split("\n")) {
|
|
48
|
+
const [paneId, title] = line.split("\t");
|
|
49
|
+
if (!paneId) continue;
|
|
50
|
+
if (title === BAR_TITLE) roles.bar = { paneId, title };
|
|
51
|
+
else if (title === SIDEBAR_TITLE) roles.sidebar = { paneId, title };
|
|
52
|
+
else roles.content = { paneId, title };
|
|
53
|
+
}
|
|
54
|
+
return roles;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How a bar pane starts itself. Separated so tests can run a stand-in. */
|
|
58
|
+
export function barCommand(self = process.argv[1]) {
|
|
59
|
+
return `${process.execPath} ${self} herd bar`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The window a pane lives in, as a target string. */
|
|
63
|
+
export function ownTarget({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
64
|
+
if (!me) return null;
|
|
65
|
+
const r = tmux(["display-message", "-p", "-t", me, "#{session_name}:#{window_index}"], { runner });
|
|
66
|
+
return r.ok ? r.stdout.trim() || null : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Put a bar at the bottom of `target`, or find the one already there.
|
|
71
|
+
*
|
|
72
|
+
* Idempotent, because both the workspace and every attach want one and neither
|
|
73
|
+
* should care which of them got there first.
|
|
74
|
+
*/
|
|
75
|
+
export function ensureBar(target, { runner = spawnSync, command = null } = {}) {
|
|
76
|
+
const existing = paneRoles(target, { runner }).bar;
|
|
77
|
+
if (existing) return { paneId: existing.paneId, created: false };
|
|
78
|
+
const made = tmux(
|
|
79
|
+
["split-window", "-t", target, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
|
|
80
|
+
{ runner },
|
|
81
|
+
);
|
|
82
|
+
if (!made.ok) return { paneId: null, created: false };
|
|
83
|
+
const paneId = made.stdout.trim().split("\n")[0];
|
|
84
|
+
if (!paneId) return { paneId: null, created: false };
|
|
85
|
+
tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner });
|
|
86
|
+
return { paneId, created: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bind the key that reaches the bar.
|
|
91
|
+
*
|
|
92
|
+
* `{bottom-right}` rather than a pane id, so one binding serves the workspace
|
|
93
|
+
* and every attached session — in both, the bar is the bottom row. A pane id
|
|
94
|
+
* would have pinned the key to whichever bar happened to be built last.
|
|
95
|
+
*
|
|
96
|
+
* The root table is what makes it work at all: tmux claims the key before the
|
|
97
|
+
* pane's application ever sees it, which is the whole point when the pane holds
|
|
98
|
+
* an agent that has taken the keyboard.
|
|
99
|
+
*/
|
|
100
|
+
export function bindJumpKey({ runner = spawnSync } = {}) {
|
|
101
|
+
return tmux(["bind-key", "-n", BAR_KEY, "select-pane", "-t", "{bottom-right}"], { runner }).ok;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Drop the bar from a window, leaving whatever else is in it alone. */
|
|
105
|
+
export function removeBar(target, { runner = spawnSync } = {}) {
|
|
106
|
+
const roles = paneRoles(target, { runner });
|
|
107
|
+
if (!roles.bar || !roles.content) return false;
|
|
108
|
+
return tmux(["kill-pane", "-t", roles.bar.paneId], { runner }).ok;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Take the bar back out of every session nobody is looking at.
|
|
113
|
+
*
|
|
114
|
+
* A bar left behind is not cosmetic: `kill` ends a member by killing its pane,
|
|
115
|
+
* so a session holding a leftover bar outlives the member it was named for and
|
|
116
|
+
* keeps showing up on the roster. Detaching cleans up after itself, but a
|
|
117
|
+
* crashed client cannot, so this also runs on the way in.
|
|
118
|
+
*
|
|
119
|
+
* Sessions with a client attached are skipped — someone else is using that bar.
|
|
120
|
+
*/
|
|
121
|
+
export function sweepBars({ runner = spawnSync, except = null } = {}) {
|
|
122
|
+
const r = tmux(["list-panes", "-a", "-F",
|
|
123
|
+
"#{session_name}\t#{window_index}\t#{pane_title}\t#{session_attached}"], { runner });
|
|
124
|
+
if (!r.ok) return 0;
|
|
125
|
+
const windows = new Map();
|
|
126
|
+
for (const line of r.stdout.split("\n")) {
|
|
127
|
+
const [session, window, title, attached] = line.split("\t");
|
|
128
|
+
if (!session || session === except || attached !== "0") continue;
|
|
129
|
+
const key = `${session}:${window}`;
|
|
130
|
+
const seen = windows.get(key) || { bars: 0, others: 0 };
|
|
131
|
+
if (title === BAR_TITLE) seen.bars += 1; else seen.others += 1;
|
|
132
|
+
windows.set(key, seen);
|
|
133
|
+
}
|
|
134
|
+
let removed = 0;
|
|
135
|
+
for (const [target, seen] of windows) {
|
|
136
|
+
if (seen.bars && seen.others && removeBar(target, { runner })) removed += 1;
|
|
137
|
+
}
|
|
138
|
+
return removed;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* --------------------------------------------------------------- line editing */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One keystroke against the current line. Pure, so the editor is testable
|
|
145
|
+
* without a terminal — the bar itself is then only plumbing.
|
|
146
|
+
*/
|
|
147
|
+
export function editLine(line, key) {
|
|
148
|
+
if (key === "\r" || key === "\n") return { line, action: "submit" };
|
|
149
|
+
if (key === "\x1b") return { line: "", action: "escape" };
|
|
150
|
+
if (key === "\x03") return { line: "", action: "escape" }; // Ctrl-C
|
|
151
|
+
if (key === "\x15") return { line: "", action: "edit" }; // Ctrl-U
|
|
152
|
+
if (key === "\x17") return { line: line.replace(/\S+\s*$/, ""), action: "edit" }; // Ctrl-W
|
|
153
|
+
if (key === "\x7f" || key === "\b") return { line: line.slice(0, -1), action: "edit" };
|
|
154
|
+
if (key.length === 1 && key >= " " && key !== "\x7f") return { line: line + key, action: "edit" };
|
|
155
|
+
return { line, action: "none" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What a typed line means.
|
|
160
|
+
*
|
|
161
|
+
* `attach` deliberately becomes `show`. Running the real attach from in here
|
|
162
|
+
* would start a tmux client inside the client already showing this pane, which
|
|
163
|
+
* tmux refuses — and the thing the word means in a workspace is "put it in the
|
|
164
|
+
* content pane" anyway.
|
|
165
|
+
*/
|
|
166
|
+
export function resolveCommand(input) {
|
|
167
|
+
const argv = String(input || "").trim().split(/\s+/).filter(Boolean);
|
|
168
|
+
if (!argv.length) return { kind: "empty", argv: [] };
|
|
169
|
+
const [verb, ...rest] = argv;
|
|
170
|
+
if (verb === "detach" || verb === "exit" || verb === "quit") return { kind: "detach", argv: rest };
|
|
171
|
+
if (verb === "show" || verb === "attach" || verb === "fg") return { kind: "show", argv: rest };
|
|
172
|
+
if (verb === "help" || verb === "?") return { kind: "help", argv: rest };
|
|
173
|
+
if (verb === "clear") return { kind: "clear", argv: rest };
|
|
174
|
+
return { kind: "herd", argv };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The prompt line. The hint is what makes the way out discoverable at rest. */
|
|
178
|
+
export function renderPrompt(line, { cols = 80, showHint = true } = {}) {
|
|
179
|
+
const prompt = `${acid("mosh")} ${bone("▸")} `;
|
|
180
|
+
if (!line && showHint) return `${prompt}${ash(HINT.slice(0, Math.max(0, cols - 8)))}`;
|
|
181
|
+
return `${prompt}${line}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function helpLines() {
|
|
185
|
+
return [
|
|
186
|
+
"the bar takes any moshcode herd verb:",
|
|
187
|
+
" ps the roster start claude new agent",
|
|
188
|
+
" show <name> put it on screen shell new shell",
|
|
189
|
+
" kill <name> end one tile all at once",
|
|
190
|
+
" read <name> its last screen prompt <n> <text> type into it",
|
|
191
|
+
"",
|
|
192
|
+
`${BAR_KEY} comes back here from anywhere · Esc returns to the session · detach leaves`,
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ----------------------------------------------------------------- the bar */
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Runs inside the one-line pane at the bottom of the workspace.
|
|
200
|
+
*/
|
|
201
|
+
export async function herdBar({
|
|
202
|
+
stdin = process.stdin,
|
|
203
|
+
stdout = process.stdout,
|
|
204
|
+
runner = spawnSync,
|
|
205
|
+
target = null,
|
|
206
|
+
run = null,
|
|
207
|
+
} = {}) {
|
|
208
|
+
const me = process.env.TMUX_PANE;
|
|
209
|
+
// The bar runs in the workspace AND under a plain attach, so it asks where it
|
|
210
|
+
// is rather than assuming. Everything below keys off that one answer.
|
|
211
|
+
const here = target || ownTarget({ runner, me }) || "herd:ui";
|
|
212
|
+
const inWorkspace = () => !!paneRoles(here, { runner }).sidebar;
|
|
213
|
+
const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options));
|
|
214
|
+
|
|
215
|
+
let line = "";
|
|
216
|
+
let open = false;
|
|
217
|
+
|
|
218
|
+
const cols = () => stdout.columns || 80;
|
|
219
|
+
const collapse = () => {
|
|
220
|
+
if (!open) return;
|
|
221
|
+
open = false;
|
|
222
|
+
tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
|
|
223
|
+
};
|
|
224
|
+
const expand = (rows) => {
|
|
225
|
+
open = true;
|
|
226
|
+
tmux(["resize-pane", "-t", me, "-y", String(Math.min(BAR_OPEN_HEIGHT, rows + 2))], { runner });
|
|
227
|
+
};
|
|
228
|
+
const draw = () => {
|
|
229
|
+
stdout.write(`\x1b[2J\x1b[H${renderPrompt(line, { cols: cols() })}`);
|
|
230
|
+
};
|
|
231
|
+
const show = (lines) => {
|
|
232
|
+
expand(lines.length);
|
|
233
|
+
stdout.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n${renderPrompt("", { cols: cols(), showHint: false })}`);
|
|
234
|
+
};
|
|
235
|
+
/** Give the keyboard back to whatever is on screen. */
|
|
236
|
+
const toContent = () => {
|
|
237
|
+
const roles = paneRoles(here, { runner });
|
|
238
|
+
if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner });
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `show` means two different things and both are right.
|
|
243
|
+
*
|
|
244
|
+
* In the workspace it swaps the content pane. Under a plain attach there is
|
|
245
|
+
* no content pane to swap, so it switches the client to that member — and
|
|
246
|
+
* gives that member a bar first, or you would arrive somewhere with no way
|
|
247
|
+
* back out, which is the bug this whole thing exists to fix.
|
|
248
|
+
*/
|
|
249
|
+
const showElsewhere = async (name) => {
|
|
250
|
+
const { paneIndex } = await import("./herd.mjs");
|
|
251
|
+
const found = paneIndex({ runner }).get(name);
|
|
252
|
+
if (!found) return false;
|
|
253
|
+
ensureBar(`${found.session}:${found.windowId}`, { runner, command: barCommand() });
|
|
254
|
+
return tmux(["switch-client", "-t", found.session], { runner }).ok;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const submit = async () => {
|
|
258
|
+
const typed = line;
|
|
259
|
+
line = "";
|
|
260
|
+
const command = resolveCommand(typed);
|
|
261
|
+
if (command.kind === "empty") { collapse(); draw(); toContent(); return true; }
|
|
262
|
+
if (command.kind === "clear") { collapse(); draw(); return true; }
|
|
263
|
+
if (command.kind === "help") { show(helpLines()); return true; }
|
|
264
|
+
if (command.kind === "detach") { tmux(["detach-client"], { runner }); return false; }
|
|
265
|
+
if (command.kind === "show") {
|
|
266
|
+
const [name] = command.argv;
|
|
267
|
+
let okShown = false;
|
|
268
|
+
if (name && inWorkspace()) {
|
|
269
|
+
const { showMember } = await import("./herd-workspace.mjs");
|
|
270
|
+
okShown = showMember(name, { runner, me });
|
|
271
|
+
} else if (name) {
|
|
272
|
+
okShown = await showElsewhere(name);
|
|
273
|
+
}
|
|
274
|
+
if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; }
|
|
275
|
+
collapse(); draw(); toContent();
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
const out = [];
|
|
279
|
+
await herdCommand(command.argv, { write: (s) => out.push(...String(s).split("\n")) });
|
|
280
|
+
if (out.length) show(out);
|
|
281
|
+
else { collapse(); draw(); }
|
|
282
|
+
return true;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Keep the bar one row.
|
|
287
|
+
*
|
|
288
|
+
* tmux scales panes proportionally when the window resizes, so a bar built
|
|
289
|
+
* before a client attached came back three rows tall once one did — the pane
|
|
290
|
+
* was created against an 80x24 window and stretched to fit 100x30. Nothing
|
|
291
|
+
* outside can predict when that happens, but the bar gets a resize event for
|
|
292
|
+
* it, so the bar is the thing that fixes it.
|
|
293
|
+
*/
|
|
294
|
+
const keepThin = () => {
|
|
295
|
+
if (open) return;
|
|
296
|
+
tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
300
|
+
stdin.resume();
|
|
301
|
+
stdout.on?.("resize", () => { keepThin(); draw(); });
|
|
302
|
+
keepThin();
|
|
303
|
+
draw();
|
|
304
|
+
|
|
305
|
+
await new Promise((resolve) => {
|
|
306
|
+
stdin.on("data", async (buf) => {
|
|
307
|
+
for (const key of String(buf)) {
|
|
308
|
+
const next = editLine(line, key);
|
|
309
|
+
line = next.line;
|
|
310
|
+
if (next.action === "submit") {
|
|
311
|
+
if (!(await submit())) { resolve(); return; }
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (next.action === "escape") { collapse(); draw(); toContent(); continue; }
|
|
315
|
+
if (next.action === "edit") { if (open) { collapse(); } draw(); }
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
package/src/herd-cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET,
|
|
13
|
-
herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt,
|
|
13
|
+
herdDir, killSession, listSessions, paneIndex, readManifest, rememberSession, sendKeys, sendPrompt,
|
|
14
14
|
slugifyName, startSession, stopRuntime, substrateNote, validName, NAME_RE,
|
|
15
15
|
} from "./herd.mjs";
|
|
16
16
|
import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
|
|
@@ -178,7 +178,7 @@ export function herdStart(argv, { write = console.log } = {}) {
|
|
|
178
178
|
}
|
|
179
179
|
write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`));
|
|
180
180
|
if (flags.agent) write(warn("agent mode: native approvals are bypassed or auto-approved."));
|
|
181
|
-
write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
181
|
+
write(info(`workspace: ${acid("moshcode herd ui")} · attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
182
182
|
const note = substrateNote(substrate);
|
|
183
183
|
if (note) write(info(note));
|
|
184
184
|
return EXIT.matched;
|
|
@@ -253,7 +253,7 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
|
|
|
253
253
|
return EXIT.matched;
|
|
254
254
|
}
|
|
255
255
|
write(ok(`${bone(name)} — ${label} running in the herd. the prompt is yours.`));
|
|
256
|
-
write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
256
|
+
write(info(`workspace: ${acid("moshcode herd ui")} · attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
|
|
257
257
|
return EXIT.matched;
|
|
258
258
|
}
|
|
259
259
|
|
|
@@ -328,9 +328,32 @@ export async function herdAttach(argv, { write = console.log } = {}) {
|
|
|
328
328
|
// this whole feature is someone quitting a session they meant to leave
|
|
329
329
|
// running, and the only defence is telling them the key first.
|
|
330
330
|
const substrate = detectSubstrate();
|
|
331
|
-
|
|
331
|
+
|
|
332
|
+
// Give the session a mosh bar, so the way out is on screen the whole time
|
|
333
|
+
// rather than in a line that the agent's first repaint scrolls away. Only a
|
|
334
|
+
// member sitting in its own session: a tiled one shares a window with its
|
|
335
|
+
// neighbours and would be handing them a footer they did not ask for.
|
|
336
|
+
const bar = await import("./herd-bar.mjs");
|
|
337
|
+
let barTarget = null;
|
|
338
|
+
if (substrate === "tmux") {
|
|
339
|
+
bar.sweepBars({ runner: undefined, except: "herd" });
|
|
340
|
+
const found = paneIndex().get(name);
|
|
341
|
+
if (found && found.session === name) {
|
|
342
|
+
barTarget = `${found.session}:${found.windowId}`;
|
|
343
|
+
bar.ensureBar(barTarget, { command: bar.barCommand() });
|
|
344
|
+
bar.bindJumpKey({});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
write(info(substrate === "tmux"
|
|
349
|
+
? `detach with Ctrl-b d — the session keeps running.${barTarget ? ` ${bar.BAR_KEY} for the mosh bar.` : ""}`
|
|
350
|
+
: "detach with Ctrl-] — the session keeps running."));
|
|
332
351
|
|
|
333
352
|
const result = await attachSession(name, { substrate });
|
|
353
|
+
// Take it back out on the way through, so a member is a member again: `kill`
|
|
354
|
+
// ends one by killing its pane, and a session still holding a bar would
|
|
355
|
+
// outlive the member and keep its name on the roster.
|
|
356
|
+
if (barTarget) bar.removeBar(barTarget, {});
|
|
334
357
|
if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; }
|
|
335
358
|
|
|
336
359
|
const after = findSession(name);
|
|
@@ -729,6 +752,7 @@ const VERBS = {
|
|
|
729
752
|
// inside it for machines with no tmux to swap panes on.
|
|
730
753
|
ui: async (argv, options) => (await import("./herd-workspace.mjs")).herdUi(argv, options),
|
|
731
754
|
sidebar: async (argv, options) => (await import("./herd-workspace.mjs")).herdSidebar(options),
|
|
755
|
+
bar: async (argv, options) => (await import("./herd-bar.mjs")).herdBar(options),
|
|
732
756
|
tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
|
|
733
757
|
untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
|
|
734
758
|
ps: herdPs, list: herdPs, status: herdStatus,
|
package/src/herd-workspace.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
21
21
|
import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
|
|
22
22
|
import { roster } from "./herd-cli.mjs";
|
|
23
23
|
import { groupByHerd, parseInput } from "./herd-ui.mjs";
|
|
24
|
+
import { BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, barCommand, bindJumpKey, ensureBar, paneRoles } from "./herd-bar.mjs";
|
|
24
25
|
import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
|
|
25
26
|
|
|
26
27
|
export const WORKSPACE = "herd";
|
|
@@ -59,15 +60,12 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn,
|
|
|
59
60
|
const sidebar = `${process.execPath} ${self} herd sidebar`;
|
|
60
61
|
const made = tmux(["new-session", "-d", "-s", WORKSPACE, "-n", WINDOW, sidebar], { runner });
|
|
61
62
|
if (!made.ok) { write(err(made.stderr.trim() || "could not open the workspace")); return 1; }
|
|
62
|
-
// The sidebar is the "main" pane of a main-vertical layout, which is what
|
|
63
|
-
// pins it to the left at a fixed width while the content pane takes the
|
|
64
|
-
// rest and follows the terminal when it resizes.
|
|
65
|
-
tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
|
|
66
63
|
tmux(["set-option", "-t", WORKSPACE, "mouse", "on"], { runner });
|
|
67
64
|
tmux(["set-option", "-t", WORKSPACE, "status", "off"], { runner });
|
|
68
65
|
tmux(["set-option", "-t", WORKSPACE, "pane-border-status", "top"], { runner });
|
|
69
66
|
tmux(["set-option", "-t", WORKSPACE, "pane-border-format", " #{pane_title} "], { runner });
|
|
70
|
-
tmux(["select-pane", "-t", `${TARGET}.0`, "-T",
|
|
67
|
+
tmux(["select-pane", "-t", `${TARGET}.0`, "-T", SIDEBAR_TITLE], { runner });
|
|
68
|
+
buildBar({ runner });
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
return new Promise((resolve) => {
|
|
@@ -82,15 +80,33 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn,
|
|
|
82
80
|
});
|
|
83
81
|
}
|
|
84
82
|
|
|
83
|
+
/* ------------------------------------------------------------------ the bar */
|
|
84
|
+
|
|
85
|
+
/** Add the one-line mosh prompt under the content, and the key that reaches it. */
|
|
86
|
+
export function buildBar({ runner = spawnSync, command = barCommand() } = {}) {
|
|
87
|
+
const { paneId } = ensureBar(TARGET, { runner, command });
|
|
88
|
+
if (!paneId) return null;
|
|
89
|
+
bindJumpKey({ runner });
|
|
90
|
+
tmux(["select-pane", "-t", `${TARGET}.0`], { runner });
|
|
91
|
+
return paneId;
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
/* ------------------------------------------------------------- the swapping */
|
|
86
95
|
|
|
87
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* The content pane — the one that is neither the sidebar nor the bar.
|
|
98
|
+
*
|
|
99
|
+
* Excluding by title rather than "any pane that is not me": the bar made that
|
|
100
|
+
* shortcut wrong, and wrong here means a swap parks the bar into a session
|
|
101
|
+
* named after it and the prompt vanishes off the bottom of the screen.
|
|
102
|
+
*/
|
|
88
103
|
export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
89
104
|
const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner });
|
|
90
105
|
if (!r.ok) return null;
|
|
91
106
|
for (const line of r.stdout.split("\n")) {
|
|
92
107
|
const [paneId, title] = line.split("\t");
|
|
93
108
|
if (!paneId || paneId === me) continue;
|
|
109
|
+
if (title === BAR_TITLE || title === SIDEBAR_TITLE) continue;
|
|
94
110
|
return { paneId, title };
|
|
95
111
|
}
|
|
96
112
|
return null;
|
|
@@ -126,16 +142,34 @@ export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PAN
|
|
|
126
142
|
if (!wanted) return false;
|
|
127
143
|
|
|
128
144
|
if (current) parkPane(current.paneId, current.title, { runner });
|
|
129
|
-
|
|
145
|
+
|
|
146
|
+
// Split the SIDEBAR rather than laying the window out.
|
|
147
|
+
//
|
|
148
|
+
// `select-layout main-vertical` was the obvious way to do this and it is the
|
|
149
|
+
// wrong one once a footer exists: it owns every pane in the window, so it
|
|
150
|
+
// dragged the bar into the right-hand column and gave it an equal share, and
|
|
151
|
+
// putting it back was a second fight every swap. Splitting the sidebar
|
|
152
|
+
// touches only the region above the footer, which leaves the bar a full-width
|
|
153
|
+
// row at the bottom and needs no correction afterwards.
|
|
154
|
+
const roles = paneRoles(TARGET, { runner });
|
|
155
|
+
const anchor = roles.sidebar?.paneId || me;
|
|
156
|
+
const joined = anchor
|
|
157
|
+
? tmux(["join-pane", "-h", "-s", wanted.paneId, "-t", anchor], { runner })
|
|
158
|
+
: tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
|
|
130
159
|
if (!joined.ok) return false;
|
|
131
|
-
tmux(["
|
|
132
|
-
// main-vertical resets the main pane's width from the option, so re-assert it
|
|
133
|
-
// after every swap or the sidebar creeps wider each time.
|
|
134
|
-
tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
|
|
160
|
+
if (anchor) tmux(["resize-pane", "-t", anchor, "-x", String(SIDEBAR_WIDTH)], { runner });
|
|
135
161
|
tmux(["select-pane", "-t", me], { runner });
|
|
136
162
|
return true;
|
|
137
163
|
}
|
|
138
164
|
|
|
165
|
+
/** Hand the keyboard to the session on screen. */
|
|
166
|
+
export function focusContent({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
167
|
+
const current = contentPane({ runner, me });
|
|
168
|
+
if (!current) return false;
|
|
169
|
+
tmux(["select-pane", "-t", current.paneId], { runner });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
139
173
|
/* --------------------------------------------------------------- the render */
|
|
140
174
|
|
|
141
175
|
const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
|
|
@@ -159,6 +193,11 @@ export function sidebarRows(sessions) {
|
|
|
159
193
|
}
|
|
160
194
|
rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" });
|
|
161
195
|
for (const action of ACTIONS) rows.push({ kind: "action", action });
|
|
196
|
+
// The two keys that stop the workspace being a one-way trip, on screen at all
|
|
197
|
+
// times. Everything else here is discoverable by looking; these are not.
|
|
198
|
+
rows.push({ kind: "gap" });
|
|
199
|
+
rows.push({ kind: "hint", text: "enter ▸ type in it" });
|
|
200
|
+
rows.push({ kind: "hint", text: `${BAR_KEY} ▸ mosh bar` });
|
|
162
201
|
return rows.map((row, i) => ({ ...row, line: i + 1 }));
|
|
163
202
|
}
|
|
164
203
|
|
|
@@ -168,6 +207,7 @@ export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH }
|
|
|
168
207
|
if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; }
|
|
169
208
|
if (row.kind === "gap") { out.push(""); continue; }
|
|
170
209
|
if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; }
|
|
210
|
+
if (row.kind === "hint") { out.push(` ${dim(row.text)}`); continue; }
|
|
171
211
|
if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; }
|
|
172
212
|
if (row.kind === "session") {
|
|
173
213
|
const s = row.session;
|
|
@@ -262,9 +302,14 @@ export async function herdSidebar({
|
|
|
262
302
|
const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action"));
|
|
263
303
|
if (!hit) continue;
|
|
264
304
|
if (hit.kind === "session") {
|
|
305
|
+
// First click browses. Clicking the one already on screen hands it
|
|
306
|
+
// the keyboard — the same second-click-opens idiom as the list, and
|
|
307
|
+
// the only way to reach an agent without using the mouse on it.
|
|
308
|
+
const already = hit.session.name === showing;
|
|
265
309
|
selected = hit.session.name;
|
|
266
310
|
if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; }
|
|
267
311
|
draw();
|
|
312
|
+
if (already) focusContent({ runner, me });
|
|
268
313
|
} else {
|
|
269
314
|
selected = hit.action.key;
|
|
270
315
|
draw();
|
|
@@ -280,7 +325,13 @@ export async function herdSidebar({
|
|
|
280
325
|
const at = names.indexOf(selected);
|
|
281
326
|
if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected;
|
|
282
327
|
if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected;
|
|
283
|
-
if (event.key === "\r" || event.key === "\n") {
|
|
328
|
+
if (event.key === "\r" || event.key === "\n") {
|
|
329
|
+
showMember(selected, { runner, me });
|
|
330
|
+
showing = selected;
|
|
331
|
+
draw();
|
|
332
|
+
focusContent({ runner, me });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
284
335
|
draw();
|
|
285
336
|
}
|
|
286
337
|
});
|
package/src/herd.mjs
CHANGED
|
@@ -629,7 +629,19 @@ export function killSession(name, { substrate = detectSubstrate(), runner = spaw
|
|
|
629
629
|
if (substrate === "tmux") {
|
|
630
630
|
// kill-pane, not kill-session: a tiled member shares its session with
|
|
631
631
|
// every other tiled member, and killing that would take the lot.
|
|
632
|
+
const found = paneIndex({ runner }).get(name);
|
|
632
633
|
const r = tmux(["kill-pane", "-t", target(name, { runner })], { runner });
|
|
634
|
+
// A member being attached to has a mosh bar under it, and the bar would
|
|
635
|
+
// hold the session open after its member is gone — an empty room still
|
|
636
|
+
// answering to the dead member's name on the roster. If that is all that is
|
|
637
|
+
// left, take the room too.
|
|
638
|
+
if (r.ok && found) {
|
|
639
|
+
const left = tmux(["list-panes", "-t", found.session, "-F", "#{pane_title}"], { runner });
|
|
640
|
+
const titles = left.ok ? left.stdout.split("\n").filter(Boolean) : [];
|
|
641
|
+
if (titles.length && titles.every((t) => t === "mosh-bar")) {
|
|
642
|
+
tmux(["kill-session", "-t", found.session], { runner });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
633
645
|
forgetSession(name);
|
|
634
646
|
return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
|
|
635
647
|
}
|