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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -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 · s shell · a agent · x stop · q detach"]],
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: [
@@ -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,
@@ -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", "herd"], { runner });
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
- /** The content pane currently on the right, if there is one. */
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
- const joined = tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
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(["select-layout", "-t", TARGET, "main-vertical"], { runner });
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") { showMember(selected, { runner, me }); showing = selected; }
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
  });