moshcode 0.36.0 → 0.37.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 +30 -1
- package/package.json +1 -1
- package/src/cli-schema.mjs +9 -1
- package/src/herd-bar.mjs +196 -0
- package/src/herd-cli.mjs +3 -2
- package/src/herd-workspace.mjs +86 -12
package/README.md
CHANGED
|
@@ -183,13 +183,42 @@ 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
|
+
|
|
193
222
|
The right-hand pane is not a picture of a session — it *is* the session's pane,
|
|
194
223
|
moved in. tmux's model is session → window → pane, so moving between *windows*
|
|
195
224
|
cannot keep anything on screen; but `join-pane` moves a running pane into an
|
package/package.json
CHANGED
package/src/cli-schema.mjs
CHANGED
|
@@ -745,9 +745,17 @@ export const HERD_VERBS = [
|
|
|
745
745
|
{ name: "untile", description: "put tiled members back in their own sessions",
|
|
746
746
|
synopsis: [["moshcode herd untile", ""]] },
|
|
747
747
|
{ name: "ui", description: "sidebar of members and actions, selected one beside it",
|
|
748
|
-
synopsis: [["moshcode herd ui", "click a member to show it ·
|
|
748
|
+
synopsis: [["moshcode herd ui", "click a member to show it · click it again to type in it · F12 for the mosh bar"]],
|
|
749
749
|
flags: [],
|
|
750
750
|
examples: [["moshcode herd ui", "the workspace — start here"]] },
|
|
751
|
+
{ name: "bar", description: "the one-line mosh prompt under the session (runs inside the workspace)",
|
|
752
|
+
synopsis: [["moshcode herd bar", "F12 reaches it from inside an agent · Esc goes back · detach leaves"]],
|
|
753
|
+
flags: [],
|
|
754
|
+
examples: [
|
|
755
|
+
["F12", "jump to the bar from anywhere, even mid-agent"],
|
|
756
|
+
["start claude", "another agent, without leaving this one"],
|
|
757
|
+
["show api", "put a different member on screen"],
|
|
758
|
+
] },
|
|
751
759
|
{ name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
|
|
752
760
|
synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
|
|
753
761
|
flags: [
|
package/src/herd-bar.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
/* --------------------------------------------------------------- line editing */
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One keystroke against the current line. Pure, so the editor is testable
|
|
61
|
+
* without a terminal — the bar itself is then only plumbing.
|
|
62
|
+
*/
|
|
63
|
+
export function editLine(line, key) {
|
|
64
|
+
if (key === "\r" || key === "\n") return { line, action: "submit" };
|
|
65
|
+
if (key === "\x1b") return { line: "", action: "escape" };
|
|
66
|
+
if (key === "\x03") return { line: "", action: "escape" }; // Ctrl-C
|
|
67
|
+
if (key === "\x15") return { line: "", action: "edit" }; // Ctrl-U
|
|
68
|
+
if (key === "\x17") return { line: line.replace(/\S+\s*$/, ""), action: "edit" }; // Ctrl-W
|
|
69
|
+
if (key === "\x7f" || key === "\b") return { line: line.slice(0, -1), action: "edit" };
|
|
70
|
+
if (key.length === 1 && key >= " " && key !== "\x7f") return { line: line + key, action: "edit" };
|
|
71
|
+
return { line, action: "none" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a typed line means.
|
|
76
|
+
*
|
|
77
|
+
* `attach` deliberately becomes `show`. Running the real attach from in here
|
|
78
|
+
* would start a tmux client inside the client already showing this pane, which
|
|
79
|
+
* tmux refuses — and the thing the word means in a workspace is "put it in the
|
|
80
|
+
* content pane" anyway.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveCommand(input) {
|
|
83
|
+
const argv = String(input || "").trim().split(/\s+/).filter(Boolean);
|
|
84
|
+
if (!argv.length) return { kind: "empty", argv: [] };
|
|
85
|
+
const [verb, ...rest] = argv;
|
|
86
|
+
if (verb === "detach" || verb === "exit" || verb === "quit") return { kind: "detach", argv: rest };
|
|
87
|
+
if (verb === "show" || verb === "attach" || verb === "fg") return { kind: "show", argv: rest };
|
|
88
|
+
if (verb === "help" || verb === "?") return { kind: "help", argv: rest };
|
|
89
|
+
if (verb === "clear") return { kind: "clear", argv: rest };
|
|
90
|
+
return { kind: "herd", argv };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The prompt line. The hint is what makes the way out discoverable at rest. */
|
|
94
|
+
export function renderPrompt(line, { cols = 80, showHint = true } = {}) {
|
|
95
|
+
const prompt = `${acid("mosh")} ${bone("▸")} `;
|
|
96
|
+
if (!line && showHint) return `${prompt}${ash(HINT.slice(0, Math.max(0, cols - 8)))}`;
|
|
97
|
+
return `${prompt}${line}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function helpLines() {
|
|
101
|
+
return [
|
|
102
|
+
"the bar takes any moshcode herd verb:",
|
|
103
|
+
" ps the roster start claude new agent",
|
|
104
|
+
" show <name> put it on screen shell new shell",
|
|
105
|
+
" kill <name> end one tile all at once",
|
|
106
|
+
" read <name> its last screen prompt <n> <text> type into it",
|
|
107
|
+
"",
|
|
108
|
+
`${BAR_KEY} comes back here from anywhere · Esc returns to the session · detach leaves`,
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ----------------------------------------------------------------- the bar */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs inside the one-line pane at the bottom of the workspace.
|
|
116
|
+
*/
|
|
117
|
+
export async function herdBar({
|
|
118
|
+
stdin = process.stdin,
|
|
119
|
+
stdout = process.stdout,
|
|
120
|
+
runner = spawnSync,
|
|
121
|
+
target = "herd:ui",
|
|
122
|
+
run = null,
|
|
123
|
+
} = {}) {
|
|
124
|
+
const me = process.env.TMUX_PANE;
|
|
125
|
+
const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options));
|
|
126
|
+
|
|
127
|
+
let line = "";
|
|
128
|
+
let open = false;
|
|
129
|
+
|
|
130
|
+
const cols = () => stdout.columns || 80;
|
|
131
|
+
const collapse = () => {
|
|
132
|
+
if (!open) return;
|
|
133
|
+
open = false;
|
|
134
|
+
tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
|
|
135
|
+
};
|
|
136
|
+
const expand = (rows) => {
|
|
137
|
+
open = true;
|
|
138
|
+
tmux(["resize-pane", "-t", me, "-y", String(Math.min(BAR_OPEN_HEIGHT, rows + 2))], { runner });
|
|
139
|
+
};
|
|
140
|
+
const draw = () => {
|
|
141
|
+
stdout.write(`\x1b[2J\x1b[H${renderPrompt(line, { cols: cols() })}`);
|
|
142
|
+
};
|
|
143
|
+
const show = (lines) => {
|
|
144
|
+
expand(lines.length);
|
|
145
|
+
stdout.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n${renderPrompt("", { cols: cols(), showHint: false })}`);
|
|
146
|
+
};
|
|
147
|
+
/** Give the keyboard back to whatever is on screen. */
|
|
148
|
+
const toContent = () => {
|
|
149
|
+
const roles = paneRoles(target, { runner });
|
|
150
|
+
if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const submit = async () => {
|
|
154
|
+
const typed = line;
|
|
155
|
+
line = "";
|
|
156
|
+
const command = resolveCommand(typed);
|
|
157
|
+
if (command.kind === "empty") { collapse(); draw(); toContent(); return true; }
|
|
158
|
+
if (command.kind === "clear") { collapse(); draw(); return true; }
|
|
159
|
+
if (command.kind === "help") { show(helpLines()); return true; }
|
|
160
|
+
if (command.kind === "detach") { tmux(["detach-client"], { runner }); return false; }
|
|
161
|
+
if (command.kind === "show") {
|
|
162
|
+
const [name] = command.argv;
|
|
163
|
+
const { showMember } = await import("./herd-workspace.mjs");
|
|
164
|
+
const okShown = name && showMember(name, { runner, me });
|
|
165
|
+
if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; }
|
|
166
|
+
collapse(); draw(); toContent();
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
const out = [];
|
|
170
|
+
await herdCommand(command.argv, { write: (s) => out.push(...String(s).split("\n")) });
|
|
171
|
+
if (out.length) show(out);
|
|
172
|
+
else { collapse(); draw(); }
|
|
173
|
+
return true;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
177
|
+
stdin.resume();
|
|
178
|
+
draw();
|
|
179
|
+
|
|
180
|
+
await new Promise((resolve) => {
|
|
181
|
+
stdin.on("data", async (buf) => {
|
|
182
|
+
for (const key of String(buf)) {
|
|
183
|
+
const next = editLine(line, key);
|
|
184
|
+
line = next.line;
|
|
185
|
+
if (next.action === "submit") {
|
|
186
|
+
if (!(await submit())) { resolve(); return; }
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (next.action === "escape") { collapse(); draw(); toContent(); continue; }
|
|
190
|
+
if (next.action === "edit") { if (open) { collapse(); } draw(); }
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
package/src/herd-cli.mjs
CHANGED
|
@@ -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
|
|
|
@@ -729,6 +729,7 @@ const VERBS = {
|
|
|
729
729
|
// inside it for machines with no tmux to swap panes on.
|
|
730
730
|
ui: async (argv, options) => (await import("./herd-workspace.mjs")).herdUi(argv, options),
|
|
731
731
|
sidebar: async (argv, options) => (await import("./herd-workspace.mjs")).herdSidebar(options),
|
|
732
|
+
bar: async (argv, options) => (await import("./herd-bar.mjs")).herdBar(options),
|
|
732
733
|
tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
|
|
733
734
|
untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
|
|
734
735
|
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_HEIGHT, BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, 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,56 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn,
|
|
|
82
80
|
});
|
|
83
81
|
}
|
|
84
82
|
|
|
83
|
+
/* ------------------------------------------------------------------ the bar */
|
|
84
|
+
|
|
85
|
+
/** How the bar pane starts itself. Separated so tests can run a stand-in. */
|
|
86
|
+
export function barCommand(self = process.argv[1]) {
|
|
87
|
+
return `${process.execPath} ${self} herd bar`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add the one-line mosh prompt under the content, and the key that reaches it.
|
|
92
|
+
*
|
|
93
|
+
* The binding goes in tmux's root table, so it is claimed before the pane's
|
|
94
|
+
* application ever sees it — that is what makes it work from inside an agent
|
|
95
|
+
* that has taken the keyboard, which is the case the bar exists for. It also
|
|
96
|
+
* switches the client first, so it is a way out of a member you attached to
|
|
97
|
+
* directly and not only of the workspace.
|
|
98
|
+
*/
|
|
99
|
+
export function buildBar({ runner = spawnSync, command = barCommand() } = {}) {
|
|
100
|
+
const made = tmux(
|
|
101
|
+
["split-window", "-t", TARGET, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
|
|
102
|
+
{ runner },
|
|
103
|
+
);
|
|
104
|
+
if (!made.ok) return null;
|
|
105
|
+
const paneId = made.stdout.trim().split("\n")[0];
|
|
106
|
+
if (!paneId) return null;
|
|
107
|
+
tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner });
|
|
108
|
+
// One string, not separate arguments: a bare ";" argument ends the bind-key
|
|
109
|
+
// command itself, so tmux binds the first command and runs the second once,
|
|
110
|
+
// now. That silently produced a key that switched sessions and did nothing
|
|
111
|
+
// else — the binding has to arrive as a single command sequence.
|
|
112
|
+
tmux(["bind-key", "-n", BAR_KEY, `switch-client -t ${WORKSPACE} ; select-pane -t ${paneId}`], { runner });
|
|
113
|
+
tmux(["select-pane", "-t", `${TARGET}.0`], { runner });
|
|
114
|
+
return paneId;
|
|
115
|
+
}
|
|
116
|
+
|
|
85
117
|
/* ------------------------------------------------------------- the swapping */
|
|
86
118
|
|
|
87
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* The content pane — the one that is neither the sidebar nor the bar.
|
|
121
|
+
*
|
|
122
|
+
* Excluding by title rather than "any pane that is not me": the bar made that
|
|
123
|
+
* shortcut wrong, and wrong here means a swap parks the bar into a session
|
|
124
|
+
* named after it and the prompt vanishes off the bottom of the screen.
|
|
125
|
+
*/
|
|
88
126
|
export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
89
127
|
const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner });
|
|
90
128
|
if (!r.ok) return null;
|
|
91
129
|
for (const line of r.stdout.split("\n")) {
|
|
92
130
|
const [paneId, title] = line.split("\t");
|
|
93
131
|
if (!paneId || paneId === me) continue;
|
|
132
|
+
if (title === BAR_TITLE || title === SIDEBAR_TITLE) continue;
|
|
94
133
|
return { paneId, title };
|
|
95
134
|
}
|
|
96
135
|
return null;
|
|
@@ -126,16 +165,34 @@ export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PAN
|
|
|
126
165
|
if (!wanted) return false;
|
|
127
166
|
|
|
128
167
|
if (current) parkPane(current.paneId, current.title, { runner });
|
|
129
|
-
|
|
168
|
+
|
|
169
|
+
// Split the SIDEBAR rather than laying the window out.
|
|
170
|
+
//
|
|
171
|
+
// `select-layout main-vertical` was the obvious way to do this and it is the
|
|
172
|
+
// wrong one once a footer exists: it owns every pane in the window, so it
|
|
173
|
+
// dragged the bar into the right-hand column and gave it an equal share, and
|
|
174
|
+
// putting it back was a second fight every swap. Splitting the sidebar
|
|
175
|
+
// touches only the region above the footer, which leaves the bar a full-width
|
|
176
|
+
// row at the bottom and needs no correction afterwards.
|
|
177
|
+
const roles = paneRoles(TARGET, { runner });
|
|
178
|
+
const anchor = roles.sidebar?.paneId || me;
|
|
179
|
+
const joined = anchor
|
|
180
|
+
? tmux(["join-pane", "-h", "-s", wanted.paneId, "-t", anchor], { runner })
|
|
181
|
+
: tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
|
|
130
182
|
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 });
|
|
183
|
+
if (anchor) tmux(["resize-pane", "-t", anchor, "-x", String(SIDEBAR_WIDTH)], { runner });
|
|
135
184
|
tmux(["select-pane", "-t", me], { runner });
|
|
136
185
|
return true;
|
|
137
186
|
}
|
|
138
187
|
|
|
188
|
+
/** Hand the keyboard to the session on screen. */
|
|
189
|
+
export function focusContent({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
190
|
+
const current = contentPane({ runner, me });
|
|
191
|
+
if (!current) return false;
|
|
192
|
+
tmux(["select-pane", "-t", current.paneId], { runner });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
139
196
|
/* --------------------------------------------------------------- the render */
|
|
140
197
|
|
|
141
198
|
const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
|
|
@@ -159,6 +216,11 @@ export function sidebarRows(sessions) {
|
|
|
159
216
|
}
|
|
160
217
|
rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" });
|
|
161
218
|
for (const action of ACTIONS) rows.push({ kind: "action", action });
|
|
219
|
+
// The two keys that stop the workspace being a one-way trip, on screen at all
|
|
220
|
+
// times. Everything else here is discoverable by looking; these are not.
|
|
221
|
+
rows.push({ kind: "gap" });
|
|
222
|
+
rows.push({ kind: "hint", text: "enter ▸ type in it" });
|
|
223
|
+
rows.push({ kind: "hint", text: `${BAR_KEY} ▸ mosh bar` });
|
|
162
224
|
return rows.map((row, i) => ({ ...row, line: i + 1 }));
|
|
163
225
|
}
|
|
164
226
|
|
|
@@ -168,6 +230,7 @@ export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH }
|
|
|
168
230
|
if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; }
|
|
169
231
|
if (row.kind === "gap") { out.push(""); continue; }
|
|
170
232
|
if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; }
|
|
233
|
+
if (row.kind === "hint") { out.push(` ${dim(row.text)}`); continue; }
|
|
171
234
|
if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; }
|
|
172
235
|
if (row.kind === "session") {
|
|
173
236
|
const s = row.session;
|
|
@@ -262,9 +325,14 @@ export async function herdSidebar({
|
|
|
262
325
|
const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action"));
|
|
263
326
|
if (!hit) continue;
|
|
264
327
|
if (hit.kind === "session") {
|
|
328
|
+
// First click browses. Clicking the one already on screen hands it
|
|
329
|
+
// the keyboard — the same second-click-opens idiom as the list, and
|
|
330
|
+
// the only way to reach an agent without using the mouse on it.
|
|
331
|
+
const already = hit.session.name === showing;
|
|
265
332
|
selected = hit.session.name;
|
|
266
333
|
if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; }
|
|
267
334
|
draw();
|
|
335
|
+
if (already) focusContent({ runner, me });
|
|
268
336
|
} else {
|
|
269
337
|
selected = hit.action.key;
|
|
270
338
|
draw();
|
|
@@ -280,7 +348,13 @@ export async function herdSidebar({
|
|
|
280
348
|
const at = names.indexOf(selected);
|
|
281
349
|
if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected;
|
|
282
350
|
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") {
|
|
351
|
+
if (event.key === "\r" || event.key === "\n") {
|
|
352
|
+
showMember(selected, { runner, me });
|
|
353
|
+
showing = selected;
|
|
354
|
+
draw();
|
|
355
|
+
focusContent({ runner, me });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
284
358
|
draw();
|
|
285
359
|
}
|
|
286
360
|
});
|