agentp 2.0.0 → 2.0.1

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
@@ -156,7 +156,10 @@ function checkServer(server) {
156
156
 
157
157
  function newestSessionId(sessions) {
158
158
  if (!sessions || sessions.length === 0) return null;
159
- const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id);
159
+ // Implicit targets skip child/subagent sessions (parentID set) — OpenCode's
160
+ // TUI opens those on its "Subagents" tab instead of the prompt. Callers fall
161
+ // back to creating a session when this returns null.
162
+ const sorted = sortSessionsByRecency(sessions).filter(s => s && s.id && !s.parentID);
160
163
  return sorted.length > 0 ? sorted[0].id : null;
161
164
  }
162
165
 
@@ -186,6 +189,109 @@ function padLeft(s, w) {
186
189
  const str = String(s);
187
190
  return ' '.repeat(Math.max(0, w - str.length)) + str;
188
191
  }
192
+
193
+ // ── '/' incremental search for interactive lists ──────────────────────────
194
+ // Filter `items` by `pattern`: case-insensitive, space-separated tokens that
195
+ // must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
196
+ function filterByPattern(items, pattern, textOf) {
197
+ const p = String(pattern || '').trim().toLowerCase();
198
+ if (!p) return items;
199
+ const tokens = p.split(/\s+/);
200
+ return items.filter((it) => {
201
+ const hay = String(textOf(it) || '').toLowerCase();
202
+ return tokens.every((t) => hay.includes(t));
203
+ });
204
+ }
205
+
206
+ // State of a '/' search line: `active` a filter exists, `live` the user is
207
+ // still typing into it, `value` the pattern, `caret` the edit position.
208
+ function newSearchState() {
209
+ return { active: false, live: false, value: '', caret: 0 };
210
+ }
211
+
212
+ function clearSearchState(s) {
213
+ s.active = false;
214
+ s.live = false;
215
+ s.value = '';
216
+ s.caret = 0;
217
+ }
218
+
219
+ // Drive a '/' search line from a keypress. Returns:
220
+ // 'edit' — pattern/caret changed (caller re-filters and redraws)
221
+ // 'close' — search cancelled/cleared (caller re-filters and redraws)
222
+ // 'done' — search committed, pattern kept (caller redraws)
223
+ // 'pass' — not handled here (caller runs its normal key logic)
224
+ function handleSearch(str, key, s) {
225
+ if (str === '/' && !key.ctrl && !key.meta && !s.live) {
226
+ // '/' starts a new search or resumes editing the active filter.
227
+ if (!s.active) { s.value = ''; s.caret = 0; }
228
+ else s.caret = s.value.length;
229
+ s.active = true;
230
+ s.live = true;
231
+ return 'edit';
232
+ }
233
+ if (!s.active) return 'pass';
234
+ if (!s.live) {
235
+ // Committed filter: ESC drops it, Backspace resumes editing it.
236
+ if (key.name === 'escape') { clearSearchState(s); return 'close'; }
237
+ if (key.name === 'backspace') {
238
+ if (s.value === '') { clearSearchState(s); return 'close'; }
239
+ s.live = true;
240
+ s.caret = s.value.length;
241
+ s.value = s.value.slice(0, -1);
242
+ s.caret--;
243
+ return 'edit';
244
+ }
245
+ return 'pass';
246
+ }
247
+ // Live typing.
248
+ if (key.name === 'escape') { clearSearchState(s); return 'close'; }
249
+ if (key.name === 'return' || key.name === 'enter') {
250
+ if (!s.value.trim()) { clearSearchState(s); return 'close'; }
251
+ s.live = false;
252
+ return 'done';
253
+ }
254
+ if (key.name === 'backspace') {
255
+ // Backspace on an already-empty field exits the search (like ESC). Removing
256
+ // the last character keeps the field open.
257
+ if (s.value === '') { clearSearchState(s); return 'close'; }
258
+ if (s.caret > 0) {
259
+ s.value = s.value.slice(0, s.caret - 1) + s.value.slice(s.caret);
260
+ s.caret--;
261
+ }
262
+ return 'edit';
263
+ }
264
+ if (key.name === 'delete' || key.name === 'del') {
265
+ if (s.caret < s.value.length) s.value = s.value.slice(0, s.caret) + s.value.slice(s.caret + 1);
266
+ return 'edit';
267
+ }
268
+ if (key.name === 'left') { s.caret = Math.max(0, s.caret - 1); return 'done'; }
269
+ if (key.name === 'right') { s.caret = Math.min(s.value.length, s.caret + 1); return 'done'; }
270
+ if (key.name === 'home') { s.caret = 0; return 'done'; }
271
+ if (key.name === 'end') { s.caret = s.value.length; return 'done'; }
272
+ if (key.name === 'up' || key.name === 'down') return 'pass'; // navigate the filtered list
273
+ if (str && !key.ctrl && !key.meta && str >= ' ') {
274
+ s.value = s.value.slice(0, s.caret) + str + s.value.slice(s.caret);
275
+ s.caret += str.length;
276
+ return 'edit';
277
+ }
278
+ return 'pass';
279
+ }
280
+
281
+ // The inverted bottom line: normal key hints, or the search line while a
282
+ // '/' filter is active (live = input with caret; committed = pattern kept).
283
+ // The caller adds the `Enter: confirm · Esc: cancel` hint on the right via
284
+ // renderList's `footerRight`.
285
+ const SEARCH_HINT = 'Enter: confirm · Esc: cancel';
286
+ function searchFooter(s, base) {
287
+ if (!s.active) return base;
288
+ const shown = s.live ? s.value.slice(0, s.caret) + '▏' + s.value.slice(s.caret) : s.value;
289
+ return s.live ? `Search: ${shown}` : `Search: ${shown} · /: edit`;
290
+ }
291
+
292
+ // Sentinel returned by switchMenu on Ctrl+C so the caller fully exits the CLI
293
+ // (instead of just returning to the session menu).
294
+ const SWITCH_QUIT = Symbol('ocmux-switch-quit');
189
295
  // Last-view stamp: HH:MM within the last 24h, else DD/MM/YYYY; '--:--' if unknown.
