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/README.md +33 -5
- package/bin/agentp +226 -47
- package/bin/ocmux +431 -76
- package/docs/specification_v2.md +43 -2
- package/lib/project-state.js +8 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
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.
|
|
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
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
720
|
+
applySearch();
|
|
721
|
+
if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
|
|
549
722
|
confirmDel = null;
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
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
|
-
|
|
579
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
918
|
+
mode = returnMode;
|
|
656
919
|
draw();
|
|
657
920
|
Promise.resolve(onEnter(item)).then(() => draw()).catch(commandErr);
|
|
658
921
|
}
|
|
659
922
|
return;
|
|
660
923
|
}
|
|
661
|
-
|
|
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 =
|
|
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 <
|
|
681
|
-
startInput('Rename to (ESC cancels):',
|
|
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 =
|
|
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 =
|
|
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 <
|
|
708
|
-
confirmDel =
|
|
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 (
|
|
715
|
-
cursor = (cursor - 1 +
|
|
1025
|
+
if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
1026
|
+
cursor = (cursor - 1 + view.length) % view.length;
|
|
716
1027
|
draw();
|
|
717
|
-
} else if (
|
|
718
|
-
cursor = (cursor + 1) %
|
|
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'
|
|
721
|
-
if (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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 = (
|
|
802
|
-
|
|
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
|
-
|
|
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 +
|
|
1171
|
+
cursor = (cursor + view.length - 1) % view.length;
|
|
824
1172
|
draw();
|
|
825
1173
|
} else if (key.name === 'down' || str === 'j') {
|
|
826
|
-
cursor = (cursor + 1) %
|
|
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 &&
|
|
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(
|
|
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
|
|
967
|
-
//
|
|
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
|
|
970
|
-
if (
|
|
971
|
-
|
|
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 };
|