agentp 2.1.1 → 2.1.3
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 +45 -5
- package/bin/agentp +122 -16
- package/bin/ocmux +717 -36
- package/docs/specification_v2.md +85 -5
- package/lib/opencode.js +95 -1
- 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
|
// ---------------------------------------------------------------------------
|
|
@@ -214,6 +214,25 @@ const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
|
|
|
214
214
|
const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
|
|
215
215
|
const QUESTION_BG = '\x1b[30;103m'; // question/input bar: black on light-yellow
|
|
216
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
|
+
const RUNNING_POLL_MS = 1000; // how often the open picker re-checks which sessions are running
|
|
228
|
+
const SPIN_INTERVAL_MS = 120; // spinner frame rate while any listed session runs
|
|
229
|
+
|
|
230
|
+
// The two marker columns for a session/project: ❓ and/or 🔒, each column fixed
|
|
231
|
+
// at 2 columns wide so rows stay aligned whether or not a marker is present.
|
|
232
|
+
function pendingCells(hasForms, hasPerms) {
|
|
233
|
+
return (hasForms ? GLYPH_FORMS : CELL_NONE) + (hasPerms ? GLYPH_PERMS : CELL_NONE);
|
|
234
|
+
}
|
|
235
|
+
|
|
217
236
|
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
218
237
|
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
219
238
|
// must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
|
|
@@ -421,14 +440,23 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
|
|
|
421
440
|
// grid. Fields are ALWAYS present (placeholders when unknown).
|
|
422
441
|
// Labels are brown (the same shade as the title/status bars); when `highlight`
|
|
423
442
|
// is true (the cursor is on the session currently selected in the TUI) they are
|
|
424
|
-
// light-yellow instead.
|
|
425
|
-
|
|
443
|
+
// light-yellow instead. `pending` (a 2-column-wide marker glyph) is pinned to
|
|
444
|
+
// the top-right corner of the panel so the pending state of the CURSOR session
|
|
445
|
+
// is visible at a glance.
|
|
446
|
+
function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false, pending = '') {
|
|
426
447
|
if (!s) return [];
|
|
427
448
|
const labelColor = highlight ? LIGHT_YELLOW : BROWN;
|
|
428
449
|
const title = (s.title && s.title.trim()) || 'Untitled';
|
|
429
450
|
const location = (s.location && s.location.directory) || 'Unknown';
|
|
451
|
+
// When pending markers are shown they are pinned to the top-right corner:
|
|
452
|
+
// pad the Title line up to `cols - pending.length` (pending is the 4-column
|
|
453
|
+
// two-marker cell, or '' when nothing is pending) and append them.
|
|
454
|
+
const titleLine = `${labelColor}Title:\x1b[0m ${title}`;
|
|
455
|
+
const firstLine = pending
|
|
456
|
+
? titleLine + ' '.repeat(Math.max(0, cols - pending.length - titleLine.length)) + pending
|
|
457
|
+
: titleLine;
|
|
430
458
|
const out = [
|
|
431
|
-
|
|
459
|
+
firstLine,
|
|
432
460
|
labeledLine('Location', location, cols, labelColor),
|
|
433
461
|
];
|
|
434
462
|
|
|
@@ -539,20 +567,30 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
539
567
|
|
|
540
568
|
// Render an interactive session picker (alt screen). Sorted most-recently-viewed
|
|
541
569
|
// first by the caller. Keys: Enter/Space switch (menu stays open), n create,
|
|
542
|
-
// r rename, d delete (confirm), a annotate, p switch project,
|
|
570
|
+
// r rename, d delete (confirm), a annotate, p switch project, A answer the
|
|
571
|
+
// cursor session's pending questions, P answer its permission requests, h help,
|
|
543
572
|
// q/Ctrl+C quit. Input mode offers readline-style caret editing.
|
|
544
573
|
// Resolves on quit (null) or with the string 'switch-project' (caller then runs
|
|
545
574
|
// the project switcher and re-invokes this picker for the new project).
|
|
546
575
|
// `dir` names the project this list belongs to — it heads the title bar so a
|
|
547
576
|
// multi-project run always says which project's sessions are on screen.
|
|
548
|
-
|
|
577
|
+
// `loadPending` (optional async () => ({ forms: Set, perms: Set })) supplies the
|
|
578
|
+
// session ids that currently await an agent question (pending form) or a
|
|
579
|
+
// permission reply. When provided the picker polls it every PENDING_POLL_MS and
|
|
580
|
+
// renders the ❓/🔒 marker columns; `A` answers the cursor session's questions
|
|
581
|
+
// and `P` its pending permission requests (allow once/always, reject).
|
|
582
|
+
// `runningIds` is the caller's snapshot of actively-running session ids (Set);
|
|
583
|
+
// `loadRunning` (optional async () => Set) refreshes it every RUNNING_POLL_MS
|
|
584
|
+
// so every row that is running shows the spinner and the BUSY/IDLE status
|
|
585
|
+
// tracks the session's real activity.
|
|
586
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [], loadPending, loadRunning, broadcast = null } = {}) {
|
|
549
587
|
return new Promise((resolve) => {
|
|
550
588
|
if (!process.stdin.isTTY) return resolve(null);
|
|
551
589
|
|
|
552
590
|
let rows = [...sessions];
|
|
553
591
|
let current = currentSession;
|
|
554
592
|
let cursor = 0;
|
|
555
|
-
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
|
|
593
|
+
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input' | 'perm' | 'perm-help'
|
|
556
594
|
let confirmDel = null;
|
|
557
595
|
let confirmAll = false; // 'D' deletes every broadcast-selected session
|
|
558
596
|
let input = null; // { label, value, kind, caret }
|
|
@@ -568,17 +606,72 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
568
606
|
let view = rows; // rows filtered by `search` (what is displayed)
|
|
569
607
|
let inspected = false; // broadcast inspection moved the TUI off `current`
|
|
570
608
|
|
|
609
|
+
// Adopt a persisted broadcast selection, but only when it is still valid:
|
|
610
|
+
// at least two of its ids must name existing sessions in this project's
|
|
611
|
+
// list (main sessions only — the caller already excluded child sessions).
|
|
612
|
+
// A stale list (too few entries, or ids that no longer exist) is cleared and
|
|
613
|
+
// the correction written back, so the picker never shows a normal list while
|
|
614
|
+
// agentp would still broadcast to the old one.
|
|
615
|
+
const persistedBroadcast = (Array.isArray(broadcast) ? broadcast : [])
|
|
616
|
+
.map(String)
|
|
617
|
+
.filter((id, i, arr) => arr.indexOf(id) === i)
|
|
618
|
+
.filter((id) => rows.some((s) => String(s.id) === id));
|
|
619
|
+
if (persistedBroadcast.length >= 2) {
|
|
620
|
+
broadcastSel = new Set(persistedBroadcast);
|
|
621
|
+
mode = 'broadcast';
|
|
622
|
+
// Anchor the picker (and the TUI) on a session that is actually selected.
|
|
623
|
+
if (!broadcastSel.has(String(current))) {
|
|
624
|
+
current = persistedBroadcast[0];
|
|
625
|
+
if (onInspectSession) onInspectSession(current);
|
|
626
|
+
}
|
|
627
|
+
// Write back when validation dropped dead or duplicate ids.
|
|
628
|
+
if (onBroadcastChange && persistedBroadcast.length !== (Array.isArray(broadcast) ? broadcast.length : 0)) {
|
|
629
|
+
onBroadcastChange(persistedBroadcast);
|
|
630
|
+
}
|
|
631
|
+
} else if (onBroadcastChange && Array.isArray(broadcast) && broadcast.length > 0) {
|
|
632
|
+
// Stale or invalid persisted list: clear it so agentp stops broadcasting.
|
|
633
|
+
onBroadcastChange([]);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Pending agent questions / permission replies, polled via `loadPending`.
|
|
637
|
+
// Each is a Set of session ids that currently await that thing.
|
|
638
|
+
let pendingForms = new Set();
|
|
639
|
+
let pendingPerms = new Set();
|
|
640
|
+
let pendingBusy = false;
|
|
641
|
+
let pendingTimer = null;
|
|
642
|
+
// Which listed sessions are actively running. The caller passes a snapshot
|
|
643
|
+
// taken at open; `loadRunning` (optional) refreshes it while the picker is
|
|
644
|
+
// open, so the spinner and BUSY/IDLE status keep up with sessions that start
|
|
645
|
+
// or finish a turn at any time.
|
|
646
|
+
let runningSet = runningIds instanceof Set ? runningIds : new Set();
|
|
647
|
+
let runningBusy = false;
|
|
648
|
+
let runningTimer = null;
|
|
649
|
+
// Answer mode ('A') state: the pending forms of the cursor session being
|
|
650
|
+
// answered one at a time, the answers chosen so far and the flat item list.
|
|
651
|
+
let answer = null;
|
|
652
|
+
// Permission mode ('P') state: the pending permission request(s) of the
|
|
653
|
+
// cursor session being answered one at a time.
|
|
654
|
+
let perm = null;
|
|
655
|
+
// The two marker columns (❓ and/or 🔒), always 4 columns wide — each in a
|
|
656
|
+
// fixed 2-column slot — so rows stay aligned with or without a marker.
|
|
657
|
+
const cellsOf = (id) => pendingCells(pendingForms.has(String(id)), pendingPerms.has(String(id)));
|
|
658
|
+
|
|
571
659
|
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
572
660
|
const helpLines = [
|
|
573
661
|
'Enter switch to the selected session (stays open)',
|
|
574
662
|
'Space start BROADCAST: add the selected session to the current one',
|
|
575
|
-
' (Space toggles each session; Enter
|
|
663
|
+
' (Space toggles each session; Enter ends broadcast and',
|
|
664
|
+
' switches; ESC/q also end it and clear the selection)',
|
|
576
665
|
'n create a new session (type a name, Enter; blank = auto)',
|
|
577
666
|
'r rename the selected session (edit in place; ESC cancels)',
|
|
578
667
|
'R (Shift+r) set a REMINDER for the selected session (sent in front of',
|
|
579
668
|
' every agentp prompt; blank clears)',
|
|
580
669
|
'd delete the selected session (then y/N to confirm)',
|
|
581
670
|
'D (Shift+d) in BROADCAST: delete ALL selected sessions (then y/N)',
|
|
671
|
+
'A answer pending agent question(s) of the selected session',
|
|
672
|
+
' (only when it shows a ❓ marker)',
|
|
673
|
+
'P answer pending permission request(s) of the selected session',
|
|
674
|
+
' (only when it shows a 🔒 marker)',
|
|
582
675
|
'a switch the AGENT of the selected session',
|
|
583
676
|
'm switch the MODEL of the selected session',
|
|
584
677
|
'p open the project switcher',
|
|
@@ -598,6 +691,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
598
691
|
process.stderr.write('\x1b[?25h');
|
|
599
692
|
process.stderr.write('\x1b[?1049l');
|
|
600
693
|
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
694
|
+
if (pendingTimer) { clearInterval(pendingTimer); pendingTimer = null; }
|
|
695
|
+
if (runningTimer) { clearInterval(runningTimer); runningTimer = null; }
|
|
601
696
|
process.stdout.removeListener('resize', draw);
|
|
602
697
|
try { process.stdin.setRawMode(false); } catch {}
|
|
603
698
|
process.stdin.pause();
|
|
@@ -619,12 +714,20 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
619
714
|
};
|
|
620
715
|
|
|
621
716
|
const draw = () => {
|
|
717
|
+
if (answer && (mode === 'answer' || mode === 'answer-help' || (mode === 'input' && input && input.kind === 'answer'))) {
|
|
718
|
+
drawAnswer();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (perm && (mode === 'perm' || mode === 'perm-help')) {
|
|
722
|
+
drawPerm();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
622
725
|
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
623
726
|
let out = '\x1b[2J\x1b[H';
|
|
624
727
|
if (mode === 'broadcast-help') {
|
|
625
728
|
out += 'ocmux broadcast mode — help\n\n';
|
|
626
729
|
out += ' Space select/deselect the cursor session\n';
|
|
627
|
-
out += ' Enter
|
|
730
|
+
out += ' Enter end broadcast (clear the selection); switch TUI to cursor\n';
|
|
628
731
|
out += ' m switch the model for ALL selected sessions\n';
|
|
629
732
|
out += ' d delete the cursor session (with confirmation)\n';
|
|
630
733
|
out += ' D (Shift+d) delete ALL selected sessions (with confirmation)\n';
|
|
@@ -692,10 +795,15 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
692
795
|
names = '... ' + keep;
|
|
693
796
|
}
|
|
694
797
|
infoLines = [bcHead + names];
|
|
695
|
-
footer = 'Enter: switch · Space: toggle · d: delete · D: delete all · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
798
|
+
footer = 'Enter: switch (ends) · Space: toggle · d: delete · D: delete all · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
696
799
|
} else {
|
|
697
800
|
const selIsCurrent = !!(selected && String(selected.id) === String(current));
|
|
698
|
-
|
|
801
|
+
// The cursor session's marker columns (❓/🔒) are pinned to the info
|
|
802
|
+
// panel's top-right corner ('' when nothing is pending).
|
|
803
|
+
const selF = selected && pendingForms.has(String(selected.id));
|
|
804
|
+
const selP = selected && pendingPerms.has(String(selected.id));
|
|
805
|
+
const selPending = (selF || selP) ? pendingCells(selF, selP) : '';
|
|
806
|
+
infoLines = sessionInfoLines(selected, runningSet, modelContext, cols, selIsCurrent, selPending);
|
|
699
807
|
}
|
|
700
808
|
// 'd'/n/r/R prompts take over the status bar (previously an easy-to-miss
|
|
701
809
|
// line at the bottom of the list) with their essential keys on the right.
|
|
@@ -720,6 +828,19 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
720
828
|
} else {
|
|
721
829
|
footer = searchFooter(search, footer);
|
|
722
830
|
}
|
|
831
|
+
// Pending-response awareness: when any session in this list is waiting
|
|
832
|
+
// for an agent question and/or a permission reply, lead the status bar
|
|
833
|
+
// with the bell (🔔) and flip the bar left-aligned so it sits at the very
|
|
834
|
+
// beginning.
|
|
835
|
+
if (!questionBar) {
|
|
836
|
+
const anyF = rows.some(r => pendingForms.has(String(r.id)));
|
|
837
|
+
const anyP = rows.some(r => pendingPerms.has(String(r.id)));
|
|
838
|
+
const bell = (anyF || anyP) ? GLYPH_BELL : '';
|
|
839
|
+
if (bell && footer.length < cols - 4) {
|
|
840
|
+
footer = `${bell} ${footer}`;
|
|
841
|
+
footerLeft = true;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
723
844
|
process.stderr.write(renderList({
|
|
724
845
|
// The project name heads the title so you always know which project's
|
|
725
846
|
// sessions are listed (matters when --all-projects moves you around).
|
|
@@ -732,8 +853,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
732
853
|
const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
|
|
733
854
|
const mark = String(s.id) === String(current) ? ' *' : '';
|
|
734
855
|
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
735
|
-
const spin = (
|
|
736
|
-
const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
856
|
+
const spin = (runningSet.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
857
|
+
const text = `${sel}${spin}${cellsOf(s.id)}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
737
858
|
// Brown for the session currently selected in the TUI window.
|
|
738
859
|
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
739
860
|
},
|
|
@@ -746,8 +867,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
746
867
|
}));
|
|
747
868
|
};
|
|
748
869
|
|
|
749
|
-
const startInput = (prompt, hint, value, kind) => {
|
|
750
|
-
input = { prompt, hint, value, kind, caret: value.length };
|
|
870
|
+
const startInput = (prompt, hint, value, kind, targetKey) => {
|
|
871
|
+
input = { prompt, hint, value, kind, targetKey, caret: value.length };
|
|
751
872
|
mode = 'input';
|
|
752
873
|
draw();
|
|
753
874
|
};
|
|
@@ -778,6 +899,11 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
778
899
|
};
|
|
779
900
|
|
|
780
901
|
const applyPick = (row) => {
|
|
902
|
+
// Selecting a session always drops any persisted broadcast list: a stale
|
|
903
|
+
// 'broadcast' in .ocmux.json would otherwise keep agentp sending prompts
|
|
904
|
+
// to sessions that are no longer selected.
|
|
905
|
+
persistBroadcast([]);
|
|
906
|
+
broadcastSel = new Set();
|
|
781
907
|
Promise.resolve(onPick ? onPick(row) : null).then((created) => {
|
|
782
908
|
if (row.new && created) {
|
|
783
909
|
rows.unshift(created);
|
|
@@ -800,6 +926,431 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
800
926
|
});
|
|
801
927
|
};
|
|
802
928
|
|
|
929
|
+
// ── Pending-response polling (❓/🔒 marker columns) ─────────────
|
|
930
|
+
// Re-read `loadPending` every PENDING_POLL_MS while the picker is open so
|
|
931
|
+
// the markers appear/disappear without re-opening the menu. Errors degrade
|
|
932
|
+
// gracefully: the previous set stays and the next poll retries.
|
|
933
|
+
const refreshPending = () => {
|
|
934
|
+
if (!loadPending || pendingBusy) return Promise.resolve();
|
|
935
|
+
pendingBusy = true;
|
|
936
|
+
return Promise.resolve(loadPending()).then((p) => {
|
|
937
|
+
pendingForms = (p && p.forms) || new Set();
|
|
938
|
+
pendingPerms = (p && p.perms) || new Set();
|
|
939
|
+
pendingBusy = false;
|
|
940
|
+
if (!done) draw();
|
|
941
|
+
}).catch(() => { pendingBusy = false; });
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// ── Live activity polling (spinner + BUSY/IDLE status) ──────────
|
|
945
|
+
// Re-read `loadRunning` every RUNNING_POLL_MS while the picker is open so a
|
|
946
|
+
// session that starts or finishes a turn after the menu opened shows it
|
|
947
|
+
// without re-opening. Errors degrade gracefully: the previous set stays.
|
|
948
|
+
const refreshRunning = () => {
|
|
949
|
+
if (!loadRunning || runningBusy) return Promise.resolve();
|
|
950
|
+
runningBusy = true;
|
|
951
|
+
return Promise.resolve(loadRunning()).then((ids) => {
|
|
952
|
+
runningSet = ids instanceof Set ? ids : new Set(ids || []);
|
|
953
|
+
runningBusy = false;
|
|
954
|
+
syncSpin();
|
|
955
|
+
}).catch(() => { runningBusy = false; });
|
|
956
|
+
};
|
|
957
|
+
// Keep the spinner animation in sync with the running set: animate while at
|
|
958
|
+
// least one listed session is active, stop when none is, and paint a fresh
|
|
959
|
+
// frame so a poll update is visible immediately.
|
|
960
|
+
const syncSpin = (redraw = true) => {
|
|
961
|
+
const anyRunning = rows.some((s) => runningSet.has(s.id));
|
|
962
|
+
if (anyRunning && !spinTimer) {
|
|
963
|
+
spinTimer = setInterval(() => { spinIdx++; draw(); }, SPIN_INTERVAL_MS);
|
|
964
|
+
} else if (!anyRunning && spinTimer) {
|
|
965
|
+
clearInterval(spinTimer);
|
|
966
|
+
spinTimer = null;
|
|
967
|
+
}
|
|
968
|
+
if (redraw && !done) draw();
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
// ── Answer mode (A): answer the agent's pending form(s) ─────────
|
|
972
|
+
// Field applicability honours `hidden` and `when` conditions over the
|
|
973
|
+
// answers chosen SO FAR (live re-validation: groups appear/disappear as you
|
|
974
|
+
// answer their gate questions).
|
|
975
|
+
const fieldVisible = (field) => {
|
|
976
|
+
if (!field || field.hidden) return false;
|
|
977
|
+
if (!Array.isArray(field.when) || field.when.length === 0) return true;
|
|
978
|
+
return field.when.every((w) => {
|
|
979
|
+
const got = answer.answers[w.key];
|
|
980
|
+
if (got === undefined || got === null) return false;
|
|
981
|
+
// For multiselect gates `eq` means the value is among the chosen ones.
|
|
982
|
+
const matches = Array.isArray(got) ? got.includes(w.value) : got === w.value;
|
|
983
|
+
return (w.op === 'neq') ? !matches : matches;
|
|
984
|
+
});
|
|
985
|
+
};
|
|
986
|
+
const fieldOptions = (field) => {
|
|
987
|
+
if (field.type === 'boolean') {
|
|
988
|
+
return [{ label: 'Yes', value: true }, { label: 'No', value: false }];
|
|
989
|
+
}
|
|
990
|
+
if (field.type === 'string' || field.type === 'multiselect') {
|
|
991
|
+
return Array.isArray(field.options) ? field.options : [];
|
|
992
|
+
}
|
|
993
|
+
return [];
|
|
994
|
+
};
|
|
995
|
+
const knownValues = (field) => new Set((field.options || []).map(o => String(o.value)));
|
|
996
|
+
// The custom text (if any) behind "Type your own answer" — distinguishes a
|
|
997
|
+
// typed answer from a chosen option for single-choice and multiselect.
|
|
998
|
+
const customText = (field) => {
|
|
999
|
+
const v = answer.answers[field.key];
|
|
1000
|
+
if (v === undefined || v === null) return null;
|
|
1001
|
+
if (Array.isArray(v)) {
|
|
1002
|
+
const extra = v.filter(x => !knownValues(field).has(String(x)));
|
|
1003
|
+
return extra.length ? extra.join(', ') : null;
|
|
1004
|
+
}
|
|
1005
|
+
return knownValues(field).has(String(v)) ? null : String(v);
|
|
1006
|
+
};
|
|
1007
|
+
const isSelected = (field, opt) => {
|
|
1008
|
+
const v = answer.answers[field.key];
|
|
1009
|
+
if (v === undefined || v === null) return false;
|
|
1010
|
+
if (Array.isArray(v)) return v.some(x => String(x) === String(opt.value));
|
|
1011
|
+
return String(v) === String(opt.value);
|
|
1012
|
+
};
|
|
1013
|
+
const toggleSelect = (field, opt) => {
|
|
1014
|
+
if (field.type === 'multiselect') {
|
|
1015
|
+
const arr = Array.isArray(answer.answers[field.key]) ? [...answer.answers[field.key]] : [];
|
|
1016
|
+
const i = arr.findIndex(x => String(x) === String(opt.value));
|
|
1017
|
+
if (i >= 0) arr.splice(i, 1); else arr.push(opt.value);
|
|
1018
|
+
answer.answers[field.key] = arr;
|
|
1019
|
+
} else {
|
|
1020
|
+
answer.answers[field.key] = opt.value;
|
|
1021
|
+
}
|
|
1022
|
+
answer.error = null;
|
|
1023
|
+
};
|
|
1024
|
+
// Save a typed value (custom / free-text / numeric field). No client-side
|
|
1025
|
+
// validation beyond numeric coercion — the server decides and its message
|
|
1026
|
+
// is shown in the status bar.
|
|
1027
|
+
const applyAnswerValue = (fieldKey, raw) => {
|
|
1028
|
+
const form = answer.forms[answer.index];
|
|
1029
|
+
const field = (form.fields || []).find(f => f.key === fieldKey);
|
|
1030
|
+
const v = raw.trim();
|
|
1031
|
+
if (!v) {
|
|
1032
|
+
delete answer.answers[fieldKey];
|
|
1033
|
+
} else if (field && (field.type === 'number' || field.type === 'integer')) {
|
|
1034
|
+
const num = Number(v);
|
|
1035
|
+
answer.answers[fieldKey] = Number.isNaN(num) ? v : num;
|
|
1036
|
+
} else if (field && field.type === 'multiselect') {
|
|
1037
|
+
// Keep the built-in selections and replace any custom entries with the
|
|
1038
|
+
// newly typed value.
|
|
1039
|
+
const known = knownValues(field);
|
|
1040
|
+
const base = Array.isArray(answer.answers[fieldKey]) ? answer.answers[fieldKey].filter(x => known.has(String(x))) : [];
|
|
1041
|
+
base.push(v);
|
|
1042
|
+
answer.answers[fieldKey] = base;
|
|
1043
|
+
} else {
|
|
1044
|
+
answer.answers[fieldKey] = v;
|
|
1045
|
+
}
|
|
1046
|
+
answer.error = null;
|
|
1047
|
+
};
|
|
1048
|
+
// Prefill `default` values once per form, before the user starts editing
|
|
1049
|
+
// (rebuilt answers never clobber what was already chosen).
|
|
1050
|
+
const seedDefaults = () => {
|
|
1051
|
+
const form = answer.forms[answer.index];
|
|
1052
|
+
if (!form || !Array.isArray(form.fields)) return;
|
|
1053
|
+
for (const f of form.fields) {
|
|
1054
|
+
if (fieldVisible(f) && f.default !== undefined && f.default !== null && answer.answers[f.key] === undefined) {
|
|
1055
|
+
answer.answers[f.key] = f.default;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
// Flat list of *visible* rows for the current form: a section header per
|
|
1060
|
+
// applicable field, one row per option (or a single type-in row for
|
|
1061
|
+
// free-text/numeric fields), then the Submit row.
|
|
1062
|
+
const buildAnswerItems = (form) => {
|
|
1063
|
+
const items = [];
|
|
1064
|
+
for (const field of (form.fields || []).filter(fieldVisible)) {
|
|
1065
|
+
if (field.type === 'external') {
|
|
1066
|
+
items.push({ kind: 'external', field });
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
items.push({ kind: 'field', field });
|
|
1070
|
+
const opts = fieldOptions(field);
|
|
1071
|
+
for (const opt of opts) items.push({ kind: 'option', field, opt });
|
|
1072
|
+
const wantTypedRow = opts.length === 0 || ((field.type === 'string' || field.type === 'multiselect') && field.custom);
|
|
1073
|
+
if (wantTypedRow) {
|
|
1074
|
+
items.push({ kind: 'option', field, opt: null, custom: !!field.custom, inputOnly: opts.length === 0 });
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
items.push({ kind: 'submit' });
|
|
1078
|
+
return items;
|
|
1079
|
+
};
|
|
1080
|
+
const answerItems = () => {
|
|
1081
|
+
const form = answer.forms[answer.index];
|
|
1082
|
+
return form ? buildAnswerItems(form) : [];
|
|
1083
|
+
};
|
|
1084
|
+
const isAnswerClickable = (item) => item && (item.kind === 'option' || item.kind === 'submit');
|
|
1085
|
+
// After a `when` re-validation some rows disappear; keep the cursor on a
|
|
1086
|
+
// selectable row (called from drawAnswer so the frame always self-heals).
|
|
1087
|
+
const clampAnswerCursor = () => {
|
|
1088
|
+
const items = answerItems();
|
|
1089
|
+
if (!items.length) { answer.cursor = 0; return; }
|
|
1090
|
+
if (isAnswerClickable(items[answer.cursor])) return;
|
|
1091
|
+
for (let off = 1; off < items.length; off++) {
|
|
1092
|
+
const i = (answer.cursor + off) % items.length;
|
|
1093
|
+
if (isAnswerClickable(items[i])) { answer.cursor = i; return; }
|
|
1094
|
+
}
|
|
1095
|
+
answer.cursor = 0;
|
|
1096
|
+
};
|
|
1097
|
+
const moveAnswerCursor = (delta) => {
|
|
1098
|
+
const items = answerItems();
|
|
1099
|
+
const n = items.length;
|
|
1100
|
+
if (!n) return;
|
|
1101
|
+
let i = answer.cursor;
|
|
1102
|
+
do {
|
|
1103
|
+
i = (i + delta + n) % n;
|
|
1104
|
+
} while (!isAnswerClickable(items[i]) && i !== answer.cursor);
|
|
1105
|
+
answer.cursor = i;
|
|
1106
|
+
draw();
|
|
1107
|
+
};
|
|
1108
|
+
const activateOption = (item) => {
|
|
1109
|
+
if (item.inputOnly || item.custom) {
|
|
1110
|
+
// Open the text input (custom answer / free-text / numeric field).
|
|
1111
|
+
const label = item.field.title || item.field.key;
|
|
1112
|
+
const cur = (item.inputOnly && answer.answers[item.field.key] !== undefined)
|
|
1113
|
+
? String(answer.answers[item.field.key])
|
|
1114
|
+
: (item.custom ? (customText(item.field) || '') : '');
|
|
1115
|
+
startInput(`Answer for "${label}":`, 'Enter: save · Esc: cancel', cur, 'answer', item.field.key);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
toggleSelect(item.field, item.opt);
|
|
1119
|
+
draw();
|
|
1120
|
+
};
|
|
1121
|
+
const submitAnswer = () => {
|
|
1122
|
+
if (answer.busy) return;
|
|
1123
|
+
const form = answer.forms[answer.index];
|
|
1124
|
+
if (!form) return;
|
|
1125
|
+
answer.busy = true;
|
|
1126
|
+
answer.error = null;
|
|
1127
|
+
draw();
|
|
1128
|
+
replyToForm(server, form.sessionID, form.id, answer.answers).then(() => {
|
|
1129
|
+
answer.busy = false;
|
|
1130
|
+
return listSessionForms(server, form.sessionID).then((forms) => {
|
|
1131
|
+
if (!forms || forms.length === 0) {
|
|
1132
|
+
// All questions answered — back to the session list (marker clears
|
|
1133
|
+
// after the pending set is re-polled).
|
|
1134
|
+
answer = null;
|
|
1135
|
+
mode = 'normal';
|
|
1136
|
+
notices.push('✔ Answers submitted.');
|
|
1137
|
+
return refreshPending();
|
|
1138
|
+
}
|
|
1139
|
+
// Move on to the session's next pending form.
|
|
1140
|
+
answer.forms = forms;
|
|
1141
|
+
answer.index = 0;
|
|
1142
|
+
answer.answers = {};
|
|
1143
|
+
answer.cursor = 0;
|
|
1144
|
+
answer.error = null;
|
|
1145
|
+
seedDefaults();
|
|
1146
|
+
draw();
|
|
1147
|
+
return refreshPending();
|
|
1148
|
+
});
|
|
1149
|
+
}).catch((err) => {
|
|
1150
|
+
answer.busy = false;
|
|
1151
|
+
if (err && err.status === 409) {
|
|
1152
|
+
// Answered elsewhere (e.g. from the TUI) while we were editing.
|
|
1153
|
+
answer = null;
|
|
1154
|
+
mode = 'normal';
|
|
1155
|
+
notices.push(err.message || 'Already answered.');
|
|
1156
|
+
return refreshPending();
|
|
1157
|
+
}
|
|
1158
|
+
// The server rejected the answers — surface its message in the status
|
|
1159
|
+
// bar and stay in answer mode so the user can adjust.
|
|
1160
|
+
answer.error = (err && err.message) || 'Failed to submit answers.';
|
|
1161
|
+
draw();
|
|
1162
|
+
});
|
|
1163
|
+
};
|
|
1164
|
+
const startAnswer = (target) => {
|
|
1165
|
+
listSessionForms(server, target.id).then((forms) => {
|
|
1166
|
+
if (!forms || forms.length === 0) { draw(); return; }
|
|
1167
|
+
answer = {
|
|
1168
|
+
forms,
|
|
1169
|
+
index: 0,
|
|
1170
|
+
answers: {},
|
|
1171
|
+
cursor: 0,
|
|
1172
|
+
error: null,
|
|
1173
|
+
busy: false,
|
|
1174
|
+
title: (target.title && target.title.trim()) || target.id || 'session',
|
|
1175
|
+
};
|
|
1176
|
+
seedDefaults();
|
|
1177
|
+
mode = 'answer';
|
|
1178
|
+
draw();
|
|
1179
|
+
}).catch(commandErr);
|
|
1180
|
+
};
|
|
1181
|
+
const renderAnswerItem = (item, cols) => {
|
|
1182
|
+
if (item.kind === 'field') {
|
|
1183
|
+
const label = (item.field.title || item.field.key).slice(0, Math.max(4, cols - 6));
|
|
1184
|
+
const req = item.field.required ? ' *' : '';
|
|
1185
|
+
return `${BROWN}▸ ${label}${req}\x1b[0m`;
|
|
1186
|
+
}
|
|
1187
|
+
if (item.kind === 'external') {
|
|
1188
|
+
const label = (item.field.title || item.field.key).slice(0, Math.max(4, cols - 6));
|
|
1189
|
+
return ` ⛓ ${label} (external link, can't be answered here)`;
|
|
1190
|
+
}
|
|
1191
|
+
if (item.kind === 'submit') {
|
|
1192
|
+
const fields = answerItems().filter(i => i.kind === 'field');
|
|
1193
|
+
const done = fields.filter(i => answer.answers[i.field.key] !== undefined).length;
|
|
1194
|
+
const total = fields.length;
|
|
1195
|
+
const tally = total > 0 ? ` (${done}/${total} answered)` : '';
|
|
1196
|
+
return ` ${done === total && total > 0 ? '✓' : ' '} Submit answers${tally}`;
|
|
1197
|
+
}
|
|
1198
|
+
// Option / custom / free-text row.
|
|
1199
|
+
let selected;
|
|
1200
|
+
let label;
|
|
1201
|
+
if (item.custom) {
|
|
1202
|
+
const cv = customText(item.field);
|
|
1203
|
+
selected = cv !== null;
|
|
1204
|
+
label = selected ? `Custom: ${cv}` : 'Type your own answer';
|
|
1205
|
+
} else if (item.inputOnly) {
|
|
1206
|
+
const v = answer.answers[item.field.key];
|
|
1207
|
+
selected = v !== undefined;
|
|
1208
|
+
label = selected ? String(v) : (item.field.placeholder || 'Type your answer');
|
|
1209
|
+
} else {
|
|
1210
|
+
selected = isSelected(item.field, item.opt);
|
|
1211
|
+
label = item.opt.label;
|
|
1212
|
+
}
|
|
1213
|
+
const prefix = selected ? '(•)' : '( )';
|
|
1214
|
+
return ` ${prefix} ${String(label).slice(0, Math.max(4, cols - 10))}`;
|
|
1215
|
+
};
|
|
1216
|
+
const drawAnswer = () => {
|
|
1217
|
+
let out = '\x1b[2J\x1b[H';
|
|
1218
|
+
if (mode === 'answer-help') {
|
|
1219
|
+
out += 'ocmux — answer mode help\n\n';
|
|
1220
|
+
out += ' j / k, arrows move between the questions and their options\n';
|
|
1221
|
+
out += ' Space / Enter select an option (extra entries toggle on\n';
|
|
1222
|
+
out += ' multi-select questions); on "Type your own\n';
|
|
1223
|
+
out += ' answer" / free-text rows it opens the input\n';
|
|
1224
|
+
out += ' Submit answers the last row: send all answers in one request\n';
|
|
1225
|
+
out += ' h toggle this help\n';
|
|
1226
|
+
out += ' q / ESC cancel WITHOUT answering (back to the list)\n';
|
|
1227
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1228
|
+
out += '\nPress any key to return.\n';
|
|
1229
|
+
process.stderr.write(out);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const form = answer.forms[answer.index];
|
|
1233
|
+
const items = answerItems();
|
|
1234
|
+
clampAnswerCursor();
|
|
1235
|
+
const cols = process.stdout.columns || 80;
|
|
1236
|
+
const termRows = process.stdout.rows || 24;
|
|
1237
|
+
// Title bar + form line + status bar eat 3 rows.
|
|
1238
|
+
const viewHeight = Math.max(1, termRows - 3);
|
|
1239
|
+
const win = windowFor(items.length, answer.cursor, viewHeight);
|
|
1240
|
+
const title = `ocmux — answer: ${answer.title}`.slice(0, cols);
|
|
1241
|
+
out += BAR_BG + centerIn(title, cols) + '\x1b[0m\n';
|
|
1242
|
+
const formLine = (answer.forms.length > 1 ? `Form ${answer.index + 1}/${answer.forms.length} · ` : '')
|
|
1243
|
+
+ (form && form.title ? form.title : '');
|
|
1244
|
+
out += formLine ? `${BROWN}${formLine.slice(0, cols)}\x1b[0m\n` : '\n';
|
|
1245
|
+
for (let i = win.start; i < win.start + win.count && i < items.length; i++) {
|
|
1246
|
+
const item = items[i];
|
|
1247
|
+
const clickable = isAnswerClickable(item);
|
|
1248
|
+
const ptr = (clickable && i === answer.cursor) ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
|
|
1249
|
+
out += ptr + renderAnswerItem(item, cols) + '\n';
|
|
1250
|
+
}
|
|
1251
|
+
for (let i = win.start + win.count; i < viewHeight; i++) out += '\n';
|
|
1252
|
+
if (mode === 'input' && input) {
|
|
1253
|
+
out += QUESTION_BG + padLeft(`${input.prompt} ${renderInputValue(input.value)}`, cols) + '\x1b[0m\n';
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
let footer;
|
|
1257
|
+
if (answer.busy) footer = 'Submitting answers…';
|
|
1258
|
+
else if (answer.error) footer = `✖ ${answer.error}`;
|
|
1259
|
+
else footer = 'Space/Enter: select · h: help · q: cancel · 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
|
+
// ── Permission-answer screen ('P') ────────────────────────────────
|
|
1265
|
+
// Each pending permission request of the cursor session is answered in
|
|
1266
|
+
// turn: o/1 allow once, a allow always, r reject. drawPerm renders the
|
|
1267
|
+
// request summary; the decision keys post the reply and advance.
|
|
1268
|
+
const drawPerm = () => {
|
|
1269
|
+
let out = '\x1b[2J\x1b[H';
|
|
1270
|
+
if (mode === 'perm-help') {
|
|
1271
|
+
out += 'ocmux — permission help\n\n';
|
|
1272
|
+
out += ' o / 1 allow this request ONCE\n';
|
|
1273
|
+
out += ' a allow ALWAYS (remembered for the session)\n';
|
|
1274
|
+
out += ' r REJECT\n';
|
|
1275
|
+
out += ' h toggle this help\n';
|
|
1276
|
+
out += ' q / ESC back to the session list (leave it pending)\n';
|
|
1277
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1278
|
+
out += '\nPress any key to return.\n';
|
|
1279
|
+
process.stderr.write(out);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const cols = process.stdout.columns || 80;
|
|
1283
|
+
const termRows = process.stdout.rows || 24;
|
|
1284
|
+
const req = perm.list[perm.cursor];
|
|
1285
|
+
out += BAR_BG + centerIn(`ocmux — permission: ${perm.title}`, cols) + '\x1b[0m\n';
|
|
1286
|
+
const countLine = perm.list.length > 1 ? `Request ${perm.cursor + 1}/${perm.list.length}` : '';
|
|
1287
|
+
out += (countLine ? `${BROWN}${countLine.slice(0, cols)}\x1b[0m\n` : '\n');
|
|
1288
|
+
if (req) {
|
|
1289
|
+
out += ` Action: ${String(req.action || 'unknown').slice(0, cols - 9)}\n`;
|
|
1290
|
+
const res = (req.resources || []).join(', ') || '(none)';
|
|
1291
|
+
out += ` Resources: ${res.slice(0, cols - 12)}\n`;
|
|
1292
|
+
if (req.message) out += ` Message: ${String(req.message).slice(0, cols - 10)}\n`;
|
|
1293
|
+
if (req.save && req.save.length) out += ` Remember: ${req.save.join(', ').slice(0, cols - 11)}\n`;
|
|
1294
|
+
}
|
|
1295
|
+
const bodyCount = req ? 2 + (req.message ? 1 : 0) + (req.save && req.save.length ? 1 : 0) : 0;
|
|
1296
|
+
for (let i = 2 + bodyCount; i < termRows - 1; i++) out += '\n';
|
|
1297
|
+
let footer;
|
|
1298
|
+
if (perm.busy) footer = 'Replying…';
|
|
1299
|
+
else if (perm.error) footer = `✖ ${perm.error}`;
|
|
1300
|
+
else footer = 'o: allow once · a: allow always · r: reject · h: help · q: back · Ctrl+C: quit';
|
|
1301
|
+
out += BAR_BG + (footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols)) + '\x1b[0m\n';
|
|
1302
|
+
process.stderr.write(out);
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
// Open permission mode for the cursor session: fetch the location's pending
|
|
1306
|
+
// permission requests and keep only this session's (the poll only tracks
|
|
1307
|
+
// ids, not the full request objects).
|
|
1308
|
+
const startPerm = (target) => {
|
|
1309
|
+
listPendingPermissions(server, dir).then((all) => {
|
|
1310
|
+
const list = (all || []).filter((r) => String(r.sessionID) === String(target.id));
|
|
1311
|
+
if (!list.length) { notices.push('No pending permission to answer.'); draw(); return; }
|
|
1312
|
+
perm = {
|
|
1313
|
+
list,
|
|
1314
|
+
cursor: 0,
|
|
1315
|
+
error: null,
|
|
1316
|
+
busy: false,
|
|
1317
|
+
title: (target.title && target.title.trim()) || target.id || 'session',
|
|
1318
|
+
};
|
|
1319
|
+
mode = 'perm';
|
|
1320
|
+
draw();
|
|
1321
|
+
}).catch(commandErr);
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
// Post a decision for the current permission request; on success drop it
|
|
1325
|
+
// from the list and advance to the next (or return to the session list
|
|
1326
|
+
// once all are answered).
|
|
1327
|
+
const submitPerm = (decision) => {
|
|
1328
|
+
if (perm.busy) return;
|
|
1329
|
+
const req = perm.list[perm.cursor];
|
|
1330
|
+
if (!req) return;
|
|
1331
|
+
perm.busy = true;
|
|
1332
|
+
perm.error = null;
|
|
1333
|
+
draw();
|
|
1334
|
+
replyToPermission(server, req.sessionID, req.id, decision).then(() => {
|
|
1335
|
+
perm.busy = false;
|
|
1336
|
+
perm.list.splice(perm.cursor, 1);
|
|
1337
|
+
if (perm.list.length === 0) {
|
|
1338
|
+
perm = null;
|
|
1339
|
+
mode = 'normal';
|
|
1340
|
+
notices.push('✔ Permission answered.');
|
|
1341
|
+
return refreshPending();
|
|
1342
|
+
}
|
|
1343
|
+
perm.cursor = Math.min(perm.cursor, perm.list.length - 1);
|
|
1344
|
+
perm.error = null;
|
|
1345
|
+
draw();
|
|
1346
|
+
return refreshPending();
|
|
1347
|
+
}).catch((err) => {
|
|
1348
|
+
perm.busy = false;
|
|
1349
|
+
perm.error = (err && err.message) || 'Failed to reply.';
|
|
1350
|
+
draw();
|
|
1351
|
+
});
|
|
1352
|
+
};
|
|
1353
|
+
|
|
803
1354
|
const applyDelete = () => {
|
|
804
1355
|
const s = confirmDel;
|
|
805
1356
|
if (!s || !onDelete) return;
|
|
@@ -821,26 +1372,27 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
821
1372
|
// Deleting the whole broadcast selection ends broadcast mode; adopt
|
|
822
1373
|
// the row now under the cursor as the new current (state + TUI +
|
|
823
1374
|
// highlight) so the list and the TUI agree on the new selection.
|
|
824
|
-
|
|
1375
|
+
// (applyPick clears the persisted broadcast; the explicit clear is
|
|
1376
|
+
// only needed when there is no row left to adopt.)
|
|
825
1377
|
broadcastSel = new Set();
|
|
826
1378
|
mode = 'normal';
|
|
827
1379
|
const next = view[cursor];
|
|
828
1380
|
if (next) applyPick(next);
|
|
829
|
-
else { current = null; draw(); }
|
|
1381
|
+
else { persistBroadcast([]); current = null; draw(); }
|
|
830
1382
|
return;
|
|
831
1383
|
}
|
|
832
1384
|
if (wasBroadcast) {
|
|
833
1385
|
broadcastSel.delete(String(s.id));
|
|
834
1386
|
if (broadcastSel.size < 2) {
|
|
835
1387
|
// Broadcast ended: make the remaining selected session the real
|
|
836
|
-
// selection (switch the TUI to it and store it).
|
|
1388
|
+
// selection (switch the TUI to it and store it). applyPick clears
|
|
1389
|
+
// the persisted broadcast; clear it here too when no row remains.
|
|
837
1390
|
const remaining = [...broadcastSel][0];
|
|
838
|
-
persistBroadcast([]);
|
|
839
1391
|
broadcastSel = new Set();
|
|
840
1392
|
mode = 'normal';
|
|
841
1393
|
const row = rows.find(r => String(r.id) === String(remaining));
|
|
842
1394
|
if (row) applyPick(row);
|
|
843
|
-
else { draw(); }
|
|
1395
|
+
else { persistBroadcast([]); draw(); }
|
|
844
1396
|
} else {
|
|
845
1397
|
persistBroadcast([...broadcastSel]);
|
|
846
1398
|
mode = 'broadcast';
|
|
@@ -914,14 +1466,14 @@ const toggleBroadcast = (idx) => {
|
|
|
914
1466
|
if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
|
|
915
1467
|
if (broadcastSel.size < 2) {
|
|
916
1468
|
// Deselecting down to one session ends broadcast mode by making that
|
|
917
|
-
// remaining session the actual selection.
|
|
1469
|
+
// remaining session the actual selection. applyPick clears the persisted
|
|
1470
|
+
// broadcast; clear it here too when no row remains.
|
|
918
1471
|
const remaining = [...broadcastSel][0];
|
|
919
|
-
persistBroadcast([]);
|
|
920
1472
|
broadcastSel = new Set();
|
|
921
1473
|
mode = 'normal';
|
|
922
1474
|
const row = rows.find(r => String(r.id) === String(remaining));
|
|
923
1475
|
if (row) applyPick(row);
|
|
924
|
-
else { restoreCurrent(); draw(); }
|
|
1476
|
+
else { persistBroadcast([]); restoreCurrent(); draw(); }
|
|
925
1477
|
return;
|
|
926
1478
|
}
|
|
927
1479
|
persistBroadcast([...broadcastSel]);
|
|
@@ -968,8 +1520,11 @@ const toggleBroadcast = (idx) => {
|
|
|
968
1520
|
}
|
|
969
1521
|
return finish(null);
|
|
970
1522
|
}
|
|
971
|
-
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
972
|
-
mode = mode === 'broadcast-help' ? 'broadcast'
|
|
1523
|
+
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help' || mode === 'answer-help' || mode === 'perm-help') {
|
|
1524
|
+
mode = mode === 'broadcast-help' ? 'broadcast'
|
|
1525
|
+
: (mode === 'list-help' ? 'list'
|
|
1526
|
+
: (mode === 'answer-help' ? 'answer'
|
|
1527
|
+
: (mode === 'perm-help' ? 'perm' : 'normal')));
|
|
973
1528
|
draw();
|
|
974
1529
|
return;
|
|
975
1530
|
}
|
|
@@ -985,19 +1540,24 @@ const toggleBroadcast = (idx) => {
|
|
|
985
1540
|
}
|
|
986
1541
|
if (mode === 'input') {
|
|
987
1542
|
if (key.name === 'escape') {
|
|
1543
|
+
const wasAnswer = input && input.kind === 'answer';
|
|
988
1544
|
input = null;
|
|
989
|
-
mode = 'normal';
|
|
1545
|
+
mode = wasAnswer ? 'answer' : 'normal';
|
|
990
1546
|
draw();
|
|
991
1547
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
992
1548
|
const v = input.value;
|
|
993
1549
|
const kind = input.kind;
|
|
1550
|
+
const targetKey = input.targetKey;
|
|
994
1551
|
input = null;
|
|
995
|
-
mode = 'normal';
|
|
1552
|
+
mode = kind === 'answer' ? 'answer' : 'normal';
|
|
996
1553
|
draw();
|
|
997
1554
|
if (kind === 'create') {
|
|
998
1555
|
applyPick({ new: true, name: v.trim() });
|
|
999
1556
|
} else if (kind === 'reminder') {
|
|
1000
1557
|
applyReminder(v.trim());
|
|
1558
|
+
} else if (kind === 'answer') {
|
|
1559
|
+
applyAnswerValue(targetKey, v);
|
|
1560
|
+
draw();
|
|
1001
1561
|
} else if (v.trim()) {
|
|
1002
1562
|
applyRenameWith(v.trim());
|
|
1003
1563
|
}
|
|
@@ -1062,6 +1622,47 @@ const toggleBroadcast = (idx) => {
|
|
|
1062
1622
|
}
|
|
1063
1623
|
const sr = handleSearch(str, key, search);
|
|
1064
1624
|
if (sr !== 'pass') { applySearch(); draw(); return; }
|
|
1625
|
+
if (mode === 'answer' && answer) {
|
|
1626
|
+
if (answer.busy) return;
|
|
1627
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
1628
|
+
// Cancel WITHOUT answering — back to the session list.
|
|
1629
|
+
answer = null;
|
|
1630
|
+
mode = 'normal';
|
|
1631
|
+
draw();
|
|
1632
|
+
} else if (key.name === 'h') {
|
|
1633
|
+
mode = 'answer-help';
|
|
1634
|
+
draw();
|
|
1635
|
+
} else if (key.name === 'up' || str === 'k') {
|
|
1636
|
+
moveAnswerCursor(-1);
|
|
1637
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
1638
|
+
moveAnswerCursor(1);
|
|
1639
|
+
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
1640
|
+
const items = answerItems();
|
|
1641
|
+
const item = items[answer.cursor];
|
|
1642
|
+
if (item && item.kind === 'submit') submitAnswer();
|
|
1643
|
+
else if (item && item.kind === 'option') activateOption(item);
|
|
1644
|
+
}
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (mode === 'perm' && perm) {
|
|
1648
|
+
if (perm.busy) return;
|
|
1649
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
1650
|
+
// Back to the session list, leaving the request(s) pending.
|
|
1651
|
+
perm = null;
|
|
1652
|
+
mode = 'normal';
|
|
1653
|
+
draw();
|
|
1654
|
+
} else if (key.name === 'h') {
|
|
1655
|
+
mode = 'perm-help';
|
|
1656
|
+
draw();
|
|
1657
|
+
} else if (str === 'o' || str === '1') {
|
|
1658
|
+
submitPerm('once');
|
|
1659
|
+
} else if (str === 'a') {
|
|
1660
|
+
submitPerm('always');
|
|
1661
|
+
} else if (str === 'r') {
|
|
1662
|
+
submitPerm('reject');
|
|
1663
|
+
}
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1065
1666
|
if (mode === 'broadcast') {
|
|
1066
1667
|
if (key.name === 'escape') {
|
|
1067
1668
|
persistBroadcast([]);
|
|
@@ -1103,8 +1704,9 @@ const toggleBroadcast = (idx) => {
|
|
|
1103
1704
|
});
|
|
1104
1705
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1105
1706
|
if (cursor < view.length) {
|
|
1707
|
+
// Enter ends broadcast mode: applyPick clears the persisted
|
|
1708
|
+
// selection so agentp stops broadcasting to these sessions.
|
|
1106
1709
|
mode = 'normal';
|
|
1107
|
-
broadcastSel = new Set(); // selection stays persisted for agentp
|
|
1108
1710
|
applyPick(view[cursor]);
|
|
1109
1711
|
}
|
|
1110
1712
|
} else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
@@ -1117,6 +1719,14 @@ const toggleBroadcast = (idx) => {
|
|
|
1117
1719
|
return;
|
|
1118
1720
|
}
|
|
1119
1721
|
if (key.name === 'q') return finish(null);
|
|
1722
|
+
// 'P' (Shift+p) answers the cursor session's pending permission request(s).
|
|
1723
|
+
// Checked before the 'p' project-switcher below for the same readline
|
|
1724
|
+
// reason as 'A'/'a': Shift+P arrives as name 'p' with the shift flag.
|
|
1725
|
+
if (str === 'P' || (key.name === 'p' && key.shift)) {
|
|
1726
|
+
const target = view[cursor];
|
|
1727
|
+
if (target && pendingPerms.has(String(target.id))) startPerm(target);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1120
1730
|
if (key.name === 'p') return finish('switch-project');
|
|
1121
1731
|
if (key.name === 'h') {
|
|
1122
1732
|
mode = 'help';
|
|
@@ -1138,6 +1748,17 @@ const toggleBroadcast = (idx) => {
|
|
|
1138
1748
|
startInput('Rename to:', 'Enter: rename · Esc: cancel', view[cursor].title || '', 'rename');
|
|
1139
1749
|
return;
|
|
1140
1750
|
}
|
|
1751
|
+
// 'A' (Shift+a) answers pending agent questions. It MUST be checked
|
|
1752
|
+
// before the 'a' agent handler below: readline reports Shift+A as
|
|
1753
|
+
// name 'a' with the shift flag (str 'A'), so the lowercase branch would
|
|
1754
|
+
// otherwise swallow it and open the agent switcher instead.
|
|
1755
|
+
if (str === 'A' || (key.name === 'a' && key.shift)) {
|
|
1756
|
+
// Answer-mode: only when the cursor session actually has a pending
|
|
1757
|
+
// agent question (a pending form) — 'P' answers permissions instead.
|
|
1758
|
+
const target = view[cursor];
|
|
1759
|
+
if (target && pendingForms.has(String(target.id))) startAnswer(target);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1141
1762
|
if (key.name === 'a') {
|
|
1142
1763
|
const target = view[cursor];
|
|
1143
1764
|
if (target) {
|
|
@@ -1185,9 +1806,18 @@ const toggleBroadcast = (idx) => {
|
|
|
1185
1806
|
process.stdin.setRawMode(true);
|
|
1186
1807
|
process.stdin.resume();
|
|
1187
1808
|
process.stdout.on('resize', draw);
|
|
1188
|
-
// Animate the spinner while any listed session is running.
|
|
1189
|
-
|
|
1190
|
-
|
|
1809
|
+
// Animate the spinner while any listed session is running. `loadRunning`
|
|
1810
|
+
// (when provided) polls for activity so the spinner follows sessions that
|
|
1811
|
+
// start or finish a turn after the picker opened.
|
|
1812
|
+
syncSpin(false);
|
|
1813
|
+
if (loadRunning) {
|
|
1814
|
+
runningTimer = setInterval(() => { refreshRunning(); }, RUNNING_POLL_MS);
|
|
1815
|
+
}
|
|
1816
|
+
// Poll for pending agent questions / permission replies while the picker is
|
|
1817
|
+
// open (the ❓/🔒 marker columns and the 'A' entry refresh live).
|
|
1818
|
+
if (loadPending) {
|
|
1819
|
+
refreshPending();
|
|
1820
|
+
pendingTimer = setInterval(() => { refreshPending(); }, PENDING_POLL_MS);
|
|
1191
1821
|
}
|
|
1192
1822
|
process.stderr.write('\x1b[?1049h\x1b[?25l');
|
|
1193
1823
|
draw();
|
|
@@ -1206,7 +1836,7 @@ const toggleBroadcast = (idx) => {
|
|
|
1206
1836
|
// row also records the target directory, so the caller moves its picker there
|
|
1207
1837
|
// and can then write that project's `.ocmux.json` — the file `agentp` reads in
|
|
1208
1838
|
// that directory.
|
|
1209
|
-
function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
1839
|
+
function switchMenu(rows, { allProjects = false, onOpen, pending } = {}) {
|
|
1210
1840
|
return new Promise((resolve) => {
|
|
1211
1841
|
if (!process.stdin.isTTY) {
|
|
1212
1842
|
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
@@ -1218,6 +1848,8 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1218
1848
|
return;
|
|
1219
1849
|
}
|
|
1220
1850
|
|
|
1851
|
+
const pendingOf = (dir) => (pending && pending.get(dir)) || { forms: new Set(), perms: new Set() };
|
|
1852
|
+
|
|
1221
1853
|
const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
|
|
1222
1854
|
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1223
1855
|
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
@@ -1317,6 +1949,10 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1317
1949
|
out += ' h toggle this help\n';
|
|
1318
1950
|
out += ' q return to the session menu\n';
|
|
1319
1951
|
out += ' Ctrl+C quit ocmux\n';
|
|
1952
|
+
out += ' \n';
|
|
1953
|
+
out += ' Markers: ❓ pending question · 🔒 pending permission, always two\n';
|
|
1954
|
+
out += ' columns (the slot is reserved when absent); 🔔 in the\n';
|
|
1955
|
+
out += ' status bar when any listed session waits\n';
|
|
1320
1956
|
out += '\n';
|
|
1321
1957
|
for (const l of modeNote) out += `${l}\n`;
|
|
1322
1958
|
out += '\nPress any key to return.\n';
|
|
@@ -1343,12 +1979,19 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1343
1979
|
const id = String(s.id || '');
|
|
1344
1980
|
const mark = String(s.id) === String(node.project.session) ? ' *' : '';
|
|
1345
1981
|
const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
|
|
1346
|
-
|
|
1982
|
+
// Pending marker columns for this session (❓/🔒), always 4 cols.
|
|
1983
|
+
const pe = pendingOf(node.project.dir);
|
|
1984
|
+
const gr = pendingCells(pe.forms.has(id), pe.perms.has(id));
|
|
1985
|
+
return ` ${title}${mark} ${gr} ${id}`;
|
|
1347
1986
|
}
|
|
1348
1987
|
const p = node.project;
|
|
1349
1988
|
const fold = foldOf(p).open ? '▾' : '▸';
|
|
1350
1989
|
const session = p.session ? p.session.slice(0, 14) : '(none)';
|
|
1351
|
-
|
|
1990
|
+
// A folded project shows its aggregate marker columns when ANY of its
|
|
1991
|
+
// sessions (per the location-scoped pending fetch) awaits an answer.
|
|
1992
|
+
const pe = pendingOf(p.dir);
|
|
1993
|
+
const gr = pendingCells(pe.forms.size > 0, pe.perms.size > 0);
|
|
1994
|
+
const text = `${fold} ${gr} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
|
|
1352
1995
|
return text;
|
|
1353
1996
|
},
|
|
1354
1997
|
footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
|
|
@@ -1529,7 +2172,9 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1529
2172
|
} catch { /* context limits unavailable */ }
|
|
1530
2173
|
|
|
1531
2174
|
const applySession = (id) => {
|
|
1532
|
-
|
|
2175
|
+
// A session switch also drops any persisted broadcast list, so agentp
|
|
2176
|
+
// never keeps broadcasting to sessions that are no longer selected.
|
|
2177
|
+
projectState.writeProjectState(sf, { session: id, broadcast: [] });
|
|
1533
2178
|
refreshTui(dir, server, id);
|
|
1534
2179
|
};
|
|
1535
2180
|
|
|
@@ -1566,6 +2211,21 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1566
2211
|
runningIds,
|
|
1567
2212
|
modelContext,
|
|
1568
2213
|
notices,
|
|
2214
|
+
// A persisted broadcast list, so the picker opens in broadcast mode when
|
|
2215
|
+
// one is active (validated and cleaned inside `sessionMenu`).
|
|
2216
|
+
broadcast: state.broadcast,
|
|
2217
|
+
// Pending agent questions / permission replies for this project, polled
|
|
2218
|
+
// by the picker while it is open. Failures degrade to empty sets.
|
|
2219
|
+
loadPending: () => Promise.all([
|
|
2220
|
+
listPendingForms(server, dir).catch(() => []),
|
|
2221
|
+
listPendingPermissions(server, dir).catch(() => []),
|
|
2222
|
+
]).then(([forms, perms]) => ({
|
|
2223
|
+
forms: new Set((forms || []).map(f => f.sessionID).filter(Boolean)),
|
|
2224
|
+
perms: new Set((perms || []).map(x => x.sessionID).filter(Boolean)),
|
|
2225
|
+
})),
|
|
2226
|
+
// Which sessions are actively running, re-polled while the picker is
|
|
2227
|
+
// open so the spinner and BUSY/IDLE status stay accurate.
|
|
2228
|
+
loadRunning: () => getActiveSessions(server),
|
|
1569
2229
|
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
1570
2230
|
onInspectSession: (id) => refreshTui(dir, server, id),
|
|
1571
2231
|
onPick: async (row) => {
|
|
@@ -1616,8 +2276,24 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1616
2276
|
console.error(`Error listing projects: ${err.message}`);
|
|
1617
2277
|
return [];
|
|
1618
2278
|
});
|
|
2279
|
+
// Fetch each listed project's pending questions/permissions once, so
|
|
2280
|
+
// folded project rows (and unfolded sessions) can carry the markers.
|
|
2281
|
+
// Errors degrade to empty sets (no markers for that project).
|
|
2282
|
+
const pendingByDir = new Map();
|
|
2283
|
+
await Promise.all(rows.map(async (p) => {
|
|
2284
|
+
if (!p.server) return;
|
|
2285
|
+
const [forms, perms] = await Promise.all([
|
|
2286
|
+
listPendingForms(p.server, p.dir).catch(() => []),
|
|
2287
|
+
listPendingPermissions(p.server, p.dir).catch(() => []),
|
|
2288
|
+
]);
|
|
2289
|
+
pendingByDir.set(p.dir, {
|
|
2290
|
+
forms: new Set((forms || []).map(f => f.sessionID).filter(Boolean)),
|
|
2291
|
+
perms: new Set((perms || []).map(x => x.sessionID).filter(Boolean)),
|
|
2292
|
+
});
|
|
2293
|
+
}));
|
|
1619
2294
|
const picked = await switchMenu(rows, {
|
|
1620
2295
|
allProjects,
|
|
2296
|
+
pending: pendingByDir,
|
|
1621
2297
|
onOpen: (project, id) => refreshTui(project.dir, project.server, id),
|
|
1622
2298
|
});
|
|
1623
2299
|
if (picked === SWITCH_QUIT) break;
|
|
@@ -1930,8 +2606,13 @@ function printHelp() {
|
|
|
1930
2606
|
console.error('project (requires a TTY). The title bar names the project whose');
|
|
1931
2607
|
console.error('sessions are listed. In the picker:');
|
|
1932
2608
|
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1933
|
-
console.error(' p projects · h help · q quit');
|
|
2609
|
+
console.error(' p projects · h help · q quit · A answer the selected session\'s questions');
|
|
2610
|
+
console.error(' P answer its pending permission requests (allow once / always / reject)');
|
|
1934
2611
|
console.error(' In BROADCAST: Space toggles a session · d deletes cursor · D deletes all');
|
|
2612
|
+
console.error('List rows and the project switcher carry pending marker columns: ❓ agent');
|
|
2613
|
+
console.error('question, 🔒 permission request (one column each, the slot is always');
|
|
2614
|
+
console.error('reserved); the status bar shows 🔔 when any listed session waits.');
|
|
2615
|
+
console.error('The picker re-checks every 2s while open.');
|
|
1935
2616
|
console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
|
|
1936
2617
|
console.error('back to the shared TUI. Opening the picker also routes that project\'s');
|
|
1937
2618
|
console.error('TUI to its stored session. Silent on success. (p opens projects; by');
|