agentp 2.1.0 → 2.1.2
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 +64 -11
- package/bin/ocmux +823 -60
- package/docs/specification_v2.md +92 -5
- package/lib/ocmux.js +10 -2
- package/lib/opencode.js +95 -1
- package/lib/tui-registry.js +8 -0
- package/package.json +1 -1
package/bin/ocmux
CHANGED
|
@@ -9,7 +9,7 @@ const child_process = require('child_process');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const { tuiArgs, switchTui } = require('../lib/ocmux');
|
|
11
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');
|
|
12
|
+
const { listSessions, listProjects: listOpenCodeProjects, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions, getSession, listPendingForms, listSessionForms, replyToForm, listPendingPermissions, replyToPermission } = require('../lib/opencode');
|
|
13
13
|
const projectState = require('../lib/project-state');
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -113,6 +113,23 @@ function checkServer(server) {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Ask a yes/no question on stderr. Only meaningful on a TTY; resolves false on
|
|
117
|
+
// EOF/close so a non-answer never silently changes state.
|
|
118
|
+
function askYesNo(question) {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
121
|
+
let done = false;
|
|
122
|
+
const settle = (value) => {
|
|
123
|
+
if (done) return;
|
|
124
|
+
done = true;
|
|
125
|
+
try { rl.close(); } catch {}
|
|
126
|
+
resolve(value);
|
|
127
|
+
};
|
|
128
|
+
rl.question(question, (answer) => settle(/^y(es)?$/i.test((answer || '').trim())));
|
|
129
|
+
rl.on('close', () => settle(false));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
function refreshTui(directory, server, session) {
|
|
117
134
|
const result = switchTui(directory, server, session, path.resolve(process.argv[1]));
|
|
118
135
|
if (!result.ok && result.reason === 'failed') {
|
|
@@ -195,6 +212,24 @@ function padLeft(s, w) {
|
|
|
195
212
|
const BROWN = '\x1b[33m'; // text: brown/dark-yellow
|
|
196
213
|
const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
|
|
197
214
|
const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
|
|
215
|
+
const QUESTION_BG = '\x1b[30;103m'; // question/input bar: black on light-yellow
|
|
216
|
+
|
|
217
|
+
// Pending-state markers shown in the session list rows, the info panel and the
|
|
218
|
+
// project switcher. Two INDEPENDENT columns — ❓ pending agent question(s) and
|
|
219
|
+
// 🔒 pending permission request(s) — each always reserves a 2-column slot
|
|
220
|
+
// (glyph or 2-space placeholder) so every row stays aligned with or without a
|
|
221
|
+
// marker. The status bar uses a single 🔔 when any listed session waits.
|
|
222
|
+
const GLYPH_FORMS = '❓'; // pending agent question (pending form)
|
|
223
|
+
const GLYPH_PERMS = '🔒'; // pending permission request
|
|
224
|
+
const GLYPH_BELL = '🔔'; // status bar: at least one marker in the list
|
|
225
|
+
const CELL_NONE = ' '; // placeholder reserving one absent marker column
|
|
226
|
+
const PENDING_POLL_MS = 2000; // how often the open picker re-checks pending state
|
|
227
|
+
|
|
228
|
+
// The two marker columns for a session/project: ❓ and/or 🔒, each column fixed
|
|
229
|
+
// at 2 columns wide so rows stay aligned whether or not a marker is present.
|
|
230
|
+
function pendingCells(hasForms, hasPerms) {
|
|
231
|
+
return (hasForms ? GLYPH_FORMS : CELL_NONE) + (hasPerms ? GLYPH_PERMS : CELL_NONE);
|
|
232
|
+
}
|
|
198
233
|
|
|
199
234
|
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
200
235
|
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
@@ -403,14 +438,23 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
|
|
|
403
438
|
// grid. Fields are ALWAYS present (placeholders when unknown).
|
|
404
439
|
// Labels are brown (the same shade as the title/status bars); when `highlight`
|
|
405
440
|
// is true (the cursor is on the session currently selected in the TUI) they are
|
|
406
|
-
// light-yellow instead.
|
|
407
|
-
|
|
441
|
+
// light-yellow instead. `pending` (a 2-column-wide marker glyph) is pinned to
|
|
442
|
+
// the top-right corner of the panel so the pending state of the CURSOR session
|
|
443
|
+
// is visible at a glance.
|
|
444
|
+
function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false, pending = '') {
|
|
408
445
|
if (!s) return [];
|
|
409
446
|
const labelColor = highlight ? LIGHT_YELLOW : BROWN;
|
|
410
447
|
const title = (s.title && s.title.trim()) || 'Untitled';
|
|
411
448
|
const location = (s.location && s.location.directory) || 'Unknown';
|
|
449
|
+
// When pending markers are shown they are pinned to the top-right corner:
|
|
450
|
+
// pad the Title line up to `cols - pending.length` (pending is the 4-column
|
|
451
|
+
// two-marker cell, or '' when nothing is pending) and append them.
|
|
452
|
+
const titleLine = `${labelColor}Title:\x1b[0m ${title}`;
|
|
453
|
+
const firstLine = pending
|
|
454
|
+
? titleLine + ' '.repeat(Math.max(0, cols - pending.length - titleLine.length)) + pending
|
|
455
|
+
: titleLine;
|
|
412
456
|
const out = [
|
|
413
|
-
|
|
457
|
+
firstLine,
|
|
414
458
|
labeledLine('Location', location, cols, labelColor),
|
|
415
459
|
];
|
|
416
460
|
|
|
@@ -453,9 +497,11 @@ function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false)
|
|
|
453
497
|
// footer -> status bar (brown background), key hints right-aligned
|
|
454
498
|
// (left-aligned when footerLeft is set — used by the search line)
|
|
455
499
|
// footerRight-> optional hint pinned to the right end of a left-aligned footer
|
|
500
|
+
// footerBg -> background for the footer bar (default: BAR_BG; question bars
|
|
501
|
+
// pass QUESTION_BG so an active prompt stands out)
|
|
456
502
|
// infoLines -> details below the status bar (colours supplied by the caller)
|
|
457
503
|
// A "(start-end/total)" range is appended to the heading when scrolling.
|
|
458
|
-
function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null }) {
|
|
504
|
+
function renderList({ title, items, cursor, row, footer, infoLines = [], extraLines = [], cols: colsArg, rows: rowsArg, footerLeft = false, footerRight = null, footerBg = BAR_BG }) {
|
|
459
505
|
const cols = colsArg || process.stdout.columns || 80;
|
|
460
506
|
const termRows = rowsArg || process.stdout.rows || 24;
|
|
461
507
|
const extras = (extraLines || []).filter((l) => l != null && l !== '');
|
|
@@ -512,28 +558,35 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
512
558
|
} else {
|
|
513
559
|
hint = padLeft(footer, cols);
|
|
514
560
|
}
|
|
515
|
-
out +=
|
|
561
|
+
out += footerBg + hint + '\x1b[0m\n';
|
|
516
562
|
for (const l of infos) out += l + '\n';
|
|
517
563
|
return out.replace(/\n$/, '');
|
|
518
564
|
}
|
|
519
565
|
|
|
520
566
|
// Render an interactive session picker (alt screen). Sorted most-recently-viewed
|
|
521
567
|
// first by the caller. Keys: Enter/Space switch (menu stays open), n create,
|
|
522
|
-
// r rename, d delete (confirm), a annotate, p switch project,
|
|
568
|
+
// r rename, d delete (confirm), a annotate, p switch project, A answer the
|
|
569
|
+
// cursor session's pending questions, P answer its permission requests, h help,
|
|
523
570
|
// q/Ctrl+C quit. Input mode offers readline-style caret editing.
|
|
524
571
|
// Resolves on quit (null) or with the string 'switch-project' (caller then runs
|
|
525
572
|
// the project switcher and re-invokes this picker for the new project).
|
|
526
573
|
// `dir` names the project this list belongs to — it heads the title bar so a
|
|
527
574
|
// multi-project run always says which project's sessions are on screen.
|
|
528
|
-
|
|
575
|
+
// `loadPending` (optional async () => ({ forms: Set, perms: Set })) supplies the
|
|
576
|
+
// session ids that currently await an agent question (pending form) or a
|
|
577
|
+
// permission reply. When provided the picker polls it every PENDING_POLL_MS and
|
|
578
|
+
// renders the ❓/🔒 marker columns; `A` answers the cursor session's questions
|
|
579
|
+
// and `P` its pending permission requests (allow once/always, reject).
|
|
580
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [], loadPending, broadcast = null } = {}) {
|
|
529
581
|
return new Promise((resolve) => {
|
|
530
582
|
if (!process.stdin.isTTY) return resolve(null);
|
|
531
583
|
|
|
532
584
|
let rows = [...sessions];
|
|
533
585
|
let current = currentSession;
|
|
534
586
|
let cursor = 0;
|
|
535
|
-
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
|
|
587
|
+
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input' | 'perm' | 'perm-help'
|
|
536
588
|
let confirmDel = null;
|
|
589
|
+
let confirmAll = false; // 'D' deletes every broadcast-selected session
|
|
537
590
|
let input = null; // { label, value, kind, caret }
|
|
538
591
|
let list = null; // { title, items, format, onEnter }
|
|
539
592
|
let listCursor = 0;
|
|
@@ -547,16 +600,65 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
547
600
|
let view = rows; // rows filtered by `search` (what is displayed)
|
|
548
601
|
let inspected = false; // broadcast inspection moved the TUI off `current`
|
|
549
602
|
|
|
603
|
+
// Adopt a persisted broadcast selection, but only when it is still valid:
|
|
604
|
+
// at least two of its ids must name existing sessions in this project's
|
|
605
|
+
// list (main sessions only — the caller already excluded child sessions).
|
|
606
|
+
// A stale list (too few entries, or ids that no longer exist) is cleared and
|
|
607
|
+
// the correction written back, so the picker never shows a normal list while
|
|
608
|
+
// agentp would still broadcast to the old one.
|
|
609
|
+
const persistedBroadcast = (Array.isArray(broadcast) ? broadcast : [])
|
|
610
|
+
.map(String)
|
|
611
|
+
.filter((id, i, arr) => arr.indexOf(id) === i)
|
|
612
|
+
.filter((id) => rows.some((s) => String(s.id) === id));
|
|
613
|
+
if (persistedBroadcast.length >= 2) {
|
|
614
|
+
broadcastSel = new Set(persistedBroadcast);
|
|
615
|
+
mode = 'broadcast';
|
|
616
|
+
// Anchor the picker (and the TUI) on a session that is actually selected.
|
|
617
|
+
if (!broadcastSel.has(String(current))) {
|
|
618
|
+
current = persistedBroadcast[0];
|
|
619
|
+
if (onInspectSession) onInspectSession(current);
|
|
620
|
+
}
|
|
621
|
+
// Write back when validation dropped dead or duplicate ids.
|
|
622
|
+
if (onBroadcastChange && persistedBroadcast.length !== (Array.isArray(broadcast) ? broadcast.length : 0)) {
|
|
623
|
+
onBroadcastChange(persistedBroadcast);
|
|
624
|
+
}
|
|
625
|
+
} else if (onBroadcastChange && Array.isArray(broadcast) && broadcast.length > 0) {
|
|
626
|
+
// Stale or invalid persisted list: clear it so agentp stops broadcasting.
|
|
627
|
+
onBroadcastChange([]);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Pending agent questions / permission replies, polled via `loadPending`.
|
|
631
|
+
// Each is a Set of session ids that currently await that thing.
|
|
632
|
+
let pendingForms = new Set();
|
|
633
|
+
let pendingPerms = new Set();
|
|
634
|
+
let pendingBusy = false;
|
|
635
|
+
let pendingTimer = null;
|
|
636
|
+
// Answer mode ('A') state: the pending forms of the cursor session being
|
|
637
|
+
// answered one at a time, the answers chosen so far and the flat item list.
|
|
638
|
+
let answer = null;
|
|
639
|
+
// Permission mode ('P') state: the pending permission request(s) of the
|
|
640
|
+
// cursor session being answered one at a time.
|
|
641
|
+
let perm = null;
|
|
642
|
+
// The two marker columns (❓ and/or 🔒), always 4 columns wide — each in a
|
|
643
|
+
// fixed 2-column slot — so rows stay aligned with or without a marker.
|
|
644
|
+
const cellsOf = (id) => pendingCells(pendingForms.has(String(id)), pendingPerms.has(String(id)));
|
|
645
|
+
|
|
550
646
|
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
551
647
|
const helpLines = [
|
|
552
648
|
'Enter switch to the selected session (stays open)',
|
|
553
649
|
'Space start BROADCAST: add the selected session to the current one',
|
|
554
|
-
' (Space toggles each session; Enter
|
|
650
|
+
' (Space toggles each session; Enter ends broadcast and',
|
|
651
|
+
' switches; ESC/q also end it and clear the selection)',
|
|
555
652
|
'n create a new session (type a name, Enter; blank = auto)',
|
|
556
653
|
'r rename the selected session (edit in place; ESC cancels)',
|
|
557
654
|
'R (Shift+r) set a REMINDER for the selected session (sent in front of',
|
|
558
655
|
' every agentp prompt; blank clears)',
|
|
559
656
|
'd delete the selected session (then y/N to confirm)',
|
|
657
|
+
'D (Shift+d) in BROADCAST: delete ALL selected sessions (then y/N)',
|
|
658
|
+
'A answer pending agent question(s) of the selected session',
|
|
659
|
+
' (only when it shows a ❓ marker)',
|
|
660
|
+
'P answer pending permission request(s) of the selected session',
|
|
661
|
+
' (only when it shows a 🔒 marker)',
|
|
560
662
|
'a switch the AGENT of the selected session',
|
|
561
663
|
'm switch the MODEL of the selected session',
|
|
562
664
|
'p open the project switcher',
|
|
@@ -576,6 +678,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
576
678
|
process.stderr.write('\x1b[?25h');
|
|
577
679
|
process.stderr.write('\x1b[?1049l');
|
|
578
680
|
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
681
|
+
if (pendingTimer) { clearInterval(pendingTimer); pendingTimer = null; }
|
|
579
682
|
process.stdout.removeListener('resize', draw);
|
|
580
683
|
try { process.stdin.setRawMode(false); } catch {}
|
|
581
684
|
process.stdin.pause();
|
|
@@ -597,14 +700,23 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
597
700
|
};
|
|
598
701
|
|
|
599
702
|
const draw = () => {
|
|
703
|
+
if (answer && (mode === 'answer' || mode === 'answer-help' || (mode === 'input' && input && input.kind === 'answer'))) {
|
|
704
|
+
drawAnswer();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (perm && (mode === 'perm' || mode === 'perm-help')) {
|
|
708
|
+
drawPerm();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
600
711
|
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
601
712
|
let out = '\x1b[2J\x1b[H';
|
|
602
713
|
if (mode === 'broadcast-help') {
|
|
603
714
|
out += 'ocmux broadcast mode — help\n\n';
|
|
604
715
|
out += ' Space select/deselect the cursor session\n';
|
|
605
|
-
out += ' Enter
|
|
716
|
+
out += ' Enter end broadcast (clear the selection); switch TUI to cursor\n';
|
|
606
717
|
out += ' m switch the model for ALL selected sessions\n';
|
|
607
718
|
out += ' d delete the cursor session (with confirmation)\n';
|
|
719
|
+
out += ' D (Shift+d) delete ALL selected sessions (with confirmation)\n';
|
|
608
720
|
out += ' j / k, arrows move the cursor (selecting shows it in the TUI)\n';
|
|
609
721
|
out += ' / search/filter the session list\n';
|
|
610
722
|
out += ' ESC / q cancel broadcast mode (back to the session list)\n';
|
|
@@ -648,24 +760,73 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
648
760
|
);
|
|
649
761
|
const inBc = mode === 'broadcast' || mode === 'broadcast-confirm';
|
|
650
762
|
const extras = [...notices];
|
|
651
|
-
if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
|
|
652
|
-
extras.push(`Delete session "${confirmDel.title || confirmDel.id || ''}"? (y/N) `);
|
|
653
|
-
} else if (mode === 'input' && input) {
|
|
654
|
-
extras.push(`${input.label} ${renderInputValue(input.value)}`);
|
|
655
|
-
}
|
|
656
763
|
if (search.active && view.length === 0) extras.push(`No sessions match "${search.value}"`);
|
|
657
764
|
const selected = view[cursor];
|
|
658
765
|
let infoLines = [];
|
|
659
766
|
let footer = 'Enter: switch · Space: broadcast · /: search · h: help · q: quit';
|
|
767
|
+
let footerLeft = search.active;
|
|
768
|
+
let footerRight = search.active ? SEARCH_HINT : null;
|
|
660
769
|
if (inBc) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
770
|
+
// List every selected session, capped so the info line never wraps. On
|
|
771
|
+
// overflow the head is trimmed and marked with '...' — the picker lists
|
|
772
|
+
// most-recently-viewed first, so the trimmed head is the most recently
|
|
773
|
+
// viewed part of the selection.
|
|
774
|
+
let names = rows.filter(r => broadcastSel.has(String(r.id))).map(r => r.title || r.id).join(', ');
|
|
775
|
+
const bcHead = 'Broadcast to sessions: ';
|
|
776
|
+
const budget = Math.max(8, Math.min(480, cols - bcHead.length));
|
|
777
|
+
if (names.length > budget) {
|
|
778
|
+
let keep = names.slice(-(budget - 4)); // 3 for '...', 1 for the space
|
|
779
|
+
const at = keep.indexOf(', ');
|
|
780
|
+
if (at > 0) keep = keep.slice(at + 2);
|
|
781
|
+
names = '... ' + keep;
|
|
782
|
+
}
|
|
783
|
+
infoLines = [bcHead + names];
|
|
784
|
+
footer = 'Enter: switch (ends) · Space: toggle · d: delete · D: delete all · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
664
785
|
} else {
|
|
665
786
|
const selIsCurrent = !!(selected && String(selected.id) === String(current));
|
|
666
|
-
|
|
787
|
+
// The cursor session's marker columns (❓/🔒) are pinned to the info
|
|
788
|
+
// panel's top-right corner ('' when nothing is pending).
|
|
789
|
+
const selF = selected && pendingForms.has(String(selected.id));
|
|
790
|
+
const selP = selected && pendingPerms.has(String(selected.id));
|
|
791
|
+
const selPending = (selF || selP) ? pendingCells(selF, selP) : '';
|
|
792
|
+
infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent, selPending);
|
|
793
|
+
}
|
|
794
|
+
// 'd'/n/r/R prompts take over the status bar (previously an easy-to-miss
|
|
795
|
+
// line at the bottom of the list) with their essential keys on the right.
|
|
796
|
+
// A prompt also flips the bar to light yellow, matching the list pointer,
|
|
797
|
+
// so the change of state is obvious at a glance.
|
|
798
|
+
let questionBar = false;
|
|
799
|
+
if ((mode === 'confirm' || mode === 'broadcast-confirm') && confirmDel) {
|
|
800
|
+
questionBar = true;
|
|
801
|
+
if (confirmAll) {
|
|
802
|
+
const n = broadcastSel.size;
|
|
803
|
+
footer = `Delete all ${n} selected session${n === 1 ? '' : 's'}?`;
|
|
804
|
+
} else {
|
|
805
|
+
footer = `Delete session "${confirmDel.title || confirmDel.id || ''}"?`;
|
|
806
|
+
}
|
|
807
|
+
footerLeft = true;
|
|
808
|
+
footerRight = 'y: delete · n/Esc: cancel';
|
|
809
|
+
} else if (mode === 'input' && input) {
|
|
810
|
+
questionBar = true;
|
|
811
|
+
footer = `${input.prompt} ${renderInputValue(input.value)}`;
|
|
812
|
+
footerLeft = true;
|
|
813
|
+
footerRight = input.hint;
|
|
814
|
+
} else {
|
|
815
|
+
footer = searchFooter(search, footer);
|
|
816
|
+
}
|
|
817
|
+
// Pending-response awareness: when any session in this list is waiting
|
|
818
|
+
// for an agent question and/or a permission reply, lead the status bar
|
|
819
|
+
// with the bell (🔔) and flip the bar left-aligned so it sits at the very
|
|
820
|
+
// beginning.
|
|
821
|
+
if (!questionBar) {
|
|
822
|
+
const anyF = rows.some(r => pendingForms.has(String(r.id)));
|
|
823
|
+
const anyP = rows.some(r => pendingPerms.has(String(r.id)));
|
|
824
|
+
const bell = (anyF || anyP) ? GLYPH_BELL : '';
|
|
825
|
+
if (bell && footer.length < cols - 4) {
|
|
826
|
+
footer = `${bell} ${footer}`;
|
|
827
|
+
footerLeft = true;
|
|
828
|
+
}
|
|
667
829
|
}
|
|
668
|
-
footer = searchFooter(search, footer);
|
|
669
830
|
process.stderr.write(renderList({
|
|
670
831
|
// The project name heads the title so you always know which project's
|
|
671
832
|
// sessions are listed (matters when --all-projects moves you around).
|
|
@@ -679,20 +840,21 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
679
840
|
const mark = String(s.id) === String(current) ? ' *' : '';
|
|
680
841
|
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
681
842
|
const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
682
|
-
const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
843
|
+
const text = `${sel}${spin}${cellsOf(s.id)}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
683
844
|
// Brown for the session currently selected in the TUI window.
|
|
684
845
|
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
685
846
|
},
|
|
686
847
|
footer,
|
|
687
|
-
footerLeft
|
|
688
|
-
footerRight
|
|
848
|
+
footerLeft,
|
|
849
|
+
footerRight,
|
|
850
|
+
footerBg: questionBar ? QUESTION_BG : BAR_BG,
|
|
689
851
|
infoLines,
|
|
690
852
|
extraLines: extras,
|
|
691
853
|
}));
|
|
692
854
|
};
|
|
693
855
|
|
|
694
|
-
const startInput = (
|
|
695
|
-
input = {
|
|
856
|
+
const startInput = (prompt, hint, value, kind, targetKey) => {
|
|
857
|
+
input = { prompt, hint, value, kind, targetKey, caret: value.length };
|
|
696
858
|
mode = 'input';
|
|
697
859
|
draw();
|
|
698
860
|
};
|
|
@@ -723,6 +885,11 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
723
885
|
};
|
|
724
886
|
|
|
725
887
|
const applyPick = (row) => {
|
|
888
|
+
// Selecting a session always drops any persisted broadcast list: a stale
|
|
889
|
+
// 'broadcast' in .ocmux.json would otherwise keep agentp sending prompts
|
|
890
|
+
// to sessions that are no longer selected.
|
|
891
|
+
persistBroadcast([]);
|
|
892
|
+
broadcastSel = new Set();
|
|
726
893
|
Promise.resolve(onPick ? onPick(row) : null).then((created) => {
|
|
727
894
|
if (row.new && created) {
|
|
728
895
|
rows.unshift(created);
|
|
@@ -735,10 +902,410 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
735
902
|
}
|
|
736
903
|
inspected = false;
|
|
737
904
|
confirmDel = null;
|
|
905
|
+
confirmAll = false;
|
|
738
906
|
draw();
|
|
739
907
|
}).catch((err) => {
|
|
740
908
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
741
909
|
confirmDel = null;
|
|
910
|
+
confirmAll = false;
|
|
911
|
+
draw();
|
|
912
|
+
});
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// ── Pending-response polling (❓/🔒 marker columns) ─────────────
|
|
916
|
+
// Re-read `loadPending` every PENDING_POLL_MS while the picker is open so
|
|
917
|
+
// the markers appear/disappear without re-opening the menu. Errors degrade
|
|
918
|
+
// gracefully: the previous set stays and the next poll retries.
|
|
919
|
+
const refreshPending = () => {
|
|
920
|
+
if (!loadPending || pendingBusy) return Promise.resolve();
|
|
921
|
+
pendingBusy = true;
|
|
922
|
+
return Promise.resolve(loadPending()).then((p) => {
|
|
923
|
+
pendingForms = (p && p.forms) || new Set();
|
|
924
|
+
pendingPerms = (p && p.perms) || new Set();
|
|
925
|
+
pendingBusy = false;
|
|
926
|
+
if (!done) draw();
|
|
927
|
+
}).catch(() => { pendingBusy = false; });
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// ── Answer mode (A): answer the agent's pending form(s) ─────────
|
|
931
|
+
// Field applicability honours `hidden` and `when` conditions over the
|
|
932
|
+
// answers chosen SO FAR (live re-validation: groups appear/disappear as you
|
|
933
|
+
// answer their gate questions).
|
|
934
|
+
const fieldVisible = (field) => {
|
|
935
|
+
if (!field || field.hidden) return false;
|
|
936
|
+
if (!Array.isArray(field.when) || field.when.length === 0) return true;
|
|
937
|
+
return field.when.every((w) => {
|
|
938
|
+
const got = answer.answers[w.key];
|
|
939
|
+
if (got === undefined || got === null) return false;
|
|
940
|
+
// For multiselect gates `eq` means the value is among the chosen ones.
|
|
941
|
+
const matches = Array.isArray(got) ? got.includes(w.value) : got === w.value;
|
|
942
|
+
return (w.op === 'neq') ? !matches : matches;
|
|
943
|
+
});
|
|
944
|
+
};
|
|
945
|
+
const fieldOptions = (field) => {
|
|
946
|
+
if (field.type === 'boolean') {
|
|
947
|
+
return [{ label: 'Yes', value: true }, { label: 'No', value: false }];
|
|
948
|
+
}
|
|
949
|
+
if (field.type === 'string' || field.type === 'multiselect') {
|
|
950
|
+
return Array.isArray(field.options) ? field.options : [];
|
|
951
|
+
}
|
|
952
|
+
return [];
|
|
953
|
+
};
|
|
954
|
+
const knownValues = (field) => new Set((field.options || []).map(o => String(o.value)));
|
|
955
|
+
// The custom text (if any) behind "Type your own answer" — distinguishes a
|
|
956
|
+
// typed answer from a chosen option for single-choice and multiselect.
|
|
957
|
+
const customText = (field) => {
|
|
958
|
+
const v = answer.answers[field.key];
|
|
959
|
+
if (v === undefined || v === null) return null;
|
|
960
|
+
if (Array.isArray(v)) {
|
|
961
|
+
const extra = v.filter(x => !knownValues(field).has(String(x)));
|
|
962
|
+
return extra.length ? extra.join(', ') : null;
|
|
963
|
+
}
|
|
964
|
+
return knownValues(field).has(String(v)) ? null : String(v);
|
|
965
|
+
};
|
|
966
|
+
const isSelected = (field, opt) => {
|
|
967
|
+
const v = answer.answers[field.key];
|
|
968
|
+
if (v === undefined || v === null) return false;
|
|
969
|
+
if (Array.isArray(v)) return v.some(x => String(x) === String(opt.value));
|
|
970
|
+
return String(v) === String(opt.value);
|
|
971
|
+
};
|
|
972
|
+
const toggleSelect = (field, opt) => {
|
|
973
|
+
if (field.type === 'multiselect') {
|
|
974
|
+
const arr = Array.isArray(answer.answers[field.key]) ? [...answer.answers[field.key]] : [];
|
|
975
|
+
const i = arr.findIndex(x => String(x) === String(opt.value));
|
|
976
|
+
if (i >= 0) arr.splice(i, 1); else arr.push(opt.value);
|
|
977
|
+
answer.answers[field.key] = arr;
|
|
978
|
+
} else {
|
|
979
|
+
answer.answers[field.key] = opt.value;
|
|
980
|
+
}
|
|
981
|
+
answer.error = null;
|
|
982
|
+
};
|
|
983
|
+
// Save a typed value (custom / free-text / numeric field). No client-side
|
|
984
|
+
// validation beyond numeric coercion — the server decides and its message
|
|
985
|
+
// is shown in the status bar.
|
|
986
|
+
const applyAnswerValue = (fieldKey, raw) => {
|
|
987
|
+
const form = answer.forms[answer.index];
|
|
988
|
+
const field = (form.fields || []).find(f => f.key === fieldKey);
|
|
989
|
+
const v = raw.trim();
|
|
990
|
+
if (!v) {
|
|
991
|
+
delete answer.answers[fieldKey];
|
|
992
|
+
} else if (field && (field.type === 'number' || field.type === 'integer')) {
|
|
993
|
+
const num = Number(v);
|
|
994
|
+
answer.answers[fieldKey] = Number.isNaN(num) ? v : num;
|
|
995
|
+
} else if (field && field.type === 'multiselect') {
|
|
996
|
+
// Keep the built-in selections and replace any custom entries with the
|
|
997
|
+
// newly typed value.
|
|
998
|
+
const known = knownValues(field);
|
|
999
|
+
const base = Array.isArray(answer.answers[fieldKey]) ? answer.answers[fieldKey].filter(x => known.has(String(x))) : [];
|
|
1000
|
+
base.push(v);
|
|
1001
|
+
answer.answers[fieldKey] = base;
|
|
1002
|
+
} else {
|
|
1003
|
+
answer.answers[fieldKey] = v;
|
|
1004
|
+
}
|
|
1005
|
+
answer.error = null;
|
|
1006
|
+
};
|
|
1007
|
+
// Prefill `default` values once per form, before the user starts editing
|
|
1008
|
+
// (rebuilt answers never clobber what was already chosen).
|
|
1009
|
+
const seedDefaults = () => {
|
|
1010
|
+
const form = answer.forms[answer.index];
|
|
1011
|
+
if (!form || !Array.isArray(form.fields)) return;
|
|
1012
|
+
for (const f of form.fields) {
|
|
1013
|
+
if (fieldVisible(f) && f.default !== undefined && f.default !== null && answer.answers[f.key] === undefined) {
|
|
1014
|
+
answer.answers[f.key] = f.default;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
// Flat list of *visible* rows for the current form: a section header per
|
|
1019
|
+
// applicable field, one row per option (or a single type-in row for
|
|
1020
|
+
// free-text/numeric fields), then the Submit row.
|
|
1021
|
+
const buildAnswerItems = (form) => {
|
|
1022
|
+
const items = [];
|
|
1023
|
+
for (const field of (form.fields || []).filter(fieldVisible)) {
|
|
1024
|
+
if (field.type === 'external') {
|
|
1025
|
+
items.push({ kind: 'external', field });
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
items.push({ kind: 'field', field });
|
|
1029
|
+
const opts = fieldOptions(field);
|
|
1030
|
+
for (const opt of opts) items.push({ kind: 'option', field, opt });
|
|
1031
|
+
const wantTypedRow = opts.length === 0 || ((field.type === 'string' || field.type === 'multiselect') && field.custom);
|
|
1032
|
+
if (wantTypedRow) {
|
|
1033
|
+
items.push({ kind: 'option', field, opt: null, custom: !!field.custom, inputOnly: opts.length === 0 });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
items.push({ kind: 'submit' });
|
|
1037
|
+
return items;
|
|
1038
|
+
};
|
|
1039
|
+
const answerItems = () => {
|
|
1040
|
+
const form = answer.forms[answer.index];
|
|
1041
|
+
return form ? buildAnswerItems(form) : [];
|
|
1042
|
+
};
|
|
1043
|
+
const isAnswerClickable = (item) => item && (item.kind === 'option' || item.kind === 'submit');
|
|
1044
|
+
// After a `when` re-validation some rows disappear; keep the cursor on a
|
|
1045
|
+
// selectable row (called from drawAnswer so the frame always self-heals).
|
|
1046
|
+
const clampAnswerCursor = () => {
|
|
1047
|
+
const items = answerItems();
|
|
1048
|
+
if (!items.length) { answer.cursor = 0; return; }
|
|
1049
|
+
if (isAnswerClickable(items[answer.cursor])) return;
|
|
1050
|
+
for (let off = 1; off < items.length; off++) {
|
|
1051
|
+
const i = (answer.cursor + off) % items.length;
|
|
1052
|
+
if (isAnswerClickable(items[i])) { answer.cursor = i; return; }
|
|
1053
|
+
}
|
|
1054
|
+
answer.cursor = 0;
|
|
1055
|
+
};
|
|
1056
|
+
const moveAnswerCursor = (delta) => {
|
|
1057
|
+
const items = answerItems();
|
|
1058
|
+
const n = items.length;
|
|
1059
|
+
if (!n) return;
|
|
1060
|
+
let i = answer.cursor;
|
|
1061
|
+
do {
|
|
1062
|
+
i = (i + delta + n) % n;
|
|
1063
|
+
} while (!isAnswerClickable(items[i]) && i !== answer.cursor);
|
|
1064
|
+
answer.cursor = i;
|
|
1065
|
+
draw();
|
|
1066
|
+
};
|
|
1067
|
+
const activateOption = (item) => {
|
|
1068
|
+
if (item.inputOnly || item.custom) {
|
|
1069
|
+
// Open the text input (custom answer / free-text / numeric field).
|
|
1070
|
+
const label = item.field.title || item.field.key;
|
|
1071
|
+
const cur = (item.inputOnly && answer.answers[item.field.key] !== undefined)
|
|
1072
|
+
? String(answer.answers[item.field.key])
|
|
1073
|
+
: (item.custom ? (customText(item.field) || '') : '');
|
|
1074
|
+
startInput(`Answer for "${label}":`, 'Enter: save · Esc: cancel', cur, 'answer', item.field.key);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
toggleSelect(item.field, item.opt);
|
|
1078
|
+
draw();
|
|
1079
|
+
};
|
|
1080
|
+
const submitAnswer = () => {
|
|
1081
|
+
if (answer.busy) return;
|
|
1082
|
+
const form = answer.forms[answer.index];
|
|
1083
|
+
if (!form) return;
|
|
1084
|
+
answer.busy = true;
|
|
1085
|
+
answer.error = null;
|
|
1086
|
+
draw();
|
|
1087
|
+
replyToForm(server, form.sessionID, form.id, answer.answers).then(() => {
|
|
1088
|
+
answer.busy = false;
|
|
1089
|
+
return listSessionForms(server, form.sessionID).then((forms) => {
|
|
1090
|
+
if (!forms || forms.length === 0) {
|
|
1091
|
+
// All questions answered — back to the session list (marker clears
|
|
1092
|
+
// after the pending set is re-polled).
|
|
1093
|
+
answer = null;
|
|
1094
|
+
mode = 'normal';
|
|
1095
|
+
notices.push('✔ Answers submitted.');
|
|
1096
|
+
return refreshPending();
|
|
1097
|
+
}
|
|
1098
|
+
// Move on to the session's next pending form.
|
|
1099
|
+
answer.forms = forms;
|
|
1100
|
+
answer.index = 0;
|
|
1101
|
+
answer.answers = {};
|
|
1102
|
+
answer.cursor = 0;
|
|
1103
|
+
answer.error = null;
|
|
1104
|
+
seedDefaults();
|
|
1105
|
+
draw();
|
|
1106
|
+
return refreshPending();
|
|
1107
|
+
});
|
|
1108
|
+
}).catch((err) => {
|
|
1109
|
+
answer.busy = false;
|
|
1110
|
+
if (err && err.status === 409) {
|
|
1111
|
+
// Answered elsewhere (e.g. from the TUI) while we were editing.
|
|
1112
|
+
answer = null;
|
|
1113
|
+
mode = 'normal';
|
|
1114
|
+
notices.push(err.message || 'Already answered.');
|
|
1115
|
+
return refreshPending();
|
|
1116
|
+
}
|
|
1117
|
+
// The server rejected the answers — surface its message in the status
|
|
1118
|
+
// bar and stay in answer mode so the user can adjust.
|
|
1119
|
+
answer.error = (err && err.message) || 'Failed to submit answers.';
|
|
1120
|
+
draw();
|
|
1121
|
+
});
|
|
1122
|
+
};
|
|
1123
|
+
const startAnswer = (target) => {
|
|
1124
|
+
listSessionForms(server, target.id).then((forms) => {
|
|
1125
|
+
if (!forms || forms.length === 0) { draw(); return; }
|
|
1126
|
+
answer = {
|
|
1127
|
+
forms,
|
|
1128
|
+
index: 0,
|
|
1129
|
+
answers: {},
|
|
1130
|
+
cursor: 0,
|
|
1131
|
+
error: null,
|
|
1132
|
+
busy: false,
|
|
1133
|
+
title: (target.title && target.title.trim()) || target.id || 'session',
|
|
1134
|
+
};
|
|
1135
|
+
seedDefaults();
|
|
1136
|
+
mode = 'answer';
|
|
1137
|
+
draw();
|
|
1138
|
+
}).catch(commandErr);
|
|
1139
|
+
};
|
|
1140
|
+
const renderAnswerItem = (item, cols) => {
|
|
1141
|
+
if (item.kind === 'field') {
|
|
1142
|
+
const label = (item.field.title || item.field.key).slice(0, Math.max(4, cols - 6));
|
|
1143
|
+
const req = item.field.required ? ' *' : '';
|
|
1144
|
+
return `${BROWN}▸ ${label}${req}\x1b[0m`;
|
|
1145
|
+
}
|
|
1146
|
+
if (item.kind === 'external') {
|
|
1147
|
+
const label = (item.field.title || item.field.key).slice(0, Math.max(4, cols - 6));
|
|
1148
|
+
return ` ⛓ ${label} (external link, can't be answered here)`;
|
|
1149
|
+
}
|
|
1150
|
+
if (item.kind === 'submit') {
|
|
1151
|
+
const fields = answerItems().filter(i => i.kind === 'field');
|
|
1152
|
+
const done = fields.filter(i => answer.answers[i.field.key] !== undefined).length;
|
|
1153
|
+
const total = fields.length;
|
|
1154
|
+
const tally = total > 0 ? ` (${done}/${total} answered)` : '';
|
|
1155
|
+
return ` ${done === total && total > 0 ? '✓' : ' '} Submit answers${tally}`;
|
|
1156
|
+
}
|
|
1157
|
+
// Option / custom / free-text row.
|
|
1158
|
+
let selected;
|
|
1159
|
+
let label;
|
|
1160
|
+
if (item.custom) {
|
|
1161
|
+
const cv = customText(item.field);
|
|
1162
|
+
selected = cv !== null;
|
|
1163
|
+
label = selected ? `Custom: ${cv}` : 'Type your own answer';
|
|
1164
|
+
} else if (item.inputOnly) {
|
|
1165
|
+
const v = answer.answers[item.field.key];
|
|
1166
|
+
selected = v !== undefined;
|
|
1167
|
+
label = selected ? String(v) : (item.field.placeholder || 'Type your answer');
|
|
1168
|
+
} else {
|
|
1169
|
+
selected = isSelected(item.field, item.opt);
|
|
1170
|
+
label = item.opt.label;
|
|
1171
|
+
}
|
|
1172
|
+
const prefix = selected ? '(•)' : '( )';
|
|
1173
|
+
return ` ${prefix} ${String(label).slice(0, Math.max(4, cols - 10))}`;
|
|
1174
|
+
};
|
|
1175
|
+
const drawAnswer = () => {
|
|
1176
|
+
let out = '\x1b[2J\x1b[H';
|
|
1177
|
+
if (mode === 'answer-help') {
|
|
1178
|
+
out += 'ocmux — answer mode help\n\n';
|
|
1179
|
+
out += ' j / k, arrows move between the questions and their options\n';
|
|
1180
|
+
out += ' Space / Enter select an option (extra entries toggle on\n';
|
|
1181
|
+
out += ' multi-select questions); on "Type your own\n';
|
|
1182
|
+
out += ' answer" / free-text rows it opens the input\n';
|
|
1183
|
+
out += ' Submit answers the last row: send all answers in one request\n';
|
|
1184
|
+
out += ' h toggle this help\n';
|
|
1185
|
+
out += ' q / ESC cancel WITHOUT answering (back to the list)\n';
|
|
1186
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1187
|
+
out += '\nPress any key to return.\n';
|
|
1188
|
+
process.stderr.write(out);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const form = answer.forms[answer.index];
|
|
1192
|
+
const items = answerItems();
|
|
1193
|
+
clampAnswerCursor();
|
|
1194
|
+
const cols = process.stdout.columns || 80;
|
|
1195
|
+
const termRows = process.stdout.rows || 24;
|
|
1196
|
+
// Title bar + form line + status bar eat 3 rows.
|
|
1197
|
+
const viewHeight = Math.max(1, termRows - 3);
|
|
1198
|
+
const win = windowFor(items.length, answer.cursor, viewHeight);
|
|
1199
|
+
const title = `ocmux — answer: ${answer.title}`.slice(0, cols);
|
|
1200
|
+
out += BAR_BG + centerIn(title, cols) + '\x1b[0m\n';
|
|
1201
|
+
const formLine = (answer.forms.length > 1 ? `Form ${answer.index + 1}/${answer.forms.length} · ` : '')
|
|
1202
|
+
+ (form && form.title ? form.title : '');
|
|
1203
|
+
out += formLine ? `${BROWN}${formLine.slice(0, cols)}\x1b[0m\n` : '\n';
|
|
1204
|
+
for (let i = win.start; i < win.start + win.count && i < items.length; i++) {
|
|
1205
|
+
const item = items[i];
|
|
1206
|
+
const clickable = isAnswerClickable(item);
|
|
1207
|
+
const ptr = (clickable && i === answer.cursor) ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
|
|
1208
|
+
out += ptr + renderAnswerItem(item, cols) + '\n';
|
|
1209
|
+
}
|
|
1210
|
+
for (let i = win.start + win.count; i < viewHeight; i++) out += '\n';
|
|
1211
|
+
if (mode === 'input' && input) {
|
|
1212
|
+
out += QUESTION_BG + padLeft(`${input.prompt} ${renderInputValue(input.value)}`, cols) + '\x1b[0m\n';
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
let footer;
|
|
1216
|
+
if (answer.busy) footer = 'Submitting answers…';
|
|
1217
|
+
else if (answer.error) footer = `✖ ${answer.error}`;
|
|
1218
|
+
else footer = 'Space/Enter: select · h: help · q: cancel · Ctrl+C: quit';
|
|
1219
|
+
out += BAR_BG + (footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols)) + '\x1b[0m\n';
|
|
1220
|
+
process.stderr.write(out);
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
// ── Permission-answer screen ('P') ────────────────────────────────
|
|
1224
|
+
// Each pending permission request of the cursor session is answered in
|
|
1225
|
+
// turn: o/1 allow once, a allow always, r reject. drawPerm renders the
|
|
1226
|
+
// request summary; the decision keys post the reply and advance.
|
|
1227
|
+
const drawPerm = () => {
|
|
1228
|
+
let out = '\x1b[2J\x1b[H';
|
|
1229
|
+
if (mode === 'perm-help') {
|
|
1230
|
+
out += 'ocmux — permission help\n\n';
|
|
1231
|
+
out += ' o / 1 allow this request ONCE\n';
|
|
1232
|
+
out += ' a allow ALWAYS (remembered for the session)\n';
|
|
1233
|
+
out += ' r REJECT\n';
|
|
1234
|
+
out += ' h toggle this help\n';
|
|
1235
|
+
out += ' q / ESC back to the session list (leave it pending)\n';
|
|
1236
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1237
|
+
out += '\nPress any key to return.\n';
|
|
1238
|
+
process.stderr.write(out);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const cols = process.stdout.columns || 80;
|
|
1242
|
+
const termRows = process.stdout.rows || 24;
|
|
1243
|
+
const req = perm.list[perm.cursor];
|
|
1244
|
+
out += BAR_BG + centerIn(`ocmux — permission: ${perm.title}`, cols) + '\x1b[0m\n';
|
|
1245
|
+
const countLine = perm.list.length > 1 ? `Request ${perm.cursor + 1}/${perm.list.length}` : '';
|
|
1246
|
+
out += (countLine ? `${BROWN}${countLine.slice(0, cols)}\x1b[0m\n` : '\n');
|
|
1247
|
+
if (req) {
|
|
1248
|
+
out += ` Action: ${String(req.action || 'unknown').slice(0, cols - 9)}\n`;
|
|
1249
|
+
const res = (req.resources || []).join(', ') || '(none)';
|
|
1250
|
+
out += ` Resources: ${res.slice(0, cols - 12)}\n`;
|
|
1251
|
+
if (req.message) out += ` Message: ${String(req.message).slice(0, cols - 10)}\n`;
|
|
1252
|
+
if (req.save && req.save.length) out += ` Remember: ${req.save.join(', ').slice(0, cols - 11)}\n`;
|
|
1253
|
+
}
|
|
1254
|
+
const bodyCount = req ? 2 + (req.message ? 1 : 0) + (req.save && req.save.length ? 1 : 0) : 0;
|
|
1255
|
+
for (let i = 2 + bodyCount; i < termRows - 1; i++) out += '\n';
|
|
1256
|
+
let footer;
|
|
1257
|
+
if (perm.busy) footer = 'Replying…';
|
|
1258
|
+
else if (perm.error) footer = `✖ ${perm.error}`;
|
|
1259
|
+
else footer = 'o: allow once · a: allow always · r: reject · h: help · q: back · Ctrl+C: quit';
|
|
1260
|
+
out += BAR_BG + (footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols)) + '\x1b[0m\n';
|
|
1261
|
+
process.stderr.write(out);
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
// Open permission mode for the cursor session: fetch the location's pending
|
|
1265
|
+
// permission requests and keep only this session's (the poll only tracks
|
|
1266
|
+
// ids, not the full request objects).
|
|
1267
|
+
const startPerm = (target) => {
|
|
1268
|
+
listPendingPermissions(server, dir).then((all) => {
|
|
1269
|
+
const list = (all || []).filter((r) => String(r.sessionID) === String(target.id));
|
|
1270
|
+
if (!list.length) { notices.push('No pending permission to answer.'); draw(); return; }
|
|
1271
|
+
perm = {
|
|
1272
|
+
list,
|
|
1273
|
+
cursor: 0,
|
|
1274
|
+
error: null,
|
|
1275
|
+
busy: false,
|
|
1276
|
+
title: (target.title && target.title.trim()) || target.id || 'session',
|
|
1277
|
+
};
|
|
1278
|
+
mode = 'perm';
|
|
1279
|
+
draw();
|
|
1280
|
+
}).catch(commandErr);
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
// Post a decision for the current permission request; on success drop it
|
|
1284
|
+
// from the list and advance to the next (or return to the session list
|
|
1285
|
+
// once all are answered).
|
|
1286
|
+
const submitPerm = (decision) => {
|
|
1287
|
+
if (perm.busy) return;
|
|
1288
|
+
const req = perm.list[perm.cursor];
|
|
1289
|
+
if (!req) return;
|
|
1290
|
+
perm.busy = true;
|
|
1291
|
+
perm.error = null;
|
|
1292
|
+
draw();
|
|
1293
|
+
replyToPermission(server, req.sessionID, req.id, decision).then(() => {
|
|
1294
|
+
perm.busy = false;
|
|
1295
|
+
perm.list.splice(perm.cursor, 1);
|
|
1296
|
+
if (perm.list.length === 0) {
|
|
1297
|
+
perm = null;
|
|
1298
|
+
mode = 'normal';
|
|
1299
|
+
notices.push('✔ Permission answered.');
|
|
1300
|
+
return refreshPending();
|
|
1301
|
+
}
|
|
1302
|
+
perm.cursor = Math.min(perm.cursor, perm.list.length - 1);
|
|
1303
|
+
perm.error = null;
|
|
1304
|
+
draw();
|
|
1305
|
+
return refreshPending();
|
|
1306
|
+
}).catch((err) => {
|
|
1307
|
+
perm.busy = false;
|
|
1308
|
+
perm.error = (err && err.message) || 'Failed to reply.';
|
|
742
1309
|
draw();
|
|
743
1310
|
});
|
|
744
1311
|
};
|
|
@@ -747,35 +1314,73 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
747
1314
|
const s = confirmDel;
|
|
748
1315
|
if (!s || !onDelete) return;
|
|
749
1316
|
const wasBroadcast = mode === 'broadcast-confirm';
|
|
750
|
-
|
|
751
|
-
|
|
1317
|
+
const deletingAll = confirmAll;
|
|
1318
|
+
// 'D' removes every session in the broadcast selection; 'd' removes just
|
|
1319
|
+
// the cursor row.
|
|
1320
|
+
const targets = deletingAll
|
|
1321
|
+
? rows.filter(r => broadcastSel.has(String(r.id)))
|
|
1322
|
+
: [s];
|
|
1323
|
+
confirmDel = null;
|
|
1324
|
+
confirmAll = false;
|
|
1325
|
+
Promise.resolve(Promise.all(targets.map(t => onDelete(t)))).then(() => {
|
|
1326
|
+
const removed = new Set(targets.map(t => String(t.id)));
|
|
1327
|
+
rows = rows.filter(r => !removed.has(String(r.id)));
|
|
752
1328
|
applySearch();
|
|
753
1329
|
if (cursor >= view.length && cursor > 0) cursor = view.length - 1;
|
|
754
|
-
|
|
1330
|
+
if (deletingAll) {
|
|
1331
|
+
// Deleting the whole broadcast selection ends broadcast mode; adopt
|
|
1332
|
+
// the row now under the cursor as the new current (state + TUI +
|
|
1333
|
+
// highlight) so the list and the TUI agree on the new selection.
|
|
1334
|
+
// (applyPick clears the persisted broadcast; the explicit clear is
|
|
1335
|
+
// only needed when there is no row left to adopt.)
|
|
1336
|
+
broadcastSel = new Set();
|
|
1337
|
+
mode = 'normal';
|
|
1338
|
+
const next = view[cursor];
|
|
1339
|
+
if (next) applyPick(next);
|
|
1340
|
+
else { persistBroadcast([]); current = null; draw(); }
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
755
1343
|
if (wasBroadcast) {
|
|
756
1344
|
broadcastSel.delete(String(s.id));
|
|
757
1345
|
if (broadcastSel.size < 2) {
|
|
758
1346
|
// Broadcast ended: make the remaining selected session the real
|
|
759
|
-
// selection (switch the TUI to it and store it).
|
|
1347
|
+
// selection (switch the TUI to it and store it). applyPick clears
|
|
1348
|
+
// the persisted broadcast; clear it here too when no row remains.
|
|
760
1349
|
const remaining = [...broadcastSel][0];
|
|
761
|
-
persistBroadcast([]);
|
|
762
1350
|
broadcastSel = new Set();
|
|
763
1351
|
mode = 'normal';
|
|
764
1352
|
const row = rows.find(r => String(r.id) === String(remaining));
|
|
765
1353
|
if (row) applyPick(row);
|
|
766
|
-
else { draw(); }
|
|
1354
|
+
else { persistBroadcast([]); draw(); }
|
|
767
1355
|
} else {
|
|
768
1356
|
persistBroadcast([...broadcastSel]);
|
|
769
1357
|
mode = 'broadcast';
|
|
1358
|
+
// Keep the anchor session pointing at something that still exists
|
|
1359
|
+
// (the cursor row when it is one of the remaining selections).
|
|
1360
|
+
if (String(s.id) === String(current)) {
|
|
1361
|
+
const next = view[cursor];
|
|
1362
|
+
current = (next && broadcastSel.has(String(next.id))) ? next.id : ([...broadcastSel][0] || null);
|
|
1363
|
+
}
|
|
770
1364
|
draw();
|
|
771
1365
|
}
|
|
772
1366
|
} else {
|
|
773
1367
|
mode = 'normal';
|
|
774
|
-
|
|
1368
|
+
if (String(s.id) === String(current)) {
|
|
1369
|
+
// The highlighted session was deleted: adopt the row now under the
|
|
1370
|
+
// cursor as the new current. applyPick records it in .ocmux.json
|
|
1371
|
+
// and refreshes the TUI, so the list highlight and the display
|
|
1372
|
+
// agree on the new selection.
|
|
1373
|
+
const next = view[cursor];
|
|
1374
|
+
if (next) applyPick(next);
|
|
1375
|
+
else { current = null; draw(); }
|
|
1376
|
+
} else {
|
|
1377
|
+
draw();
|
|
1378
|
+
}
|
|
775
1379
|
}
|
|
776
1380
|
}).catch((err) => {
|
|
777
1381
|
process.stderr.write(`\nError: ${err.message}\n`);
|
|
778
1382
|
confirmDel = null;
|
|
1383
|
+
confirmAll = false;
|
|
779
1384
|
mode = wasBroadcast ? 'broadcast' : 'normal';
|
|
780
1385
|
draw();
|
|
781
1386
|
});
|
|
@@ -820,14 +1425,14 @@ const toggleBroadcast = (idx) => {
|
|
|
820
1425
|
if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
|
|
821
1426
|
if (broadcastSel.size < 2) {
|
|
822
1427
|
// Deselecting down to one session ends broadcast mode by making that
|
|
823
|
-
// remaining session the actual selection.
|
|
1428
|
+
// remaining session the actual selection. applyPick clears the persisted
|
|
1429
|
+
// broadcast; clear it here too when no row remains.
|
|
824
1430
|
const remaining = [...broadcastSel][0];
|
|
825
|
-
persistBroadcast([]);
|
|
826
1431
|
broadcastSel = new Set();
|
|
827
1432
|
mode = 'normal';
|
|
828
1433
|
const row = rows.find(r => String(r.id) === String(remaining));
|
|
829
1434
|
if (row) applyPick(row);
|
|
830
|
-
else { restoreCurrent(); draw(); }
|
|
1435
|
+
else { persistBroadcast([]); restoreCurrent(); draw(); }
|
|
831
1436
|
return;
|
|
832
1437
|
}
|
|
833
1438
|
persistBroadcast([...broadcastSel]);
|
|
@@ -874,8 +1479,11 @@ const toggleBroadcast = (idx) => {
|
|
|
874
1479
|
}
|
|
875
1480
|
return finish(null);
|
|
876
1481
|
}
|
|
877
|
-
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
878
|
-
mode = mode === 'broadcast-help' ? 'broadcast'
|
|
1482
|
+
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help' || mode === 'answer-help' || mode === 'perm-help') {
|
|
1483
|
+
mode = mode === 'broadcast-help' ? 'broadcast'
|
|
1484
|
+
: (mode === 'list-help' ? 'list'
|
|
1485
|
+
: (mode === 'answer-help' ? 'answer'
|
|
1486
|
+
: (mode === 'perm-help' ? 'perm' : 'normal')));
|
|
879
1487
|
draw();
|
|
880
1488
|
return;
|
|
881
1489
|
}
|
|
@@ -883,6 +1491,7 @@ const toggleBroadcast = (idx) => {
|
|
|
883
1491
|
if (key.name === 'y') return applyDelete();
|
|
884
1492
|
if (key.name === 'n' || key.name === 'escape' || key.name === 'q' || key.name === 'return') {
|
|
885
1493
|
confirmDel = null;
|
|
1494
|
+
confirmAll = false;
|
|
886
1495
|
mode = mode === 'broadcast-confirm' ? 'broadcast' : 'normal';
|
|
887
1496
|
draw();
|
|
888
1497
|
}
|
|
@@ -890,19 +1499,24 @@ const toggleBroadcast = (idx) => {
|
|
|
890
1499
|
}
|
|
891
1500
|
if (mode === 'input') {
|
|
892
1501
|
if (key.name === 'escape') {
|
|
1502
|
+
const wasAnswer = input && input.kind === 'answer';
|
|
893
1503
|
input = null;
|
|
894
|
-
mode = 'normal';
|
|
1504
|
+
mode = wasAnswer ? 'answer' : 'normal';
|
|
895
1505
|
draw();
|
|
896
1506
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
897
1507
|
const v = input.value;
|
|
898
1508
|
const kind = input.kind;
|
|
1509
|
+
const targetKey = input.targetKey;
|
|
899
1510
|
input = null;
|
|
900
|
-
mode = 'normal';
|
|
1511
|
+
mode = kind === 'answer' ? 'answer' : 'normal';
|
|
901
1512
|
draw();
|
|
902
1513
|
if (kind === 'create') {
|
|
903
1514
|
applyPick({ new: true, name: v.trim() });
|
|
904
1515
|
} else if (kind === 'reminder') {
|
|
905
1516
|
applyReminder(v.trim());
|
|
1517
|
+
} else if (kind === 'answer') {
|
|
1518
|
+
applyAnswerValue(targetKey, v);
|
|
1519
|
+
draw();
|
|
906
1520
|
} else if (v.trim()) {
|
|
907
1521
|
applyRenameWith(v.trim());
|
|
908
1522
|
}
|
|
@@ -967,6 +1581,47 @@ const toggleBroadcast = (idx) => {
|
|
|
967
1581
|
}
|
|
968
1582
|
const sr = handleSearch(str, key, search);
|
|
969
1583
|
if (sr !== 'pass') { applySearch(); draw(); return; }
|
|
1584
|
+
if (mode === 'answer' && answer) {
|
|
1585
|
+
if (answer.busy) return;
|
|
1586
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
1587
|
+
// Cancel WITHOUT answering — back to the session list.
|
|
1588
|
+
answer = null;
|
|
1589
|
+
mode = 'normal';
|
|
1590
|
+
draw();
|
|
1591
|
+
} else if (key.name === 'h') {
|
|
1592
|
+
mode = 'answer-help';
|
|
1593
|
+
draw();
|
|
1594
|
+
} else if (key.name === 'up' || str === 'k') {
|
|
1595
|
+
moveAnswerCursor(-1);
|
|
1596
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
1597
|
+
moveAnswerCursor(1);
|
|
1598
|
+
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
1599
|
+
const items = answerItems();
|
|
1600
|
+
const item = items[answer.cursor];
|
|
1601
|
+
if (item && item.kind === 'submit') submitAnswer();
|
|
1602
|
+
else if (item && item.kind === 'option') activateOption(item);
|
|
1603
|
+
}
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (mode === 'perm' && perm) {
|
|
1607
|
+
if (perm.busy) return;
|
|
1608
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
1609
|
+
// Back to the session list, leaving the request(s) pending.
|
|
1610
|
+
perm = null;
|
|
1611
|
+
mode = 'normal';
|
|
1612
|
+
draw();
|
|
1613
|
+
} else if (key.name === 'h') {
|
|
1614
|
+
mode = 'perm-help';
|
|
1615
|
+
draw();
|
|
1616
|
+
} else if (str === 'o' || str === '1') {
|
|
1617
|
+
submitPerm('once');
|
|
1618
|
+
} else if (str === 'a') {
|
|
1619
|
+
submitPerm('always');
|
|
1620
|
+
} else if (str === 'r') {
|
|
1621
|
+
submitPerm('reject');
|
|
1622
|
+
}
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
970
1625
|
if (mode === 'broadcast') {
|
|
971
1626
|
if (key.name === 'escape') {
|
|
972
1627
|
persistBroadcast([]);
|
|
@@ -987,6 +1642,15 @@ const toggleBroadcast = (idx) => {
|
|
|
987
1642
|
draw();
|
|
988
1643
|
} else if (key.name === 'space') {
|
|
989
1644
|
if (cursor < view.length) toggleBroadcast(cursor);
|
|
1645
|
+
} else if (str === 'D') {
|
|
1646
|
+
// Shift+d: delete ALL broadcast-selected sessions at once (with
|
|
1647
|
+
// confirmation), handy to drop a whole selection you no longer want.
|
|
1648
|
+
if (broadcastSel.size > 0) {
|
|
1649
|
+
confirmAll = true;
|
|
1650
|
+
confirmDel = { all: true };
|
|
1651
|
+
mode = 'broadcast-confirm';
|
|
1652
|
+
draw();
|
|
1653
|
+
}
|
|
990
1654
|
} else if (key.name === 'd') {
|
|
991
1655
|
if (cursor < view.length) {
|
|
992
1656
|
confirmDel = view[cursor];
|
|
@@ -999,8 +1663,9 @@ const toggleBroadcast = (idx) => {
|
|
|
999
1663
|
});
|
|
1000
1664
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1001
1665
|
if (cursor < view.length) {
|
|
1666
|
+
// Enter ends broadcast mode: applyPick clears the persisted
|
|
1667
|
+
// selection so agentp stops broadcasting to these sessions.
|
|
1002
1668
|
mode = 'normal';
|
|
1003
|
-
broadcastSel = new Set(); // selection stays persisted for agentp
|
|
1004
1669
|
applyPick(view[cursor]);
|
|
1005
1670
|
}
|
|
1006
1671
|
} else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
@@ -1013,6 +1678,14 @@ const toggleBroadcast = (idx) => {
|
|
|
1013
1678
|
return;
|
|
1014
1679
|
}
|
|
1015
1680
|
if (key.name === 'q') return finish(null);
|
|
1681
|
+
// 'P' (Shift+p) answers the cursor session's pending permission request(s).
|
|
1682
|
+
// Checked before the 'p' project-switcher below for the same readline
|
|
1683
|
+
// reason as 'A'/'a': Shift+P arrives as name 'p' with the shift flag.
|
|
1684
|
+
if (str === 'P' || (key.name === 'p' && key.shift)) {
|
|
1685
|
+
const target = view[cursor];
|
|
1686
|
+
if (target && pendingPerms.has(String(target.id))) startPerm(target);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1016
1689
|
if (key.name === 'p') return finish('switch-project');
|
|
1017
1690
|
if (key.name === 'h') {
|
|
1018
1691
|
mode = 'help';
|
|
@@ -1020,18 +1693,29 @@ const toggleBroadcast = (idx) => {
|
|
|
1020
1693
|
return;
|
|
1021
1694
|
}
|
|
1022
1695
|
if (key.name === 'n') {
|
|
1023
|
-
startInput('New session name (blank
|
|
1696
|
+
startInput('New session name (blank = auto):', 'Enter: create · Esc: cancel', '', 'create');
|
|
1024
1697
|
return;
|
|
1025
1698
|
}
|
|
1026
1699
|
// Shift+r → Reminder; lowercase r → Rename.
|
|
1027
1700
|
if (str === 'R') {
|
|
1028
1701
|
const row = view[cursor];
|
|
1029
1702
|
const note = (row && annotations && annotations[row.id]) || '';
|
|
1030
|
-
startInput('Reminder
|
|
1703
|
+
startInput('Reminder (blank clears):', 'Enter: save · Esc: cancel', note, 'reminder');
|
|
1031
1704
|
return;
|
|
1032
1705
|
}
|
|
1033
1706
|
if (key.name === 'r' && cursor < view.length) {
|
|
1034
|
-
startInput('Rename to
|
|
1707
|
+
startInput('Rename to:', 'Enter: rename · Esc: cancel', view[cursor].title || '', 'rename');
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
// 'A' (Shift+a) answers pending agent questions. It MUST be checked
|
|
1711
|
+
// before the 'a' agent handler below: readline reports Shift+A as
|
|
1712
|
+
// name 'a' with the shift flag (str 'A'), so the lowercase branch would
|
|
1713
|
+
// otherwise swallow it and open the agent switcher instead.
|
|
1714
|
+
if (str === 'A' || (key.name === 'a' && key.shift)) {
|
|
1715
|
+
// Answer-mode: only when the cursor session actually has a pending
|
|
1716
|
+
// agent question (a pending form) — 'P' answers permissions instead.
|
|
1717
|
+
const target = view[cursor];
|
|
1718
|
+
if (target && pendingForms.has(String(target.id))) startAnswer(target);
|
|
1035
1719
|
return;
|
|
1036
1720
|
}
|
|
1037
1721
|
if (key.name === 'a') {
|
|
@@ -1085,6 +1769,12 @@ const toggleBroadcast = (idx) => {
|
|
|
1085
1769
|
if (rows.some(s => runningIds && runningIds.has(s.id))) {
|
|
1086
1770
|
spinTimer = setInterval(() => { spinIdx++; draw(); }, 120);
|
|
1087
1771
|
}
|
|
1772
|
+
// Poll for pending agent questions / permission replies while the picker is
|
|
1773
|
+
// open (the ❓/🔒 marker columns and the 'A' entry refresh live).
|
|
1774
|
+
if (loadPending) {
|
|
1775
|
+
refreshPending();
|
|
1776
|
+
pendingTimer = setInterval(() => { refreshPending(); }, PENDING_POLL_MS);
|
|
1777
|
+
}
|
|
1088
1778
|
process.stderr.write('\x1b[?1049h\x1b[?25l');
|
|
1089
1779
|
draw();
|
|
1090
1780
|
});
|
|
@@ -1102,7 +1792,7 @@ const toggleBroadcast = (idx) => {
|
|
|
1102
1792
|
// row also records the target directory, so the caller moves its picker there
|
|
1103
1793
|
// and can then write that project's `.ocmux.json` — the file `agentp` reads in
|
|
1104
1794
|
// that directory.
|
|
1105
|
-
function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
1795
|
+
function switchMenu(rows, { allProjects = false, onOpen, pending } = {}) {
|
|
1106
1796
|
return new Promise((resolve) => {
|
|
1107
1797
|
if (!process.stdin.isTTY) {
|
|
1108
1798
|
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
@@ -1114,6 +1804,8 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1114
1804
|
return;
|
|
1115
1805
|
}
|
|
1116
1806
|
|
|
1807
|
+
const pendingOf = (dir) => (pending && pending.get(dir)) || { forms: new Set(), perms: new Set() };
|
|
1808
|
+
|
|
1117
1809
|
const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
|
|
1118
1810
|
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1119
1811
|
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
@@ -1213,6 +1905,10 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1213
1905
|
out += ' h toggle this help\n';
|
|
1214
1906
|
out += ' q return to the session menu\n';
|
|
1215
1907
|
out += ' Ctrl+C quit ocmux\n';
|
|
1908
|
+
out += ' \n';
|
|
1909
|
+
out += ' Markers: ❓ pending question · 🔒 pending permission, always two\n';
|
|
1910
|
+
out += ' columns (the slot is reserved when absent); 🔔 in the\n';
|
|
1911
|
+
out += ' status bar when any listed session waits\n';
|
|
1216
1912
|
out += '\n';
|
|
1217
1913
|
for (const l of modeNote) out += `${l}\n`;
|
|
1218
1914
|
out += '\nPress any key to return.\n';
|
|
@@ -1239,12 +1935,19 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1239
1935
|
const id = String(s.id || '');
|
|
1240
1936
|
const mark = String(s.id) === String(node.project.session) ? ' *' : '';
|
|
1241
1937
|
const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
|
|
1242
|
-
|
|
1938
|
+
// Pending marker columns for this session (❓/🔒), always 4 cols.
|
|
1939
|
+
const pe = pendingOf(node.project.dir);
|
|
1940
|
+
const gr = pendingCells(pe.forms.has(id), pe.perms.has(id));
|
|
1941
|
+
return ` ${title}${mark} ${gr} ${id}`;
|
|
1243
1942
|
}
|
|
1244
1943
|
const p = node.project;
|
|
1245
1944
|
const fold = foldOf(p).open ? '▾' : '▸';
|
|
1246
1945
|
const session = p.session ? p.session.slice(0, 14) : '(none)';
|
|
1247
|
-
|
|
1946
|
+
// A folded project shows its aggregate marker columns when ANY of its
|
|
1947
|
+
// sessions (per the location-scoped pending fetch) awaits an answer.
|
|
1948
|
+
const pe = pendingOf(p.dir);
|
|
1949
|
+
const gr = pendingCells(pe.forms.size > 0, pe.perms.size > 0);
|
|
1950
|
+
const text = `${fold} ${gr} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
|
|
1248
1951
|
return text;
|
|
1249
1952
|
},
|
|
1250
1953
|
footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
|
|
@@ -1367,7 +2070,7 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1367
2070
|
process.exit(1);
|
|
1368
2071
|
}
|
|
1369
2072
|
const state = projectState.readProjectState(sf);
|
|
1370
|
-
|
|
2073
|
+
let server = state.server;
|
|
1371
2074
|
if (!server) {
|
|
1372
2075
|
console.error(`Error: ${sf} does not record a server.`);
|
|
1373
2076
|
console.error(`Run 'ocmux serve --server <url>' to set it up, or add "server" to the file.`);
|
|
@@ -1375,7 +2078,19 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1375
2078
|
}
|
|
1376
2079
|
const dir = state.directory;
|
|
1377
2080
|
|
|
1378
|
-
|
|
2081
|
+
// If the recorded server is down, offer to repoint the project to the
|
|
2082
|
+
// default server — but only after confirming the default actually answers.
|
|
2083
|
+
let alive = await checkServer(server);
|
|
2084
|
+
if (!alive && process.stdin.isTTY && server !== DEFAULT_SERVER
|
|
2085
|
+
&& await checkServer(DEFAULT_SERVER)) {
|
|
2086
|
+
const useDefault = await askYesNo(
|
|
2087
|
+
`OpenCode server not reachable at ${server}. Use the default ${DEFAULT_SERVER} instead? [y/N] `);
|
|
2088
|
+
if (useDefault) {
|
|
2089
|
+
projectState.writeProjectState(sf, { server: DEFAULT_SERVER });
|
|
2090
|
+
server = DEFAULT_SERVER;
|
|
2091
|
+
alive = true;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
1379
2094
|
if (!alive) {
|
|
1380
2095
|
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1381
2096
|
console.error(`Start it with 'opencode serve' (or your usual launcher) first.`);
|
|
@@ -1413,7 +2128,9 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1413
2128
|
} catch { /* context limits unavailable */ }
|
|
1414
2129
|
|
|
1415
2130
|
const applySession = (id) => {
|
|
1416
|
-
|
|
2131
|
+
// A session switch also drops any persisted broadcast list, so agentp
|
|
2132
|
+
// never keeps broadcasting to sessions that are no longer selected.
|
|
2133
|
+
projectState.writeProjectState(sf, { session: id, broadcast: [] });
|
|
1417
2134
|
refreshTui(dir, server, id);
|
|
1418
2135
|
};
|
|
1419
2136
|
|
|
@@ -1438,12 +2155,30 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1438
2155
|
// model of the previously selected session); r renames; R sets a reminder;
|
|
1439
2156
|
// d deletes; a switches agent; m switches model; p switches project; q quits.
|
|
1440
2157
|
let lastRef = (state.session && mainSessions.find(s => String(s.id) === String(state.session))) || mainSessions[0] || null;
|
|
2158
|
+
// Opening the picker in a project routes its applicable TUI to the stored
|
|
2159
|
+
// session, so merely running `ocmux` here switches the pane. (Respawn is
|
|
2160
|
+
// skipped when the TUI is already showing that project/session.)
|
|
2161
|
+
if (state.session && mainSessions.some(s => String(s.id) === String(state.session))) {
|
|
2162
|
+
refreshTui(dir, server, state.session);
|
|
2163
|
+
}
|
|
1441
2164
|
const result = await new Promise((resolve) => {
|
|
1442
2165
|
sessionMenu(mainSessions, state.session, annotations, server, {
|
|
1443
2166
|
dir,
|
|
1444
2167
|
runningIds,
|
|
1445
2168
|
modelContext,
|
|
1446
2169
|
notices,
|
|
2170
|
+
// A persisted broadcast list, so the picker opens in broadcast mode when
|
|
2171
|
+
// one is active (validated and cleaned inside `sessionMenu`).
|
|
2172
|
+
broadcast: state.broadcast,
|
|
2173
|
+
// Pending agent questions / permission replies for this project, polled
|
|
2174
|
+
// by the picker while it is open. Failures degrade to empty sets.
|
|
2175
|
+
loadPending: () => Promise.all([
|
|
2176
|
+
listPendingForms(server, dir).catch(() => []),
|
|
2177
|
+
listPendingPermissions(server, dir).catch(() => []),
|
|
2178
|
+
]).then(([forms, perms]) => ({
|
|
2179
|
+
forms: new Set((forms || []).map(f => f.sessionID).filter(Boolean)),
|
|
2180
|
+
perms: new Set((perms || []).map(x => x.sessionID).filter(Boolean)),
|
|
2181
|
+
})),
|
|
1447
2182
|
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
1448
2183
|
onInspectSession: (id) => refreshTui(dir, server, id),
|
|
1449
2184
|
onPick: async (row) => {
|
|
@@ -1494,8 +2229,24 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1494
2229
|
console.error(`Error listing projects: ${err.message}`);
|
|
1495
2230
|
return [];
|
|
1496
2231
|
});
|
|
2232
|
+
// Fetch each listed project's pending questions/permissions once, so
|
|
2233
|
+
// folded project rows (and unfolded sessions) can carry the markers.
|
|
2234
|
+
// Errors degrade to empty sets (no markers for that project).
|
|
2235
|
+
const pendingByDir = new Map();
|
|
2236
|
+
await Promise.all(rows.map(async (p) => {
|
|
2237
|
+
if (!p.server) return;
|
|
2238
|
+
const [forms, perms] = await Promise.all([
|
|
2239
|
+
listPendingForms(p.server, p.dir).catch(() => []),
|
|
2240
|
+
listPendingPermissions(p.server, p.dir).catch(() => []),
|
|
2241
|
+
]);
|
|
2242
|
+
pendingByDir.set(p.dir, {
|
|
2243
|
+
forms: new Set((forms || []).map(f => f.sessionID).filter(Boolean)),
|
|
2244
|
+
perms: new Set((perms || []).map(x => x.sessionID).filter(Boolean)),
|
|
2245
|
+
});
|
|
2246
|
+
}));
|
|
1497
2247
|
const picked = await switchMenu(rows, {
|
|
1498
2248
|
allProjects,
|
|
2249
|
+
pending: pendingByDir,
|
|
1499
2250
|
onOpen: (project, id) => refreshTui(project.dir, project.server, id),
|
|
1500
2251
|
});
|
|
1501
2252
|
if (picked === SWITCH_QUIT) break;
|
|
@@ -1622,20 +2373,22 @@ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
|
|
|
1622
2373
|
process.exit(1);
|
|
1623
2374
|
}
|
|
1624
2375
|
const sf = findParentStatefile(targetDir);
|
|
1625
|
-
|
|
2376
|
+
const state = sf ? projectState.readProjectState(sf) : null;
|
|
2377
|
+
if (!state && !shared) {
|
|
1626
2378
|
console.error(`Error: no .ocmux.json found in ${targetDir} or any of its parents.`);
|
|
1627
2379
|
console.error("Run 'ocmux serve' first.");
|
|
1628
2380
|
process.exit(1);
|
|
1629
2381
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
const
|
|
2382
|
+
// The shared TUI is a cross-project fallback, so it can run without a project
|
|
2383
|
+
// state file (defaulting to the configured/default server).
|
|
2384
|
+
const directory = state ? state.directory : targetDir;
|
|
2385
|
+
const server = serverArg || (state && state.server) || DEFAULT_SERVER;
|
|
1633
2386
|
if (!await checkServer(server)) {
|
|
1634
2387
|
console.error(`Error: OpenCode server not reachable at ${server}.`);
|
|
1635
2388
|
process.exit(1);
|
|
1636
2389
|
}
|
|
1637
2390
|
|
|
1638
|
-
let session = sessionArg || state.session;
|
|
2391
|
+
let session = sessionArg || (state && state.session) || null;
|
|
1639
2392
|
if (session) {
|
|
1640
2393
|
try {
|
|
1641
2394
|
const found = await getSession(server, session);
|
|
@@ -1648,7 +2401,7 @@ async function cmdTui(targetDir, serverArg, shared, sessionArg) {
|
|
|
1648
2401
|
const sessions = await listSessions(server, directory);
|
|
1649
2402
|
session = newestSessionId(sessions);
|
|
1650
2403
|
if (!session) session = (await createSessionWithModel(server, undefined, directory, sessions)).id;
|
|
1651
|
-
projectState.writeProjectState(sf, { session, server });
|
|
2404
|
+
if (sf) projectState.writeProjectState(sf, { session, server });
|
|
1652
2405
|
}
|
|
1653
2406
|
|
|
1654
2407
|
const registration = tuiRegistry.register({
|
|
@@ -1806,11 +2559,20 @@ function printHelp() {
|
|
|
1806
2559
|
console.error('project (requires a TTY). The title bar names the project whose');
|
|
1807
2560
|
console.error('sessions are listed. In the picker:');
|
|
1808
2561
|
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1809
|
-
console.error(' p projects · h help · q quit');
|
|
2562
|
+
console.error(' p projects · h help · q quit · A answer the selected session\'s questions');
|
|
2563
|
+
console.error(' P answer its pending permission requests (allow once / always / reject)');
|
|
2564
|
+
console.error(' In BROADCAST: Space toggles a session · d deletes cursor · D deletes all');
|
|
2565
|
+
console.error('List rows and the project switcher carry pending marker columns: ❓ agent');
|
|
2566
|
+
console.error('question, 🔒 permission request (one column each, the slot is always');
|
|
2567
|
+
console.error('reserved); the status bar shows 🔔 when any listed session waits.');
|
|
2568
|
+
console.error('The picker re-checks every 2s while open.');
|
|
1810
2569
|
console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
|
|
1811
|
-
console.error('back to the shared TUI.
|
|
2570
|
+
console.error('back to the shared TUI. Opening the picker also routes that project\'s');
|
|
2571
|
+
console.error('TUI to its stored session. Silent on success. (p opens projects; by');
|
|
1812
2572
|
console.error('default it only inspects other projects and never modifies any');
|
|
1813
2573
|
console.error('.ocmux.json — see --all-projects.)');
|
|
2574
|
+
console.error('If the recorded server is down but the default server answers, ocmux');
|
|
2575
|
+
console.error('offers to repoint the project to the default.');
|
|
1814
2576
|
console.error('');
|
|
1815
2577
|
console.error('Subcommands:');
|
|
1816
2578
|
console.error(' serve Initialize project state for <directory> (default: $PWD).');
|
|
@@ -1821,7 +2583,8 @@ function printHelp() {
|
|
|
1821
2583
|
console.error(' tui [--shared] [--server <url>] [directory]');
|
|
1822
2584
|
console.error(' Register the current tmux pane and run its OpenCode TUI.');
|
|
1823
2585
|
console.error(' Without --shared it is dedicated to the current project;');
|
|
1824
|
-
console.error(' --shared makes it the single fallback for every project
|
|
2586
|
+
console.error(' --shared makes it the single fallback for every project and');
|
|
2587
|
+
console.error(' runs even without .ocmux.json (using the default server).');
|
|
1825
2588
|
console.error(' --list lists all live registrations; --status inspects one;');
|
|
1826
2589
|
console.error(' --detach unregisters one without closing its pane or TUI.');
|
|
1827
2590
|
console.error(' With --status/--detach, --shared targets the shared slot;');
|
|
@@ -2071,4 +2834,4 @@ if (require.main === module) {
|
|
|
2071
2834
|
main();
|
|
2072
2835
|
}
|
|
2073
2836
|
|
|
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 };
|
|
2837
|
+
module.exports = { main, checkServer, askYesNo, 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, QUESTION_BG };
|