190
296
  function fmtClock(ts) {
191
297
  if (!ts) return '--:--';
@@ -321,9 +427,11 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
321
427
  // row(index, item) -> string | { text, reverse }
322
428
  // extraLines -> prompt/confirm lines right after the list (normal colors)
323
429
  // footer -> inverted, RIGHT-aligned key-hint bar (acts as a separator)
430
+ // (left-aligned when footerLeft is set — used by the search line)
431
+ // footerRight-> optional hint pinned to the right end of a left-aligned footer
324
432
  // infoLines -> details below the footer (normal colors, bold labels)
325
433
  // 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 }) {
434
+ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null }) {
327
435
  const cols = colsArg || process.stdout.columns || 80;
328
436
  const termRows = rowsArg || process.stdout.rows || 24;
329
437
  const extras = (extraLines || []).filter((l) => l != null && l !== '');
@@ -362,7 +470,21 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
362
470
  // (instead of floating right under the last row on a tall window).
363
471
  for (let i = rendered; i < viewHeight; i++) out += '\n';
364
472
  for (const l of extras) out += l + '\n';
365
- const hint = footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols);
473
+ let hint;
474
+ if (footerRight) {
475
+ // Left-aligned text plus a hint pinned to the right end of the bar.
476
+ const left = footer.length + footerRight.length + 1 <= cols
477
+ ? footer
478
+ : footer.slice(0, Math.max(0, cols - footerRight.length - 1));
479
+ const gap = Math.max(0, cols - left.length - footerRight.length);
480
+ hint = (left + ' '.repeat(gap) + footerRight).slice(0, cols);
481
+ } else if (footer.length > cols) {
482
+ hint = footer.slice(0, cols);
483
+ } else if (footerLeft) {
484
+ hint = footer + ' '.repeat(cols - footer.length);
485
+ } else {
486
+ hint = padLeft(footer, cols);
487
+ }
366
488
  out += '\x1b[7m' + hint + '\x1b[0m\n';
367
489
  for (const l of infos) out += l + '\n';
368
490
  return out.replace(/\n$/, '');
@@ -374,7 +496,7 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
374
496
  // q/Ctrl+C quit. Input mode offers readline-style caret editing.
375
497
  // Resolves on quit (null) or with the string 'switch-project' (caller then runs
376
498
  // 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 } = {}) {
