agentp 2.0.0 → 2.1.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/bin/ocmux CHANGED
@@ -5,26 +5,11 @@ const path = require('path');
5
5
  const readline = require('readline');
6
6
  const http = require('http');
7
7
  const { URL } = require('url');
8
- const { spawnSync } = require('child_process');
8
+ const child_process = require('child_process');
9
9
  const os = require('os');
10
- const {
11
- SESSION,
12
- statefileFor,
13
- tuiPaneId,
14
- windowByDir,
15
- windowNameByIndex,
16
- activeWindowIndex,
17
- listProjects,
18
- activateProject,
19
- relaunchTui,
20
- closeProjectWindow,
21
- createProjectWindow,
22
- ensureSession,
23
- pinWindowName,
24
- sleep,
25
- tuiAttachCommand,
26
- } = require('../lib/ocmux');
27
- const { listSessions, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions } = require('../lib/opencode');
10
+ const { tuiArgs, switchTui } = require('../lib/ocmux');
11
+ const tuiRegistry = require('../lib/tui-registry');
12
+ const { listSessions, listProjects: listOpenCodeProjects, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions, getSession } = require('../lib/opencode');
28
13
  const projectState = require('../lib/project-state');
29
14
 
30
15
  // ---------------------------------------------------------------------------
@@ -41,15 +26,6 @@ const DEFAULT_SERVER = process.env.OCMUX_SERVER || 'http://localhost:4096';
41
26
  // Helpers
42
27
  // ---------------------------------------------------------------------------
43
28
 
44
- function tmux(args) {
45
- const result = spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
46
- if (result.error && result.error.code === 'ENOENT') {
47
- console.error('Error: tmux is not installed or not in PATH');
48
- process.exit(1);
49
- }
50
- return result;
51
- }
52
-
53
29
  function findParentStatefile(startDir) {
54
30
  let dir = path.resolve(startDir);
55
31
  while (true) {
@@ -93,18 +69,12 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
93
69
  if (!result.dir) {
94
70
  const label = requireDir ? '.git directory' : '.git entry';
95
71
  console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
96
- console.error(`Run '${SCRIPT} serve' without ${flagLabel} to create a project window in the current directory.`);
72
+ console.error(`Run '${SCRIPT} serve' without ${flagLabel} to initialize the current directory.`);
97
73
  process.exit(1);
98
74
  }
99
75
  return result.dir;
100
76
  }
101
77
 
102
- function findWindowsByBasename(name) {
103
- const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_name}']);
104
- if (r.status !== 0) return [];
105
- return r.stdout.trim().split('\n').filter(Boolean).filter(w => path.basename(w) === name);
106
- }
107
-
108
78
  function resolveDir(arg) {
109
79
  const resolved = path.resolve(arg);
110
80
  try {
@@ -112,17 +82,6 @@ function resolveDir(arg) {
112
82
  } catch {
113
83
  // not a valid path or inaccessible
114
84
  }
115
-
116
- if (!arg.includes('/')) {
117
- const matches = findWindowsByBasename(arg);
118
- if (matches.length === 1) return matches[0];
119
- if (matches.length > 1) {
120
- console.error(`Error: more than one match for '${arg}':`);
121
- for (const m of matches) console.error(` ${m}`);
122
- process.exit(1);
123
- }
124
- }
125
-
126
85
  console.error(`Error: invalid directory '${arg}'`);
127
86
  console.error(`Run '${SCRIPT} -h' for help.`);
128
87
  process.exit(1);
@@ -154,9 +113,51 @@ function checkServer(server) {
154
113
  });
155
114
  }
156
115
 
116
+ function refreshTui(directory, server, session) {
117
+ const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
118
+ if (!result.ok && result.reason === 'failed') {
119
+ console.error(`Warning: session selected, but the ${result.kind} TUI could not be refreshed: ${result.error}`);
120
+ }
121
+ return result;
122
+ }
123
+
124
+ async function configuredProjects(server) {
125
+ const [projects, sessions] = await Promise.all([
126
+ listOpenCodeProjects(server),
127
+ listSessions(server),
128
+ ]);
129
+ const rows = [];
130
+ const seen = new Set();
131
+ const directories = [
132
+ ...(projects || []).map(project => project.canonical || project.directory),
133
+ ...(sessions || []).map(session => session.location && session.location.directory),
134
+ ];
135
+ for (const dir of directories) {
136
+ if (!dir) continue;
137
+ let canonical;
138
+ try { canonical = fs.realpathSync(dir); } catch { canonical = path.resolve(dir); }
139
+ if (seen.has(canonical)) continue;
140
+ const sf = path.join(canonical, '.ocmux.json');
141
+ const state = projectState.readProjectState(sf);
142
+ if (!state) continue;
143
+ seen.add(canonical);
144
+ const target = tuiRegistry.resolve(canonical);
145
+ rows.push({
146
+ dir: canonical,
147
+ status: target ? target.kind : 'headless',
148
+ session: state.session,
149
+ server: state.server || server,
150
+ });
151
+ }
152
+ return rows.sort((a, b) => a.dir.localeCompare(b.dir));
153
+ }
154
+
157
155
  function newestSessionId(sessions) {
158
156
  if (!sessions || sessions.length === 0) return null;
159
- const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id);
157
+ // Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
158
+ // TUI opens those on its "Subagents" tab instead of the prompt. Callers fall
159
+ // back to creating a session when this returns null.
160
+ const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id && !s.parentID);
160
161
  return sorted.length > 0 ? sorted[0].id : null;
161
162
  }
162
163
 
@@ -186,6 +187,134 @@ function padLeft(s, w) {
186
187
  const str = String(s);
187
188
  return ' '.repeat(Math.max(0, w - str.length)) + str;
188
189
  }
190
+
191
+ // ── Palette (classic 16-colour ANSI) ─────────────────────────────────────────
192
+ // Colour 3 renders as brown/dark-yellow; bold colour 3 as light yellow. The
193
+ // whole list UI (title/status bars, cursor pointer, info labels) sticks to these
194
+ // two shades so the chrome reads as one theme.
195
+ const BROWN = '\x1b[33m'; // text: brown/dark-yellow
196
+ const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
197
+ const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
198
+
199
+ // ── '/' incremental search for interactive lists ──────────────────────────
200
+ // Filter `items` by `pattern`: case-insensitive, space-separated tokens that
201
+ // must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
202
+ function filterByPattern(items, pattern, textOf) {
203
+ const p = String(pattern || '').trim().toLowerCase();
204
+ if (!p) return items;
205
+ const tokens = p.split(/\s+/);
206
+ return items.filter((it) => {
207
+ const hay = String(textOf(it) || '').toLowerCase();
208
+ return tokens.every((t) => hay.includes(t));
209
+ });
210
+ }
211
+
212
+ // State of a '/' search line: `active` a filter exists, `live` the user is
213
+ // still typing into it, `value` the pattern, `caret` the edit position.
214
+ function newSearchState() {
215
+ return { active: false, live: false, value: '', caret: 0 };
216
+ }
217
+
218
+ function clearSearchState(s) {
219
+ s.active = false;
220
+ s.live = false;
221
+ s.value = '';
222
+ s.caret = 0;
223
+ }
224
+
225
+ // Drive a '/' search line from a keypress. Returns:
226
+ // 'edit' — pattern/caret changed (caller re-filters and redraws)
227
+ // 'close' — search cancelled/cleared (caller re-filters and redraws)
228
+ // 'done' — search committed, pattern kept (caller redraws)
229
+ // 'pass' — not handled here (caller runs its normal key logic)
230
+ function handleSearch(str, key, s) {
231
+ if (str === '/' && !key.ctrl && !key.meta && !s.live) {
232
+ // '/' starts a new search or resumes editing the active filter.
233
+ if (!s.active) { s.value = ''; s.caret = 0; }
234
+ else s.caret = s.value.length;
235
+ s.active = true;
236
+ s.live = true;
237
+ return 'edit';
238
+ }
239
+ if (!s.active) return 'pass';
240
+ if (!s.live) {
241
+ // Committed filter: ESC drops it, Backspace resumes editing it.
242
+ if (key.name === 'escape') { clearSearchState(s); return 'close'; }
243
+ if (key.name === 'backspace') {
244
+ if (s.value === '') { clearSearchState(s); return 'close'; }
245
+ s.live = true;
246
+ s.caret = s.value.length;
247
+ s.value = s.value.slice(0, -1);
248
+ s.caret--;
249
+ return 'edit';
250
+ }
251
+ return 'pass';
252
+ }
253
+ // Live typing.
254
+ if (key.name === 'escape') { clearSearchState(s); return 'close'; }
255
+ if (key.name === 'return' || key.name === 'enter') {
256
+ if (!s.value.trim()) { clearSearchState(s); return 'close'; }
257
+ s.live = false;
258
+ return 'done';
259
+ }
260
+ if (key.name === 'backspace') {
261
+ // Backspace on an already-empty field exits the search (like ESC). Removing
262
+ // the last character keeps the field open.
263
+ if (s.value === '') { clearSearchState(s); return 'close'; }
264
+ if (s.caret > 0) {
265
+ s.value = s.value.slice(0, s.caret - 1) + s.value.slice(s.caret);
266
+ s.caret--;
267
+ }
268
+ return 'edit';
269
+ }
270
+ if (key.name === 'delete' || key.name === 'del') {
271
+ if (s.caret < s.value.length) s.value = s.value.slice(0, s.caret) + s.value.slice(s.caret + 1);
272
+ return 'edit';
273
+ }
274
+ if (key.name === 'left') { s.caret = Math.max(0, s.caret - 1); return 'done'; }
275
+ if (key.name === 'right') { s.caret = Math.min(s.value.length, s.caret + 1); return 'done'; }
276
+ if (key.name === 'home') { s.caret = 0; return 'done'; }
277
+ if (key.name === 'end') { s.caret = s.value.length; return 'done'; }
278
+ if (key.name === 'up' || key.name === 'down') return 'pass'; // navigate the filtered list
279
+ if (str && !key.ctrl && !key.meta && str >= ' ') {
280
+ s.value = s.value.slice(0, s.caret) + str + s.value.slice(s.caret);
281
+ s.caret += str.length;
282
+ return 'edit';
283
+ }
284
+ return 'pass';
285
+ }
286
+
287
+ // The status bar's search line: normal key hints, or the pattern while a '/'
288
+ // filter is active (live = input with caret; committed = pattern kept).
289
+ // The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
290
+ // renderList's `footerRight`.
291
+ const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
292
+ function searchFooter(s, base) {
293
+ if (!s.active) return base;
294
+ const shown = s.live ? s.value.slice(0, s.caret) + '▏' + s.value.slice(s.caret) : s.value;
295
+ return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
296
+ }
297
+
298
+ // ── Model picker ordering ─────────────────────────────────────────────────────
299
+ // Models sorted by provider then id (the provider already leads the label, so
300
+ // this is also alphabetical by display order).
301
+ function sortModels(models) {
302
+ const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
303
+ return [...(models || [])].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
304
+ }
305
+
306
+ // Index of `model` (as stored on a session: { providerID, id }) inside
307
+ // `models`, or -1 when unknown/unmatched — so a picker can open on it.
308
+ function indexOfModel(models, model) {
309
+ if (!model || !models || !models.length) return -1;
310
+ const key = (m) => `${String(m.providerID || '').toLowerCase()}/${String(m.id || '').toLowerCase()}`;
311
+ const target = key(model);
312
+ return models.findIndex((m) => key(m) === target);
313
+ }
314
+
315
+ // Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
316
+ // (instead of just returning to the session menu).
317
+ const SWITCH_QUIT = Symbol('ocmux-switch-quit');
189
318
  // Last-view stamp: HH:MM within the last 24h, else DD/MM/YYYY; '--:--' if unknown.