499
+ function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession } = {}) {
378
500
  return new Promise((resolve) => {
379
501
  if (!process.stdin.isTTY) return resolve(null);
380
502
 
@@ -390,10 +512,17 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
390
512
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
391
513
  let spinIdx = 0;
392
514
  let spinTimer = null;
515
+ let broadcastSel = new Set(); // session ids selected in broadcast mode
516
+ let search = newSearchState(); // '/' filter for the session list
517
+ let listSearch = newSearchState(); // '/' filter for inline lists (models/agents)
518
+ let view = rows; // rows filtered by `search` (what is displayed)
519
+ let inspected = false; // broadcast inspection moved the TUI off `current`
393
520
 
394
521
  const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
395
522
  const helpLines = [
396
- 'Enter / Space switch to the selected session (stays open)',
523
+ 'Enter switch to the selected session (stays open)',
524
+ 'Space start BROADCAST: add the selected session to the current one',
525
+ ' (Space toggles each session; Enter keeps the selection)',
397
526
  'n create a new session (type a name, Enter; blank = auto)',
398
527
  'r rename the selected session (edit in place; ESC cancels)',
399
528
  'R (Shift+r) set a REMINDER for the selected session (sent in front of',
@@ -402,6 +531,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
402
531
  'a switch the AGENT of the selected session',
403
532
  'm switch the MODEL of the selected session',
404
533
  'p open the project switcher',
534
+ '/ search/filter this list (Enter keeps the filter,',
535
+ ' ESC clears it, / resumes editing it)',
405
536
  'j / k, arrows move the cursor',
406
537
  'h toggle this help',
407
538
  'q / Ctrl+C quit',
@@ -437,21 +568,45 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
437
568
  };
438
569
 
439
570
  const draw = () => {
440
- if (mode === 'help') {
571
+ if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
441
572
  let out = '\x1b[2J\x1b[H';
442
- out += 'ocmux session picker — help\n\n';
443
- for (const l of helpLines) out += ' ' + l + '\n';
573
+ if (mode === 'broadcast-help') {
574
+ out += 'ocmux broadcast mode — help\n\n';
575
+ out += ' Space select/deselect the cursor session\n';
576
+ out += ' Enter keep the broadcast selection; switch TUI to cursor\n';
577
+ out += ' m switch the model for ALL selected sessions\n';
578
+ out += ' d delete the cursor session (with confirmation)\n';
579
+ out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
580
+ out += ' / search/filter the session list\n';
581
+ out += ' ESC / q cancel broadcast mode (back to the session list)\n';
582
+ out += ' Ctrl+C quit ocmux\n';
583
+ } else if (mode === 'list-help') {
584
+ out += `ocmux ${list && list.title ? list.title : 'list'} — help\n\n`;
585
+ out += ' j / k, arrows move the cursor\n';
586
+ out += ' Enter / Space select the highlighted item\n';
587
+ out += ' / search/filter the list (Enter keeps, ESC clears)\n';
588
+ out += ' h toggle this help\n';
589
+ out += ' ESC / q / Backspace return to the previous menu\n';
590
+ out += ' Ctrl+C quit ocmux\n';
591
+ } else {
592
+ out += 'ocmux session picker — help\n\n';
593
+ for (const l of helpLines) out += ' ' + l + '\n';
594
+ }
444
595
  out += '\nPress any key to return.\n';
445
596
  process.stderr.write(out);
446
597
  return;
447
598
  }
448
599
  if (mode === 'list' && list) {
600
+ const listExtras = (listSearch.active && list.view.length === 0) ? [`No matches for "${listSearch.value}"`] : [];
449
601
  process.stderr.write(renderList({
450
602
  title: `ocmux — ${list.title}`,
451
- items: list.items,
603
+ items: list.view,
452
604
  cursor: listCursor,
453
605
  row: (i, item) => list.format(item) || '',
454
- footer: 'Enter: select · ESC: back',
606
+ footer: searchFooter(listSearch, 'Enter: select · /: search · h: help · ESC: back'),
607
+ footerLeft: listSearch.active,
608
+ footerRight: listSearch.active ? SEARCH_HINT : null,
609
+ extraLines: listExtras,
455
610
  }));
456
611
  return;
457
612
  }
@@ -462,30 +617,44 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
462
617
  Math.max(...rows.map(s => (s.title || s.id || '').length), 8) + 6,
463
618
  Math.max(8, cols - (showTime ? TIME_W + 10 : 8)),
464
619
  );
620
+ const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
465
621
  const extras = [];
466
- if (mode === 'confirm' && confirmDel) {
622
+ if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
467
623
  extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
468
624
  } else if (mode === 'input' && input) {
469
625
  extras.push(`${input.label} ${renderInputValue(input.value)}`);
470
626
  }
471
- const selected = rows[cursor];
472
- const selIsCurrent = !!(selected && String(selected.id) === String(current));
473
- const infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
627
+ if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
628
+ const selected = view[cursor];
629
+ let infoLines = [];
630
+ let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
631
+ if (inBc) {
632
+ const names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
633
+ infoLines = [`Broadcast to sessions: ${names}`];
634
+ footer = 'Enter: switch · Space: toggle · d: delete · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
635
+ } else {
636
+ const selIsCurrent = !!(selected && String(selected.id) === String(current));
637
+ infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent);
638
+ }
639
+ footer = searchFooter(search, footer);
474
640
  process.stderr.write(renderList({
475
641
  title: 'ocmux — sessions',
476
- items: rows,
642
+ items: view,
477
643
  cursor,
478
644
  row: (i, s) => {
645
+ const sel = inBc ? (broadcastSel.has(String(s.id)) ? '[x] ' : '[ ] ') : '';
479
646
  const title = (s.title || s.id || '').slice(0, maxLen);
480
647
  const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
481
648
  const mark = String(s.id) === String(current) ? ' *' : '';
482
649
  const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
483
650
  const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
484
- const text = `${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
651
+ const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
485
652
  // Yellow for the session currently selected in the TUI window.
486
653
  return String(s.id) === String(current) ? `\x1b[33m${text}\x1b[0m` : text;
487
654
  },
488
- footer: 'Enter: switch · h: help · q: quit',
655
+ footer,
656
+ footerLeft: search.active,
657
+ footerRight: search.active ? SEARCH_HINT : null,
489
658
  infoLines,
490
659
  extraLines: extras,
491
660
  }));
@@ -498,7 +667,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
498
667
  };
499
668
 
500
669
  const applyRenameWith = (name) => {
501
- const row = rows[cursor];
670
+ const row = view[cursor];
502
671
  if (!row || !onRename || !name) return;
503
672
  Promise.resolve(onRename(row, name)).then((updated) => {
504
673
  row.title = (updated && updated.title) || name;
@@ -510,7 +679,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
510
679
  };
511
680
 
512
681
  const applyReminder = (text) => {
513
- const row = rows[cursor];
682
+ const row = view[cursor];
514
683
  if (!row || !onReminder) return;
515
684
  Promise.resolve(onReminder(row, text)).then((saved) => {
516
685
  if (saved) annotations[row.id] = saved;
@@ -526,11 +695,13 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
526
695
  Promise.resolve(onPick ? onPick(row) : null).then((created) => {
527
696
  if (row.new && created) {
528
697
  rows.unshift(created);
529
- current = created.id;
530
- cursor = 0;
698
+ applySearch();
699
+ const ci = view.indexOf(created);
700
+ cursor = ci >= 0 ? ci : 0;
531
701
  } else if (!row.new) {
532
702
  current = row.id;
533
703
  }
704
+ inspected = false;
534
705
  confirmDel = null;
535
706
  draw();
536
707
  }).catch((err) => {
@@ -543,27 +714,102 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
543
714
  const applyDelete = () => {
544
715
  const s = confirmDel;
545
716
  if (!s || !onDelete) return;
717
+ const wasBroadcast = mode === 'broadcast-confirm';
546
718
  Promise.resolve(onDelete(s)).then(() => {
547
719
  rows = rows.filter(r => r.id !== s.id);
548
- if (cursor >= rows.length && cursor > 0) cursor = rows.length - 1;
720
+ applySearch();
721
+ if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
549
722
  confirmDel = null;
550
- mode = 'normal';
551
- draw();
723
+ if (wasBroadcast) {
724
+ broadcastSel.delete(String(s.id));
725
+ if (broadcastSel.size < 2) {
726
+ // Broadcast ended: make the remaining selected session the real
727
+ // selection (switch the TUI to it and store it).
728
+ const remaining = [...broadcastSel][0];
729
+ persistBroadcast([]);
730
+ broadcastSel = new Set();
731
+ mode = 'normal';
732
+ const row = rows.find(r => String(r.id) === String(remaining));
733
+ if (row) applyPick(row);
734
+ else { draw(); }
735
+ } else {
736
+ persistBroadcast([...broadcastSel]);
737
+ mode = 'broadcast';
738
+ draw();
739
+ }
740
+ } else {
741
+ mode = 'normal';
742
+ draw();
743
+ }
552
744
  }).catch((err) => {
553
745
  process.stderr.write(`\nError: ${err.message}\n`);
554
746
  confirmDel = null;
555
- mode = 'normal';
747
+ mode = wasBroadcast ? 'broadcast' : 'normal';
556
748
  draw();
557
749
  });
558
750
  };
559
751
 
560
- const openList = (title, items, format, onEnter) => {
752
+ const persistBroadcast = (ids) => {
753
+ if (onBroadcastChange) onBroadcastChange(ids);
754
+ };
755
+ // Recompute the filtered session view after `rows` or the pattern change.
756
+ const applySearch = () => {
757
+ view = filterByPattern(rows, search.active ? search.value : '',
758
+ (r) => `${r.title || ''} ${r.id || ''}`);
759
+ if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
760
+ };
761
+ // Recompute the filtered view of the currently open inline list.
762
+ const applyListSearch = () => {
763
+ if (!list) return;
764
+ list.view = filterByPattern(list.items, listSearch.active ? listSearch.value : '', list.format);
765
+ if (listCursor >= list.view.length) listCursor = Math.max(0, list.view.length - 1);
766
+ };
767
+ // After a broadcast attempt (cancel / drop below two): put the menu — and
768
+ // the TUI — back on the stored current session, which inspection may have
769
+ // relaunched away without updating `.ocmux.json`.
770
+ const restoreCurrent = () => {
771
+ const idx = view.findIndex((r) => String(r.id) === String(current));
772
+ if (idx >= 0) cursor = idx;
773
+ if (inspected && current != null && onInspectSession) onInspectSession(current);
774
+ inspected = false;
775
+ };
776
+ const startBroadcast = (idx) => {
777
+ broadcastSel = new Set([String(current), String(view[idx].id)]);
778
+ persistBroadcast([...broadcastSel]);
779
+ mode = 'broadcast';
780
+ if (onInspectSession) { onInspectSession(view[idx].id); inspected = true; }
781
+ draw();
782
+ };
783
+ const toggleBroadcast = (idx) => {
784
+ const id = String(view[idx].id);
785
+ const adding = !broadcastSel.has(id);
786
+ if (adding) broadcastSel.add(id);
787
+ else broadcastSel.delete(id);
788
+ if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
789
+ if (broadcastSel.size < 2) {
790
+ // Deselecting down to one session ends broadcast mode by making that
791
+ // remaining session the actual selection.
792
+ const remaining = [...broadcastSel][0];
793
+ persistBroadcast([]);
794
+ broadcastSel = new Set();
795
+ mode = 'normal';
796
+ const row = rows.find(r => String(r.id) === String(remaining));
797
+ if (row) applyPick(row);
798
+ else { restoreCurrent(); draw(); }
799
+ return;
800
+ }
801
+ persistBroadcast([...broadcastSel]);
802
+ draw();
803
+ };
804
+
805
+ const openList = (title, items, format, onEnter, returnMode = 'normal') => {
561
806
  if (!items || items.length === 0) {
562
807
  process.stderr.write(`\nNo ${title.toLowerCase()} available.\n`);
563
808
  draw();
564
809
  return;
565
810
  }
566
- list = { title, items, format, onEnter };
811
+ list = { title, items, view: items, format, onEnter, returnMode };
812
+ listSearch = newSearchState();
567
813
  listCursor = 0;
568
814
  mode = 'list';
569
815
  draw();
@@ -575,16 +821,25 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
575
821
  };
576
822
 
577
823
  process.stdin.on('keypress', (str, key) => {
578
- if (mode === 'help') {
579
- mode = 'normal';
824
+ // Ctrl+C always fully exits ocmux, from any menu.
825
+ if (key.ctrl && key.name === 'c') {
826
+ if (mode === 'broadcast' || mode === 'broadcast-confirm') {
827
+ persistBroadcast([]);
828
+ broadcastSel = new Set();
829
+ restoreCurrent();
830
+ }
831
+ return finish(null);
832
+ }
833
+ if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
834
+ mode = mode === 'broadcast-help' ? 'broadcast' : (mode === 'list-help' ? 'list' : 'normal');
580
835
  draw();
581
836
  return;
582
837
  }
583
- if (mode === 'confirm') {
838
+ if (mode === 'confirm' || mode === 'broadcast-confirm') {
584
839
  if (key.name === 'y') return applyDelete();
585
840
  if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
586
841
  confirmDel = null;
587
- mode = 'normal';
842
+ mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
588
843
  draw();
589
844
  }
590
845
  return;
@@ -638,27 +893,83 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
638
893
  return;
639
894
  }
640
895
  if (mode === 'list') {
896
+ const lr = handleSearch(str, key, listSearch);
897
+ if (lr !== 'pass') { applyListSearch(); draw(); return; }
641
898
  if (key.name === 'escape' || key.name === 'q' || key.name === 'backspace') {
899
+ const returnMode = list.returnMode;
642
900
  list = null;
643
- mode = 'normal';
901
+ mode = returnMode;
902
+ draw();
903
+ } else if (key.name === 'h') {
904
+ mode = 'list-help';
644
905
  draw();
645
906
  } else if (key.name === 'up' || str === 'k') {
646
- listCursor = (listCursor - 1 + list.items.length) % list.items.length;
907
+ if (list.view.length) listCursor = (listCursor - 1 + list.view.length) % list.view.length;
647
908
  draw();
648
909
  } else if (key.name === 'down' || str === 'j') {
649
- listCursor = (listCursor + 1) % list.items.length;
910
+ if (list.view.length) listCursor = (listCursor + 1) % list.view.length;
650
911
  draw();
651
912
  } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
652
- const item = list.items[listCursor];
913
+ const item = list.view[listCursor];
914
+ if (item === undefined) { draw(); return; }
653
915
  const onEnter = list.onEnter;
916
+ const returnMode = list.returnMode;
654
917
  list = null;
655
- mode = 'normal';
918
+ mode = returnMode;
656
919
  draw();
657
920
  Promise.resolve(onEnter(item)).then(() => draw()).catch(commandErr);
658
921
  }
659
922
  return;
660
923
  }
661
- if (key.ctrl && key.name === 'c') return finish(null);
924
+ const sr = handleSearch(str, key, search);
925
+ if (sr !== 'pass') { applySearch(); draw(); return; }
926
+ if (mode === 'broadcast') {
927
+ if (key.name === 'escape') {
928
+ persistBroadcast([]);
929
+ broadcastSel = new Set();
930
+ mode = 'normal';
931
+ restoreCurrent();
932
+ draw();
933
+ } else if (key.name === 'q') {
934
+ // 'q' only quits from the plain session list; in broadcast mode it
935
+ // cancels back to that list (Ctrl+C quits from anywhere).
936
+ persistBroadcast([]);
937
+ broadcastSel = new Set();
938
+ mode = 'normal';
939
+ restoreCurrent();
940
+ draw();
941
+ } else if (key.name === 'h') {
942
+ mode = 'broadcast-help';
943
+ draw();
944
+ } else if (key.name === 'space') {
945
+ if (cursor < view.length) toggleBroadcast(cursor);
946
+ } else if (key.name === 'd') {
947
+ if (cursor < view.length) {
948
+ confirmDel = view[cursor];
949
+ mode = 'broadcast-confirm';
950
+ draw();
951
+ }
952
+ } else if (key.name === 'm') {
953
+ listModels(server).then((models) => {
954
+ openList('Broadcast model (all selected sessions)', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, async (item) => {
955
+ await Promise.all([...broadcastSel].map(id => switchModel(server, id, item)));
956
+ }, 'broadcast');
957
+ }).catch(commandErr);
958
+ } else if (key.name === 'return' || key.name === 'enter') {
959
+ if (cursor < view.length) {
960
+ mode = 'normal';
961
+ broadcastSel = new Set(); // selection stays persisted for agentp
962
+ applyPick(view[cursor]);
963
+ }
964
+ } else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
965
+ cursor = (cursor - 1 + view.length) % view.length;
966
+ draw();
967
+ } else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
968
+ cursor = (cursor + 1) % view.length;
969
+ draw();
970
+ }
971
+ return;
972
+ }
662
973
  if (key.name === 'q') return finish(null);
663
974
  if (key.name === 'p') return finish('switch-project');
664
975
  if (key.name === 'h') {
@@ -672,17 +983,17 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
672
983
  }
673
984
  // Shift+r → Reminder; lowercase r → Rename.
674
985
  if (str === 'R') {
675
- const row = rows[cursor];
986
+ const row = view[cursor];
676
987
  const note = (row && annotations && annotations[row.id]) || '';
677
988
  startInput('Reminder for this session (blank clears; ESC cancels):', note, 'reminder');
678
989
  return;
679
990
  }
680
- if (key.name === 'r' && cursor < rows.length) {
681
- startInput('Rename to (ESC cancels):', rows[cursor].title || '', 'rename');
991
+ if (key.name === 'r' && cursor < view.length) {
992
+ startInput('Rename to (ESC cancels):', view[cursor].title || '', 'rename');
682
993
  return;
683
994
  }
684
995
  if (key.name === 'a') {
685
- const target = rows[cursor];
996
+ const target = view[cursor];
686
997
  if (target) {
687
998
  listAgents(server).then((all) => {
688
999
  const primary = all.filter(x => x.mode === 'primary' && !x.hidden);
@@ -694,7 +1005,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
694
1005
  return;
695
1006
  }
696
1007
  if (key.name === 'm') {
697
- const target = rows[cursor];
1008
+ const target = view[cursor];
698
1009
  if (target) {
699
1010
  listModels(server).then((models) => {
700
1011
  openList('Models', models, (x) => `${x.label}${x.variant ? `#${x.variant}` : ''}`, (item) =>
@@ -704,21 +1015,28 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
704
1015
  return;
705
1016
  }
706
1017
  if (key.name === 'd') {
707
- if (cursor < rows.length) {
708
- confirmDel = rows[cursor];
1018
+ if (cursor < view.length) {
1019
+ confirmDel = view[cursor];
709
1020
  mode = 'confirm';
710
1021
  draw();
711
1022
  }
712
1023
  return;
713
1024
  }
714
- if (rows.length > 0 && (key.name === 'up' || str === 'k')) {
715
- cursor = (cursor - 1 + rows.length) % rows.length;
1025
+ if (view.length > 0 && (key.name === 'up' || str === 'k')) {
1026
+ cursor = (cursor - 1 + view.length) % view.length;
716
1027
  draw();
717
- } else if (rows.length > 0 && (key.name === 'down' || str === 'j')) {
718
- cursor = (cursor + 1) % rows.length;
1028
+ } else if (view.length > 0 && (key.name === 'down' || str === 'j')) {
1029
+ cursor = (cursor + 1) % view.length;
719
1030
  draw();
720
- } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
721
- if (cursor < rows.length) applyPick(rows[cursor]);
1031
+ } else if (key.name === 'return' || key.name === 'enter') {
1032
+ if (cursor < view.length) applyPick(view[cursor]);
1033
+ } else if (key.name === 'space') {
1034
+ // Start broadcast only when adding a DIFFERENT session to the current one.
1035
+ if (cursor < view.length && String(view[cursor].id) !== String(current)) {
1036
+ startBroadcast(cursor);
1037
+ } else if (cursor < view.length) {
1038
+ draw(); // no-op on the already-selected session
1039
+ }
722
1040
  }
723
1041
  });
724
1042
 
@@ -756,16 +1074,21 @@ function switchMenu(rows, longMode) {
756
1074
  const lineWidth = Math.max(cols, nameW + 2 + stsW + 2 + ssnW + 6);
757
1075
 
758
1076
  let cursor = 0;
759
- let activeRow = 0;
1077
+ let activeIdx = null; // tmux window index of the focused project
760
1078
  let lastDir = null;
761
1079
  let done = false;
1080
+ let help = false;
1081
+ const search = newSearchState();
1082
+ let view = rows;
1083
+
1084
+ const applySearch = () => {
1085
+ view = filterByPattern(rows, search.active ? search.value : '',
1086
+ (r) => `${path.basename(r.dir)} ${r.dir} ${r.session || ''}`);
1087
+ if (cursor >= view.length) cursor = Math.max(0, view.length - 1);
1088
+ };
762
1089
 
763
1090
  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; }
768
- }
1091
+ activeIdx = activeWindowIndex();
769
1092
  };
770
1093
 
771
1094
  const cleanup = () => {
@@ -775,31 +1098,50 @@ function switchMenu(rows, longMode) {
775
1098
  try { process.stdin.setRawMode(false); } catch {}
776
1099
  };
777
1100
 
778
- const finish = () => {
1101
+ const finish = (value) => {
779
1102
  if (done) return;
780
1103
  done = true;
781
1104
  cleanup();
782
1105
  process.stdin.pause();
783
- resolve(lastDir);
1106
+ process.stdin.removeAllListeners('keypress');
1107
+ process.removeListener('SIGINT', onSig);
1108
+ resolve(value === undefined ? lastDir : value);
784
1109
  };
785
1110
 
786
1111
  const draw = () => {
1112
+ if (help) {
1113
+ let out = '\x1b[2J\x1b[H';
1114
+ out += 'ocmux project switcher — help\n\n';
1115
+ out += ' j / k, arrows move the cursor\n';
1116
+ out += ' Enter / Space focus the selected project (menu stays open)\n';
1117
+ out += ' / search/filter the list (Enter keeps, ESC clears)\n';
1118
+ out += ' h toggle this help\n';
1119
+ out += ' q return to the session menu\n';
1120
+ out += ' Ctrl+C quit ocmux\n';
1121
+ out += '\nPress any key to return.\n';
1122
+ process.stderr.write(out);
1123
+ return;
1124
+ }
787
1125
  refreshActive();
1126
+ const extras = (search.active && view.length === 0) ? [`No projects match "${search.value}"`] : [];
788
1127
  process.stderr.write(renderList({
789
1128
  title: 'ocmux — project switcher',
790
- items: rows,
1129
+ items: view,
791
1130
  cursor,
792
1131
  row: (i, row) => {
793
1132
  const session = row.session ? row.session.slice(0, 14) : '(none)';
794
1133
  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;
1134
+ return row.index === activeIdx ? { text, reverse: true } : text;
796
1135
  },
797
- footer: 'Enter: focus · q: quit',
1136
+ footer: searchFooter(search, 'Enter: focus · /: search · h: help · q: back · Ctrl+C: quit'),
1137
+ footerLeft: search.active,
1138
+ footerRight: search.active ? SEARCH_HINT : null,
1139
+ extraLines: extras,
798
1140
  }));
799
1141
  };
800
1142
 
801
- const activate = (i) => {
802
- const row = rows[i];
1143
+ const activate = (row) => {
1144
+ if (!row) return;
803
1145
  try {
804
1146
  if (row.server) {
805
1147
  activateProject(row.dir, row.index, row.server, row.session);
@@ -813,20 +1155,26 @@ function switchMenu(rows, longMode) {
813
1155
  draw();
814
1156
  };
815
1157
 
816
- const onSig = () => finish();
1158
+ const onSig = () => finish(SWITCH_QUIT);
817
1159
  process.on('SIGINT', onSig);
818
1160
 
819
1161
  process.stdin.on('keypress', (str, key) => {
820
- if (key.ctrl && key.name === 'c') return finish();
1162
+ // Ctrl+C fully exits ocmux; 'q' only closes this menu (back to sessions).
1163
+ if (key.ctrl && key.name === 'c') return finish(SWITCH_QUIT);
1164
+ if (help) { help = false; draw(); return; }
1165
+ const sr = handleSearch(str, key, search);
1166
+ if (sr !== 'pass') { applySearch(); draw(); return; }
1167
+ if (key.name === 'h') { help = true; draw(); return; }
821
1168
  if (key.name === 'q') return finish();
1169
+ if (view.length === 0) return;
822
1170
  if (key.name === 'up' || str === 'k') {
823
- cursor = (cursor + rows.length - 1) % rows.length;
1171
+ cursor = (cursor + view.length - 1) % view.length;
824
1172
  draw();
825
1173
  } else if (key.name === 'down' || str === 'j') {
826
- cursor = (cursor + 1) % rows.length;
1174
+ cursor = (cursor + 1) % view.length;
827
1175
  draw();
828
1176
  } else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
829
- activate(cursor);
1177
+ activate(view[cursor]);
830
1178
  }
831
1179
  });
832
1180
 
@@ -884,6 +1232,10 @@ async function cmdSessionSwitch(targetDir, directRef) {
884
1232
  process.exit(1);
885
1233
  });
886
1234
  const sorted = sortSessionsByRecency(sessions);
1235
+ // Only "main" sessions are listed in the picker: exclude child/subagent
1236
+ // sessions (parentID set) — they are work products of a main session, not
1237
+ // targets. Explicit --session can still reach one by id/title.
1238
+ const mainSessions = sorted.filter(s => !s.parentID);
887
1239
  const annotations = projectState.readAnnotations(sf);
888
1240
  const runningIds = await getActiveSessions(server);
889
1241
  let modelContext = new Map();
@@ -918,11 +1270,13 @@ async function cmdSessionSwitch(targetDir, directRef) {
918
1270
  // Interactive: Enter switches but stays open; n creates (inheriting the
919
1271
  // model of the previously selected session); r renames; R sets a reminder;
920
1272
  // 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;
1273
+ let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
922
1274
  const result = await new Promise((resolve) => {
923
- sessionMenu(sorted, state.session, annotations, server, {
1275
+ sessionMenu(mainSessions, state.session, annotations, server, {
924
1276
  runningIds,
925
1277
  modelContext,
1278
+ onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
1279
+ onInspectSession: (id) => relaunchTui(winIdx, dir, server, id),
926
1280
  onPick: async (row) => {
927
1281
  if (row.new) {
928
1282
  const created = await createSessionWithModel(server, row.name || undefined, dir, [lastRef, ...sorted]).catch((err) => {
@@ -963,12 +1317,13 @@ async function cmdSessionSwitch(targetDir, directRef) {
963
1317
  });
964
1318
 
965
1319
  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).
1320
+ // 'p' pressed: open the project switcher. Picking a project re-runs this
1321
+ // picker for it; 'q' returns here unchanged; Ctrl+C fully exits.
968
1322
  ensureSession();
969
- const lastDir = await switchMenu(listProjects());
970
- if (!lastDir) break;
971
- target = lastDir;
1323
+ const picked = await switchMenu(listProjects());
1324
+ if (picked === SWITCH_QUIT) break;
1325
+ if (!picked) continue;
1326
+ target = picked;
972
1327
  continue;
973
1328
  }
974
1329
  break;
@@ -1396,4 +1751,4 @@ if (require.main === module) {
1396
1751
  main();
1397
1752
  }
1398
1753
 
1399
- module.exports = { main, checkServer, newestSessionId, cmdKill, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn };
1754
+ module.exports = { main, checkServer, newestSessionId, cmdKill, cmdList, cmdMigrate, windowFor, renderList, fmtClock, fmtDuration, fmtTokens, fmtCost, layoutCells, sessionInfoLines, centerIn, filterByPattern, newSearchState, handleSearch, searchFooter, sessionMenu, switchMenu, SWITCH_QUIT, SEARCH_HINT };