190
319
  function fmtClock(ts) {
191
320
  if (!ts) return '--:--';
@@ -270,13 +399,14 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
270
399
  return `${labelColor}${lbl}\x1b[0m ${v}`;
271
400
  }
272
401
 
273
- // Info lines about the selected session: title + location (bold labels) and a
274
- // responsive grid. Fields are ALWAYS present (placeholders when unknown).
275
- // When `highlight` is true (the cursor is on the current session), labels are
276
- // yellow instead of white.
402
+ // Info lines about the selected session: title + location and a responsive
403
+ // grid. Fields are ALWAYS present (placeholders when unknown).
404
+ // Labels are brown (the same shade as the title/status bars); when `highlight`
405
+ // is true (the cursor is on the session currently selected in the TUI) they are
406
+ // light-yellow instead.
277
407
  function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false) {
278
408
  if (!s) return [];
279
- const labelColor = highlight ? '\x1b[1;33m' : '\x1b[1m';
409
+ const labelColor = highlight ? LIGHT_YELLOW : BROWN;
280
410
  const title = (s.title && s.title.trim()) || 'Untitled';
281
411
  const location = (s.location && s.location.directory) || 'Unknown';
282
412
  const out = [
@@ -317,13 +447,15 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
317
447
  }
318
448
 
319
449
  // Render a scrollable list into the alternate screen.
320
- // heading -> centered + bold + inverted heading
450
+ // heading -> centered title bar (brown background)
321
451
  // row(index, item) -> string | { text, reverse }
322
452
  // extraLines -> prompt/confirm lines right after the list (normal colors)
323
- // footer -> inverted, RIGHT-aligned key-hint bar (acts as a separator)
324
- // infoLines -> details below the footer (normal colors, bold labels)
453
+ // footer -> status bar (brown background), key hints right-aligned
454
+ // (left-aligned when footerLeft is set — used by the search line)
455
+ // footerRight-> optional hint pinned to the right end of a left-aligned footer
456
+ // infoLines -> details below the status bar (colours supplied by the caller)
325
457
  // A "(start-end/total)" range is appended to the heading when scrolling.
326
- function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg }) {
458
+ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null }) {
327
459
  const cols = colsArg || process.stdout.columns || 80;
328
460
  const termRows = rowsArg || process.stdout.rows || 24;
329
461
  const extras = (extraLines || []).filter((l) => l != null && l !== '');
@@ -341,7 +473,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
341
473
  }
342
474
 
343
475
  let out = '\x1b[2J\x1b[H';
344
- out += '\x1b[1;7m' + centerIn(titleLine, cols) + '\x1b[0m\n\n';
476
+ out += BAR_BG + centerIn(titleLine, cols) + '\x1b[0m\n\n';
345
477
 
346
478
  let rendered = 0;
347
479
  if (items.length === 0) {
@@ -353,8 +485,11 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
353
485
  const r = row(idx, items[idx]);
354
486
  const text = r && typeof r === 'object' ? r.text : r;
355
487
  const reverse = r && typeof r === 'object' && r.reverse;
356
- const line = `${idx === cursor ? '▶ ' : ' '}${text == null ? '' : text}`;
357
- out += (reverse ? `\x1b[7m${line}\x1b[0m` : line) + '\n';
488
+ // The pointer sits outside the row's own styling so a light-yellow arrow
489
+ // stays visible even on a reverse-video row.
490
+ const ptr = idx === cursor ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
491
+ const body = text == null ? '' : text;
492
+ out += ptr + (reverse ? `\x1b[7m${body}\x1b[0m` : body) + '\n';
358
493
  rendered++;
359
494
  }
360
495
  }
@@ -362,8 +497,22 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
362
497
  // (instead of floating right under the last row on a tall window).
363
498
  for (let i = rendered; i < viewHeight; i++) out += '\n';
364
499
  for (const l of extras) out += l + '\n';
365
- const hint = footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols);
366
- out += '\x1b[7m' + hint + '\x1b[0m\n';
500
+ let hint;
501
+ if (footerRight) {
502
+ // Left-aligned text plus a hint pinned to the right end of the bar.
503
+ const left = footer.length + footerRight.length + 1 <= cols
504
+ ? footer
505
+ : footer.slice(0, Math.max(0, cols - footerRight.length - 1));
506
+ const gap = Math.max(0, cols - left.length - footerRight.length);
507
+ hint = (left + ' '.repeat(gap) + footerRight).slice(0, cols);
508
+ } else if (footer.length > cols) {
509
+ hint = footer.slice(0, cols);
510
+ } else if (footerLeft) {
511
+ hint = footer + ' '.repeat(cols - footer.length);
512
+ } else {
513
+ hint = padLeft(footer, cols);
514
+ }
515
+ out += BAR_BG + hint + '\x1b[0m\n';
367
516
  for (const l of infos) out += l + '\n';
368
517
  return out.replace(/\n$/, '');
369
518
  }
@@ -374,7 +523,9 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
374
523
  // q/Ctrl+C quit. Input mode offers readline-style caret editing.
375
524
  // Resolves on quit (null) or with the string 'switch-project' (caller then runs
376
525
  // the project switcher and re-invokes this picker for the new project).
377
- function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext } = {}) {
526
+ // `dir` names the project this list belongs to — it heads the title bar so a
527
+ // multi-project run always says which project's sessions are on screen.
528
+ function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [] } = {}) {
378
529
  return new Promise((resolve) => {
379
530
  if (!process.stdin.isTTY) return resolve(null);
380
531
 
@@ -390,10 +541,17 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
390
541
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
391
542
  let spinIdx = 0;
392
543
  let spinTimer = null;
544
+ let broadcastSel = new Set(); // session ids selected in broadcast mode
545
+ let search = newSearchState(); // '/' filter for the session list
546
+ let listSearch = newSearchState(); // '/' filter for inline lists (models/agents)
547
+ let view = rows; // rows filtered by `search` (what is displayed)
548
+ let inspected = false; // broadcast inspection moved the TUI off `current`
393
549
 
394
550
  const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
395
551
  const helpLines = [
396
- 'Enter / Space switch to the selected session (stays open)',
552
+ 'Enter switch to the selected session (stays open)',
553
+ 'Space start BROADCAST: add the selected session to the current one',
554
+ ' (Space toggles each session; Enter keeps the selection)',
397
555
  'n create a new session (type a name, Enter; blank = auto)',
398
556
  'r rename the selected session (edit in place; ESC cancels)',
399
557
  'R (Shift+r) set a REMINDER for the selected session (sent in front of',
@@ -402,6 +560,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
402
560
  'a switch the AGENT of the selected session',
403
561
  'm switch the MODEL of the selected session',
404
562
  'p open the project switcher',
563
+ '/ search/filter this list (Enter keeps the filter,',
564
+ ' ESC clears it, / resumes editing it)',
405
565
  'j / k, arrows move the cursor',
406
566
  'h toggle this help',
407
567
  'q / Ctrl+C quit',
@@ -437,21 +597,45 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
437
597
  };
438
598
 
439
599
  const draw = () => {
440
- if (mode === 'help') {
600
+ if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
441
601
  let out = '\x1b[2J\x1b[H';
442
- out += 'ocmux session picker — help\n\n';
443
- for (const l of helpLines) out += ' ' + l + '\n';
602
+ if (mode === 'broadcast-help') {
603
+ out += 'ocmux broadcast mode — help\n\n';
604
+ out += ' Space select/deselect the cursor session\n';
605
+ out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
606
+ out += ' m switch the model for ALL selected sessions\n';
607
+ out += ' d delete the cursor session (with confirmation)\n';
608
+ out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
609
+ out += ' / search/filter the session list\n';
610
+ out += ' ESC / q cancel broadcast mode (back to the session list)\n';
611
+ out += ' Ctrl+C quit ocmux\n';
612
+ } else if (mode === 'list-help') {
613
+ out += `ocmux ${list && list.title ? list.title : 'list'} — help\n\n`;
614
+ out += ' j / k, arrows move the cursor\n';
615
+ out += ' Enter / Space select the highlighted item\n';
616
+ out += ' / search/filter the list (Enter keeps, ESC clears)\n';
617
+ out += ' h toggle this help\n';
618
+ out += ' ESC / q / Backspace return to the previous menu\n';
619
+ out += ' Ctrl+C quit ocmux\n';
620
+ } else {
621
+ out += 'ocmux session picker — help\n\n';
622
+ for (const l of helpLines) out += ' ' + l + '\n';
623
+ }
444
624
  out += '\nPress any key to return.\n';
445
625
  process.stderr.write(out);
446
626
  return;
447
627
  }
448
628
  if (mode === 'list' && list) {
629
+ const listExtras = (listSearch.active && list.view.length === 0) ? [`No matches for "${listSearch.value}"`] : [];
449
630
  process.stderr.write(renderList({
450
631
  title: `ocmux — ${list.title}`,
451
- items: list.items,
632
+ items: list.view,
452
633
  cursor: listCursor,
453
634
  row: (i, item) => list.format(item) || '',
454
- footer: 'Enter: select · ESC: back',
635
+ footer: searchFooter(listSearch, 'Enter: select · /: search · h: help · ESC: back'),
636
+ footerLeft: listSearch.active,
637
+ footerRight: listSearch.active ? SEARCH_HINT : null,
638
+ extraLines: listExtras,
455
639
  }));
456
640
  return;
457
641
  }
@@ -462,30 +646,46 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
462
646
  Math.max(...rows.map(s => (s.title || s.id || '').length), 8) + 6,
463
647
  Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
464
648
  );
465
- const extras = [];
466
- if (mode === 'confirm' && confirmDel) {
649
+ const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
650
+ const extras = [...notices];
651
+ if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
467
652
  extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
468
653
  } else if (mode === 'input' && input) {
469
654
  extras.push(`${input.label} ${renderInputValue(input.value)}`);
470
655
  }
471
- const selected = rows[cursor];
472
- const selIsCurrent = !!(selected && String(selected.id) === String(current));
473
- const infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
656
+ if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
657
+ const selected = view[cursor];
658
+ let infoLines = [];
659
+ let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
660
+ if (inBc) {
661
+ const names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
662
+ infoLines = [`Broadcast to sessions: ${names}`];
663
+ footer = 'Enter: switch · Space: toggle · d: delete · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
664
+ } else {
665
+ const selIsCurrent = !!(selected && String(selected.id) === String(current));
666
+ infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
667
+ }
668
+ footer = searchFooter(search, footer);
474
669
  process.stderr.write(renderList({
475
- title: 'ocmux — sessions',
476
- items: rows,
670
+ // The project name heads the title so you always know which project's
671
+ // sessions are listed (matters when --all-projects moves you around).
672
+ title: dir ? `ocmux — ${path.basename(dir)} sessions` : 'ocmux — sessions',
673
+ items: view,
477
674
  cursor,
478
675
  row: (i, s) => {
676
+ const sel = inBc ? (broadcastSel.has(String(s.id)) ? '[x] ' : '[ ] ') : '';
479
677
  const title = (s.title || s.id || '').slice(0, maxLen);
480
678
  const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
481
679
  const mark = String(s.id) === String(current) ? ' *' : '';
482
680
  const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
483
681
  const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
484
- const text = `${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
485
- // Yellow for the session currently selected in the TUI window.
486
- return String(s.id) === String(current) ? `\x1b[33m${text}\x1b[0m` : text;
682
+ const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
683
+ // Brown for the session currently selected in the TUI window.
684
+ return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
487
685
  },
488
- footer: 'Enter: switch · h: help · q: quit',
686
+ footer,
687
+ footerLeft: search.active,
688
+ footerRight: search.active ? SEARCH_HINT : null,
489
689
  infoLines,
490
690
  extraLines: extras,
491
691
  }));
@@ -498,7 +698,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
498
698
  };
499
699
 
500
700
  const applyRenameWith = (name) => {
501
- const row = rows[cursor];
701
+ const row = view[cursor];
502
702
  if (!row || !onRename || !name) return;
503
703
  Promise.resolve(onRename(row, name)).then((updated) => {
504
704
  row.title = (updated && updated.title) || name;
@@ -510,7 +710,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
510
710
  };
511
711
 
512
712
  const applyReminder = (text) => {
513
- const row = rows[cursor];
713
+ const row = view[cursor];
514
714
  if (!row || !onReminder) return;
515
715
  Promise.resolve(onReminder(row, text)).then((saved) => {
516
716
  if (saved) annotations[row.id] = saved;
@@ -526,11 +726,14 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
526
726
  Promise.resolve(onPick ? onPick(row) : null).then((created) => {
527
727
  if (row.new && created) {
528
728
  rows.unshift(created);
729
+ applySearch();
730
+ const ci = view.indexOf(created);
731
+ cursor = ci >= 0 ? ci : 0;
529
732
  current = created.id;
530
- cursor = 0;
531
733
  } else if (!row.new) {
532
734
  current = row.id;
533
735
  }
736
+ inspected = false;
534
737
  confirmDel = null;
535
738
  draw();
536
739
  }).catch((err) => {
@@ -543,28 +746,105 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
543
746
  const applyDelete = () => {
544
747
  const s = confirmDel;
545
748
  if (!s || !onDelete) return;
749
+ const wasBroadcast = mode === 'broadcast-confirm';
546
750
  Promise.resolve(onDelete(s)).then(() => {
547
751
  rows = rows.filter(r => r.id !== s.id);
548
- if (cursor >= rows.length && cursor > 0) cursor = rows.length - 1;
752
+ applySearch();
753
+ if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
549
754
  confirmDel = null;
550
- mode = 'normal';
551
- draw();
755
+ if (wasBroadcast) {
756
+ broadcastSel.delete(String(s.id));
757
+ if (broadcastSel.size < 2) {
758
+ // Broadcast ended: make the remaining selected session the real
759
+ // selection (switch the TUI to it and store it).
760
+ const remaining = [...broadcastSel][0];
761
+ persistBroadcast([]);
762
+ broadcastSel = new Set();
763
+ mode = 'normal';
764
+ const row = rows.find(r => String(r.id) === String(remaining));
765
+ if (row) applyPick(row);
766
+ else { draw(); }
767
+ } else {
768
+ persistBroadcast([...broadcastSel]);
769
+ mode = 'broadcast';
770
+ draw();
771
+ }
772
+ } else {
773
+ mode = 'normal';
774
+ draw();
775
+ }
552
776
  }).catch((err) => {
553
777
  process.stderr.write(`\nError: ${err.message}\n`);
554
778
  confirmDel = null;
555
- mode = 'normal';
779
+ mode = wasBroadcast ? 'broadcast' : 'normal';
556
780
  draw();
557
781
  });
558
782
  };
559
783
 
560
- const openList = (title, items, format, onEnter) => {
784
+ const persistBroadcast = (ids) => {
785
+ if (onBroadcastChange) onBroadcastChange(ids);
786
+ };
787
+ // Recompute the filtered session view after `rows` or the pattern change.
788
+ const applySearch = () => {
789
+ view = filterByPattern(rows, search.active ? search.value : '',
790
+ (r) => `${r.title || ''} ${r.id || ''}`);
791
+ if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
792
+ };
793
+ // Recompute the filtered view of the currently open inline list.
794
+ const applyListSearch = () => {
795
+ if (!list) return;
796
+ list.view = filterByPattern(list.items, listSearch.active ? listSearch.value : '', list.format);
797
+ if (listCursor >= list.view.length) listCursor = Math.max(0, list.view.length - 1);
798
+ };
799
+ // After a broadcast attempt (cancel / drop below two): put the menu — and
800
+ // the TUI — back on the stored current session, which inspection may have
801
+ // relaunched away without updating `.ocmux.json`.
802
+ const restoreCurrent = () => {
803
+ const idx = view.findIndex((r) => String(r.id) === String(current));
804
+ if (idx >= 0) cursor = idx;
805
+ if (inspected && current != null && onInspectSession) onInspectSession(current);
806
+ inspected = false;
807
+ };
808
+ const startBroadcast = (idx) => {
809
+ broadcastSel = new Set([String(current), String(view[idx].id)]);
810
+ persistBroadcast([...broadcastSel]);
811
+ mode = 'broadcast';
812
+ if (onInspectSession) { onInspectSession(view[idx].id); inspected = true; }
813
+ draw();
814
+ };
815
+ const toggleBroadcast = (idx) => {
816
+ const id = String(view[idx].id);
817
+ const adding = !broadcastSel.has(id);
818
+ if (adding) broadcastSel.add(id);
819
+ else broadcastSel.delete(id);
820
+ if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
821
+ if (broadcastSel.size < 2) {
822
+ // Deselecting down to one session ends broadcast mode by making that
823
+ // remaining session the actual selection.
824
+ const remaining = [...broadcastSel][0];
825
+ persistBroadcast([]);
826
+ broadcastSel = new Set();
827
+ mode = 'normal';
828
+ const row = rows.find(r => String(r.id) === String(remaining));
829
+ if (row) applyPick(row);
830
+ else { restoreCurrent(); draw(); }
831
+ return;
832
+ }
833
+ persistBroadcast([...broadcastSel]);
834
+ draw();
835
+ };
836
+
837
+ // `startCursor` lets a picker open with the cursor already on the relevant
838
+ // entry (the model pickers open on the current model).
839
+ const openList = (title, items, format, onEnter, returnMode = 'normal', startCursor = 0) => {
561
840
  if (!items || items.length === 0) {
562
841
  process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
563
842
  draw();
564
843
  return;
565
844
  }
566
- list = { title, items, format, onEnter };
567
- listCursor = 0;
845
+ list = { title, items, view: items, format, onEnter, returnMode };
846
+ listSearch = newSearchState();
847
+ listCursor = Math.max(0, Math.min(startCursor, items.length - 1));
568
848
  mode = 'list';
569
849
  draw();
570
850
  };
@@ -574,17 +854,36 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
574
854
  draw();
575
855
  };
576
856
 
857
+ // Open the model picker with the list sorted by provider and the cursor
858
+ // already on `session`'s current model (so `m` starts where the change is).
859
+ const openModelPicker = (title, session, returnMode, onPick) => {
860
+ listModels(server).then((models) => {
861
+ const sorted = sortModels(models);
862
+ openList(title, sorted, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`,
863
+ onPick, returnMode, indexOfModel(sorted, session && session.model));
864
+ }).catch(commandErr);
865
+ };
866
+
577
867
  process.stdin.on('keypress', (str, key) => {
578
- if (mode === 'help') {
579
- mode = 'normal';
868
+ // Ctrl+C always fully exits ocmux, from any menu.
869
+ if (key.ctrl && key.name === 'c') {
870
+ if (mode === 'broadcast' || mode === 'broadcast-confirm') {
871
+ persistBroadcast([]);
872
+ broadcastSel = new Set();
873
+ restoreCurrent();
874
+ }
875
+ return finish(null);
876
+ }
877
+ if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
878
+ mode = mode === 'broadcast-help' ? 'broadcast' : (mode === 'list-help' ? 'list' : 'normal');
580
879
  draw();
581
880
  return;
582
881
  }
583
- if (mode === 'confirm') {
882
+ if (mode === 'confirm' || mode === 'broadcast-confirm') {
584
883
  if (key.name === 'y') return applyDelete();
585
884
  if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
586
885
  confirmDel = null;
587
- mode = 'normal';
886
+ mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
588
887
  draw();
589
888
  }
590
889
  return;
@@ -638,27 +937,81 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
638
937
  return;
639
938
  }
640
939
  if (mode === 'list') {
940
+ const lr = handleSearch(str, key, listSearch);
941
+ if (lr !== 'pass') { applyListSearch(); draw(); return; }
641
942
  if (key.name === 'escape' || key.name === 'q' || key.name === 'backspace') {
943
+ const returnMode = list.returnMode;
642
944
  list = null;
643
- mode = 'normal';
945
+ mode = returnMode;
946
+ draw();
947
+ } else if (key.name === 'h') {
948
+ mode = 'list-help';
644
949
  draw();
645
950
  } else if (key.name === 'up' || str === 'k') {
646
- listCursor = (listCursor - 1 + list.items.length) % list.items.length;
951
+ if (list.view.length) listCursor = (listCursor - 1 + list.view.length) % list.view.length;
647
952
  draw();
648
953
  } else if (key.name === 'down' || str === 'j') {
649
- listCursor = (listCursor + 1) % list.items.length;
954
+ if (list.view.length) listCursor = (listCursor + 1) % list.view.length;
650
955
  draw();
651
956
  } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
652
- const item = list.items[listCursor];
957
+ const item = list.view[listCursor];
958
+ if (item === undefined) { draw(); return; }
653
959
  const onEnter = list.onEnter;
960
+ const returnMode = list.returnMode;
654
961
  list = null;
655
- mode = 'normal';
962
+ mode = returnMode;
656
963
  draw();
657
964
  Promise.resolve(onEnter(item)).then(() => draw()).catch(commandErr);
658
965
  }
659
966
  return;
660
967
  }
661
- if (key.ctrl && key.name === 'c') return finish(null);
968
+ const sr = handleSearch(str, key, search);
969
+ if (sr !== 'pass') { applySearch(); draw(); return; }
970
+ if (mode === 'broadcast') {
971
+ if (key.name === 'escape') {
972
+ persistBroadcast([]);
973
+ broadcastSel = new Set();
974
+ mode = 'normal';
975
+ restoreCurrent();
976
+ draw();
977
+ } else if (key.name === 'q') {
978
+ // 'q' only quits from the plain session list; in broadcast mode it
979
+ // cancels back to that list (Ctrl+C quits from anywhere).
980
+ persistBroadcast([]);
981
+ broadcastSel = new Set();
982
+ mode = 'normal';
983
+ restoreCurrent();
984
+ draw();
985
+ } else if (key.name === 'h') {
986
+ mode = 'broadcast-help';
987
+ draw();
988
+ } else if (key.name === 'space') {
989
+ if (cursor < view.length) toggleBroadcast(cursor);
990
+ } else if (key.name === 'd') {
991
+ if (cursor < view.length) {
992
+ confirmDel = view[cursor];
993
+ mode = 'broadcast-confirm';
994
+ draw();
995
+ }
996
+ } else if (key.name === 'm') {
997
+ openModelPicker('Broadcast model (all selected sessions)', view[cursor], 'broadcast', async (item) => {
998
+ await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
999
+ });
1000
+ } else if (key.name === 'return' || key.name === 'enter') {
1001
+ if (cursor < view.length) {
1002
+ mode = 'normal';
1003
+ broadcastSel = new Set(); // selection stays persisted for agentp
1004
+ applyPick(view[cursor]);
1005
+ }
1006
+ } else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
1007
+ cursor = (cursor - 1 + view.length) % view.length;
1008
+ draw();
1009
+ } else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
1010
+ cursor = (cursor + 1) % view.length;
1011
+ draw();
1012
+ }
1013
+ return;
1014
+ }
662
1015
  if (key.name === 'q') return finish(null);
663
1016
  if (key.name === 'p') return finish('switch-project');
664
1017
  if (key.name === 'h') {
@@ -672,17 +1025,17 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
672
1025
  }
673
1026
  // Shift+r → Reminder; lowercase r → Rename.
674
1027
  if (str === 'R') {
675
- const row = rows[cursor];
1028
+ const row = view[cursor];
676
1029
  const note = (row && annotations && annotations[row.id]) || '';
677
1030
  startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
678
1031
  return;
679
1032
  }
680
- if (key.name === 'r' && cursor < rows.length) {
681
- startInput('Rename to (ESC cancels):', rows[cursor].title || '', 'rename');
1033
+ if (key.name === 'r' && cursor < view.length) {
1034
+ startInput('Rename to (ESC cancels):', view[cursor].title || '', 'rename');
682
1035
  return;
683
1036
  }
684
1037
  if (key.name === 'a') {
685
- const target = rows[cursor];
1038
+ const target = view[cursor];
686
1039
  if (target) {
687
1040
  listAgents(server).then((all) => {
688
1041
  const primary = all.filter(x => x.mode === 'primary' && !x.hidden);
@@ -694,31 +1047,33 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
694
1047
  return;
695
1048
  }
696
1049
  if (key.name === 'm') {
697
- const target = rows[cursor];
698
- if (target) {
699
- listModels(server).then((models) => {
700
- openList('Models', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, (item) =>
701
- switchModel(server, target.id, item));
702
- }).catch(commandErr);
703
- }
1050
+ const target = view[cursor];
1051
+ if (target) openModelPicker('Models', target, 'normal', (item) => switchModel(server, target.id, item));
704
1052
  return;
705
1053
  }
706
1054
  if (key.name === 'd') {
707
- if (cursor < rows.length) {
708
- confirmDel = rows[cursor];
1055
+ if (cursor < view.length) {
1056
+ confirmDel = view[cursor];
709
1057
  mode = 'confirm';
710
1058
  draw();
711
1059
  }
712
1060
  return;
713
1061
  }
714
- if (rows.length > 0 && (key.name === 'up' || str === 'k')) {
715
- cursor = (cursor - 1 + rows.length) % rows.length;
1062
+ if (view.length > 0 && (key.name === 'up' || str === 'k')) {
1063
+ cursor = (cursor - 1 + view.length) % view.length;
716
1064
  draw();
717
- } else if (rows.length > 0 && (key.name === 'down' || str === 'j')) {
718
- cursor = (cursor + 1) % rows.length;
1065
+ } else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
1066
+ cursor = (cursor + 1) % view.length;
719
1067
  draw();
720
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
721
- if (cursor < rows.length) applyPick(rows[cursor]);
1068
+ } else if (key.name === 'return' || key.name === 'enter') {
1069
+ if (cursor < view.length) applyPick(view[cursor]);
1070
+ } else if (key.name === 'space') {
1071
+ // Start broadcast only when adding a DIFFERENT session to the current one.
1072
+ if (cursor < view.length && String(view[cursor].id) !== String(current)) {
1073
+ startBroadcast(cursor);
1074
+ } else if (cursor < view.length) {
1075
+ draw(); // no-op on the already-selected session
1076
+ }
722
1077
  }
723
1078
  });
724
1079
 
@@ -735,8 +1090,19 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
735
1090
  });
736
1091
  }
737
1092
 
738
- // Interactive project picker — purely visual/navigation, never writes state.
739
- function switchMenu(rows, longMode) {
1093
+ // Interactive project picker — never writes state (it only moves what a
1094
+ // registered dedicated/shared TUI displays).
1095
+ //
1096
+ // Default (safe): an INSPECTOR. Selecting a project/session row still focuses
1097
+ // the routed TUI and shows the session there, but the promise always resolves to
1098
+ // null, so the caller re-opens its own project's list. Nothing about the
1099
+ // caller's project changes.
1100
+ //
1101
+ // `{ allProjects: true }` (CLI `--all-projects`): a real SWITCHER. Selecting a
1102
+ // row also records the target directory, so the caller moves its picker there
1103
+ // and can then write that project's `.ocmux.json` — the file `agentp` reads in
1104
+ // that directory.
1105
+ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
740
1106
  return new Promise((resolve) => {
741
1107
  if (!process.stdin.isTTY) {
742
1108
  console.error('Error: "switch" is interactive and requires a TTY.');
@@ -756,17 +1122,53 @@ function switchMenu(rows, longMode) {
756
1122
  const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
757
1123
 
758
1124
  let cursor = 0;
759
- let activeRow = 0;
760
- let lastDir = null;
1125
+ let lastDir = null; // target dir to move to (only with allProjects)
761
1126
  let done = false;
762
-
763
- const refreshActive = () => {
764
- const idx = activeWindowIndex();
765
- if (idx == null) return;
766
- for (let i = 0; i < rows.length; i++) {
767
- if (rows[i].index === idx) { activeRow = i; return; }
1127
+ let help = false;
1128
+ const search = newSearchState();
1129
+ let view = []; // flattened nodes: { kind:'project'|'session', ... }
1130
+ // Fold state per project directory: { open, sessions, loading, error }.
1131
+ const folds = new Map();
1132
+
1133
+ const foldOf = (p) => folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
1134
+ const projectText = (p) => `${path.basename(p.dir)} ${p.dir} ${p.session || ''}`;
1135
+ const sessionText = (s) => `${s.title || ''} ${s.id || ''}`;
1136
+ const matches = (pattern, text) => filterByPattern([1], pattern, () => text).length > 0;
1137
+
1138
+ // Flatten projects (plus their unfolded sessions) honouring the '/' pattern.
1139
+ // A project header survives when it matches itself OR when one of its
1140
+ // unfolded sessions does, so a match deeper down is still navigable.
1141
+ const buildView = () => {
1142
+ const pattern = search.active ? search.value : '';
1143
+ const out = [];
1144
+ for (const p of rows) {
1145
+ const f = foldOf(p);
1146
+ const sessions = f.open && f.sessions ? f.sessions : [];
1147
+ const shown = sessions.filter((s) => matches(pattern, sessionText(s)));
1148
+ const header = { kind: 'project', project: p };
1149
+ if (sessions.length === 0) {
1150
+ if (matches(pattern, projectText(p))) out.push(header);
1151
+ } else if (matches(pattern, projectText(p)) || shown.length) {
1152
+ out.push(header);
1153
+ for (const s of shown) out.push({ kind: 'session', project: p, session: s });
1154
+ }
768
1155
  }
1156
+ return out;
1157
+ };
1158
+
1159
+ const sameNode = (a, b) => {
1160
+ if (!a || !b || a.kind !== b.kind || a.project.dir !== b.project.dir) return false;
1161
+ return a.kind !== 'session' || a.session.id === b.session.id;
1162
+ };
1163
+
1164
+ const applySearch = () => {
1165
+ const prev = view[cursor];
1166
+ view = buildView();
1167
+ const idx = prev ? view.findIndex((n) => sameNode(n, prev)) : -1;
1168
+ if (idx >= 0) cursor = idx;
1169
+ else if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
769
1170
  };
1171
+ applySearch();
770
1172
 
771
1173
  const cleanup = () => {
772
1174
  process.stderr.write('\x1b[?25h');
@@ -775,58 +1177,165 @@ function switchMenu(rows, longMode) {
775
1177
  try { process.stdin.setRawMode(false); } catch {}
776
1178
  };
777
1179
 
778
- const finish = () => {
1180
+ const finish = (value) => {
779
1181
  if (done) return;
780
1182
  done = true;
781
1183
  cleanup();
782
1184
  process.stdin.pause();
783
- resolve(lastDir);
1185
+ process.stdin.removeAllListeners('keypress');
1186
+ process.removeListener('SIGINT', onSig);
1187
+ resolve(value === undefined ? lastDir : value);
784
1188
  };
785
1189
 
1190
+ // Mode-dependent wording: the switcher inspects by default and really
1191
+ // switches only under --all-projects.
1192
+ const switcherTitle = allProjects ? 'ocmux — project switcher (all projects)' : 'ocmux — project switcher';
1193
+ const enterHint = allProjects ? 'Enter: switch' : 'Enter: view';
1194
+ const modeNote = allProjects
1195
+ ? ['--all-projects: selecting moves the picker to that project, so a',
1196
+ 'session picked there updates ITS .ocmux.json — the file agentp',
1197
+ 'reads when it runs in that directory.']
1198
+ : ['Selecting only moves the TUI view — .ocmux.json is never',
1199
+ 'written, so agentp keeps prompting your own project’s session.',
1200
+ 'You always come back to your own project’s list.'];
1201
+
786
1202
  const draw = () => {
787
- refreshActive();
1203
+ if (done) return;
1204
+ if (help) {
1205
+ let out = '\x1b[2J\x1b[H';
1206
+ out += 'ocmux project switcher — help\n\n';
1207
+ out += ' j / k, arrows move the cursor (projects and sessions)\n';
1208
+ out += ` Enter ${allProjects ? 'switch the picker to the selected project' : 'focus the selected project (menu stays open)'}\n`;
1209
+ out += ' (on a session, show it in that project’s TUI)\n';
1210
+ out += ' Space fold/unfold a project’s sessions;\n';
1211
+ out += ' on a session, show it in the TUI as well\n';
1212
+ out += ' / search/filter the list (Enter keeps, ESC clears)\n';
1213
+ out += ' h toggle this help\n';
1214
+ out += ' q return to the session menu\n';
1215
+ out += ' Ctrl+C quit ocmux\n';
1216
+ out += '\n';
1217
+ for (const l of modeNote) out += `${l}\n`;
1218
+ out += '\nPress any key to return.\n';
1219
+ process.stderr.write(out);
1220
+ return;
1221
+ }
1222
+ const extras = [];
1223
+ if (search.active && view.length === 0) extras.push(`No projects match "${search.value}"`);
1224
+ for (const n of view) {
1225
+ if (n.kind !== 'project') continue;
1226
+ const f = folds.get(n.project.dir);
1227
+ if (!f || !f.open) continue;
1228
+ const name = path.basename(n.project.dir);
1229
+ if (f.loading) extras.push(` … loading sessions of ${name} …`);
1230
+ else if (f.error) extras.push(` ! ${name}: ${f.error}`);
1231
+ }
788
1232
  process.stderr.write(renderList({
789
- title: 'ocmux — project switcher',
790
- items: rows,
1233
+ title: switcherTitle,
1234
+ items: view,
791
1235
  cursor,
792
- row: (i, row) => {
793
- const session = row.session ? row.session.slice(0, 14) : '(none)';
794
- const text = `${pad(path.basename(row.dir), nameW)} ${pad(row.status, stsW)} ${pad(session, ssnW)} ${row.dir}`;
795
- return i === activeRow ? { text, reverse: true } : text;
1236
+ row: (i, node) => {
1237
+ if (node.kind === 'session') {
1238
+ const s = node.session;
1239
+ const id = String(s.id || '');
1240
+ const mark = String(s.id) === String(node.project.session) ? ' *' : '';
1241
+ const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
1242
+ return ` ${title}${mark} ${id}`;
1243
+ }
1244
+ const p = node.project;
1245
+ const fold = foldOf(p).open ? '▾' : '▸';
1246
+ const session = p.session ? p.session.slice(0, 14) : '(none)';
1247
+ const text = `${fold} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
1248
+ return text;
796
1249
  },
797
- footer: 'Enter: focus · q: quit',
1250
+ footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
1251
+ footerLeft: search.active,
1252
+ footerRight: search.active ? SEARCH_HINT : null,
1253
+ extraLines: extras,
798
1254
  }));
799
1255
  };
800
1256
 
801
- const activate = (i) => {
802
- const row = rows[i];
1257
+ // Enter on a project row: show its current session in its routed TUI. With
1258
+ // allProjects off this stays a pure inspection and
1259
+ // `lastDir` is left null so the caller returns to its own project.
1260
+ const activate = (p) => {
1261
+ if (!p) return;
803
1262
  try {
804
- if (row.server) {
805
- activateProject(row.dir, row.index, row.server, row.session);
806
- } else {
807
- tmux(['select-window', '-t', `${SESSION}:${row.index}`]);
808
- }
809
- lastDir = row.dir;
1263
+ if (p.server && p.session && onOpen) onOpen(p, p.session);
1264
+ if (allProjects) lastDir = p.dir;
1265
+ } catch (err) {
1266
+ process.stderr.write(`\nError: ${err.message}\n`);
1267
+ }
1268
+ draw();
1269
+ };
1270
+
1271
+ // Enter/Space on a session row: move the TUI VIEW onto it. The switcher
1272
+ // itself never writes `.ocmux.json`; with allProjects on the caller only
1273
+ // moves its picker to that project — the write happens later, when a
1274
+ // session is picked there (and that file is what agentp reads there).
1275
+ const openSession = (node) => {
1276
+ const p = node.project;
1277
+ try {
1278
+ if (p.server && onOpen) onOpen(p, node.session.id);
1279
+ if (allProjects) lastDir = p.dir;
810
1280
  } catch (err) {
811
1281
  process.stderr.write(`\nError: ${err.message}\n`);
812
1282
  }
813
1283
  draw();
814
1284
  };
815
1285
 
816
- const onSig = () => finish();
1286
+ // Space on a project row: fetch its sessions once, then flip the fold.
1287
+ const toggleFold = (p) => {
1288
+ const f = folds.get(p.dir) || { open: false, sessions: null, loading: false, error: null };
1289
+ folds.set(p.dir, f);
1290
+ if (!f.open && !f.sessions && !f.error && !f.loading) {
1291
+ if (!p.server) {
1292
+ f.error = 'no server recorded';
1293
+ } else {
1294
+ f.loading = true;
1295
+ f.open = true;
1296
+ applySearch();
1297
+ draw();
1298
+ listSessions(p.server, p.dir)
1299
+ .then((list) => {
1300
+ // Main sessions only: child/subagent sessions are work products,
1301
+ // not navigation targets (same rule as the session picker).
1302
+ f.sessions = sortSessionsByRecency((list || []).filter((s) => !s.parentID));
1303
+ })
1304
+ .catch((err) => { f.error = err.message; })
1305
+ .then(() => { f.loading = false; applySearch(); draw(); });
1306
+ return;
1307
+ }
1308
+ }
1309
+ f.open = !f.open;
1310
+ applySearch();
1311
+ draw();
1312
+ };
1313
+
1314
+ const onSig = () => finish(SWITCH_QUIT);
817
1315
  process.on('SIGINT', onSig);
818
1316
 
819
1317
  process.stdin.on('keypress', (str, key) => {
820
- if (key.ctrl && key.name === 'c') return finish();
1318
+ // Ctrl+C fully exits ocmux; 'q' only closes this menu (back to sessions).
1319
+ if (key.ctrl && key.name === 'c') return finish(SWITCH_QUIT);
1320
+ if (help) { help = false; draw(); return; }
1321
+ const sr = handleSearch(str, key, search);
1322
+ if (sr !== 'pass') { applySearch(); draw(); return; }
1323
+ if (key.name === 'h') { help = true; draw(); return; }
821
1324
  if (key.name === 'q') return finish();
1325
+ if (view.length === 0) return;
1326
+ const node = view[cursor];
822
1327
  if (key.name === 'up' || str === 'k') {
823
- cursor = (cursor + rows.length - 1) % rows.length;
1328
+ cursor = (cursor + view.length - 1) % view.length;
824
1329
  draw();
825
1330
  } else if (key.name === 'down' || str === 'j') {
826
- cursor = (cursor + 1) % rows.length;
1331
+ cursor = (cursor + 1) % view.length;
827
1332
  draw();
828
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
829
- activate(cursor);
1333
+ } else if (key.name === 'return' || key.name === 'enter') {
1334
+ if (node.kind === 'project') activate(node.project);
1335
+ else openSession(node);
1336
+ } else if (key.name === 'space') {
1337
+ if (node.kind === 'project') toggleFold(node.project);
1338
+ else openSession(node);
830
1339
  }
831
1340
  });
832
1341
 
@@ -843,13 +1352,18 @@ function switchMenu(rows, longMode) {
843
1352
  // Subcommand: default — interactive session switcher for the current project
844
1353
  // ---------------------------------------------------------------------------
845
1354
 
846
- async function cmdSessionSwitch(targetDir, directRef) {
1355
+ // `allProjects` (CLI `--all-projects`) unlocks cross-project switching: the
1356
+ // project switcher may then move this picker onto another project, whose
1357
+ // `.ocmux.json` gets written when a session is picked there. Without it the
1358
+ // switcher is inspection-only and this command always stays on `targetDir`'s
1359
+ // project.
1360
+ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
847
1361
  let target = targetDir;
848
1362
  for (;;) {
849
1363
  const sf = findParentStatefile(target);
850
1364
  if (!sf) {
851
1365
  console.error(`Error: no .ocmux.json found in ${target} or any of its parents.`);
852
- console.error(`Run 'ocmux serve' to create a project window.`);
1366
+ console.error(`Run 'ocmux serve' to initialize the project.`);
853
1367
  process.exit(1);
854
1368
  }
855
1369
  const state = projectState.readProjectState(sf);
@@ -868,22 +1382,28 @@ async function cmdSessionSwitch(targetDir, directRef) {
868
1382
  process.exit(1);
869
1383
  }
870
1384
 
871
- const winIdx = windowByDir(dir);
872
- if (winIdx == null) {
873
- console.error(`Error: no tmux window for ${dir}.`);
874
- console.error(`Run 'ocmux serve' to create it.`);
875
- process.exit(1);
876
- }
877
-
878
- // Focus this project's window up-front so the picker (and any session
879
- // switch) never leaves another project's window selected.
880
- activateProject(dir, winIdx, server, state.session);
881
-
882
1385
  const sessions = await listSessions(server, dir).catch((err) => {
883
1386
  console.error(`Error connecting to ${server}: ${err.message}`);
884
1387
  process.exit(1);
885
1388
  });
886
1389
  const sorted = sortSessionsByRecency(sessions);
1390
+ const notices = [];
1391
+ if (state.session && !sorted.some(s => String(s.id) === String(state.session))) {
1392
+ try {
1393
+ const moved = await getSession(server, state.session);
1394
+ const movedDir = moved && moved.location && moved.location.directory;
1395
+ if (movedDir && path.resolve(movedDir) !== path.resolve(dir)) {
1396
+ notices.push(`⚠ Current session moved to ${movedDir}`);
1397
+ notices.push(` Run: ocmux serve ${JSON.stringify(movedDir)} --server ${JSON.stringify(server)}`);
1398
+ }
1399
+ } catch {
1400
+ // A deleted/stale stored session is handled by the normal picker.
1401
+ }
1402
+ }
1403
+ // Only "main" sessions are listed in the picker: exclude child/subagent
1404
+ // sessions (parentID set) — they are work products of a main session, not
1405
+ // targets. Explicit --session can still reach one by id/title.
1406
+ const mainSessions = sorted.filter(s => !s.parentID);
887
1407
  const annotations = projectState.readAnnotations(sf);
888
1408
  const runningIds = await getActiveSessions(server);
889
1409
  let modelContext = new Map();
@@ -894,7 +1414,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
894
1414
 
895
1415
  const applySession = (id) => {
896
1416
  projectState.writeProjectState(sf, { session: id });
897
- relaunchTui(winIdx, dir, server, id);
1417
+ refreshTui(dir, server, id);
898
1418
  };
899
1419
 
900
1420
  if (directRef) {
@@ -910,19 +1430,22 @@ async function cmdSessionSwitch(targetDir, directRef) {
910
1430
  }
911
1431
 
912
1432
  if (!process.stdin.isTTY) {
913
- // Silent path (non-TTY): just make sure the window is focused.
914
- activateProject(dir, winIdx, server, state.session);
1433
+ // Silent path (non-TTY): project state already identifies the target.
915
1434
  return;
916
1435
  }
917
1436
 
918
1437
  // Interactive: Enter switches but stays open; n creates (inheriting the
919
1438
  // model of the previously selected session); r renames; R sets a reminder;
920
1439
  // d deletes; a switches agent; m switches model; p switches project; q quits.
921
- let lastRef = (state.session && sorted.find(s => String(s.id) === String(state.session))) || sorted[0] || null;
1440
+ let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
922
1441
  const result = await new Promise((resolve) => {
923
- sessionMenu(sorted, state.session, annotations, server, {
1442
+ sessionMenu(mainSessions, state.session, annotations, server, {
1443
+ dir,
924
1444
  runningIds,
925
1445
  modelContext,
1446
+ notices,
1447
+ onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
1448
+ onInspectSession: (id) => refreshTui(dir, server, id),
926
1449
  onPick: async (row) => {
927
1450
  if (row.new) {
928
1451
  const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
@@ -963,12 +1486,21 @@ async function cmdSessionSwitch(targetDir, directRef) {
963
1486
  });
964
1487
 
965
1488
  if (result === 'switch-project') {
966
- // 'p' pressed: open the project switcher, then re-run this picker for
967
- // the project that was just activated (if any).
968
- ensureSession();
969
- const lastDir = await switchMenu(listProjects());
970
- if (!lastDir) break;
971
- target = lastDir;
1489
+ // 'p' pressed: open the project switcher. Without --all-projects it only
1490
+ // inspects (picked stays null => re-run this picker for the same
1491
+ // project); with it, picking a project re-runs the picker there.
1492
+ // Ctrl+C fully exits.
1493
+ const rows = await configuredProjects(server).catch((err) => {
1494
+ console.error(`Error listing projects: ${err.message}`);
1495
+ return [];
1496
+ });
1497
+ const picked = await switchMenu(rows, {
1498
+ allProjects,
1499
+ onOpen: (project, id) => refreshTui(project.dir, project.server, id),
1500
+ });
1501
+ if (picked === SWITCH_QUIT) break;
1502
+ if (!picked) continue;
1503
+ target = picked;
972
1504
  continue;
973
1505
  }
974
1506
  break;
@@ -976,7 +1508,7 @@ async function cmdSessionSwitch(targetDir, directRef) {
976
1508
  }
977
1509
 
978
1510
  // ---------------------------------------------------------------------------
979
- // Subcommand: serve — create a project TUI window (server is user-managed)
1511
+ // Subcommand: serve — initialize project/session state (server is user-managed)
980
1512
  // ---------------------------------------------------------------------------
981
1513
 
982
1514
  async function cmdServeNew(targetDir, serverArg, force) {
@@ -985,7 +1517,7 @@ async function cmdServeNew(targetDir, serverArg, force) {
985
1517
  const sf = path.join(targetDir, '.ocmux.json');
986
1518
  if (fs.existsSync(sf)) {
987
1519
  if (!force) {
988
- console.error(`Error: a project window already exists in ${targetDir}.`);
1520
+ console.error(`Error: project state already exists in ${targetDir}.`);
989
1521
  console.error(`Use 'ocmux' to switch sessions there, or 'ocmux serve --server <url> --force' to repoint it.`);
990
1522
  process.exit(1);
991
1523
  }
@@ -995,11 +1527,10 @@ async function cmdServeNew(targetDir, serverArg, force) {
995
1527
  const parent = findParentStatefile(path.dirname(targetDir));
996
1528
  if (parent) {
997
1529
  const parentDir = path.dirname(parent);
998
- console.error(`Warning: there is already an opencode project in ${parentDir}.`);
999
- console.error(`Run 'ocmux' and press 'p' to move between project windows.`);
1530
+ console.error(`Warning: there is already an ocmux project in ${parentDir}.`);
1000
1531
 
1001
1532
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
1002
- rl.question('Are you sure you want to create a new project window here? [y/N] ', (answer) => {
1533
+ rl.question('Are you sure you want to initialize a nested project here? [y/N] ', (answer) => {
1003
1534
  rl.close();
1004
1535
  if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
1005
1536
  console.error('Cancelled.');
@@ -1015,8 +1546,8 @@ async function cmdServeNew(targetDir, serverArg, force) {
1015
1546
  return doServeNew(targetDir, serverArg);
1016
1547
  }
1017
1548
 
1018
- // Repoint an existing project to a (possibly new) server: update .ocmux.json's
1019
- // `server`, keep the session if it still exists, and (re)launch the TUI on it.
1549
+ // Repoint an existing project to a (possibly new) server. A registered TUI is
1550
+ // refreshed through the normal dedicated → shared routing rule.
1020
1551
  async function doForceServe(targetDir, sf, server) {
1021
1552
  const alive = await checkServer(server);
1022
1553
  if (!alive) {
@@ -1037,14 +1568,8 @@ async function doForceServe(targetDir, sf, server) {
1037
1568
  session = created.id;
1038
1569
  }
1039
1570
  projectState.writeProjectState(sf, { server, session });
1040
- const winIdx = windowByDir(targetDir);
1041
- if (winIdx != null) {
1042
- relaunchTui(winIdx, targetDir, server, session);
1043
- console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
1044
- } else {
1045
- createProjectWindow(targetDir, server, session);
1046
- console.error(`Recreated project window for ${targetDir} → ${server} (session ${session}).`);
1047
- }
1571
+ refreshTui(targetDir, server, session);
1572
+ console.error(`Repointed ${targetDir} to ${server} (session ${session}).`);
1048
1573
  }
1049
1574
 
1050
1575
  async function doServeNew(targetDir, serverArg) {
@@ -1076,108 +1601,181 @@ async function doServeNew(targetDir, serverArg) {
1076
1601
  }
1077
1602
  }
1078
1603
 
1079
- console.error(`Creating project window for ${targetDir} (session ${session})...`);
1080
- try {
1081
- createProjectWindow(targetDir, server, session);
1082
- } catch (err) {
1083
- console.error(`Error: ${err.message}`);
1084
- process.exit(1);
1085
- }
1604
+ projectState.writeProjectState(path.join(targetDir, '.ocmux.json'), {
1605
+ directory: targetDir,
1606
+ server,
1607
+ session,
1608
+ status: 'active',
1609
+ });
1610
+ refreshTui(targetDir, server, session);
1611
+ console.error(`Initialized ${targetDir} → ${server} (session ${session}).`);
1086
1612
  }
1087
1613
 
1088
1614
  // ---------------------------------------------------------------------------
1089
- // Subcommand: list
1615
+ // Subcommand: tui — register this tmux pane and host an OpenCode TUI
1090
1616
  // ---------------------------------------------------------------------------
1091
1617
 
1092
- function cmdList(longMode) {
1093
- const rows = listProjects();
1094
- if (rows.length === 0) {
1095
- console.log('No opencode projects running.');
1096
- return;
1097
- }
1098
-
1099
- const dirW = Math.max(...rows.map(r => r.dir.length), 9);
1100
- const stsW = Math.max(...rows.map(r => r.status.length), 6);
1101
- const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
1102
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1103
-
1104
- console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
1105
- for (const row of rows) {
1106
- console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
1107
- if (longMode && row.server) console.log(` server: ${row.server}`);
1618
+ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
1619
+ const tmuxTarget = tuiRegistry.parseTmuxEnvironment();
1620
+ if (!tmuxTarget) {
1621
+ console.error("Error: 'ocmux tui' must run inside a tmux pane.");
1622
+ process.exit(1);
1108
1623
  }
1109
- }
1110
-
1111
- // ---------------------------------------------------------------------------
1112
- // Subcommand: kill — close the project window but keep .ocmux.json
1113
- // ---------------------------------------------------------------------------
1114
-
1115
- function cmdKill(targetDir) {
1116
1624
  const sf = findParentStatefile(targetDir);
1117
1625
  if (!sf) {
1118
1626
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1627
+ console.error("Run 'ocmux serve' first.");
1628
+ process.exit(1);
1629
+ }
1630
+ const state = projectState.readProjectState(sf);
1631
+ const directory = state.directory;
1632
+ const server = serverArg || state.server || DEFAULT_SERVER;
1633
+ if (!await checkServer(server)) {
1634
+ console.error(`Error: OpenCode server not reachable at ${server}.`);
1119
1635
  process.exit(1);
1120
1636
  }
1121
- const serverDir = path.dirname(sf);
1122
- const winIdx = windowByDir(serverDir);
1123
- if (winIdx != null) {
1124
- console.error(`Closing project window for ${serverDir}...`);
1637
+
1638
+ let session = sessionArg || state.session;
1639
+ if (session) {
1125
1640
  try {
1126
- closeProjectWindow(winIdx);
1127
- } catch (err) {
1128
- console.error(`Error: ${err.message}`);
1129
- process.exit(1);
1641
+ const found = await getSession(server, session);
1642
+ if (found.location && found.location.directory) targetDir = found.location.directory;
1643
+ } catch {
1644
+ session = null;
1130
1645
  }
1131
- } else {
1132
- console.error(`Tmux window not found for ${serverDir} (state kept).`);
1133
1646
  }
1134
- // Session memory intentionally kept.
1135
- projectState.writeProjectState(sf, { status: 'stopped' });
1136
- console.error('Done (state file kept).');
1137
- }
1647
+ if (!session) {
1648
+ const sessions = await listSessions(server, directory);
1649
+ session = newestSessionId(sessions);
1650
+ if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
1651
+ projectState.writeProjectState(sf, { session, server });
1652
+ }
1138
1653
 
1139
- // ---------------------------------------------------------------------------
1140
- // Subcommand: resurrect — recreate a project window from its state
1141
- // ---------------------------------------------------------------------------
1654
+ const registration = tuiRegistry.register({
1655
+ ...tmuxTarget,
1656
+ shared,
1657
+ directory: shared ? targetDir : directory,
1658
+ server,
1659
+ session,
1660
+ pid: process.pid,
1661
+ });
1662
+
1663
+ const instance = registration.instance;
1664
+ const launchDir = targetDir || directory;
1665
+ const child = child_process.spawn('opencode', tuiArgs(server, session, launchDir), {
1666
+ cwd: launchDir,
1667
+ stdio: 'inherit',
1668
+ });
1669
+ tuiRegistry.updateInstance(instance.id, instance.token, { childPid: child.pid || null });
1142
1670
 
1143
- async function cmdResurrect(targetDir) {
1671
+ return new Promise((resolve, reject) => {
1672
+ let settled = false;
1673
+ const forward = (signal) => {
1674
+ try { child.kill(signal); } catch {}
1675
+ };
1676
+ const onHup = () => forward('SIGHUP');
1677
+ const onTerm = () => forward('SIGTERM');
1678
+ const onInt = () => forward('SIGINT');
1679
+ process.on('SIGHUP', onHup);
1680
+ process.on('SIGTERM', onTerm);
1681
+ process.on('SIGINT', onInt);
1682
+ const finish = (err) => {
1683
+ if (settled) return;
1684
+ settled = true;
1685
+ process.removeListener('SIGHUP', onHup);
1686
+ process.removeListener('SIGTERM', onTerm);
1687
+ process.removeListener('SIGINT', onInt);
1688
+ tuiRegistry.unregister(instance.id, instance.token);
1689
+ if (err) reject(err);
1690
+ else resolve();
1691
+ };
1692
+ child.once('error', finish);
1693
+ child.once('exit', () => finish());
1694
+ });
1695
+ }
1696
+
1697
+ function tuiProjectDirectory(targetDir) {
1144
1698
  const sf = findParentStatefile(targetDir);
1145
- if (!sf) {
1699
+ if (!sf) return null;
1700
+ const state = projectState.readProjectState(sf);
1701
+ return state && state.directory;
1702
+ }
1703
+
1704
+ function formatTuiRegistration(instance) {
1705
+ return `${instance.shared ? 'shared' : 'project'} ${instance.pane} ${instance.directory || '(none)'} ${instance.server || '(none)'} ${instance.session || '(none)'}`;
1706
+ }
1707
+
1708
+ function cmdTuiList() {
1709
+ const instances = tuiRegistry.list();
1710
+ if (instances.length === 0) {
1711
+ console.log('No registered TUIs.');
1712
+ return;
1713
+ }
1714
+ console.log('Kind Pane Directory Server Session');
1715
+ for (const instance of instances) console.log(formatTuiRegistration(instance));
1716
+ }
1717
+
1718
+ function cmdTuiStatus(targetDir, shared) {
1719
+ const directory = shared ? null : tuiProjectDirectory(targetDir);
1720
+ if (!shared && !directory) {
1146
1721
  console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1147
1722
  process.exit(1);
1148
1723
  }
1149
- const state = projectState.readProjectState(sf);
1150
- const server = state.server;
1151
- if (!server) {
1152
- console.error(`Error: ${sf} does not record a server.`);
1724
+ const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
1725
+ if (!instance) {
1726
+ console.log(shared ? 'Shared TUI: not registered' : `Project TUI: not registered (${directory})`);
1727
+ return;
1728
+ }
1729
+ console.log(formatTuiRegistration(instance));
1730
+ }
1731
+
1732
+ function cmdTuiDetach(targetDir, shared) {
1733
+ const directory = shared ? null : tuiProjectDirectory(targetDir);
1734
+ if (!shared && !directory) {
1735
+ console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
1153
1736
  process.exit(1);
1154
1737
  }
1155
- const dir = state.directory;
1738
+ const instance = shared ? tuiRegistry.shared() : tuiRegistry.dedicated(directory);
1739
+ if (!instance) {
1740
+ console.log(shared ? 'Shared TUI was not registered.' : `Project TUI was not registered (${directory}).`);
1741
+ return;
1742
+ }
1743
+ tuiRegistry.unregister(instance.id, instance.token);
1744
+ console.log(`Detached ${shared ? 'shared' : 'project'} TUI from ${instance.pane}.`);
1745
+ }
1156
1746
 
1157
- const winIdx = windowByDir(dir);
1158
- if (winIdx != null) {
1159
- console.error(`Project window for ${dir} already exists.`);
1747
+ // ---------------------------------------------------------------------------
1748
+ // Subcommand: list
1749
+ // ---------------------------------------------------------------------------
1750
+
1751
+ async function cmdList(longMode, server) {
1752
+ const rows = await configuredProjects(server);
1753
+ if (rows.length === 0) {
1754
+ console.log('No configured ocmux projects found.');
1160
1755
  return;
1161
1756
  }
1162
1757
 
1163
- const alive = await checkServer(server);
1164
- if (!alive) {
1165
- console.error(`Error: OpenCode server not reachable at ${server}.`);
1166
- process.exit(1);
1758
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
1759
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
1760
+ const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
1761
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
1762
+
1763
+ console.log(`${pad('Directory', dirW)} ${pad('Status', stsW)} ${pad('Session', ssnW)}`);
1764
+ for (const row of rows) {
1765
+ console.log(`${pad(row.dir, dirW)} ${pad(row.status, stsW)} ${pad(row.session || '(none)', ssnW)}`);
1766
+ if (longMode && row.server) console.log(` server: ${row.server}`);
1167
1767
  }
1168
- console.error(`Recreating project window for ${dir}...`);
1169
- createProjectWindow(dir, server, state.session);
1170
1768
  }
1171
1769
 
1172
1770
  // ---------------------------------------------------------------------------
1173
1771
  // Subcommand: migrate — rewrite legacy .ocmux.json files to v2
1174
1772
  // ---------------------------------------------------------------------------
1175
1773
 
1176
- function cmdMigrate() {
1177
- const rows = listProjects();
1774
+ async function cmdMigrate(server) {
1775
+ const rows = await configuredProjects(server);
1178
1776
  let migrated = 0;
1179
1777
  for (const row of rows) {
1180
- const sf = statefileFor(row.dir);
1778
+ const sf = projectState.statefileFor(row.dir);
1181
1779
  const state = projectState.readProjectState(sf);
1182
1780
  if (!state) continue;
1183
1781
  if (state.version >= 2) continue;
@@ -1198,31 +1796,39 @@ function cmdMigrate() {
1198
1796
  // ---------------------------------------------------------------------------
1199
1797
 
1200
1798
  function printHelp() {
1201
- console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
1799
+ console.error(`Usage: ${SCRIPT} [-l] [--all-projects] [<subcommand>] [<directory>]`);
1202
1800
  console.error('');
1203
- console.error('Manage project OpenCode TUI windows in tmux. The OpenCode server');
1204
- console.error('itself is user-managed: start it once with `opencode serve`.');
1801
+ console.error('Manage project/session routing and optional registered OpenCode TUIs.');
1802
+ console.error('The OpenCode server is user-managed: start it with `opencode serve`.');
1205
1803
  console.error('');
1206
1804
  console.error('Without arguments, searches upward from <directory> (default: $PWD)');
1207
1805
  console.error('for .ocmux.json and opens an interactive SESSION picker for that');
1208
- console.error('project (requires a TTY). In the picker:');
1806
+ console.error('project (requires a TTY). The title bar names the project whose');
1807
+ console.error('sessions are listed. In the picker:');
1209
1808
  console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
1210
1809
  console.error(' p projects · h help · q quit');
1211
- console.error('Selecting updates .ocmux.json and relaunches the project TUI on the');
1212
- console.error('chosen session. Silent on success. (p opens the read-only project');
1213
- console.error('switcher; it never modifies .ocmux.json.)');
1810
+ console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
1811
+ console.error('back to the shared TUI. Silent on success. (p opens projects; by');
1812
+ console.error('default it only inspects other projects and never modifies any');
1813
+ console.error('.ocmux.json — see --all-projects.)');
1214
1814
  console.error('');
1215
1815
  console.error('Subcommands:');
1216
- console.error(' serve Create a TUI window for <directory> (default: $PWD).');
1816
+ console.error(' serve Initialize project state for <directory> (default: $PWD).');
1217
1817
  console.error(' --server <url> Server to record (default: $OCMUX_SERVER or http://localhost:4096).');
1218
1818
  console.error(' --force Repoint an existing project to --server (overwrites .ocmux.json).');
1219
1819
  console.error(' --git / --GIT Use the nearest parent with a .git entry / directory.');
1220
1820
  console.error(' Aliased as "new" for backwards compatibility.');
1821
+ console.error(' tui [--shared] [--server <url>] [directory]');
1822
+ console.error(' Register the current tmux pane and run its OpenCode TUI.');
1823
+ console.error(' Without --shared it is dedicated to the current project;');
1824
+ console.error(' --shared makes it the single fallback for every project.');
1825
+ console.error(' --list lists all live registrations; --status inspects one;');
1826
+ console.error(' --detach unregisters one without closing its pane or TUI.');
1827
+ console.error(' With --status/--detach, --shared targets the shared slot;');
1828
+ console.error(' otherwise the slot dedicated to directory is targeted.');
1221
1829
  console.error(' session <id|title>');
1222
1830
  console.error(' Non-interactive session switch for the current project.');
1223
- console.error(' list [-l] List project windows (with -l, their server URL).');
1224
- console.error(' kill Close the current project window. KEEPS .ocmux.json (session memory).');
1225
- console.error(' resurrect Recreate the project window from its .ocmux.json.');
1831
+ console.error(' list [-l] List configured projects and TUI routing status.');
1226
1832
  console.error(' migrate Rewrite legacy .ocmux.json files to the v2 schema.');
1227
1833
  console.error('');
1228
1834
  console.error('Reminders: the R key in the session picker attaches a note to a');
@@ -1230,6 +1836,12 @@ function printHelp() {
1230
1836
  console.error('');
1231
1837
  console.error('Options:');
1232
1838
  console.error(' -l Long output (list: also show server URL).');
1839
+ console.error(' --all-projects');
1840
+ console.error(' Let the picker’s project switcher (p) MOVE to another');
1841
+ console.error(' project, so picking a session there updates that');
1842
+ console.error(' project’s .ocmux.json (what agentp reads in it).');
1843
+ console.error(' Default: the switcher only inspects other projects');
1844
+ console.error(' and you always return to your own project’s list.');
1233
1845
  console.error(' --version Show version');
1234
1846
  console.error(' -h Show this help');
1235
1847
  console.error('');
@@ -1247,8 +1859,13 @@ function main() {
1247
1859
  let gitMode = null;
1248
1860
  let serverArg = null;
1249
1861
  let force = false;
1862
+ let allProjects = false;
1863
+ let sharedTui = false;
1864
+ let tuiSession = null;
1865
+ let tuiList = false;
1866
+ let tuiStatus = false;
1867
+ let tuiDetach = false;
1250
1868
  const positional = [];
1251
- let dirIsForced = false;
1252
1869
 
1253
1870
  const parsedArgs = process.argv.slice(2);
1254
1871
  for (let i = 0; i < parsedArgs.length; i++) {
@@ -1269,6 +1886,24 @@ function main() {
1269
1886
  process.exit(1);
1270
1887
  } else if (arg === '--force') {
1271
1888
  force = true;
1889
+ } else if (arg === '--shared') {
1890
+ sharedTui = true;
1891
+ } else if (arg === '--session-id') {
1892
+ const val = parsedArgs[i + 1];
1893
+ if (!val || val.startsWith('-')) {
1894
+ console.error("Error: '--session-id' requires a session ID.");
1895
+ process.exit(1);
1896
+ }
1897
+ tuiSession = val;
1898
+ i++;
1899
+ } else if (arg === '--list') {
1900
+ tuiList = true;
1901
+ } else if (arg === '--status') {
1902
+ tuiStatus = true;
1903
+ } else if (arg === '--detach') {
1904
+ tuiDetach = true;
1905
+ } else if (arg === '--all-projects') {
1906
+ allProjects = true;
1272
1907
  } else if (arg === '--git') {
1273
1908
  if (gitMode && gitMode !== 'nearest') {
1274
1909
  console.error("Error: cannot combine '--git' with '--GIT'.");
@@ -1287,13 +1922,19 @@ function main() {
1287
1922
  } else if (arg === '-h' || arg === '--help') {
1288
1923
  printHelp();
1289
1924
  } else if (arg === '--') {
1290
- dirIsForced = true;
1925
+ const directory = parsedArgs[i + 1];
1926
+ if (!directory) {
1927
+ console.error("Error: '--' requires a directory argument.");
1928
+ process.exit(1);
1929
+ }
1930
+ positional.push({ value: directory, forcedDirectory: true });
1931
+ i++;
1291
1932
  } else if (arg.startsWith('-')) {
1292
1933
  console.error(`Error: unknown option '${arg}'`);
1293
1934
  console.error(`Run '${SCRIPT} -h' for help.`);
1294
1935
  process.exit(1);
1295
1936
  } else {
1296
- positional.push(arg);
1937
+ positional.push({ value: arg, forcedDirectory: false });
1297
1938
  }
1298
1939
  }
1299
1940
 
@@ -1303,16 +1944,16 @@ function main() {
1303
1944
  let targetDir = null;
1304
1945
  let directRef = null;
1305
1946
 
1306
- for (const arg of positional) {
1307
- if (dirIsForced) {
1308
- dirIsForced = false;
1947
+ for (const positionalArg of positional) {
1948
+ const arg = positionalArg.value;
1949
+ if (positionalArg.forcedDirectory) {
1309
1950
  if (targetDir) {
1310
1951
  console.error(`Error: unexpected argument '${arg}'`);
1311
1952
  process.exit(1);
1312
1953
  }
1313
1954
  targetDir = resolveDir(arg);
1314
- } else if (arg === 'serve' || arg === 'new' || arg === 'kill' || arg === 'list'
1315
- || arg === 'resurrect' || arg === 'session'
1955
+ } else if (arg === 'serve' || arg === 'new' || arg === 'list' || arg === 'tui'
1956
+ || arg === 'session'
1316
1957
  || arg === 'migrate') {
1317
1958
  if (subcommand) {
1318
1959
  console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
@@ -1332,6 +1973,23 @@ function main() {
1332
1973
 
1333
1974
  if (!targetDir) targetDir = process.cwd();
1334
1975
 
1976
+ if (sharedTui && subcommand !== 'tui') {
1977
+ console.error("Error: '--shared' is only valid with 'ocmux tui'.");
1978
+ process.exit(1);
1979
+ }
1980
+ if (tuiSession && subcommand !== 'tui') {
1981
+ console.error("Error: '--session-id' is only valid with 'ocmux tui'.");
1982
+ process.exit(1);
1983
+ }
1984
+ if ((tuiList || tuiStatus || tuiDetach) && subcommand !== 'tui') {
1985
+ console.error("Error: '--list', '--status', and '--detach' are only valid with 'ocmux tui'.");
1986
+ process.exit(1);
1987
+ }
1988
+ if ([tuiList, tuiStatus, tuiDetach].filter(Boolean).length > 1) {
1989
+ console.error("Error: combine at most one of '--list', '--status', and '--detach'.");
1990
+ process.exit(1);
1991
+ }
1992
+
1335
1993
  // Dispatch. Each branch may throw synchronously (EXIT) for validation errors
1336
1994
  // or return a promise for async commands; main() returns the settled promise
1337
1995
  // so tests can await it.
@@ -1346,28 +2004,45 @@ function main() {
1346
2004
  console.error("Error: 'list' does not accept a directory path.");
1347
2005
  process.exit(1);
1348
2006
  }
1349
- ensureSession();
1350
- cmdList(longMode);
1351
- } else if (subcommand === 'session') {
1352
- if (gitMode) {
1353
- console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
2007
+ p = cmdList(longMode, serverArg || DEFAULT_SERVER);
2008
+ } else if (subcommand === 'tui') {
2009
+ if (gitMode || force || allProjects || longMode) {
2010
+ console.error("Error: 'tui' does not accept --git, --GIT, --force, --all-projects, or -l.");
1354
2011
  process.exit(1);
1355
2012
  }
1356
- p = cmdSessionSwitch(targetDir, directRef);
1357
- } else if (subcommand === 'kill') {
2013
+ if (tuiList) {
2014
+ if (sharedTui || serverArg || tuiSession || targetDir !== process.cwd()) {
2015
+ console.error("Error: 'ocmux tui --list' does not accept --shared, --server, --session-id, or a directory.");
2016
+ process.exit(1);
2017
+ }
2018
+ cmdTuiList();
2019
+ } else if (tuiStatus) {
2020
+ if (serverArg || tuiSession) {
2021
+ console.error("Error: 'ocmux tui --status' does not accept --server or --session-id.");
2022
+ process.exit(1);
2023
+ }
2024
+ cmdTuiStatus(targetDir, sharedTui);
2025
+ } else if (tuiDetach) {
2026
+ if (serverArg || tuiSession) {
2027
+ console.error("Error: 'ocmux tui --detach' does not accept --server or --session-id.");
2028
+ process.exit(1);
2029
+ }
2030
+ cmdTuiDetach(targetDir, sharedTui);
2031
+ } else {
2032
+ p = cmdTui(targetDir, serverArg, sharedTui, tuiSession);
2033
+ }
2034
+ } else if (subcommand === 'session') {
1358
2035
  if (gitMode) {
1359
2036
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1360
2037
  process.exit(1);
1361
2038
  }
1362
- cmdKill(targetDir);
1363
- } else if (subcommand === 'resurrect') {
1364
- p = cmdResurrect(targetDir);
2039
+ p = cmdSessionSwitch(targetDir, directRef, allProjects);
1365
2040
  } else if (subcommand === 'migrate') {
1366
2041
  if (targetDir !== process.cwd()) {
1367
2042
  console.error("Error: 'migrate' does not accept a directory path.");
1368
2043
  process.exit(1);
1369
2044
  }
1370
- cmdMigrate();
2045
+ p = cmdMigrate(serverArg || DEFAULT_SERVER);
1371
2046
  } else if (subcommand === 'serve' || subcommand === 'new') {
1372
2047
  if (gitMode) {
1373
2048
  const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
@@ -1380,7 +2055,7 @@ function main() {
1380
2055
  console.error("Error: '--git' and '--GIT' are only valid with 'serve'.");
1381
2056
  process.exit(1);
1382
2057
  }
1383
- p = cmdSessionSwitch(targetDir, null);
2058
+ p = cmdSessionSwitch(targetDir, null, allProjects);
1384
2059
  }
1385
2060
 
1386
2061
  return p.catch((err) => {
@@ -1396,4 +2071,4 @@ if (require.main === module) {
1396
2071
  main();
1397
2072
  }
1398
2073
 
1399
- module.exports = { main, checkServer, newestSessionId, cmdKill, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn };
2074
+ module.exports = { main, checkServer, newestSessionId, configuredProjects, refreshTui, cmdTui, cmdTuiList, cmdTuiStatus, cmdTuiDetach, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn, filterByPattern, newSearchState, handleSearch, searchFooter, sessionMenu, switchMenu, SWITCH_QUIT, SEARCH_HINT, sortModels, indexOfModel, BROWN, BAR_BG, LIGHT_YELLOW };